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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user