test/shared/bunny-db.test.ts
Rama main · réplica de lectura del repositorio original.
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<string, { body: unknown; method: string }> = {};
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<string, string>)?.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);
});
}
},
);