feat: 관리자 프로필에서 플레이 감사 진입 연결
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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도
|
||||
|
||||
Reference in New Issue
Block a user