diff --git a/apps/cli/src/legacy/commands/network-restrictions/get/get.live.test.ts b/apps/cli/src/legacy/commands/network-restrictions/get/get.live.test.ts new file mode 100644 index 0000000000..bb991b27b8 --- /dev/null +++ b/apps/cli/src/legacy/commands/network-restrictions/get/get.live.test.ts @@ -0,0 +1,23 @@ +import { expect } from "vitest"; + +import { + experimentalProjectLiveFlags, + requireLiveJson, + test, +} from "../../../../../tests/helpers/live.ts"; + +test("reads the network restrictions of the target project", async ({ cli, project }) => { + const result = await cli([ + "network-restrictions", + "get", + ...experimentalProjectLiveFlags(project), + "-o", + "json", + ]); + expect(result.exitCode, result.stderr).toBe(0); + expect(requireLiveJson(result, "network-restrictions get"), result.stdout).toMatchObject({ + entitlement: expect.stringMatching(/^(?:allowed|disallowed)$/u), + config: expect.any(Object), + status: expect.stringMatching(/^(?:stored|applied)$/u), + }); +}); diff --git a/apps/cli/src/legacy/commands/network-restrictions/update/update.live.test.ts b/apps/cli/src/legacy/commands/network-restrictions/update/update.live.test.ts new file mode 100644 index 0000000000..cddb0f3dff --- /dev/null +++ b/apps/cli/src/legacy/commands/network-restrictions/update/update.live.test.ts @@ -0,0 +1,186 @@ +import { Schema } from "effect"; +import { expect } from "vitest"; + +import { + experimentalProjectLiveFlags, + type LiveFixtures, + requireLiveJson, + requireLiveSuccess, + test, + throwWithCleanup, +} from "../../../../../tests/helpers/live.ts"; + +type LiveCli = LiveFixtures["cli"]; +type LiveRun = Awaited>; + +// Every subprocess is bounded and the test's own timeout covers the longest +// path through them: four 60s commands (the restore is issued at most twice) +// plus two proof polls that can each run 102s (a 60s deadline that still +// finishes an in-flight 20s attempt, waits the 2s interval and runs one last +// 20s attempt), 444s in all, on top of the workspace fixture's own 60s init. +// Once a test has timed out its fixtures are disposed, so a late restore +// cannot take effect and the shared project stays locked down. +const EXIT_TIMEOUT_MS = 60_000; +const POLL_ATTEMPT_EXIT_TIMEOUT_MS = 20_000; +const PROOF_TIMEOUT_MS = 60_000; +const PROOF_INTERVAL_MS = 2_000; +const LIVE_TIMEOUT_MS = 600_000; + +interface AllowedCidrs { + readonly v4: ReadonlyArray; + readonly v6: ReadonlyArray; +} + +// Documentation ranges (RFC 5737 TEST-NET-3, RFC 3849): public, so the local +// private-range check accepts them, and unroutable, so allowing them admits +// nobody while the project is restricted. +const TEST_CIDRS: AllowedCidrs = { v4: ["203.0.113.0/24"], v6: ["2001:db8::/32"] }; + +// The allow-all sentinels `config.toml` ships as the `db.network_restrictions` +// defaults; ADR 0022 treats them as the platform's unconfigured state. +const ALLOW_ALL_CIDRS: AllowedCidrs = { v4: ["0.0.0.0/0"], v6: ["::/0"] }; + +const NetworkRestrictions = Schema.Struct({ + config: Schema.Struct({ + dbAllowedCidrs: Schema.optionalKey(Schema.Array(Schema.String)), + dbAllowedCidrsV6: Schema.optionalKey(Schema.Array(Schema.String)), + }), + status: Schema.Literals(["stored", "applied"]), +}); + +interface Posture { + readonly cidrs: AllowedCidrs; + readonly applied: boolean; +} + +function updateArgs(cidrs: AllowedCidrs, flags: ReadonlyArray): string[] { + return [ + "network-restrictions", + "update", + ...[...cidrs.v4, ...cidrs.v6].flatMap((cidr) => ["--db-allow-cidr", cidr]), + ...flags, + ]; +} + +function describeAttempt(attempt: number, result: LiveRun): string { + return `\nattempt ${attempt} (exit ${result.exitCode})\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`; +} + +// A family the platform leaves absent has nothing to restore, so it reads as +// `[]` like an explicitly empty one. +async function readPosture( + cli: LiveCli, + flags: ReadonlyArray, + label: string, + exitTimeoutMs: number, +): Promise { + const result = await cli(["network-restrictions", "get", ...flags, "-o", "json"], { + exitTimeoutMs, + }); + requireLiveSuccess(result, label); + const payload = requireLiveJson(result, label); + if (!Schema.is(NetworkRestrictions)(payload)) { + throw new Error( + `${label}: unexpected network-restrictions get payload\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + } + return { + cidrs: { + v4: payload.config.dbAllowedCidrs ?? [], + v6: payload.config.dbAllowedCidrsV6 ?? [], + }, + applied: payload.status === "applied", + }; +} + +// get reports `status: "stored"` until a requested allowlist has propagated +// (see the `V1GetNetworkRestrictionsOutput` config annotation in +// packages/api), so proving an update or a restore means polling get until +// the requested allowlist is reported as applied. +function expectApplied( + cli: LiveCli, + flags: ReadonlyArray, + cidrs: AllowedCidrs, + label: string, +): Promise { + return expect + .poll(() => readPosture(cli, flags, label, POLL_ATTEMPT_EXIT_TIMEOUT_MS), { + interval: PROOF_INTERVAL_MS, + timeout: PROOF_TIMEOUT_MS, + message: label, + }) + .toEqual({ cidrs, applied: true }); +} + +test( + "replaces the allowlist, get proves it, and restores the baseline allowlist", + { timeout: LIVE_TIMEOUT_MS }, + async ({ cli, project }) => { + const flags = experimentalProjectLiveFlags(project); + const captured = ( + await readPosture( + cli, + flags, + "network-restrictions get capture for network-restrictions update", + EXIT_TIMEOUT_MS, + ) + ).cidrs; + // Two empty allowlists (what an unconfigured project reads as) cannot be + // posted back: update sends both arrays and an empty one is restrict-all, so + // that baseline is restored as allow-all rather than leaving the shared + // project locked down for every later live test that reaches the database + // directly. Any other capture is restored as read, an empty family beside a + // populated one included. + const baselineCidrs: AllowedCidrs = + captured.v4.length === 0 && captured.v6.length === 0 ? ALLOW_ALL_CIDRS : captured; + let targetError: unknown; + const cleanupErrors: Array = []; + try { + const updated = await cli([...updateArgs(TEST_CIDRS, flags), "-o", "json"], { + exitTimeoutMs: EXIT_TIMEOUT_MS, + }); + expect(updated.exitCode, updated.stderr).toBe(0); + expect(requireLiveJson(updated, "network-restrictions update"), updated.stdout).toMatchObject( + { config: { dbAllowedCidrs: TEST_CIDRS.v4, dbAllowedCidrsV6: TEST_CIDRS.v6 } }, + ); + + await expectApplied( + cli, + flags, + TEST_CIDRS, + "network-restrictions get proof for network-restrictions update", + ); + } catch (error) { + targetError = error; + } finally { + try { + // One re-issue covers a restore that failed transiently. The proof runs + // whatever the restore reported: it alone shows whether the allowlist + // came back, and a restore killed while its request was still in flight + // exits non-zero after the platform may already have applied it. + const restore = () => + cli(updateArgs(baselineCidrs, flags), { exitTimeoutMs: EXIT_TIMEOUT_MS }); + const first = await restore(); + const restored = first.exitCode === 0 ? first : await restore(); + if (restored.exitCode !== 0) { + cleanupErrors.push( + new Error( + "network-restrictions update restore of the baseline allowlist failed twice" + + describeAttempt(1, first) + + describeAttempt(2, restored), + ), + ); + } + await expectApplied( + cli, + flags, + baselineCidrs, + "network-restrictions get proof of the restored allowlist for network-restrictions update", + ); + } catch (error) { + cleanupErrors.push(error); + } + } + throwWithCleanup(targetError, cleanupErrors); + }, +); diff --git a/apps/cli/tests/helpers/live.ts b/apps/cli/tests/helpers/live.ts index 5de71f3148..4ab352d9fe 100644 --- a/apps/cli/tests/helpers/live.ts +++ b/apps/cli/tests/helpers/live.ts @@ -124,6 +124,20 @@ export function requireLiveSuccess( } } +/** Parse a command's stdout as JSON, failing with both streams when it is not. */ +export function requireLiveJson( + result: { readonly stdout: string; readonly stderr: string }, + command: string, +): unknown { + try { + return JSON.parse(result.stdout); + } catch { + throw new Error( + `${command} did not print JSON\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + } +} + /** Flags every storage live test passes: the suite links the shared project * and the storage command family is experimental-gated. */ export const storageLiveFlags: ReadonlyArray = ["--linked", "--experimental"]; @@ -210,12 +224,7 @@ export async function expectPostgresConfigLiveOverride( { exitTimeoutMs: 20_000 }, ); requireLiveSuccess(proof, label); - let config: unknown; - try { - config = JSON.parse(proof.stdout); - } catch { - config = undefined; - } + const config = requireLiveJson(proof, label); if (!Predicate.isObject(config)) { throw new Error( `${label}: unexpected postgres-config get payload\nstdout:\n${proof.stdout}\nstderr:\n${proof.stderr}`,