A minimal AT Protocol Personal Data Server written in JavaScript.
atproto
pds
1// @pds/core/loader tests - Repository loader from CAR files
2import { describe, it, expect, beforeEach } from 'vitest';
3import {
4 loadRepositoryFromCar,
5 getCarRepoInfo,
6 validateCarFile,
7} from '../packages/core/src/loader.js';
8import {
9 buildCarFile,
10 CID,
11 cborEncodeDagCbor,
12 createCid,
13 cidToString,
14 cidToBytes,
15 createTid,
16} from '../packages/core/src/repo.js';
17import { buildMst } from '../packages/core/src/mst.js';
18
19// In-memory storage for tests
20function createMockStorage() {
21 /** @type {Map<string, Uint8Array>} */
22 const blocks = new Map();
23 /** @type {Map<string, {cid: string, value: Uint8Array}>} */
24 const records = new Map();
25 /** @type {Array<{seq: number, cid: string, rev: string, prev: string|null}>} */
26 const commits = [];
27 /** @type {{did: string|null, handle: string|null, privateKey: Uint8Array|null}} */
28 const metadata = { did: null, handle: null, privateKey: null };
29
30 return {
31 // Block operations
32 async getBlock(cid) {
33 return blocks.get(cid) || null;
34 },
35 async putBlock(cid, data) {
36 blocks.set(cid, data);
37 },
38
39 // Record operations
40 async getRecord(uri) {
41 const rec = records.get(uri);
42 return rec || null;
43 },
44 async putRecord(uri, cid, collection, rkey, value) {
45 records.set(uri, { cid, value });
46 },
47 async listRecords(collection, cursor, limit) {
48 const all = [];
49 for (const [uri, rec] of records) {
50 if (uri.includes(`/${collection}/`)) {
51 const parts = uri.split('/');
52 all.push({ uri, cid: rec.cid, value: rec.value, rkey: parts[parts.length - 1] });
53 }
54 }
55 return { records: all.slice(0, limit), cursor: null };
56 },
57 async listAllRecords() {
58 const all = [];
59 for (const [uri, rec] of records) {
60 const parts = uri.replace('at://', '').split('/');
61 const key = `${parts[1]}/${parts[2]}`;
62 all.push({ key, cid: rec.cid });
63 }
64 return all;
65 },
66 async deleteRecord(uri) {
67 records.delete(uri);
68 },
69
70 // Commit operations
71 async getLatestCommit() {
72 return commits.length > 0 ? commits[commits.length - 1] : null;
73 },
74 async putCommit(seq, cid, rev, prev) {
75 commits.push({ seq, cid, rev, prev });
76 },
77
78 // Metadata operations
79 async getDid() {
80 return metadata.did;
81 },
82 async setDid(did) {
83 metadata.did = did;
84 },
85 async getHandle() {
86 return metadata.handle;
87 },
88 async setHandle(handle) {
89 metadata.handle = handle;
90 },
91 async getPrivateKey() {
92 return metadata.privateKey;
93 },
94 async setPrivateKey(key) {
95 metadata.privateKey = key;
96 },
97 async getPreferences() {
98 return [];
99 },
100 async setPreferences() {},
101
102 // Events
103 async getEvents() {
104 return { events: [], cursor: 0 };
105 },
106 async putEvent() {},
107
108 // Blobs
109 async getBlob() {
110 return null;
111 },
112 async putBlob() {},
113 async deleteBlob() {},
114 async listBlobs() {
115 return { cids: [], cursor: null };
116 },
117 async getOrphanedBlobs() {
118 return [];
119 },
120 async linkBlobToRecord() {},
121 async unlinkBlobsFromRecord() {},
122
123 // Test helpers
124 _getBlockCount() {
125 return blocks.size;
126 },
127 _getRecordCount() {
128 return records.size;
129 },
130 _getCommits() {
131 return commits;
132 },
133 };
134}
135
136/**
137 * Build a test CAR file with records
138 * @param {string} did
139 * @param {Array<{collection: string, rkey: string, record: object}>} recordsToInclude
140 * @returns {Promise<Uint8Array>}
141 */
142async function buildTestCar(did, recordsToInclude) {
143 /** @type {Array<{cid: string, data: Uint8Array}>} */
144 const blocks = [];
145 /** @type {Map<string, Uint8Array>} */
146 const blockMap = new Map();
147
148 // Encode records and build MST entries
149 /** @type {Array<{key: string, cid: string}>} */
150 const mstEntries = [];
151
152 for (const { collection, rkey, record } of recordsToInclude) {
153 const recordBytes = cborEncodeDagCbor(record);
154 const recordCid = cidToString(await createCid(recordBytes));
155 blocks.push({ cid: recordCid, data: recordBytes });
156 blockMap.set(recordCid, recordBytes);
157 mstEntries.push({ key: `${collection}/${rkey}`, cid: recordCid });
158 }
159
160 // Sort entries for MST
161 mstEntries.sort((a, b) => a.key.localeCompare(b.key));
162
163 // Build MST
164 const mstRoot = await buildMst(mstEntries, async (cid, data) => {
165 blocks.push({ cid, data });
166 blockMap.set(cid, data);
167 });
168
169 // Build commit
170 const rev = createTid();
171 const commit = {
172 did,
173 version: 3,
174 rev,
175 prev: null,
176 data: mstRoot ? new CID(cidToBytes(mstRoot)) : null,
177 };
178
179 // Add signature field (empty for test)
180 const signedCommit = { ...commit, sig: new Uint8Array(64) };
181 const commitBytes = cborEncodeDagCbor(signedCommit);
182 const commitCid = cidToString(await createCid(commitBytes));
183 blocks.push({ cid: commitCid, data: commitBytes });
184
185 return buildCarFile(commitCid, blocks);
186}
187
188describe('loadRepositoryFromCar', () => {
189 let storage;
190
191 beforeEach(() => {
192 storage = createMockStorage();
193 });
194
195 it('should load empty repository', async () => {
196 const did = 'did:plc:test123';
197 const car = await buildTestCar(did, []);
198
199 const result = await loadRepositoryFromCar(car, storage);
200
201 expect(result.did).toBe(did);
202 expect(result.recordCount).toBe(0);
203 expect(result.blockCount).toBeGreaterThan(0);
204 expect(await storage.getDid()).toBe(did);
205 });
206
207 it('should load repository with single record', async () => {
208 const did = 'did:plc:test123';
209 const car = await buildTestCar(did, [
210 {
211 collection: 'app.bsky.feed.post',
212 rkey: '3abc123',
213 record: { $type: 'app.bsky.feed.post', text: 'Hello World', createdAt: '2024-01-01T00:00:00Z' },
214 },
215 ]);
216
217 const result = await loadRepositoryFromCar(car, storage);
218
219 expect(result.did).toBe(did);
220 expect(result.recordCount).toBe(1);
221
222 const record = await storage.getRecord(`at://${did}/app.bsky.feed.post/3abc123`);
223 expect(record).not.toBeNull();
224 expect(record?.cid).toBeDefined();
225 });
226
227 it('should load repository with multiple records', async () => {
228 const did = 'did:plc:test456';
229 const car = await buildTestCar(did, [
230 {
231 collection: 'app.bsky.feed.post',
232 rkey: '3post1',
233 record: { $type: 'app.bsky.feed.post', text: 'Post 1', createdAt: '2024-01-01T00:00:00Z' },
234 },
235 {
236 collection: 'app.bsky.feed.post',
237 rkey: '3post2',
238 record: { $type: 'app.bsky.feed.post', text: 'Post 2', createdAt: '2024-01-02T00:00:00Z' },
239 },
240 {
241 collection: 'app.bsky.actor.profile',
242 rkey: 'self',
243 record: { $type: 'app.bsky.actor.profile', displayName: 'Test User' },
244 },
245 ]);
246
247 const result = await loadRepositoryFromCar(car, storage);
248
249 expect(result.did).toBe(did);
250 expect(result.recordCount).toBe(3);
251
252 // Verify all records exist
253 expect(await storage.getRecord(`at://${did}/app.bsky.feed.post/3post1`)).not.toBeNull();
254 expect(await storage.getRecord(`at://${did}/app.bsky.feed.post/3post2`)).not.toBeNull();
255 expect(await storage.getRecord(`at://${did}/app.bsky.actor.profile/self`)).not.toBeNull();
256 });
257
258 it('should create commit in storage', async () => {
259 const did = 'did:plc:test789';
260 const car = await buildTestCar(did, [
261 {
262 collection: 'app.bsky.feed.post',
263 rkey: '3test',
264 record: { $type: 'app.bsky.feed.post', text: 'Test', createdAt: '2024-01-01T00:00:00Z' },
265 },
266 ]);
267
268 const result = await loadRepositoryFromCar(car, storage);
269 const commit = await storage.getLatestCommit();
270
271 expect(commit).not.toBeNull();
272 expect(commit?.cid).toBe(result.commitCid);
273 expect(commit?.rev).toBe(result.rev);
274 expect(commit?.seq).toBe(1);
275 });
276});
277
278describe('getCarRepoInfo', () => {
279 it('should extract DID and commit info', async () => {
280 const did = 'did:plc:infotest';
281 const car = await buildTestCar(did, [
282 {
283 collection: 'app.bsky.feed.post',
284 rkey: '3test',
285 record: { $type: 'app.bsky.feed.post', text: 'Test', createdAt: '2024-01-01T00:00:00Z' },
286 },
287 ]);
288
289 const info = getCarRepoInfo(car);
290
291 expect(info.did).toBe(did);
292 expect(info.commitCid).toBeDefined();
293 expect(info.rev).toBeDefined();
294 });
295});
296
297describe('validateCarFile', () => {
298 it('should validate correct CAR file', async () => {
299 const did = 'did:plc:validtest';
300 const car = await buildTestCar(did, []);
301
302 const result = validateCarFile(car);
303
304 expect(result.valid).toBe(true);
305 expect(result.did).toBe(did);
306 expect(result.error).toBeUndefined();
307 });
308
309 it('should reject malformed CAR', () => {
310 const garbage = new Uint8Array([0xff, 0xff, 0xff]);
311
312 const result = validateCarFile(garbage);
313
314 expect(result.valid).toBe(false);
315 expect(result.error).toBeDefined();
316 });
317});