관리자 승인과 사용자 OAuth 증명을 분리하고 기존 Kakao stable ID를 영구 폐기한다. 로그인 ID와 닉네임 변경은 현재 장수에 revision 기반으로 투영하며 과거 기록은 당시 이름으로 보존한다.
332 lines
17 KiB
TypeScript
332 lines
17 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 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;
|
|
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.runtime',
|
|
label: 'Profile 실행 관리',
|
|
description: '지정 profile의 시작, 정지와 실행 상태를 관리합니다.',
|
|
risk: 'HIGH',
|
|
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,
|
|
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.lookup') {
|
|
return response({
|
|
id: 'target-user',
|
|
username,
|
|
displayName,
|
|
roles: ['user'],
|
|
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 });
|
|
}
|
|
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 });
|
|
});
|