From 1e7c61a486ab603e91dcf4484dc57f537364fa90 Mon Sep 17 00:00:00 2001 From: Adam Eivy Date: Wed, 2 Sep 2026 05:11:25 +0000 Subject: [PATCH] fix: enforce the FableLoom hosted-session HTTPS gate under the test runner (#5670) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit checkHostedSessionReadiness forced its HTTPS posture to "enabled" whenever NODE_ENV === 'test', so the one preflight check that actually blocks a hosted play session was unreachable from the suite: an HTTP-only install refusing to start a session with a 412 HOSTED_SESSION_PREFLIGHT_FAILED had never been pinned by a test and could regress silently. The same line also computed an httpsEnabled binding that nothing read. Drop both NODE_ENV escape hatches and the dead binding, and mock getNetworkExposureStatus in the tests instead — matching how the rest of the server treats that dependency. New coverage pins the HTTPS-on green path, the HTTPS-off readiness error, and the 412 refusal from createHostedSession; the socket-namespace suite gets the same TLS-provisioned snapshot because it drives the real createHostedSession. Claude-Session: https://claude.ai/code/session_01GMxEz43s3YCLaVZV9KmVwE --- server/services/fableLoom/hostedSession.js | 3 +- .../services/fableLoom/hostedSession.test.js | 44 ++++++++++++++++++- server/sockets/fableLoomHosted.test.js | 9 ++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/server/services/fableLoom/hostedSession.js b/server/services/fableLoom/hostedSession.js index 6be70e22ce..23c27a56d8 100644 --- a/server/services/fableLoom/hostedSession.js +++ b/server/services/fableLoom/hostedSession.js @@ -113,12 +113,11 @@ export async function checkHostedSessionReadiness({ loomId, episodeId, loom: cus // 1. HTTPS & Network Exposure check const netStatus = getNetworkExposureStatus(); - const httpsEnabled = netStatus.httpsEnabled === true || process.env.NODE_ENV === 'test'; const joinHost = netStatus.cert?.tailscaleHost || (netStatus.bind?.host && !isLoopbackHost(netStatus.bind.host) && netStatus.bind.host !== '0.0.0.0' ? netStatus.bind.host : null) || 'localhost'; const joinPort = netStatus.bind?.port || PORTS.API; - const isHttps = netStatus.scheme === 'https' || process.env.NODE_ENV === 'test'; + const isHttps = netStatus.scheme === 'https'; const httpsUrl = isHttps ? `https://${joinHost}${joinPort === 443 ? '' : `:${joinPort}`}` : `http://${joinHost}${joinPort === 80 ? '' : `:${joinPort}`}`; diff --git a/server/services/fableLoom/hostedSession.test.js b/server/services/fableLoom/hostedSession.test.js index babfb92746..2f5784a3ac 100644 --- a/server/services/fableLoom/hostedSession.test.js +++ b/server/services/fableLoom/hostedSession.test.js @@ -67,9 +67,27 @@ describe('fableLoom hostedSession', () => { }], }; + // Hosted-session preflight gates on the live network posture. Every test + // that isn't specifically exercising the HTTPS gate runs against this + // TLS-provisioned snapshot so the rest of the readiness checks are what + // the assertion is about. + const httpsExposure = () => ({ + scheme: 'https', + httpsEnabled: true, + bind: { host: '0.0.0.0', port: 5555, audience: 'all-interfaces' }, + cert: { mode: 'tailscale', tailscaleHost: 'host-example.example-tailnet.ts.net' }, + }); + + const httpExposure = () => ({ + ...httpsExposure(), + scheme: 'http', + httpsEnabled: false, + }); + beforeEach(() => { _resetHostedSessions(); vi.restoreAllMocks(); + vi.spyOn(networkExposure, 'getNetworkExposureStatus').mockImplementation(httpsExposure); vi.spyOn(records, 'getLoom').mockResolvedValue(mockLoom); vi.spyOn(tts, 'synthesize').mockResolvedValue({ wav: Buffer.from('RIFFmockwavdata'), @@ -97,13 +115,27 @@ describe('fableLoom hostedSession', () => { }); describe('checkHostedSessionReadiness', () => { - it('passes readiness when loom, episode, and start scene are configured', async () => { + it('passes readiness when loom, episode, and start scene are configured over HTTPS', async () => { const result = await checkHostedSessionReadiness({ loomId: 'loom-1', episodeId: 'ep-1' }); expect(result.ready).toBe(true); - expect(result.https.url).toMatch(/^https?:\/\//); + expect(result.https.enabled).toBe(true); + expect(result.https.url).toMatch(/^https:\/\//); + expect(result.checks.https.ok).toBe(true); expect(result.checks.host.ok).toBe(true); }); + it('flags error when the install is serving plain HTTP', async () => { + vi.spyOn(networkExposure, 'getNetworkExposureStatus').mockImplementation(httpExposure); + const result = await checkHostedSessionReadiness({ loomId: 'loom-1', episodeId: 'ep-1' }); + expect(result.ready).toBe(false); + expect(result.https.enabled).toBe(false); + expect(result.checks.https.ok).toBe(false); + expect(result.https.url).toMatch(/^http:\/\//); + expect(result.errors).toContain( + 'HTTPS is required for mobile device QR microphone join (run npm run setup:cert to enable TLS).', + ); + }); + it('flags error if start scene is missing', async () => { const badLoom = { ...mockLoom, @@ -142,6 +174,14 @@ describe('fableLoom hostedSession', () => { expect(verifyHostedToken(result.session.id, 'wrong-token')).toBe(false); expect(verifyHostedToken('missing-session', result.token)).toBe(false); }); + + it('refuses to start a session on an HTTP-only install with a 412 preflight failure', async () => { + vi.spyOn(networkExposure, 'getNetworkExposureStatus').mockImplementation(httpExposure); + await expect(createHostedSession('loom-1', 'ep-1', { audioTarget: 'host' })).rejects.toMatchObject({ + status: 412, + code: 'HOSTED_SESSION_PREFLIGHT_FAILED', + }); + }); }); describe('revalidateLiveConversationGate', () => { diff --git a/server/sockets/fableLoomHosted.test.js b/server/sockets/fableLoomHosted.test.js index 9256344215..55166edb0b 100644 --- a/server/sockets/fableLoomHosted.test.js +++ b/server/sockets/fableLoomHosted.test.js @@ -9,6 +9,7 @@ import { getHostedSession, } from '../services/fableLoom/hostedSession.js'; import * as records from '../services/fableLoom/records.js'; +import * as networkExposure from '../lib/networkExposure.js'; import * as tts from '../services/voice/tts.js'; import * as stt from '../services/voice/stt.js'; @@ -45,6 +46,14 @@ describe('fableLoomHosted Socket.IO namespace', () => { beforeEach(() => { _resetHostedSessions(); vi.restoreAllMocks(); + // createHostedSession runs the readiness preflight, which refuses to start + // a session unless the install is serving HTTPS. + vi.spyOn(networkExposure, 'getNetworkExposureStatus').mockReturnValue({ + scheme: 'https', + httpsEnabled: true, + bind: { host: '0.0.0.0', port: 5555, audience: 'all-interfaces' }, + cert: { mode: 'tailscale', tailscaleHost: 'host-example.example-tailnet.ts.net' }, + }); vi.spyOn(records, 'getLoom').mockResolvedValue(mockLoom); vi.spyOn(tts, 'synthesize').mockResolvedValue({ wav: Buffer.from('mockwav'), latencyMs: 20 }); vi.spyOn(stt, 'transcribe').mockResolvedValue({ text: 'go next', latencyMs: 50 });