Ctrl K
ComunidadGuías…

test/shared/bunny-db.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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);
      });
    }
  },
);