atproto user agency toolkit for individuals and groups
1/**
2 * Mock PDS and test repo builder for integration tests.
3 *
4 * Provides:
5 * - createTestRepo(did, records?) — Build a minimal valid atproto repo as CAR bytes
6 * - startMockPds(accounts) — HTTP server serving XRPC endpoints for test accounts
7 * - createMockDidResolver(mapping) — DidResolver that resolves test DIDs to mock PDS URLs
8 */
9
10import { createServer, type Server } from "node:http";
11import {
12 Repo,
13 MemoryBlockstore,
14 blocksToCarFile,
15 WriteOpAction,
16 type RecordCreateOp,
17} from "@atproto/repo";
18import { Secp256k1Keypair } from "@atproto/crypto";
19import type { DidResolver, DidDocument } from "../did-resolver.js";
20
21/** A fixed test signing key (32 bytes hex). */
22const TEST_SIGNING_KEY =
23 "0000000000000000000000000000000000000000000000000000000000000001";
24
25export interface TestAccount {
26 did: string;
27 carBytes: Uint8Array;
28 /** Blob CID → blob bytes, served by mock PDS */
29 blobs?: Map<string, Uint8Array>;
30}
31
32/**
33 * Create a minimal valid atproto repo as CAR bytes.
34 * Uses @atproto/repo's MemoryBlockstore + Repo.create() with a real MST.
35 */
36export async function createTestRepo(
37 did: string,
38 records?: Array<{ collection: string; rkey: string; record: Record<string, unknown> }>,
39): Promise<Uint8Array> {
40 const storage = new MemoryBlockstore();
41 const keypair = await Secp256k1Keypair.import(TEST_SIGNING_KEY);
42
43 const initialWrites: RecordCreateOp[] = (records ?? []).map((r) => ({
44 action: WriteOpAction.Create,
45 collection: r.collection,
46 rkey: r.rkey,
47 record: r.record,
48 }));
49
50 const repo = await Repo.create(storage, did, keypair, initialWrites);
51 const carBytes = await blocksToCarFile(repo.cid, storage.blocks);
52 return carBytes;
53}
54
55/**
56 * Create a second version of a repo (for incremental sync testing).
57 * Creates initial repo, applies additional writes, returns both CARs.
58 */
59export async function createTestRepoWithUpdate(
60 did: string,
61 initialRecords: Array<{ collection: string; rkey: string; record: Record<string, unknown> }>,
62 additionalRecords: Array<{ collection: string; rkey: string; record: Record<string, unknown> }>,
63): Promise<{ initialCar: Uint8Array; updatedCar: Uint8Array; fullCar: Uint8Array }> {
64 const storage = new MemoryBlockstore();
65 const keypair = await Secp256k1Keypair.import(TEST_SIGNING_KEY);
66
67 const initialWrites: RecordCreateOp[] = initialRecords.map((r) => ({
68 action: WriteOpAction.Create,
69 collection: r.collection,
70 rkey: r.rkey,
71 record: r.record,
72 }));
73
74 const repo = await Repo.create(storage, did, keypair, initialWrites);
75 const initialCar = await blocksToCarFile(repo.cid, storage.blocks);
76
77 const updateWrites: RecordCreateOp[] = additionalRecords.map((r) => ({
78 action: WriteOpAction.Create,
79 collection: r.collection,
80 rkey: r.rkey,
81 record: r.record,
82 }));
83
84 const updatedRepo = await repo.applyWrites(updateWrites, keypair);
85 const fullCar = await blocksToCarFile(updatedRepo.cid, storage.blocks);
86
87 // For incremental: just the new blocks from the commit
88 const commitData = await repo.formatCommit(updateWrites, keypair);
89 const updatedCar = await blocksToCarFile(commitData.cid, commitData.newBlocks);
90
91 return { initialCar, updatedCar, fullCar };
92}
93
94export interface MockPds {
95 url: string;
96 port: number;
97 close: () => Promise<void>;
98 /** Update the CAR bytes served for a DID (for incremental sync testing) */
99 updateAccount: (did: string, carBytes: Uint8Array) => void;
100}
101
102/**
103 * Start an HTTP server that serves XRPC endpoints for test accounts.
104 * Serves: com.atproto.sync.getRepo, com.atproto.sync.getBlob, com.atproto.repo.getRecord
105 */
106export async function startMockPds(
107 accounts: TestAccount[],
108): Promise<MockPds> {
109 const accountMap = new Map<string, TestAccount>();
110 for (const account of accounts) {
111 accountMap.set(account.did, account);
112 }
113
114 const server = createServer((req, res) => {
115 const url = new URL(req.url ?? "/", `http://localhost`);
116 const pathname = url.pathname;
117
118 if (pathname === "/xrpc/com.atproto.sync.getRepo") {
119 const did = url.searchParams.get("did");
120 if (!did || !accountMap.has(did)) {
121 res.writeHead(404, { "Content-Type": "application/json" });
122 res.end(JSON.stringify({ error: "RepoNotFound" }));
123 return;
124 }
125 const account = accountMap.get(did)!;
126 res.writeHead(200, {
127 "Content-Type": "application/vnd.ipld.car",
128 "Content-Length": String(account.carBytes.length),
129 });
130 res.end(Buffer.from(account.carBytes));
131 return;
132 }
133
134 if (pathname === "/xrpc/com.atproto.sync.getBlob") {
135 const did = url.searchParams.get("did");
136 const cid = url.searchParams.get("cid");
137 if (!did || !cid || !accountMap.has(did)) {
138 res.writeHead(404, { "Content-Type": "application/json" });
139 res.end(JSON.stringify({ error: "BlobNotFound" }));
140 return;
141 }
142 const account = accountMap.get(did)!;
143 const blobBytes = account.blobs?.get(cid);
144 if (!blobBytes) {
145 res.writeHead(404, { "Content-Type": "application/json" });
146 res.end(JSON.stringify({ error: "BlobNotFound" }));
147 return;
148 }
149 res.writeHead(200, {
150 "Content-Type": "application/octet-stream",
151 "Content-Length": String(blobBytes.length),
152 });
153 res.end(Buffer.from(blobBytes));
154 return;
155 }
156
157 if (pathname === "/xrpc/com.atproto.repo.listRecords") {
158 // Return empty records list (sufficient for tests)
159 res.writeHead(200, { "Content-Type": "application/json" });
160 res.end(JSON.stringify({ records: [] }));
161 return;
162 }
163
164 res.writeHead(404, { "Content-Type": "application/json" });
165 res.end(JSON.stringify({ error: "NotFound" }));
166 });
167
168 return new Promise((resolve) => {
169 server.listen(0, "127.0.0.1", () => {
170 const addr = server.address() as { port: number };
171 const url = `http://127.0.0.1:${addr.port}`;
172 resolve({
173 url,
174 port: addr.port,
175 close: () =>
176 new Promise<void>((res) => server.close(() => res())),
177 updateAccount: (did: string, carBytes: Uint8Array) => {
178 const existing = accountMap.get(did);
179 if (existing) {
180 existing.carBytes = carBytes;
181 } else {
182 accountMap.set(did, { did, carBytes });
183 }
184 },
185 });
186 });
187 });
188}
189
190/**
191 * Create a mock DidResolver that maps test DIDs to a mock PDS URL.
192 * Returns DidDocument with #atproto_pds serviceEndpoint.
193 */
194export function createMockDidResolver(
195 mapping: Record<string, string>,
196): DidResolver {
197 return {
198 resolve: async (did: string): Promise<DidDocument | null> => {
199 const pdsUrl = mapping[did];
200 if (!pdsUrl) return null;
201 return {
202 id: did,
203 service: [
204 {
205 id: "#atproto_pds",
206 type: "AtprotoPersonalDataServer",
207 serviceEndpoint: pdsUrl,
208 },
209 ],
210 } as unknown as DidDocument;
211 },
212 } as DidResolver;
213}