From f969faa28fcdb1097d73a325103102f7e304641f Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Tue, 25 Aug 2026 11:21:35 -0400 Subject: [PATCH] fix: testing for peer --- .../controllers/peer/PeerController.test.ts | 56 ++++++++++- src/puter-js/src/modules/Peer.turn.test.js | 99 +++++++++++++++++++ 2 files changed, 154 insertions(+), 1 deletion(-) diff --git a/src/backend/controllers/peer/PeerController.test.ts b/src/backend/controllers/peer/PeerController.test.ts index a91ca4fca..fc84f1d0e 100644 --- a/src/backend/controllers/peer/PeerController.test.ts +++ b/src/backend/controllers/peer/PeerController.test.ts @@ -38,7 +38,9 @@ const makeReq = (init: { method?: string; }): Request => { return { - body: init.body ?? {}, + // Distinguish "no body key" from an explicit `body: undefined`, so a + // test can exercise a request that never had a parsed body at all. + body: 'body' in init ? init.body : {}, query: {}, headers: init.headers ?? {}, actor: init.actor, @@ -912,6 +914,58 @@ describe('PeerController guest TURN', () => { }); }); + describe('default lifetimes', () => { + // The defaults are the security-relevant knob — a deployment that sets + // only the secret still gets short-lived grants and credentials, and a + // guest credential still cannot outlive the host's own. + it('falls back to an hour for grants and guest credentials', async () => { + const defaultsServer = await setupTestServer({ + peers: { + turn: { + cloudflare_turn_service_id: 'svc-1', + cloudflare_turn_api_token: 'token-1', + ttl: 86_400, + }, + guest_turn: { grant_secret: GRANT_SECRET }, + }, + } as never); + const fetchSpy = stubCloudflare(); + try { + const router = new PuterRouter(); + ( + defaultsServer.controllers.peer as unknown as PeerController + ).registerRoutes(router); + const handlerFor = (path: string) => + router.routes.find((r) => r.path === path)!.handler; + + const grantRes = makeRes(); + handlerFor('/peer/turn-grant')( + makeReq({ actor: hostActor }), + grantRes.res, + ); + const { grant, expiresAt } = grantRes.captured.body as { + grant: string; + expiresAt: number; + }; + const grantTtl = expiresAt - Math.floor(Date.now() / 1000); + expect(grantTtl).toBeGreaterThan(3590); + expect(grantTtl).toBeLessThanOrEqual(3600); + + const turnRes = makeRes(); + await handlerFor('/peer/guest-turn')( + makeReq({ body: { grant } }), + turnRes.res, + ); + // An hour, not the host's 24 — the guest ceiling wins here. + expect(turnRes.captured.body).toMatchObject({ ttl: 3600 }); + expect(upstreamBody(fetchSpy).ttl).toBe(3600); + } finally { + fetchSpy.mockRestore(); + await defaultsServer.shutdown(); + } + }); + }); + describe('when guest access is not configured', () => { it('refuses to issue or redeem a grant', async () => { const noGuestServer = await setupTestServer({ diff --git a/src/puter-js/src/modules/Peer.turn.test.js b/src/puter-js/src/modules/Peer.turn.test.js index 7aad006dc..75f132e86 100644 --- a/src/puter-js/src/modules/Peer.turn.test.js +++ b/src/puter-js/src/modules/Peer.turn.test.js @@ -264,6 +264,105 @@ class FakeRTCPeerConnection { close () {} } +/** Polls until `pred` holds, for handshakes that resolve across microtasks. */ +const waitFor = async (pred, tries = 50) => { + for ( let i = 0; i < tries; i++ ) { + if ( pred() ) return; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error('condition never became true'); +}; + +describe('serve as a host', () => { + const origWebSocket = globalThis.WebSocket; + + beforeEach(() => { + FakeWebSocket.latest = null; + globalThis.WebSocket = FakeWebSocket; + }); + + afterEach(() => { + globalThis.WebSocket = origWebSocket; + }); + + const signaller = respond({ + url: 'ws://signaller.test/', + fallbackIce: [{ urls: 'stun:fallback.test' }], + }); + + /** Drives the signaller's create handshake and resolves the invite code. */ + const startServing = async (peer, options) => { + const started = peer.serve(options); + await waitFor(() => FakeWebSocket.latest?.onmessage); + await FakeWebSocket.latest.onmessage({ + data: JSON.stringify({ + server: { create: { success: true, invitecode: 'HOST-1234' } }, + }), + }); + return await started; + }; + + it('mints relays against the host session', async () => { + routeFetch({ + '/peer/signaller-info': signaller, + '/peer/generate-turn': respond({ + iceServers: HOST_SERVERS, + ttl: 3600, + }), + }); + const { peer, puter } = makePeer({ authToken: 'host-token' }); + + const server = await startServing(peer); + + expect(server.inviteCode).toBe('HOST-1234'); + expect(puter.ui.authenticateWithPuter).not.toHaveBeenCalled(); + expect(callTo('/peer/generate-turn')).toBeDefined(); + expect(callTo('/peer/guest-turn')).toBeUndefined(); + + const sent = JSON.parse(FakeWebSocket.latest.sent[0]); + expect(sent.server.create.authToken).toBe('host-token'); + }); + + it('prompts an unauthenticated host to sign in', async () => { + routeFetch({ + '/peer/signaller-info': signaller, + '/peer/generate-turn': respond({ + iceServers: HOST_SERVERS, + ttl: 3600, + }), + }); + const { peer, puter } = makePeer(); + + await startServing(peer); + + expect(puter.ui.authenticateWithPuter).toHaveBeenCalledTimes(1); + }); + + it('hosts anonymously without relays of its own', async () => { + // An anonymous host has no account to attribute relay usage to, so it + // gets the public ICE servers and no sign-in prompt. + routeFetch({ + '/peer/signaller-info': signaller, + '/peer/generate-turn': respond({}, false), + }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { peer, puter } = makePeer(); + try { + await startServing(peer, { + anonToken: '11111111-2222-3333-4444-555555555555', + }); + } finally { + warn.mockRestore(); + } + + expect(puter.ui.authenticateWithPuter).not.toHaveBeenCalled(); + const sent = JSON.parse(FakeWebSocket.latest.sent[0]); + expect(sent.server.create.anonToken).toBe( + '11111111-2222-3333-4444-555555555555', + ); + }); +}); + describe('connect as a guest', () => { const origWebSocket = globalThis.WebSocket; const origRTC = globalThis.RTCPeerConnection;