브라우저의 Gateway·Game tRPC 클라이언트를 POST JSON 전송으로 통일하고 서버의 query method override를 허용한다. GET mutation 거부 계약과 production Chromium 요청 형태를 회귀 테스트로 고정한다.
288 lines
14 KiB
TypeScript
288 lines
14 KiB
TypeScript
import { expect, test, type Page, type Route } from '@playwright/test';
|
|
import { writeFile } from 'node:fs/promises';
|
|
|
|
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) => {
|
|
const requests: Array<{ operation: string; body: unknown }> = [];
|
|
let deleteAfter: string | null = null;
|
|
let graceUntil: 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;
|
|
const results = operations.map((operation) => {
|
|
requests.push({ operation, body });
|
|
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.manage',
|
|
label: 'Profile 운영',
|
|
description: '지정 profile을 관리합니다.',
|
|
risk: 'CRITICAL',
|
|
scope: 'PROFILE',
|
|
},
|
|
{
|
|
permission: 'admin.audit.read',
|
|
label: '관리자 감사 조회',
|
|
description: 'Gateway 관리자 변경 이력을 조회합니다.',
|
|
risk: 'HIGH',
|
|
scope: 'GLOBAL',
|
|
},
|
|
]);
|
|
}
|
|
if (operation === 'admin.audit.list') return response(auditHistory);
|
|
if (operation === 'admin.users.getLocalAccountStatus') return response({ enabled: false });
|
|
if (operation === 'admin.users.list') {
|
|
return response({
|
|
total: 3,
|
|
users: [
|
|
{
|
|
id: 'target-user',
|
|
username: 'target',
|
|
displayName: '대상 사용자',
|
|
email: 'target@example.test',
|
|
oauthType: 'NONE',
|
|
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.lookup') {
|
|
return response({
|
|
id: 'target-user',
|
|
username: 'target',
|
|
displayName: '대상 사용자',
|
|
roles: ['user'],
|
|
sanctions: {},
|
|
oauthType: 'NONE',
|
|
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',
|
|
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.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 });
|
|
}
|
|
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 page.getByRole('button', { name: /접근 · 권한/ }).click();
|
|
await expect(page.getByRole('cell', { name: 'che:default' })).toBeVisible();
|
|
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.getByPlaceholder('che 또는 che:2 (쉼표 구분, 비우면 전체)').fill('che');
|
|
await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('휴대폰 분실 임시 복구');
|
|
await page.getByRole('button', { name: '특수 접근 부여', exact: true }).click();
|
|
await expect(page.getByText('특수 접근 자격을 부여했습니다.').first()).toBeVisible();
|
|
await expect(page.getByText(/RECOVERY · che/)).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 });
|
|
});
|