import { expect, test, type Page, type Route } from '@playwright/test'; import { writeFile } from 'node:fs/promises'; import { formatRoleGroups } from '../src/utils/roleDisplay'; const response = (data: unknown) => ({ result: { data } }); const operationNames = (route: Route): string[] => { const url = new URL(route.request().url()); return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); }; 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', 'admin.profiles.runtime:che:default', 'admin.profiles.runtime:kwe:default', 'admin.profiles.runtime:pwe:default', ]; let deleteAfter: string | null = deletion.deleteAfter ?? null; let graceUntil: string | null = null; let username = 'target'; let displayName = '대상 사용자'; let kakaoReplacementApprovedUntil: string | null = null; let specialGrants: Array> = []; const auditHistory = [ { id: 'audit-1', correlationId: 'correlation-1', actorUsername: 'admin', action: 'admin.users.updateSanctions', outcome: 'SUCCEEDED', reason: '기존 제재 사유', summary: {}, createdAt: '2026-08-06T01:00:00.000Z', }, ]; await page.addInitScript(() => { window.localStorage.setItem('sammo-session-token', 'playwright-admin-session'); }); 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) => { if (operation === 'me') { return response({ id: 'admin-user', username: 'admin', displayName: '관리자', roles: ['superuser'], createdAt: '2026-08-01T00:00:00.000Z', }); } if (operation === 'admin.capabilities.list') { return response([ { permission: 'admin.users.manage', label: '사용자·제재 관리', description: '계정 복구, 제재, OAuth 유예와 예약 탈퇴를 관리합니다.', risk: 'CRITICAL', scope: 'GLOBAL', }, { permission: 'admin.profiles.runtime', label: 'Profile 실행 관리', description: '지정 profile의 시작, 정지와 실행 상태를 관리합니다.', risk: 'HIGH', scope: 'PROFILE', scopes, }, { permission: 'admin.audit.read', label: '관리자 감사 조회', description: 'Gateway 관리자 변경 이력을 조회합니다.', risk: 'HIGH', scope: 'GLOBAL', }, ]); } if (operation === 'admin.audit.list') return response(auditHistory); if (operation === 'account.notifications.get') return response({ capability: { enabled: false, publicKey: null }, eventTypes: [], profiles: [], preferences: [], subscriptionCount: 0, currentDeviceSubscribed: false, }); if (operation === 'admin.users.getLocalAccountStatus') return response({ enabled: false }); if (operation === 'admin.users.list') { return response({ total: 3, users: [ { id: 'target-user', username, displayName, email: 'target@example.test', oauthType: 'KAKAO', roles: ['user'], hasActiveSanction: false, deleteAfter, createdAt: '2026-07-20T00:00:00.000Z', }, { id: 'viewer-user', username: 'viewer', displayName: '조회 사용자', oauthType: 'KAKAO', roles: ['user'], hasActiveSanction: true, createdAt: '2026-07-19T00:00:00.000Z', }, { id: 'admin-user', username: 'admin', displayName: '관리자', oauthType: 'NONE', roles: ['superuser'], hasActiveSanction: false, createdAt: '2026-07-18T00:00:00.000Z', }, ], }); } if (operation === 'admin.system.getNotice') return response({ notice: '' }); if (operation === 'admin.profiles.listNavigation') return response([]); if (operation === 'admin.profiles.list') return response([]); if (operation === 'admin.profiles.listScenarios') return response([]); if (operation === 'admin.users.updateRoles') { const input = (body as Record)['0']; roles = input.mode === 'grant' ? [...new Set([...roles, ...input.roles])] : roles.filter((role) => !input.roles.includes(role)); return response({ roles }); } if (operation === 'admin.users.lookup') { return response({ id: 'target-user', username, displayName, roles, sanctions: {}, oauthType: 'KAKAO', oauthId: 'fixture-kakao-stable-id', kakaoReplacementApprovedUntil, kakaoGraceStartedAt: '2026-07-20T00:00:00.000Z', kakaoGraceUntil: graceUntil, deleteAfter, createdAt: '2026-07-20T00:00:00.000Z', }); } if (operation === 'admin.users.getKakaoGracePolicies') { return response({ kakaoVerified: false, kakaoGraceStartedAt: '2026-07-20T00:00:00.000Z', kakaoGraceUntil: graceUntil, specialAccessGrants: specialGrants, profiles: [ { profileName: 'che:default', profile: 'che', instanceKey: 'default', displayName: '체', requiresKakaoVerification: true, kakaoVerified: false, accessAllowed: true, canCreateGeneral: false, graceEndsAt: graceUntil ?? '2026-08-10T00:00:00.000Z', generalCreationGraceDays: 0, accessGraceDays: 7, specialAccess: specialGrants.length ? { kind: 'RECOVERY', grantId: '11111111-1111-4111-8111-111111111111', expiresAt: '2026-08-20T00:00:00.000Z', allowsGeneralCreation: true, } : null, }, ], }); } if (operation === 'admin.users.listHistory') return response(auditHistory); if (operation === 'admin.users.updateKakaoGrace') { graceUntil = '2026-08-20T00:00:00.000Z'; auditHistory.unshift({ ...auditHistory[0], id: 'audit-2', action: 'admin.users.updateKakaoGrace', reason: '본인 확인 처리 중', }); return response({ kakaoGraceUntil: graceUntil }); } if (operation === 'admin.users.updateIdentity') { username = 'target-renamed'; displayName = '변경된 대상'; auditHistory.unshift({ ...auditHistory[0], id: 'audit-identity', action: 'admin.users.updateIdentity', reason: '고객 본인 확인 완료', }); return response({ username, displayName, identityRevision: '2026-08-24T12:00:00.000Z' }); } if (operation === 'admin.users.setKakaoReplacementApproval') { kakaoReplacementApprovedUntil = '2026-08-26T00:00:00.000Z'; auditHistory.unshift({ ...auditHistory[0], id: 'audit-kakao-replacement', action: 'admin.users.setKakaoReplacementApproval', reason: '기존 단말 분실 교체', }); return response({ kakaoReplacementApprovedUntil }); } if (operation === 'admin.users.grantSpecialAccess') { specialGrants = [ { id: '11111111-1111-4111-8111-111111111111', userId: 'target-user', kind: 'RECOVERY', profiles: ['che'], allowsGeneralCreation: true, expiresAt: '2026-08-20T00:00:00.000Z', reason: '휴대폰 분실 임시 복구', grantedByUserId: 'admin-user', createdAt: '2026-08-08T00:00:00.000Z', }, ]; auditHistory.unshift({ ...auditHistory[0], id: 'audit-special', action: 'admin.users.grantSpecialAccess', reason: '휴대폰 분실 임시 복구', }); return response(specialGrants[0]); } if (operation === 'admin.users.scheduleDeletion') { deleteAfter = '2026-09-05T00:00:00.000Z'; auditHistory.unshift({ ...auditHistory[0], id: 'audit-3', action: 'admin.users.scheduleDeletion', reason: '탈퇴 요청 접수', }); 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'; await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(isBatch ? results : results[0]), }); }); return requests; }; test('operates OAuth grace and scheduled deletion with reasoned audit history', async ({ page }, testInfo) => { const requests = await installFixture(page); page.on('dialog', (dialog) => dialog.accept()); await page.goto('admin/users'); await expect(page.getByRole('region', { name: '계정 목록' })).toBeVisible(); await expect(page.getByText('총 3개')).toBeVisible(); await page.getByRole('button', { name: /target.*대상 사용자/ }).click(); await expect(page.getByText('Kakao 인증: 미완료')).toBeVisible(); await expect(page.getByRole('navigation', { name: '사용자 관리 기능' })).toBeVisible(); await expect(page.getByRole('heading', { name: '비밀번호 리셋' })).toBeHidden(); await expect(page.getByRole('heading', { name: 'ID · 닉네임 변경' })).toBeVisible(); await page.getByLabel('로그인 ID').fill('target-renamed'); await page.getByLabel('닉네임').fill('변경된 대상'); await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('고객 본인 확인 완료'); await page.getByRole('button', { name: 'ID · 닉네임 변경', exact: true }).click(); await expect(page.getByText('ID와 닉네임을 변경했습니다.').first()).toBeVisible(); await page.getByLabel('Kakao 계정 교체 승인 만료 시각').fill('2026-08-26T00:00'); await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('기존 단말 분실 교체'); await page.getByRole('button', { name: '교체 승인', exact: true }).click(); await expect(page.getByText('새 카카오 계정 교체를 승인했습니다.').first()).toBeVisible(); expect(requests.some(({ operation }) => operation === 'admin.users.updateIdentity')).toBe(true); expect(requests.some(({ operation }) => operation === 'admin.users.setKakaoReplacementApproval')).toBe(true); await page.getByRole('button', { name: /접근 · 권한/ }).click(); await expect(page.getByRole('option', { name: /Profile 전체 운영/ })).toHaveCount(0); await expect(page.getByRole('option', { name: /Profile 실행 관리/ })).toHaveCount(1); await expect(page.getByRole('cell', { name: '체' })).toBeVisible(); await expect(page.getByText('che:default', { exact: true })).toHaveCount(0); await page.screenshot({ path: testInfo.outputPath('gateway-admin-account-controls-desktop.png'), fullPage: true }); await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('본인 확인 처리 중'); await page.getByLabel('특수 접근 만료 시각').fill('2026-08-20T00:00'); await page.getByRole('checkbox', { name: '체' }).check(); await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('휴대폰 분실 임시 복구'); await page.getByRole('button', { name: '특수 접근 부여', exact: true }).click(); await expect(page.getByText('특수 접근 자격을 부여했습니다.').first()).toBeVisible(); await expect(page.getByText(/RECOVERY · 체/)).toBeVisible(); await page.screenshot({ path: testInfo.outputPath('gateway-admin-special-access-granted.png'), fullPage: true }); const gracePanel = page.getByRole('heading', { name: 'Kakao 인증 유예' }).locator('..'); await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('본인 확인 처리 중'); await gracePanel.locator('input[type="datetime-local"]').fill('2026-08-20T00:00'); await page.getByRole('button', { name: '유예 연장', exact: true }).click(); await expect(page.getByText('OAuth 유예 연장 완료').first()).toBeVisible(); await page.getByRole('button', { name: /탈퇴 · 이력/ }).click(); await expect(page.getByText('SUCCEEDED · admin.users.updateKakaoGrace').first()).toBeVisible(); const deletionButton = page.getByRole('button', { name: '보존 기간 후 탈퇴 예약', exact: true }); const baseDeleteColor = await deletionButton.evaluate((button) => getComputedStyle(button).backgroundColor); await deletionButton.hover(); await expect .poll(() => deletionButton.evaluate((button) => getComputedStyle(button).backgroundColor)) .not.toBe(baseDeleteColor); await page.screenshot({ path: testInfo.outputPath('gateway-admin-account-controls-hover.png'), fullPage: true }); await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('탈퇴 요청 접수'); await page.getByLabel('탈퇴 전 보존 일수').fill('30'); await deletionButton.click(); await expect(page.getByText(/탈퇴 예약 완료/).first()).toBeVisible(); expect(requests.some(({ operation }) => operation === 'admin.users.updateKakaoGrace')).toBe(true); expect(requests.some(({ operation }) => operation === 'admin.users.grantSpecialAccess')).toBe(true); expect(requests.some(({ operation }) => operation === 'admin.users.scheduleDeletion')).toBe(true); await page.setViewportSize({ width: 390, height: 844 }); const userDirectoryGeometry = await page.getByRole('region', { name: '계정 목록' }).evaluate((directory) => { const rect = directory.getBoundingClientRect(); return { left: rect.left, right: rect.right, width: rect.width, viewportWidth: window.innerWidth }; }); expect(userDirectoryGeometry.left).toBeGreaterThanOrEqual(0); expect(userDirectoryGeometry.right).toBeLessThanOrEqual(userDirectoryGeometry.viewportWidth); await writeFile( testInfo.outputPath('gateway-admin-user-directory-mobile-geometry.json'), JSON.stringify(userDirectoryGeometry) ); await page.screenshot({ path: testInfo.outputPath('gateway-admin-user-directory-mobile.png'), fullPage: true }); await page.getByRole('button', { name: '관리자 메뉴' }).click(); await page.getByRole('link', { name: '감사 로그' }).click(); await expect(page).toHaveURL(/\/gateway\/admin\/audit$/); await expect(page.getByRole('heading', { name: '전체 관리자 감사 원장' })).toBeVisible(); await expect(page.getByText('SUCCEEDED · admin.users.updateKakaoGrace').first()).toBeVisible(); const geometry = await page .getByRole('heading', { name: '전체 관리자 감사 원장' }) .locator('..') .evaluate((panel) => { const rect = panel.getBoundingClientRect(); return { left: rect.left, right: rect.right, width: rect.width, viewportWidth: window.innerWidth }; }); expect(geometry.left).toBeGreaterThanOrEqual(0); expect(geometry.right).toBeLessThanOrEqual(geometry.viewportWidth); await writeFile( testInfo.outputPath('gateway-admin-account-controls-mobile-geometry.json'), JSON.stringify(geometry) ); await page.screenshot({ path: testInfo.outputPath('gateway-admin-account-controls-mobile.png'), fullPage: true }); }); for (const width of [1365, 390]) { test(`groups server roles and applies all-server action at ${width}px`, async ({ page }, testInfo) => { const requests = await installFixture(page); await page.setViewportSize({ width, height: 900 }); await page.goto('admin/users'); await page.getByRole('button', { name: /target.*대상 사용자/ }).click(); const summary = page.getByLabel('보유 권한'); await expect(summary).toContainText('admin.profiles.runtime:{che, kwe, pwe}:default'); await page.getByRole('button', { name: /접근 · 권한/ }).click(); await page.getByLabel('관리 기능 권한').selectOption('admin.profiles.runtime'); const server = page.getByLabel('권한 대상 서버'); await server.selectOption('*'); await expect(server).toHaveValue('*'); await expect(page.getByText(/선택한 행동만 현재와 앞으로 추가되는 모든 서버에 허용/)).toBeVisible(); await server.focus(); await server.click(); await page.screenshot({ path: testInfo.outputPath('all-servers-selected.png'), fullPage: true }); await page.keyboard.press('Escape'); await page.getByRole('button', { name: '선택 반영', exact: true }).click(); const input = page.getByPlaceholder('admin, moderator 등'); await expect(input).toHaveValue('admin.profiles.runtime:*'); await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('모든 서버 실행 관리 허용'); const apply = page.getByRole('button', { name: '적용', exact: true }); await apply.hover(); await apply.focus(); await expect(apply).toBeFocused(); await apply.click(); await expect(summary).toContainText('admin.profiles.runtime:* (모든 서버)'); const update = requests.find(({ operation }) => operation === 'admin.users.updateRoles'); expect(update?.body).toMatchObject({ '0': { roles: ['admin.profiles.runtime:*'], mode: 'grant' }, }); // 전역 기능은 서버 선택이 없고, 다른 서버로 범위를 암묵적으로 옮기지 않는다. await page.getByLabel('관리 기능 권한').selectOption('admin.users.manage'); await expect(server).toHaveCount(0); await page.getByLabel('관리 기능 권한').selectOption('admin.profiles.runtime'); await expect(server).toHaveValue(''); await page.getByRole('button', { name: '선택 반영', exact: true }).click(); await expect(page.getByText('대상 서버를 선택하세요.', { exact: true }).first()).toBeVisible(); await page.evaluate(() => document.fonts.ready); const geometry = await summary.evaluate((element) => { const rect = element.getBoundingClientRect(); const style = getComputedStyle(element); return { left: rect.left, right: rect.right, width: rect.width, viewportWidth: innerWidth, overflowWrap: style.overflowWrap, documentOverflow: document.documentElement.scrollWidth - innerWidth, text: element.textContent, }; }); await writeFile(testInfo.outputPath('permission-geometry.json'), JSON.stringify(geometry, null, 2)); expect(geometry.right).toBeLessThanOrEqual(width); expect(geometry.documentOverflow).toBe(0); await writeFile(testInfo.outputPath('permissions.html'), await page.content()); await page.screenshot({ path: testInfo.outputPath('permissions.png'), fullPage: true }); }); } test('limits the server selector to the administrators own action scope', async ({ page }) => { await installFixture(page, ['che:default']); await page.goto('admin/users'); await page.getByRole('button', { name: /target.*대상 사용자/ }).click(); await page.getByRole('button', { name: /접근 · 권한/ }).click(); await page.getByLabel('관리 기능 권한').selectOption('admin.profiles.runtime'); const server = page.getByLabel('권한 대상 서버'); await expect(server.locator('option[value="*"]')).toHaveCount(0); await server.selectOption('che:default'); await page.getByRole('button', { name: '선택 반영', exact: true }).click(); await expect(page.getByPlaceholder('admin, moderator 등')).toHaveValue('admin.profiles.runtime:che:default'); }); test('keeps action and instance boundaries when formatting explicit grants', () => { const roles = [ 'user', 'admin.profiles.runtime:che:default', 'admin.profiles.runtime:kwe:default', 'admin.profiles.runtime:che:season2', 'admin.profiles.runtime:hwe:season2', 'admin.playAudit.read:che:default', 'admin.playAudit.accounts', 'admin.profiles.deploy', 'admin.profiles.deploy:*', ]; expect(formatRoleGroups(roles)).toEqual([ 'user', 'admin.profiles.runtime:{che, kwe}:default', 'admin.profiles.runtime:{che, hwe}:season2', 'admin.playAudit.read:che:default', 'admin.playAudit.accounts', 'admin.profiles.deploy', 'admin.profiles.deploy:* (모든 서버)', ]); expect(roles[1]).toBe('admin.profiles.runtime:che:default'); expect(formatRoleGroups([])).toEqual([]); }); test('shows grouped roles on the account page after direct navigation and refresh', async ({ page }, testInfo) => { await installFixture(page); let showGroupedRoles = false; await page.route('**/gateway/api/trpc/account.get*', async (route) => { await route.fulfill({ contentType: 'application/json', body: JSON.stringify([ response({ id: 'target-user', username: 'target', displayName: '대상 사용자', roles: showGroupedRoles ? [ 'user', ...['che', 'kwe', 'pwe', 'hwe', 'twe', 'nya', 'pya'].flatMap((profile) => [ `admin.profiles.runtime:${profile}:default`, `admin.playAudit.read:${profile}:default`, ]), 'admin.profiles.deploy:*', ] : ['user'], oauthType: 'NONE', createdAt: '2026-07-20T00:00:00.000Z', thirdPartyUse: false, icons: [], maxActiveIcons: 5, deleteAfter: null, }), ]), }); }); await page.setViewportSize({ width: 390, height: 844 }); await page.goto('account'); await expect(page.getByLabel('보유 권한')).toHaveText('user'); const baselineOverflow = await page.evaluate(() => document.documentElement.scrollWidth - innerWidth); showGroupedRoles = true; await page.reload(); const summary = page.getByLabel('보유 권한'); await expect(summary).toContainText('admin.profiles.runtime:{che, kwe, pwe, hwe, twe, nya, pya}:default'); await expect(summary).toContainText('admin.profiles.deploy:* (모든 서버)'); await expect(summary).not.toContainText('admin.playAudit.accounts'); await page.evaluate(() => document.fonts.ready); const geometry = await summary.evaluate((element) => ({ right: element.getBoundingClientRect().right, viewport: innerWidth, cellRight: element.closest('td')!.getBoundingClientRect().right, documentOverflow: document.documentElement.scrollWidth - innerWidth, overflowWrap: getComputedStyle(element).overflowWrap, })); expect(geometry.right).toBeLessThanOrEqual(geometry.cellRight); expect(geometry.documentOverflow).toBe(baselineOverflow); await writeFile(testInfo.outputPath('account-permissions-geometry.json'), JSON.stringify(geometry, null, 2)); 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(); });