feat(federation): two-gateway test harness scaffold (FED-M3-02) (#505)
This commit was merged in pull request #505.
This commit is contained in:
603
tools/federation-harness/seed.ts
Normal file
603
tools/federation-harness/seed.ts
Normal file
@@ -0,0 +1,603 @@
|
||||
#!/usr/bin/env tsx
|
||||
/**
|
||||
* tools/federation-harness/seed.ts
|
||||
*
|
||||
* Provisions test data for the two-gateway federation harness.
|
||||
* Run via: tsx tools/federation-harness/seed.ts
|
||||
*
|
||||
* What this script does:
|
||||
* 1. (Optional) Boots the compose stack if --boot flag is passed.
|
||||
* 2. Waits for both gateways to be healthy.
|
||||
* 3. Bootstraps an admin user + token on each gateway via POST /api/bootstrap/setup.
|
||||
* 4. Creates three grants on Server B matching the M3 acceptance test scenarios:
|
||||
* - Scope variant A: tasks + notes, include_personal: true
|
||||
* - Scope variant B: tasks only, include_teams: ['T1'], exclude T2
|
||||
* - Scope variant C: tasks + credentials in resources, credentials excluded (sanity)
|
||||
* 5. For each grant, walks the full enrollment flow:
|
||||
* a. Server B creates a peer keypair (represents the requesting side).
|
||||
* b. Server B creates the grant referencing that peer.
|
||||
* c. Server B issues an enrollment token.
|
||||
* d. Server A creates its own peer keypair (represents its view of B).
|
||||
* e. Server A redeems the enrollment token at Server B's enrollment endpoint,
|
||||
* submitting A's CSR → receives signed cert back.
|
||||
* f. Server A stores the cert on its peer record → peer becomes active.
|
||||
* 6. Inserts representative test tasks/notes/credentials on Server B.
|
||||
*
|
||||
* IMPORTANT: This script uses the real admin REST API — no direct DB writes.
|
||||
* It exercises the full enrollment flow as M3 acceptance tests will.
|
||||
*
|
||||
* ESM / NodeNext: all imports use .js extensions.
|
||||
*/
|
||||
|
||||
import { execSync } from 'node:child_process';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const COMPOSE_FILE = resolve(__dirname, 'docker-compose.two-gateways.yml');
|
||||
|
||||
/** Base URLs as seen from the host machine (mapped host ports). */
|
||||
const SERVER_A_URL = process.env['GATEWAY_A_URL'] ?? 'http://localhost:14001';
|
||||
const SERVER_B_URL = process.env['GATEWAY_B_URL'] ?? 'http://localhost:14002';
|
||||
|
||||
/**
|
||||
* Bootstrap passwords used when calling POST /api/bootstrap/setup on each
|
||||
* gateway. Each gateway starts with zero users and requires a one-time setup
|
||||
* call before any admin-guarded endpoints can be used.
|
||||
*/
|
||||
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 = 120_000;
|
||||
const READINESS_POLL_MS = 3_000;
|
||||
|
||||
// ─── Scope variant definitions (for M3 acceptance tests) ─────────────────────
|
||||
|
||||
/** Scope variant A — tasks + notes, personal data included. */
|
||||
export const SCOPE_VARIANT_A = {
|
||||
resources: ['tasks', 'notes'],
|
||||
filters: {
|
||||
tasks: { include_personal: true },
|
||||
notes: { include_personal: true },
|
||||
},
|
||||
excluded_resources: [] as string[],
|
||||
max_rows_per_query: 500,
|
||||
};
|
||||
|
||||
/** Scope variant B — tasks only, team T1 only, no personal. */
|
||||
export const SCOPE_VARIANT_B = {
|
||||
resources: ['tasks'],
|
||||
filters: {
|
||||
tasks: { include_teams: ['T1'], include_personal: false },
|
||||
},
|
||||
excluded_resources: [] as string[],
|
||||
max_rows_per_query: 500,
|
||||
};
|
||||
|
||||
/**
|
||||
* Scope variant C — tasks + credentials in resources list, but credentials
|
||||
* explicitly in excluded_resources. Sanity test: credentials must still be
|
||||
* inaccessible even though they appear in resources.
|
||||
*/
|
||||
export const SCOPE_VARIANT_C = {
|
||||
resources: ['tasks', 'credentials'],
|
||||
filters: {
|
||||
tasks: { include_personal: true },
|
||||
},
|
||||
excluded_resources: ['credentials'],
|
||||
max_rows_per_query: 500,
|
||||
};
|
||||
|
||||
// ─── Inline types (no import from packages/types — M3-01 branch not yet merged) ─
|
||||
|
||||
interface AdminFetchOptions {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
adminToken: string;
|
||||
}
|
||||
|
||||
interface PeerRecord {
|
||||
peerId: string;
|
||||
csrPem: string;
|
||||
}
|
||||
|
||||
interface GrantRecord {
|
||||
id: string;
|
||||
status: string;
|
||||
scope: unknown;
|
||||
}
|
||||
|
||||
interface EnrollmentTokenResult {
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
enrollmentUrl: string;
|
||||
}
|
||||
|
||||
interface EnrollmentRedeemResult {
|
||||
certPem: string;
|
||||
certChainPem: string;
|
||||
}
|
||||
|
||||
interface BootstrapResult {
|
||||
adminUserId: string;
|
||||
adminToken: string;
|
||||
}
|
||||
|
||||
export interface SeedResult {
|
||||
serverAUrl: string;
|
||||
serverBUrl: string;
|
||||
adminTokenA: string;
|
||||
adminTokenB: string;
|
||||
adminUserIdA: string;
|
||||
adminUserIdB: string;
|
||||
grants: {
|
||||
variantA: GrantRecord;
|
||||
variantB: GrantRecord;
|
||||
variantC: GrantRecord;
|
||||
};
|
||||
peers: {
|
||||
variantA: PeerRecord & { grantId: string };
|
||||
variantB: PeerRecord & { grantId: string };
|
||||
variantC: PeerRecord & { grantId: string };
|
||||
};
|
||||
}
|
||||
|
||||
// ─── HTTP helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Authenticated admin fetch. Sends `Authorization: Bearer <adminToken>` which
|
||||
* is the only path supported by AdminGuard (DB-backed sha256 token lookup).
|
||||
* No `x-admin-key` header path exists in the gateway.
|
||||
*/
|
||||
async function adminFetch<T>(baseUrl: string, path: string, opts: AdminFetchOptions): Promise<T> {
|
||||
const url = `${baseUrl}${path}`;
|
||||
const res = await fetch(url, {
|
||||
method: opts.method ?? 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${opts.adminToken}`,
|
||||
},
|
||||
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '(no body)');
|
||||
throw new Error(`${opts.method ?? 'GET'} ${url} → ${res.status}: ${text}`);
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ─── Admin bootstrap ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Bootstrap an admin user on a pristine gateway.
|
||||
*
|
||||
* Steps:
|
||||
* 1. GET /api/bootstrap/status — confirms needsSetup === true.
|
||||
* 2. POST /api/bootstrap/setup with { name, email, password } — returns
|
||||
* { user, token: { plaintext } }.
|
||||
*
|
||||
* The harness assumes a fresh DB. If needsSetup is false the harness fails
|
||||
* fast with a clear error rather than proceeding with an unknown token.
|
||||
*/
|
||||
async function bootstrapAdmin(
|
||||
baseUrl: string,
|
||||
label: string,
|
||||
password: string,
|
||||
): Promise<BootstrapResult> {
|
||||
console.log(`[seed] Bootstrapping admin on ${label} (${baseUrl})...`);
|
||||
|
||||
// 1. Check status
|
||||
const statusRes = await fetch(`${baseUrl}/api/bootstrap/status`);
|
||||
if (!statusRes.ok) {
|
||||
throw new Error(`[seed] GET ${baseUrl}/api/bootstrap/status → ${statusRes.status.toString()}`);
|
||||
}
|
||||
const status = (await statusRes.json()) as { needsSetup: boolean };
|
||||
|
||||
if (!status.needsSetup) {
|
||||
throw new Error(
|
||||
`[seed] ${label} at ${baseUrl} already has users (needsSetup=false). ` +
|
||||
`The harness requires a pristine database. Run 'docker compose down -v' to reset.`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Bootstrap
|
||||
const setupRes = await fetch(`${baseUrl}/api/bootstrap/setup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: `Harness Admin (${label})`,
|
||||
email: `harness-admin-${label.toLowerCase().replace(/\s+/g, '-')}@example.invalid`,
|
||||
password,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!setupRes.ok) {
|
||||
const body = await setupRes.text().catch(() => '(no body)');
|
||||
throw new Error(
|
||||
`[seed] POST ${baseUrl}/api/bootstrap/setup → ${setupRes.status.toString()}: ${body}`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = (await setupRes.json()) as {
|
||||
user: { id: string };
|
||||
token: { plaintext: string };
|
||||
};
|
||||
|
||||
console.log(`[seed] ${label} admin user: ${result.user.id}`);
|
||||
console.log(`[seed] ${label} admin token: ${result.token.plaintext.slice(0, 8)}...`);
|
||||
|
||||
return {
|
||||
adminUserId: result.user.id,
|
||||
adminToken: result.token.plaintext,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Readiness probe ──────────────────────────────────────────────────────────
|
||||
|
||||
async function waitForGateway(baseUrl: string, label: string): Promise<void> {
|
||||
const deadline = Date.now() + READINESS_TIMEOUT_MS;
|
||||
let lastError: string = '';
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const res = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(5_000) });
|
||||
if (res.ok) {
|
||||
console.log(`[seed] ${label} is ready (${baseUrl})`);
|
||||
return;
|
||||
}
|
||||
lastError = `HTTP ${res.status.toString()}`;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, READINESS_POLL_MS));
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`[seed] ${label} did not become ready within ${READINESS_TIMEOUT_MS.toString()}ms — last error: ${lastError}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Enrollment flow ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Walk the full enrollment flow for one grant.
|
||||
*
|
||||
* The correct two-sided flow (matching the data model's FK semantics):
|
||||
*
|
||||
* 1. On Server B: POST /api/admin/federation/peers/keypair
|
||||
* → peerId_B (Server B's peer record representing the requesting side)
|
||||
* 2. On Server B: POST /api/admin/federation/grants with peerId: peerId_B
|
||||
* → grant (FK to Server B's own federation_peers table — no violation)
|
||||
* 3. On Server B: POST /api/admin/federation/grants/:id/tokens
|
||||
* → enrollmentUrl pointing back to Server B
|
||||
* 4. On Server A: POST /api/admin/federation/peers/keypair
|
||||
* → peerId_A + csrPem_A (Server A's local record of Server B)
|
||||
* 5. Server A → Server B: POST enrollmentUrl with { csrPem: csrPem_A }
|
||||
* → certPem signed by Server B's CA
|
||||
* 6. On Server A: PATCH /api/admin/federation/peers/:peerId_A/cert with certPem
|
||||
* → Server A's peer record transitions to active
|
||||
*
|
||||
* Returns the activated grant (from Server B) and Server A's peer record.
|
||||
*/
|
||||
async function enrollGrant(opts: {
|
||||
label: string;
|
||||
subjectUserId: string;
|
||||
scope: unknown;
|
||||
adminTokenA: string;
|
||||
adminTokenB: string;
|
||||
serverAUrl: string;
|
||||
serverBUrl: string;
|
||||
}): Promise<{ grant: GrantRecord; peer: PeerRecord & { grantId: string } }> {
|
||||
const { label, subjectUserId, scope, adminTokenA, adminTokenB, serverAUrl, serverBUrl } = opts;
|
||||
console.log(`\n[seed] Enrolling grant for scope variant ${label}...`);
|
||||
|
||||
// 1. Create peer keypair on Server B (represents the requesting peer from B's perspective)
|
||||
const peerB = await adminFetch<PeerRecord>(serverBUrl, '/api/admin/federation/peers/keypair', {
|
||||
method: 'POST',
|
||||
adminToken: adminTokenB,
|
||||
body: {
|
||||
commonName: `harness-peer-${label.toLowerCase()}-from-b`,
|
||||
displayName: `Harness Peer ${label} (Server A as seen from B)`,
|
||||
endpointUrl: serverAUrl,
|
||||
},
|
||||
});
|
||||
console.log(`[seed] Created peer on B: ${peerB.peerId}`);
|
||||
|
||||
// 2. Create grant on Server B referencing B's own peer record
|
||||
const grant = await adminFetch<GrantRecord>(serverBUrl, '/api/admin/federation/grants', {
|
||||
method: 'POST',
|
||||
adminToken: adminTokenB,
|
||||
body: {
|
||||
peerId: peerB.peerId,
|
||||
subjectUserId,
|
||||
scope,
|
||||
},
|
||||
});
|
||||
console.log(`[seed] Created grant on B: ${grant.id} (status: ${grant.status})`);
|
||||
|
||||
// 3. Generate enrollment token on Server B
|
||||
const tokenResult = await adminFetch<EnrollmentTokenResult>(
|
||||
serverBUrl,
|
||||
`/api/admin/federation/grants/${grant.id}/tokens`,
|
||||
{ method: 'POST', adminToken: adminTokenB, body: { ttlSeconds: 900 } },
|
||||
);
|
||||
console.log(`[seed] Enrollment token: ${tokenResult.token.slice(0, 8)}...`);
|
||||
console.log(`[seed] Enrollment URL: ${tokenResult.enrollmentUrl}`);
|
||||
|
||||
// 4. Create peer keypair on Server A (Server A's local record of Server B)
|
||||
const peerA = await adminFetch<PeerRecord>(serverAUrl, '/api/admin/federation/peers/keypair', {
|
||||
method: 'POST',
|
||||
adminToken: adminTokenA,
|
||||
body: {
|
||||
commonName: `harness-peer-${label.toLowerCase()}-from-a`,
|
||||
displayName: `Harness Peer ${label} (Server B as seen from A)`,
|
||||
endpointUrl: serverBUrl,
|
||||
},
|
||||
});
|
||||
console.log(`[seed] Created peer on A: ${peerA.peerId}`);
|
||||
|
||||
// 5. Redeem token at Server B's enrollment endpoint with A's CSR.
|
||||
// The enrollment endpoint is not admin-guarded — the one-time token IS the credential.
|
||||
//
|
||||
// The enrollmentUrl returned by the gateway is built using BETTER_AUTH_URL which
|
||||
// resolves to the in-cluster Docker hostname (gateway-b:3000). That URL is only
|
||||
// reachable from other containers, not from the host machine running this script.
|
||||
// We rewrite the host portion to use the host-accessible serverBUrl so the
|
||||
// seed script can reach the endpoint from the host.
|
||||
const parsedEnrollment = new URL(tokenResult.enrollmentUrl);
|
||||
const tokenSegment = parsedEnrollment.pathname.split('/').pop()!;
|
||||
const redeemUrl = `${serverBUrl}/api/federation/enrollment/${tokenSegment}`;
|
||||
console.log(`[seed] Rewritten redeem URL (host-accessible): ${redeemUrl}`);
|
||||
const redeemRes = await fetch(redeemUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ csrPem: peerA.csrPem }),
|
||||
});
|
||||
|
||||
if (!redeemRes.ok) {
|
||||
const body = await redeemRes.text().catch(() => '(no body)');
|
||||
throw new Error(`Enrollment redemption failed: ${redeemRes.status.toString()} — ${body}`);
|
||||
}
|
||||
|
||||
const redeemResult = (await redeemRes.json()) as EnrollmentRedeemResult;
|
||||
console.log(`[seed] Cert issued (${redeemResult.certPem.length.toString()} bytes)`);
|
||||
|
||||
// 6. Store cert on Server A's peer record → transitions to active
|
||||
await adminFetch<unknown>(serverAUrl, `/api/admin/federation/peers/${peerA.peerId}/cert`, {
|
||||
method: 'PATCH',
|
||||
adminToken: adminTokenA,
|
||||
body: { certPem: redeemResult.certPem },
|
||||
});
|
||||
console.log(`[seed] Cert stored on A — peer ${peerA.peerId} is now active`);
|
||||
|
||||
// Verify grant flipped to active on B
|
||||
const activeGrant = await adminFetch<GrantRecord>(
|
||||
serverBUrl,
|
||||
`/api/admin/federation/grants/${grant.id}`,
|
||||
{ adminToken: adminTokenB },
|
||||
);
|
||||
console.log(`[seed] Grant status on B: ${activeGrant.status}`);
|
||||
|
||||
return { grant: activeGrant, peer: { ...peerA, grantId: grant.id } };
|
||||
}
|
||||
|
||||
// ─── Test data insertion ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Insert representative test data on Server B via its admin APIs.
|
||||
*
|
||||
* NOTE: The gateway's task/note/credential APIs require an authenticated user
|
||||
* session. For the harness, we seed via admin-level endpoints if available,
|
||||
* or document the gap here for M3-11 to fill in with proper user session seeding.
|
||||
*
|
||||
* ASSUMPTION: Server B exposes POST /api/admin/tasks (or similar) for test data.
|
||||
* If that endpoint does not yet exist, this function logs a warning and skips
|
||||
* without failing — M3-11 will add the session-based seeding path.
|
||||
*/
|
||||
async function seedTestData(
|
||||
subjectUserId: string,
|
||||
scopeLabel: string,
|
||||
serverBUrl: string,
|
||||
adminTokenB: string,
|
||||
): Promise<void> {
|
||||
console.log(`\n[seed] Seeding test data on Server B for ${scopeLabel}...`);
|
||||
|
||||
const testTasks = [
|
||||
{
|
||||
title: `${scopeLabel} Task 1`,
|
||||
description: 'Federation harness test task',
|
||||
userId: subjectUserId,
|
||||
},
|
||||
{
|
||||
title: `${scopeLabel} Task 2`,
|
||||
description: 'Team-scoped test task',
|
||||
userId: subjectUserId,
|
||||
teamId: 'T1',
|
||||
},
|
||||
];
|
||||
|
||||
const testNotes = [
|
||||
{
|
||||
title: `${scopeLabel} Note 1`,
|
||||
content: 'Personal note for federation test',
|
||||
userId: subjectUserId,
|
||||
},
|
||||
];
|
||||
|
||||
// Attempt to insert — tolerate 404 (endpoint not yet implemented)
|
||||
for (const task of testTasks) {
|
||||
try {
|
||||
await adminFetch<unknown>(serverBUrl, '/api/admin/tasks', {
|
||||
method: 'POST',
|
||||
adminToken: adminTokenB,
|
||||
body: task,
|
||||
});
|
||||
console.log(`[seed] Inserted task: "${task.title}"`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes('404') || msg.includes('Cannot POST')) {
|
||||
console.warn(
|
||||
`[seed] WARN: /api/admin/tasks not found — skipping task insertion (expected until M3-11)`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
for (const note of testNotes) {
|
||||
try {
|
||||
await adminFetch<unknown>(serverBUrl, '/api/admin/notes', {
|
||||
method: 'POST',
|
||||
adminToken: adminTokenB,
|
||||
body: note,
|
||||
});
|
||||
console.log(`[seed] Inserted note: "${note.title}"`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes('404') || msg.includes('Cannot POST')) {
|
||||
console.warn(
|
||||
`[seed] WARN: /api/admin/notes not found — skipping note insertion (expected until M3-11)`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[seed] Test data seeding for ${scopeLabel} complete.`);
|
||||
}
|
||||
|
||||
// ─── Main entrypoint ──────────────────────────────────────────────────────────
|
||||
|
||||
export async function runSeed(opts?: {
|
||||
serverAUrl?: string;
|
||||
serverBUrl?: string;
|
||||
adminBootstrapPasswordA?: string;
|
||||
adminBootstrapPasswordB?: string;
|
||||
subjectUserIds?: { variantA: string; variantB: string; variantC: string };
|
||||
}): Promise<SeedResult> {
|
||||
const aUrl = opts?.serverAUrl ?? SERVER_A_URL;
|
||||
const bUrl = opts?.serverBUrl ?? SERVER_B_URL;
|
||||
const passwordA = opts?.adminBootstrapPasswordA ?? ADMIN_BOOTSTRAP_PASSWORD_A;
|
||||
const passwordB = opts?.adminBootstrapPasswordB ?? ADMIN_BOOTSTRAP_PASSWORD_B;
|
||||
|
||||
// Use provided or default subject user IDs.
|
||||
// In a real run these would be real user UUIDs from Server B's DB.
|
||||
// For the harness, the admin bootstrap user on Server B is used as the subject.
|
||||
// These are overridden after bootstrap if opts.subjectUserIds is not provided.
|
||||
const subjectIds = opts?.subjectUserIds;
|
||||
|
||||
console.log('[seed] Waiting for gateways to be ready...');
|
||||
await Promise.all([waitForGateway(aUrl, 'Server A'), waitForGateway(bUrl, 'Server B')]);
|
||||
|
||||
// Bootstrap admin users on both gateways (requires pristine DBs).
|
||||
console.log('\n[seed] Bootstrapping admin accounts...');
|
||||
const [bootstrapA, bootstrapB] = await Promise.all([
|
||||
bootstrapAdmin(aUrl, 'Server A', passwordA),
|
||||
bootstrapAdmin(bUrl, 'Server B', passwordB),
|
||||
]);
|
||||
|
||||
// Default subject user IDs to the admin user on Server B (guaranteed to exist).
|
||||
const resolvedSubjectIds = subjectIds ?? {
|
||||
variantA: bootstrapB.adminUserId,
|
||||
variantB: bootstrapB.adminUserId,
|
||||
variantC: bootstrapB.adminUserId,
|
||||
};
|
||||
|
||||
// Enroll all three scope variants sequentially to avoid race conditions on
|
||||
// the step-ca signing queue. Parallel enrollment would work too but
|
||||
// sequential is easier to debug when something goes wrong.
|
||||
console.log('\n[seed] Enrolling scope variants...');
|
||||
const resultA = await enrollGrant({
|
||||
label: 'A',
|
||||
subjectUserId: resolvedSubjectIds.variantA,
|
||||
scope: SCOPE_VARIANT_A,
|
||||
adminTokenA: bootstrapA.adminToken,
|
||||
adminTokenB: bootstrapB.adminToken,
|
||||
serverAUrl: aUrl,
|
||||
serverBUrl: bUrl,
|
||||
});
|
||||
const resultB = await enrollGrant({
|
||||
label: 'B',
|
||||
subjectUserId: resolvedSubjectIds.variantB,
|
||||
scope: SCOPE_VARIANT_B,
|
||||
adminTokenA: bootstrapA.adminToken,
|
||||
adminTokenB: bootstrapB.adminToken,
|
||||
serverAUrl: aUrl,
|
||||
serverBUrl: bUrl,
|
||||
});
|
||||
const resultC = await enrollGrant({
|
||||
label: 'C',
|
||||
subjectUserId: resolvedSubjectIds.variantC,
|
||||
scope: SCOPE_VARIANT_C,
|
||||
adminTokenA: bootstrapA.adminToken,
|
||||
adminTokenB: bootstrapB.adminToken,
|
||||
serverAUrl: aUrl,
|
||||
serverBUrl: bUrl,
|
||||
});
|
||||
|
||||
// Seed test data on Server B for each scope variant
|
||||
await Promise.all([
|
||||
seedTestData(resolvedSubjectIds.variantA, 'A', bUrl, bootstrapB.adminToken),
|
||||
seedTestData(resolvedSubjectIds.variantB, 'B', bUrl, bootstrapB.adminToken),
|
||||
seedTestData(resolvedSubjectIds.variantC, 'C', bUrl, bootstrapB.adminToken),
|
||||
]);
|
||||
|
||||
const result: SeedResult = {
|
||||
serverAUrl: aUrl,
|
||||
serverBUrl: bUrl,
|
||||
adminTokenA: bootstrapA.adminToken,
|
||||
adminTokenB: bootstrapB.adminToken,
|
||||
adminUserIdA: bootstrapA.adminUserId,
|
||||
adminUserIdB: bootstrapB.adminUserId,
|
||||
grants: {
|
||||
variantA: resultA.grant,
|
||||
variantB: resultB.grant,
|
||||
variantC: resultC.grant,
|
||||
},
|
||||
peers: {
|
||||
variantA: resultA.peer,
|
||||
variantB: resultB.peer,
|
||||
variantC: resultC.peer,
|
||||
},
|
||||
};
|
||||
|
||||
console.log('\n[seed] Seed complete.');
|
||||
console.log('[seed] Summary:');
|
||||
console.log(` Variant A grant: ${result.grants.variantA.id} (${result.grants.variantA.status})`);
|
||||
console.log(` Variant B grant: ${result.grants.variantB.id} (${result.grants.variantB.status})`);
|
||||
console.log(` Variant C grant: ${result.grants.variantC.id} (${result.grants.variantC.status})`);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── CLI entry ────────────────────────────────────────────────────────────────
|
||||
|
||||
const isCli =
|
||||
process.argv[1] != null &&
|
||||
fileURLToPath(import.meta.url).endsWith(process.argv[1]!.split('/').pop()!);
|
||||
|
||||
if (isCli) {
|
||||
const shouldBoot = process.argv.includes('--boot');
|
||||
|
||||
if (shouldBoot) {
|
||||
console.log('[seed] --boot flag detected — starting compose stack...');
|
||||
execSync(`docker compose -f "${COMPOSE_FILE}" up -d`, { stdio: 'inherit' });
|
||||
}
|
||||
|
||||
runSeed()
|
||||
.then(() => {
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[seed] Fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user