/** * gstack-gbrain-supabase-provision — Supabase Management API wrapper. * * All tests run against a per-test local mock HTTP server (Bun.serve) * that returns fixture responses. Never hits the real Supabase API, never * requires a live PAT. * * Covers the D21 HTTP error suite (401/403/402/409/429/5xx), the happy * path for each subcommand (list-orgs, create, wait, pooler-url), the * verified schema corrections (POST /v1/projects with organization_slug, * GET /config/database/pooler), PAT + DB_PASS env-var discipline, retry * + backoff on transient errors, pooler URL construction using the * generated DB_PASS (not the API response's templated connection_string). * * Tests drive lib/gbrain-supabase-provision.ts IN-PROCESS with injected * fetch/env/sleep (decision D7: dependency injection via options, never * process.env mutation before import — ESM hoists imports so env-set-before- * import silently doesn't work). One spawn-based smoke test at the bottom * runs the real bin end-to-end to pin the shebang/CLI contract. This * replaced ~30 process spawns (~16s of Bun boot + transpile) with direct * module calls. */ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { runProvision } from '../lib/gbrain-supabase-provision'; const ROOT = path.resolve(import.meta.dir, '..'); const BIN = path.join(ROOT, 'bin', 'gstack-gbrain-supabase-provision'); // Minimal PATH that finds standard tools but excludes user bins. The smoke // test prepends the running bun's own directory so the shebang resolves. const SAFE_PATH = '/usr/bin:/bin:/usr/sbin:/sbin:/opt/homebrew/bin:/usr/local/bin'; type Handler = (req: Request) => Response | Promise; interface MockServer { url: string; close: () => void; requests: Array<{ method: string; path: string; body?: string }>; } function startMock(routes: Record): MockServer { const requests: MockServer['requests'] = []; const server = Bun.serve({ port: 0, async fetch(req) { const u = new URL(req.url); const key = `${req.method} ${u.pathname}`; // Log method+path only. Handlers that need the body read it themselves; // Response bodies can only be consumed once. requests.push({ method: req.method, path: u.pathname }); const handler = routes[key] || routes[`${req.method} *`]; if (!handler) { return new Response( JSON.stringify({ message: `no mock for ${key}` }), { status: 404, headers: { 'content-type': 'application/json' } } ); } return handler(req); }, }); const base = `http://localhost:${server.port}`; return { url: base, close: () => server.stop(true), requests, }; } // Per-test GSTACK_HOME so egress receipts land in a throwaway ledger, never // the operator's real ~/.gstack/security/egress.jsonl. let egressHome: string; /** * Run the CLI in-process with injected fetch (real fetch — it round-trips to * the Bun.serve loopback mock), injected env (the module never reads * process.env), and a no-op sleep so retry/backoff and wait-poll paths run * instantly. */ async function runCmd( args: string[], env: Record = {} ): Promise<{ stdout: string; stderr: string; status: number }> { let stdout = ''; let stderr = ''; const status = await runProvision(args, { fetch: globalThis.fetch, env: { GSTACK_HOME: egressHome, ...env }, stdout: (chunk) => { stdout += chunk; }, stderr: (chunk) => { stderr += chunk; }, sleep: async () => {}, }); return { stdout: stdout.trim(), stderr: stderr.trim(), status }; } function jsonResp(body: any, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' }, }); } let mock: MockServer; beforeEach(() => { egressHome = fs.mkdtempSync(path.join(os.tmpdir(), 'provision-egress-')); }); afterEach(() => { if (mock) mock.close(); fs.rmSync(egressHome, { recursive: true, force: true }); }); describe('list-orgs', () => { test('happy path: returns orgs from GET /v1/organizations', async () => { mock = startMock({ 'GET /v1/organizations': () => jsonResp([ { id: 'deprec-1', slug: 'acme', name: 'Acme Inc' }, { id: 'deprec-2', slug: 'personal', name: 'Personal' }, ]), }); const r = await runCmd(['list-orgs', '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test_pat', SUPABASE_API_BASE: mock.url, }); expect(r.status).toBe(0); const j = JSON.parse(r.stdout); expect(j.orgs).toEqual([ { slug: 'acme', name: 'Acme Inc' }, { slug: 'personal', name: 'Personal' }, ]); }); test('sends Authorization: Bearer header', async () => { let authHeader = ''; mock = startMock({ 'GET /v1/organizations': (req) => { authHeader = req.headers.get('authorization') || ''; return jsonResp([]); }, }); await runCmd(['list-orgs', '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_expected_pat_xxx', SUPABASE_API_BASE: mock.url, }); expect(authHeader).toBe('Bearer sbp_expected_pat_xxx'); }); test('exits 3 with auth error when SUPABASE_ACCESS_TOKEN is missing', async () => { const r = await runCmd(['list-orgs']); expect(r.status).toBe(3); expect(r.stderr).toContain('SUPABASE_ACCESS_TOKEN is not set'); }); test('exits 3 on 401 Unauthorized', async () => { mock = startMock({ 'GET /v1/organizations': () => jsonResp({ message: 'Invalid JWT' }, 401), }); const r = await runCmd(['list-orgs'], { SUPABASE_ACCESS_TOKEN: 'sbp_bad', SUPABASE_API_BASE: mock.url, }); expect(r.status).toBe(3); expect(r.stderr).toContain('401 Unauthorized'); }); test('exits 3 on 403 Forbidden', async () => { mock = startMock({ 'GET /v1/organizations': () => jsonResp({ message: 'Forbidden' }, 403), }); const r = await runCmd(['list-orgs'], { SUPABASE_ACCESS_TOKEN: 'sbp_noperm', SUPABASE_API_BASE: mock.url, }); expect(r.status).toBe(3); expect(r.stderr).toContain('403 Forbidden'); }); }); describe('create', () => { test('happy path: POST /v1/projects with organization_slug, no `plan` field', async () => { let sentBody: any = null; mock = startMock({ 'POST /v1/projects': async (req) => { sentBody = JSON.parse(await req.text()); return jsonResp({ id: 'deprec', ref: 'abcdefghijklmnopqrst', organization_slug: 'acme', name: 'gbrain', region: 'us-east-1', created_at: '2026-04-23T00:00:00Z', status: 'COMING_UP', }, 201); }, }); const r = await runCmd(['create', 'gbrain', 'us-east-1', 'acme', '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'generated-secret-pw', SUPABASE_API_BASE: mock.url, }); expect(r.status).toBe(0); const j = JSON.parse(r.stdout); expect(j.ref).toBe('abcdefghijklmnopqrst'); expect(j.status).toBe('COMING_UP'); // Verify the request body had the right shape expect(sentBody.name).toBe('gbrain'); expect(sentBody.region).toBe('us-east-1'); expect(sentBody.organization_slug).toBe('acme'); expect(sentBody.db_pass).toBe('generated-secret-pw'); // Critical: no `plan` field, since it's ignored server-side per OpenAPI expect(sentBody.plan).toBeUndefined(); }); test('passes desired_instance_size when --instance-size flag is used', async () => { let sentBody: any = null; mock = startMock({ 'POST /v1/projects': async (req) => { sentBody = JSON.parse(await req.text()); return jsonResp({ ref: 'r', status: 'COMING_UP' }, 201); }, }); await runCmd(['create', 'gbrain', 'us-east-1', 'acme', '--instance-size', 'small', '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'pw', SUPABASE_API_BASE: mock.url, }); expect(sentBody.desired_instance_size).toBe('small'); }); test('exits 4 on 402 Payment Required (quota)', async () => { mock = startMock({ 'POST /v1/projects': () => jsonResp({ message: 'project limit reached' }, 402), }); const r = await runCmd(['create', 'gbrain', 'us-east-1', 'acme'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'pw', SUPABASE_API_BASE: mock.url, }); expect(r.status).toBe(4); expect(r.stderr).toContain('402 Payment Required'); expect(r.stderr).toContain('quota exceeded'); }); test('exits 5 on 409 Conflict (duplicate name)', async () => { mock = startMock({ 'POST /v1/projects': () => jsonResp({ message: 'conflict' }, 409), }); const r = await runCmd(['create', 'gbrain', 'us-east-1', 'acme'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'pw', SUPABASE_API_BASE: mock.url, }); expect(r.status).toBe(5); expect(r.stderr).toContain('409 Conflict'); expect(r.stderr).toContain('duplicate project name'); }); test('fails when DB_PASS is missing', async () => { const r = await runCmd(['create', 'gbrain', 'us-east-1', 'acme'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', }); expect(r.status).toBe(2); expect(r.stderr).toContain('DB_PASS env var is required'); }); test('missing positional args rejected with exit 2', async () => { const r = await runCmd(['create', 'gbrain'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'pw', }); expect(r.status).toBe(2); expect(r.stderr).toContain('missing'); }); test('retries on 429 rate limit with backoff and eventually succeeds', async () => { let count = 0; mock = startMock({ 'POST /v1/projects': () => { count += 1; if (count < 2) return jsonResp({ message: 'too many requests' }, 429); return jsonResp({ ref: 'r', status: 'COMING_UP' }, 201); }, }); const r = await runCmd(['create', 'gbrain', 'us-east-1', 'acme', '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'pw', SUPABASE_API_BASE: mock.url, }); expect(r.status).toBe(0); expect(count).toBe(2); }); test('exits 8 on persistent 5xx after max retries', async () => { let count = 0; mock = startMock({ 'POST /v1/projects': () => { count += 1; return jsonResp({ message: 'internal server error' }, 502); }, }); const r = await runCmd(['create', 'gbrain', 'us-east-1', 'acme'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'pw', SUPABASE_API_BASE: mock.url, }); expect(r.status).toBe(8); expect(r.stderr).toContain('502'); expect(count).toBeGreaterThanOrEqual(3); }); }); describe('wait', () => { test('happy path: polls until ACTIVE_HEALTHY', async () => { let count = 0; mock = startMock({ 'GET /v1/projects/abc': () => { count += 1; if (count < 2) return jsonResp({ ref: 'abc', status: 'COMING_UP' }); return jsonResp({ ref: 'abc', status: 'ACTIVE_HEALTHY' }); }, }); const r = await runCmd(['wait', 'abc', '--timeout', '30', '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', SUPABASE_API_BASE: mock.url, }); expect(r.status).toBe(0); const j = JSON.parse(r.stdout); expect(j.status).toBe('ACTIVE_HEALTHY'); expect(j.ref).toBe('abc'); }); test('exits 7 on terminal INIT_FAILED state', async () => { mock = startMock({ 'GET /v1/projects/abc': () => jsonResp({ ref: 'abc', status: 'INIT_FAILED' }), }); const r = await runCmd(['wait', 'abc', '--timeout', '10'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', SUPABASE_API_BASE: mock.url, }); expect(r.status).toBe(7); expect(r.stderr).toContain('INIT_FAILED'); }); test('exits 6 on timeout with resume-provision hint', async () => { // Stay in COMING_UP forever. mock = startMock({ 'GET /v1/projects/abc': () => jsonResp({ ref: 'abc', status: 'COMING_UP' }), }); const r = await runCmd(['wait', 'abc', '--timeout', '0'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', SUPABASE_API_BASE: mock.url, }); expect(r.status).toBe(6); expect(r.stderr).toContain('wait timed out'); expect(r.stderr).toContain('--resume-provision abc'); }); test('non-numeric --timeout dies at parse time instead of polling forever', async () => { // NaN would make `elapsed >= timeout` always false: an infinite 5s poll loop. // The bash predecessor errored on `[ "$elapsed" -ge "abc" ]`; the port // must be at least as strict. const r = await runCmd(['wait', 'abc', '--timeout', 'abc'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', }); expect(r.status).toBe(2); expect(r.stderr).toContain('--timeout must be a non-negative integer'); }); }); describe('pooler-url', () => { const REF = 'abcdefghijklmnopqrst'; const POOLER_OK = { db_user: `postgres.${REF}`, db_host: 'aws-0-us-east-1.pooler.supabase.com', db_port: 6543, db_name: 'postgres', pool_mode: 'session', connection_string: 'postgresql://postgres.abcdefghijklmnopqrst:[PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres', }; test('constructs URL from db_user/host/port/name + DB_PASS (not response connection_string)', async () => { mock = startMock({ [`GET /v1/projects/${REF}/config/database/pooler`]: () => jsonResp(POOLER_OK), }); const r = await runCmd(['pooler-url', REF, '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'my-real-password', SUPABASE_API_BASE: mock.url, }); expect(r.status).toBe(0); const j = JSON.parse(r.stdout); expect(j.pooler_url).toBe( `postgresql://postgres.${REF}:my-real-password@aws-0-us-east-1.pooler.supabase.com:6543/postgres` ); // The API's templated connection_string is NOT what we output. expect(j.pooler_url).not.toContain('[PASSWORD]'); }); test('percent-encodes reserved characters in DB_PASS (DSN stays parseable)', async () => { // Raw interpolation of a password containing / # ? % @ changes URI // structure: provisioning succeeds, every consumer then fails to parse // the DSN — an unusable billable orphan. mock = startMock({ [`GET /v1/projects/${REF}/config/database/pooler`]: () => jsonResp(POOLER_OK), }); const r = await runCmd(['pooler-url', REF, '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'p@ss/w#rd?100%', SUPABASE_API_BASE: mock.url, }); expect(r.status).toBe(0); const j = JSON.parse(r.stdout); // Expected URL assembled from parts so this file's own pushed bytes never // form a contiguous scheme://user:pass@host credential shape. const expectedUrl = 'postgresql://postgres.' + REF + ':' + encodeURIComponent('p@ss/w#rd?100%') + '@' + 'aws-0-us-east-1.pooler.supabase.com:6543/postgres'; expect(j.pooler_url).toBe(expectedUrl); // The password segment must parse back out intact. expect(decodeURIComponent(new URL(j.pooler_url).password)).toBe('p@ss/w#rd?100%'); }); test('handles array response by preferring session pool_mode entry', async () => { mock = startMock({ [`GET /v1/projects/${REF}/config/database/pooler`]: () => jsonResp([ { ...POOLER_OK, pool_mode: 'transaction', db_port: 6543 }, { ...POOLER_OK, pool_mode: 'session', db_port: 5432 }, ]), }); const r = await runCmd(['pooler-url', REF, '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'pw', SUPABASE_API_BASE: mock.url, }); expect(r.status).toBe(0); const j = JSON.parse(r.stdout); // Picked session entry with port 5432 (for this fixture) expect(j.pooler_url).toContain(':5432/postgres'); }); test('fails cleanly when pooler config is missing required fields', async () => { mock = startMock({ [`GET /v1/projects/${REF}/config/database/pooler`]: () => jsonResp({ identifier: 'x', pool_mode: 'session' }), }); const r = await runCmd(['pooler-url', REF], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'pw', SUPABASE_API_BASE: mock.url, }); expect(r.status).toBe(2); expect(r.stderr).toContain('missing pooler config fields'); }); test('requires DB_PASS to construct URL', async () => { const r = await runCmd(['pooler-url', REF], { SUPABASE_ACCESS_TOKEN: 'sbp_test', }); expect(r.status).toBe(2); expect(r.stderr).toContain('DB_PASS env var is required'); }); // --- Issue #1301: New Supabase projects' API returns transaction/6543 but // the shared pooler tenant only listens on session/5432. Rewrite that // single combination, leave every other shape alone. --- test('rewrites single transaction/6543 response to session/5432 (issue #1301)', async () => { mock = startMock({ [`GET /v1/projects/${REF}/config/database/pooler`]: () => jsonResp({ ...POOLER_OK, pool_mode: 'transaction', db_port: 6543 }), }); const r = await runCmd(['pooler-url', REF, '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'pw', SUPABASE_API_BASE: mock.url, }); expect(r.status).toBe(0); expect(JSON.parse(r.stdout).pooler_url).toContain(':5432/postgres'); expect(r.stderr).toContain('rewriting'); }); test('leaves session/6543 alone (some regions genuinely serve session on 6543)', async () => { mock = startMock({ [`GET /v1/projects/${REF}/config/database/pooler`]: () => jsonResp({ ...POOLER_OK, pool_mode: 'session', db_port: 6543 }), }); const r = await runCmd(['pooler-url', REF, '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'pw', SUPABASE_API_BASE: mock.url, }); expect(r.status).toBe(0); expect(JSON.parse(r.stdout).pooler_url).toContain(':6543/postgres'); expect(r.stderr).not.toContain('rewriting'); }); test('leaves transaction/5432 alone (only the 6543 case is the known footgun)', async () => { mock = startMock({ [`GET /v1/projects/${REF}/config/database/pooler`]: () => jsonResp({ ...POOLER_OK, pool_mode: 'transaction', db_port: 5432 }), }); const r = await runCmd(['pooler-url', REF, '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'pw', SUPABASE_API_BASE: mock.url, }); expect(r.status).toBe(0); expect(JSON.parse(r.stdout).pooler_url).toContain(':5432/postgres'); expect(r.stderr).not.toContain('rewriting'); }); test('GSTACK_SUPABASE_TRUST_API_PORT=1 disables the rewrite', async () => { mock = startMock({ [`GET /v1/projects/${REF}/config/database/pooler`]: () => jsonResp({ ...POOLER_OK, pool_mode: 'transaction', db_port: 6543 }), }); const r = await runCmd(['pooler-url', REF, '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'pw', SUPABASE_API_BASE: mock.url, GSTACK_SUPABASE_TRUST_API_PORT: '1', }); expect(r.status).toBe(0); expect(JSON.parse(r.stdout).pooler_url).toContain(':6543/postgres'); expect(r.stderr).not.toContain('rewriting'); }); test('array response with explicit session entry on 5432 is unaffected (existing behavior)', async () => { mock = startMock({ [`GET /v1/projects/${REF}/config/database/pooler`]: () => jsonResp([ { ...POOLER_OK, pool_mode: 'transaction', db_port: 6543 }, { ...POOLER_OK, pool_mode: 'session', db_port: 5432 }, ]), }); const r = await runCmd(['pooler-url', REF, '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', DB_PASS: 'pw', SUPABASE_API_BASE: mock.url, }); expect(r.status).toBe(0); expect(JSON.parse(r.stdout).pooler_url).toContain(':5432/postgres'); expect(r.stderr).not.toContain('rewriting'); }); }); describe('list-orphans (D20)', () => { const MOCK_PROJECTS = [ { ref: 'aaaaaaaaaaaaaaaaaaaa', name: 'gbrain', created_at: '2026-04-20', region: 'us-east-1' }, { ref: 'bbbbbbbbbbbbbbbbbbbb', name: 'gbrain-backup', created_at: '2026-04-21', region: 'us-east-1' }, { ref: 'cccccccccccccccccccc', name: 'my-production', created_at: '2026-04-15', region: 'us-west-2' }, { ref: 'dddddddddddddddddddd', name: 'gbrain', created_at: '2026-04-22', region: 'eu-west-1' }, ]; test('lists gbrain-prefixed projects that are NOT the active brain', async () => { mock = startMock({ 'GET /v1/projects': () => jsonResp(MOCK_PROJECTS), }); const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-orphan-')); // use top-level fs fs.mkdirSync(path.join(home, '.gbrain')); fs.writeFileSync( path.join(home, '.gbrain', 'config.json'), JSON.stringify({ engine: 'postgres', // Active brain points at aaaaaaaaaaaaaaaaaaaa database_url: 'postgresql://postgres.aaaaaaaaaaaaaaaaaaaa:pw@host:6543/postgres', }) ); try { const r = await runCmd(['list-orphans', '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', SUPABASE_API_BASE: mock.url, HOME: home, }); expect(r.status).toBe(0); const j = JSON.parse(r.stdout); expect(j.active_ref).toBe('aaaaaaaaaaaaaaaaaaaa'); expect(j.orphans.length).toBe(2); const refs = j.orphans.map((o: any) => o.ref).sort(); expect(refs).toEqual(['bbbbbbbbbbbbbbbbbbbb', 'dddddddddddddddddddd']); // my-production is NOT in orphans — filtered out by gbrain prefix expect(refs).not.toContain('cccccccccccccccccccc'); } finally { fs.rmSync(home, { recursive: true, force: true }); } }); test('treats all gbrain-prefixed projects as orphans when no active config exists', async () => { mock = startMock({ 'GET /v1/projects': () => jsonResp(MOCK_PROJECTS), }); const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-no-cfg-')); try { const r = await runCmd(['list-orphans', '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', SUPABASE_API_BASE: mock.url, HOME: home, }); expect(r.status).toBe(0); const j = JSON.parse(r.stdout); expect(j.active_ref).toBeNull(); // All 3 gbrain-prefixed projects are orphans when no active config expect(j.orphans.length).toBe(3); } finally { // use top-level fs fs.rmSync(home, { recursive: true, force: true }); } }); test('respects custom --name-prefix', async () => { mock = startMock({ 'GET /v1/projects': () => jsonResp([ { ref: 'aaaaaaaaaaaaaaaaaaaa', name: 'my-prefix-one', created_at: '2026-04-20' }, { ref: 'bbbbbbbbbbbbbbbbbbbb', name: 'gbrain', created_at: '2026-04-20' }, ]), }); const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-prefix-')); try { const r = await runCmd(['list-orphans', '--name-prefix', 'my-prefix', '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', SUPABASE_API_BASE: mock.url, HOME: home, }); const j = JSON.parse(r.stdout); expect(j.orphans.length).toBe(1); expect(j.orphans[0].name).toBe('my-prefix-one'); } finally { // use top-level fs fs.rmSync(home, { recursive: true, force: true }); } }); }); describe('delete-project (D20)', () => { test('issues DELETE /v1/projects/ and returns the deleted ref', async () => { let deletedPath = ''; mock = startMock({ 'DELETE /v1/projects/abcdefghijklmnopqrst': (req) => { deletedPath = new URL(req.url).pathname; return jsonResp({ id: 1, ref: 'abcdefghijklmnopqrst', name: 'gbrain' }); }, }); const r = await runCmd(['delete-project', 'abcdefghijklmnopqrst', '--json'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', SUPABASE_API_BASE: mock.url, }); expect(r.status).toBe(0); expect(deletedPath).toBe('/v1/projects/abcdefghijklmnopqrst'); const j = JSON.parse(r.stdout); expect(j.deleted_ref).toBe('abcdefghijklmnopqrst'); }); test('surfaces 404 when the project does not exist', async () => { mock = startMock({ 'DELETE /v1/projects/nonexistent': () => jsonResp({ message: 'Project not found' }, 404), }); const r = await runCmd(['delete-project', 'nonexistent'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', SUPABASE_API_BASE: mock.url, }); expect(r.status).toBe(2); expect(r.stderr).toContain('404'); }); test('requires a ref', async () => { const r = await runCmd(['delete-project'], { SUPABASE_ACCESS_TOKEN: 'sbp_test', }); expect(r.status).toBe(2); expect(r.stderr).toContain('missing'); }); }); describe('general', () => { test('unknown subcommand exits 2', async () => { const r = await runCmd(['nope']); expect(r.status).toBe(2); expect(r.stderr).toContain('unknown subcommand'); }); test('no args prints usage and exits 2', async () => { const r = await runCmd([]); expect(r.status).toBe(2); expect(r.stderr).toContain('usage'); }); test('--help prints the doc header and exits 0', async () => { const r = await runCmd(['--help']); expect(r.status).toBe(0); expect(r.stdout).toContain( 'gstack-gbrain-supabase-provision — Supabase Management API wrapper' ); expect(r.stdout).toContain('Exit codes:'); }); }); describe('bin smoke test (spawned)', () => { // Exactly one spawn-based test: runs the real bin end-to-end against the // mock server to pin the shebang/CLI contract (bun-shebang resolves, argv // and env flow through, JSON lands on stdout, exit code propagates). All // behavioral coverage above runs in-process. test('real bin: list-orgs --json round-trips against a mock server', async () => { let authHeader = ''; mock = startMock({ 'GET /v1/organizations': (req) => { authHeader = req.headers.get('authorization') || ''; return jsonResp([{ id: 'x', slug: 'acme', name: 'Acme Inc' }]); }, }); // Use Bun.spawn (async) rather than spawnSync. spawnSync blocks the Bun // event loop, which prevents Bun.serve mocks from responding — every // HTTP call would hit fetch's timeout instead of round-tripping. const proc = Bun.spawn([BIN, 'list-orgs', '--json'], { env: { PATH: `${path.dirname(process.execPath)}:${SAFE_PATH}`, SUPABASE_ACCESS_TOKEN: 'sbp_smoke_pat', SUPABASE_API_BASE: mock.url, GSTACK_HOME: egressHome, }, stdout: 'pipe', stderr: 'pipe', }); const [stdout, stderr, status] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), proc.exited, ]); expect(status).toBe(0); expect(stderr.trim()).toBe(''); expect(authHeader).toBe('Bearer sbp_smoke_pat'); expect(JSON.parse(stdout.trim())).toEqual({ orgs: [{ slug: 'acme', name: 'Acme Inc' }] }); // The spawned bin wrote its egress receipt into the per-test ledger. const ledger = path.join(egressHome, 'security', 'egress.jsonl'); expect(fs.existsSync(ledger)).toBe(true); expect(fs.readFileSync(ledger, 'utf-8')).toContain('"sink":"supabase-provision"'); }, 20_000); });