fix: align legacy general access call boundaries

This commit is contained in:
2026-07-31 07:33:26 +00:00
parent 821da19731
commit 98a3cf514f
34 changed files with 909 additions and 177 deletions
+78 -4
View File
@@ -161,9 +161,9 @@ const install = async (
);
await page.route('**/che/api/trpc/**', async (route) => {
const requestBody = route.request().postDataJSON() as
| Record<string, { json?: { page?: unknown }; page?: unknown }>
| undefined;
Record<string, { json?: { page?: unknown }; page?: unknown }> | undefined;
const results = operationNames(route).map((operation, operationIndex) => {
if (operation === 'auth.status') return response({ ok: true });
if (operation === 'lobby.info') {
return response({ myGeneral: mode === 'no-general' ? null : { id: 1, name: '조회자' } });
}
@@ -191,7 +191,8 @@ const install = async (
};
}
const sort = parseSort(route);
const rows = sort === 8 ? [...generals].sort((left, right) => left.killturn - right.killturn) : generals;
const rows =
sort === 8 ? [...generals].sort((left, right) => left.killturn - right.killturn) : generals;
return response({ sort, generals: rows });
}
return { error: { message: `unhandled ${operation}`, data: { code: 'BAD_REQUEST' } } };
@@ -200,6 +201,30 @@ const install = async (
});
};
const installAccessBoundary = async (page: Page, accessPages: string[]) => {
await page.addInitScript(() => {
localStorage.setItem('sammo-game-token', 'ga_access_boundary');
localStorage.setItem('sammo-game-profile', 'che:default');
});
await page.route('**/image/**', (route) => route.abort('failed'));
await page.route('**/che/api/trpc/**', async (route) => {
const requestBody = route.request().postDataJSON() as
Record<string, { json?: { page?: unknown }; page?: unknown }> | undefined;
const results = operationNames(route).map((operation, operationIndex) => {
if (operation === 'auth.status') return response({ ok: true });
if (operation === 'lobby.info') return response({ myGeneral: { id: 1, name: '조회자' } });
if (operation === 'public.recordAccess') {
const payload = requestBody?.[String(operationIndex)];
const pageName = payload?.json?.page ?? payload?.page;
if (typeof pageName === 'string') accessPages.push(pageName);
return response({ recorded: true });
}
return response({});
});
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) });
});
};
test('nation and general directories preserve the fixed legacy Chromium geometry', async ({ page }) => {
const accessPages: string[] = [];
await install(page, 'general', accessPages);
@@ -283,7 +308,8 @@ test('nation and general directories preserve the fixed legacy Chromium geometry
).toBe(65);
}
}
await expect.poll(() => accessPages).toEqual(expect.arrayContaining(['nation-list', 'general-list']));
await expect.poll(() => accessPages).toContain('nation-list');
expect(accessPages).not.toContain('general-list');
const header = page.locator('.general-table thead td').first();
expect(await header.evaluate((element) => getComputedStyle(element).backgroundImage)).toContain('back_green.jpg');
@@ -345,3 +371,51 @@ test('an authenticated account without a general is redirected away from both di
await expect(page).toHaveURL(/\/che\/join$/);
}
});
test('route access belongs only to the eight Ref page boundaries', async ({ page }) => {
const accessPages: string[] = [];
await installAccessBoundary(page, accessPages);
const retained = [
['nation/info', 'nation-info'],
['nation/cities', 'nation-cities'],
['nation-list', 'nation-list'],
['current-city', 'current-city'],
['dynasty', 'dynasty'],
['dynasty/1', 'dynasty'],
['traffic', 'traffic'],
['npc-control', 'npc-control'],
] as const;
for (const [path, pageName] of retained) {
const before = accessPages.length;
await page.goto(path);
await expect.poll(() => accessPages.length).toBe(before + 1);
expect(accessPages.at(-1)).toBe(pageName);
}
const endpointOwned = [
'./',
'global-info',
'general-list',
'diplomacy',
'nation/generals',
'nation/personnel',
'nation/finance',
'battle-center',
'board',
'board/secret',
'best-general',
'hall-of-fame',
'yearbook',
'nation-betting',
'npc-list',
'my-page',
'tournament',
'betting',
];
for (const path of endpointOwned) {
const before = accessPages.length;
await page.goto(path);
await page.waitForTimeout(50);
expect(accessPages).toHaveLength(before);
}
});
+68 -7
View File
@@ -26,12 +26,16 @@ type FixtureState = {
instantRetreatEnabled?: boolean;
instantRetreatAttempts?: number;
instantRetreatInputs?: Array<Record<string, unknown>>;
buildNationCandidateEnabled?: boolean;
buildNationCandidateAttempts?: number;
buildNationCandidateInputs?: Array<Record<string, unknown>>;
dieOnPrestartShow?: boolean;
dieOnPrestartAvailableAt?: string;
dieOnPrestartAttempts?: number;
dieOnPrestartInputs?: Array<Record<string, unknown>>;
generalMeQueries?: number;
generalLogQueries?: number;
ensurePrestartQueries?: number;
nationNoticeInput?: string;
settingMutations: Array<Record<string, unknown>>;
accessPages: string[];
@@ -47,7 +51,7 @@ const myGeneral = (state: FixtureState) => ({
id: 7,
name: '검증장수',
npcState: 0,
nationId: 1,
nationId: state.buildNationCandidateEnabled ? 0 : 1,
cityId: 1,
troopId: 0,
picture: null,
@@ -165,12 +169,14 @@ const install = async (page: Page, state: FixtureState) => {
state.generalMeQueries = (state.generalMeQueries ?? 0) + 1;
return response(myGeneral(state));
}
if (operation === 'general.ensureDieOnPrestartStatus')
if (operation === 'general.ensureDieOnPrestartStatus') {
state.ensurePrestartQueries = (state.ensurePrestartQueries ?? 0) + 1;
return response({
show: state.dieOnPrestartShow ?? false,
available: false,
availableAt: state.dieOnPrestartAvailableAt ?? null,
});
}
if (operation === 'general.getFrontStatus')
return response({
onlineUserCount: 1,
@@ -196,7 +202,9 @@ const install = async (page: Page, state: FixtureState) => {
},
meta: {
turntime: '2026-01-01T00:00:00.000Z',
opentime: '2025-12-01T00:00:00.000Z',
opentime: state.buildNationCandidateEnabled
? '2026-02-01T00:00:00.000Z'
: '2025-12-01T00:00:00.000Z',
autorun_user: {},
},
});
@@ -261,6 +269,20 @@ const install = async (page: Page, state: FixtureState) => {
}
return response({ ok: true });
}
if (operation === 'general.buildNationCandidate') {
state.buildNationCandidateInputs?.push(jsonInput);
state.buildNationCandidateAttempts = (state.buildNationCandidateAttempts ?? 0) + 1;
if (state.buildNationCandidateAttempts === 1) {
return {
error: {
message: '요청 처리 결과를 확인하지 못했습니다.',
code: -32000,
data: { code: 'TIMEOUT', httpStatus: 408, path: operation },
},
};
}
return response({ ok: true });
}
if (operation === 'general.dieOnPrestart') {
state.dieOnPrestartInputs?.push(jsonInput);
state.dieOnPrestartAttempts = (state.dieOnPrestartAttempts ?? 0) + 1;
@@ -395,7 +417,8 @@ test('내 정보&설정 keeps the legacy 1000px/500px geometry and saves in plac
await page.goto('my-page');
await expect(page.locator('.title-row')).toContainText('내 정 보');
await expect(page.locator('#set_my_setting')).toBeVisible();
await expect.poll(() => state.accessPages).toContain('my-page');
await expect.poll(() => state.generalMeQueries).toBeGreaterThan(0);
expect(state.accessPages).not.toContain('my-page');
const noDefenceOption = page.locator('option[value="999"]');
await expect(noDefenceOption).toHaveText('× [훈련 -3,사기 -6]');
await expect(page.locator('#defence_train option')).toHaveText([
@@ -533,6 +556,7 @@ test('내 정보 즉시행동은 timeout 재시도 ID를 유지하고 성공 후
instantRetreatInputs: [],
generalMeQueries: 0,
generalLogQueries: 0,
ensurePrestartQueries: 0,
settingMutations: [],
accessPages: [],
};
@@ -547,18 +571,21 @@ test('내 정보 즉시행동은 timeout 재시도 ID를 유지하고 성공 후
const instantRetreatButton = page.getByRole('button', { name: '접경 귀환' });
await expect(instantRetreatButton).toBeVisible();
await expect.poll(() => state.generalMeQueries).toBe(1);
await expect.poll(() => state.ensurePrestartQueries).toBe(1);
await instantRetreatButton.click();
await expect.poll(() => state.instantRetreatInputs?.length).toBe(1);
await expect
.poll(() => dialogs.some((message) => message.includes('요청 처리 결과를 확인하지 못했습니다.')))
.toBe(true);
expect(state.generalMeQueries).toBe(1);
await expect.poll(() => state.generalMeQueries).toBe(2);
await expect.poll(() => state.ensurePrestartQueries).toBe(2);
await instantRetreatButton.click();
await expect.poll(() => state.instantRetreatInputs?.length).toBe(2);
await expect.poll(() => state.generalMeQueries).toBe(2);
await expect.poll(() => state.generalLogQueries).toBe(8);
await expect.poll(() => state.generalMeQueries).toBe(3);
await expect.poll(() => state.ensurePrestartQueries).toBe(3);
await expect.poll(() => state.generalLogQueries).toBe(12);
await page.evaluate(() => new Promise<void>((resolveFrame) => requestAnimationFrame(() => resolveFrame())));
await instantRetreatButton.click();
@@ -690,6 +717,40 @@ test('가오픈 장수 삭제는 레거시 표시와 확인을 보존하고 time
});
});
test('사전 거병은 timeout reload 뒤 같은 ID를 재시도하고 성공 reload 뒤 새 ID를 만든다', async ({ page }) => {
const state: FixtureState = {
permission: 'head',
myset: 3,
buildNationCandidateEnabled: true,
buildNationCandidateAttempts: 0,
buildNationCandidateInputs: [],
ensurePrestartQueries: 0,
settingMutations: [],
accessPages: [],
};
page.on('dialog', (dialog) => dialog.accept());
await install(page, state);
await page.goto('my-page');
const build = page.getByRole('button', { name: '사전 거병' });
await expect(build).toBeVisible();
await expect.poll(() => state.ensurePrestartQueries).toBe(1);
await build.click();
await expect.poll(() => state.buildNationCandidateInputs?.length).toBe(1);
await expect.poll(() => state.ensurePrestartQueries).toBe(2);
await build.click();
await expect.poll(() => state.buildNationCandidateInputs?.length).toBe(2);
await expect.poll(() => state.ensurePrestartQueries).toBe(3);
await build.click();
await expect.poll(() => state.buildNationCandidateInputs?.length).toBe(3);
const requestIds = state.buildNationCandidateInputs?.map((input) => input.clientRequestId);
expect(requestIds?.[1]).toBe(requestIds?.[0]);
expect(requestIds?.[2]).not.toBe(requestIds?.[1]);
});
test('감찰부 keeps the selector interaction and shows the permission error path', async ({ page }) => {
const head: FixtureState = { permission: 'head', myset: 3, settingMutations: [], accessPages: [] };
await install(page, head);
-18
View File
@@ -41,32 +41,14 @@ import { useSessionStore } from '../stores/session';
import { trpc } from '../utils/trpc';
const accessPageByRouteName = {
home: 'front-info',
'nation-info': 'nation-info',
'nation-cities': 'nation-cities',
'global-info': 'global-info',
'nation-list': 'nation-list',
'general-list': 'general-list',
'current-city': 'current-city',
diplomacy: 'diplomacy',
'nation-generals': 'nation-generals',
'nation-personnel': 'nation-personnel',
'nation-finance': 'nation-finance',
'battle-center': 'battle-center',
board: 'board',
'board-secret': 'board',
'best-general': 'best-general',
'hall-of-fame': 'hall-of-fame',
'dynasty-list': 'dynasty',
'dynasty-detail': 'dynasty',
yearbook: 'yearbook',
'nation-betting': 'nation-betting',
traffic: 'traffic',
'npc-list': 'npc-list',
'my-page': 'my-page',
'npc-control': 'npc-control',
tournament: 'tournament',
betting: 'betting',
} as const;
const routes = [
+2 -1
View File
@@ -86,9 +86,10 @@ const placeBet = async (targetId: number) => {
try {
await trpc.tournament.placeBet.mutate({ targetId, amount });
message.value = '베팅이 등록되었습니다.';
await load();
} catch (value) {
message.value = errorText(value);
} finally {
await load();
}
};
</script>
@@ -106,10 +106,7 @@ watch([selectedSeason, selectedScenario], () => {
void loadHall();
});
onMounted(async () => {
await loadOptions();
await loadHall();
});
onMounted(loadOptions);
</script>
<template>
+24 -12
View File
@@ -187,7 +187,7 @@ const loadLog = async (type: LogType, beforeId?: number) => {
}
};
const loadPage = async () => {
const loadPage = async (resetImmediateActionIds = true) => {
if (loading.value) return;
loading.value = true;
error.value = null;
@@ -206,7 +206,9 @@ const loadPage = async () => {
Object.assign(form, general.settings);
}
await Promise.all(logTypes.map((type) => loadLog(type)));
resetImmediateActionRequestIds();
if (resetImmediateActionIds) {
resetImmediateActionRequestIds();
}
} catch (cause) {
error.value = errorText(cause);
} finally {
@@ -224,13 +226,17 @@ const saveSettings = async () => {
}
};
const confirmMutation = async (message: string, mutation: () => Promise<unknown>) => {
const confirmMutation = async (message: string, mutation: () => Promise<unknown>, reloadAfterFailure = false) => {
if (!confirm(message)) return;
try {
await mutation();
await loadPage();
} catch (cause) {
alert(`실패했습니다: ${errorText(cause)}`);
if (reloadAfterFailure) {
const code = asRecord(asRecord(cause).data).code;
await loadPage(code !== 'TIMEOUT');
}
}
};
@@ -288,7 +294,7 @@ onMounted(() => {
<span> </span>
<RouterLink class="legacy-button" to="/past-plays">지난 플레이</RouterLink>
<RouterLink class="legacy-button" to="/">돌아가기</RouterLink>
<button class="legacy-button" type="button" @click="loadPage">새로고침</button>
<button class="legacy-button" type="button" @click="() => loadPage()">새로고침</button>
</div>
<div v-if="error" class="error-row">{{ error }}</div>
@@ -430,10 +436,13 @@ onMounted(() => {
<button
class="action-button"
@click="
confirmMutation('거병 이후 장수를 삭제할 수 없게됩니다. 거병하시겠습니까?', () =>
trpc.general.buildNationCandidate.mutate({
clientRequestId: immediateActionRequestIds.buildNationCandidate,
})
confirmMutation(
'거병 이후 장수를 삭제할 수 없게됩니다. 거병하시겠습니까?',
() =>
trpc.general.buildNationCandidate.mutate({
clientRequestId: immediateActionRequestIds.buildNationCandidate,
}),
true
)
"
>
@@ -445,10 +454,13 @@ onMounted(() => {
<button
class="action-button"
@click="
confirmMutation('아군 접경으로 이동할까요?', () =>
trpc.general.instantRetreat.mutate({
clientRequestId: immediateActionRequestIds.instantRetreat,
})
confirmMutation(
'아군 접경으로 이동할까요?',
() =>
trpc.general.instantRetreat.mutate({
clientRequestId: immediateActionRequestIds.instantRetreat,
}),
true
)
"
>
@@ -102,9 +102,10 @@ const join = async () => {
try {
await trpc.tournament.join.mutate();
actionMessage.value = '참가 신청이 반영되었습니다.';
await load();
} catch (value) {
actionMessage.value = errorText(value);
} finally {
await load();
}
};