diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index e2ed8dea..bb908c4c 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -1161,6 +1161,31 @@ export const adminRouter = router({ await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-scheduled-withdrawal'); return { ok: true, deleteAfter: deleteAfter.toISOString() }; }), + cancelDeletion: userAdminProcedure + .input(z.object({ userId: z.string().min(1), reason: z.string().trim().min(3).max(200) })) + .mutation(async ({ ctx, input }) => { + const user = await ctx.users.findById(input.userId); + if (!user) throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' }); + assertTargetUserManageable(requireAdminAuth(ctx), user); + if (!user.deleteAfter) { + throw new TRPCError({ code: 'CONFLICT', message: '탈퇴 예약이 없는 계정입니다.' }); + } + // 관리자 복구는 보존 기한과 무관하게 허용하되, 조회한 예약만 해제한다. + // revision 증가로 이전 session과 사용자 복구 증명의 재사용을 막는다. + if (!(await ctx.users.cancelDeletion(user.id, user.deleteAfter, user.authRevision ?? 0))) { + throw new TRPCError({ + code: 'CONFLICT', + message: '계정 상태가 변경되었습니다. 다시 조회해 주세요.', + }); + } + let flushPublished = true; + try { + await ctx.flushPublisher.publishUserFlush(user.id, 'admin-cancelled-withdrawal'); + } catch { + flushPublished = false; + } + return { ok: true, deleteAfter: null, flushPublished }; + }), forceDelete: userAdminProcedure .input( z.object({ diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index 3dff259b..f1706310 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import type { GatewayPrismaClient } from '@sammo-ts/infra'; @@ -25,6 +25,7 @@ const buildCaller = async ( createOperation: GatewayProfileRepository['createOperation'], options: { adminRoles?: string[]; + flushError?: Error; firstUserIsAdmin?: boolean; runtimeActionCreateError?: unknown; initialNotice?: string; @@ -242,6 +243,7 @@ const buildCaller = async ( sessions, flushPublisher: { publishUserFlush: async (userId, reason, metadata) => { + if (options.flushError) throw options.flushError; flushes.push({ userId, reason, @@ -2216,6 +2218,94 @@ describe('Gateway administrator account controls', () => { ).rejects.toMatchObject({ code: 'FORBIDDEN' }); }); + it.each([new Date('2020-01-01T00:00:00Z'), new Date('2099-01-01T00:00:00Z')])( + 'lets a user administrator cancel deletion dated %s with an audited reason', + async (deadline) => { + const harness = await buildCaller(unusedCreateOperation, { + adminRoles: ['user', 'admin.users.manage'], + firstUserIsAdmin: false, + }); + const target = await harness.users.createUser({ username: 'restore-target', password: 'secretpass' }); + await harness.users.updateSanctions(target.id, { bannedUntil: '2099-01-01T00:00:00Z' }); + await harness.users.scheduleDeletion(target.id, deadline); + const before = structuredClone(await harness.users.findById(target.id)); + expect( + await harness.caller.admin.users.cancelDeletion({ + userId: target.id, + reason: '고객 본인 확인 후 탈퇴 취소', + }) + ).toEqual({ ok: true, deleteAfter: null, flushPublished: true }); + const after = await harness.users.findById(target.id); + expect(after?.deleteAfter).toBeUndefined(); + expect(after?.authRevision).toBe((before?.authRevision ?? 0) + 1); + expect(after?.sanctions).toEqual(before?.sanctions); + expect(after?.passwordHash).toBe(before?.passwordHash); + expect(after?.oauthId).toBe(before?.oauthId); + expect(harness.flushes).toContainEqual({ userId: target.id, reason: 'admin-cancelled-withdrawal' }); + expect(harness.auditEvents.map((event) => event.outcome)).toEqual(['STARTED', 'SUCCEEDED']); + expect(harness.auditEvents[1]).toMatchObject({ + action: 'admin.users.cancelDeletion', + capability: 'admin.users.manage', + actorUserId: harness.admin.id, + targetId: target.id, + reason: '고객 본인 확인 후 탈퇴 취소', + }); + await expect( + harness.caller.admin.users.cancelDeletion({ userId: target.id, reason: '중복 취소 요청' }) + ).rejects.toMatchObject({ code: 'CONFLICT' }); + expect(harness.auditEvents.at(-1)).toMatchObject({ outcome: 'FAILED', errorCode: 'CONFLICT' }); + } + ); + + it('denies missing scope, protected root targets, bad reasons and missing targets without mutation', async () => { + for (const roles of [['admin.notice.manage'], ['admin.users.manage']]) { + const h = await buildCaller(unusedCreateOperation, { adminRoles: roles, firstUserIsAdmin: false }); + const target = await h.users.createUser({ username: 'protected-restore', password: 'secretpass' }); + await h.users.updateRoles(target.id, ['superuser']); + await h.users.scheduleDeletion(target.id, new Date('2020-01-01')); + const cancel = vi.spyOn(h.users, 'cancelDeletion'); + await expect( + h.caller.admin.users.cancelDeletion({ userId: target.id, reason: '권한 없는 복구 시도' }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + expect(cancel).not.toHaveBeenCalled(); + expect(h.flushes).toHaveLength(0); + expect(h.auditEvents.at(-1)).toMatchObject({ outcome: 'FAILED', errorCode: 'FORBIDDEN' }); + } + const h = await buildCaller(unusedCreateOperation); + await expect(h.caller.admin.users.cancelDeletion({ userId: h.admin.id, reason: ' ' })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + await expect( + h.caller.admin.users.cancelDeletion({ userId: 'missing-user', reason: '없는 계정 복구' }) + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('keeps a newer deletion reservation when compare-and-swap fails', async () => { + const h = await buildCaller(unusedCreateOperation); + const target = await h.users.createUser({ username: 'rescheduled-restore', password: 'secretpass' }); + await h.users.scheduleDeletion(target.id, new Date('2020-01-01')); + vi.spyOn(h.users, 'cancelDeletion').mockImplementation(async () => { + await h.users.scheduleDeletion(target.id, new Date('2099-01-01')); + return false; + }); + await expect( + h.caller.admin.users.cancelDeletion({ userId: target.id, reason: '재예약 충돌 확인' }) + ).rejects.toMatchObject({ code: 'CONFLICT' }); + expect((await h.users.findById(target.id))?.deleteAfter).toBe('2099-01-01T00:00:00.000Z'); + expect(h.flushes).toHaveLength(0); + }); + + it('reports a committed cancellation as success when flush delivery fails', async () => { + const h = await buildCaller(unusedCreateOperation, { flushError: new Error('synthetic flush failure') }); + const target = await h.users.createUser({ username: 'flush-restore', password: 'secretpass' }); + await h.users.scheduleDeletion(target.id, new Date('2020-01-01')); + expect( + await h.caller.admin.users.cancelDeletion({ userId: target.id, reason: '고객 요청 복구' }) + ).toMatchObject({ ok: true, flushPublished: false }); + expect((await h.users.findById(target.id))?.deleteAfter).toBeUndefined(); + expect(h.auditEvents.at(-1)).toMatchObject({ outcome: 'SUCCEEDED' }); + }); + it('prevents a delegated administrator from changing a root administrator', async () => { const harness = await buildCaller(unusedCreateOperation, { adminRoles: ['user', 'admin.users.manage'], diff --git a/app/gateway-api/test/adminSecurityTransport.e2e.test.ts b/app/gateway-api/test/adminSecurityTransport.e2e.test.ts index 7bdcb9e6..b70c2a04 100644 --- a/app/gateway-api/test/adminSecurityTransport.e2e.test.ts +++ b/app/gateway-api/test/adminSecurityTransport.e2e.test.ts @@ -505,4 +505,46 @@ describe('admin security over HTTP transport', () => { }, }); }); + it('requires a valid authorized administrator to cancel deletion over HTTP and invalidates prior sessions', async () => { + const h = await createHarness(); + await h.users.scheduleDeletion(h.target.id, new Date('2020-01-01')); + const input = { userId: h.target.id, reason: '본인 확인 후 관리자 복구' }; + expect((await postTrpc(h.baseUrl, 'admin.users.cancelDeletion', input)).response.status).toBe(401); + expect( + (await postTrpc(h.baseUrl, 'admin.users.cancelDeletion', input, h.targetSessionToken)).response.status + ).toBe(403); + expect( + (await postTrpc(h.baseUrl, 'admin.users.cancelDeletion', { ...input, reason: ' ' }, h.adminSessionToken)) + .response.status + ).toBe(400); + await h.users.updateRoles(h.target.id, ['superuser']); + expect( + (await postTrpc(h.baseUrl, 'admin.users.cancelDeletion', input, h.adminSessionToken)).response.status + ).toBe(403); + expect(h.flushes).toHaveLength(0); + expect((await h.users.findById(h.target.id))?.deleteAfter).toBeDefined(); + await h.users.updateRoles(h.target.id, ['user']); + expect( + (await postTrpc(h.baseUrl, 'admin.users.cancelDeletion', input, h.adminSessionToken)).response.status + ).toBe(200); + expect((await h.users.findById(h.target.id))?.deleteAfter).toBeUndefined(); + const me = await fetch(`${h.baseUrl}/trpc/me`, { headers: { 'x-session-token': h.targetSessionToken } }); + expect(await me.json()).toMatchObject({ result: { data: null } }); + expect( + (await postTrpc(h.baseUrl, 'admin.users.cancelDeletion', input, h.adminSessionToken)).response.status + ).toBe(409); + const noScope = await createHarness(['user', 'admin.notice.manage']); + await noScope.users.scheduleDeletion(noScope.target.id, new Date('2020-01-01')); + expect( + ( + await postTrpc( + noScope.baseUrl, + 'admin.users.cancelDeletion', + { userId: noScope.target.id, reason: '사용자 관리 권한 없음' }, + noScope.adminSessionToken + ) + ).response.status + ).toBe(403); + expect((await noScope.users.findById(noScope.target.id))?.deleteAfter).toBeDefined(); + }); }); diff --git a/app/gateway-frontend/e2e/admin-account-controls.spec.ts b/app/gateway-frontend/e2e/admin-account-controls.spec.ts index e17b6912..5332670d 100644 --- a/app/gateway-frontend/e2e/admin-account-controls.spec.ts +++ b/app/gateway-frontend/e2e/admin-account-controls.spec.ts @@ -8,7 +8,16 @@ const operationNames = (route: Route): string[] => { return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); }; -const installFixture = async (page: Page, scopes = ['*']) => { +const installFixture = async ( + page: Page, + scopes = ['*'], + deletion: { + deleteAfter?: string; + cancelFails?: boolean; + flushPublished?: boolean; + beforeCancel?: () => Promise; + } = {} +) => { const requests: Array<{ operation: string; body: unknown }> = []; let roles = [ 'user', @@ -16,7 +25,7 @@ const installFixture = async (page: Page, scopes = ['*']) => { 'admin.profiles.runtime:kwe:default', 'admin.profiles.runtime:pwe:default', ]; - let deleteAfter: string | null = null; + let deleteAfter: string | null = deletion.deleteAfter ?? null; let graceUntil: string | null = null; let username = 'target'; let displayName = '대상 사용자'; @@ -40,8 +49,9 @@ const installFixture = async (page: Page, scopes = ['*']) => { await page.route('**/gateway/api/trpc/**', async (route) => { const operations = operationNames(route); const body = route.request().postDataJSON() as unknown; + for (const operation of operations) requests.push({ operation, body }); + if (operations.includes('admin.users.cancelDeletion')) await deletion.beforeCancel?.(); const results = operations.map((operation) => { - requests.push({ operation, body }); if (operation === 'me') { return response({ id: 'admin-user', @@ -247,6 +257,25 @@ const installFixture = async (page: Page, scopes = ['*']) => { }); return response({ ok: true, deleteAfter }); } + if (operation === 'admin.users.cancelDeletion') { + if (deletion.cancelFails) + return { + error: { + message: '계정 상태가 변경되었습니다. 다시 조회해 주세요.', + code: -32009, + data: { code: 'CONFLICT', httpStatus: 409 }, + }, + }; + deleteAfter = null; + const input = (body as Record)['0']; + auditHistory.unshift({ + ...auditHistory[0], + id: 'audit-cancel-deletion', + action: 'admin.users.cancelDeletion', + reason: input.reason, + }); + return response({ ok: true, deleteAfter: null, flushPublished: deletion.flushPublished ?? true }); + } throw new Error(`Unhandled tRPC operation: ${operation}`); }); const isBatch = new URL(route.request().url()).searchParams.get('batch') === '1'; @@ -509,3 +538,121 @@ test('shows grouped roles on the account page after direct navigation and refres await writeFile(testInfo.outputPath('account-permissions.html'), await page.content()); await page.screenshot({ path: testInfo.outputPath('account-permissions.png'), fullPage: true }); }); + +for (const viewport of [ + { name: 'desktop', width: 1200, height: 900 }, + { name: 'mobile', width: 390, height: 844 }, +]) { + test(`cancels an expired deletion with a reason, confirmation and audit on ${viewport.name}`, async ({ + page, + }, testInfo) => { + await page.setViewportSize(viewport); + let complete: (() => void) | undefined; + const requests = await installFixture(page, ['*'], { + deleteAfter: '2020-01-01T00:00:00Z', + beforeCancel: () => + new Promise((resolve) => { + complete = resolve; + }), + }); + await page.goto('admin/users'); + const target = page.getByRole('button', { name: /target.*대상 사용자/ }); + await target.click(); + await page.getByRole('button', { name: /탈퇴 · 이력/ }).click(); + const panel = page.getByRole('heading', { name: '관리자 탈퇴 예약', exact: true }).locator('..'); + const cancel = panel.getByRole('button', { name: '탈퇴 취소', exact: true }); + await expect(cancel).toBeEnabled(); + await cancel.click(); + expect(requests.filter(({ operation }) => operation === 'admin.users.cancelDeletion')).toHaveLength(0); + await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('본인 확인 후 탈퇴 취소'); + page.once('dialog', (dialog) => dialog.dismiss()); + await cancel.click(); + expect(requests.filter(({ operation }) => operation === 'admin.users.cancelDeletion')).toHaveLength(0); + await page.evaluate(() => document.fonts.ready); + await cancel.hover(); + await expect + .poll(() => cancel.evaluate((el) => getComputedStyle(el).backgroundColor)) + .toBe('oklch(0.546 0.245 262.881)'); + await page.mouse.down(); + expect(await cancel.evaluate((el) => el.matches(':active'))).toBe(true); + await page.mouse.move(1, 1); + await page.mouse.up(); + await cancel.focus(); + await page.keyboard.press('Tab'); + await page.keyboard.press('Shift+Tab'); + await expect(cancel).toBeFocused(); + const geometry = await panel.evaluate((el) => { + const rect = el.getBoundingClientRect(); + const button = el.querySelectorAll('button')[1]!; + const buttonRect = button.getBoundingClientRect(); + const style = getComputedStyle(button); + return { + x: rect.x, + width: rect.width, + height: rect.height, + viewport: innerWidth, + right: rect.right, + buttonWidth: buttonRect.width, + buttonHeight: buttonRect.height, + textAlign: style.textAlign, + fontSize: style.fontSize, + focus: button.matches(':focus-visible'), + html: el.outerHTML, + documentOverflow: document.documentElement.scrollWidth - innerWidth, + }; + }); + expect(geometry.documentOverflow).toBe(0); + expect(geometry.right).toBeLessThanOrEqual(viewport.width); + expect(geometry.buttonHeight).toBeGreaterThanOrEqual(40); + expect(geometry.focus).toBe(true); + await writeFile(testInfo.outputPath('admin-deletion-geometry-style.json'), JSON.stringify(geometry, null, 2)); + await writeFile(testInfo.outputPath('admin-deletion-panel.html'), geometry.html); + await page.screenshot({ path: testInfo.outputPath('admin-deletion-before.png'), fullPage: true }); + page.once('dialog', (dialog) => dialog.accept()); + await cancel.click(); + await expect(cancel).toBeDisabled(); + await expect(panel.getByRole('button', { name: '보존 기간 후 탈퇴 예약' })).toBeDisabled(); + await expect + .poll(() => requests.filter(({ operation }) => operation === 'admin.users.cancelDeletion').length) + .toBe(1); + expect(complete).toBeDefined(); + complete!(); + await expect(panel.getByRole('status')).toHaveText('탈퇴 예약을 취소했습니다.'); + await expect(cancel).toBeDisabled(); + await expect(target).not.toContainText('탈퇴 예약'); + await expect(page.getByText('SUCCEEDED · admin.users.cancelDeletion').first()).toBeVisible(); + const request = requests.find(({ operation }) => operation === 'admin.users.cancelDeletion'); + expect(Object.values(request!.body as Record)).toEqual([ + { userId: 'target-user', reason: '본인 확인 후 탈퇴 취소' }, + ]); + await page.reload(); + await target.click(); + await page.getByRole('button', { name: /탈퇴 · 이력/ }).click(); + await expect(cancel).toBeDisabled(); + await page.screenshot({ path: testInfo.outputPath('admin-deletion-after.png'), fullPage: true }); + }); +} + +test('keeps the pending deletion visible on failure and reports delayed flush as committed success', async ({ + page, +}) => { + const requests = await installFixture(page, ['*'], { deleteAfter: '2020-01-01T00:00:00Z', cancelFails: true }); + page.on('dialog', (dialog) => dialog.accept()); + await page.goto('admin/users'); + await page.getByRole('button', { name: /target.*대상 사용자/ }).click(); + await page.getByRole('button', { name: /탈퇴 · 이력/ }).click(); + const panel = page.getByRole('heading', { name: '관리자 탈퇴 예약', exact: true }).locator('..'); + await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('본인 확인 후 복구'); + await panel.getByRole('button', { name: '탈퇴 취소', exact: true }).click(); + await expect(panel.getByRole('status')).toContainText('다시 조회해 주세요.'); + await expect(panel.getByRole('button', { name: '탈퇴 취소', exact: true })).toBeEnabled(); + expect(requests.filter(({ operation }) => operation === 'admin.users.cancelDeletion')).toHaveLength(1); + await installFixture(page, ['*'], { deleteAfter: '2020-01-01T00:00:00Z', flushPublished: false }); + await page.reload(); + await page.getByRole('button', { name: /target.*대상 사용자/ }).click(); + await page.getByRole('button', { name: /탈퇴 · 이력/ }).click(); + await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('본인 확인 후 복구'); + await panel.getByRole('button', { name: '탈퇴 취소', exact: true }).click(); + await expect(panel.getByRole('status')).toContainText('탈퇴 예약은 취소되었습니다.'); + await expect(panel.getByRole('button', { name: '탈퇴 취소', exact: true })).toBeDisabled(); +}); diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index 973ae03f..dc0e59d3 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -367,6 +367,13 @@ type AdminClient = { deleteAfter: string; }>; }; + cancelDeletion: { + mutate: (input: { userId: string; reason: string }) => Promise<{ + ok: boolean; + deleteAfter: null; + flushPublished: boolean; + }>; + }; }; profiles: { list: { @@ -632,6 +639,7 @@ const restrictionNotes = ref(''); const restrictionStatus = ref(''); const forceDeleteStatus = ref(''); +const deletionActionBusy = ref(false); const deletionRetentionDays = ref(30); const kakaoGraceUntil = ref(''); const kakaoGraceStatus = ref(''); @@ -1485,7 +1493,7 @@ const clearRestriction = async () => { }; const scheduleDeleteUser = async () => { - if (!userResult.value) { + if (!userResult.value || deletionActionBusy.value) { return; } const reason = requireUserActionReason(); @@ -1496,6 +1504,7 @@ const scheduleDeleteUser = async () => { return; } } + deletionActionBusy.value = true; try { const result = await adminClient.users.scheduleDeletion.mutate({ userId: userResult.value.id, @@ -1507,6 +1516,33 @@ const scheduleDeleteUser = async () => { await Promise.all([refreshUserHistory(), loadUserDirectory()]); } catch (error) { forceDeleteStatus.value = '탈퇴 예약 실패'; + } finally { + deletionActionBusy.value = false; + } +}; + +const cancelDeleteUser = async (): Promise => { + const target = userResult.value; + if (!target?.deleteAfter || deletionActionBusy.value) return; + const reason = requireUserActionReason(); + if (!reason || !window.confirm('선택한 계정의 탈퇴 예약을 취소하시겠습니까?')) return; + deletionActionBusy.value = true; + try { + const result = await adminClient.users.cancelDeletion.mutate({ userId: target.id, reason }); + if (userResult.value?.id === target.id) { + userResult.value = { ...userResult.value, deleteAfter: undefined }; + forceDeleteStatus.value = result.flushPublished + ? '탈퇴 예약을 취소했습니다.' + : '탈퇴 예약은 취소되었습니다. 게임 접속 정보 갱신이 지연되고 있습니다.'; + await refreshUserHistory(); + } + await loadUserDirectory(); + } catch (error) { + if (userResult.value?.id === target.id) { + forceDeleteStatus.value = error instanceof Error ? error.message : '탈퇴 예약 취소 실패'; + } + } finally { + deletionActionBusy.value = false; } }; @@ -2292,6 +2328,9 @@ onMounted(() => { class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4" >

관리자 탈퇴 예약

+

+ 삭제 예정일: {{ formatServerDateTime(userResult.deleteAfter) }} +

{ max="90" class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white" aria-label="탈퇴 전 보존 일수" - :disabled="!hasUser" + :disabled="!hasUser || deletionActionBusy" /> - -
{{ forceDeleteStatus }}
+
+ + +
+
{{ forceDeleteStatus }}