diff --git a/server-node/src/modules/story/storySpacetimeBridge.test.ts b/server-node/src/modules/story/storySpacetimeBridge.test.ts new file mode 100644 index 00000000..e6167dcd --- /dev/null +++ b/server-node/src/modules/story/storySpacetimeBridge.test.ts @@ -0,0 +1,248 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { Request } from 'express'; + +import type { AppConfig } from '../../config.js'; +import { + authenticateRuntimeStoryViaSpacetime, + createRuntimeStorySnapshotRepository, + resetRuntimeStoryBridgeConnectorForTest, + setRuntimeStoryBridgeConnectorForTest, + shouldUseSpacetimeStoryAuth, +} from './storySpacetimeBridge.js'; + +function createTestConfig(): AppConfig { + return { + nodeEnv: 'test', + projectRoot: '/tmp/genarrative-test', + publicDir: '/tmp/genarrative-test/public', + logsDir: '/tmp/genarrative-test/logs', + dataDir: '/tmp/genarrative-test/data', + rawEnv: {}, + databaseUrl: 'pg-mem://genarrative-test', + serverAddr: ':0', + logLevel: 'silent', + editorApiEnabled: true, + assetsApiEnabled: true, + jwtSecret: 'test-secret', + jwtExpiresIn: '7d', + jwtIssuer: 'genarrative-test', + llm: { + baseUrl: 'https://example.invalid', + apiKey: '', + model: 'test-model', + }, + dashScope: { + baseUrl: 'https://example.invalid', + apiKey: '', + imageModel: 'test-image-model', + requestTimeoutMs: 1000, + }, + smsAuth: { + enabled: true, + provider: 'mock', + endpoint: 'dypnsapi.aliyuncs.com', + accessKeyId: '', + accessKeySecret: '', + signName: 'Test Sign', + templateCode: '100001', + templateParamKey: 'code', + countryCode: '86', + schemeName: '', + codeLength: 6, + codeType: 1, + validTimeSeconds: 300, + intervalSeconds: 60, + duplicatePolicy: 1, + caseAuthPolicy: 1, + returnVerifyCode: false, + mockVerifyCode: '123456', + maxSendPerPhonePerDay: 20, + maxSendPerIpPerHour: 30, + maxVerifyFailuresPerPhonePerHour: 12, + maxVerifyFailuresPerIpPerHour: 24, + captchaTtlSeconds: 180, + captchaTriggerVerifyFailuresPerPhone: 3, + captchaTriggerVerifyFailuresPerIp: 5, + blockPhoneFailureThreshold: 6, + blockIpFailureThreshold: 10, + blockPhoneDurationMinutes: 30, + blockIpDurationMinutes: 30, + }, + wechatAuth: { + enabled: true, + provider: 'mock', + appId: '', + appSecret: '', + authorizeEndpoint: 'https://open.weixin.qq.com/connect/qrconnect', + accessTokenEndpoint: 'https://api.weixin.qq.com/sns/oauth2/access_token', + userInfoEndpoint: 'https://api.weixin.qq.com/sns/userinfo', + callbackPath: '/api/auth/wechat/callback', + defaultRedirectPath: '/', + mockUserId: 'mock_wechat_user', + mockUnionId: 'mock_wechat_union', + mockDisplayName: '微信旅人', + mockAvatarUrl: '', + }, + authSession: { + refreshCookieName: 'genarrative_refresh_session', + refreshSessionTtlDays: 30, + refreshCookieSecure: false, + refreshCookieSameSite: 'Lax', + refreshCookiePath: '/api/auth', + }, + spacetime: { + uri: 'ws://127.0.0.1:3000', + databaseName: 'genarrative-test', + }, + }; +} + +function createRequest(headers: Record = {}) { + const normalizedHeaders = Object.fromEntries( + Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]), + ); + + return { + header(name: string) { + return normalizedHeaders[name.toLowerCase()] ?? undefined; + }, + ip: '127.0.0.1', + userId: undefined, + runtimeStoryAuthMode: undefined, + } as unknown as Request; +} + +test('shouldUseSpacetimeStoryAuth only accepts the runtime story spacetime marker', () => { + assert.equal( + shouldUseSpacetimeStoryAuth( + createRequest({ + 'x-genarrative-runtime-story-auth': 'spacetime-token', + }), + ), + true, + ); + assert.equal( + shouldUseSpacetimeStoryAuth( + createRequest({ + 'x-genarrative-runtime-story-auth': 'http-access-token', + }), + ), + false, + ); +}); + +test('authenticateRuntimeStoryViaSpacetime resolves account id from bridge connection', async () => { + setRuntimeStoryBridgeConnectorForTest(async () => ({ + db: { + my_auth_state: { + iter: () => + [ + { + accountId: 'stdb-account-01', + }, + ][Symbol.iterator](), + }, + }, + disconnect() {}, + })); + + const request = createRequest({ + authorization: 'Bearer stdb-token', + }); + + await authenticateRuntimeStoryViaSpacetime(request, { + config: createTestConfig(), + }); + + assert.equal(request.userId, 'stdb-account-01'); + assert.equal(request.runtimeStoryAuthMode, 'spacetime'); + + resetRuntimeStoryBridgeConnectorForTest(); +}); + +test('createRuntimeStorySnapshotRepository uses STDB snapshot rows when request is marked as spacetime', async () => { + let savedSnapshotInput: Record | null = null; + + setRuntimeStoryBridgeConnectorForTest(async () => ({ + db: { + my_snapshot: { + iter: () => + [ + { + version: 8, + savedAtMs: BigInt(Date.parse('2026-04-20T00:00:00.000Z')), + gameStateJson: JSON.stringify({ + worldType: 'WUXIA', + currentScene: 'Story', + }), + bottomTab: 'adventure', + currentStoryJson: JSON.stringify({ + text: '桥接后的快照', + options: [], + }), + }, + ][Symbol.iterator](), + }, + }, + procedures: { + async saveSnapshot(input: Record) { + savedSnapshotInput = input; + return { + ok: true, + message: 'ok', + }; + }, + }, + disconnect() {}, + })); + + const request = createRequest({ + authorization: 'Bearer stdb-token', + 'user-agent': 'test-agent', + }); + request.runtimeStoryAuthMode = 'spacetime'; + + const repository = createRuntimeStorySnapshotRepository({ + request, + config: createTestConfig(), + runtimeRepository: { + async getSnapshot() { + throw new Error('should not hit legacy repository'); + }, + async putSnapshot() { + throw new Error('should not hit legacy repository'); + }, + } as never, + }); + + const snapshot = await repository.getSnapshot('ignored-user-id'); + assert.equal(snapshot?.version, 8); + assert.equal(snapshot?.gameState.worldType, 'WUXIA'); + + const persisted = await repository.putSnapshot('ignored-user-id', { + savedAt: '2026-04-20T00:00:00.000Z', + bottomTab: 'adventure', + gameState: { + worldType: 'WUXIA', + currentScene: 'Story', + }, + currentStory: { + text: '桥接后的快照', + options: [], + }, + }); + + assert.equal(persisted.version, 8); + assert.equal(savedSnapshotInput?.bottomTab, 'adventure'); + assert.equal( + savedSnapshotInput?.currentStoryJson, + JSON.stringify({ + text: '桥接后的快照', + options: [], + }), + ); + + resetRuntimeStoryBridgeConnectorForTest(); +}); diff --git a/server-node/src/modules/story/storySpacetimeBridge.ts b/server-node/src/modules/story/storySpacetimeBridge.ts index ac5129bf..0af14fb9 100644 --- a/server-node/src/modules/story/storySpacetimeBridge.ts +++ b/server-node/src/modules/story/storySpacetimeBridge.ts @@ -23,6 +23,8 @@ type StoryStdbBridgeContext = { config: AppConfig; }; +type StoryBridgeDbConnection = Awaited>; + function readBearerToken(request: Request) { const authorization = request.header('authorization')?.trim() || ''; if (!authorization.startsWith('Bearer ')) { @@ -102,9 +104,9 @@ async function connectWithToken(config: AppConfig, token: string) { async function withBridgeConnection( config: AppConfig, token: string, - run: (connection: DbConnection) => Promise, + run: (connection: StoryBridgeDbConnection) => Promise, ) { - const connection = await connectWithToken(config, token); + const connection = await runtimeStoryBridgeConnector(config, token); try { return await run(connection); } finally { @@ -112,6 +114,18 @@ async function withBridgeConnection( } } +let runtimeStoryBridgeConnector = connectWithToken; + +export function setRuntimeStoryBridgeConnectorForTest( + connector: typeof connectWithToken, +) { + runtimeStoryBridgeConnector = connector; +} + +export function resetRuntimeStoryBridgeConnectorForTest() { + runtimeStoryBridgeConnector = connectWithToken; +} + export function shouldUseSpacetimeStoryAuth(request: Request) { return ( request.header(STORY_STDB_AUTH_HEADER)?.trim() === 'spacetime-token'