Merge remote-tracking branch 'origin/main' into fix/realtime-delta-clone-recovery-20260811
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 ?? '-' }}국 ·
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user