Files
core2026/app/gateway-frontend/e2e/admin-account-controls.spec.ts
T

659 lines
34 KiB
TypeScript

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<void>;
} = {}
) => {
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<Record<string, unknown>> = [];
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<string, { roles: string[]; mode: string }>)['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<string, { reason: string }>)['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<void>((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<string, unknown>)).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();
});