import { FakeTransport } from '../../test-helpers/FakeTransport.js'; import { ICMType, ISMType } from './messaging.js'; import { SessionServer } from './SessionServer.js'; type ServerMessage = { type: 'tick'; data: number }; type ClientMessage = { type: 'move'; data: string }; const connect = (transport: FakeTransport) => transport.receiveJson({ type: ICMType.AMQ_CONNECT }); const reconnect = (transport: FakeTransport, id: unknown) => transport.receiveJson({ type: ICMType.AMQ_RECONNECT, data: id }); describe('SessionServer', () => { let sut: SessionServer; beforeEach(() => { sut = new SessionServer(); vi.spyOn(console, 'error').mockImplementation(() => {}); }); afterEach(() => { vi.restoreAllMocks(); }); describe('the handshake', () => { it('emits nothing until the client announces itself', () => { const onConnection = vi.fn(); sut.onConnection(onConnection); sut.accept(new FakeTransport()); expect(onConnection).not.toHaveBeenCalled(); }); it('emits a connection on AMQ_CONNECT', () => { const transport = new FakeTransport(); const onConnection = vi.fn(); sut.onConnection(onConnection); sut.accept(transport); connect(transport); expect(onConnection).toHaveBeenCalledTimes(1); }); it('tells the client which id it got', () => { const transport = new FakeTransport(); sut.accept(transport); connect(transport); expect(transport.lastSentJson.type).toBe(ISMType.AMQ_CONNECTED); expect(transport.lastSentJson.data).toBeDefined(); }); it('gives each client a distinct id', () => { const first = new FakeTransport(); const second = new FakeTransport(); sut.accept(first); connect(first); sut.accept(second); connect(second); expect(first.lastSentJson.data).not.toBe(second.lastSentJson.data); }); it('complains about an unexpected first message', () => { const transport = new FakeTransport(); const onConnection = vi.fn(); sut.onConnection(onConnection); sut.accept(transport); transport.receiveJson({ type: 'move', data: 'north' }); expect(console.error).toHaveBeenCalled(); expect(onConnection).not.toHaveBeenCalled(); }); it('stops listening for handshakes once one lands', () => { const transport = new FakeTransport(); sut.accept(transport); const duringHandshake = transport.listenerCount; connect(transport); // The connection binds its own two listeners; the handshake's are gone. expect(transport.listenerCount).toBe(duringHandshake); }); }); describe('a client coming back', () => { it('is bound to the same connection it had before', () => { const first = new FakeTransport(); sut.accept(first); connect(first); const id = first.lastSentJson.data; const connections: unknown[] = []; sut.onConnection(c => connections.push(c)); const second = new FakeTransport(); sut.accept(second); reconnect(second, id); expect(second.lastSentJson.data).toBe(id); expect(connections).toHaveLength(0); // not a new client }); it('keeps the listeners registered before the drop', () => { const first = new FakeTransport(); let received: string[] = []; sut.onConnection(client => client.onMessageType('move', data => received.push(data)), ); sut.accept(first); connect(first); first.receiveJson({ type: 'move', data: 'north' }); first.drop(); const second = new FakeTransport(); sut.accept(second); reconnect(second, first.lastSentJson.data); second.receiveJson({ type: 'move', data: 'south' }); expect(received).toEqual(['north', 'south']); }); it('sends over the new socket, not the dead one', () => { const first = new FakeTransport(); let client!: { send: (t: 'tick', d: number) => void }; sut.onConnection(c => (client = c)); sut.accept(first); connect(first); first.drop(); const second = new FakeTransport(); sut.accept(second); reconnect(second, first.lastSentJson.data); const sentToFirst = first.sent.length; client.send('tick', 42); expect(first.sent).toHaveLength(sentToFirst); expect(second.lastSentJson).toEqual({ type: 'tick', data: 42 }); }); it('gets a fresh session if its id is unknown', () => { const transport = new FakeTransport(); const onConnection = vi.fn(); sut.onConnection(onConnection); sut.accept(transport); reconnect(transport, 'never-seen'); // Not adopted: a new id is issued and it counts as a new client. expect(transport.lastSentJson.data).not.toBe('never-seen'); expect(onConnection).toHaveBeenCalledTimes(1); }); it('cannot be told which id to use', () => { const transport = new FakeTransport(); sut.accept(transport); reconnect(transport, 'chosen-by-the-client'); expect(transport.lastSentJson.data).not.toBe('chosen-by-the-client'); }); }); // The hole this closed: ids were `++lastId`, so 1, 2, 3, and a reconnect naming // someone else's id was handed their session with no secret involved. describe('session ids', () => { const idsFrom = (server: SessionServer, n: number) => Array.from({ length: n }, () => { const transport = new FakeTransport(); server.accept(transport); connect(transport); return transport.lastSentJson.data as string; }); it('are not sequential, and not derivable from each other', () => { const ids = idsFrom(sut, 5); expect(new Set(ids).size).toBe(5); expect(ids).not.toContain('1'); expect(ids.every(id => !Number.isInteger(Number(id)))).toBe(true); }); it('carry enough randomness to be unguessable', () => { // A v4 UUID: 122 random bits. Enough that guessing is not a strategy. const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; for (const id of idsFrom(sut, 3)) expect(id).toMatch(uuid); }); it('do not repeat across servers', () => { const other = new SessionServer(); const mine = idsFrom(sut, 3); const theirs = idsFrom(other, 3); expect(mine.filter(id => theirs.includes(id))).toEqual([]); }); // The id is a *bearer* credential: whoever presents it is that client, the // same contract as a session cookie. Unguessability is the whole defence — // there is nothing else to tell a reconnecting client from a thief, and // pretending otherwise would be the wrong thing to encode in a test. // // Which is why this is asserted rather than left implied: a leaked id is a // taken-over session, so it must not be logged, put in a URL or persisted // anywhere a third party reads. it('is a bearer token: presenting it takes over the session', () => { const received: string[] = []; sut.onConnection(client => client.onMessageType('move', data => received.push(data)), ); const first = new FakeTransport(); sut.accept(first); connect(first); const id = first.lastSentJson.data; const second = new FakeTransport(); sut.accept(second); reconnect(second, id); second.receiveJson({ type: 'move', data: 'still me' }); expect(second.lastSentJson.data).toBe(id); expect(received).toEqual(['still me']); }); it('rebinding moves the session off the previous socket', () => { let client!: { send: (t: 'tick', d: number) => void }; sut.onConnection(c => (client = c as any)); const first = new FakeTransport(); sut.accept(first); connect(first); const id = first.lastSentJson.data; const second = new FakeTransport(); sut.accept(second); reconnect(second, id); const sentToFirst = first.sent.length; client.send('tick', 1); expect(first.sent).toHaveLength(sentToFirst); expect(second.lastSentJson).toEqual({ type: 'tick', data: 1 }); }); }); describe('messages', () => { it('routes by type and also emits them all', () => { const transport = new FakeTransport(); const byType = vi.fn(); const all = vi.fn(); sut.onConnection(client => { client.onMessageType('move', byType); client.onMessage(all); }); sut.accept(transport); connect(transport); transport.receiveJson({ type: 'move', data: 'east' }); expect(byType).toHaveBeenCalledWith('east'); expect(all).toHaveBeenCalledWith({ type: 'move', data: 'east' }); }); it('destroys the session on AMQ_DISCONNECT', () => { const transport = new FakeTransport(); const onDestroy = vi.fn(); sut.onConnection(client => client.onDestroy(onDestroy)); sut.accept(transport); connect(transport); transport.receiveJson({ type: ICMType.AMQ_DISCONNECT, data: null }); expect(onDestroy).toHaveBeenCalledTimes(1); }); }); });