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
378 lines
12 KiB
TypeScript
378 lines
12 KiB
TypeScript
/**
|
|
* Mock atproto PDS for tests.
|
|
*
|
|
* Implements just enough of `com.atproto.repo.{getRecord,putRecord,listRecords,applyWrites}`
|
|
* to drive `PublishingClient`-shaped tests without booting a real PDS or
|
|
* going through OAuth.
|
|
*
|
|
* State is held in-memory keyed by AT URI (`at://<did>/<collection>/<rkey>`).
|
|
* Test helpers expose the underlying record map so individual tests can seed
|
|
* existing records, assert what was written, or verify call counts.
|
|
*
|
|
* Returns realistic atproto error payloads (`RecordNotFound`,
|
|
* `InvalidRequest`, `RepoMismatch`) so the publish flow's error handling --
|
|
* which keys off `ClientResponseError.error === "RecordNotFound"` -- runs
|
|
* the same paths a real PDS would trigger.
|
|
*
|
|
* What this mock does NOT model:
|
|
*
|
|
* - Atproto MST signing or repo commit chain.
|
|
* - DPoP or OAuth token validation.
|
|
* - Lexicon-level record validation. (We don't run `mainSchema.safeParse`
|
|
* against `validate: true` requests; tests that need that should hit a
|
|
* real PDS.)
|
|
*
|
|
* What it DOES enforce:
|
|
*
|
|
* - The `repo` field on every write must match the mock's DID. A real PDS
|
|
* ties auth to repo; ours has no auth, so we cross-check that callers
|
|
* are writing where they think they are.
|
|
* - rkey must match atproto's record-key alphabet (`[a-zA-Z0-9_~.:-]+`).
|
|
* Used to verify that the registry CLI never sends rkeys that a real
|
|
* PDS would reject.
|
|
* - CIDs are derived from the record bytes so an overwrite with identical
|
|
* bytes returns an identical CID (matching real PDS behaviour). Tests
|
|
* that want to detect "did this actually rewrite the record?" should
|
|
* compare bytes, not CIDs.
|
|
*/
|
|
|
|
import { createHash } from "node:crypto";
|
|
|
|
import type { FetchHandlerObject } from "@atcute/client";
|
|
|
|
interface StoredRecord {
|
|
uri: string;
|
|
cid: string;
|
|
value: unknown;
|
|
}
|
|
|
|
interface MockPdsCall {
|
|
method: string;
|
|
pathname: string;
|
|
body?: unknown;
|
|
}
|
|
|
|
export interface MockPdsOptions {
|
|
/**
|
|
* The DID this mock pretends to host. Defaults to a fixed test DID; tests
|
|
* that need a different one can override.
|
|
*/
|
|
did?: `did:${string}:${string}`;
|
|
}
|
|
|
|
const RKEY_RE = /^[a-zA-Z0-9._~:-]+$/;
|
|
|
|
/**
|
|
* In-memory mock PDS implementing the `FetchHandlerObject` contract that
|
|
* `PublishingClient.fromHandler` accepts.
|
|
*/
|
|
export class MockPds implements FetchHandlerObject {
|
|
readonly did: `did:${string}:${string}`;
|
|
readonly records = new Map<string, StoredRecord>();
|
|
readonly calls: MockPdsCall[] = [];
|
|
|
|
constructor(options: MockPdsOptions = {}) {
|
|
this.did = options.did ?? "did:plc:test123";
|
|
}
|
|
|
|
async handle(pathname: string, init: RequestInit): Promise<Response> {
|
|
const url = new URL(pathname, "http://mock.test");
|
|
const method = init.method?.toLowerCase() ?? "get";
|
|
|
|
const body = await readJsonBody(init.body);
|
|
this.calls.push({ method, pathname, ...(body !== undefined ? { body } : {}) });
|
|
|
|
switch (url.pathname) {
|
|
case "/xrpc/com.atproto.repo.getRecord":
|
|
return this.#getRecord(url);
|
|
case "/xrpc/com.atproto.repo.putRecord":
|
|
return this.#putRecord(body);
|
|
case "/xrpc/com.atproto.repo.listRecords":
|
|
return this.#listRecords(url);
|
|
case "/xrpc/com.atproto.repo.applyWrites":
|
|
return this.#applyWrites(body);
|
|
default:
|
|
return jsonResponse(404, {
|
|
error: "MethodNotFound",
|
|
message: `mock-pds does not implement ${url.pathname}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Pre-seed a record under this DID. Helper for tests. */
|
|
seedRecord(collection: string, rkey: string, value: unknown): StoredRecord {
|
|
const uri = `at://${this.did}/${collection}/${rkey}`;
|
|
const stored: StoredRecord = {
|
|
uri,
|
|
cid: cidOf(value),
|
|
value,
|
|
};
|
|
this.records.set(uri, stored);
|
|
return stored;
|
|
}
|
|
|
|
/**
|
|
* Returns calls matching the given XRPC method name, in order. Useful for
|
|
* asserting that the publish flow made the expected XRPC sequence.
|
|
*/
|
|
callsTo(nsid: string): MockPdsCall[] {
|
|
return this.calls.filter((c) => c.pathname.startsWith(`/xrpc/${nsid}`));
|
|
}
|
|
|
|
#getRecord(url: URL): Response {
|
|
const repo = url.searchParams.get("repo") ?? this.did;
|
|
const collection = url.searchParams.get("collection");
|
|
const rkey = url.searchParams.get("rkey");
|
|
if (!collection || !rkey) {
|
|
return jsonResponse(400, {
|
|
error: "InvalidRequest",
|
|
message: "missing collection or rkey",
|
|
});
|
|
}
|
|
if (repo !== this.did) {
|
|
return jsonResponse(400, {
|
|
error: "RepoNotFound",
|
|
message: `mock-pds hosts ${this.did}, not ${repo}`,
|
|
});
|
|
}
|
|
const uri = `at://${repo}/${collection}/${rkey}`;
|
|
const record = this.records.get(uri);
|
|
if (!record) {
|
|
return jsonResponse(400, {
|
|
error: "RecordNotFound",
|
|
message: `Could not locate record: ${uri}`,
|
|
});
|
|
}
|
|
return jsonResponse(200, {
|
|
uri: record.uri,
|
|
cid: record.cid,
|
|
value: record.value,
|
|
});
|
|
}
|
|
|
|
#putRecord(body: unknown): Response {
|
|
if (!body || typeof body !== "object") {
|
|
return jsonResponse(400, { error: "InvalidRequest", message: "missing body" });
|
|
}
|
|
const input = body as {
|
|
repo: string;
|
|
collection: string;
|
|
rkey: string;
|
|
record: unknown;
|
|
swapRecord?: string;
|
|
};
|
|
const guard = this.#validateWriteTarget(input.repo, input.collection, input.rkey);
|
|
if (guard) return guard;
|
|
const uri = `at://${input.repo}/${input.collection}/${input.rkey}`;
|
|
// swapRecord is atproto's CID-based CAS precondition. Enforce it so
|
|
// tests can exercise the stale-write race a real PDS would catch.
|
|
if (input.swapRecord !== undefined) {
|
|
const current = this.records.get(uri);
|
|
const currentCid = current?.cid ?? "";
|
|
if (currentCid !== input.swapRecord) {
|
|
return jsonResponse(400, {
|
|
error: "InvalidSwap",
|
|
message: `swapRecord mismatch: expected ${input.swapRecord}, current ${currentCid}`,
|
|
});
|
|
}
|
|
}
|
|
const stored: StoredRecord = {
|
|
uri,
|
|
cid: cidOf(input.record),
|
|
value: input.record,
|
|
};
|
|
this.records.set(uri, stored);
|
|
return jsonResponse(200, { uri: stored.uri, cid: stored.cid });
|
|
}
|
|
|
|
#listRecords(url: URL): Response {
|
|
const repo = url.searchParams.get("repo") ?? this.did;
|
|
const collection = url.searchParams.get("collection");
|
|
if (!collection) {
|
|
return jsonResponse(400, {
|
|
error: "InvalidRequest",
|
|
message: "missing collection",
|
|
});
|
|
}
|
|
if (repo !== this.did) {
|
|
return jsonResponse(400, {
|
|
error: "RepoNotFound",
|
|
message: `mock-pds hosts ${this.did}, not ${repo}`,
|
|
});
|
|
}
|
|
const prefix = `at://${repo}/${collection}/`;
|
|
const records = [...this.records.values()].filter((r) => r.uri.startsWith(prefix));
|
|
return jsonResponse(200, { records });
|
|
}
|
|
|
|
#applyWrites(body: unknown): Response {
|
|
if (!body || typeof body !== "object") {
|
|
return jsonResponse(400, { error: "InvalidRequest", message: "missing body" });
|
|
}
|
|
const input = body as {
|
|
repo: string;
|
|
writes: Array<{
|
|
$type: string;
|
|
collection: string;
|
|
rkey?: string;
|
|
value?: unknown;
|
|
}>;
|
|
};
|
|
if (input.repo !== this.did) {
|
|
return jsonResponse(400, {
|
|
error: "RepoNotFound",
|
|
message: `mock-pds hosts ${this.did}, not ${input.repo}`,
|
|
});
|
|
}
|
|
|
|
// Validate every operation up front; either all-or-nothing, like a real
|
|
// applyWrites would be (atomic commit). Validation includes:
|
|
// - rkey present
|
|
// - rkey shape and repo match
|
|
// - create ops must not collide with an existing record
|
|
// - update ops must target an existing record
|
|
//
|
|
// CAVEAT: this mock evaluates create/update existence against the
|
|
// pre-batch state. Whether a real PDS allows within-batch
|
|
// dependencies (e.g. `create A; update A` in one applyWrites) is
|
|
// not crisply documented in the atproto spec, and behaviour may
|
|
// vary between PDS implementations. The publish flow doesn't
|
|
// depend on within-batch dependencies (profile and release have
|
|
// different rkeys), so the divergence (if any) doesn't affect
|
|
// coverage today. If you change the publish flow to issue a
|
|
// dependent batch, validate the assumption against an actual PDS
|
|
// before relying on it.
|
|
for (const op of input.writes ?? []) {
|
|
if (!op.rkey) {
|
|
return jsonResponse(400, {
|
|
error: "InvalidRequest",
|
|
message: `applyWrites op missing rkey: ${JSON.stringify(op)}`,
|
|
});
|
|
}
|
|
const guard = this.#validateWriteTarget(input.repo, op.collection, op.rkey);
|
|
if (guard) return guard;
|
|
const uri = `at://${input.repo}/${op.collection}/${op.rkey}`;
|
|
if (op.$type === "com.atproto.repo.applyWrites#create" && this.records.has(uri)) {
|
|
return jsonResponse(400, {
|
|
error: "RecordAlreadyExists",
|
|
message: `cannot create ${uri}: a record with that key already exists`,
|
|
});
|
|
}
|
|
if (op.$type === "com.atproto.repo.applyWrites#update" && !this.records.has(uri)) {
|
|
return jsonResponse(400, {
|
|
error: "RecordNotFound",
|
|
message: `cannot update ${uri}: no record with that key exists`,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Apply atomically: collect results, then commit.
|
|
const results: Array<Record<string, unknown>> = [];
|
|
for (const op of input.writes ?? []) {
|
|
const uri = `at://${input.repo}/${op.collection}/${op.rkey}`;
|
|
switch (op.$type) {
|
|
case "com.atproto.repo.applyWrites#create":
|
|
case "com.atproto.repo.applyWrites#update": {
|
|
const stored: StoredRecord = {
|
|
uri,
|
|
cid: cidOf(op.value),
|
|
value: op.value,
|
|
};
|
|
this.records.set(uri, stored);
|
|
const resultType =
|
|
op.$type === "com.atproto.repo.applyWrites#create"
|
|
? "com.atproto.repo.applyWrites#createResult"
|
|
: "com.atproto.repo.applyWrites#updateResult";
|
|
results.push({
|
|
$type: resultType,
|
|
uri: stored.uri,
|
|
cid: stored.cid,
|
|
});
|
|
break;
|
|
}
|
|
case "com.atproto.repo.applyWrites#delete":
|
|
this.records.delete(uri);
|
|
results.push({ $type: "com.atproto.repo.applyWrites#deleteResult" });
|
|
break;
|
|
default:
|
|
return jsonResponse(400, {
|
|
error: "InvalidRequest",
|
|
message: `unknown applyWrites op $type: ${op.$type}`,
|
|
});
|
|
}
|
|
}
|
|
return jsonResponse(200, { results });
|
|
}
|
|
|
|
/**
|
|
* Cross-check that every write targets this mock's repo and that the rkey
|
|
* matches atproto's record-key alphabet. Real PDSes enforce both; without
|
|
* these guards a test could write malformed records that a real PDS would
|
|
* have rejected.
|
|
*/
|
|
#validateWriteTarget(repo: string, collection: string, rkey: string): Response | null {
|
|
if (repo !== this.did) {
|
|
return jsonResponse(400, {
|
|
error: "RepoNotFound",
|
|
message: `mock-pds hosts ${this.did}, not ${repo}`,
|
|
});
|
|
}
|
|
if (!collection) {
|
|
return jsonResponse(400, {
|
|
error: "InvalidRequest",
|
|
message: "missing collection",
|
|
});
|
|
}
|
|
if (!RKEY_RE.test(rkey) || rkey.length === 0 || rkey.length > 512) {
|
|
return jsonResponse(400, {
|
|
error: "InvalidRequest",
|
|
message: `rkey ${JSON.stringify(rkey)} is not a valid record key`,
|
|
});
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function jsonResponse(status: number, body: unknown): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Mock CID derivation: sha256 of the JSON-stringified value, prefixed with `b`
|
|
* (base32 multibase) and shaped to look like a CIDv1. Crucially, identical
|
|
* record values produce identical CIDs -- matching real PDS behaviour where
|
|
* the CID is content-addressed.
|
|
*/
|
|
function cidOf(value: unknown): string {
|
|
const hash = createHash("sha256")
|
|
.update(JSON.stringify(value ?? null))
|
|
.digest("hex");
|
|
return `bafyreig${hash.slice(0, 52)}`;
|
|
}
|
|
|
|
async function readJsonBody(body: BodyInit | null | undefined): Promise<unknown> {
|
|
if (body === null || body === undefined) return undefined;
|
|
if (typeof body === "string") {
|
|
try {
|
|
return JSON.parse(body) as unknown;
|
|
} catch {
|
|
return body;
|
|
}
|
|
}
|
|
if (body instanceof Uint8Array || body instanceof ArrayBuffer) {
|
|
const text = new TextDecoder().decode(
|
|
body instanceof ArrayBuffer ? new Uint8Array(body) : body,
|
|
);
|
|
try {
|
|
return JSON.parse(text) as unknown;
|
|
} catch {
|
|
return text;
|
|
}
|
|
}
|
|
// Streams, FormData, Blob, etc. -- not used in our tests.
|
|
return undefined;
|
|
}
|