import { randomUUID } from 'node:crypto'; import fastify, { type FastifyRequest } from 'fastify'; import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; import { trpcJsonBodyHttpServerOptions } from '@sammo-ts/common'; import { appRouter } from '../src/router.js'; import { createGatewayApiContext } from '../src/context.js'; import { AuthAttemptBudget, RedisAuthCounterStore } from '../src/auth/attemptBudget.js'; import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js'; import { createClient } from 'redis'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; import { RedisGatewaySessionService } from '../src/auth/redisSessionService.js'; import { InMemoryGatewaySessionService } from '../src/auth/inMemorySessionService.js'; import { createInMemoryUserRepository } from '../src/auth/inMemoryUserRepository.js'; const redisUrl = process.env.GATEWAY_SESSION_REDIS_TEST_URL; const limit = 256; const user = async () => { const users = createInMemoryUserRepository(); return users.createUser({ username: `session-${randomUUID()}`, password: 'synthetic-test-password' }); }; describe.skipIf(!redisUrl)('game session lifecycle over real Redis', () => { const client = createClient({ url: redisUrl }); const prefix = `game-session-security-test:${randomUUID()}`; const store = new RedisGatewaySessionService(client, { keyPrefix: prefix, sessionTtlSeconds: 600, gameSessionTtlSeconds: 300, }); beforeAll(async () => { await client.connect(); }); afterAll(async () => { for await (const keys of client.scanIterator({ MATCH: `${prefix}:*`, COUNT: 100 })) { if (keys.length) await client.del(keys); } await client.quit(); }); const keys = (token: string) => ({ parent: `${prefix}:session:${token}`, games: `${prefix}:session-games:${token}`, }); const httpFixture = async () => { const users = createInMemoryUserRepository(); const first = await users.createUser({ username: `first-${randomUUID()}`, password: 'synthetic-password' }); const second = await users.createUser({ username: `second-${randomUUID()}`, password: 'synthetic-password' }); const parents = await Promise.all([ store.createSession(first), store.createSession(first), store.createSession(second), ]); const profiles = { getProfile: vi.fn(async () => null) }; const authBudget = new AuthAttemptBudget( new RedisAuthCounterStore(client), `${prefix}:${randomUUID()}`, 'synthetic-rate-key' ); const passwordEnvelope = createPasswordEnvelopeService(); const app = fastify({ trustProxy: false }); await app.register(fastifyTRPCPlugin, { prefix: '/trpc', trpcOptions: { router: appRouter, ...trpcJsonBodyHttpServerOptions, createContext: ({ req }: { req: FastifyRequest }) => createGatewayApiContext({ users, sessions: store, passwordEnvelope, authBudget, requestIp: req.ip, requestHeaders: req.headers, flushPublisher: { publishUserFlush: async () => {} }, gameTokenSecret: 'synthetic-game-key', gameSessionTtlSeconds: 300, kakaoClient: {} as never, oauthSessions: {} as never, publicBaseUrl: 'http://localhost', adminLocalAccountEnabled: true, localRegistrationEnabled: true, localAccountGraceDays: 7, profiles: profiles as never, orchestrator: {} as never, profileStatus: {} as never, prisma: {} as never, }), }, }); const address = await app.listen({ host: '127.0.0.1', port: 0 }); const post = (operation: string, input: unknown) => fetch(`${address}/trpc/${operation}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input), }); const issue = (index: number) => post('auth.issueGameSession', { sessionToken: parents[index]!.sessionToken, profile: 'che:default' }); return { app, post, issue, parents, profiles }; }; it('limits real HTTP issuance by account across login sessions before profile/icon reads', async () => { const f = await httpFixture(); try { for (let i = 0; i < 32; i++) expect((await f.issue(i % 2)).status).toBe(200); const denied = await f.issue(1); expect(denied.status).toBe(429); expect(f.profiles.getProfile).toHaveBeenCalledTimes(32); expect(JSON.stringify(await denied.json())).not.toContain('stack'); expect((await f.issue(2)).status).toBe(200); expect( (await f.post('auth.issueGameSession', { sessionToken: 'unknown', profile: 'che:default' })).status ).toBe(401); } finally { await f.app.close(); } }); it('maps the live record cap to HTTP 429 without invalidating existing tokens', async () => { const f = await httpFixture(); const parent = f.parents[0]!; try { const first = await store.createGameSession(parent.sessionToken, 'che:default'); for (let i = 1; i < limit; i++) await store.createGameSession(parent.sessionToken, 'che:default'); expect((await f.issue(0)).status).toBe(429); expect(await store.getGameSession('che:default', first!.gameToken)).toEqual(first); expect((await f.post('auth.logout', { sessionToken: parent.sessionToken })).status).toBe(200); expect((await f.issue(0)).status).toBe(401); expect(await client.sCard(keys(parent.sessionToken).games)).toBe(0); } finally { await f.app.close(); } }); it('preserves game records when explicitly revoking only the parent', async () => { const parent = await store.createSession(await user()); const game = await store.createGameSession(parent.sessionToken, 'che:default'); await store.revokeSession(parent.sessionToken, { revokeGames: false }); expect(await store.getSession(parent.sessionToken)).toBeNull(); expect(await store.getGameSession('che:default', game!.gameToken)).toEqual(game); }); it('atomically admits exactly 256 live records across concurrent profile requests', async () => { const session = await store.createSession(await user()); const outcomes = await Promise.allSettled( Array.from({ length: limit + 32 }, (_, i) => store.createGameSession(session.sessionToken, i % 2 ? 'che:default' : 'hwe:default') ) ); expect(outcomes.filter((x) => x.status === 'fulfilled' && x.value)).toHaveLength(limit); expect(outcomes.filter((x) => x.status === 'rejected')).toHaveLength(32); expect(await client.sCard(keys(session.sessionToken).games)).toBe(limit); }); it('removes expired membership before admitting a new game session', async () => { const session = await store.createSession(await user()); const key = keys(session.sessionToken).games; const expiredKeys = Array.from({ length: limit }, (_, i) => `${prefix}:game-session:che:default:expired-${i}`); await client.sAdd(key, expiredKeys); expect(await store.createGameSession(session.sessionToken, 'che:default')).not.toBeNull(); expect(await client.sCard(key)).toBe(1); }); it('does not revive a parent revoked after its snapshot was read', async () => { const session = await store.createSession(await user()); const get = vi.spyOn(store, 'getSession'); get.mockImplementationOnce(async () => { await store.revokeSession(session.sessionToken); return session; }); try { expect(await store.createGameSession(session.sessionToken, 'che:default')).toBeNull(); expect(await client.exists(keys(session.sessionToken).games)).toBe(0); } finally { get.mockRestore(); } }); it('preserves independent game TTL without resetting the index to a fresh parent TTL', async () => { const session = await store.createSession(await user()); const { parent, games } = keys(session.sessionToken); await client.pExpire(parent, 1000); const game = await store.createGameSession(session.sessionToken, 'che:default'); expect(game).not.toBeNull(); expect(await client.pTTL(games)).toBeGreaterThan(295_000); expect(await client.pTTL(games)).toBeLessThanOrEqual(300_000); }); it('revokes existing oversized indexes in bounded batches while preserving other parents', async () => { const session = await store.createSession(await user()); const other = await store.createSession(await user()); const gameKeys = Array.from({ length: limit + 9 }, (_, i) => `${prefix}:game-session:che:default:legacy-${i}`); const transaction = client.multi(); for (const key of gameKeys) transaction.set(key, '{}', { EX: 300 }); transaction.sAdd(keys(session.sessionToken).games, gameKeys); await transaction.exec(); await expect(store.createGameSession(session.sessionToken, 'che:default')).rejects.toThrow( 'Game session limit' ); await store.revokeSession(session.sessionToken); expect( await client.exists([keys(session.sessionToken).parent, keys(session.sessionToken).games, ...gameKeys]) ).toBe(0); expect(await store.getSession(other.sessionToken)).not.toBeNull(); }); }); describe('in-memory game session lifecycle', () => { it('enforces the same live cap and reclaims expired records', async () => { const store = new InMemoryGatewaySessionService({ sessionTtlSeconds: 600, gameSessionTtlSeconds: 1 }); const session = await store.createSession(await user()); try { vi.useFakeTimers(); for (let i = 0; i < limit; i++) expect(await store.createGameSession(session.sessionToken, 'che:default')).not.toBeNull(); await expect(store.createGameSession(session.sessionToken, 'hwe:default')).rejects.toThrow( 'Game session limit' ); vi.advanceTimersByTime(1001); expect(await store.createGameSession(session.sessionToken, 'hwe:default')).not.toBeNull(); } finally { vi.useRealTimers(); } }); it('does not issue after a concurrent revoke', async () => { const store = new InMemoryGatewaySessionService({ sessionTtlSeconds: 600, gameSessionTtlSeconds: 300 }); const session = await store.createSession(await user()); const get = vi.spyOn(store, 'getSession').mockImplementationOnce(async () => { await store.revokeSession(session.sessionToken); return session; }); try { expect(await store.createGameSession(session.sessionToken, 'che:default')).toBeNull(); } finally { get.mockRestore(); } }); });