fix(federation): harness CRIT bugs — admin bootstrap auth + peer FK + boot deadline (review remediation)

CRIT-1: Replace nonexistent x-admin-key header with Authorization: Bearer <token>;
add bootstrapAdmin() to call POST /api/bootstrap/setup on each pristine gateway
before any admin-guarded endpoint is used.

CRIT-2: Fix cross-gateway peer FK violation — peer keypair is now created on
Server B first (so the grant FK resolves against B's own federation_peers table),
then Server A creates its own keypair and redeems the enrollment token at B.

HIGH-3: waitForStack() now polls both gateways in parallel via Promise.all, each
with an independent deadline, so a slow gateway-a cannot starve gateway-b's budget.

MED-4: seed() throws immediately with a clear error if scenario !== 'all';
per-variant narrowing deferred to M3-11 with explicit JSDoc note.

Also: remove ADMIN_API_KEY (no such path in AdminGuard) from compose, replace
with ADMIN_BOOTSTRAP_PASSWORD; add BETTER_AUTH_URL production-code limitation
as a TODO in the README.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jarvis
2026-04-23 20:35:36 -05:00
parent b445033c69
commit cb118a53d9
4 changed files with 337 additions and 108 deletions

View File

@@ -19,14 +19,17 @@
* });
*
* test('variant A — list tasks', async () => {
* const seedResult = await seed(handle, 'variantA');
* const seedResult = await seed(handle, 'all');
* const a = serverA(handle);
* const res = await fetch(`${a.baseUrl}/api/federation/list/tasks`, {
* headers: { 'x-admin-key': a.adminKey },
* headers: { Authorization: `Bearer ${seedResult.adminTokenA}` },
* });
* expect(res.status).toBe(200);
* });
*
* NOTE: The `seed()` helper currently only supports scenario='all'. Passing any
* other value throws immediately. Per-variant narrowing is deferred to M3-11.
*
* ESM / NodeNext: all imports use .js extensions.
*/
@@ -40,8 +43,8 @@ import { runSeed, type SeedResult } from './seed.js';
export interface GatewayAccessor {
/** Base URL reachable from the host machine, e.g. http://localhost:14001 */
baseUrl: string;
/** Admin key for X-Admin-Key header */
adminKey: string;
/** Bootstrap password used for POST /api/bootstrap/setup on a pristine gateway */
bootstrapPassword: string;
/** Internal Docker network hostname (for container-to-container calls) */
internalHostname: string;
}
@@ -59,6 +62,11 @@ export interface HarnessHandle {
seedResult?: SeedResult;
}
/**
* Scenario to seed. Currently only 'all' is implemented; per-variant narrowing
* is tracked as M3-11. Passing any other value throws immediately with a clear
* error rather than silently over-seeding.
*/
export type SeedScenario = 'variantA' | 'variantB' | 'variantC' | 'all';
// ─── Constants ────────────────────────────────────────────────────────────────
@@ -68,8 +76,10 @@ const COMPOSE_FILE = resolve(__dirname, 'docker-compose.two-gateways.yml');
const GATEWAY_A_URL = process.env['GATEWAY_A_URL'] ?? 'http://localhost:14001';
const GATEWAY_B_URL = process.env['GATEWAY_B_URL'] ?? 'http://localhost:14002';
const ADMIN_KEY_A = process.env['ADMIN_KEY_A'] ?? 'harness-admin-key-a';
const ADMIN_KEY_B = process.env['ADMIN_KEY_B'] ?? 'harness-admin-key-b';
const ADMIN_BOOTSTRAP_PASSWORD_A =
process.env['ADMIN_BOOTSTRAP_PASSWORD_A'] ?? 'harness-admin-password-a';
const ADMIN_BOOTSTRAP_PASSWORD_B =
process.env['ADMIN_BOOTSTRAP_PASSWORD_B'] ?? 'harness-admin-password-b';
const READINESS_TIMEOUT_MS = 180_000;
const READINESS_POLL_MS = 3_000;
@@ -85,29 +95,43 @@ async function isGatewayHealthy(baseUrl: string): Promise<boolean> {
}
}
/**
* Poll both gateways in parallel until both are healthy or the shared deadline
* expires. Polling in parallel (rather than sequentially) avoids the bug where
* a slow gateway-a consumes all of the readiness budget before gateway-b is
* checked.
*/
async function waitForStack(handle: HarnessHandle): Promise<void> {
const deadline = Date.now() + READINESS_TIMEOUT_MS;
const gateways: Array<{ label: string; url: string }> = [
{ label: 'gateway-a', url: handle.a.baseUrl },
{ label: 'gateway-b', url: handle.b.baseUrl },
];
for (const gw of gateways) {
process.stdout.write(`[harness] Waiting for ${gw.label}...`);
while (Date.now() < deadline) {
if (await isGatewayHealthy(gw.url)) {
process.stdout.write(' ready\n');
break;
await Promise.all(
gateways.map(async (gw) => {
// Each gateway gets its own independent deadline.
const deadline = Date.now() + READINESS_TIMEOUT_MS;
process.stdout.write(`[harness] Waiting for ${gw.label}...`);
while (Date.now() < deadline) {
if (await isGatewayHealthy(gw.url)) {
process.stdout.write(` ready\n`);
return;
}
if (Date.now() + READINESS_POLL_MS > deadline) {
throw new Error(
`[harness] ${gw.label} did not become healthy within ${READINESS_TIMEOUT_MS.toString()}ms`,
);
}
await new Promise((r) => setTimeout(r, READINESS_POLL_MS));
process.stdout.write('.');
}
if (Date.now() + READINESS_POLL_MS > deadline) {
throw new Error(
`[harness] ${gw.label} did not become healthy within ${READINESS_TIMEOUT_MS}ms`,
);
}
await new Promise((r) => setTimeout(r, READINESS_POLL_MS));
process.stdout.write('.');
}
}
throw new Error(
`[harness] ${gw.label} did not become healthy within ${READINESS_TIMEOUT_MS.toString()}ms`,
);
}),
);
}
function isStackRunning(): boolean {
@@ -155,12 +179,12 @@ export async function bootHarness(): Promise<HarnessHandle> {
const handle: HarnessHandle = {
a: {
baseUrl: GATEWAY_A_URL,
adminKey: ADMIN_KEY_A,
bootstrapPassword: ADMIN_BOOTSTRAP_PASSWORD_A,
internalHostname: 'gateway-a',
},
b: {
baseUrl: GATEWAY_B_URL,
adminKey: ADMIN_KEY_B,
bootstrapPassword: ADMIN_BOOTSTRAP_PASSWORD_B,
internalHostname: 'gateway-b',
},
composeFile: COMPOSE_FILE,
@@ -231,26 +255,34 @@ export function serverB(handle: HarnessHandle): GatewayAccessor {
* Seed the harness with test data for one or more scenarios.
*
* @param handle The harness handle returned by bootHarness().
* @param scenario Which scope variants to provision:
* 'variantA' | 'variantB' | 'variantC' | 'all'
* @param scenario Which scope variants to provision. Currently only 'all' is
* supported — passing any other value throws immediately with a
* clear error. Per-variant narrowing is tracked as M3-11.
*
* Returns a SeedResult with grant IDs and peer IDs for each variant,
* which test assertions can reference.
* Returns a SeedResult with grant IDs, peer IDs, and admin tokens for each
* gateway, which test assertions can reference.
*
* IMPORTANT: The harness assumes a pristine database on both gateways. The seed
* bootstraps an admin user on each gateway via POST /api/bootstrap/setup. If
* either gateway already has users, seed() throws with a clear error message.
* Run 'docker compose down -v' to reset state.
*/
export async function seed(
handle: HarnessHandle,
scenario: SeedScenario = 'all',
): Promise<SeedResult> {
// For now all scenarios run the full seed — M3-11 can narrow this.
// The seed script is idempotent in the sense that it creates new grants
// each time; tests should start with a clean stack for isolation.
void scenario; // narrowing deferred to M3-11
if (scenario !== 'all') {
throw new Error(
`seed: scenario narrowing not yet implemented; pass "all" for now. ` +
`Got: "${scenario}". Per-variant narrowing is tracked as M3-11.`,
);
}
const result = await runSeed({
serverAUrl: handle.a.baseUrl,
serverBUrl: handle.b.baseUrl,
adminKeyA: handle.a.adminKey,
adminKeyB: handle.b.adminKey,
adminBootstrapPasswordA: handle.a.bootstrapPassword,
adminBootstrapPasswordB: handle.b.bootstrapPassword,
});
handle.seedResult = result;