feat: 관리자 프로필에서 플레이 감사 진입 연결

This commit is contained in:
2026-09-16 03:25:03 +00:00
parent 8f3852662e
commit b12a899526
5 changed files with 180 additions and 32 deletions
@@ -1,4 +1,5 @@
import { expect, test, type Page, type Route } from '@playwright/test';
import { mkdir, writeFile } from 'node:fs/promises';
const response = (data: unknown) => ({ result: { data } });
const operationNames = (route: Route): string[] => {
@@ -43,6 +44,7 @@ const installFixture = async (
gameIsUnited?: number;
openerOnly?: boolean;
diagnosticsFixture?: boolean;
auditScopes?: string[];
} = {}
) => {
let requested = false;
@@ -86,6 +88,10 @@ const installFixture = async (
installActive = true;
}
const results = operations.map((operation) => {
if (operation === 'auth.issueGameSession') {
requestBodies.push(body);
return response({ profile: 'hwe:default', gameToken: 'audit-fixture-token' });
}
if (operation === 'admin.profiles.diagnostics' && options.diagnosticsFixture) {
diagnosticReads += 1;
return response({
@@ -154,6 +160,18 @@ const installFixture = async (
}
if (operation === 'admin.capabilities.list') {
const capabilities = [
...(options.auditScopes
? [
{
permission: 'admin.playAudit.read',
label: '플레이 감사',
description: '조회',
risk: 'HIGH',
scope: 'PROFILE',
scopes: options.auditScopes,
},
]
: []),
{
permission: 'admin.users.manage',
label: '사용자·제재 관리',
@@ -683,3 +701,76 @@ test('runtime diagnostics shows expired lease and retains history after recovery
await expect(panel).toContainText('턴 프로세스와 실행 권한 정상');
await expect(panel).toContainText('Heartbeat deadline exceeded');
});
for (const width of [1280, 390]) {
test(`play audit entry uses scoped capability and private session transfer at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 900 });
const fixture = await installFixture(page, { auditScopes: ['hwe:default'] });
await page.route('**/hwe/play-audit', (route) =>
route.fulfill({ contentType: 'text/html', body: '<h1>감사 도착</h1>' })
);
await page.goto('/gateway/admin/servers/hwe%3Adefault');
const button = page.getByRole('button', { name: '플레이 감사', exact: true });
await expect(button).toBeVisible();
await button.scrollIntoViewIfNeeded();
await button.hover();
await button.focus();
await expect(button).toBeFocused();
await page.evaluate(() => document.fonts.ready);
const geometry = await button.evaluate((node) => {
const rect = node.getBoundingClientRect();
const style = getComputedStyle(node);
return {
x: rect.x,
width: rect.width,
right: rect.right,
viewport: innerWidth,
fontSize: style.fontSize,
background: style.backgroundColor,
documentWidth: document.documentElement.scrollWidth,
};
});
expect(geometry.right).toBeLessThanOrEqual(width);
await mkdir('/tmp/play-audit-entry', { recursive: true });
await writeFile(`/tmp/play-audit-entry/${width}.json`, JSON.stringify(geometry));
await writeFile(`/tmp/play-audit-entry/${width}.html`, await page.content());
await page.screenshot({ path: `/tmp/play-audit-entry/${width}.png`, fullPage: true });
await button.click();
await expect(page).toHaveURL(/\/hwe\/play-audit$/);
expect(fixture.requestBodies).toHaveLength(1);
expect(JSON.stringify(fixture.requestBodies[0])).toContain('hwe:default');
expect(
await page.evaluate(() => {
const transfer = JSON.parse(sessionStorage.getItem('sammo-pending-game-session') ?? '{}');
return transfer.profile === 'hwe:default' && transfer.gatewayToken === 'audit-fixture-token';
})
).toBe(true);
});
}
test('play audit entry is hidden for another profile scope', async ({ page }) => {
const fixture = await installFixture(page, { auditScopes: ['che:default'] });
await page.goto('/gateway/admin/servers/hwe%3Adefault');
await expect(page.getByRole('heading', { name: '서버 관리', exact: true, level: 1 })).toBeVisible();
await expect(page.getByText('현재 시나리오: 1010', { exact: true })).toBeVisible();
await expect(page.getByRole('button', { name: '플레이 감사', exact: true })).toHaveCount(0);
expect(fixture.requestBodies).toHaveLength(0);
});
test('play audit storage failure keeps credentials out of the URL and allows retry', async ({ page }) => {
await installFixture(page, { auditScopes: ['hwe:default'] });
await page.addInitScript(() => {
const original = Storage.prototype.setItem;
Storage.prototype.setItem = function (key: string, value: string) {
if (key === 'sammo-pending-game-session') throw new Error('fixture storage denied');
return original.call(this, key, value);
};
});
await page.goto('/gateway/admin/servers/hwe%3Adefault');
const button = page.getByRole('button', { name: '플레이 감사', exact: true });
await button.click();
await expect(page.getByRole('alert')).toContainText('세션 전달');
await expect(button).toBeEnabled();
expect(new URL(page.url()).search).toBe('');
expect(new URL(page.url()).pathname).toContain('/gateway/admin/servers/');
});
@@ -0,0 +1,38 @@
import { writeGameSessionTransfer } from '@sammo-ts/common/auth/gameSessionTransfer';
// 기존 로비의 query fallback은 유지하되 새 관리자 진입은 sessionStorage 전달만 허용한다.
export const resolveGameUrl = (
path: string,
profileName: string,
gameToken: string,
allowLegacyQueryTransfer = true
): string | null => {
const profile = profileName.split(':', 1)[0] ?? profileName;
const baseUrl =
import.meta.env.VITE_GAME_WEB_URL ??
import.meta.env.VITE_GAME_WEB_URL_TEMPLATE?.replaceAll('{profile}', encodeURIComponent(profile)) ??
'';
if (!baseUrl) {
return null;
}
const base = new URL(baseUrl, window.location.origin);
const normalizedPath = path.replace(/^\//, '');
const url = new URL(normalizedPath, base);
let transferredInSessionStorage = false;
if (url.origin === window.location.origin) {
try {
transferredInSessionStorage = writeGameSessionTransfer(window.sessionStorage, {
profile: profileName,
gatewayToken: gameToken,
});
} catch {
transferredInSessionStorage = false;
}
}
if (!transferredInSessionStorage) {
if (!allowLegacyQueryTransfer) return null;
url.searchParams.set('profile', profileName);
url.searchParams.set('gameToken', gameToken);
}
return url.toString();
};
@@ -19,6 +19,7 @@ import {
type ResetAutorunOption,
} from '../utils/resetDefaults';
import { trpc } from '../utils/trpc';
import { resolveGameUrl } from '../utils/gameEntry';
type AdminSection = 'users' | 'servers' | 'system' | 'audit';
@@ -571,6 +572,29 @@ const rolesInput = ref('');
const rolesMode = ref<'set' | 'grant' | 'revoke'>('grant');
const rolesStatus = ref('');
const capabilities = ref<AdminCapability[]>([]);
const auditEntryLoading = ref<Record<string, boolean>>({});
const auditEntryError = ref<Record<string, string>>({});
const enterPlayAudit = async (profileName: string): Promise<void> => {
if (auditEntryLoading.value[profileName] || !hasCapability('admin.playAudit.read', profileName)) return;
auditEntryLoading.value[profileName] = true;
auditEntryError.value[profileName] = '';
try {
const sessionToken = window.localStorage.getItem('sammo-session-token');
if (!sessionToken) throw new Error('로그인 후 다시 시도해 주세요.');
const issued = await trpc.auth.issueGameSession.mutate({ sessionToken, profile: profileName });
const url = resolveGameUrl('/play-audit', issued.profile, issued.gameToken, false);
if (!url)
throw new Error(
'게임 주소 또는 세션 전달을 확인하지 못했습니다. 같은 사이트에서 쿠키·저장소를 허용한 뒤 다시 시도해 주세요.'
);
window.location.assign(url);
} catch (cause) {
auditEntryError.value[profileName] =
cause instanceof Error ? cause.message : '플레이 감사 화면에 연결하지 못했습니다.';
} finally {
auditEntryLoading.value[profileName] = false;
}
};
const hasCapability = (permission: string, profileName?: string): boolean =>
capabilities.value.some((entry) => {
if (entry.permission !== permission) return false;
@@ -2418,6 +2442,23 @@ onMounted(() => {
:can-reset="hasCapability('admin.scenarios.reset', profile.profileName)"
:can-cancel="hasCapability('admin.games.cancel', profile.profileName)"
/>
<div v-if="hasCapability('admin.playAudit.read', profile.profileName)">
<button
type="button"
class="rounded bg-sky-700 px-3 py-1.5 text-sm font-semibold text-white hover:bg-sky-600 disabled:opacity-50"
:disabled="auditEntryLoading[profile.profileName]"
@click="enterPlayAudit(profile.profileName)"
>
{{ auditEntryLoading[profile.profileName] ? '연결 중…' : '플레이 감사' }}
</button>
<p
v-if="auditEntryError[profile.profileName]"
role="alert"
class="mt-2 text-sm text-red-300"
>
{{ auditEntryError[profile.profileName] }}
</p>
</div>
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2">
<div>
+1 -31
View File
@@ -4,7 +4,7 @@ import { computed, ref, onMounted, onUnmounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import type { inferRouterOutputs } from '@trpc/server';
import type { AppRouter } from '@sammo-ts/gateway-api';
import { writeGameSessionTransfer } from '@sammo-ts/common/auth/gameSessionTransfer';
import { resolveGameUrl } from '../utils/gameEntry';
import DefaultLayout from '../layouts/DefaultLayout.vue';
import MapPreview from '../components/MapPreview.vue';
import ProfilePreopenAnnouncement from '../components/ProfilePreopenAnnouncement.vue';
@@ -338,36 +338,6 @@ const handleKakaoVerification = async (): Promise<void> => {
}
};
const resolveGameUrl = (path: string, profileName: string, gameToken: string): string | null => {
const profile = profileName.split(':', 1)[0] ?? profileName;
const baseUrl =
import.meta.env.VITE_GAME_WEB_URL ??
import.meta.env.VITE_GAME_WEB_URL_TEMPLATE?.replaceAll('{profile}', encodeURIComponent(profile)) ??
'';
if (!baseUrl) {
return null;
}
const base = new URL(baseUrl, window.location.origin);
const normalizedPath = path.replace(/^\//, '');
const url = new URL(normalizedPath, base);
let transferredInSessionStorage = false;
if (url.origin === window.location.origin) {
try {
transferredInSessionStorage = writeGameSessionTransfer(window.sessionStorage, {
profile: profileName,
gatewayToken: gameToken,
});
} catch {
transferredInSessionStorage = false;
}
}
if (!transferredInSessionStorage) {
url.searchParams.set('profile', profileName);
url.searchParams.set('gameToken', gameToken);
}
return url.toString();
};
const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
if (entryLoading.value[profile.profileName]) {
return;
+9 -1
View File
@@ -29,9 +29,17 @@ PanelCard, legacy-button, legacy-sort-select를 재사용한다. 새 차트 라
이 화면은 Core 신규 UX다. 최대 폭 1200px, 390px 모바일에서 문서 가로 넘침 없음,
넓은 표만 내부 수평 스크롤, 공통 14px 기본 typography와 명시적 focus/disabled가 계약이다.
월말/FINAL 장수·도시 projection을 보여주지만 국가 FINAL 별도 시계열, 지도,
로그/예약 명령/전투 상세, 검색·정렬, 관리자 패널 진입 버튼은 후속 구현으로 남는다.
로그/예약 명령/전투 상세, 검색·정렬은 후속 구현으로 남는다.
따라서 기본 화면 추가만으로 R1~R3/P2를 완료 처리하지 않는다.
Gateway 서버 관리의 프로필 카드에는 `admin.playAudit.read` capability의 해당 전체
profile scope가 있을 때만 진입 버튼을 표시한다. 기존 `auth.issueGameSession` 발급과
game session transfer를 사용하고 Gateway가 감사 데이터를 대신 읽지 않는다.
기존 로비의 URL 구성/세션 전달을 `utils/gameEntry.ts`로 추출해 공유한다.
새 감사 진입은 동일 origin의 sessionStorage 전달만 허용하며, 실패하면 현재 화면에
재시도 가능한 오류를 표시한다. 기존 로비의 query fallback은 동작 변경 없이 유지하되
새 감사 경로에는 적용하지 않는다. 서로 다른 origin의 관리자 진입은 지원하지 않는다.
`app/game-engine/src/playAudit/snapshot.ts`는 기존 메모리 엔티티에서 명시적으로
허용한 장수·도시 필드와 국가별 자원·숙련 집계를 만든다. 입력 iterable을 각각
한 번 순회하며 국가마다 장수 목록을 다시 검색하지 않는다. 장수의 stats/role/items도