b3a7f98e5a
CI / E2E Cloudflare (4/8) (push) Failing after 0s
CI / E2E Cloudflare (8/8) (push) Failing after 1s
CI / Lint (push) Failing after 1s
Auto Extract / Extract (push) Failing after 4s
CI / Version Check (push) Failing after 9s
CI / Integration Tests (push) Failing after 1s
CI / E2E tests (1/8) (push) Failing after 1s
CI / E2E tests (2/8) (push) Failing after 2s
CI / E2E tests (3/8) (push) Failing after 2s
CI / Browser Tests (push) Failing after 1s
CI / E2E tests (5/8) (push) Failing after 1s
CI / E2E Cloudflare (2/8) (push) Failing after 2s
CI / Typecheck (push) Failing after 1s
CI / Changeset Validation (push) Failing after 2s
CI / E2E Cloudflare (5/8) (push) Failing after 1s
CI / E2E Cloudflare (6/8) (push) Failing after 1s
CI / E2E Cloudflare (7/8) (push) Failing after 1s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1s
Format / Format (push) Failing after 0s
CodeQL / Analyze (actions) (push) Failing after 4s
CI / E2E tests (4/8) (push) Failing after 1s
CI / E2E tests (6/8) (push) Failing after 1s
CI / E2E tests (7/8) (push) Failing after 2s
CI / E2E tests (8/8) (push) Failing after 1s
CI / E2E Cloudflare (1/8) (push) Failing after 1s
CI / E2E Cloudflare (3/8) (push) Failing after 2s
Preview Releases / Publish Preview (push) Failing after 0s
zizmor / Run zizmor (push) Failing after 1s
Release / Release (push) Failing after 2s
CI / Smoke Tests (push) Failing after 5m36s
CI / Tests (push) Failing after 6m36s
Release / Sync Templates (push) Has been skipped
CI / E2E Tests (push) Has been cancelled
175 lines
6.9 KiB
TypeScript
175 lines
6.9 KiB
TypeScript
/**
|
|
* pds-verify unit tests.
|
|
*
|
|
* Cover the HTTP / error-shaping logic with a stub `fetch`. The actual
|
|
* verification handoff to `@atcute/repo`'s `verifyRecord` is NOT exercised
|
|
* end-to-end anywhere in this suite — building a valid signed CAR by hand
|
|
* would re-implement what `@atcute/repo` already tests internally, and the
|
|
* consumer-level test path stubs verification via `ConsumerDeps.verify`
|
|
* (the FakePublisher / MockPds fixture from `@emdash-cms/atproto-test-utils`
|
|
* can't load inside `@cloudflare/vitest-pool-workers` due to a transitive
|
|
* `@atproto/lex-data` incompatibility; see records-consumer test header).
|
|
*
|
|
* What we DO test here is the surface every reason code can be reached
|
|
* through, plus the `isTransient` policy mapping the consumer relies on.
|
|
*/
|
|
|
|
import { P256PublicKey, P256PrivateKeyExportable } from "@atcute/crypto";
|
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
|
|
import { fetchAndVerifyRecord, isTransient, PdsVerificationError } from "../src/pds-verify.js";
|
|
|
|
const TEST_DID = "did:plc:test00000000000000000000";
|
|
const TEST_PDS = "https://pds.test.example";
|
|
|
|
let publicKey: P256PublicKey;
|
|
|
|
beforeAll(async () => {
|
|
const kp = await P256PrivateKeyExportable.createKeypair();
|
|
const raw = await kp.exportPublicKey("raw");
|
|
publicKey = await P256PublicKey.importRaw(raw);
|
|
});
|
|
|
|
async function captureRejection<T>(promise: Promise<T>): Promise<PdsVerificationError> {
|
|
try {
|
|
await promise;
|
|
} catch (err) {
|
|
if (err instanceof PdsVerificationError) return err;
|
|
throw err;
|
|
}
|
|
throw new Error("expected promise to reject with PdsVerificationError");
|
|
}
|
|
|
|
function buildOpts(overrides: {
|
|
fetch: typeof fetch;
|
|
timeoutMs?: number;
|
|
maxResponseBytes?: number;
|
|
}) {
|
|
return {
|
|
pds: TEST_PDS,
|
|
did: TEST_DID,
|
|
collection: "com.emdashcms.experimental.package.profile",
|
|
rkey: "demo",
|
|
publicKey,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("fetchAndVerifyRecord — HTTP path", () => {
|
|
it("builds the canonical sync.getRecord URL with did/collection/rkey", async () => {
|
|
let observedUrl: string | undefined;
|
|
const fetchImpl: typeof fetch = async (input) => {
|
|
observedUrl =
|
|
typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
|
return new Response(new Uint8Array([0]), { status: 200 });
|
|
};
|
|
await fetchAndVerifyRecord(buildOpts({ fetch: fetchImpl })).catch(() => {
|
|
/* verifyRecord rejects on the dummy bytes — we only care about the URL */
|
|
});
|
|
expect(observedUrl).toBe(
|
|
`${TEST_PDS}/xrpc/com.atproto.sync.getRecord?did=${encodeURIComponent(TEST_DID)}&collection=com.emdashcms.experimental.package.profile&rkey=demo`,
|
|
);
|
|
});
|
|
|
|
it("maps a network error to PDS_NETWORK_ERROR", async () => {
|
|
const fetchImpl: typeof fetch = () => Promise.reject(new TypeError("connection refused"));
|
|
await expect(fetchAndVerifyRecord(buildOpts({ fetch: fetchImpl }))).rejects.toMatchObject({
|
|
name: "PdsVerificationError",
|
|
reason: "PDS_NETWORK_ERROR",
|
|
});
|
|
});
|
|
|
|
it("maps an aborted fetch (timeout) to PDS_NETWORK_ERROR with the timeout in the message", async () => {
|
|
const fetchImpl: typeof fetch = (_input, init) => {
|
|
return new Promise((_resolve, reject) => {
|
|
init?.signal?.addEventListener("abort", () => {
|
|
const err = new DOMException("aborted", "AbortError");
|
|
reject(err);
|
|
});
|
|
});
|
|
};
|
|
const err = await captureRejection(
|
|
fetchAndVerifyRecord(buildOpts({ fetch: fetchImpl, timeoutMs: 25 })),
|
|
);
|
|
expect(err.reason).toBe("PDS_NETWORK_ERROR");
|
|
expect(err.message).toMatch(/aborted after 25ms/);
|
|
});
|
|
|
|
it("maps a 404 to RECORD_NOT_FOUND with status", async () => {
|
|
const fetchImpl: typeof fetch = () => Promise.resolve(new Response("", { status: 404 }));
|
|
const err = await captureRejection(fetchAndVerifyRecord(buildOpts({ fetch: fetchImpl })));
|
|
expect(err.reason).toBe("RECORD_NOT_FOUND");
|
|
expect(err.status).toBe(404);
|
|
});
|
|
|
|
it("maps a 500 to PDS_HTTP_ERROR with status", async () => {
|
|
const fetchImpl: typeof fetch = () => Promise.resolve(new Response("", { status: 503 }));
|
|
const err = await captureRejection(fetchAndVerifyRecord(buildOpts({ fetch: fetchImpl })));
|
|
expect(err.reason).toBe("PDS_HTTP_ERROR");
|
|
expect(err.status).toBe(503);
|
|
});
|
|
|
|
it("maps a non-404 4xx to PDS_HTTP_ERROR with status", async () => {
|
|
const fetchImpl: typeof fetch = () => Promise.resolve(new Response("", { status: 401 }));
|
|
const err = await captureRejection(fetchAndVerifyRecord(buildOpts({ fetch: fetchImpl })));
|
|
expect(err.reason).toBe("PDS_HTTP_ERROR");
|
|
expect(err.status).toBe(401);
|
|
});
|
|
|
|
it("rejects responses larger than maxResponseBytes with RESPONSE_TOO_LARGE", async () => {
|
|
const big = new Uint8Array(64);
|
|
big.fill(0xff);
|
|
const fetchImpl: typeof fetch = () => Promise.resolve(new Response(big, { status: 200 }));
|
|
const err = await captureRejection(
|
|
fetchAndVerifyRecord(buildOpts({ fetch: fetchImpl, maxResponseBytes: 16 })),
|
|
);
|
|
expect(err.reason).toBe("RESPONSE_TOO_LARGE");
|
|
});
|
|
|
|
it("rejects a null body with INVALID_PROOF", async () => {
|
|
const fetchImpl: typeof fetch = () => {
|
|
// Construct a Response with a null body. The Response constructor
|
|
// allows null for HEAD-style responses; we never get null in
|
|
// practice but the guard is defensive.
|
|
return Promise.resolve(new Response(null, { status: 200 }));
|
|
};
|
|
const err = await captureRejection(fetchAndVerifyRecord(buildOpts({ fetch: fetchImpl })));
|
|
expect(err.reason).toBe("INVALID_PROOF");
|
|
});
|
|
|
|
it("hands successful body bytes to verifyRecord (which rejects malformed input as INVALID_PROOF)", async () => {
|
|
// Random bytes are guaranteed not to parse as a valid CAR. The point
|
|
// of the test is that we got past HTTP and INTO verifyRecord — and
|
|
// that verifyRecord's rejection is wrapped as INVALID_PROOF.
|
|
const garbage = new Uint8Array([1, 2, 3, 4, 5]);
|
|
const fetchImpl: typeof fetch = () => Promise.resolve(new Response(garbage, { status: 200 }));
|
|
const err = await captureRejection(fetchAndVerifyRecord(buildOpts({ fetch: fetchImpl })));
|
|
expect(err.reason).toBe("INVALID_PROOF");
|
|
expect(err.cause).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("isTransient policy", () => {
|
|
it("network errors retry", () => {
|
|
expect(isTransient("PDS_NETWORK_ERROR", undefined)).toBe(true);
|
|
});
|
|
it("HTTP 5xx retries", () => {
|
|
expect(isTransient("PDS_HTTP_ERROR", 500)).toBe(true);
|
|
expect(isTransient("PDS_HTTP_ERROR", 503)).toBe(true);
|
|
});
|
|
it("HTTP 4xx is permanent", () => {
|
|
expect(isTransient("PDS_HTTP_ERROR", 401)).toBe(false);
|
|
expect(isTransient("PDS_HTTP_ERROR", 400)).toBe(false);
|
|
});
|
|
it("missing status on PDS_HTTP_ERROR is treated as permanent", () => {
|
|
// Defensive: PDS_HTTP_ERROR is always raised with a status, but the
|
|
// policy must not blow up if a future code path drops it.
|
|
expect(isTransient("PDS_HTTP_ERROR", undefined)).toBe(false);
|
|
});
|
|
it("404, oversized response, and invalid proof are permanent", () => {
|
|
expect(isTransient("RECORD_NOT_FOUND", 404)).toBe(false);
|
|
expect(isTransient("RESPONSE_TOO_LARGE", undefined)).toBe(false);
|
|
expect(isTransient("INVALID_PROOF", undefined)).toBe(false);
|
|
});
|
|
});
|