Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:23:53 +08:00

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;
}