import { describe, it, expect, afterEach, beforeAll, afterAll } from 'vitest'; import jwt from 'jsonwebtoken'; import type { Socket } from 'socket.io'; import { MessageGateway } from './message.gateway'; import type { MessageService, MessagePrincipal } from './message.service'; import { SessionVerifier } from '../platform/session.verifier'; import type { OutboxBus } from '../outbox/outbox.bus'; import type { PresenceService } from '../notifications/presence.service'; // Realtime delegation: the browser opens the socket with a short-lived token minted for the // realtime audience (iios-message). When IIOS_REALTIME_AUDIENCE is set, the gateway accepts ONLY // that audience — a full REST/actor token (iios-core) or an un-scoped token can't open the stream. const APP = 'crm-web'; const SECRET = 'dev-crm-secret'; // ─── the real SessionVerifier must expose `aud` so the gateway can enforce it ─── describe('SessionVerifier — app-token audience surfacing', () => { let v: SessionVerifier; beforeAll(async () => { delete process.env.AUTH_ISSUERS; delete process.env.SUPABASE_URL; process.env.APP_SECRETS = JSON.stringify({ [APP]: SECRET }); v = new SessionVerifier(); await v.onModuleInit(); }); afterAll(() => { delete process.env.APP_SECRETS; }); it('surfaces the aud claim of a realtime-scoped token', () => { const tok = jwt.sign({ appId: APP, aud: 'iios-message' }, SECRET, { algorithm: 'HS256', subject: 'pp_1', expiresIn: '5m' }); expect(v.verify(tok).audience).toBe('iios-message'); }); it('leaves audience undefined for an un-scoped token (a REST/actor token)', () => { const tok = jwt.sign({ appId: APP }, SECRET, { algorithm: 'HS256', subject: 'pp_1', expiresIn: '5m' }); expect(v.verify(tok).audience).toBeUndefined(); }); }); // ─── the gateway enforces the realtime audience on connect ─── function fakeSocket(): { sock: Socket; disconnected: () => boolean } { let disconnected = false; const sock = { handshake: { auth: { token: 'tok' } }, disconnect: () => { disconnected = true; }, data: undefined as unknown, } as unknown as Socket; return { sock, disconnected: () => disconnected }; } function gatewayReturning(principal: MessagePrincipal): MessageGateway { const session = { verify: () => principal } as unknown as SessionVerifier; return new MessageGateway( undefined as unknown as MessageService, session, undefined as unknown as OutboxBus, undefined as unknown as PresenceService, ); } const principal = (audience?: string): MessagePrincipal => ({ userId: 'u', appId: APP, orgId: 'org', audience }); describe('MessageGateway — realtime audience enforcement', () => { afterEach(() => { delete process.env.IIOS_REALTIME_AUDIENCE; }); it('accepts any valid token when enforcement is OFF (env unset)', () => { const { sock, disconnected } = fakeSocket(); gatewayReturning(principal(undefined)).handleConnection(sock); expect(disconnected()).toBe(false); expect((sock.data as { principal: MessagePrincipal }).principal.userId).toBe('u'); }); it('accepts a token whose aud matches the required realtime audience', () => { process.env.IIOS_REALTIME_AUDIENCE = 'iios-message'; const { sock, disconnected } = fakeSocket(); gatewayReturning(principal('iios-message')).handleConnection(sock); expect(disconnected()).toBe(false); }); it('rejects a full REST/actor token (aud=iios-core) on the socket', () => { process.env.IIOS_REALTIME_AUDIENCE = 'iios-message'; const { sock, disconnected } = fakeSocket(); gatewayReturning(principal('iios-core')).handleConnection(sock); expect(disconnected()).toBe(true); }); it('rejects an un-scoped token (no aud) when enforcement is on', () => { process.env.IIOS_REALTIME_AUDIENCE = 'iios-message'; const { sock, disconnected } = fakeSocket(); gatewayReturning(principal(undefined)).handleConnection(sock); expect(disconnected()).toBe(true); }); });