diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2e2f8cb0e..e4a2fd414 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,9 @@ jobs: runs-on: ${{ matrix.os }} permissions: contents: read + # No Cloudflare creds in this job. The credentialed live deploy e2e runs in the separate + # `live-e2e` job (below), whose secrets are bound to the `cloudflare-e2e` environment. Here the + # live suite (part of `vp run test:all`) sees empty creds and self-skips on every event. strategy: fail-fast: false matrix: @@ -43,11 +46,52 @@ jobs: - run: vp run test:all + # Credentialed live Cloudflare deploy e2e, ISOLATED from `test` so the account token exists ONLY in + # a job bound to the `cloudflare-e2e` GitHub Environment. That environment's deployment branch policy + # allows `main` ONLY and the ref is enforced server-side, so a `pull_request` run (ref + # refs/pull//merge) can never reach the secret — even a PR that edits this file — and a modified + # workflow cannot exfiltrate it. The `if` also keeps the job off everything except a direct push to + # the `main` branch: tag pushes and `workflow_call` (release) carry a non-main ref, so they are + # skipped — which both avoids a branch-policy failure and matches where the secret is allowed, and + # it is scoped to the canonical repo so forks don't run it. It deploys a UNIQUELY-named worker + # (VOID_E2E_RUN_ID) to a throwaway account and tears it down. A preflight step fails the job when a + # credential is empty (unset / deleted / not-yet-rotated token), so a missing secret can never turn + # the live-deploy gate into a silent green no-op (the `test` job's uncredentialed run still skips). + live-e2e: + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository == 'voidzero-dev/void' }} + runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + contents: read + environment: cloudflare-e2e + env: + VOID_E2E_CF_TOKEN: ${{ secrets.VOID_E2E_CF_TOKEN }} + VOID_E2E_CF_ACCOUNT_ID: ${{ secrets.VOID_E2E_CF_ACCOUNT_ID }} + VOID_E2E_RUN_ID: ${{ github.run_id }}-${{ github.run_attempt }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + persist-credentials: false + - uses: voidzero-dev/setup-vp@2dec1e33f4ab2c6d5bce1b0c4607961bb1a3f7a1 # v1.12.0 + with: + node-version-file: .node-version + cache: true + # Fail closed BEFORE the build: a missing/empty credential must fail this job, not self-skip. The + # `${VAR:?}` expansion exits nonzero when the secret is unset OR empty (GitHub resolves an unset + # secret to ''). + - name: Require live Cloudflare credentials + run: | + : "${VOID_E2E_CF_TOKEN:?empty — set it as a cloudflare-e2e environment secret}" + : "${VOID_E2E_CF_ACCOUNT_ID:?empty — set it as a cloudflare-e2e environment secret}" + # build:core produces dist/cli/cli.mjs + the built `void` plugin the deploy uses to build the app. + - run: vp run build:core + - run: vp test run packages/void/test/e2e/cloudflare-deploy-live.test.ts + done: runs-on: ubuntu-latest if: always() needs: - test + - live-e2e steps: - run: exit 1 if: ${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }} diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index 16bcdadda..5b9bfba84 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -2094,7 +2094,7 @@ function isCleanWranglerExit(result: DeployWranglerRunResult): boolean { * unreadable — callers then treat every field as unset (safe: a genuinely broken * config fails later in wrangler with a clear error). */ -function parseRootWranglerConfig(configPath: string): Record { +export function parseRootWranglerConfig(configPath: string): Record { if (!existsSync(configPath)) { return {}; } diff --git a/packages/void/test/e2e/README.md b/packages/void/test/e2e/README.md new file mode 100644 index 000000000..eada0c4a0 --- /dev/null +++ b/packages/void/test/e2e/README.md @@ -0,0 +1,85 @@ +# `packages/void/test/e2e` + +Gated live-account end-to-end tests. These run REAL Cloudflare operations against a REAL account, so +they **skip unless credentials are present**. Locally that means exporting the env vars below; in CI +they run on **pushes to `main`** (see [In CI](#in-ci)). + +## `cloudflare-deploy-live.test.ts` + +Deploys `playground/spa` (D1 + KV + cron) to the target account via `void deploy --backend cloudflare`, +verifies the live worker answers HTTP 200, then deletes the worker and every resource it created. + +It does **not** drive `--provision`: that path fails closed in non-interactive shells by design (a +spawned CLI has no TTY, and CI cannot commit back the ids a create would mint). So the test plays the +provisioner's role itself — it creates the id-bearing D1 + KV directly with `wrangler … create`, +writes their real ids into the app's root config exactly as `--provision` would, then runs the +CI-safe `void deploy --backend cloudflare` (no `--provision`). That exercises the real +build → tamper-guard → remote-migrations → `wrangler deploy` pipeline end to end. + +### Prerequisites + +The suite is skipped unless BOTH env vars are set: + +| Env var | Purpose | +| ------------------------ | -------------------------------------------------------------------------- | +| `VOID_E2E_CF_TOKEN` | Cloudflare API token that can create + delete Workers Scripts, D1, and KV. | +| `VOID_E2E_CF_ACCOUNT_ID` | Cloudflare account id to deploy into. | + +The token needs, on that account, **Workers Scripts: Edit**, **D1: Edit**, and **Workers KV Storage: +Edit** (Edit implies read, covering the ownership/teardown list calls). Cron triggers ride along with +the worker deploy — no extra permission. + +The account must also have a **workers.dev subdomain** already registered (with its wildcard TLS cert +provisioned). Without one, `wrangler deploy` uploads the worker but cannot publish a public URL, and +the HTTP-200 assertion has nothing to hit. Opening the Workers & Pages dashboard once registers a +subdomain automatically; a freshly-registered subdomain takes a few minutes for its cert to go live, +during which the first run may see a TLS handshake failure — rerun once the cert is ready. + +With no env vars the suite SKIPS cleanly (0 run, N skipped). + +### Running it + +```sh +# Ensure the CLI is built first (dist/cli/cli.mjs): +vp run build:core + +VOID_E2E_CF_TOKEN=... VOID_E2E_CF_ACCOUNT_ID=... vp test run packages/void/test/e2e +``` + +### In CI + +`VOID_E2E_CF_TOKEN` and `VOID_E2E_CF_ACCOUNT_ID` are **environment secrets** on the `cloudflare-e2e` +GitHub Environment (not repo secrets), and a dedicated `live-e2e` job in `.github/workflows/ci.yml` +consumes them — the main `test` job carries no creds at all. Two layers keep the account token off +untrusted code: + +- The `live-e2e` job runs only on a direct push to `main` (`github.event_name == 'push' && github.ref +== 'refs/heads/main'`), so `pull_request` runs, tag pushes, and `workflow_call` (release) skip it. +- The `cloudflare-e2e` environment's deployment branch policy allows `main` **only**, enforced by + GitHub server-side on the run's ref. A `pull_request` run's ref is `refs/pull//merge`, so it can + never reach the secret **even if a PR edits this workflow** — the expression-level gate alone would + be bypassable that way; the environment ref-check is the real boundary. + +So the credentialed live deploy runs on every merge to `main`; everywhere else the creds are empty and +the suite SKIPS (the `test` job still imports the file and skips it). The `live-e2e` job itself is +fail-closed — a preflight step errors if either credential is empty (a missing, deleted, or +not-yet-rotated secret), so a misconfigured gate is loud instead of a silent green skip. Each +`live-e2e` run also gets `VOID_E2E_RUN_ID = -` for the unique per-run resource names +below. + +### Resource naming & cleanup + +Each run derives a UNIQUE resource name — `void-e2e-cf-deploy-` — so overlapping runs never +collide on remote resources. The suffix is `VOID_E2E_RUN_ID` (CI: `-`) or, locally, a +timestamp + random. Derived names mirror the provisioner: `-db` (D1), `-kv` (KV). The +shared `void-e2e-cf-deploy-` prefix keeps every resource greppable, so an orphan from a hard-killed run +is easy to find and `wrangler delete`. A pre-run ownership gate refuses to start if the run's names +already exist (failing closed on any lookup it cannot confirm), so the test never adopts or deletes a +resource it does not own. Teardown runs even when the deploy or an assertion fails. + +> **Local concurrency:** unique names isolate _remote_ resources, but a run also snapshots and +> overwrites the shared `playground/spa/wrangler.jsonc` and builds in place. Do **not** run two live +> e2e invocations against the same checkout at once — they would race that file. CI is unaffected (each +> runner is an isolated checkout, and only the single `main`-push `live-e2e` job deploys). The deploy +> test also asserts the returned host's worker label equals this run's `WORKER_NAME`, so a clobbered +> overlay fails loudly instead of validating another run's worker. diff --git a/packages/void/test/e2e/cloudflare-deploy-live.test.ts b/packages/void/test/e2e/cloudflare-deploy-live.test.ts new file mode 100644 index 000000000..8da4cca2f --- /dev/null +++ b/packages/void/test/e2e/cloudflare-deploy-live.test.ts @@ -0,0 +1,788 @@ +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import spawn from 'cross-spawn'; +import { join } from 'pathe'; +import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; + +// --------------------------------------------------------------------------------------------- +// Gated live-account e2e for the self-host `void deploy --backend cloudflare` path. +// +// Runs whenever BOTH env vars are present, and cleanly SKIPS (per test, at runtime) otherwise: +// - VOID_E2E_CF_TOKEN a Cloudflare API token with permissions to create + delete Workers +// Scripts, D1, and KV in the target account. +// - VOID_E2E_CF_ACCOUNT_ID the Cloudflare account id to deploy into. +// +// When they are set, this deploys the `playground/spa` app (D1 + KV + cron) to the target account +// under a UNIQUE per-run worker name (derived from `VOID_E2E_RUN_ID` in CI, a timestamp locally) so +// that overlapping runs — two pushes in flight, a rerun of the same commit — never collide on the +// same worker or D1/KV. Each run owns a fresh, exclusively-named set that teardown deletes, then +// asserts the live worker answers HTTP 200. +// +// Why it does NOT drive `--provision`: `void deploy --provision` fails closed in non-interactive +// shells by design (a spawned CLI has no TTY, and CI cannot commit back the ids a create would +// mint). A subprocess can never satisfy that gate, so the test plays the role the provisioner +// would: it creates the id-bearing D1 + KV directly (`wrangler d1/kv … create`), writes their real +// ids into the root config exactly as `--provision` would, then runs the CI-safe +// `void deploy --backend cloudflare` (no `--provision`) — exercising the real build → tamper-guard +// → remote-migrations → `wrangler deploy` pipeline end to end against a live account. +// +// In CI the two vars come from repo secrets (same-repo pushes/PRs); fork PRs get no secrets and skip. +// Do not commit a token; to exercise the real path locally, export the two vars: +// VOID_E2E_CF_TOKEN=... VOID_E2E_CF_ACCOUNT_ID=... vp test run packages/void/test/e2e +// --------------------------------------------------------------------------------------------- + +// Read the credentials + the enable gate at EXECUTION time, never at module top-level. Vitest imports +// (collects) this file BEFORE the CI runner injects the job's step env into the worker's process.env, +// so a top-level `process.env.VOID_E2E_CF_TOKEN` read sees an empty string and `describe.skipIf` would +// skip the suite even when the credentials are present — while an identical read inside a hook/test +// (which run AFTER injection) sees the real value. So these are functions, called from the hooks/tests. +function cfToken(): string { + return process.env.VOID_E2E_CF_TOKEN ?? ''; +} +function cfAccountId(): string { + return process.env.VOID_E2E_CF_ACCOUNT_ID ?? ''; +} +// Windows is intentionally excluded from the LIVE deploy: invoking the wrangler bin against a real +// account on win32 is unverified, and this gate runs on EVERY CI — a first-time, unproven windows +// live deploy must not be what turns `main` red. The real-build integration test +// (test/integration/cloudflare-deploy-build.test.ts) already exercises the build path on windows; +// enabling the live path there is a follow-up gated on a verified windows run. Fork PRs also skip +// cleanly: they receive no secrets, so the token is empty. +function e2eEnabled(): boolean { + return cfToken().length > 0 && cfAccountId().length > 0 && process.platform !== 'win32'; +} +const NO_CREDS_NOTE = + 'VOID_E2E_CF_TOKEN + VOID_E2E_CF_ACCOUNT_ID not set (or win32) — live deploy skipped'; + +const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); +const PACKAGE_ROOT = join(ROOT, 'packages', 'void'); +const CLI_ENTRY = join(PACKAGE_ROOT, 'dist', 'cli', 'cli.mjs'); +const WRANGLER_BIN = join(PACKAGE_ROOT, 'node_modules', '.bin', 'wrangler'); +// Bound each deploy subprocess BELOW the enclosing vitest timeout. `spawn.sync` blocks the test +// worker's event loop, so a hung CLI/wrangler would keep vitest's own timer from firing AND keep +// `afterAll` from running — leaking the worker/D1/KV. On timeout, spawnSync returns a null status, +// which fails the status assertion loudly and lets teardown reclaim the resources. +const DEPLOY_TIMEOUT_MS = 480_000; +// The same event-loop-block reasoning applies to every `wrangler` call (setup create/list, the +// ownership gate, teardown list/delete): an unbounded `spawn.sync` on a stalled wrangler/CF call would +// freeze vitest's hook timer and strand already-created resources. Quick ops, so a much shorter bound. +const WRANGLER_TIMEOUT_MS = 120_000; +// Per-request abort deadline for the polling fetches, so a single stalled request aborts and the +// retry loop moves on instead of consuming the whole per-test budget on one hung socket. +const REQUEST_TIMEOUT_MS = 20_000; +// `WRANGLER_TIMEOUT_MS` bounds ONE stalled call, but each hook fires SEVERAL wrangler calls back to +// back with no `await` between them (setup: 6 create/list; teardown: up to 5 list/delete), so +// `spawn.sync` blocks the event loop across the whole run and vitest's hook timer can never fire to +// interrupt it — the per-call bound alone lets the AGGREGATE (5–6 × 120s) blow past the hook budget, +// and an external cancel mid-teardown strands D1/KV. These budgets cap the SUM of wrangler wall-clock +// per hook: each hook stamps `wranglerDeadline` at its start and `wrangler()` shrinks each call's +// timeout to the time left, skipping (as a failed call) once spent. Sized under the hook timeouts — +// setup: 90s + DEPLOY_TIMEOUT_MS (a separate spawn) = 570s < 600s beforeAll; teardown: 200s + the +// ~64s async worker probe = 264s < 300s afterAll. +const SETUP_WRANGLER_BUDGET_MS = 90_000; +const TEARDOWN_WRANGLER_BUDGET_MS = 200_000; + +// Deploy target: the SPA playground (real, buildable Void app exercising D1 + KV + a cron). The +// deploy CLI runs its own build → provision → migrate → deploy pipeline against this dir. +const APP = join(ROOT, 'playground', 'spa'); +const APP_WRANGLER = join(APP, 'wrangler.jsonc'); + +// A UNIQUE per-run worker name so running this on EVERY CI is safe: two runs that overlap (two pushes +// in flight, a rerun of the same commit) must never deploy the same-named worker or create the same +// D1/KV, or they would clobber each other and teardown would race one against the other. The suffix is +// `VOID_E2E_RUN_ID` (CI sets it to run-id + attempt — unique per deploying run; windows skips the live +// path so only the ubuntu leg deploys) or, locally, a timestamp + random. The shared +// `void-e2e-cf-deploy-` prefix keeps every resource greppable for manual cleanup of an orphan from a +// hard-killed run. Derived names mirror the provisioner (`suffixedResourceName`): `-db` (D1), +// `-kv` (KV). +function uniqueRunSuffix(): string { + // `||`, not `??`: `VOID_E2E_RUN_ID` arrives as an EMPTY STRING (not undefined) when unset, because + // `vite.config.ts` forwards `VOID_E2E_RUN_ID: process.env.VOID_E2E_RUN_ID ?? ''` into the worker + // via `test.env`. `??` would treat that `''` as present and collapse every local run onto the + // `-local` suffix (below), so overlapping local runs would clobber one shared worker/D1/KV. `||` + // lets the empty value fall through to a unique timestamp + random. CI sets a non-empty run id, so + // its value is unaffected. + const raw = + process.env.VOID_E2E_RUN_ID || + `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; + // Cloudflare worker/D1/KV names are lowercase `[a-z0-9-]` and must not start/end with `-`. + // Sanitize, then cap the suffix so the worker name — and its `-db`/`-kv` tails — stay well under + // the 63-char limit. + const cleaned = raw + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .slice(0, 28) + .replace(/^-+|-+$/g, ''); + return cleaned || 'local'; +} +// Computed in `beforeAll` (execution time), not here, so `VOID_E2E_RUN_ID` is read after the runner +// injects it — the same collection-vs-execution timing as the credentials above. A module-load read +// would always miss the CI run id and fall back to the timestamp. +let WORKER_NAME = ''; +let D1_NAME = ''; +let KV_TITLE = ''; +function computeResourceNames(): void { + WORKER_NAME = `void-e2e-cf-deploy-${uniqueRunSuffix()}`; + D1_NAME = `${WORKER_NAME}-db`; + KV_TITLE = `${WORKER_NAME}-kv`; +} + +// Binding names the SPA app resolves for its D1 + KV (Void's defaults: `void/db` → DB, `env.KV` → +// KV). The pre-created resources must be written into the root config under THESE bindings so the +// deploy's desired-set / drift-check sees them as satisfied. +const D1_BINDING = 'DB'; +const KV_BINDING = 'KV'; +// Where Void keeps D1 migrations — the deploy's no-provision path REQUIRES `migrations_dir` on the +// D1 entry (else it hard-errors), and `wrangler d1 migrations apply --remote` reads it. Matches what +// the provisioner stamps. +const MIGRATIONS_DIR = './db/migrations'; +const COMPAT_DATE = '2026-02-24'; + +let wranglerSnapshotTaken = false; +let wranglerOriginal: string | undefined; +let deployOutput = ''; +let deployStatus: number | null = null; +// Set true ONLY once the pre-deploy ownership gate proves none of the fixed-name resources already +// existed in the account. Teardown deletes ONLY when this is true, so a collision (gate throws) can +// never delete a pre-existing resource the test does not own. +let ownershipVerified = false; + +/** The credentialed env every real Cloudflare call (deploy + teardown) shares. */ +function cloudEnv(): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { + ...process.env, + CLOUDFLARE_API_TOKEN: cfToken(), + CLOUDFLARE_ACCOUNT_ID: cfAccountId(), + }; + // This suite only supports the PUBLIC Cloudflare API + a `*.workers.dev` URL: `workerExistence()` + // fetches `api.cloudflare.com` directly and the 200 assertion hits `*.workers.dev`. Scrub every + // wrangler transport-redirect override inherited from the operator's shell so the spawned wrangler + // calls AND the direct fetch resolve the SAME api base. Otherwise a worker deployed to a + // staging/FedRAMP/custom base would 404 on the public api, and teardown's existence check would + // treat it as absent and skip the delete — leaking the worker on the alternate base. + delete env.CLOUDFLARE_API_BASE_URL; + delete env.CF_API_BASE_URL; + delete env.WRANGLER_API_ENVIRONMENT; + delete env.CLOUDFLARE_COMPLIANCE_REGION; + return env; +} + +// Absolute wall-clock deadline (epoch ms) for the CURRENT hook's aggregate wrangler work; each hook +// stamps it at its start (see SETUP_/TEARDOWN_WRANGLER_BUDGET_MS). Infinity outside a hook so a stray +// call is still bounded by WRANGLER_TIMEOUT_MS alone. +let wranglerDeadline = Number.POSITIVE_INFINITY; + +/** Best-effort `wrangler` invocation for teardown — never throws, so afterAll always continues. */ +function wrangler(args: Array): { status: number; stdout: string; output: string } { + // Shrink this call's timeout to the time left in the hook's aggregate budget, so a run of stalled + // calls can't collectively overrun the hook (spawn.sync blocks the loop, so vitest can't interrupt). + // Once the budget is spent, skip the call as a failed one rather than launching a doomed spawn — the + // ownership gate fails closed and teardown records the leak loudly instead of silently hanging. + const remaining = wranglerDeadline - Date.now(); + if (remaining <= 0) { + return { + status: 1, + stdout: '', + output: `wrangler ${args.join(' ')} skipped — hook wrangler-time budget exhausted`, + }; + } + const res = spawn.sync(WRANGLER_BIN, args, { + cwd: APP, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + env: cloudEnv(), + // Bound each call so a stalled wrangler/CF request can't block the event loop and freeze teardown + // (see WRANGLER_TIMEOUT_MS). On timeout, status is null → `?? 1` makes it a non-zero (failed) call, + // which every caller already handles: the ownership gate fails closed, teardown records a failure. + timeout: Math.min(WRANGLER_TIMEOUT_MS, remaining), + }); + const stdout = res.stdout ?? ''; + const stderr = res.stderr ?? ''; + // `output` (stdout+stderr) is for human-facing error text only. JSON parsing must read `stdout` + // ALONE: wrangler can print notices/warnings to stderr on a status-0 list (update notices, a + // "Delegating to locally-installed wrangler" line, a Node deprecation warning), and any non-JSON + // trailing the payload would make JSON.parse throw — breaking the ownership gate / teardown. + return { + status: res.status ?? 1, + stdout, + output: `${stdout}${stderr}${res.error ? `\n${res.error}` : ''}`, + }; +} + +/** + * Definitively resolve whether the fixed-name worker already exists, for the ownership gate. The CF + * API returns an unambiguous 200 (exists) / 404 (absent) for a script, unlike a wrangler exit code + * (a genuinely-absent worker ALSO exits nonzero). A DEFINITIVE 200/404 is returned immediately; any + * other outcome — a non-200/404 status or a network error — is retried a few times so a single + * transient CF API blip (a 5xx, a 429, a dropped connection) does not needlessly fail the gate. Only + * when EVERY attempt is non-definitive does it return `'unknown'`, which the gate treats as a + * collision and fails closed on — so an ownership lookup that merely errored can never be mistaken + * for "the name is free", while a lone hiccup no longer blocks an otherwise-clean run. + */ +async function workerExistence(): Promise<'exists' | 'absent' | 'unknown'> { + for (let attempt = 0; attempt < 3; attempt++) { + try { + const res = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${cfAccountId()}/workers/scripts/${WORKER_NAME}`, + { + headers: { Authorization: `Bearer ${cfToken()}` }, + // Abort a stalled connection so the retry loop (and teardown that depends on it) stays + // bounded — an accepted-but-never-answered request would otherwise hang the whole check. + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }, + ); + if (res.status === 200) { + return 'exists'; + } + if (res.status === 404) { + return 'absent'; + } + } catch { + // Network error — transient; fall through to retry. + } + if (attempt < 2) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + } + } + return 'unknown'; +} + +/** + * Create the throwaway D1 database directly and return its real id — the id-bearing half of what + * `--provision` would do, but runnable non-interactively. `d1 create` has no JSON flag, so the id is + * resolved from `d1 list --json` by EXACT name. Throws on any failure so `beforeAll` surfaces a + * clear cause (teardown still deletes whatever got created). + */ +function createD1Database(): string { + const created = wrangler(['d1', 'create', D1_NAME]); + if (created.status !== 0) { + throw new Error( + `live e2e setup: "wrangler d1 create ${D1_NAME}" failed:\n${created.output.trim()}`, + ); + } + const list = wrangler(['d1', 'list', '--json']); + if (list.status !== 0) { + throw new Error(`live e2e setup: "wrangler d1 list" failed:\n${list.output.trim()}`); + } + let databases: Array<{ uuid: string; name: string }>; + try { + databases = JSON.parse(list.stdout) as Array<{ uuid: string; name: string }>; + } catch { + throw new Error(`live e2e setup: "wrangler d1 list" returned non-JSON:\n${list.output.trim()}`); + } + const db = databases.find((d) => d.name === D1_NAME); + if (!db?.uuid) { + throw new Error(`live e2e setup: D1 ${D1_NAME} not found in the account after create`); + } + return db.uuid; +} + +/** + * Create the throwaway KV namespace directly and return its real id. `kv namespace create ` + * makes a namespace whose title is EXACTLY the argument (no worker prefix); the id is resolved from + * `kv namespace list` by EXACT title. Throws on any failure. + */ +function createKvNamespace(): string { + const created = wrangler(['kv', 'namespace', 'create', KV_TITLE]); + if (created.status !== 0) { + throw new Error( + `live e2e setup: "wrangler kv namespace create ${KV_TITLE}" failed:\n${created.output.trim()}`, + ); + } + const list = wrangler(['kv', 'namespace', 'list']); + if (list.status !== 0) { + throw new Error(`live e2e setup: "wrangler kv namespace list" failed:\n${list.output.trim()}`); + } + let namespaces: Array<{ id: string; title: string }>; + try { + namespaces = JSON.parse(list.stdout) as Array<{ id: string; title: string }>; + } catch { + throw new Error( + `live e2e setup: "wrangler kv namespace list" returned non-JSON:\n${list.output.trim()}`, + ); + } + const ns = namespaces.find((n) => n.title === KV_TITLE); + if (!ns?.id) { + throw new Error(`live e2e setup: KV ${KV_TITLE} not found in the account after create`); + } + return ns.id; +} + +/** Extract the deployed workers.dev URL the CLI prints on a successful deploy. */ +function extractWorkerUrl(output: string): string | undefined { + // `[a-z0-9.-]+` spans the MULTI-label host (`<worker>.<account-subdomain>.workers.dev`); the + // optional `(?:\/[^\s]*)?` consumes only a real URL path, so a trailing sentence period (were the + // URL ever printed in prose) is not captured. A bare `[a-z0-9-]+` before `.workers.dev` would NOT + // match the two-label host and would return undefined — do not "simplify" it to that. + return output.match(/https:\/\/[a-z0-9.-]+\.workers\.dev(?:\/[^\s]*)?/)?.[0]; +} + +/** A `setTimeout` delay that also resolves immediately when `signal` aborts, so a pending retry wait + * never outlives a timed-out test. */ +function abortableDelay(ms: number, signal?: AbortSignal): Promise<void> { + return new Promise((resolve) => { + if (signal?.aborted) { + resolve(); + return; + } + const timer = setTimeout(resolve, ms); + signal?.addEventListener( + 'abort', + () => { + clearTimeout(timer); + resolve(); + }, + { once: true }, + ); + }); +} + +/** + * Poll the live worker until it serves a 200. A BRAND-NEW `*.workers.dev` hostname (every run uses a + * unique worker name) needs the route to propagate globally before the first request succeeds, and + * that window is materially longer from a CI runner's network path than from a dev machine — a run + * observed a solid 30s of 404s before the route went live. So poll generously (~2 min). Retries on ANY + * non-200 — not just 5xx — so a propagation-window 404 is not mistaken for the final answer. On + * exhaustion returns the LAST response so the caller's status assertion reports the real final status; + * throws only when every attempt was a network error (no response at all). `init` is forwarded to + * `fetch` for non-GET requests (e.g. a KV `PUT`) and defaults to a GET. + */ +async function fetchWithRetry( + url: string, + init: RequestInit = {}, + attempts = 40, + delayMs = 3000, + signal?: AbortSignal, +): Promise<Response> { + let lastResponse: Response | undefined; + let lastError: unknown; + for (let i = 0; i < attempts; i++) { + // Overall deadline: callers pass the test's `ctx.signal`, which vitest aborts at the test timeout. + // Without it, a worker that accepts connections but never answers could keep this loop running + // (e.g. 40 × ~22s ≈ 15 min) long after vitest already failed the test, leaking requests and timers + // into later tests and teardown. Stop as soon as the enclosing test is done. + if (signal?.aborted) { + break; + } + try { + const res = await fetch(url, { + ...init, + // Per-request deadline (fail a single stalled socket fast) AND the overall test signal, so + // neither one request nor the whole loop can outlive its budget. + signal: + init.signal ?? + (signal + ? AbortSignal.any([AbortSignal.timeout(REQUEST_TIMEOUT_MS), signal]) + : AbortSignal.timeout(REQUEST_TIMEOUT_MS)), + }); + if (res.status === 200) { + return res; + } + lastResponse = res; + lastError = undefined; + } catch (error) { + lastError = error; + lastResponse = undefined; + } + if (i < attempts - 1) { + await abortableDelay(delayMs, signal); + } + } + if (lastResponse) { + return lastResponse; + } + throw lastError ?? new Error(`worker at ${url} never answered`); +} + +/** + * Whether the deployed worker has its `*.workers.dev` route enabled, for diagnosing a persistent 404. + * A propagation-window 404 has the route enabled (just not live yet); a route that is DISABLED 404s + * forever. Best-effort — returns a short human string, never throws. + */ +async function workersDevEnabled(): Promise<string> { + try { + const res = await fetch( + `https://api.cloudflare.com/client/v4/accounts/${cfAccountId()}/workers/scripts/${WORKER_NAME}/subdomain`, + { + headers: { Authorization: `Bearer ${cfToken()}` }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }, + ); + const body = (await res.json().catch(() => ({}))) as { result?: unknown }; + return `workers.dev subdomain endpoint ${res.status}: ${JSON.stringify(body.result ?? body)}`; + } catch (error) { + return `workers.dev subdomain check errored: ${String(error)}`; + } +} + +/** Authenticated GET against the Cloudflare API for THIS deployed worker (Bearer `cfToken()`). */ +async function cfWorkerApi(path: string, signal?: AbortSignal): Promise<Response> { + return fetch( + `https://api.cloudflare.com/client/v4/accounts/${cfAccountId()}/workers/scripts/${WORKER_NAME}${path}`, + { + headers: { Authorization: `Bearer ${cfToken()}` }, + // Bound the request so one stalled schedules read aborts and the caller's retry loop continues; + // when the caller passes the test signal, also abort the moment Vitest times the test out. + signal: signal + ? AbortSignal.any([AbortSignal.timeout(REQUEST_TIMEOUT_MS), signal]) + : AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }, + ); +} + +describe('void deploy --backend cloudflare (live account)', () => { + beforeAll(async () => { + // Gate at execution time (see the credential note): when the token/account are absent the suite is + // a no-op — the tests below skip too. Compute the unique resource names now, from the injected env. + if (!e2eEnabled()) { + return; + } + // Cap the SUM of this hook's wrangler wall-clock (the ownership-gate lists + the create/list pairs) + // so it plus the separate deploy spawn stays under the 600s beforeAll budget. The deploy is NOT a + // wrangler() call, so it keeps its own DEPLOY_TIMEOUT_MS. + wranglerDeadline = Date.now() + SETUP_WRANGLER_BUDGET_MS; + computeResourceNames(); + + // Ownership gate — refuse to run if any of this run's resources ALREADY exist in the target account. + // `--provision` ADOPTS an exact-name D1/KV (it would migrate pre-existing data), and teardown + // deletes by these exact names, so a collision with a real resource risks destroying data this + // test does not own. Only after proving the names are exclusively ours do we set + // `ownershipVerified` — the sole condition under which teardown deletes anything. + // + // Every check fails CLOSED: a lookup that neither confirms "free" nor confirms "taken" (a + // transient API error, a missing read scope, a non-JSON body) is recorded as a collision, so an + // ambiguous result can never be mistaken for a free name and let teardown delete a resource this + // test did not create. + const collisions: Array<string> = []; + + // Worker — a genuinely-absent worker ALSO makes `wrangler` exit nonzero, so an exit code cannot + // tell "free" from "errored". Resolve it against the CF API instead, which returns an unambiguous + // 200/404; anything else is `'unknown'` → treated as a collision (fail closed). + const worker = await workerExistence(); + if (worker === 'exists') { + collisions.push(`worker ${WORKER_NAME}`); + } else if (worker === 'unknown') { + collisions.push(`worker ${WORKER_NAME} (could not confirm it is free — lookup errored)`); + } + + // D1 — `d1 info <name>` exits nonzero BOTH when the database is absent and when the call errors, + // so it cannot fail closed. List every database instead: a successful JSON list that omits the + // name proves it is free; a list failure or non-JSON body is ambiguous → collision. + const d1List = wrangler(['d1', 'list', '--json']); + if (d1List.status !== 0) { + collisions.push(`d1 database ${D1_NAME} (could not list databases to rule out a collision)`); + } else { + try { + const databases = JSON.parse(d1List.stdout) as Array<{ name: string }>; + if (databases.some((d) => d.name === D1_NAME)) { + collisions.push(`d1 database ${D1_NAME}`); + } + } catch { + collisions.push( + `d1 database ${D1_NAME} (non-JSON database list — cannot rule out a collision)`, + ); + } + } + + const kvList = wrangler(['kv', 'namespace', 'list']); + if (kvList.status !== 0) { + collisions.push( + `kv namespace ${KV_TITLE} (could not list namespaces to rule out a collision)`, + ); + } else { + try { + const namespaces = JSON.parse(kvList.stdout) as Array<{ title: string }>; + if (namespaces.some((n) => n.title === KV_TITLE)) { + collisions.push(`kv namespace ${KV_TITLE}`); + } + } catch { + collisions.push( + `kv namespace ${KV_TITLE} (non-JSON namespace list — cannot rule out a collision)`, + ); + } + } + if (collisions.length > 0) { + throw new Error( + 'live e2e refuses to run — these throwaway-named resources already exist in the target ' + + 'account, so provisioning would adopt/migrate them and teardown would delete them ' + + `(risking data this test does not own):\n ${collisions.join('\n ')}\n` + + 'Remove them (or change WORKER_NAME) and rerun.', + ); + } + // Exclusive ownership proven: any resource with these names after this point was created by THIS + // run, so teardown may safely delete them. + ownershipVerified = true; + + // Snapshot BEFORE any filesystem work so an early failure never deletes a developer-local file. + wranglerOriginal = existsSync(APP_WRANGLER) ? readFileSync(APP_WRANGLER, 'utf-8') : undefined; + wranglerSnapshotTaken = true; + + // Play the provisioner's role: create the id-bearing D1 + KV directly (non-interactive), then + // write their REAL ids into the root config exactly as `--provision` would — including the D1 + // `migrations_dir` the no-provision deploy requires. `account_id` is pinned so the deploy's + // whoami / migrate / deploy all target the intended account. + const databaseId = createD1Database(); + const kvId = createKvNamespace(); + const overlay = { + name: WORKER_NAME, + compatibility_date: COMPAT_DATE, + account_id: cfAccountId(), + d1_databases: [ + { + binding: D1_BINDING, + database_name: D1_NAME, + database_id: databaseId, + migrations_dir: MIGRATIONS_DIR, + }, + ], + kv_namespaces: [{ binding: KV_BINDING, id: kvId }], + }; + writeFileSync(APP_WRANGLER, `${JSON.stringify(overlay, null, 2)}\n`); + + // The CI-safe path: no `--provision`. The root config now covers every desired id-bearing + // resource, so the drift-check passes; this exercises build → tamper-guard → remote migrations → + // `wrangler deploy` against the live account. + const res = spawn.sync('node', [CLI_ENTRY, 'deploy', '--backend', 'cloudflare'], { + cwd: APP, + encoding: 'utf8', + maxBuffer: 20 * 1024 * 1024, + env: cloudEnv(), + timeout: DEPLOY_TIMEOUT_MS, + }); + deployStatus = res.status; + deployOutput = `${res.stdout ?? ''}${res.stderr ?? ''}${res.error ? `\n${res.error}` : ''}`; + }, 600_000); + + afterAll(async () => { + // Tear DOWN the worker + the D1/KV created in `beforeAll`, even if the deploy or an assertion + // failed. Each delete is best-effort and independent so one failure never blocks the others. + // A delete is only required to succeed for a resource that STILL EXISTS: a resource confirmed + // ABSENT (e.g. the deploy failed before the worker was created) is already gone — no leak, no + // failure. A nonzero delete of a resource that IS present — or a lookup that cannot confirm the + // resource is gone — is COLLECTED and re-thrown after cleanup + config restore, so a genuine + // leak (or an ambiguous teardown) fails LOUD instead of reporting green. + const failures: Array<string> = []; + const attemptDelete = (label: string, args: Array<string>): void => { + const res = wrangler(args); + if (res.status !== 0) { + failures.push(`${label} (exit ${res.status})\n${res.output.trim()}`); + } + }; + // Delete ONLY when the ownership gate proved these names were free before the deploy, so a + // collision that aborted beforeAll never deletes a pre-existing resource the test does not own. + if (e2eEnabled() && ownershipVerified) { + // Cap the SUM of teardown's wrangler wall-clock (the worker probe is async + separately bounded) + // so the whole cleanup stays under the 300s afterAll budget even if every list/delete stalls. + wranglerDeadline = Date.now() + TEARDOWN_WRANGLER_BUDGET_MS; + // Worker — exists only if the deploy succeeded. Skip when the CF API confirms it is ABSENT + // (avoids a spurious "does not exist" failure on a deploy that never got that far); fail closed + // when existence cannot be confirmed ('unknown') by attempting the delete anyway. + const worker = await workerExistence(); + if (worker !== 'absent') { + attemptDelete(`worker ${WORKER_NAME}`, ['delete', '--name', WORKER_NAME, '--force']); + } + // D1 — created in beforeAll; delete when the account still lists it by EXACT name. A list + // failure cannot confirm the database is gone, so it is recorded (fail closed). + const d1List = wrangler(['d1', 'list', '--json']); + if (d1List.status !== 0) { + failures.push(`d1 list (exit ${d1List.status})\n${d1List.output.trim()}`); + } else { + try { + const databases = JSON.parse(d1List.stdout) as Array<{ name: string }>; + if (databases.some((d) => d.name === D1_NAME)) { + attemptDelete(`d1 ${D1_NAME}`, ['d1', 'delete', D1_NAME, '--skip-confirmation']); + } + } catch { + failures.push( + `d1 list returned non-JSON output — delete the ${D1_NAME} database manually`, + ); + } + } + // KV delete needs the namespace id — resolve it from the account's namespace list by EXACT + // title. Matching by exact title (not `endsWith`) avoids selecting an unrelated namespace that + // merely shares the suffix. A namespace absent from the list is already gone (skip). + const list = wrangler(['kv', 'namespace', 'list']); + if (list.status !== 0) { + failures.push(`kv namespace list (exit ${list.status})\n${list.output.trim()}`); + } else { + try { + const namespaces = JSON.parse(list.stdout) as Array<{ id: string; title: string }>; + const ns = namespaces.find((n) => n.title === KV_TITLE); + if (ns) { + attemptDelete(`kv ${KV_TITLE}`, ['kv', 'namespace', 'delete', '--namespace-id', ns.id]); + } + } catch { + failures.push( + `kv namespace list returned non-JSON output — delete the ${KV_TITLE} namespace manually`, + ); + } + } + } + // Restore the app's original root config (or remove the overlay if it had none) BEFORE surfacing + // any teardown failure, so the working tree is always left clean. + if (wranglerSnapshotTaken) { + if (wranglerOriginal === undefined) { + rmSync(APP_WRANGLER, { force: true }); + } else { + writeFileSync(APP_WRANGLER, wranglerOriginal); + } + } + if (failures.length > 0) { + throw new Error(`live e2e teardown failed — manual cleanup needed:\n${failures.join('\n')}`); + } + }, 300_000); + + it('deploys the worker to the live account', (ctx) => { + ctx.skip(!e2eEnabled(), NO_CREDS_NOTE); + expect(deployStatus, deployOutput).toBe(0); + // A successful `wrangler deploy` prints the worker's workers.dev URL. + const url = extractWorkerUrl(deployOutput); + expect(url, deployOutput).toBeDefined(); + // The host's FIRST label must be THIS run's worker name. If a concurrent local run had clobbered + // the shared `wrangler.jsonc` overlay, the deploy would have targeted a different worker — this + // catches that cross-run contamination rather than silently validating someone else's worker. + const workerLabel = new URL(url!).hostname.split('.')[0]; + expect(workerLabel, `deployed host for ${url}`).toBe(WORKER_NAME); + }); + + it('serves a live 200 from the deployed worker', async (ctx) => { + ctx.skip(!e2eEnabled(), NO_CREDS_NOTE); + const base = extractWorkerUrl(deployOutput); + expect(base, deployOutput).toBeDefined(); + const url = `${base!.replace(/\/$/, '')}/api/hello`; + const res = await fetchWithRetry(url, {}, 40, 3000, ctx.signal); + // On a persistent non-200, report the URL + whether the workers.dev route is even enabled, to + // tell a slow-propagation 404 (route enabled) from a route that never went live (disabled). + const diagnostic = + res.status === 200 ? '' : ` — GET ${url} → ${res.status}; ${await workersDevEnabled()}`; + expect(res.status, `worker ${WORKER_NAME}${diagnostic}`).toBe(200); + }, 200_000); + + it('persists data through the live D1 binding', async (ctx) => { + ctx.skip(!e2eEnabled(), NO_CREDS_NOTE); + const base = extractWorkerUrl(deployOutput); + expect(base, deployOutput).toBeDefined(); + const root = base!.replace(/\/$/, ''); + // A 500 here means the remote D1 migration never applied (no `users` table) or the DB binding did + // not resolve — the exact deploy failure the static /api/hello check cannot catch. A unique email + // keeps the read-back assertion precise regardless of any pre-existing rows. This (and the KV/ + // idempotency cases) run AFTER `serves a live 200` has already blocked until the hostname routes + // globally, so their shorter retry budgets only need to cover worker cold-start, not propagation. + const email = `e2e-${uniqueRunSuffix()}@example.com`; + const created = await fetchWithRetry( + `${root}/api/users`, + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ name: 'E2E User', email }), + }, + 15, + 2000, + ctx.signal, + ); + const createdBody = await created.text(); + expect(created.status, createdBody).toBe(200); + expect(JSON.parse(createdBody)).toEqual({ created: true }); + + const list = await fetchWithRetry(`${root}/api/users`, {}, 15, 2000, ctx.signal); + const listBody = await list.text(); + expect(list.status, listBody).toBe(200); + const rows = JSON.parse(listBody) as Array<{ email: string }>; + expect( + rows.some((u) => u.email === email), + `posted user ${email} not found in ${listBody}`, + ).toBe(true); + }, 120_000); + + it('round-trips a value through the live KV binding', async (ctx) => { + ctx.skip(!e2eEnabled(), NO_CREDS_NOTE); + const base = extractWorkerUrl(deployOutput); + expect(base, deployOutput).toBeDefined(); + const root = base!.replace(/\/$/, ''); + // Proves the `KV` binding resolves to the real namespace end to end: write via the deployed worker, + // then read it back. (The namespace id was pinned into wrangler.jsonc by beforeAll.) + const key = `probe-${uniqueRunSuffix()}`; + const value = `kv-${Date.now().toString(36)}`; + const put = await fetchWithRetry( + `${root}/api/kv/${key}`, + { method: 'PUT', body: value }, + 15, + 2000, + ctx.signal, + ); + expect(put.status, `PUT /api/kv/${key}`).toBe(200); + // KV is eventually consistent and a first-read MISS can be negatively cached at the edge for up to + // ~60s, so poll past that window (40 × 2s = 80s). A healthy binding whose first GET raced the + // write's central visibility then converges rather than false-failing CI. + const got = await fetchWithRetry(`${root}/api/kv/${key}`, {}, 40, 2000, ctx.signal); + const gotBody = await got.text(); + expect(got.status, gotBody).toBe(200); + expect(gotBody).toBe(value); + }, 150_000); + + it('registers the cron trigger on the deployed worker', async (ctx) => { + ctx.skip(!e2eEnabled(), NO_CREDS_NOTE); + // The deploy emits `triggers.crons` and `wrangler deploy` PUTs them to the worker's schedules. We + // assert the schedule is REGISTERED via the CF API — fast + deterministic — instead of waiting for + // the hourly job to actually fire. Retry so a single transient CF-API blip (a 429/5xx or a network + // throw) or a brief registration-visibility lag on the shared CI runner does not red an + // otherwise-healthy deploy; a genuinely-unregistered cron still fails loud after the retries. + let crons: Array<string | undefined> = []; + let diag = 'no response'; + for (let attempt = 0; attempt < 8; attempt++) { + // Stop as soon as Vitest times the test out: like `fetchWithRetry`, thread `ctx.signal` into the + // request AND the inter-attempt delay so 8 × 20s of polling can't outlive the 60s test budget. + if (ctx.signal.aborted) { + break; + } + try { + const res = await cfWorkerApi('/schedules', ctx.signal); + const body = (await res.json().catch(() => ({}))) as { + result?: { schedules?: Array<{ cron?: string }> }; + }; + diag = `status ${res.status}: ${JSON.stringify(body)}`; + if (res.status === 200) { + crons = (body.result?.schedules ?? []).map((s) => s.cron); + if (crons.includes('0 * * * *')) { + break; + } + } + } catch (error) { + diag = `fetch threw: ${String(error)}`; + } + if (attempt < 7) { + await abortableDelay(2000, ctx.signal); + } + } + expect(crons, `registered schedules — ${diag}`).toContain('0 * * * *'); + }, 60_000); + + it('re-deploys idempotently to the same worker', async (ctx) => { + ctx.skip(!e2eEnabled(), NO_CREDS_NOTE); + // A SECOND `void deploy` against the already-created worker/D1/KV must succeed cleanly: the + // drift-check still passes (the overlay covers every id), the remote migration is already applied + // (a no-op), and the worker is updated in place. The overlay wrangler.jsonc is still on disk + // (afterAll restores it only after every test), so this reuses it. Runs LAST for that reason. + const res = spawn.sync('node', [CLI_ENTRY, 'deploy', '--backend', 'cloudflare'], { + cwd: APP, + encoding: 'utf8', + maxBuffer: 20 * 1024 * 1024, + env: cloudEnv(), + timeout: DEPLOY_TIMEOUT_MS, + }); + const output = `${res.stdout ?? ''}${res.stderr ?? ''}${res.error ? `\n${res.error}` : ''}`; + expect(res.status, output).toBe(0); + // Prove THIS deploy re-targeted the same worker: read the URL from ITS OWN output (no fallback to + // the first deploy, which would mask a second deploy that uploaded nothing) and require the host's + // worker label to equal WORKER_NAME. + const base = extractWorkerUrl(output); + expect(base, output).toBeDefined(); + expect(new URL(base!).hostname.split('.')[0], output).toBe(WORKER_NAME); + const root = base!.replace(/\/$/, ''); + // The re-deployed worker still serves AND its bindings survived the update: /api/hello (static) is + // 200, and /api/users (a D1-backed route) is 200 — a dropped or broken DB binding would 500. + const served = await fetchWithRetry(`${root}/api/hello`, {}, 15, 2000, ctx.signal); + expect(served.status).toBe(200); + const dbRoute = await fetchWithRetry(`${root}/api/users`, {}, 15, 2000, ctx.signal); + expect(dbRoute.status, await dbRoute.text()).toBe(200); + }, 600_000); +}); diff --git a/packages/void/test/integration/cloudflare-deploy-build.test.ts b/packages/void/test/integration/cloudflare-deploy-build.test.ts new file mode 100644 index 000000000..2a017945e --- /dev/null +++ b/packages/void/test/integration/cloudflare-deploy-build.test.ts @@ -0,0 +1,271 @@ +import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import spawn from 'cross-spawn'; +import { join } from 'pathe'; +import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; +import { + computeDesiredCloudflareResources, + parseRootWranglerConfig, + reconstructExpectedContainers, + snapshotBindingIdentities, + snapshotPassthroughIdentities, + snapshotPassthroughRawFields, + verifyGeneratedDeployConfig, +} from '../../src/cli/deploy.ts'; +import { readConfig } from '../../src/config.ts'; + +// Real-build FP-safety lock for `void deploy --backend cloudflare`. The 600+ unit tests drive +// `runCloudflareDeploy` with a STUBBED `vite build` and HAND-WRITTEN emitted configs, so nothing +// there proves the guard's core premise: that a REAL `@cloudflare/vite-plugin` + wrangler build +// emits exactly the shape `verifyGeneratedDeployConfig` reconstructs, adding nothing that would +// false-positive a legitimate deploy. This test runs a real `vite build` of the richest playground +// (kitchen-sink: D1 + KV + R2 + AI + sandbox/containers + cron + queue), reconstructs `expected` +// with the SAME exported builders `runCloudflareDeploy` uses (steps 1 + 5/6), and asserts the real +// emission PASSES the real guard unmodified. A future plugin/wrangler upgrade that adds a field +// would break this lock instead of surfacing as a production deploy-blocking false positive. +// +// Negative cases start from the SAME real emitted artifact, apply one minimal tamper in memory, +// write it back to the path the guard reads, and assert it is REJECTED — real-shape teeth. + +const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); +const PLAYGROUND = 'playground/kitchen-sink'; +const PG = join(ROOT, PLAYGROUND); +const CONFIG_PATH = join(PG, 'wrangler.jsonc'); +const EMITTED_PATH = join(PG, 'dist', 'ssr', 'wrangler.json'); + +// Real-SHAPED ids the guard treats as genuine (`isRealD1Id` UUID / `isRealKvId` 32-hex). Because +// `void deploy --provision` writes real ids into the root config before the build, and the build +// merges the root config into the emitted artifact, an overlaid root `wrangler.jsonc` carrying +// these ids yields an emitted artifact whose id-bearing bindings match the pinned snapshot — the +// exact clean-deploy condition the guard authorizes. +const ACCOUNT_ID = '0123456789abcdef0123456789abcdef'; +const WORKER_NAME = 'void-kitchen-sink'; +const REAL_D1_ID = '52b3bbc7-c473-4b26-9486-1345f5de61c4'; +const REAL_KV_ID = '7c07b54c9f2a4d1e8b3f6a0c153aa11a'; +const REAL_ISR_CACHE_ID = 'a1b2c3d4e5f60718293a4b5c6d7e8f90'; +const OTHER_D1_ID = '11111111-2222-3333-4444-555555555555'; +const R2_BUCKET_NAME = 'void-kitchen-sink-storage'; + +// The overlaid root config. kitchen-sink ships NO root wrangler config (it relies on void.json + +// inference), so the test writes one declaring `account_id`, `name`, and real-shaped ids for every +// id-bearing binding the source uses, then removes it on teardown. +const OVERLAY_WRANGLER = { + name: WORKER_NAME, + account_id: ACCOUNT_ID, + compatibility_date: '2026-02-24', + d1_databases: [{ binding: 'DB', database_name: 'void-kitchen-sink-db', database_id: REAL_D1_ID }], + // kitchen-sink enables ISR (`pages/revalidate.server.ts`), so `computeDesiredCloudflareResources` + // adds a SECOND KV binding — `ISR_CACHE` — beyond the app's own `KV`. Pin a distinct real-shaped id + // for it too, so the emitted artifact carries both KV namespaces and the FP-lock covers the two-KV + // shape a real provisioned ISR deploy builds (not just the single app KV). + kv_namespaces: [ + { binding: 'KV', id: REAL_KV_ID }, + { binding: 'ISR_CACHE', id: REAL_ISR_CACHE_ID }, + ], + r2_buckets: [{ binding: 'STORAGE', bucket_name: R2_BUCKET_NAME }], +}; + +// The `expected` payload `runCloudflareDeploy` (steps 5/6) threads into the guard, rebuilt here from +// the same exported builders. +type ExpectedPayload = Parameters<typeof verifyGeneratedDeployConfig>[1]; + +let buildStatus: number | null = null; +let buildOutput = ''; +// The pristine real emission, parsed once and reused (deep-cloned) across every tamper case. +let pristineEmitted: Record<string, unknown> = {}; +let expectedPayload: ExpectedPayload; + +// Snapshot guard: only touch/restore `wrangler.jsonc` if we actually reached the snapshot step, so +// an early beforeAll failure never deletes a developer-local file the hook never saw. +let wranglerSnapshotTaken = false; +let wranglerOriginal: string | undefined; + +/** A fresh deep clone of the pristine emitted config, so a tamper never mutates the shared copy. */ +function cloneEmitted(): Record<string, unknown> { + return JSON.parse(JSON.stringify(pristineEmitted)) as Record<string, unknown>; +} + +/** + * Write a tampered emitted config to the exact path the guard reads (via the CF plugin's + * `.wrangler/deploy/config.json` redirect), run the REAL guard, then restore the pristine emission — + * even if the assertion throws. No rebuild: the tamper models a compromised build editing the one + * artifact `wrangler deploy` uploads. + */ +function guardOnTamper(mutate: (config: Record<string, unknown>) => void): { + ok: boolean; + reason?: string; +} { + const tampered = cloneEmitted(); + mutate(tampered); + writeFileSync(EMITTED_PATH, JSON.stringify(tampered)); + try { + return verifyGeneratedDeployConfig(PG, expectedPayload); + } finally { + writeFileSync(EMITTED_PATH, JSON.stringify(pristineEmitted)); + } +} + +describe('playground/kitchen-sink --backend cloudflare real-build deploy guard', () => { + beforeAll(async () => { + // Snapshot BEFORE any other filesystem work. + wranglerOriginal = existsSync(CONFIG_PATH) ? readFileSync(CONFIG_PATH, 'utf-8') : undefined; + wranglerSnapshotTaken = true; + + writeFileSync(CONFIG_PATH, `${JSON.stringify(OVERLAY_WRANGLER, null, 2)}\n`); + // Fresh build inputs: drop the previous dist + the stale CF-plugin deploy pointer so the guard + // trusts only a pointer this build wrote. + rmSync(join(PG, 'dist'), { recursive: true, force: true }); + rmSync(join(PG, '.wrangler', 'deploy', 'config.json'), { force: true }); + + // Reconstruct `expected` EXACTLY as runCloudflareDeploy does (steps 1 + 5/6): the desired set + + // signals from `computeDesiredCloudflareResources`, and the four snapshots + container set from + // the root config — captured BEFORE the build, matching the orchestrator's pre-build snapshot. + // Doing this AFTER the build would let build-time code that rewrote the root config launder a + // tampered binding set into `expected`, so the FP-safety lock could pass an artifact that + // production (which snapshots pre-build) would reject. + const voidConfig = readConfig(PG); + const { desired, needsAI, needsSandbox, expectedCrons } = + await computeDesiredCloudflareResources(PG, voidConfig, CONFIG_PATH); + const rootConfig = parseRootWranglerConfig(CONFIG_PATH); + expectedPayload = { + accountId: ACCOUNT_ID, + workerName: WORKER_NAME, + desired, + bindingIdentitySnapshot: snapshotBindingIdentities(rootConfig), + passthroughIdentitySnapshot: snapshotPassthroughIdentities(rootConfig), + passthroughRawFields: snapshotPassthroughRawFields(rootConfig), + needsAI, + expectedContainers: reconstructExpectedContainers(voidConfig, PG, rootConfig, needsSandbox), + expectedCrons, + }; + + const result = spawn.sync('vp', ['run', '--filter', `./${PLAYGROUND}`, 'build'], { + cwd: ROOT, + encoding: 'utf8', + maxBuffer: 10 * 1024 * 1024, + }); + buildStatus = result.status; + buildOutput = `${result.stdout ?? ''}${result.stderr ?? ''}`; + expect(buildStatus, buildOutput).toBe(0); + + pristineEmitted = JSON.parse(readFileSync(EMITTED_PATH, 'utf-8')) as Record<string, unknown>; + }); + + afterAll(() => { + if (!wranglerSnapshotTaken) { + return; + } + if (wranglerOriginal === undefined) { + rmSync(CONFIG_PATH, { force: true }); + } else { + writeFileSync(CONFIG_PATH, wranglerOriginal); + } + }); + + // --- Proof this is a REAL build (not a hand-written facsimile) --------------------------------- + + it('emits the CF-plugin deploy pointer the guard follows', () => { + const pointer = JSON.parse( + readFileSync(join(PG, '.wrangler', 'deploy', 'config.json'), 'utf-8'), + ) as { configPath?: string }; + expect(pointer.configPath).toBe('../../dist/ssr/wrangler.json'); + }); + + it('merges the pinned account, worker name, and real binding ids into the emitted artifact', () => { + expect(pristineEmitted.account_id).toBe(ACCOUNT_ID); + expect(pristineEmitted.name).toBe(WORKER_NAME); + expect(pristineEmitted.d1_databases).toContainEqual( + expect.objectContaining({ binding: 'DB', database_id: REAL_D1_ID }), + ); + expect(pristineEmitted.kv_namespaces).toContainEqual( + expect.objectContaining({ binding: 'KV', id: REAL_KV_ID }), + ); + // The SECOND KV binding an ISR app requires — the guard's clean-pass below therefore exercises + // the two-KV shape a real provisioned ISR deploy builds, not just the single app KV. + expect(pristineEmitted.kv_namespaces).toContainEqual( + expect.objectContaining({ binding: 'ISR_CACHE', id: REAL_ISR_CACHE_ID }), + ); + expect(pristineEmitted.r2_buckets).toContainEqual( + expect.objectContaining({ binding: 'STORAGE', bucket_name: R2_BUCKET_NAME }), + ); + }); + + it('emits the sandbox container, injected AI binding, cron trigger, and queue bindings', () => { + expect(pristineEmitted.containers).toContainEqual( + expect.objectContaining({ name: 'void-sandbox', class_name: 'Sandbox' }), + ); + expect(pristineEmitted.ai).toEqual({ binding: 'AI' }); + expect(pristineEmitted.triggers).toEqual({ crons: ['* * * * *'] }); + const queues = pristineEmitted.queues as { + producers: Array<{ queue: string }>; + consumers: Array<{ queue: string }>; + }; + expect(queues.producers).toContainEqual(expect.objectContaining({ queue: 'demo-queue' })); + expect(queues.consumers).toContainEqual(expect.objectContaining({ queue: 'demo-queue' })); + }); + + it('reconstructs a container set byte-identical to the emitted sandbox container', () => { + // The highest-value FP-safety lock: `reconstructExpectedContainers` (a pure pre-build function) + // must produce EXACTLY what the CF plugin emitted, or every sandbox deploy would false-positive. + expect(expectedPayload.expectedContainers).toEqual(pristineEmitted.containers); + }); + + // --- Positive: the real emission passes the real guard unmodified (FP-lock) ------------------- + + it('the real emitted config passes verifyGeneratedDeployConfig unmodified', () => { + expect(verifyGeneratedDeployConfig(PG, expectedPayload)).toEqual({ ok: true }); + }); + + // --- Negative: the SAME real emission, minimally tampered, is rejected (teeth) ----------------- + + it('rejects an injected container application', () => { + const result = guardOnTamper((config) => { + (config.containers as Array<unknown>).push({ class_name: 'Evil', image: '/attacker' }); + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/container application .*injected/i); + }); + + it('rejects a swapped D1 database_id', () => { + const result = guardOnTamper((config) => { + (config.d1_databases as Array<{ database_id: string }>)[0].database_id = OTHER_D1_ID; + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/identity|tampered|flattened/i); + }); + + it('rejects an injected id-bearing binding (EXTRA_DB)', () => { + const result = guardOnTamper((config) => { + (config.d1_databases as Array<unknown>).push({ + binding: 'EXTRA_DB', + database_name: 'attacker-db', + database_id: OTHER_D1_ID, + }); + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/not present before the build|injected/i); + }); + + it('rejects an injected cron schedule', () => { + const result = guardOnTamper((config) => { + (config.triggers as { crons: Array<string> }).crons.push('0 0 * * *'); + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/cron trigger schedule .*injected/i); + }); + + it('rejects an injected services binding', () => { + const result = guardOnTamper((config) => { + config.services = [{ binding: 'EVIL_SVC', service: 'attacker-worker' }]; + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/services binding .*injected/i); + }); + + it('rejects an injected routes entry', () => { + const result = guardOnTamper((config) => { + config.routes = [{ pattern: 'evil.example.com/*', zone_name: 'example.com' }]; + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/routes value does not match/i); + }); +}); diff --git a/playground/spa/README.md b/playground/spa/README.md index 5ab9fff55..fb8fc231e 100644 --- a/playground/spa/README.md +++ b/playground/spa/README.md @@ -13,6 +13,8 @@ routes/api/ users/ index.ts # GET list + POST create (valibot validation) [id].ts # GET single user by param + kv/ + [key].ts # PUT store + GET read a value in KV (:key param) middleware/ 01.logger.ts # Logs method + path for every request migrations/ @@ -28,6 +30,7 @@ crons/ - **Input validation** — `defineHandler.withValidator()` with valibot schemas for body and params - **Typed fetch client** — `fetch` from `void/client` with full type inference from route definitions - **Typed DB queries** — Drizzle ORM via `db` from `void/db` with schema defined in `db/schema.ts` +- **KV storage** — `c.env.KV` read/write in a route, plus the cron writing a heartbeat key - **Middleware** — `defineMiddleware` for cross-cutting concerns (request logging) - **Cron jobs** — `defineScheduled` with cron expression syntax (`"0 * * * *"`) - **SQL migrations** — auto-applied on dev server start diff --git a/playground/spa/db/migrations/meta/001_snapshot.json b/playground/spa/db/migrations/meta/001_snapshot.json new file mode 100644 index 000000000..b6124cd1e --- /dev/null +++ b/playground/spa/db/migrations/meta/001_snapshot.json @@ -0,0 +1,57 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "ccc9547b-6781-44a2-8880-37effa1cce86", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} diff --git a/playground/spa/db/migrations/meta/_journal.json b/playground/spa/db/migrations/meta/_journal.json new file mode 100644 index 000000000..fac8777c1 --- /dev/null +++ b/playground/spa/db/migrations/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1784708395851, + "tag": "001_create_users", + "breakpoints": true + } + ] +} diff --git a/playground/spa/routes/api/kv/[key].ts b/playground/spa/routes/api/kv/[key].ts new file mode 100644 index 000000000..468d65925 --- /dev/null +++ b/playground/spa/routes/api/kv/[key].ts @@ -0,0 +1,21 @@ +import * as v from 'valibot'; +import { defineHandler } from 'void'; + +// PUT /api/kv/:key — store the raw request body in KV under an e2e-namespaced key. +export const PUT = defineHandler.withValidator({ + params: v.object({ key: v.string() }), +})(async (c, { params }) => { + await c.env.KV.put(`e2e:${params.key}`, await c.req.text()); + return { ok: true }; +}); + +// GET /api/kv/:key — read the value back (404 when the key is absent). +export const GET = defineHandler.withValidator({ + params: v.object({ key: v.string() }), +})(async (c, { params }) => { + const value = await c.env.KV.get(`e2e:${params.key}`); + if (value === null) { + return new Response('Not found', { status: 404 }); + } + return c.text(value); +}); diff --git a/vite.config.ts b/vite.config.ts index 7df4cf9bd..08426ff5d 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,6 +3,17 @@ import { defineConfig } from 'vite-plus'; const maxWorkers = process.env.CI ? 2 : Math.max(2, Math.floor(availableParallelism() * 0.75)); +// Vitest forwards a FILTERED env to test workers — in CI only VOID_E2E_CF_TOKEN survives, while +// VOID_E2E_CF_ACCOUNT_ID and VOID_E2E_RUN_ID are dropped. The gated live Cloudflare deploy e2e +// (packages/void/test/e2e/cloudflare-deploy-live.test.ts) needs all three, so forward them into the +// core-markdown workers explicitly from this main config process (which has the runner's step env). +// Absent/unset → empty strings → the suite skips itself as designed. +const e2eWorkerEnv = { + VOID_E2E_CF_TOKEN: process.env.VOID_E2E_CF_TOKEN ?? '', + VOID_E2E_CF_ACCOUNT_ID: process.env.VOID_E2E_CF_ACCOUNT_ID ?? '', + VOID_E2E_RUN_ID: process.env.VOID_E2E_RUN_ID ?? '', +}; + // Windows CI runners are 2-3x slower for builds and server startup. const ciTimeoutScale = process.platform === 'win32' ? 3 : 1; const testTimeout = 120_000 * ciTimeoutScale; @@ -27,6 +38,11 @@ export default defineConfig({ }, 'test:all': { command: 'vp check && vp run build:all && vp run check && vp test run', + // `vp run` passes ONLY the env vars a task declares to that task — not the full environment. + // Without this, the gated live Cloudflare e2e's credentials never reach `vp test run`, so it + // skips even when the CI job injects them. `untrackedEnv` forwards them without folding them + // into the task's cache fingerprint (credentials must not bust the cache). + untrackedEnv: ['VOID_E2E_CF_TOKEN', 'VOID_E2E_CF_ACCOUNT_ID', 'VOID_E2E_RUN_ID'], }, }, }, @@ -325,8 +341,13 @@ export default defineConfig({ sequence: { groupOrder: 3 }, testTimeout, hookTimeout, + // Forward the live-e2e credentials past vitest's worker env filter (see e2eWorkerEnv). + env: e2eWorkerEnv, include: [ 'packages/void/test/integration/**/*.test.ts', + // Gated live-account e2e (test/e2e/**). Collected here so it is skipped cleanly in CI + // (no token) rather than silently undiscovered; runs only when its env vars are set. + 'packages/void/test/e2e/**/*.test.ts', 'packages/md/test/integration/**/*.test.ts', ], exclude: metaframeworks,