From 3718c91706dab0741f33c8be9f0bdf8f9fa34fa2 Mon Sep 17 00:00:00 2001 From: chick Date: Sat, 18 Jul 2026 15:45:57 +0800 Subject: [PATCH] test(runtime): verify live same-origin canary --- apps/api/src/canary-gateway.test.ts | 8 ++ apps/api/src/canary-gateway.ts | 8 ++ apps/api/src/production-control-plane.test.ts | 73 +++++++++++++++++++ apps/api/src/production-control-plane.ts | 4 + scripts/real-browser-e2e-origin.mjs | 34 +++++++++ scripts/real-browser-e2e.mjs | 37 ++++++++-- test/real-browser-e2e-origin.test.js | 27 +++++++ 7 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 apps/api/src/production-control-plane.test.ts create mode 100644 scripts/real-browser-e2e-origin.mjs create mode 100644 test/real-browser-e2e-origin.test.js diff --git a/apps/api/src/canary-gateway.test.ts b/apps/api/src/canary-gateway.test.ts index 1c967b4..ed66564 100644 --- a/apps/api/src/canary-gateway.test.ts +++ b/apps/api/src/canary-gateway.test.ts @@ -179,6 +179,14 @@ describe('same-origin canary gateway', () => { expect(route.headers.get('content-type')).toMatch(/^text\/html/); expect(await route.text()).toContain('canary'); + const favicon = await fetch(`${origin}/favicon.ico`); + expect(favicon.status).toBe(204); + expect(await favicon.text()).toBe(''); + + const unknownFile = await fetch(`${origin}/missing.ico`); + expect(unknownFile.status).toBe(404); + expect(await unknownFile.text()).not.toContain('canary'); + const unknownApi = await fetch(`${origin}/api/not-real`); expect(unknownApi.status).toBe(404); expect(await unknownApi.text()).not.toContain('canary'); diff --git a/apps/api/src/canary-gateway.ts b/apps/api/src/canary-gateway.ts index 834e319..bde6df9 100644 --- a/apps/api/src/canary-gateway.ts +++ b/apps/api/src/canary-gateway.ts @@ -256,6 +256,14 @@ export function createCanaryGateway(options: CanaryGatewayOptions): CanaryGatewa return sendText(response, 405, 'Method Not Allowed'); } + // Browsers request this implicitly. Avoid a noisy console 404 without routing + // this file-like path through the SPA fallback. + if (pathname === '/favicon.ico') { + response.writeHead(204, { 'cache-control': 'no-cache' }); + response.end(); + return; + } + const distRoot = await realpath(resolve(options.distDir)).catch(() => resolve(options.distDir)); if (pathname !== '/' && (await serveFile(request, response, distRoot, pathname))) return; if ( diff --git a/apps/api/src/production-control-plane.test.ts b/apps/api/src/production-control-plane.test.ts new file mode 100644 index 0000000..42d62e9 --- /dev/null +++ b/apps/api/src/production-control-plane.test.ts @@ -0,0 +1,73 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { + buildProductionControlPlane, + type ProductionControlPlaneOptions, +} from './production-control-plane.js'; +import { GATEWAY_AUTH_HEADER } from './runtime-config.js'; + +const cleanup: Array<() => Promise> = []; +const gatewayToken = 'production-gateway-token-with-at-least-32-bytes'; + +afterEach(async () => { + await Promise.all(cleanup.splice(0).map((fn) => fn())); +}); + +async function fixtureOptions(): Promise { + const directory = await mkdtemp(join(tmpdir(), 'production-control-plane-')); + cleanup.push(() => rm(directory, { recursive: true, force: true })); + return { + databasePath: join(directory, 'db.sqlite'), + gatewayToken, + store: { + set: async () => '', + get: async () => undefined, + delete: async () => false, + }, + upstream: { + get: async () => ({ status: 200, headers: {}, body: '' }), + request: async () => ({ status: 200, headers: {}, body: '' }), + postNetworkRegisterAuto: async () => ({ status: 200 }), + }, + keychainMetadataCheck: async () => true, + }; +} + +describe('production control-plane authentication composition', () => { + it('keeps the event route fail-closed when trusted-gateway auth is not configured', async () => { + const options = await fixtureOptions(); + const app = buildProductionControlPlane({ + databasePath: options.databasePath, + store: options.store!, + upstream: options.upstream!, + keychainMetadataCheck: options.keychainMetadataCheck!, + }); + cleanup.push(() => app.close()); + + const response = await app.inject({ method: 'GET', url: '/api/v1/events' }); + expect(response.statusCode).toBe(401); + expect(response.json()).toMatchObject({ code: 'UNAUTHORIZED' }); + }); + + it('opens SSE only after mandatory trusted-gateway authentication succeeds', async () => { + const app = buildProductionControlPlane(await fixtureOptions()); + cleanup.push(() => app.close()); + + const direct = await app.inject({ method: 'GET', url: '/api/v1/events' }); + expect(direct.statusCode).toBe(401); + expect(direct.json()).toMatchObject({ code: 'GATEWAY_AUTH_REQUIRED' }); + + await app.listen({ host: '127.0.0.1', port: 0 }); + const address = app.server.address(); + if (!address || typeof address === 'string') throw new Error('Expected TCP server address'); + const response = await fetch(`http://127.0.0.1:${address.port}/api/v1/events`, { + headers: { [GATEWAY_AUTH_HEADER]: gatewayToken }, + }); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toMatch(/^text\/event-stream/); + await response.body?.cancel(); + }); +}); diff --git a/apps/api/src/production-control-plane.ts b/apps/api/src/production-control-plane.ts index 6007361..1768e8c 100644 --- a/apps/api/src/production-control-plane.ts +++ b/apps/api/src/production-control-plane.ts @@ -37,6 +37,10 @@ export function buildProductionControlPlane( db, store, upstream: options.upstream ?? createProductionUpstream(), + // This route-local decision is safe only in this composition: when a token is + // configured, buildApp's global onRequest hook rejects the request first. + // Keep all other control-plane compositions fail-closed by default. + ...(options.gatewayToken ? { authenticateEventStream: () => true } : {}), app: { logger: {}, ...(options.gatewayToken ? { gatewayToken: options.gatewayToken } : {}), diff --git a/scripts/real-browser-e2e-origin.mjs b/scripts/real-browser-e2e-origin.mjs new file mode 100644 index 0000000..922a0f3 --- /dev/null +++ b/scripts/real-browser-e2e-origin.mjs @@ -0,0 +1,34 @@ +const FORBIDDEN_PORT = 8788; + +export function parseExternalE2eOrigin(value) { + if (value === undefined) return undefined; + if (value === '') throw new Error('E2E_ORIGIN must not be empty when set.'); + + let url; + try { + url = new URL(value); + } catch { + throw new Error('E2E_ORIGIN must be a valid absolute URL.'); + } + + if ( + url.protocol !== 'http:' || + url.hostname !== '127.0.0.1' || + !url.port || + url.username || + url.password || + url.pathname !== '/' || + url.search || + url.hash + ) { + throw new Error( + 'E2E_ORIGIN must be an HTTP origin on numeric loopback with an explicit port (for example http://127.0.0.1:8789).', + ); + } + + if (Number(url.port) === FORBIDDEN_PORT) { + throw new Error(`E2E_ORIGIN must not use forbidden legacy port ${FORBIDDEN_PORT}.`); + } + + return url.origin; +} diff --git a/scripts/real-browser-e2e.mjs b/scripts/real-browser-e2e.mjs index f44090a..67a48e6 100644 --- a/scripts/real-browser-e2e.mjs +++ b/scripts/real-browser-e2e.mjs @@ -10,10 +10,13 @@ import process from 'node:process'; import { setTimeout as delay } from 'node:timers/promises'; import { fileURLToPath } from 'node:url'; +import { parseExternalE2eOrigin } from './real-browser-e2e-origin.mjs'; + const { WebSocket } = globalThis; const DIST = fileURLToPath(new URL('../apps/web/dist/', import.meta.url)); const CLEAN_DIST = process.argv.includes('--clean-dist'); const FORBIDDEN_PORT = 8788; +const EXTERNAL_ORIGIN = parseExternalE2eOrigin(process.env.E2E_ORIGIN); const CHROME_CANDIDATES = [ process.env.CHROME_BIN, @@ -276,9 +279,12 @@ let profile; try { const chromeBinary = await executableChrome(); - http = createHttpServer((request, response) => void builtAssetHandler(request, response)); - const serverPort = await listenOnSafeEphemeralPort(http); - const origin = `http://127.0.0.1:${serverPort}`; + let origin = EXTERNAL_ORIGIN; + if (!origin) { + http = createHttpServer((request, response) => void builtAssetHandler(request, response)); + const serverPort = await listenOnSafeEphemeralPort(http); + origin = `http://127.0.0.1:${serverPort}`; + } profile = await mkdtemp(join(tmpdir(), 'multi-simadmin-chrome-')); chrome = spawn( @@ -327,8 +333,23 @@ try { const unknownApi = await fetch(`${origin}/api/v1/not-a-real-route`); assert.equal(unknownApi.status, 404, 'Unknown API routes must not receive the SPA shell'); - assert.match(unknownApi.headers.get('content-type') ?? '', /^application\/json\b/u); - assert.deepEqual(await unknownApi.json(), { error: 'Not Found', statusCode: 404 }); + const unknownApiContentType = unknownApi.headers.get('content-type') ?? ''; + assert.doesNotMatch(unknownApiContentType, /^text\/html\b/u); + assert.match(unknownApiContentType, /^application\/(?:problem\+)?json\b/u); + const unknownApiProblem = await unknownApi.json(); + if (EXTERNAL_ORIGIN) { + assert( + unknownApiProblem && + typeof unknownApiProblem === 'object' && + (unknownApiProblem.status === 404 || + unknownApiProblem.statusCode === 404 || + typeof unknownApiProblem.title === 'string' || + typeof unknownApiProblem.type === 'string'), + 'Unknown external API route must return a JSON Problem Details object', + ); + } else { + assert.deepEqual(unknownApiProblem, { error: 'Not Found', statusCode: 404 }); + } const loaded = cdp.once('Page.loadEventFired'); await cdp.send('Page.navigate', { url: `${origin}/fleet` }); @@ -384,7 +405,11 @@ try { assert.deepEqual(failures, [], `Browser failures detected:\n${failures.join('\n')}`); console.log(`PASS real Chrome E2E (${chromeBinary})`); - console.log(`PASS isolated built-asset server on ${origin} (legacy port 8788 untouched)`); + console.log( + EXTERNAL_ORIGIN + ? `PASS live same-origin canary on ${origin} (legacy port 8788 untouched)` + : `PASS isolated built-asset server on ${origin} (legacy port 8788 untouched)`, + ); console.log( 'PASS keyboard navigation and rendered API states: /fleet -> /jobs -> /audit -> /settings/instances', ); diff --git a/test/real-browser-e2e-origin.test.js b/test/real-browser-e2e-origin.test.js new file mode 100644 index 0000000..2f3f2ed --- /dev/null +++ b/test/real-browser-e2e-origin.test.js @@ -0,0 +1,27 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { parseExternalE2eOrigin } from '../scripts/real-browser-e2e-origin.mjs'; + +test('parseExternalE2eOrigin preserves default mode when E2E_ORIGIN is unset', () => { + assert.equal(parseExternalE2eOrigin(undefined), undefined); +}); + +test('parseExternalE2eOrigin accepts an explicit numeric loopback HTTP origin', () => { + assert.equal(parseExternalE2eOrigin('http://127.0.0.1:8789'), 'http://127.0.0.1:8789'); +}); + +test('parseExternalE2eOrigin rejects the legacy port and non-loopback or non-origin URLs', () => { + for (const value of [ + '', + 'http://127.0.0.1:8788', + 'http://localhost:8789', + 'http://0.0.0.0:8789', + 'http://192.168.1.10:8789', + 'https://127.0.0.1:8789', + 'http://127.0.0.1:8789/fleet', + 'http://user:secret@127.0.0.1:8789', + ]) { + assert.throws(() => parseExternalE2eOrigin(value), /E2E_ORIGIN/u, value); + } +});