171 lines
9.6 KiB
TypeScript
171 lines
9.6 KiB
TypeScript
import { createServer } from 'node:net';
|
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
|
|
const capabilities = 'asset_search,asset_valuation';
|
|
const safeEnv = { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: process.env.HOME ?? '/tmp', TMPDIR: process.env.TMPDIR ?? '/tmp' };
|
|
async function freePort(): Promise<number> {
|
|
return await new Promise((resolve, reject) => {
|
|
const probe = createServer();
|
|
probe.once('error', reject);
|
|
probe.listen(0, '127.0.0.1', () => {
|
|
const address = probe.address();
|
|
const port = typeof address === 'object' && address ? address.port : 0;
|
|
probe.close((error) => error ? reject(error) : resolve(port));
|
|
});
|
|
});
|
|
}
|
|
const port = await freePort();
|
|
const base = `http://127.0.0.1:${port}`;
|
|
const releaseId = `gate-${crypto.randomUUID()}`;
|
|
const referenceDir = await mkdtemp(join(tmpdir(), 'eden-gate-reference-'));
|
|
let server: Bun.Subprocess | undefined;
|
|
let trap: ReturnType<typeof Bun.serve> | undefined;
|
|
async function run(args: string[], env: Record<string, string>): Promise<void> {
|
|
const child = Bun.spawn(args, { env: { ...safeEnv, ...env }, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore' });
|
|
if (await child.exited !== 0) throw new Error(`Command failed: ${args.join(' ')}`);
|
|
}
|
|
async function waitForServer(baseUrl: string, expectedReleaseId: string): Promise<void> {
|
|
const deadline = Date.now() + 30_000;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const response = await fetch(baseUrl);
|
|
if (response.status === 200 && response.headers.get('x-application-release') === expectedReleaseId) return;
|
|
} catch { /* startup race */ }
|
|
await Bun.sleep(250);
|
|
}
|
|
throw new Error('Rollout-gate smoke server did not become ready');
|
|
}
|
|
async function assertStatus(response: Response, expected: number, label: string): Promise<void> {
|
|
if (response.status !== expected) throw new Error(`${label}: expected ${expected}, got ${response.status}; ${await response.clone().text()}`);
|
|
}
|
|
try {
|
|
await run(['bun', 'workers/reference.ts', '--fixture', '--directory', referenceDir], { APP_MODE: 'demo', REFERENCE_DIR: referenceDir });
|
|
server = Bun.spawn(['bun', 'build/index.js'], {
|
|
env: {
|
|
...safeEnv, APP_MODE: 'demo', APP_ORIGIN: base, ORIGIN: base, HOST: '127.0.0.1', PORT: String(port),
|
|
REFERENCE_DIR: referenceDir, APP_RELEASE_ID: releaseId, APP_CAPABILITY_ALLOWLIST: capabilities,
|
|
TYPESAFE_API_KEY: 'sentinel-not-used-when-assistant-is-disabled',
|
|
}, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore',
|
|
});
|
|
await waitForServer(base, releaseId);
|
|
const headers = { origin: base, 'content-type': 'application/json' };
|
|
const chatRequest = () => fetch(`${base}/api/chat`, { method: 'POST', headers, body: JSON.stringify({ message: '/value', requestId: crypto.randomUUID() }) });
|
|
const chat = await chatRequest();
|
|
await assertStatus(chat, 503, 'assistant dispatch');
|
|
if (!(await chat.text()).includes('assistant_dispatch')) throw new Error('assistant gate reason missing');
|
|
// Chat retains its existing 30-request window for anonymous IPs.
|
|
for (let attempt = 2; attempt <= 30; attempt += 1) {
|
|
const response = await chatRequest();
|
|
await assertStatus(response, 503, `anonymous chat denial ${attempt}`);
|
|
await response.text();
|
|
}
|
|
const chatLimited = await chatRequest();
|
|
await assertStatus(chatLimited, 429, 'anonymous chat rate limit');
|
|
await chatLimited.text();
|
|
const syncRequest = () => fetch(`${base}/api/sync`, { method: 'POST', headers: { origin: base } });
|
|
const sync = await syncRequest();
|
|
await assertStatus(sync, 503, 'asset sync');
|
|
await sync.text();
|
|
// The pre-session anonymous sync bucket allows exactly six denials; the
|
|
// seventh request is rate-limited. This is intentionally isolated from the
|
|
// authenticated trap subprocess below.
|
|
for (let attempt = 2; attempt <= 6; attempt += 1) {
|
|
const response = await syncRequest();
|
|
await assertStatus(response, 503, `anonymous sync denial ${attempt}`);
|
|
await response.text();
|
|
}
|
|
const syncLimited = await syncRequest();
|
|
await assertStatus(syncLimited, 429, 'anonymous sync rate limit');
|
|
await syncLimited.text();
|
|
const token = await fetch(`${base}/api/session/token`);
|
|
await assertStatus(token, 503, 'session token');
|
|
const characters = await fetch(`${base}/characters`);
|
|
await assertStatus(characters, 200, 'character page');
|
|
if (!(await characters.text()).includes('identity')) throw new Error('identity gate reason missing');
|
|
const oauth = await fetch(`${base}/auth/eve`, { method: 'POST', headers: { origin: base, 'content-type': 'application/x-www-form-urlencoded' }, redirect: 'manual' });
|
|
await assertStatus(oauth, 503, 'OAuth initiation');
|
|
const callback = await fetch(`${base}/auth/eve/callback?state=synthetic`, { redirect: 'manual' });
|
|
await assertStatus(callback, 303, 'OAuth callback');
|
|
if (!callback.headers.get('location')?.includes('auth=disabled')) throw new Error('OAuth callback gate reason missing');
|
|
server.kill('SIGTERM');
|
|
await server.exited;
|
|
server = undefined;
|
|
|
|
// Live-mode trap: a synthetic session cookie makes any accidental session()
|
|
// call observable without contacting Convex or a provider.
|
|
let trapCalls = 0;
|
|
trap = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch: () => { trapCalls++; return new Response('null', { headers: { 'content-type': 'application/json' } }); } });
|
|
const livePort = await freePort();
|
|
const liveBase = `http://127.0.0.1:${livePort}`;
|
|
const liveRelease = `gate-live-${crypto.randomUUID()}`;
|
|
server = Bun.spawn(['bun', 'build/index.js'], {
|
|
env: {
|
|
...safeEnv, APP_MODE: 'live', APP_ORIGIN: liveBase, ORIGIN: liveBase, HOST: '127.0.0.1', PORT: String(livePort),
|
|
REFERENCE_DIR: referenceDir, APP_RELEASE_ID: liveRelease, APP_CAPABILITY_ALLOWLIST: '',
|
|
CONVEX_URL: `http://127.0.0.1:${trap.port}`, PUBLIC_CONVEX_URL: `http://127.0.0.1:${trap.port}`,
|
|
CONVEX_BRIDGE_SECRET: 'synthetic-bridge-secret', TYPESAFE_API_KEY: 'sentinel-not-used',
|
|
}, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore',
|
|
});
|
|
await waitForServer(liveBase, liveRelease);
|
|
const cookie = { cookie: 'eve_session=synthetic-session' };
|
|
const liveRoot = await fetch(liveBase, { headers: cookie });
|
|
await assertStatus(liveRoot, 200, 'authenticated fully-disabled root');
|
|
const liveChat = await fetch(`${liveBase}/api/chat`, { method: 'POST', headers: { ...headers, origin: liveBase, ...cookie }, body: JSON.stringify({ message: '/value', requestId: crypto.randomUUID() }) });
|
|
await assertStatus(liveChat, 503, 'authenticated chat gate');
|
|
const liveSync = await fetch(`${liveBase}/api/sync`, { method: 'POST', headers: { origin: liveBase, ...cookie } });
|
|
await assertStatus(liveSync, 503, 'authenticated sync gate');
|
|
const liveCharacters = await fetch(`${liveBase}/characters`, { headers: cookie });
|
|
await assertStatus(liveCharacters, 200, 'authenticated character gate');
|
|
const liveAssets = await fetch(`${liveBase}/assets`, { headers: cookie });
|
|
await assertStatus(liveAssets, 200, 'authenticated asset gate');
|
|
if (trapCalls !== 0) throw new Error(`disabled route performed ${trapCalls} Convex session calls`);
|
|
server.kill('SIGTERM');
|
|
await server.exited;
|
|
server = undefined;
|
|
|
|
// Enabled live-mode regression: no-cookie requests resolve to anonymous
|
|
// users, so the post-session limiter must not reuse the same IP key. Sync
|
|
// reaches its 401 response and chat uses invalid JSON schema to avoid every
|
|
// provider while still exercising both limiter passes.
|
|
const enabledPort = await freePort();
|
|
const enabledBase = `http://127.0.0.1:${enabledPort}`;
|
|
const enabledRelease = `gate-enabled-${crypto.randomUUID()}`;
|
|
server = Bun.spawn(['bun', 'build/index.js'], {
|
|
env: {
|
|
...safeEnv, APP_MODE: 'live', APP_ORIGIN: enabledBase, ORIGIN: enabledBase, HOST: '127.0.0.1', PORT: String(enabledPort),
|
|
REFERENCE_DIR: referenceDir, APP_RELEASE_ID: enabledRelease, APP_CAPABILITY_ALLOWLIST: 'asset_sync,assistant_dispatch',
|
|
CONVEX_URL: `http://127.0.0.1:${trap.port}`, PUBLIC_CONVEX_URL: `http://127.0.0.1:${trap.port}`,
|
|
CONVEX_BRIDGE_SECRET: 'synthetic-bridge-secret', TYPESAFE_API_KEY: 'sentinel-not-used',
|
|
}, stdout: 'pipe', stderr: 'pipe', stdin: 'ignore',
|
|
});
|
|
await waitForServer(enabledBase, enabledRelease);
|
|
const enabledSyncRequest = () => fetch(`${enabledBase}/api/sync`, { method: 'POST', headers: { origin: enabledBase } });
|
|
for (let attempt = 1; attempt <= 6; attempt += 1) {
|
|
const response = await enabledSyncRequest();
|
|
await assertStatus(response, 401, `enabled anonymous sync denial ${attempt}`);
|
|
await response.text();
|
|
}
|
|
const enabledSyncLimited = await enabledSyncRequest();
|
|
await assertStatus(enabledSyncLimited, 429, 'enabled anonymous sync rate limit');
|
|
await enabledSyncLimited.text();
|
|
const enabledChatRequest = () => fetch(`${enabledBase}/api/chat`, {
|
|
method: 'POST', headers: { origin: enabledBase, 'content-type': 'application/json' }, body: '{}',
|
|
});
|
|
for (let attempt = 1; attempt <= 30; attempt += 1) {
|
|
const response = await enabledChatRequest();
|
|
await assertStatus(response, 400, `enabled anonymous chat denial ${attempt}`);
|
|
await response.text();
|
|
}
|
|
const enabledChatLimited = await enabledChatRequest();
|
|
await assertStatus(enabledChatLimited, 429, 'enabled anonymous chat rate limit');
|
|
await enabledChatLimited.text();
|
|
if (trapCalls !== 0) throw new Error(`enabled anonymous routes performed ${trapCalls} Convex/provider calls`);
|
|
console.log(`Rollout-gate HTTP smoke passed: anonymous thresholds and authenticated disabled paths avoided provider/session calls`);
|
|
} finally {
|
|
if (server) { server.kill('SIGTERM'); await server.exited; }
|
|
trap?.stop();
|
|
await rm(referenceDir, { recursive: true, force: true });
|
|
}
|