atproto user agency toolkit for individuals and groups
8
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add tests for IPFS/Helia integration and RASL endpoint

22 tests covering IpfsService unit tests (lifecycle, block storage
roundtrips, BlockMap, graceful no-ops, provideBlocks), RASL endpoint
integration tests (IPFS/SQLite/blob fallback chain, 404, headers),
and config flag defaults.

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