test(runtime): verify live same-origin canary
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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<void>> = [];
|
||||
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<ProductionControlPlaneOptions> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user