A minimal AT Protocol Personal Data Server written in JavaScript.
atproto pds
at main 317 lines 8.9 kB view raw
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});