Merge remote-tracking branch 'origin/main' into fix/realtime-delta-clone-recovery-20260811

This commit is contained in:
2026-08-11 15:10:11 +00:00
8 changed files with 239 additions and 11 deletions
@@ -34,6 +34,56 @@ const seedGameStorage = async (page: Page, gameToken: string): Promise<void> =>
);
};
test('consumes a same-tab gateway transfer from a clean profile URL', async ({ page }) => {
await page.addInitScript((profile) => {
window.sessionStorage.setItem(
'sammo-pending-game-session',
JSON.stringify({ profile, gatewayToken: 'gateway-transfer-token' })
);
}, gameProfile);
const exchangedInputs: unknown[] = [];
await page.route(gameTrpcRoute, async (route) => {
const operations = operationNames(route);
const requestBody = route.request().postDataJSON() as
{ json?: unknown; 0?: { json?: unknown; gatewayToken?: string } } | undefined;
const results = operations.map((operation) => {
if (operation === 'auth.exchangeGatewayToken') {
exchangedInputs.push(requestBody?.json ?? requestBody?.[0]?.json ?? requestBody?.[0]);
return response({
accessToken: 'ga_transferred',
profile: gameProfile,
expiresAt: '2026-08-11T01:00:00Z',
});
}
if (operation === 'auth.status') {
expect(route.request().headers().authorization).toBe('Bearer ga_transferred');
return response({ userId: 'transfer-user' });
}
if (operation === 'lobby.info') {
return response({ myGeneral: null });
}
if (operation === 'join.getConfig') {
return response({});
}
return publicResponse(operation);
});
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(results),
});
});
await page.goto('select-general');
await expect(page).toHaveURL(new RegExp(`${gameBasePath}/select-general$`));
await expect(page.locator('.page-title')).toContainText('장 수 선 택');
expect(exchangedInputs).toEqual([{ gatewayToken: 'gateway-transfer-token' }]);
expect(await page.evaluate(() => window.sessionStorage.getItem('sammo-pending-game-session'))).toBeNull();
expect(await page.evaluate(() => window.localStorage.getItem('sammo-game-profile'))).toBe(gameProfile);
expect(await page.evaluate(() => window.localStorage.getItem('sammo-game-token'))).toBe('ga_transferred');
});
test('removes an invalid ga_ token and redirects an authenticated route to public', async ({ page }) => {
await seedGameStorage(page, 'ga_invalid');
let gatewayRequests = 0;
+18
View File
@@ -1,4 +1,5 @@
import { defineStore } from 'pinia';
import { takeGameSessionTransfer, type GameSessionTransfer } from '@sammo-ts/common/auth/gameSessionTransfer';
import { gatewayTrpc } from '../utils/gatewayTrpc';
import { trpc as gameTrpc } from '../utils/trpc';
@@ -57,6 +58,17 @@ const readQueryParam = (key: string): string | null => {
return value;
};
const readTransferredGameSession = (): GameSessionTransfer | null => {
if (typeof window === 'undefined') {
return null;
}
try {
return takeGameSessionTransfer(window.sessionStorage);
} catch {
return null;
}
};
const isAccessToken = (token: string | null): boolean => {
if (!token) {
return false;
@@ -194,6 +206,12 @@ export const useSessionStore = defineStore('session', {
this.setGameToken(gatewayTokenFromQuery);
}
const transferredGameSession = readTransferredGameSession();
if (transferredGameSession) {
this.setProfile(transferredGameSession.profile);
this.setGameToken(transferredGameSession.gatewayToken);
}
const storedToken = this.sessionToken ?? readStorage(SESSION_TOKEN_KEY);
if (storedToken && storedToken !== this.sessionToken) {
this.setSessionToken(storedToken);
@@ -189,10 +189,7 @@ test('exchanges the gateway token before loading authenticated lobby general dat
await expect(row).toContainText('선택장수');
await expect(row.getByRole('button', { name: '입장' })).toBeVisible();
const portrait = row.locator('img');
await expect(portrait).toHaveAttribute(
'src',
'https://sam-image.hided.net/icons/users/core2026/account-hash.png'
);
await expect(portrait).toHaveAttribute('src', 'https://sam-image.hided.net/icons/users/core2026/account-hash.png');
await expect.poll(() => portrait.evaluate((image: HTMLImageElement) => image.naturalWidth)).toBe(1);
expect(gameOperations.find(({ operation }) => operation === 'auth.exchangeGatewayToken')).toEqual({
@@ -219,7 +216,32 @@ test('applies the signed general-acquisition policy to both create and possessio
await expect(row.getByRole('button', { name: '장수빙의' })).toBeDisabled();
});
test('opens the mode-1 possession tab with a fresh gateway game token', async ({ page }) => {
test('opens the profile root without profile or game token query parameters', async ({ page }) => {
await installFixture(page);
await page.route('**/hwe/', async (route) => {
await route.fulfill({
status: 200,
contentType: 'text/html',
body: '<title>Clean profile target</title>',
});
});
await page.goto('lobby');
const row = page.locator('tbody tr').filter({ hasText: 'hwe섭' });
await row.getByRole('button', { name: '입장', exact: true }).click();
await expect(page).toHaveURL(/\/hwe\/$/);
const target = new URL(page.url());
expect(target.search).toBe('');
expect(
await page.evaluate(() => JSON.parse(window.sessionStorage.getItem('sammo-pending-game-session') ?? 'null'))
).toEqual({
profile: 'hwe:903',
gatewayToken: 'encrypted-gateway-game-token',
});
});
test('opens the mode-1 possession route with a fresh gateway game token outside the URL', async ({ page }) => {
await installFixture(page, {
myGeneral: null,
selectionPoolEnabled: false,
@@ -238,11 +260,17 @@ test('opens the mode-1 possession tab with a fresh gateway game token', async ({
await expect(row.getByRole('button', { name: '장수빙의' })).toBeEnabled();
await row.getByRole('button', { name: '장수빙의' }).click();
await expect(page).toHaveURL(/\/hwe\/join\?/);
await expect(page).toHaveURL(/\/hwe\/join\?tab=possess$/);
const target = new URL(page.url());
expect(target.searchParams.get('tab')).toBe('possess');
expect(target.searchParams.get('profile')).toBe('hwe:903');
expect(target.searchParams.get('gameToken')).toBe('encrypted-gateway-game-token');
expect(target.searchParams.has('profile')).toBe(false);
expect(target.searchParams.has('gameToken')).toBe(false);
expect(
await page.evaluate(() => JSON.parse(window.sessionStorage.getItem('sammo-pending-game-session') ?? 'null'))
).toEqual({
profile: 'hwe:903',
gatewayToken: 'encrypted-gateway-game-token',
});
});
test('shows registration closed instead of acquisition actions at the Ref capacity boundary', async ({ page }) => {
+20 -3
View File
@@ -3,6 +3,7 @@ import { computed, ref, onMounted, 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 DefaultLayout from '../layouts/DefaultLayout.vue';
import MapPreview from '../components/MapPreview.vue';
import { useToast } from '../composables/useToast';
@@ -227,8 +228,21 @@ const resolveGameUrl = (path: string, profileName: string, gameToken: string): s
const base = new URL(baseUrl, window.location.origin);
const normalizedPath = path.replace(/^\//, '');
const url = new URL(normalizedPath, base);
url.searchParams.set('profile', profileName);
url.searchParams.set('gameToken', gameToken);
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();
};
@@ -571,7 +585,10 @@ const handleEnter = async (profile: LobbyProfile, targetPath: string) => {
:map-layout="selectedMapPreview.mapLayout"
mode="detail"
/>
<div v-if="profileDetails[selectedMapProfile.profileName]" class="text-xs text-zinc-400 mt-2">
<div
v-if="profileDetails[selectedMapProfile.profileName]"
class="text-xs text-zinc-400 mt-2"
>
유저 {{ profileDetails[selectedMapProfile.profileName]?.userCnt ?? '-' }} /
{{ profileDetails[selectedMapProfile.profileName]?.maxUserCnt ?? '-' }} ·
{{ profileDetails[selectedMapProfile.profileName]?.nationCnt ?? '-' }}국 ·
+4
View File
@@ -17,6 +17,10 @@
"./auth/sanctions": {
"types": "./dist/auth/sanctions.d.ts",
"default": "./dist/auth/sanctions.js"
},
"./auth/gameSessionTransfer": {
"types": "./dist/auth/gameSessionTransfer.d.ts",
"default": "./dist/auth/gameSessionTransfer.js"
}
},
"scripts": {
@@ -0,0 +1,56 @@
export const GAME_SESSION_TRANSFER_STORAGE_KEY = 'sammo-pending-game-session';
export interface GameSessionTransfer {
profile: string;
gatewayToken: string;
}
export interface GameSessionTransferStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
const isGameSessionTransfer = (value: unknown): value is GameSessionTransfer => {
if (!value || typeof value !== 'object') {
return false;
}
const candidate = value as Partial<GameSessionTransfer>;
return (
typeof candidate.profile === 'string' &&
candidate.profile.trim().length > 0 &&
typeof candidate.gatewayToken === 'string' &&
candidate.gatewayToken.trim().length > 0
);
};
export const writeGameSessionTransfer = (
storage: GameSessionTransferStorage,
transfer: GameSessionTransfer
): boolean => {
try {
storage.setItem(GAME_SESSION_TRANSFER_STORAGE_KEY, JSON.stringify(transfer));
return true;
} catch {
return false;
}
};
export const takeGameSessionTransfer = (storage: GameSessionTransferStorage): GameSessionTransfer | null => {
let raw: string | null;
try {
raw = storage.getItem(GAME_SESSION_TRANSFER_STORAGE_KEY);
storage.removeItem(GAME_SESSION_TRANSFER_STORAGE_KEY);
} catch {
return null;
}
if (!raw) {
return null;
}
try {
const parsed: unknown = JSON.parse(raw);
return isGameSessionTransfer(parsed) ? parsed : null;
} catch {
return null;
}
};
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import {
GAME_SESSION_TRANSFER_STORAGE_KEY,
takeGameSessionTransfer,
writeGameSessionTransfer,
type GameSessionTransferStorage,
} from '../src/auth/gameSessionTransfer.js';
const createStorage = (): GameSessionTransferStorage & { values: Map<string, string> } => {
const values = new Map<string, string>();
return {
values,
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, value),
removeItem: (key) => values.delete(key),
};
};
describe('game session transfer', () => {
it('stores and consumes a valid transfer exactly once', () => {
const storage = createStorage();
const transfer = { profile: 'hwe:903', gatewayToken: 'encrypted-token' };
expect(writeGameSessionTransfer(storage, transfer)).toBe(true);
expect(storage.values.has(GAME_SESSION_TRANSFER_STORAGE_KEY)).toBe(true);
expect(takeGameSessionTransfer(storage)).toEqual(transfer);
expect(takeGameSessionTransfer(storage)).toBeNull();
});
it('removes malformed transfer data without returning it', () => {
const storage = createStorage();
storage.setItem(GAME_SESSION_TRANSFER_STORAGE_KEY, JSON.stringify({ profile: 'hwe:903' }));
expect(takeGameSessionTransfer(storage)).toBeNull();
expect(storage.values.has(GAME_SESSION_TRANSFER_STORAGE_KEY)).toBe(false);
});
it('reports unavailable storage so callers can use a compatibility fallback', () => {
const unavailable: GameSessionTransferStorage = {
getItem: () => {
throw new Error('blocked');
},
setItem: () => {
throw new Error('blocked');
},
removeItem: () => {
throw new Error('blocked');
},
};
expect(writeGameSessionTransfer(unavailable, { profile: 'hwe:903', gatewayToken: 'token' })).toBe(false);
expect(takeGameSessionTransfer(unavailable)).toBeNull();
});
});
+1
View File
@@ -4,6 +4,7 @@ export default defineConfig({
entry: {
index: 'src/index.ts',
'auth/gameToken': 'src/auth/gameToken.ts',
'auth/gameSessionTransfer': 'src/auth/gameSessionTransfer.ts',
'auth/sanctions': 'src/auth/sanctions.ts',
},
format: 'es',