atproto user agency toolkit for individuals and groups
at main 432 lines 12 kB view raw
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});