import { assertStringIncludes } from "@std/assert"; import { expect } from "@std/expect"; import { it as test } from "@std/testing/bdd"; import { runWithSiteBuildScope } from "#shared/builder-dry-run.ts"; import { bunnyDbProvider as bunnyDbApi } from "#shared/bunny-db.ts"; import { expectIncludesAll } from "#test-utils/assertions.ts"; import { describeWithEnv } from "#test-utils/db.ts"; import { withEnv } from "#test-utils/env.ts"; import { type FetchReply, requestLines, stubFetch, } from "#test-utils/fetch-stub.ts"; /** Bunny's live region config, as returned by GET /database/v1/config. */ const CONFIG_RESPONSE = { primary_regions: [{ id: "DE" }, { id: "UK" }, { id: "NY" }, { id: "SG" }], replica_regions: [{ id: "LA" }, { id: "SYD" }, { id: "BR" }, { id: "JH" }], storage_region_available: [{ id: "eu-west-1" }, { id: "us-east-1" }], }; const PRIMARY_REGION_IDS = CONFIG_RESPONSE.primary_regions.map((r) => r.id); const REPLICA_REGION_IDS = CONFIG_RESPONSE.replica_regions.map((r) => r.id); /** A 200 response carrying Bunny's region config JSON. */ const configResponse = (): Response => new Response(JSON.stringify(CONFIG_RESPONSE), { status: 200 }); interface DbDetail { name: string; token: string; url: string; } const DEFAULT_DETAIL: DbDetail = { name: "Test", token: "tok", url: "libsql://x.bunnydb.net", }; /** Happy-path responses for the get-details and token steps of a create. */ const getAndAuthResponse = ( url: string, dbId: string, detail: DbDetail = DEFAULT_DETAIL, ): Response => url.includes(`/v2/databases/${dbId}`) && !url.includes("/auth") ? new Response( JSON.stringify({ db: { db_id: dbId, name: detail.name, url: detail.url }, }), { status: 200 }, ) : new Response(JSON.stringify({ token: detail.token }), { status: 200 }); /** * Stub fetch so the config endpoint returns Bunny's live regions and every * other URL is answered by `fallback`. */ const stubFetchAfterConfig = (fallback: (url: string) => Response) => stubFetch((url) => url.endsWith("/v1/config") ? configResponse() : fallback(url), ); describeWithEnv("bunny-db", { env: { BUNNY_API_KEY: "test-api-key" } }, () => { const dbCreateFetch = (dbId: string, fallback: (url: string) => Response) => stubFetchAfterConfig((url) => url.endsWith("/v2/databases") ? new Response(JSON.stringify({ db_id: dbId }), { status: 200 }) : fallback(url), ); test("createDatabase calls create, get, and token endpoints", async () => { const fetchCalls: string[] = []; using _fetch = stubFetch((url) => { fetchCalls.push(url); if (url.endsWith("/v1/config")) { return configResponse(); } if (url.endsWith("/v2/databases") && !url.includes("/auth")) { return new Response(JSON.stringify({ db_id: "db_test123" })); } return getAndAuthResponse(url, "db_test123", { name: "My Site", token: "bny_token_abc", url: "libsql://my-site.lite.bunnydb.net", }); }); const result = await bunnyDbApi.createDatabase("My Site"); expect(result.ok).toBe(true); if (result.ok) { expect(result.value.dbUrl).toBe("libsql://my-site.lite.bunnydb.net"); expect(result.value.dbToken).toBe("bny_token_abc"); expect(result.value.dbId).toBe("db_test123"); } expect(fetchCalls.length).toBe(4); assertStringIncludes(fetchCalls[0] ?? "", "/v1/config"); }); test("createDatabase rejects a token response with no token", async () => { using _fetch = dbCreateFetch("db_missing_token", (url) => url.includes("/auth") ? new Response("{}") : getAndAuthResponse(url, "db_missing_token"), ); await expect(bunnyDbApi.createDatabase("Missing token")).rejects.toThrow( "Database response is missing token", ); }); test("createDatabase sends every region Bunny reported as primaries and replicas", async () => { let createBody: unknown; using _fetch = stubFetch((url, init) => { if (url.endsWith("/v1/config")) { return configResponse(); } if (url.endsWith("/v2/databases")) { createBody = JSON.parse(init?.body as string); return new Response(JSON.stringify({ db_id: "db_abc" })); } return getAndAuthResponse(url, "db_abc"); }); await bunnyDbApi.createDatabase("Test"); // storage_region is pinned by its literal, not by the exported // constant, so a mutated constant cannot match itself. expect(createBody).toEqual({ name: "Test", primary_regions: PRIMARY_REGION_IDS, replicas_regions: REPLICA_REGION_IDS, storage_region: "eu-west-1", }); }); test("createDatabase posts the create and puts the token", async () => { const requests: Record = {}; const record = (url: string, init: RequestInit | undefined): void => { requests[url] = { body: init?.body === undefined ? null : JSON.parse(String(init.body)), method: init?.method ?? "", }; }; using _fetch = stubFetch((url, init) => { if (url.endsWith("/v2/databases") || url.includes("/auth/generate")) { record(url, init); } if (url.endsWith("/v1/config")) { return configResponse(); } if (url.endsWith("/v2/databases")) { return new Response(JSON.stringify({ db_id: "db_m" })); } return getAndAuthResponse(url, "db_m"); }); await bunnyDbApi.createDatabase("Methods"); expect(requests).toEqual({ "https://api.bunny.net/database/v2/databases": { body: { // The literal pins the region, so a mutated constant cannot // match itself. name: "Methods", primary_regions: PRIMARY_REGION_IDS, replicas_regions: REPLICA_REGION_IDS, storage_region: "eu-west-1", }, method: "POST", }, "https://api.bunny.net/database/v2/databases/db_m/auth/generate": { body: { authorization: "full-access", expires_at: null }, method: "PUT", }, }); }); test("createDatabase uses AccessKey header", async () => { const headers: string[] = []; const respondForHeaderTest = (url: string): Response => { if (url.endsWith("/v1/config")) { return configResponse(); } if (url.endsWith("/v2/databases")) { return new Response(JSON.stringify({ db_id: "db_hdr" }), { status: 200, }); } if (url.includes("/v2/databases/db_hdr") && !url.includes("/auth")) { return new Response( JSON.stringify({ db: { db_id: "db_hdr", name: "H", url: "libsql://h.net" }, }), { status: 200 }, ); } if (url.includes("/auth/generate")) { return new Response(JSON.stringify({ token: "t" }), { status: 200 }); } return new Response("", { status: 500 }); }; using _fetch = stubFetch((url, init) => { const accessKey = (init?.headers as Record)?.AccessKey; if (accessKey) headers.push(accessKey); return respondForHeaderTest(url); }); await bunnyDbApi.createDatabase("H"); expect(headers.every((h) => h === "test-api-key")).toBe(true); expect(headers.length).toBeGreaterThan(0); }); test("createDatabase stops and returns error when the config endpoint fails", async () => { using fetchStub = stubFetch(new Response("Forbidden", { status: 403 })); const result = await bunnyDbApi.createDatabase("Bad"); expect(result.ok).toBe(false); if (!result.ok) { assertStringIncludes(result.error, "Get database config failed (403)"); } // A failed config lookup must not go on to create anything. expect(fetchStub.calls.length).toBe(1); assertStringIncludes(String(fetchStub.calls[0]!.args[0]), "/v1/config"); }); test("createDatabase answers from canned bodies in a dry run", async () => { using _env = withEnv({ SITE_BUILD_DRY_RUN: "true" }); using _network = stubFetch(() => { throw new Error("dry run must not touch the network"); }); // Database creation is a build call, so it answers only inside the // build's scope; the same instance's live admin actions stay real. const result = await runWithSiteBuildScope(() => bunnyDbApi.createDatabase("Dry run db"), ); expect(result.ok).toBe(true); if (result.ok) { expect(result.value.dbUrl).toMatch(/^libsql:\/\/dry-run-\d+\.invalid$/); expect(result.value.dbToken).toBe("dry-run-db-token"); } }); test("createDatabase returns error when create endpoint fails", async () => { using _fetch = stubFetchAfterConfig( () => new Response("Forbidden", { status: 403 }), ); const result = await bunnyDbApi.createDatabase("Bad"); expect(result.ok).toBe(false); if (!result.ok) { assertStringIncludes(result.error, "Create database failed (403)"); } }); test("createDatabase returns error when get database endpoint fails with JSON Message", async () => { using _fetch = dbCreateFetch( "db_err", () => new Response(JSON.stringify({ Message: "Database not found" }), { status: 404, }), ); const result = await bunnyDbApi.createDatabase("Err"); expect(result.ok).toBe(false); if (!result.ok) { expectIncludesAll(result.error, [ "Get database failed (404)", "Database not found", ]); } }); test("createDatabase returns error when token generation fails with JSON body", async () => { using _fetch = dbCreateFetch("db_tok", (url) => { if (url.includes("/v2/databases/db_tok") && !url.includes("/auth")) { return new Response( JSON.stringify({ db: { db_id: "db_tok", name: "T", url: "libsql://t.net" }, }), ); } return new Response(JSON.stringify({ code: 401 }), { status: 401 }); }); const result = await bunnyDbApi.createDatabase("T"); expect(result.ok).toBe(false); if (!result.ok) { assertStringIncludes( result.error, "Generate database token failed (401)", ); } }); }); /** One page of the account's database list: the databases, and whether another * page follows. */ type DatabasePage = [databases: { id: string; url: string }[], more: boolean]; const BUNNY_API = "https://api.bunny.net/database/v2/databases"; const listLine = (page: number): string => `GET ${BUNNY_API}?page=${page}&per_page=50`; const SITE_DB = { id: "db_site", url: "libsql://site.lite.bunnydb.net" }; const OTHER_DB = { id: "db_other", url: "libsql://other.lite.bunnydb.net" }; /** Answer one key-rotation request: the next database page, the revoke answer, * or the token answer. */ const keyReply = ( remaining: DatabasePage[], revokeStatus: number, tokenStatus: number, ): FetchReply => (url) => { if (url.includes("/databases?")) { const [databases, more] = remaining.shift()!; return Response.json({ databases, page_info: { has_more_items: more } }); } if (url.endsWith("/auth/revoke")) { return new Response(revokeStatus === 204 ? null : "refused", { status: revokeStatus, }); } return new Response( tokenStatus === 200 ? JSON.stringify({ token: "bunny-rotated-token" }) : "refused", { status: tokenStatus }, ); }; describeWithEnv( "bunny-db key rotation", { env: { BUNNY_API_KEY: "test-api-key" } }, () => { /** The two steps of a rotation, each asking the provider for itself. */ const STEPS = { invalidate: (dbUrl: string) => bunnyDbApi.invalidateDatabaseTokens(dbUrl), issue: (dbUrl: string) => bunnyDbApi.issueDatabaseToken(dbUrl), } as const; const cases: { name: string; step: keyof typeof STEPS; pages: DatabasePage[]; revokeStatus: number; tokenStatus: number; result: unknown; requests: string[]; }[] = [ { name: "removes every key of the database at the site's address", pages: [[[OTHER_DB, SITE_DB], false]], requests: [listLine(1), `POST ${BUNNY_API}/db_site/auth/revoke`], result: { ok: true }, revokeStatus: 204, step: "invalidate", tokenStatus: 200, }, { name: "walks every page to find the database", pages: [ [[OTHER_DB], true], [[SITE_DB], false], ], requests: [ listLine(1), listLine(2), `POST ${BUNNY_API}/db_site/auth/revoke`, ], result: { ok: true }, revokeStatus: 204, step: "invalidate", tokenStatus: 200, }, { name: "refuses an address the account does not list", pages: [[[OTHER_DB], false]], requests: [listLine(1)], result: { error: "No Bunny database has the address site.lite.bunnydb.net.", ok: false, }, revokeStatus: 204, step: "issue", tokenStatus: 200, }, { name: "reports a refused key removal", pages: [[[SITE_DB], false]], requests: [listLine(1), `POST ${BUNNY_API}/db_site/auth/revoke`], result: { error: "Revoke database tokens failed (500): refused", ok: false, }, revokeStatus: 500, step: "invalidate", tokenStatus: 200, }, { name: "asks for one new key of the database at the site's address", pages: [[[OTHER_DB, SITE_DB], false]], requests: [listLine(1), `PUT ${BUNNY_API}/db_site/auth/generate`], result: { ok: true, value: "bunny-rotated-token" }, revokeStatus: 204, step: "issue", tokenStatus: 200, }, { name: "reports a refused new key", pages: [[[SITE_DB], false]], requests: [listLine(1), `PUT ${BUNNY_API}/db_site/auth/generate`], result: { error: "Generate database token failed (401): refused", ok: false, }, revokeStatus: 204, step: "issue", tokenStatus: 401, }, ]; test("reports a refused database list", async () => { using _fetch = stubFetch(new Response("refused", { status: 500 })); expect(await bunnyDbApi.invalidateDatabaseTokens(SITE_DB.url)).toEqual({ error: "List databases failed (500): refused", ok: false, }); }); for (const { name, step, pages, revokeStatus, tokenStatus, result, requests, } of cases) { test(name, async () => { const remaining = [...pages]; using fetchStub = stubFetch( keyReply(remaining, revokeStatus, tokenStatus), ); expect(await STEPS[step](SITE_DB.url)).toEqual(result); expect(requestLines(fetchStub)).toEqual(requests); }); } }, );