/** * Mock PDS and test repo builder for integration tests. * * Provides: * - createTestRepo(did, records?) — Build a minimal valid atproto repo as CAR bytes * - startMockPds(accounts) — HTTP server serving XRPC endpoints for test accounts * - createMockDidResolver(mapping) — DidResolver that resolves test DIDs to mock PDS URLs */ import { createServer, type Server } from "node:http"; import { Repo, MemoryBlockstore, blocksToCarFile, WriteOpAction, type RecordCreateOp, } from "@atproto/repo"; import { Secp256k1Keypair } from "@atproto/crypto"; import type { DidResolver, DidDocument } from "../did-resolver.js"; /** A fixed test signing key (32 bytes hex). */ const TEST_SIGNING_KEY = "0000000000000000000000000000000000000000000000000000000000000001"; export interface TestAccount { did: string; carBytes: Uint8Array; /** Blob CID → blob bytes, served by mock PDS */ blobs?: Map; } /** * Create a minimal valid atproto repo as CAR bytes. * Uses @atproto/repo's MemoryBlockstore + Repo.create() with a real MST. */ export async function createTestRepo( did: string, records?: Array<{ collection: string; rkey: string; record: Record }>, ): Promise { const storage = new MemoryBlockstore(); const keypair = await Secp256k1Keypair.import(TEST_SIGNING_KEY); const initialWrites: RecordCreateOp[] = (records ?? []).map((r) => ({ action: WriteOpAction.Create, collection: r.collection, rkey: r.rkey, record: r.record, })); const repo = await Repo.create(storage, did, keypair, initialWrites); const carBytes = await blocksToCarFile(repo.cid, storage.blocks); return carBytes; } /** * Create a second version of a repo (for incremental sync testing). * Creates initial repo, applies additional writes, returns both CARs. */ export async function createTestRepoWithUpdate( did: string, initialRecords: Array<{ collection: string; rkey: string; record: Record }>, additionalRecords: Array<{ collection: string; rkey: string; record: Record }>, ): Promise<{ initialCar: Uint8Array; updatedCar: Uint8Array; fullCar: Uint8Array }> { const storage = new MemoryBlockstore(); const keypair = await Secp256k1Keypair.import(TEST_SIGNING_KEY); const initialWrites: RecordCreateOp[] = initialRecords.map((r) => ({ action: WriteOpAction.Create, collection: r.collection, rkey: r.rkey, record: r.record, })); const repo = await Repo.create(storage, did, keypair, initialWrites); const initialCar = await blocksToCarFile(repo.cid, storage.blocks); const updateWrites: RecordCreateOp[] = additionalRecords.map((r) => ({ action: WriteOpAction.Create, collection: r.collection, rkey: r.rkey, record: r.record, })); const updatedRepo = await repo.applyWrites(updateWrites, keypair); const fullCar = await blocksToCarFile(updatedRepo.cid, storage.blocks); // For incremental: just the new blocks from the commit const commitData = await repo.formatCommit(updateWrites, keypair); const updatedCar = await blocksToCarFile(commitData.cid, commitData.newBlocks); return { initialCar, updatedCar, fullCar }; } export interface MockPds { url: string; port: number; close: () => Promise; /** Update the CAR bytes served for a DID (for incremental sync testing) */ updateAccount: (did: string, carBytes: Uint8Array) => void; } /** * Start an HTTP server that serves XRPC endpoints for test accounts. * Serves: com.atproto.sync.getRepo, com.atproto.sync.getBlob, com.atproto.repo.getRecord */ export async function startMockPds( accounts: TestAccount[], ): Promise { const accountMap = new Map(); for (const account of accounts) { accountMap.set(account.did, account); } const server = createServer((req, res) => { const url = new URL(req.url ?? "/", `http://localhost`); const pathname = url.pathname; if (pathname === "/xrpc/com.atproto.sync.getRepo") { const did = url.searchParams.get("did"); if (!did || !accountMap.has(did)) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "RepoNotFound" })); return; } const account = accountMap.get(did)!; res.writeHead(200, { "Content-Type": "application/vnd.ipld.car", "Content-Length": String(account.carBytes.length), }); res.end(Buffer.from(account.carBytes)); return; } if (pathname === "/xrpc/com.atproto.sync.getBlob") { const did = url.searchParams.get("did"); const cid = url.searchParams.get("cid"); if (!did || !cid || !accountMap.has(did)) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "BlobNotFound" })); return; } const account = accountMap.get(did)!; const blobBytes = account.blobs?.get(cid); if (!blobBytes) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "BlobNotFound" })); return; } res.writeHead(200, { "Content-Type": "application/octet-stream", "Content-Length": String(blobBytes.length), }); res.end(Buffer.from(blobBytes)); return; } if (pathname === "/xrpc/com.atproto.repo.listRecords") { // Return empty records list (sufficient for tests) res.writeHead(200, { "Content-Type": "application/json" }); res.end(JSON.stringify({ records: [] })); return; } res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "NotFound" })); }); return new Promise((resolve) => { server.listen(0, "127.0.0.1", () => { const addr = server.address() as { port: number }; const url = `http://127.0.0.1:${addr.port}`; resolve({ url, port: addr.port, close: () => new Promise((res) => server.close(() => res())), updateAccount: (did: string, carBytes: Uint8Array) => { const existing = accountMap.get(did); if (existing) { existing.carBytes = carBytes; } else { accountMap.set(did, { did, carBytes }); } }, }); }); }); } /** * Create a mock DidResolver that maps test DIDs to a mock PDS URL. * Returns DidDocument with #atproto_pds serviceEndpoint. */ export function createMockDidResolver( mapping: Record, ): DidResolver { return { resolve: async (did: string): Promise => { const pdsUrl = mapping[did]; if (!pdsUrl) return null; return { id: did, service: [ { id: "#atproto_pds", type: "AtprotoPersonalDataServer", serviceEndpoint: pdsUrl, }, ], } as unknown as DidDocument; }, } as DidResolver; }