atproto user agency toolkit for individuals and groups
1import { describe, it, expect, beforeEach, afterEach } from "vitest";
2import { mkdtempSync, rmSync } from "node:fs";
3import { tmpdir } from "node:os";
4import { join } from "node:path";
5import { IpfsService } from "./ipfs.js";
6import {
7 create as createCid,
8 CODEC_RAW,
9 toString as cidToString,
10} from "@atcute/cid";
11import Database from "better-sqlite3";
12import { createApp } from "./index.js";
13import { RepoManager } from "./repo-manager.js";
14import { BlobStore } from "./blobs.js";
15import { Firehose } from "./firehose.js";
16import { loadConfig } from "./config.js";
17import type { Config } from "./config.js";
18import { BlockMap } from "@atproto/repo";
19import { CID } from "@atproto/lex-data";
20
21/** Create a CID string from raw bytes using SHA-256. */
22async function makeCidStr(bytes: Uint8Array): Promise<string> {
23 const cid = await createCid(CODEC_RAW, bytes);
24 return cidToString(cid);
25}
26
27/** Create a minimal test Config. */
28function testConfig(dataDir: string): Config {
29 return {
30 DID: "did:plc:test123",
31 HANDLE: "test.example.com",
32 PDS_HOSTNAME: "test.example.com",
33 AUTH_TOKEN: "test-auth-token",
34 SIGNING_KEY:
35 "0000000000000000000000000000000000000000000000000000000000000001",
36 SIGNING_KEY_PUBLIC: "zQ3shP2mWsZYWgvZM9GJ3EvMfRXQJwuTh6BdXLvJB9gFhT3Lr",
37 JWT_SECRET: "test-jwt-secret",
38 PASSWORD_HASH: "$2a$10$test",
39 DATA_DIR: dataDir,
40 PORT: 3000,
41 IPFS_ENABLED: true,
42 IPFS_NETWORKING: false,
43 REPLICATE_DIDS: [],
44 FIREHOSE_URL: "wss://localhost/xrpc/com.atproto.sync.subscribeRepos",
45 FIREHOSE_ENABLED: false,
46 RATE_LIMIT_ENABLED: false,
47 RATE_LIMIT_READ_PER_MIN: 300,
48 RATE_LIMIT_SYNC_PER_MIN: 30,
49 RATE_LIMIT_SESSION_PER_MIN: 10,
50 RATE_LIMIT_WRITE_PER_MIN: 200,
51 RATE_LIMIT_CHALLENGE_PER_MIN: 20,
52 RATE_LIMIT_MAX_CONNECTIONS: 100,
53 RATE_LIMIT_FIREHOSE_PER_IP: 3,
54 OAUTH_ENABLED: false, PUBLIC_URL: "http://localhost:3000",
55 };
56}
57
58// ============================================
59// IpfsService Unit Tests
60// ============================================
61
62describe("IpfsService", () => {
63 let tmpDir: string;
64 let db: InstanceType<typeof Database>;
65 let service: IpfsService;
66
67 beforeEach(() => {
68 tmpDir = mkdtempSync(join(tmpdir(), "ipfs-test-"));
69 db = new Database(join(tmpDir, "test.db"));
70 service = new IpfsService({
71 db,
72 networking: false,
73 });
74 });
75
76 afterEach(async () => {
77 if (service.isRunning()) {
78 await service.stop();
79 }
80 db.close();
81 rmSync(tmpDir, { recursive: true, force: true });
82 });
83
84 describe("lifecycle", () => {
85 it("start sets isRunning to true", async () => {
86 expect(service.isRunning()).toBe(false);
87 await service.start();
88 expect(service.isRunning()).toBe(true);
89 });
90
91 it("stop sets isRunning to false", async () => {
92 await service.start();
93 await service.stop();
94 expect(service.isRunning()).toBe(false);
95 });
96
97 it("getPeerId returns null without networking", async () => {
98 await service.start();
99 expect(service.getPeerId()).toBeNull();
100 });
101
102 it("getConnectionCount returns 0 without networking", async () => {
103 await service.start();
104 expect(service.getConnectionCount()).toBe(0);
105 });
106 });
107
108 describe("putBlock + getBlock roundtrip", () => {
109 it("stores and retrieves bytes by CID", async () => {
110 await service.start();
111 const bytes = new TextEncoder().encode("hello ipfs");
112 const cidStr = await makeCidStr(bytes);
113
114 await service.putBlock(cidStr, bytes);
115 const retrieved = await service.getBlock(cidStr);
116
117 expect(retrieved).not.toBeNull();
118 expect(Buffer.from(retrieved!)).toEqual(Buffer.from(bytes));
119 });
120 });
121
122 describe("putBlock + hasBlock", () => {
123 it("returns true for stored block", async () => {
124 await service.start();
125 const bytes = new TextEncoder().encode("test block");
126 const cidStr = await makeCidStr(bytes);
127
128 await service.putBlock(cidStr, bytes);
129 expect(await service.hasBlock(cidStr)).toBe(true);
130 });
131
132 it("returns false for unknown CID", async () => {
133 await service.start();
134 const bytes = new TextEncoder().encode("unknown block");
135 const cidStr = await makeCidStr(bytes);
136 expect(await service.hasBlock(cidStr)).toBe(false);
137 });
138 });
139
140 describe("getBlock missing", () => {
141 it("returns null for CID not in store", async () => {
142 await service.start();
143 const bytes = new TextEncoder().encode("missing block");
144 const cidStr = await makeCidStr(bytes);
145 expect(await service.getBlock(cidStr)).toBeNull();
146 });
147 });
148
149 describe("putBlocks (BlockMap)", () => {
150 it("stores multiple blocks from a BlockMap", async () => {
151 await service.start();
152
153 const entries: Array<{ bytes: Uint8Array; cidStr: string }> = [];
154 for (let i = 0; i < 3; i++) {
155 const bytes = new TextEncoder().encode(`block-${i}`);
156 const cidStr = await makeCidStr(bytes);
157 entries.push({ bytes, cidStr });
158 }
159
160 const blockMap = new BlockMap();
161 for (const entry of entries) {
162 blockMap.set(CID.parse(entry.cidStr), entry.bytes);
163 }
164
165 await service.putBlocks(blockMap);
166
167 for (const entry of entries) {
168 const retrieved = await service.getBlock(entry.cidStr);
169 expect(retrieved).not.toBeNull();
170 expect(Buffer.from(retrieved!)).toEqual(Buffer.from(entry.bytes));
171 }
172 });
173 });
174
175 describe("graceful no-ops before start", () => {
176 it("putBlock does not throw", async () => {
177 const bytes = new TextEncoder().encode("test");
178 const cidStr = await makeCidStr(bytes);
179 await expect(
180 service.putBlock(cidStr, bytes),
181 ).resolves.toBeUndefined();
182 });
183
184 it("getBlock returns null", async () => {
185 const bytes = new TextEncoder().encode("test");
186 const cidStr = await makeCidStr(bytes);
187 expect(await service.getBlock(cidStr)).toBeNull();
188 });
189
190 it("hasBlock returns false", async () => {
191 const bytes = new TextEncoder().encode("test");
192 const cidStr = await makeCidStr(bytes);
193 expect(await service.hasBlock(cidStr)).toBe(false);
194 });
195 });
196
197 describe("provideBlocks", () => {
198 it("resolves without error when no networking", async () => {
199 await service.start();
200 const bytes = new TextEncoder().encode("provide-test");
201 const cidStr = await makeCidStr(bytes);
202 await expect(
203 service.provideBlocks([cidStr]),
204 ).resolves.toBeUndefined();
205 });
206 });
207
208 describe("gossipsub no-ops without networking", () => {
209 it("onCommitNotification does not throw", async () => {
210 await service.start();
211 expect(() => service.onCommitNotification(() => {})).not.toThrow();
212 });
213
214 it("subscribeCommitTopics does not throw", async () => {
215 await service.start();
216 expect(() => service.subscribeCommitTopics(["did:plc:test"])).not.toThrow();
217 });
218
219 it("unsubscribeCommitTopics does not throw", async () => {
220 await service.start();
221 expect(() => service.unsubscribeCommitTopics(["did:plc:test"])).not.toThrow();
222 });
223
224 it("publishCommitNotification resolves without error", async () => {
225 await service.start();
226 await expect(
227 service.publishCommitNotification("did:plc:test", "bafytest", "rev1"),
228 ).resolves.toBeUndefined();
229 });
230 });
231});
232
233// ============================================
234// RASL Endpoint Integration Tests
235// ============================================
236
237describe("RASL endpoint", () => {
238 let tmpDir: string;
239 let db: InstanceType<typeof Database>;
240 let ipfsService: IpfsService;
241 let blobStore: BlobStore;
242 let app: ReturnType<typeof createApp>;
243
244 beforeEach(async () => {
245 tmpDir = mkdtempSync(join(tmpdir(), "rasl-test-"));
246 const config = testConfig(tmpDir);
247
248 db = new Database(join(tmpDir, "test.db"));
249 const repoManager = new RepoManager(db, config);
250 repoManager.init();
251
252 const firehose = new Firehose(repoManager);
253
254 ipfsService = new IpfsService({
255 db,
256 networking: false,
257 });
258 await ipfsService.start();
259
260 blobStore = new BlobStore(tmpDir, config.DID!);
261
262 app = createApp(config, firehose, ipfsService, ipfsService, blobStore, undefined, undefined, repoManager);
263 });
264
265 afterEach(async () => {
266 if (ipfsService.isRunning()) {
267 await ipfsService.stop();
268 }
269 db.close();
270 rmSync(tmpDir, { recursive: true, force: true });
271 });
272
273 it("fetches block from IPFS", async () => {
274 const bytes = new TextEncoder().encode("ipfs block data");
275 const cidStr = await makeCidStr(bytes);
276
277 await ipfsService.putBlock(cidStr, bytes);
278
279 const res = await app.request(
280 `/.well-known/rasl/${cidStr}`,
281 undefined,
282 {},
283 );
284 expect(res.status).toBe(200);
285
286 const body = new Uint8Array(await res.arrayBuffer());
287 expect(Buffer.from(body)).toEqual(Buffer.from(bytes));
288 });
289
290 it("falls back to SQLite", async () => {
291 const bytes = new TextEncoder().encode("sqlite block data");
292 const cidStr = await makeCidStr(bytes);
293
294 // Insert directly into blocks table (not in IPFS)
295 db.prepare("INSERT INTO blocks (cid, bytes, rev) VALUES (?, ?, ?)").run(
296 cidStr,
297 Buffer.from(bytes),
298 "test-rev",
299 );
300
301 const res = await app.request(
302 `/.well-known/rasl/${cidStr}`,
303 undefined,
304 {},
305 );
306 expect(res.status).toBe(200);
307
308 const body = new Uint8Array(await res.arrayBuffer());
309 expect(Buffer.from(body)).toEqual(Buffer.from(bytes));
310 });
311
312 it("falls back to blob store", async () => {
313 const bytes = new TextEncoder().encode("blob data");
314 const blobRef = await blobStore.putBlob(bytes, "application/octet-stream");
315 const cidStr = blobRef.ref.$link;
316
317 const res = await app.request(
318 `/.well-known/rasl/${cidStr}`,
319 undefined,
320 {},
321 );
322 expect(res.status).toBe(200);
323
324 const body = new Uint8Array(await res.arrayBuffer());
325 expect(Buffer.from(body)).toEqual(Buffer.from(bytes));
326 });
327
328 it("returns 404 for missing block", async () => {
329 const bytes = new TextEncoder().encode("nonexistent");
330 const cidStr = await makeCidStr(bytes);
331
332 const res = await app.request(
333 `/.well-known/rasl/${cidStr}`,
334 undefined,
335 {},
336 );
337 expect(res.status).toBe(404);
338
339 const json = (await res.json()) as { error: string };
340 expect(json.error).toBe("BlockNotFound");
341 });
342
343 it("returns correct response headers", async () => {
344 const bytes = new TextEncoder().encode("header test");
345 const cidStr = await makeCidStr(bytes);
346
347 await ipfsService.putBlock(cidStr, bytes);
348
349 const res = await app.request(
350 `/.well-known/rasl/${cidStr}`,
351 undefined,
352 {},
353 );
354 expect(res.status).toBe(200);
355 expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
356 expect(res.headers.get("Cache-Control")).toBe(
357 "public, max-age=31536000, immutable",
358 );
359 expect(res.headers.get("ETag")).toBe(`"${cidStr}"`);
360 });
361});
362
363// ============================================
364// Config Tests
365// ============================================
366
367describe("config", () => {
368 const envKeys = [
369 "DID",
370 "HANDLE",
371 "PDS_HOSTNAME",
372 "AUTH_TOKEN",
373 "SIGNING_KEY",
374 "SIGNING_KEY_PUBLIC",
375 "JWT_SECRET",
376 "PASSWORD_HASH",
377 "IPFS_ENABLED",
378 "IPFS_NETWORKING",
379 "DATA_DIR",
380 "PORT",
381 "EMAIL",
382 ];
383 const savedEnv: Record<string, string | undefined> = {};
384
385 beforeEach(() => {
386 for (const key of envKeys) {
387 savedEnv[key] = process.env[key];
388 }
389 process.env.DID = "did:plc:test";
390 process.env.HANDLE = "test.example.com";
391 process.env.PDS_HOSTNAME = "test.example.com";
392 process.env.AUTH_TOKEN = "token";
393 process.env.SIGNING_KEY = "key";
394 process.env.SIGNING_KEY_PUBLIC = "pubkey";
395 process.env.JWT_SECRET = "secret";
396 process.env.PASSWORD_HASH = "hash";
397 delete process.env.IPFS_ENABLED;
398 delete process.env.IPFS_NETWORKING;
399 });
400
401 afterEach(() => {
402 for (const key of envKeys) {
403 if (savedEnv[key] === undefined) {
404 delete process.env[key];
405 } else {
406 process.env[key] = savedEnv[key];
407 }
408 }
409 });
410
411 it("IPFS_ENABLED defaults to true", () => {
412 const config = loadConfig("/nonexistent/.env");
413 expect(config.IPFS_ENABLED).toBe(true);
414 });
415
416 it('IPFS_ENABLED set to "false" returns false', () => {
417 process.env.IPFS_ENABLED = "false";
418 const config = loadConfig("/nonexistent/.env");
419 expect(config.IPFS_ENABLED).toBe(false);
420 });
421
422 it("IPFS_NETWORKING defaults to true", () => {
423 const config = loadConfig("/nonexistent/.env");
424 expect(config.IPFS_NETWORKING).toBe(true);
425 });
426
427 it('IPFS_NETWORKING set to "false" returns false', () => {
428 process.env.IPFS_NETWORKING = "false";
429 const config = loadConfig("/nonexistent/.env");
430 expect(config.IPFS_NETWORKING).toBe(false);
431 });
432});