From a17671ae1912802a747ffaaf4e3a1d1f6fca2872 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 16 Sep 2026 17:30:35 +0200 Subject: [PATCH 1/3] test(cli): reconcile live test resource lifecycles --- .../sweep-live-projects.integration.test.ts | 133 +++++++ apps/cli/scripts/sweep-live-projects.sh | 107 +++-- .../branches/create/create.live.test.ts | 35 +- .../branches/delete/delete.live.test.ts | 20 +- .../branches/disable/disable.live.test.ts | 22 +- .../commands/branches/get/get.live.test.ts | 21 +- .../commands/branches/list/list.live.test.ts | 14 +- .../branches/update/update.live.test.ts | 23 +- .../helpers/branches-live.integration.test.ts | 215 ++++++++++ apps/cli/tests/helpers/branches-live.ts | 375 ++++++++++++++++++ .../helpers/live-project.integration.test.ts | 119 ++++++ apps/cli/tests/helpers/live-project.ts | 35 +- apps/cli/tests/helpers/live.ts | 117 +++--- 13 files changed, 1073 insertions(+), 163 deletions(-) create mode 100644 apps/cli/scripts/sweep-live-projects.integration.test.ts create mode 100644 apps/cli/tests/helpers/branches-live.integration.test.ts create mode 100644 apps/cli/tests/helpers/branches-live.ts create mode 100644 apps/cli/tests/helpers/live-project.integration.test.ts diff --git a/apps/cli/scripts/sweep-live-projects.integration.test.ts b/apps/cli/scripts/sweep-live-projects.integration.test.ts new file mode 100644 index 0000000000..325fbc990c --- /dev/null +++ b/apps/cli/scripts/sweep-live-projects.integration.test.ts @@ -0,0 +1,133 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const script = path.resolve(import.meta.dirname, "sweep-live-projects.sh"); +const directories: string[] = []; + +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); +}); + +type Scenario = { + lists: Array; + deletes: Record; +}; + +async function runSweep(scenario: Scenario) { + const directory = await mkdtemp(path.join(tmpdir(), "sweep-live-projects-")); + directories.push(directory); + const fakeSleep = path.join(directory, "sleep"); + await writeFile(fakeSleep, "#!/bin/sh\nexit 0\n"); + await chmod(fakeSleep, 0o755); + + let listIndex = 0; + const deletes: string[] = []; + const server = Bun.serve({ + port: 0, + fetch(request) { + const url = new URL(request.url); + const ref = url.pathname.split("/").pop() ?? ""; + if (request.method === "GET" && url.pathname === "/v1/projects") { + const value = scenario.lists[Math.min(listIndex++, scenario.lists.length - 1)]; + return typeof value === "number" + ? new Response("temporary failure", { status: value }) + : Response.json(value); + } + if (request.method === "DELETE" && url.pathname.startsWith("/v1/projects/")) { + deletes.push(ref); + const result = scenario.deletes[ref] ?? { status: 404 }; + return new Response(result.body === undefined ? null : JSON.stringify(result.body), { + status: result.status, + headers: { "x-request-id": `delete-${ref}` }, + }); + } + return new Response("not found", { status: 404 }); + }, + }); + try { + const child = Bun.spawn(["bash", script, "e2e-"], { + env: { + ...globalThis.process.env, + PATH: `${directory}:${globalThis.process.env.PATH}`, + SUPABASE_ACCESS_TOKEN: "test-token", + SUPABASE_LIVE_API_URL: `http://127.0.0.1:${server.port}`, + }, + stdout: "pipe", + stderr: "pipe", + }); + const timeout = setTimeout(() => child.kill(), 10_000); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]); + clearTimeout(timeout); + return { exitCode, stdout, stderr, deletes }; + } finally { + await server.stop(true); + } +} + +const active = (ref: string, name = `e2e-${ref}`) => ({ ref, name, status: "ACTIVE" }); + +describe("sweep-live-projects.sh", () => { + test("accepts refused deletion when a fresh authenticated list shows absence", async () => { + const result = await runSweep({ + lists: [[active("gone")], []], + deletes: { gone: { status: 403, body: { message: "already removed" } } }, + }); + expect(result.exitCode).toBe(0); + expect(result.deletes).toEqual(["gone"]); + expect(`${result.stdout}\n${result.stderr}`).not.toContain("test-token"); + }); + + test("accepts a terminal status and converges after a stale list", async () => { + const result = await runSweep({ + lists: [[active("stale")], [active("stale")], [{ ...active("stale"), status: "REMOVED" }]], + deletes: { stale: { status: 202 } }, + }); + expect(result.exitCode).toBe(0); + }); + + test("retries a transient read failure but rejects malformed evidence", async () => { + const transient = await runSweep({ + lists: [503, [active("retry")], []], + deletes: { retry: { status: 202 } }, + }); + expect(transient.exitCode).toBe(0); + + const malformed = await runSweep({ lists: [{ projects: [] }], deletes: {} }); + expect(malformed.exitCode).not.toBe(0); + expect(malformed.deletes).toEqual([]); + }); + + test("fails terminal listing errors without retrying or deleting", async () => { + const forbidden = await runSweep({ lists: [403, []], deletes: {} }); + expect(forbidden.exitCode).not.toBe(0); + expect(forbidden.deletes).toEqual([]); + + const unavailable = await runSweep({ lists: [503, 503, 503], deletes: {} }); + expect(unavailable.exitCode).not.toBe(0); + expect(unavailable.deletes).toEqual([]); + + const malformedReconciliation = await runSweep({ + lists: [[active("bad-evidence")], { projects: [] }], + deletes: { "bad-evidence": { status: 403 } }, + }); + expect(malformedReconciliation.exitCode).not.toBe(0); + }); + + test("attempts every owned project and fails if one remains active", async () => { + const result = await runSweep({ + lists: [ + [active("stuck"), active("other"), { ref: "foreign", name: "unrelated", status: "ACTIVE" }], + [active("stuck"), { ref: "foreign", name: "unrelated", status: "ACTIVE" }], + ], + deletes: { stuck: { status: 403 }, other: { status: 204 } }, + }); + expect(result.exitCode).not.toBe(0); + expect(result.deletes).toEqual(["stuck", "other"]); + }); +}); diff --git a/apps/cli/scripts/sweep-live-projects.sh b/apps/cli/scripts/sweep-live-projects.sh index 633c58d3b6..6a14903979 100755 --- a/apps/cli/scripts/sweep-live-projects.sh +++ b/apps/cli/scripts/sweep-live-projects.sh @@ -1,57 +1,84 @@ #!/usr/bin/env bash -# Delete every staging project whose name starts with the given prefix (the live -# e2e job's per-run prefix). Runs as the live e2e job's always() cleanup step, -# which propagates the exit code. -# -# Reads SUPABASE_ACCESS_TOKEN + SUPABASE_LIVE_API_URL from the environment. Exits -# non-zero if any DELETE failed and the project still reads as live; a failed -# *listing* also exits non-zero. +# Delete every staging project whose name starts with the live e2e prefix. set -eo pipefail PREFIX="${1:?usage: sweep-live-projects.sh PREFIX}" : "${SUPABASE_ACCESS_TOKEN:?SUPABASE_ACCESS_TOKEN required}" : "${SUPABASE_LIVE_API_URL:?SUPABASE_LIVE_API_URL required}" -# Retry into a file, not a pipe: curl rewinds a file between attempts, but a -# pipe keeps an already-flushed body and the retry would duplicate the listing. listing=$(mktemp) -project=$(mktemp) -trap 'rm -f "$listing" "$project"' EXIT -curl -fsS --retry 3 --retry-connrefused --max-time 60 --retry-max-time 120 \ - -H "Authorization: Bearer ${SUPABASE_ACCESS_TOKEN}" \ - -o "$listing" "${SUPABASE_LIVE_API_URL}/v1/projects" -# Capture the list in a var (not a pipe-to-while subshell) so a failed delete is -# recorded in $failed; a failed listing aborts above via errexit. -projects=$(jq -r --arg p "$PREFIX" '.[] | select(.name|startswith($p)) | "\(.ref // .id) \(.status)"' "$listing") +headers=$(mktemp) +response=$(mktemp) +trap 'rm -f "$listing" "$headers" "$response"' EXIT -# The suite's teardown can delete a project after the listing was taken, so a -# refused DELETE only counts once the project still reads as live afterwards -# (12 bounded reads, 5s apart); a removed project is refused or GOING_DOWN. -gone() { - local attempt code status +api_request() { + local method=$1 url=$2 output=$3 + : > "$headers" + : > "$output" + API_CODE=$(curl -sS --max-time 15 -X "$method" -D "$headers" -o "$output" \ + -H "Authorization: Bearer ${SUPABASE_ACCESS_TOKEN}" -w '%{http_code}' "$url") || API_CODE=000 + API_REQUEST_ID=$(awk 'tolower($0) ~ /^x-request-id:/ { sub(/^[^:]*:[[:space:]]*/, ""); gsub(/[[:space:]]+$/, ""); print; exit }' "$headers" | tr -cd '[:alnum:]_.:/-' | cut -c1-120) +} + +fetch_listing_once() { + api_request GET "${SUPABASE_LIVE_API_URL}/v1/projects" "$listing" + if [ "$API_CODE" != 200 ]; then + echo "project listing failed (HTTP $API_CODE${API_REQUEST_ID:+ request $API_REQUEST_ID})" >&2 + case "$API_CODE" in 000|408|429|5??) return 1 ;; esac + return 2 + fi + if ! jq -e 'type == "array" and all(.[]; (.name | type == "string") and ((.ref // .id) | type == "string") and ((.status // "") | type == "string"))' "$listing" >/dev/null; then + echo "project listing was malformed (HTTP 200${API_REQUEST_ID:+ request $API_REQUEST_ID})" >&2 + return 2 + fi +} + +initial_attempt=1 +while true; do + if fetch_listing_once; then + initial_result=0 + break + else + initial_result=$? + fi + [ "$initial_result" -eq 2 ] && break + [ "$initial_attempt" -lt 3 ] || break + initial_attempt=$((initial_attempt + 1)) + sleep 5 +done +if [ "$initial_result" -ne 0 ]; then + echo "::error::unable to obtain a complete authenticated project listing" >&2 + exit 1 +fi + +projects=$(jq -r --arg p "$PREFIX" '.[] | select(.name | startswith($p)) | "\(.ref // .id) \(.status // "")"' "$listing") + +# Reconcile through fresh authenticated list responses. A DELETE 403/400 or a +# single-project 404 is not evidence that a project is gone. +reconcile() { + local ref=$1 attempt for attempt in $(seq 12); do - # curl leaves the output file untouched when no body arrives. - : > "$project" - code=$(curl -sS --max-time 10 -o "$project" -w '%{http_code}' \ - -H "Authorization: Bearer ${SUPABASE_ACCESS_TOKEN}" \ - "${SUPABASE_LIVE_API_URL}/v1/projects/$1") || code=000 - status=$(jq -r '.status // empty' "$project" 2>/dev/null || true) - case "$code:$status" in - 403:* | 404:* | 200:GOING_DOWN | 200:REMOVED) - echo "project $1 already gone ($code${status:+ $status})" + if fetch_listing_once; then + if jq -e --arg ref "$ref" 'any(.[]; ((.ref // .id) == $ref) and ((.status // "") != "GOING_DOWN") and ((.status // "") != "REMOVED"))' "$listing" >/dev/null; then + echo "project $ref still active (list HTTP $API_CODE${API_REQUEST_ID:+ request $API_REQUEST_ID})" >&2 + else + echo "project $ref reconciled as absent or terminal (list HTTP $API_CODE${API_REQUEST_ID:+ request $API_REQUEST_ID})" return 0 - ;; - esac + fi + elif [ "$?" -eq 2 ]; then + echo "project $ref reconciliation evidence was unavailable or malformed" >&2 + return 1 + else + echo "project $ref reconciliation read failed (HTTP $API_CODE${API_REQUEST_ID:+ request $API_REQUEST_ID})" >&2 + fi [ "$attempt" -lt 12 ] && sleep 5 done - echo "project $1 still reads $code${status:+ $status}" return 1 } failed=0 while read -r ref status; do [ -n "$ref" ] || continue - # Already deleted by the suite; a second DELETE is refused. case "$status" in GOING_DOWN | REMOVED) echo "skipping project $ref ($status)" @@ -59,9 +86,13 @@ while read -r ref status; do ;; esac echo "deleting leftover project $ref" - if ! curl -fsS -X DELETE -H "Authorization: Bearer ${SUPABASE_ACCESS_TOKEN}" \ - "${SUPABASE_LIVE_API_URL}/v1/projects/${ref}" >/dev/null && ! gone "$ref"; then - echo "::error::failed to delete leftover project $ref" + api_request DELETE "${SUPABASE_LIVE_API_URL}/v1/projects/${ref}" "$response" + case "$API_CODE" in + 2??) echo "delete accepted for project $ref (HTTP $API_CODE${API_REQUEST_ID:+ request $API_REQUEST_ID})" ;; + *) echo "delete failed for project $ref (HTTP $API_CODE${API_REQUEST_ID:+ request $API_REQUEST_ID})" >&2 ;; + esac + if ! reconcile "$ref"; then + echo "::error::project $ref remains active or cleanup evidence was unavailable" >&2 failed=1 fi done <<< "$projects" diff --git a/apps/cli/src/commands/branches/create/create.live.test.ts b/apps/cli/src/commands/branches/create/create.live.test.ts index e219766666..4077567874 100644 --- a/apps/cli/src/commands/branches/create/create.live.test.ts +++ b/apps/cli/src/commands/branches/create/create.live.test.ts @@ -3,25 +3,48 @@ import { expect } from "vitest"; import { awaitLiveBranch, + requireLiveJson, removeLiveBranch, + removeLiveBranchByName, test, throwWithCleanup, } from "../../../../tests/helpers/live.ts"; -test("creates a preview branch", async ({ cli, project }) => { +test("creates a preview branch", async ({ cli, cliEffect, project }) => { const name = `cli-e2e-create-${randomUUID().slice(0, 8)}`; + let branchRef: string | undefined; + let createdSuccessfully = false; let targetError: unknown; let cleanupError: unknown; try { - const result = await cli(["branches", "create", name, "--project-ref", project.ref]); - expect(result.exitCode, result.stderr).toBe(0); - expect(result.stdout).toContain("Created preview branch"); - await awaitLiveBranch(cli, project, name); + const created = await cli([ + "branches", + "create", + name, + "--project-ref", + project.ref, + "--output-format", + "json", + ]); + createdSuccessfully = created.exitCode === 0; + expect(created.exitCode, created.stderr).toBe(0); + const body = requireLiveJson(created, "branches create"); + if (typeof body === "object" && body !== null && "project_ref" in body) { + const ref = body.project_ref; + if (typeof ref === "string" && ref.length > 0) branchRef = ref; + } + expect(body).toMatchObject({ + message: "Created preview branch", + project_ref: expect.any(String), + }); + expect(branchRef).toBeDefined(); + await awaitLiveBranch(cliEffect, project, name); } catch (error) { targetError = error; } finally { try { - await removeLiveBranch(cli, project, name); + if (branchRef !== undefined) await removeLiveBranch(cliEffect, project, branchRef); + else if (createdSuccessfully) await removeLiveBranchByName(cliEffect, project, name); } catch (error) { cleanupError = error; } diff --git a/apps/cli/src/commands/branches/delete/delete.live.test.ts b/apps/cli/src/commands/branches/delete/delete.live.test.ts index 75e835b024..a0fd5ff488 100644 --- a/apps/cli/src/commands/branches/delete/delete.live.test.ts +++ b/apps/cli/src/commands/branches/delete/delete.live.test.ts @@ -3,33 +3,37 @@ import { expect } from "vitest"; import { awaitLiveBranch, - removeLiveBranch, - requireLiveSuccess, + awaitLiveBranchRemoved, + createLiveBranch, test, throwWithCleanup, } from "../../../../tests/helpers/live.ts"; -test("deletes a preview branch", async ({ cli, project }) => { +test("deletes a preview branch", async ({ cli, cliEffect, project }) => { const name = `cli-e2e-delete-${randomUUID().slice(0, 8)}`; + let branchRef: string | undefined; let mayExist = false; + let deletionAcknowledged = false; let targetError: unknown; let cleanupError: unknown; try { + branchRef = await createLiveBranch(cliEffect, project, name); mayExist = true; - const created = await cli(["branches", "create", name, "--project-ref", project.ref]); - requireLiveSuccess(created, "branches create"); - await awaitLiveBranch(cli, project, name); + await awaitLiveBranch(cliEffect, project, name); const removed = await cli(["branches", "delete", name, "--project-ref", project.ref, "--yes"]); - if (removed.exitCode === 0) mayExist = false; + deletionAcknowledged = removed.exitCode === 0; expect(removed.exitCode, removed.stderr).toBe(0); expect(removed.stderr).toContain("Deleted preview branch"); + mayExist = false; + await awaitLiveBranchRemoved(cliEffect, project, branchRef, deletionAcknowledged); } catch (error) { targetError = error; } finally { if (mayExist) { try { - await removeLiveBranch(cli, project, name); + if (branchRef !== undefined) + await awaitLiveBranchRemoved(cliEffect, project, branchRef, deletionAcknowledged); } catch (error) { cleanupError = error; } diff --git a/apps/cli/src/commands/branches/disable/disable.live.test.ts b/apps/cli/src/commands/branches/disable/disable.live.test.ts index 53deac4040..b95f713c22 100644 --- a/apps/cli/src/commands/branches/disable/disable.live.test.ts +++ b/apps/cli/src/commands/branches/disable/disable.live.test.ts @@ -3,16 +3,19 @@ import { expect } from "vitest"; import { awaitLiveBranch, + awaitLiveBranchRemoved, awaitLiveBranchesRemoved, - removeLiveBranch, + createLiveBranch, requireLiveSuccess, test, throwWithCleanup, } from "../../../../tests/helpers/live.ts"; -test("disables preview branching", async ({ cli, project }) => { +test("disables preview branching", async ({ cli, cliEffect, project }) => { const name = `cli-e2e-disable-${randomUUID().slice(0, 8)}`; + let branchRef: string | undefined; let mayExist = false; + let deletionAcknowledged = false; let targetError: unknown; let cleanupError: unknown; try { @@ -20,14 +23,16 @@ test("disables preview branching", async ({ cli, project }) => { // creates then deletes one first, waiting for it to fully disappear. Sibling tests // re-enable branching by creating their own branch first. mayExist = true; - const created = await cli(["branches", "create", name, "--project-ref", project.ref]); - requireLiveSuccess(created, "branches create"); - await awaitLiveBranch(cli, project, name); + branchRef = await createLiveBranch(cliEffect, project, name); + await awaitLiveBranch(cliEffect, project, name); const removed = await cli(["branches", "delete", name, "--project-ref", project.ref, "--yes"]); - if (removed.exitCode === 0) mayExist = false; + deletionAcknowledged = removed.exitCode === 0; requireLiveSuccess(removed, "branches delete"); - await awaitLiveBranchesRemoved(cli, project); + mayExist = false; + if (branchRef !== undefined) + await awaitLiveBranchRemoved(cliEffect, project, branchRef, deletionAcknowledged); + await awaitLiveBranchesRemoved(cliEffect, project); const disabled = await cli(["branches", "disable", "--project-ref", project.ref]); expect(disabled.exitCode, disabled.stderr).toBe(0); @@ -37,7 +42,8 @@ test("disables preview branching", async ({ cli, project }) => { } finally { if (mayExist) { try { - await removeLiveBranch(cli, project, name); + if (branchRef !== undefined) + await awaitLiveBranchRemoved(cliEffect, project, branchRef, deletionAcknowledged); } catch (error) { cleanupError = error; } diff --git a/apps/cli/src/commands/branches/get/get.live.test.ts b/apps/cli/src/commands/branches/get/get.live.test.ts index a58ae1e9f2..422919b501 100644 --- a/apps/cli/src/commands/branches/get/get.live.test.ts +++ b/apps/cli/src/commands/branches/get/get.live.test.ts @@ -3,31 +3,20 @@ import { expect } from "vitest"; import { awaitLiveBranch, + createLiveBranch, removeLiveBranch, - requireLiveSuccess, test, throwWithCleanup, } from "../../../../tests/helpers/live.ts"; -test("gets a preview branch by name", async ({ cli, project }) => { +test("gets a preview branch by name", async ({ cli, cliEffect, project }) => { const name = `cli-e2e-get-${randomUUID().slice(0, 8)}`; let branchRef: string | undefined; let targetError: unknown; let cleanupError: unknown; try { - const created = await cli([ - "branches", - "create", - name, - "--project-ref", - project.ref, - "--output-format", - "json", - ]); - requireLiveSuccess(created, "branches create"); - branchRef = (JSON.parse(created.stdout) as { project_ref: string }).project_ref; - expect(branchRef, created.stdout).toBeTruthy(); - await awaitLiveBranch(cli, project, name); + branchRef = await createLiveBranch(cliEffect, project, name); + await awaitLiveBranch(cliEffect, project, name); const result = await cli(["branches", "get", name, "--project-ref", project.ref]); expect(result.exitCode, result.stderr).toBe(0); @@ -45,7 +34,7 @@ test("gets a preview branch by name", async ({ cli, project }) => { targetError = error; } finally { try { - await removeLiveBranch(cli, project, branchRef ?? name); + if (branchRef !== undefined) await removeLiveBranch(cliEffect, project, branchRef); } catch (error) { cleanupError = error; } diff --git a/apps/cli/src/commands/branches/list/list.live.test.ts b/apps/cli/src/commands/branches/list/list.live.test.ts index df57e09ec0..41d48ef098 100644 --- a/apps/cli/src/commands/branches/list/list.live.test.ts +++ b/apps/cli/src/commands/branches/list/list.live.test.ts @@ -2,21 +2,21 @@ import { randomUUID } from "node:crypto"; import { expect } from "vitest"; import { - awaitLiveBranch, + awaitLiveBranchListed, + createLiveBranch, removeLiveBranch, - requireLiveSuccess, test, throwWithCleanup, } from "../../../../tests/helpers/live.ts"; -test("lists a preview branch for the project", async ({ cli, project }) => { +test("lists a preview branch for the project", async ({ cli, cliEffect, project }) => { const name = `cli-e2e-list-${randomUUID().slice(0, 8)}`; + let branchRef: string | undefined; let targetError: unknown; let cleanupError: unknown; try { - const created = await cli(["branches", "create", name, "--project-ref", project.ref]); - requireLiveSuccess(created, "branches create setup"); - await awaitLiveBranch(cli, project, name); + branchRef = await createLiveBranch(cliEffect, project, name); + await awaitLiveBranchListed(cliEffect, project, name); const result = await cli([ "branches", @@ -33,7 +33,7 @@ test("lists a preview branch for the project", async ({ cli, project }) => { targetError = error; } finally { try { - await removeLiveBranch(cli, project, name); + if (branchRef !== undefined) await removeLiveBranch(cliEffect, project, branchRef); } catch (error) { cleanupError = error; } diff --git a/apps/cli/src/commands/branches/update/update.live.test.ts b/apps/cli/src/commands/branches/update/update.live.test.ts index 136df5463a..5feddd8736 100644 --- a/apps/cli/src/commands/branches/update/update.live.test.ts +++ b/apps/cli/src/commands/branches/update/update.live.test.ts @@ -3,32 +3,21 @@ import { expect } from "vitest"; import { awaitLiveBranch, + createLiveBranch, removeLiveBranch, - requireLiveSuccess, test, throwWithCleanup, } from "../../../../tests/helpers/live.ts"; -test("renames a preview branch", async ({ cli, project }) => { +test("renames a preview branch", async ({ cli, cliEffect, project }) => { const name = `cli-e2e-update-${randomUUID().slice(0, 8)}`; const renamed = `${name}-renamed`; let branchRef: string | undefined; let targetError: unknown; let cleanupError: unknown; try { - const created = await cli([ - "branches", - "create", - name, - "--project-ref", - project.ref, - "--output-format", - "json", - ]); - requireLiveSuccess(created, "branches create"); - branchRef = (JSON.parse(created.stdout) as { project_ref: string }).project_ref; - expect(branchRef, created.stdout).toBeTruthy(); - await awaitLiveBranch(cli, project, name); + branchRef = await createLiveBranch(cliEffect, project, name); + await awaitLiveBranch(cliEffect, project, name); // `--output json` keeps stdout payload-only and sends the confirmation to stderr. const updated = await cli([ @@ -45,12 +34,12 @@ test("renames a preview branch", async ({ cli, project }) => { expect(updated.exitCode, updated.stderr).toBe(0); expect(updated.stderr).toContain("Updated preview branch"); expect(JSON.parse(updated.stdout)).toMatchObject({ name: renamed }); - await awaitLiveBranch(cli, project, renamed); + await awaitLiveBranch(cliEffect, project, renamed); } catch (error) { targetError = error; } finally { try { - await removeLiveBranch(cli, project, branchRef ?? name); + if (branchRef !== undefined) await removeLiveBranch(cliEffect, project, branchRef); } catch (error) { cleanupError = error; } diff --git a/apps/cli/tests/helpers/branches-live.integration.test.ts b/apps/cli/tests/helpers/branches-live.integration.test.ts new file mode 100644 index 0000000000..3fc2c6bd32 --- /dev/null +++ b/apps/cli/tests/helpers/branches-live.integration.test.ts @@ -0,0 +1,215 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Fiber } from "effect"; +import * as TestClock from "effect/testing/TestClock"; + +import { + awaitLiveBranchEffect, + awaitLiveBranchListedEffect, + awaitLiveBranchRemovedEffect, + createLiveBranchEffect, + removeLiveBranchEffect, + type BranchCli, +} from "./branches-live.ts"; +import type { LiveProject } from "./live.ts"; + +const project: LiveProject = { + ref: "abcdefghijklmnopqrst", + dbUrl: "postgresql://postgres:password@example.com:5432/postgres", + dbPassword: "password", + anonKey: "anon", + serviceRoleKey: "service-role", + functionsUrl: "https://example.com/functions", + storageBucket: "bucket", +}; +const branchRef = "zyxwvutsrqponmlkjihg"; +const branch = { name: "feature-x", project_ref: branchRef, is_default: false }; +const defaultBranch = { name: "main", project_ref: project.ref, is_default: true }; + +type State = { + readonly calls: string[]; + listed: boolean; + getReady: boolean; + deleteAttempts: number; +}; + +function result(stdout = "", stderr = "", exitCode = 0) { + return { stdout, stderr, exitCode }; +} + +function statefulCli(state: State): BranchCli { + return (args) => + Effect.sync(() => { + state.calls.push(args.join(" ")); + if (args[1] === "create") { + return result(JSON.stringify({ ...branch, project_ref: branchRef })); + } + if (args[1] === "get") { + return state.getReady + ? result(JSON.stringify(branch)) + : result("", "Request failed with status 404", 1); + } + if (args[1] === "list") { + return result(JSON.stringify(state.listed ? [defaultBranch, branch] : [defaultBranch])); + } + if (args[1] === "delete") { + state.deleteAttempts += 1; + if (state.deleteAttempts === 1) return result("", "Request failed with status 404", 1); + state.listed = false; + return result(JSON.stringify(branch)); + } + return result("", "unexpected command", 1); + }); +} + +describe("live branch lifecycle helpers", () => { + it.effect("waits for LIST visibility after name lookup succeeds", () => + Effect.gen(function* () { + const state: State = { calls: [], listed: false, getReady: true, deleteAttempts: 0 }; + const cli = statefulCli(state); + const listed = yield* awaitLiveBranchListedEffect(cli, project, branch.name).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("2 seconds"); + state.listed = true; + yield* TestClock.adjust("2 seconds"); + yield* Fiber.join(listed); + expect(state.calls.filter((call) => call.includes("branches list")).length).toBeGreaterThan( + 1, + ); + expect(state.calls.some((call) => call.includes("branches get"))).toBe(false); + }), + ); + + it.effect("does not treat early LIST absence as deletion after a DELETE 404", () => + Effect.gen(function* () { + const state: State = { calls: [], listed: false, getReady: false, deleteAttempts: 0 }; + const cli = statefulCli(state); + const ref = yield* createLiveBranchEffect(cli, project, branch.name); + expect(ref).toBe(branchRef); + const cleanup = yield* removeLiveBranchEffect(cli, project, ref).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("2 seconds"); + yield* TestClock.adjust("2 seconds"); + yield* Fiber.join(cleanup); + expect(state.deleteAttempts).toBe(2); + const firstDelete = state.calls.findIndex((call) => call.includes("branches delete")); + const secondDelete = state.calls.findIndex( + (call, index) => index > firstDelete && call.includes("branches delete"), + ); + expect( + state.calls.slice(firstDelete, secondDelete).some((call) => call.includes("branches list")), + ).toBe(false); + expect(state.calls.at(-1)).toContain("branches list"); + expect(state.listed).toBe(false); + }), + ); + + it.effect("waits for LIST absence after a primary name deletion succeeds", () => + Effect.gen(function* () { + const state: State = { calls: [], listed: true, getReady: true, deleteAttempts: 0 }; + const cli: BranchCli = (args) => + Effect.sync(() => { + state.calls.push(args.join(" ")); + if (args[1] === "list") { + if (state.calls.filter((call) => call.includes("branches list")).length > 1) { + state.listed = false; + } + return result(JSON.stringify(state.listed ? [defaultBranch, branch] : [defaultBranch])); + } + return result(); + }); + const cleanup = yield* awaitLiveBranchRemovedEffect(cli, project, branchRef, true).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("2 seconds"); + yield* Fiber.join(cleanup); + expect(state.listed).toBe(false); + }), + ); + + it.effect("fails a cleanup authorization error without retrying", () => + Effect.gen(function* () { + let attempts = 0; + const cli: BranchCli = () => + Effect.sync(() => { + attempts += 1; + return result("", "Request failed with status 403: not found", 1); + }); + const exit = yield* Effect.exit(removeLiveBranchEffect(cli, project, branchRef)); + if (!Exit.isFailure(exit)) throw new Error("expected cleanup authorization failure"); + expect(String(exit.cause)).toContain("status 403"); + expect(attempts).toBe(1); + }), + ); + + it.effect("fails malformed LIST payloads instead of treating them as branch absence", () => + Effect.gen(function* () { + let attempts = 0; + const cli: BranchCli = () => + Effect.sync(() => { + attempts += 1; + return result(JSON.stringify([{}])); + }); + const exit = yield* Effect.exit(awaitLiveBranchListedEffect(cli, project, branch.name)); + if (!Exit.isFailure(exit)) throw new Error("expected malformed list failure"); + expect(String(exit.cause)).toContain("unexpected payload"); + expect(attempts).toBe(1); + }), + ); + + it.effect("honors the readiness deadline", () => + Effect.gen(function* () { + const state: State = { calls: [], listed: false, getReady: false, deleteAttempts: 0 }; + const waiting = yield* awaitLiveBranchEffect(statefulCli(state), project, branch.name).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("60 seconds"); + const exit = yield* Fiber.await(waiting); + if (!Exit.isFailure(exit)) throw new Error("expected readiness timeout"); + expect(String(exit.cause)).toContain("timed out"); + }), + ); + + it.effect("cleans up an owned name when a successful create payload is malformed", () => + Effect.gen(function* () { + const state: State = { calls: [], listed: true, getReady: false, deleteAttempts: 0 }; + const cli: BranchCli = (args) => + Effect.sync(() => { + state.calls.push(args.join(" ")); + if (args[1] === "create") + return result(JSON.stringify({ message: "Created preview branch" })); + if (args[1] === "delete") { + state.listed = false; + return result(); + } + if (args[1] === "list") + return result(JSON.stringify(state.listed ? [defaultBranch, branch] : [defaultBranch])); + return result(); + }); + const exit = yield* Effect.exit(createLiveBranchEffect(cli, project, branch.name)); + if (!Exit.isFailure(exit)) throw new Error("expected malformed create failure"); + expect(String(exit.cause)).toContain("unexpected payload"); + expect(state.calls.some((call) => call.includes(`branches delete ${branch.name}`))).toBe( + true, + ); + expect(state.listed).toBe(false); + }), + ); + + it.effect("uses one deadline across delayed deletion and LIST absence", () => + Effect.gen(function* () { + const cli: BranchCli = (args) => + args[1] === "delete" + ? Effect.sleep("60 seconds").pipe(Effect.as(result())) + : Effect.succeed(result(JSON.stringify([defaultBranch, branch]))); + const waiting = yield* awaitLiveBranchRemovedEffect(cli, project, branchRef).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("120 seconds"); + const exit = yield* Fiber.await(waiting); + if (!Exit.isFailure(exit)) throw new Error("expected removal timeout"); + expect(String(exit.cause)).toContain("timed out"); + }), + ); +}); diff --git a/apps/cli/tests/helpers/branches-live.ts b/apps/cli/tests/helpers/branches-live.ts new file mode 100644 index 0000000000..7c32b22eb6 --- /dev/null +++ b/apps/cli/tests/helpers/branches-live.ts @@ -0,0 +1,375 @@ +import { Data, Duration, Effect, Schedule } from "effect"; + +import type { LiveProject } from "./live.ts"; + +type BranchCliOptions = { readonly exitTimeoutMs?: number }; +type BranchCliResult = { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +}; + +/** The Effect edge used by branch lifecycle helpers. */ +export type BranchCli = ( + args: string[], + options?: BranchCliOptions, +) => Effect.Effect; + +const COMMAND_TIMEOUT = 20_000; +const POLL_INTERVAL = "2 seconds"; +const READINESS_TIMEOUT = Duration.seconds(60); +const REMOVAL_TIMEOUT = Duration.seconds(120); + +class BranchCommandFailed extends Data.TaggedError("BranchCommandFailed")<{ + readonly phase: string; + readonly exitCode: number; + readonly stderr: string; +}> { + override get message(): string { + return `${this.phase} failed (exit ${this.exitCode})${this.stderr.length === 0 ? "" : `\nstderr:\n${this.stderr}`}`; + } +} + +class BranchNotReady extends Data.TaggedError("BranchNotReady")<{ + readonly phase: string; +}> { + override get message(): string { + return `${this.phase} is not ready`; + } +} + +class BranchPollTimedOut extends Data.TaggedError("BranchPollTimedOut")<{ + readonly phase: string; +}> { + override get message(): string { + return `${this.phase} timed out`; + } +} + +class BranchPayloadInvalid extends Data.TaggedError("BranchPayloadInvalid")<{ + readonly phase: string; +}> { + override get message(): string { + return `${this.phase} returned an unexpected payload`; + } +} + +function boundedStderr(stderr: string): string { + return stderr.length <= 2_000 ? stderr : stderr.slice(stderr.length - 2_000); +} + +function command( + cli: BranchCli, + args: string[], + phase: string, + timeout?: number, +): Effect.Effect { + const response = timeout === undefined ? cli(args) : cli(args, { exitTimeoutMs: timeout }); + return response.pipe( + Effect.flatMap((result) => + result.exitCode === 0 + ? Effect.succeed(result) + : Effect.fail( + new BranchCommandFailed({ + phase, + exitCode: result.exitCode, + stderr: boundedStderr(result.stderr), + }), + ), + ), + ); +} + +function poll( + phase: string, + effect: Effect.Effect, + timeout: Duration.Duration, +): Effect.Effect { + return Effect.timeoutOrElse( + Effect.retry(effect, { + schedule: Schedule.spaced(POLL_INTERVAL), + while: (error) => error instanceof BranchNotReady, + }), + { + duration: timeout, + orElse: () => Effect.fail(new BranchPollTimedOut({ phase })), + }, + ); +} + +function isNotFound(result: BranchCliResult): boolean { + return /\b(?:status|http(?: status)?|code)\D{0,12}404\b/iu.test(result.stderr); +} + +function branchRefFromCreate( + result: BranchCliResult, +): Effect.Effect { + return Effect.try({ + try: () => JSON.parse(result.stdout) as unknown, + catch: () => new BranchPayloadInvalid({ phase: "branches create" }), + }).pipe( + Effect.flatMap((body) => + typeof body === "object" && + body !== null && + "project_ref" in body && + typeof body.project_ref === "string" && + body.project_ref.length > 0 + ? Effect.succeed(body.project_ref) + : Effect.fail(new BranchPayloadInvalid({ phase: "branches create" })), + ), + ); +} + +/** Creates one named branch and captures its immutable reference before polling readiness. */ +export function createLiveBranchEffect( + cli: BranchCli, + project: LiveProject, + name: string, +): Effect.Effect { + const created = command( + cli, + ["branches", "create", name, "--project-ref", project.ref, "--output-format", "json"], + "branches create", + undefined, + ); + return created.pipe( + Effect.flatMap((result) => + branchRefFromCreate(result).pipe( + Effect.catchTag("BranchPayloadInvalid", (primary) => + removeLiveBranchByNameEffect(cli, project, name).pipe( + Effect.matchEffect({ + onSuccess: () => Effect.fail(primary), + onFailure: (cleanup) => + Effect.fail( + new AggregateError([primary, cleanup], "Branch create and cleanup failed"), + ), + }), + ), + ), + ), + ), + ); +} + +function getBranchReady( + cli: BranchCli, + project: LiveProject, + name: string, +): Effect.Effect { + const response: Effect.Effect = cli( + ["branches", "get", name, "--project-ref", project.ref], + { + exitTimeoutMs: COMMAND_TIMEOUT, + }, + ); + return response.pipe( + Effect.flatMap((result): Effect.Effect => { + if (result.exitCode === 0) return Effect.succeed(true as const); + if (isNotFound(result)) + return Effect.fail(new BranchNotReady({ phase: `branches get ${name}` })); + return Effect.fail( + new BranchCommandFailed({ + phase: `branches get ${name}`, + exitCode: result.exitCode, + stderr: boundedStderr(result.stderr), + }), + ); + }), + ); +} + +/** Waits until name lookup observes a created or renamed branch. */ +export function awaitLiveBranchEffect( + cli: BranchCli, + project: LiveProject, + name: string, +): Effect.Effect { + return poll( + `branches get ${name}`, + Effect.suspend(() => getBranchReady(cli, project, name)), + READINESS_TIMEOUT, + ); +} + +function listBranches( + cli: BranchCli, + project: LiveProject, + phase: string, +): Effect.Effect>, unknown, never> { + return command( + cli, + ["branches", "list", "--output", "json", "--project-ref", project.ref], + phase, + COMMAND_TIMEOUT, + ).pipe( + Effect.flatMap( + (result): Effect.Effect>, unknown, never> => { + let body: unknown; + try { + body = JSON.parse(result.stdout); + } catch { + return Effect.fail(new BranchPayloadInvalid({ phase })); + } + if ( + !Array.isArray(body) || + !body.every( + (item) => + typeof item === "object" && + item !== null && + "name" in item && + typeof item.name === "string" && + "project_ref" in item && + typeof item.project_ref === "string" && + "is_default" in item && + typeof item.is_default === "boolean", + ) + ) { + return Effect.fail(new BranchPayloadInvalid({ phase })); + } + return Effect.succeed(body as ReadonlyArray>); + }, + ), + ); +} + +/** Waits until the list endpoint contains the named branch. */ +export function awaitLiveBranchListedEffect( + cli: BranchCli, + project: LiveProject, + name: string, +): Effect.Effect { + const read = listBranches(cli, project, `branches list ${name}`).pipe( + Effect.flatMap((branches) => + branches.some((branch) => branch["name"] === name) + ? Effect.succeed(true as const) + : Effect.fail(new BranchNotReady({ phase: `branches list ${name}` })), + ), + ); + return poll( + `branches list ${name}`, + Effect.suspend(() => read), + READINESS_TIMEOUT, + ); +} + +function deleteBranch( + cli: BranchCli, + project: LiveProject, + target: string, + phase: string, +): Effect.Effect { + const response: Effect.Effect = cli( + ["branches", "delete", target, "--project-ref", project.ref, "--yes"], + { + exitTimeoutMs: COMMAND_TIMEOUT, + }, + ); + return response.pipe( + Effect.flatMap((result): Effect.Effect => { + if (result.exitCode === 0) return Effect.succeed(true as const); + if (isNotFound(result)) return Effect.fail(new BranchNotReady({ phase })); + return Effect.fail( + new BranchCommandFailed({ + phase, + exitCode: result.exitCode, + stderr: boundedStderr(result.stderr), + }), + ); + }), + ); +} + +function branchIsListed( + cli: BranchCli, + project: LiveProject, + target: string, + field: "project_ref" | "name", +): Effect.Effect { + return listBranches(cli, project, "branches list while awaiting branch removal").pipe( + Effect.map((branches) => branches.some((branch) => branch[field] === target)), + ); +} + +function removeBranchEffect( + cli: BranchCli, + project: LiveProject, + target: string, + field: "project_ref" | "name", + deletionAcknowledged: boolean, +): Effect.Effect { + const waitForList = Effect.retry( + Effect.suspend(() => + branchIsListed(cli, project, target, field).pipe( + Effect.flatMap((listed) => + listed + ? Effect.fail(new BranchNotReady({ phase: `branches list removal ${target}` })) + : Effect.succeed(true as const), + ), + ), + ), + { + schedule: Schedule.spaced(POLL_INTERVAL), + while: (error) => error instanceof BranchNotReady, + }, + ); + const acknowledged = deletionAcknowledged + ? waitForList + : Effect.retry( + Effect.suspend(() => deleteBranch(cli, project, target, `branches delete ${target}`)), + { + schedule: Schedule.spaced(POLL_INTERVAL), + while: (error) => error instanceof BranchNotReady, + }, + ).pipe(Effect.flatMap(() => waitForList)); + return Effect.timeoutOrElse(acknowledged, { + duration: REMOVAL_TIMEOUT, + orElse: () => Effect.fail(new BranchPollTimedOut({ phase: `branch removal ${target}` })), + }); +} + +/** Deletes an owned branch by its unique test name and confirms LIST absence. */ +export function removeLiveBranchByNameEffect( + cli: BranchCli, + project: LiveProject, + name: string, +): Effect.Effect { + return removeBranchEffect(cli, project, name, "name", false); +} + +/** Confirms deletion using the owned ref, then waits for LIST to omit that ref. */ +export function awaitLiveBranchRemovedEffect( + cli: BranchCli, + project: LiveProject, + branchRef: string, + deletionAcknowledged = false, +): Effect.Effect { + return removeBranchEffect(cli, project, branchRef, "project_ref", deletionAcknowledged); +} + +/** Deletes and confirms removal of one owned branch by immutable ref. */ +export function removeLiveBranchEffect( + cli: BranchCli, + project: LiveProject, + branchRef: string, +): Effect.Effect { + return awaitLiveBranchRemovedEffect(cli, project, branchRef); +} + +/** Waits for LIST to contain only the default project. */ +export function awaitLiveBranchesRemovedEffect( + cli: BranchCli, + project: LiveProject, +): Effect.Effect { + const read = listBranches(cli, project, "branches list while awaiting branch removal").pipe( + Effect.flatMap((branches) => + branches.some((branch) => branch["is_default"] !== true) + ? Effect.fail(new BranchNotReady({ phase: "branches list while awaiting branch removal" })) + : Effect.succeed(true as const), + ), + ); + return poll( + "branches list while awaiting branch removal", + Effect.suspend(() => read), + REMOVAL_TIMEOUT, + ); +} diff --git a/apps/cli/tests/helpers/live-project.integration.test.ts b/apps/cli/tests/helpers/live-project.integration.test.ts new file mode 100644 index 0000000000..4868f04d55 --- /dev/null +++ b/apps/cli/tests/helpers/live-project.integration.test.ts @@ -0,0 +1,119 @@ +import { makeApiClient } from "@supabase/api/effect"; +import { Effect, Layer } from "effect"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { provisionLiveEnvironment } from "./live-project.ts"; + +const ref = "abcdefghijklmnopqrst"; +function jsonResponse( + request: HttpClientRequest.HttpClientRequest, + status: number, + body: unknown, + headers: Record = {}, +): HttpClientResponse.HttpClientResponse { + return HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json", ...headers }, + }), + ); +} + +const project = { + id: "1", + ref, + organization_id: "org-id", + organization_slug: "org-slug", + name: "live-test", + region: "us-east-1", + created_at: "2026-01-01T00:00:00.000Z", + status: "ACTIVE_HEALTHY" as const, + database: { + host: `db.${ref}.supabase.green`, + version: "17", + postgres_engine: "17", + release_channel: "stable", + }, +}; + +describe("live project provisioning", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("reports a terminal API key rejection with safe context and cleans up the project", async () => { + vi.stubEnv("SUPABASE_LIVE_API_URL", "https://api.supabase.green"); + vi.stubEnv("SUPABASE_ACCESS_TOKEN", "test-token"); + vi.stubEnv("SUPABASE_LIVE_ORG_ID", "org-slug"); + vi.stubEnv("SUPABASE_LIVE_REGION", "us-east-1"); + vi.stubEnv("SUPABASE_LIVE_KEEP_PROJECT", "0"); + + const requests: string[] = []; + const ownedProjects = new Set(); + const client = await Effect.runPromise( + makeApiClient({ baseUrl: "https://api.supabase.green", accessToken: "test-token" }).pipe( + Effect.provide( + Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => { + const url = new URL(request.url); + requests.push(`${request.method} ${url.pathname}`); + if (request.method === "GET" && url.pathname === "/v1/organizations") { + return Effect.succeed( + jsonResponse(request, 200, [{ id: "org-id", slug: "org-slug", name: "Test" }]), + ); + } + if (request.method === "POST" && url.pathname === "/v1/projects") { + ownedProjects.add(ref); + return Effect.succeed( + jsonResponse(request, 201, { ...project, database: undefined }), + ); + } + if (request.method === "GET" && url.pathname === `/v1/projects/${ref}`) { + return Effect.succeed(jsonResponse(request, 200, project)); + } + if (request.method === "GET" && url.pathname === `/v1/projects/${ref}/api-keys`) { + return Effect.succeed( + jsonResponse( + request, + 403, + { message: "authorization denied", access_token: "secret-value" }, + { "x-request-id": "req-live-403" }, + ), + ); + } + if (request.method === "DELETE" && url.pathname === `/v1/projects/${ref}`) { + ownedProjects.delete(ref); + return Effect.succeed( + jsonResponse(request, 200, { id: 1, ref, name: "live-test" }), + ); + } + return Effect.die(`unexpected request ${request.method} ${url.pathname}`); + }), + ), + ), + ), + ); + + let failure: unknown; + try { + await Effect.runPromise(provisionLiveEnvironment(client)); + } catch (error) { + failure = error; + } + expect(String(failure)).toMatch( + /project API keys failed: GET \/v1\/projects\/abcdefghijklmnopqrst\/api-keys returned HTTP 403.*x-request-id=req-live-403.*response body omitted/, + ); + expect(requests.filter((request) => request.includes("/api-keys"))).toHaveLength(1); + expect(requests).toContain("DELETE /v1/projects/abcdefghijklmnopqrst"); + expect(ownedProjects).toEqual(new Set()); + const message = String(failure); + expect(message).not.toContain("test-token"); + expect(message).not.toContain("secret-value"); + expect(message).not.toContain("authorization denied"); + }); +}); diff --git a/apps/cli/tests/helpers/live-project.ts b/apps/cli/tests/helpers/live-project.ts index 1e2a6a8ef9..fef17a3b79 100644 --- a/apps/cli/tests/helpers/live-project.ts +++ b/apps/cli/tests/helpers/live-project.ts @@ -98,6 +98,39 @@ function apiError(error: unknown): Error { return error instanceof Error ? error : new Error(String(error)); } +const DIAGNOSTIC_HEADER_NAMES = [ + "x-request-id", + "x-correlation-id", + "cf-ray", + "traceparent", +] as const; + +function boundedHeaderValue(value: string | undefined): string | undefined { + if (value === undefined || value.length === 0 || !/^[\x20-\x7e]+$/u.test(value)) return undefined; + return value.length <= 128 ? value : `${value.slice(0, 125)}...`; +} + +/** Formats management API failures without exposing credentials or response bodies. */ +function liveApiErrorMessage(phase: string, cause: unknown): string { + if (!HttpClientError.isHttpClientError(cause) || cause.reason._tag !== "StatusCodeError") { + return `${phase} failed: ${apiError(cause).message}`; + } + + const { request, response } = cause.reason; + let path = request.url; + try { + path = new URL(request.url).pathname; + } catch { + path = ""; + } + const requestIds = DIAGNOSTIC_HEADER_NAMES.flatMap((name) => { + const value = boundedHeaderValue(response.headers[name] ?? request.headers[name]); + return value === undefined ? [] : [`${name}=${value}`]; + }); + const context = requestIds.length === 0 ? "" : `, ${requestIds.join(", ")}`; + return `${phase} failed: ${request.method} ${path} returned HTTP ${response.status}${context} (response body omitted)`; +} + export function supportedRegion(value: string): Effect.Effect { const region = REGIONS.find((candidate) => candidate === value); return region === undefined @@ -153,7 +186,7 @@ function classifyPollError(phase: string, cause: unknown): LiveTransientPoll | L ? new LiveTransientPoll({ phase, cause }) : new LiveTerminalPoll({ phase, - message: `${phase} failed: ${apiError(cause).message}`, + message: liveApiErrorMessage(phase, cause), cause, }); } diff --git a/apps/cli/tests/helpers/live.ts b/apps/cli/tests/helpers/live.ts index 539bf30f0b..1c9eb9e71e 100644 --- a/apps/cli/tests/helpers/live.ts +++ b/apps/cli/tests/helpers/live.ts @@ -13,6 +13,15 @@ import { runSupabase, runSupabaseEffect, } from "./cli.ts"; +import { + awaitLiveBranchEffect, + awaitLiveBranchListedEffect, + awaitLiveBranchRemovedEffect, + awaitLiveBranchesRemovedEffect, + createLiveBranchEffect, + removeLiveBranchByNameEffect, + removeLiveBranchEffect, +} from "./branches-live.ts"; import { LIVE_EXIT_TIMEOUT_MS } from "./live-env.ts"; import type { LiveCliProjectEnvironment } from "./live-project.ts"; @@ -175,21 +184,43 @@ export async function removeStorageLiveObject( } } -/** Exact cleanup for branches live tests by name or ref; deleting an already-removed branch is tolerated. */ +/** Exact cleanup for one owned branch ref, including delete acknowledgement and LIST absence. */ export async function removeLiveBranch( - cli: LiveFixtures["cli"], + cli: LiveFixtures["cliEffect"], project: LiveProject, branch: string, ): Promise { - const removed = await cli(["branches", "delete", branch, "--project-ref", project.ref, "--yes"]); - if ( - removed.exitCode !== 0 && - !/not found|does not exist|status 404\b/i.test(`${removed.stdout}\n${removed.stderr}`) - ) { - throw new Error( - `branches delete cleanup for ${branch} failed (exit ${removed.exitCode})\n${removed.stdout}\n${removed.stderr}`, - ); - } + await Effect.runPromise(removeLiveBranchEffect(cli, project, branch)); +} + +/** Waits for deletion acknowledgement when needed, then for exact-ref LIST absence. */ +export async function awaitLiveBranchRemoved( + cli: LiveFixtures["cliEffect"], + project: LiveProject, + branchRef: string, + deletionAcknowledged = false, +): Promise { + await Effect.runPromise( + awaitLiveBranchRemovedEffect(cli, project, branchRef, deletionAcknowledged), + ); +} + +/** Creates a branch and returns its immutable reference for exact cleanup. */ +export async function createLiveBranch( + cli: LiveFixtures["cliEffect"], + project: LiveProject, + name: string, +): Promise { + return Effect.runPromise(createLiveBranchEffect(cli, project, name)); +} + +/** Exact-name fallback cleanup for a successful create with an unreadable payload. */ +export async function removeLiveBranchByName( + cli: LiveFixtures["cliEffect"], + project: LiveProject, + name: string, +): Promise { + await Effect.runPromise(removeLiveBranchByNameEffect(cli, project, name)); } /** Flags for experimental-gated live tests that address the shared project by @@ -276,29 +307,20 @@ export async function expectPostgresConfigLiveOverride( * bounded). Reports stderr only since `get` prints secrets on stdout. */ export async function awaitLiveBranch( - cli: LiveFixtures["cli"], + cli: LiveFixtures["cliEffect"], project: LiveProject, branch: string, ): Promise { - const read = async (): Promise => { - const proof = await cli(["branches", "get", branch, "--project-ref", project.ref], { - exitTimeoutMs: 20_000, - }); - if (proof.exitCode !== 0 && !/status 404\b/u.test(proof.stderr)) { - requireCliSuccess({ ...proof, stdout: "" }, `branches get ${branch}`); - } - return proof.exitCode === 0 - ? "found" - : `not found (exit ${proof.exitCode})\nstderr:\n${proof.stderr}`; - }; - if ((await read()) === "found") return; - await expect - .poll(read, { - interval: 2_000, - timeout: 60_000, - message: `branches get ${branch} still does not find the branch`, - }) - .toBe("found"); + await Effect.runPromise(awaitLiveBranchEffect(cli, project, branch)); +} + +/** Waits until the list endpoint contains a named branch. */ +export async function awaitLiveBranchListed( + cli: LiveFixtures["cliEffect"], + project: LiveProject, + branch: string, +): Promise { + await Effect.runPromise(awaitLiveBranchListedEffect(cli, project, branch)); } /** @@ -310,39 +332,10 @@ export async function awaitLiveBranch( * read and then polls the list (2s apart, 120s deadline, each attempt bounded). */ export async function awaitLiveBranchesRemoved( - cli: LiveFixtures["cli"], + cli: LiveFixtures["cliEffect"], project: LiveProject, ): Promise { - const label = "branches list while awaiting branch removal"; - const read = async (): Promise> => { - const listed = await cli( - ["branches", "list", "--output", "json", "--project-ref", project.ref], - { exitTimeoutMs: 20_000 }, - ); - requireCliSuccess(listed, label); - let branches: unknown; - try { - branches = JSON.parse(listed.stdout); - } catch { - branches = undefined; - } - if (!Array.isArray(branches)) { - throw new Error( - `${label}: unexpected branches list payload\nstdout:\n${listed.stdout}\nstderr:\n${listed.stderr}`, - ); - } - return branches - .filter((branch: { is_default: boolean }) => !branch.is_default) - .map((branch: { name: string }) => branch.name); - }; - if ((await read()).length === 0) return; - await expect - .poll(read, { - interval: 2_000, - timeout: 120_000, - message: "non-default preview branches still exist", - }) - .toEqual([]); + await Effect.runPromise(awaitLiveBranchesRemovedEffect(cli, project)); } /** From 2207a7d0c69c6c03ce96b83eb8f61e49afddf6cd Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 16 Sep 2026 18:20:11 +0200 Subject: [PATCH 2/3] test(cli): address live lifecycle review feedback --- .../sweep-live-projects.integration.test.ts | 37 +++++++- apps/cli/scripts/sweep-live-projects.sh | 16 +++- .../branches/create/create.live.test.ts | 8 +- .../commands/branches/get/get.live.test.ts | 4 +- .../commands/branches/list/list.live.test.ts | 4 +- .../branches/update/update.live.test.ts | 4 +- .../helpers/branches-live.integration.test.ts | 90 +++++++++++++++++-- apps/cli/tests/helpers/branches-live.ts | 30 ++----- .../helpers/live-project.integration.test.ts | 44 ++++++--- apps/cli/tests/helpers/live-project.ts | 12 ++- apps/cli/tests/helpers/live.ts | 12 +-- 11 files changed, 192 insertions(+), 69 deletions(-) diff --git a/apps/cli/scripts/sweep-live-projects.integration.test.ts b/apps/cli/scripts/sweep-live-projects.integration.test.ts index 325fbc990c..ac124dce97 100644 --- a/apps/cli/scripts/sweep-live-projects.integration.test.ts +++ b/apps/cli/scripts/sweep-live-projects.integration.test.ts @@ -11,8 +11,9 @@ afterEach(async () => { }); type Scenario = { - lists: Array; + lists: Array | ((deletes: ReadonlyArray) => unknown); deletes: Record; + deleteStatuses?: Record>; }; async function runSweep(scenario: Scenario) { @@ -30,14 +31,29 @@ async function runSweep(scenario: Scenario) { const url = new URL(request.url); const ref = url.pathname.split("/").pop() ?? ""; if (request.method === "GET" && url.pathname === "/v1/projects") { - const value = scenario.lists[Math.min(listIndex++, scenario.lists.length - 1)]; + const value = + typeof scenario.lists === "function" + ? scenario.lists(deletes) + : scenario.lists[Math.min(listIndex++, scenario.lists.length - 1)]; return typeof value === "number" ? new Response("temporary failure", { status: value }) : Response.json(value); } if (request.method === "DELETE" && url.pathname.startsWith("/v1/projects/")) { deletes.push(ref); - const result = scenario.deletes[ref] ?? { status: 404 }; + const statuses = scenario.deleteStatuses?.[ref]; + const result: { status: number; body?: unknown } = + statuses === undefined + ? (scenario.deletes[ref] ?? { status: 404 }) + : { + status: + statuses[ + Math.min( + deletes.filter((value) => value === ref).length - 1, + statuses.length - 1, + ) + ] ?? 500, + }; return new Response(result.body === undefined ? null : JSON.stringify(result.body), { status: result.status, headers: { "x-request-id": `delete-${ref}` }, @@ -72,7 +88,7 @@ async function runSweep(scenario: Scenario) { const active = (ref: string, name = `e2e-${ref}`) => ({ ref, name, status: "ACTIVE" }); -describe("sweep-live-projects.sh", () => { +describe.skipIf(process.platform === "win32")("sweep-live-projects.sh", { timeout: 40_000 }, () => { test("accepts refused deletion when a fresh authenticated list shows absence", async () => { const result = await runSweep({ lists: [[active("gone")], []], @@ -130,4 +146,17 @@ describe("sweep-live-projects.sh", () => { expect(result.exitCode).not.toBe(0); expect(result.deletes).toEqual(["stuck", "other"]); }); + + test("retries a transient deletion after fresh evidence still shows the project", async () => { + const result = await runSweep({ + lists: (deletes) => (deletes.length >= 2 ? [] : [active("flaky")]), + deletes: {}, + deleteStatuses: { flaky: [503, 204] }, + }); + expect(result.exitCode).toBe(0); + expect(result.deletes).toEqual(["flaky", "flaky"]); + expect(result.stderr).toContain( + "delete retry completed for project flaky (HTTP 204 request delete-flaky)", + ); + }); }); diff --git a/apps/cli/scripts/sweep-live-projects.sh b/apps/cli/scripts/sweep-live-projects.sh index 6a14903979..5598b8168c 100755 --- a/apps/cli/scripts/sweep-live-projects.sh +++ b/apps/cli/scripts/sweep-live-projects.sh @@ -61,6 +61,18 @@ reconcile() { if fetch_listing_once; then if jq -e --arg ref "$ref" 'any(.[]; ((.ref // .id) == $ref) and ((.status // "") != "GOING_DOWN") and ((.status // "") != "REMOVED"))' "$listing" >/dev/null; then echo "project $ref still active (list HTTP $API_CODE${API_REQUEST_ID:+ request $API_REQUEST_ID})" >&2 + if [ "$delete_attempt" -lt 3 ]; then + case "$delete_code" in + 000|408|425|429|5??) + delete_attempt=$((delete_attempt + 1)) + echo "retrying transient delete for project $ref (attempt $delete_attempt)" >&2 + sleep 5 + api_request DELETE "${SUPABASE_LIVE_API_URL}/v1/projects/${ref}" "$response" + delete_code=$API_CODE + echo "delete retry completed for project $ref (HTTP $delete_code${API_REQUEST_ID:+ request $API_REQUEST_ID})" >&2 + ;; + esac + fi else echo "project $ref reconciled as absent or terminal (list HTTP $API_CODE${API_REQUEST_ID:+ request $API_REQUEST_ID})" return 0 @@ -85,8 +97,10 @@ while read -r ref status; do continue ;; esac - echo "deleting leftover project $ref" + delete_attempt=1 + echo "deleting leftover project $ref (attempt $delete_attempt)" api_request DELETE "${SUPABASE_LIVE_API_URL}/v1/projects/${ref}" "$response" + delete_code=$API_CODE case "$API_CODE" in 2??) echo "delete accepted for project $ref (HTTP $API_CODE${API_REQUEST_ID:+ request $API_REQUEST_ID})" ;; *) echo "delete failed for project $ref (HTTP $API_CODE${API_REQUEST_ID:+ request $API_REQUEST_ID})" >&2 ;; diff --git a/apps/cli/src/commands/branches/create/create.live.test.ts b/apps/cli/src/commands/branches/create/create.live.test.ts index 4077567874..f95ae7fb7a 100644 --- a/apps/cli/src/commands/branches/create/create.live.test.ts +++ b/apps/cli/src/commands/branches/create/create.live.test.ts @@ -3,8 +3,8 @@ import { expect } from "vitest"; import { awaitLiveBranch, + awaitLiveBranchRemoved, requireLiveJson, - removeLiveBranch, removeLiveBranchByName, test, throwWithCleanup, @@ -13,7 +13,6 @@ import { test("creates a preview branch", async ({ cli, cliEffect, project }) => { const name = `cli-e2e-create-${randomUUID().slice(0, 8)}`; let branchRef: string | undefined; - let createdSuccessfully = false; let targetError: unknown; let cleanupError: unknown; try { @@ -26,7 +25,6 @@ test("creates a preview branch", async ({ cli, cliEffect, project }) => { "--output-format", "json", ]); - createdSuccessfully = created.exitCode === 0; expect(created.exitCode, created.stderr).toBe(0); const body = requireLiveJson(created, "branches create"); if (typeof body === "object" && body !== null && "project_ref" in body) { @@ -43,8 +41,8 @@ test("creates a preview branch", async ({ cli, cliEffect, project }) => { targetError = error; } finally { try { - if (branchRef !== undefined) await removeLiveBranch(cliEffect, project, branchRef); - else if (createdSuccessfully) await removeLiveBranchByName(cliEffect, project, name); + if (branchRef !== undefined) await awaitLiveBranchRemoved(cliEffect, project, branchRef); + else await removeLiveBranchByName(cliEffect, project, name); } catch (error) { cleanupError = error; } diff --git a/apps/cli/src/commands/branches/get/get.live.test.ts b/apps/cli/src/commands/branches/get/get.live.test.ts index 422919b501..54cc85c23f 100644 --- a/apps/cli/src/commands/branches/get/get.live.test.ts +++ b/apps/cli/src/commands/branches/get/get.live.test.ts @@ -3,8 +3,8 @@ import { expect } from "vitest"; import { awaitLiveBranch, + awaitLiveBranchRemoved, createLiveBranch, - removeLiveBranch, test, throwWithCleanup, } from "../../../../tests/helpers/live.ts"; @@ -34,7 +34,7 @@ test("gets a preview branch by name", async ({ cli, cliEffect, project }) => { targetError = error; } finally { try { - if (branchRef !== undefined) await removeLiveBranch(cliEffect, project, branchRef); + if (branchRef !== undefined) await awaitLiveBranchRemoved(cliEffect, project, branchRef); } catch (error) { cleanupError = error; } diff --git a/apps/cli/src/commands/branches/list/list.live.test.ts b/apps/cli/src/commands/branches/list/list.live.test.ts index 41d48ef098..91461a58df 100644 --- a/apps/cli/src/commands/branches/list/list.live.test.ts +++ b/apps/cli/src/commands/branches/list/list.live.test.ts @@ -3,8 +3,8 @@ import { expect } from "vitest"; import { awaitLiveBranchListed, + awaitLiveBranchRemoved, createLiveBranch, - removeLiveBranch, test, throwWithCleanup, } from "../../../../tests/helpers/live.ts"; @@ -33,7 +33,7 @@ test("lists a preview branch for the project", async ({ cli, cliEffect, project targetError = error; } finally { try { - if (branchRef !== undefined) await removeLiveBranch(cliEffect, project, branchRef); + if (branchRef !== undefined) await awaitLiveBranchRemoved(cliEffect, project, branchRef); } catch (error) { cleanupError = error; } diff --git a/apps/cli/src/commands/branches/update/update.live.test.ts b/apps/cli/src/commands/branches/update/update.live.test.ts index 5feddd8736..c65e0bb04f 100644 --- a/apps/cli/src/commands/branches/update/update.live.test.ts +++ b/apps/cli/src/commands/branches/update/update.live.test.ts @@ -3,8 +3,8 @@ import { expect } from "vitest"; import { awaitLiveBranch, + awaitLiveBranchRemoved, createLiveBranch, - removeLiveBranch, test, throwWithCleanup, } from "../../../../tests/helpers/live.ts"; @@ -39,7 +39,7 @@ test("renames a preview branch", async ({ cli, cliEffect, project }) => { targetError = error; } finally { try { - if (branchRef !== undefined) await removeLiveBranch(cliEffect, project, branchRef); + if (branchRef !== undefined) await awaitLiveBranchRemoved(cliEffect, project, branchRef); } catch (error) { cleanupError = error; } diff --git a/apps/cli/tests/helpers/branches-live.integration.test.ts b/apps/cli/tests/helpers/branches-live.integration.test.ts index 3fc2c6bd32..2c5a429ecc 100644 --- a/apps/cli/tests/helpers/branches-live.integration.test.ts +++ b/apps/cli/tests/helpers/branches-live.integration.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, Fiber } from "effect"; +import { Cause, Effect, Exit, Fiber } from "effect"; import * as TestClock from "effect/testing/TestClock"; import { @@ -7,7 +7,6 @@ import { awaitLiveBranchListedEffect, awaitLiveBranchRemovedEffect, createLiveBranchEffect, - removeLiveBranchEffect, type BranchCli, } from "./branches-live.ts"; import type { LiveProject } from "./live.ts"; @@ -86,7 +85,7 @@ describe("live branch lifecycle helpers", () => { const cli = statefulCli(state); const ref = yield* createLiveBranchEffect(cli, project, branch.name); expect(ref).toBe(branchRef); - const cleanup = yield* removeLiveBranchEffect(cli, project, ref).pipe( + const cleanup = yield* awaitLiveBranchRemovedEffect(cli, project, ref).pipe( Effect.forkChild({ startImmediately: true }), ); yield* TestClock.adjust("2 seconds"); @@ -105,7 +104,7 @@ describe("live branch lifecycle helpers", () => { }), ); - it.effect("waits for LIST absence after a primary name deletion succeeds", () => + it.effect("waits for LIST absence after deletion was acknowledged", () => Effect.gen(function* () { const state: State = { calls: [], listed: true, getReady: true, deleteAttempts: 0 }; const cli: BranchCli = (args) => @@ -136,7 +135,7 @@ describe("live branch lifecycle helpers", () => { attempts += 1; return result("", "Request failed with status 403: not found", 1); }); - const exit = yield* Effect.exit(removeLiveBranchEffect(cli, project, branchRef)); + const exit = yield* Effect.exit(awaitLiveBranchRemovedEffect(cli, project, branchRef)); if (!Exit.isFailure(exit)) throw new Error("expected cleanup authorization failure"); expect(String(exit.cause)).toContain("status 403"); expect(attempts).toBe(1); @@ -197,6 +196,87 @@ describe("live branch lifecycle helpers", () => { }), ); + it.effect("cleans up an owned name when create exits after starting", () => + Effect.gen(function* () { + const state: State = { calls: [], listed: true, getReady: false, deleteAttempts: 0 }; + const cli: BranchCli = (args) => + Effect.sync(() => { + state.calls.push(args.join(" ")); + if (args[1] === "create") return result("", "create failed", 1); + if (args[1] === "delete") { + state.listed = false; + return result(); + } + if (args[1] === "list") + return result(JSON.stringify(state.listed ? [defaultBranch, branch] : [defaultBranch])); + return result(); + }); + const exit = yield* Effect.exit(createLiveBranchEffect(cli, project, branch.name)); + if (!Exit.isFailure(exit)) throw new Error("expected create failure"); + expect(String(exit.cause)).toContain("create failed"); + expect(state.calls).toContain( + `branches delete ${branch.name} --project-ref ${project.ref} --yes`, + ); + expect(state.listed).toBe(false); + }), + ); + + it.effect("cleans up an owned name when create invocation fails", () => + Effect.gen(function* () { + const calls: string[] = []; + const cli: BranchCli = (args) => { + calls.push(args.join(" ")); + if (args[1] === "create") return Effect.fail(new Error("invocation failed")); + if (args[1] === "delete") return Effect.succeed(result()); + if (args[1] === "list") return Effect.succeed(result(JSON.stringify([defaultBranch]))); + return Effect.succeed(result()); + }; + const exit = yield* Effect.exit(createLiveBranchEffect(cli, project, branch.name)); + if (!Exit.isFailure(exit)) throw new Error("expected invocation failure"); + expect(String(exit.cause)).toContain("invocation failed"); + expect(calls.some((call) => call.startsWith(`branches delete ${branch.name}`))).toBe(true); + }), + ); + + it.effect("preserves create and cleanup failures", () => + Effect.gen(function* () { + const cli: BranchCli = (args) => + args[1] === "create" + ? Effect.fail(new Error("create invocation failed")) + : Effect.succeed(result("", "cleanup forbidden", 1)); + const exit = yield* Effect.exit(createLiveBranchEffect(cli, project, branch.name)); + if (!Exit.isFailure(exit)) throw new Error("expected aggregate failure"); + const failure = Cause.squash(exit.cause); + if (!(failure instanceof AggregateError)) throw new Error("expected aggregate failure cause"); + expect(failure.message).toBe("Branch create and cleanup failed"); + const causes = failure.errors.map(String).join("\n"); + expect(causes).toContain("create invocation failed"); + expect(causes).toContain("cleanup forbidden"); + }), + ); + + it.effect("keeps conservative by-name cleanup bounded when create never appears", () => + Effect.gen(function* () { + const cli: BranchCli = (args) => + args[1] === "create" + ? Effect.fail(new Error("create invocation failed")) + : args[1] === "delete" + ? Effect.succeed(result("", "Request failed with status 404", 1)) + : Effect.succeed(result(JSON.stringify([defaultBranch]))); + const cleanup = yield* createLiveBranchEffect(cli, project, branch.name).pipe( + Effect.forkChild({ startImmediately: true }), + ); + yield* TestClock.adjust("120 seconds"); + const exit = yield* Fiber.await(cleanup); + if (!Exit.isFailure(exit)) throw new Error("expected bounded cleanup failure"); + const failure = Cause.squash(exit.cause); + if (!(failure instanceof AggregateError)) throw new Error("expected aggregate failure cause"); + const causes = failure.errors.map(String).join("\n"); + expect(causes).toContain("create invocation failed"); + expect(causes).toContain("branch removal feature-x timed out"); + }), + ); + it.effect("uses one deadline across delayed deletion and LIST absence", () => Effect.gen(function* () { const cli: BranchCli = (args) => diff --git a/apps/cli/tests/helpers/branches-live.ts b/apps/cli/tests/helpers/branches-live.ts index 7c32b22eb6..01d378a504 100644 --- a/apps/cli/tests/helpers/branches-live.ts +++ b/apps/cli/tests/helpers/branches-live.ts @@ -133,19 +133,14 @@ export function createLiveBranchEffect( undefined, ); return created.pipe( - Effect.flatMap((result) => - branchRefFromCreate(result).pipe( - Effect.catchTag("BranchPayloadInvalid", (primary) => - removeLiveBranchByNameEffect(cli, project, name).pipe( - Effect.matchEffect({ - onSuccess: () => Effect.fail(primary), - onFailure: (cleanup) => - Effect.fail( - new AggregateError([primary, cleanup], "Branch create and cleanup failed"), - ), - }), - ), - ), + Effect.flatMap((result) => branchRefFromCreate(result)), + Effect.catch((primary) => + removeLiveBranchByNameEffect(cli, project, name).pipe( + Effect.matchEffect({ + onSuccess: () => Effect.fail(primary), + onFailure: (cleanup) => + Effect.fail(new AggregateError([primary, cleanup], "Branch create and cleanup failed")), + }), ), ), ); @@ -346,15 +341,6 @@ export function awaitLiveBranchRemovedEffect( return removeBranchEffect(cli, project, branchRef, "project_ref", deletionAcknowledged); } -/** Deletes and confirms removal of one owned branch by immutable ref. */ -export function removeLiveBranchEffect( - cli: BranchCli, - project: LiveProject, - branchRef: string, -): Effect.Effect { - return awaitLiveBranchRemovedEffect(cli, project, branchRef); -} - /** Waits for LIST to contain only the default project. */ export function awaitLiveBranchesRemovedEffect( cli: BranchCli, diff --git a/apps/cli/tests/helpers/live-project.integration.test.ts b/apps/cli/tests/helpers/live-project.integration.test.ts index 4868f04d55..be267fa325 100644 --- a/apps/cli/tests/helpers/live-project.integration.test.ts +++ b/apps/cli/tests/helpers/live-project.integration.test.ts @@ -40,12 +40,32 @@ const project = { }, }; +type ApiKeyScenario = { + name: string; + status: number; + body: unknown; + headers: Record; +}; + describe("live project provisioning", () => { afterEach(() => { vi.unstubAllEnvs(); }); - it("reports a terminal API key rejection with safe context and cleans up the project", async () => { + it.each([ + { + name: "terminal API key rejection", + status: 403, + body: { message: "authorization denied", access_token: "secret-value" }, + headers: { "x-request-id": "req-live-403" }, + }, + { + name: "malformed HTTP 200 API key response", + status: 200, + body: "sentinel-secret", + headers: {}, + }, + ])("redacts $name while cleaning up", async ({ status, body, headers }) => { vi.stubEnv("SUPABASE_LIVE_API_URL", "https://api.supabase.green"); vi.stubEnv("SUPABASE_ACCESS_TOKEN", "test-token"); vi.stubEnv("SUPABASE_LIVE_ORG_ID", "org-slug"); @@ -77,14 +97,7 @@ describe("live project provisioning", () => { return Effect.succeed(jsonResponse(request, 200, project)); } if (request.method === "GET" && url.pathname === `/v1/projects/${ref}/api-keys`) { - return Effect.succeed( - jsonResponse( - request, - 403, - { message: "authorization denied", access_token: "secret-value" }, - { "x-request-id": "req-live-403" }, - ), - ); + return Effect.succeed(jsonResponse(request, status, body, headers)); } if (request.method === "DELETE" && url.pathname === `/v1/projects/${ref}`) { ownedProjects.delete(ref); @@ -105,9 +118,15 @@ describe("live project provisioning", () => { } catch (error) { failure = error; } - expect(String(failure)).toMatch( - /project API keys failed: GET \/v1\/projects\/abcdefghijklmnopqrst\/api-keys returned HTTP 403.*x-request-id=req-live-403.*response body omitted/, - ); + if (status === 403) { + expect(String(failure)).toMatch( + /project API keys failed: GET \/v1\/projects\/abcdefghijklmnopqrst\/api-keys returned HTTP 403.*x-request-id=req-live-403.*response body omitted/, + ); + } else { + expect(String(failure)).toContain( + "project API keys failed: management API response schema validation failed", + ); + } expect(requests.filter((request) => request.includes("/api-keys"))).toHaveLength(1); expect(requests).toContain("DELETE /v1/projects/abcdefghijklmnopqrst"); expect(ownedProjects).toEqual(new Set()); @@ -115,5 +134,6 @@ describe("live project provisioning", () => { expect(message).not.toContain("test-token"); expect(message).not.toContain("secret-value"); expect(message).not.toContain("authorization denied"); + expect(message).not.toContain("sentinel-secret"); }); }); diff --git a/apps/cli/tests/helpers/live-project.ts b/apps/cli/tests/helpers/live-project.ts index fef17a3b79..ecff21050d 100644 --- a/apps/cli/tests/helpers/live-project.ts +++ b/apps/cli/tests/helpers/live-project.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { makeApiClient, type OperationOutput } from "@supabase/api/effect"; -import { Cause, Data, Effect, Exit, Schedule } from "effect"; +import { Cause, Data, Effect, Exit, Schedule, Schema } from "effect"; import * as HttpClientError from "effect/unstable/http/HttpClientError"; import { getDomain } from "tldts"; @@ -112,8 +112,14 @@ function boundedHeaderValue(value: string | undefined): string | undefined { /** Formats management API failures without exposing credentials or response bodies. */ function liveApiErrorMessage(phase: string, cause: unknown): string { - if (!HttpClientError.isHttpClientError(cause) || cause.reason._tag !== "StatusCodeError") { - return `${phase} failed: ${apiError(cause).message}`; + if (Schema.isSchemaError(cause)) { + return `${phase} failed: management API response schema validation failed`; + } + if (!HttpClientError.isHttpClientError(cause)) { + return `${phase} failed: management API request failed`; + } + if (cause.reason._tag !== "StatusCodeError") { + return `${phase} failed: management API request failed (${cause.reason._tag})`; } const { request, response } = cause.reason; diff --git a/apps/cli/tests/helpers/live.ts b/apps/cli/tests/helpers/live.ts index 1c9eb9e71e..50e1379cc2 100644 --- a/apps/cli/tests/helpers/live.ts +++ b/apps/cli/tests/helpers/live.ts @@ -20,7 +20,6 @@ import { awaitLiveBranchesRemovedEffect, createLiveBranchEffect, removeLiveBranchByNameEffect, - removeLiveBranchEffect, } from "./branches-live.ts"; import { LIVE_EXIT_TIMEOUT_MS } from "./live-env.ts"; import type { LiveCliProjectEnvironment } from "./live-project.ts"; @@ -184,15 +183,6 @@ export async function removeStorageLiveObject( } } -/** Exact cleanup for one owned branch ref, including delete acknowledgement and LIST absence. */ -export async function removeLiveBranch( - cli: LiveFixtures["cliEffect"], - project: LiveProject, - branch: string, -): Promise { - await Effect.runPromise(removeLiveBranchEffect(cli, project, branch)); -} - /** Waits for deletion acknowledgement when needed, then for exact-ref LIST absence. */ export async function awaitLiveBranchRemoved( cli: LiveFixtures["cliEffect"], @@ -214,7 +204,7 @@ export async function createLiveBranch( return Effect.runPromise(createLiveBranchEffect(cli, project, name)); } -/** Exact-name fallback cleanup for a successful create with an unreadable payload. */ +/** Exact-name fallback cleanup for a create that did not yield an immutable ref. */ export async function removeLiveBranchByName( cli: LiveFixtures["cliEffect"], project: LiveProject, From 6a6f04eb2990cf12fc1a64a86b2bebd7663d924e Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Fri, 18 Sep 2026 09:08:20 +0200 Subject: [PATCH 3/3] test(cli): fix live helper quality checks --- apps/cli/tests/helpers/branches-live.ts | 47 +++++---------- apps/cli/tests/helpers/live.ts | 77 ------------------------- 2 files changed, 15 insertions(+), 109 deletions(-) diff --git a/apps/cli/tests/helpers/branches-live.ts b/apps/cli/tests/helpers/branches-live.ts index 3494cb131d..1682231d7b 100644 --- a/apps/cli/tests/helpers/branches-live.ts +++ b/apps/cli/tests/helpers/branches-live.ts @@ -1,4 +1,4 @@ -import { Data, Duration, Effect, Schedule } from "effect"; +import { Data, Duration, Effect, Schedule, Schema } from "effect"; import type { LiveProject } from "./live.ts"; @@ -19,6 +19,13 @@ const COMMAND_TIMEOUT = 20_000; const POLL_INTERVAL = "2 seconds"; const READINESS_TIMEOUT = Duration.seconds(60); const REMOVAL_TIMEOUT = Duration.seconds(120); +const UnknownFromJsonString = Schema.fromJsonString(Schema.Unknown); +const BranchListItem = Schema.Struct({ + name: Schema.String, + project_ref: Schema.String, + is_default: Schema.Boolean, +}); +const BranchList = Schema.Array(BranchListItem); class BranchCommandFailed extends Data.TaggedError("BranchCommandFailed")<{ readonly phase: string; @@ -112,10 +119,8 @@ function isNotFound(result: BranchCliResult): boolean { function branchRefFromCreate( result: BranchCliResult, ): Effect.Effect { - return Effect.try({ - try: () => JSON.parse(result.stdout) as unknown, - catch: () => new BranchPayloadInvalid({ phase: "branches create" }), - }).pipe( + return Schema.decodeEffect(UnknownFromJsonString)(result.stdout).pipe( + Effect.mapError(() => new BranchPayloadInvalid({ phase: "branches create" })), Effect.flatMap((body) => typeof body === "object" && body !== null && @@ -198,39 +203,17 @@ function listBranches( cli: BranchCli, project: LiveProject, phase: string, -): Effect.Effect>, BranchError, never> { +): Effect.Effect>, BranchError, never> { return command( cli, ["branches", "list", "--output", "json", "--project-ref", project.ref], phase, COMMAND_TIMEOUT, ).pipe( - Effect.flatMap( - (result): Effect.Effect>, BranchError, never> => { - let body: unknown; - try { - body = JSON.parse(result.stdout); - } catch { - return Effect.fail(new BranchPayloadInvalid({ phase })); - } - if ( - !Array.isArray(body) || - !body.every( - (item) => - typeof item === "object" && - item !== null && - "name" in item && - typeof item.name === "string" && - "project_ref" in item && - typeof item.project_ref === "string" && - "is_default" in item && - typeof item.is_default === "boolean", - ) - ) { - return Effect.fail(new BranchPayloadInvalid({ phase })); - } - return Effect.succeed(body as ReadonlyArray>); - }, + Effect.flatMap((result) => + Schema.decodeEffect(Schema.fromJsonString(BranchList))(result.stdout).pipe( + Effect.mapError(() => new BranchPayloadInvalid({ phase })), + ), ), ); } diff --git a/apps/cli/tests/helpers/live.ts b/apps/cli/tests/helpers/live.ts index 50e1379cc2..397fa18da7 100644 --- a/apps/cli/tests/helpers/live.ts +++ b/apps/cli/tests/helpers/live.ts @@ -13,14 +13,6 @@ import { runSupabase, runSupabaseEffect, } from "./cli.ts"; -import { - awaitLiveBranchEffect, - awaitLiveBranchListedEffect, - awaitLiveBranchRemovedEffect, - awaitLiveBranchesRemovedEffect, - createLiveBranchEffect, - removeLiveBranchByNameEffect, -} from "./branches-live.ts"; import { LIVE_EXIT_TIMEOUT_MS } from "./live-env.ts"; import type { LiveCliProjectEnvironment } from "./live-project.ts"; @@ -183,36 +175,6 @@ export async function removeStorageLiveObject( } } -/** Waits for deletion acknowledgement when needed, then for exact-ref LIST absence. */ -export async function awaitLiveBranchRemoved( - cli: LiveFixtures["cliEffect"], - project: LiveProject, - branchRef: string, - deletionAcknowledged = false, -): Promise { - await Effect.runPromise( - awaitLiveBranchRemovedEffect(cli, project, branchRef, deletionAcknowledged), - ); -} - -/** Creates a branch and returns its immutable reference for exact cleanup. */ -export async function createLiveBranch( - cli: LiveFixtures["cliEffect"], - project: LiveProject, - name: string, -): Promise { - return Effect.runPromise(createLiveBranchEffect(cli, project, name)); -} - -/** Exact-name fallback cleanup for a create that did not yield an immutable ref. */ -export async function removeLiveBranchByName( - cli: LiveFixtures["cliEffect"], - project: LiveProject, - name: string, -): Promise { - await Effect.runPromise(removeLiveBranchByNameEffect(cli, project, name)); -} - /** Flags for experimental-gated live tests that address the shared project by * ref rather than linking it (contrast `storageLiveFlags`). */ export function experimentalProjectLiveFlags(project: LiveProject): ReadonlyArray { @@ -289,45 +251,6 @@ export async function expectPostgresConfigLiveOverride( await expect.poll(read, { interval: 2_000, timeout: 60_000, message: label }).toBe(expected); } -/** - * Waits until `branches get` resolves `branch` on the live project. `branches - * create` and `update --name` return before the platform can look the branch - * up, so a caller that acts on it next does one fail-fast read (aborting on - * anything but a 404) and then polls (2s apart, 60s deadline, each attempt - * bounded). Reports stderr only since `get` prints secrets on stdout. - */ -export async function awaitLiveBranch( - cli: LiveFixtures["cliEffect"], - project: LiveProject, - branch: string, -): Promise { - await Effect.runPromise(awaitLiveBranchEffect(cli, project, branch)); -} - -/** Waits until the list endpoint contains a named branch. */ -export async function awaitLiveBranchListed( - cli: LiveFixtures["cliEffect"], - project: LiveProject, - branch: string, -): Promise { - await Effect.runPromise(awaitLiveBranchListedEffect(cli, project, branch)); -} - -/** - * Waits until `branches list` shows no non-default branch on the live project. - * `branches delete` returns before the platform finishes tearing the branch - * down, and `branches disable` is refused ("Please delete all non-default - * branches before disabling branching.") while any non-default branch still - * exists, so a caller that needs an empty branching setup does one fail-fast - * read and then polls the list (2s apart, 120s deadline, each attempt bounded). - */ -export async function awaitLiveBranchesRemoved( - cli: LiveFixtures["cliEffect"], - project: LiveProject, -): Promise { - await Effect.runPromise(awaitLiveBranchesRemovedEffect(cli, project)); -} - /** * Unique migration version for a live test: a sortable `YYYYMMDDHHMMSS` UTC * stamp plus four random digits, so it always orders after any conventional