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 Database from "better-sqlite3";
6import { PolicyEngine } from "../../policy/engine.js";
7import { mutualAid } from "../../policy/presets.js";
8import { SyncStorage } from "../sync-storage.js";
9import { ChallengeStorage } from "./challenge-storage.js";
10import { ChallengeScheduler } from "./challenge-scheduler.js";
11import type { ChallengeTransport } from "./transport.js";
12import type {
13 StorageChallenge,
14 StorageChallengeResponse,
15} from "./types.js";
16import type { BlockStore } from "../../ipfs.js";
17import type { BlockMap } from "@atproto/repo";
18
19// ============================================
20// Mock transport
21// ============================================
22
23class MockTransport implements ChallengeTransport {
24 challenges: StorageChallenge[] = [];
25
26 async sendChallenge(
27 _target: string,
28 challenge: StorageChallenge,
29 ): Promise<StorageChallengeResponse> {
30 this.challenges.push(challenge);
31 return {
32 challengeId: challenge.id,
33 responderDid: challenge.targetDid,
34 respondedAt: new Date().toISOString(),
35 mstProofs:
36 challenge.challengeType !== "block-sample" ? [] : undefined,
37 blockResults:
38 challenge.challengeType !== "mst-proof"
39 ? challenge.blockCids?.map((cid) => ({
40 cid,
41 available: false,
42 }))
43 : undefined,
44 };
45 }
46
47 onChallenge(
48 _handler: (
49 challenge: StorageChallenge,
50 ) => Promise<StorageChallengeResponse>,
51 ): void {}
52}
53
54// ============================================
55// Mock blockstore
56// ============================================
57
58class MockBlockStore implements BlockStore {
59 private blocks = new Map<string, Uint8Array>();
60
61 async putBlock(cidStr: string, bytes: Uint8Array): Promise<void> {
62 this.blocks.set(cidStr, bytes);
63 }
64
65 async getBlock(cidStr: string): Promise<Uint8Array | null> {
66 return this.blocks.get(cidStr) ?? null;
67 }
68
69 async hasBlock(cidStr: string): Promise<boolean> {
70 return this.blocks.has(cidStr);
71 }
72
73 async putBlocks(_blocks: BlockMap): Promise<void> {}
74
75 async deleteBlock(_cidStr: string): Promise<void> {}
76}
77
78// ============================================
79// Tests
80// ============================================
81
82describe("ChallengeScheduler", () => {
83 let tmpDir: string;
84 let db: InstanceType<typeof Database>;
85 let syncStorage: SyncStorage;
86 let challengeStorage: ChallengeStorage;
87 let transport: MockTransport;
88 let blockStore: MockBlockStore;
89
90 beforeEach(() => {
91 tmpDir = mkdtempSync(join(tmpdir(), "scheduler-test-"));
92 db = new Database(join(tmpDir, "test.db"));
93 syncStorage = new SyncStorage(db);
94 syncStorage.initSchema();
95 challengeStorage = new ChallengeStorage(db);
96 challengeStorage.initSchema();
97 transport = new MockTransport();
98 blockStore = new MockBlockStore();
99 });
100
101 afterEach(() => {
102 db.close();
103 rmSync(tmpDir, { recursive: true, force: true });
104 });
105
106 describe("deriveSchedules", () => {
107 it("returns empty when no policy engine", () => {
108 const scheduler = new ChallengeScheduler(
109 "did:plc:self",
110 null,
111 syncStorage,
112 challengeStorage,
113 blockStore,
114 transport,
115 );
116
117 expect(scheduler.deriveSchedules()).toEqual([]);
118 });
119
120 it("derives schedule from mutual-aid policy", () => {
121 const peerDids = [
122 "did:plc:alice",
123 "did:plc:bob",
124 "did:plc:carol",
125 ];
126 const engine = new PolicyEngine({
127 version: 1,
128 policies: [mutualAid({ peerDids, minCopies: 3 })],
129 });
130
131 // Set up sync state for a tracked DID
132 syncStorage.upsertState({
133 did: "did:plc:alice",
134 pdsEndpoint: "https://alice.pds.example",
135 });
136
137 const scheduler = new ChallengeScheduler(
138 "did:plc:self",
139 engine,
140 syncStorage,
141 challengeStorage,
142 blockStore,
143 transport,
144 );
145
146 const schedules = scheduler.deriveSchedules();
147
148 expect(schedules.length).toBe(1);
149 expect(schedules[0]!.subjectDid).toBe("did:plc:alice");
150 expect(schedules[0]!.targetPeers).toEqual(peerDids);
151 expect(schedules[0]!.requiredSuccesses).toBe(2); // minCopies(3) - 1
152 expect(schedules[0]!.challengeType).toBe("mst-proof"); // priority 50 < 70
153 expect(schedules[0]!.intervalMs).toBe(600 * 2 * 1000); // 2x sync interval
154 });
155
156 it("uses combined type for high-priority policies", () => {
157 const engine = new PolicyEngine({
158 version: 1,
159 policies: [
160 {
161 id: "saas-with-peers",
162 name: "SaaS with peers",
163 target: {
164 type: "list",
165 dids: ["did:plc:customer"],
166 },
167 replication: {
168 minCopies: 3,
169 preferredPeers: [
170 "did:plc:peer1",
171 "did:plc:peer2",
172 ],
173 },
174 sync: { intervalSec: 60 },
175 retention: { maxAgeSec: 0, keepHistory: true },
176 priority: 80,
177 enabled: true,
178 },
179 ],
180 });
181
182 syncStorage.upsertState({
183 did: "did:plc:customer",
184 pdsEndpoint: "https://customer.pds.example",
185 });
186
187 const scheduler = new ChallengeScheduler(
188 "did:plc:self",
189 engine,
190 syncStorage,
191 challengeStorage,
192 blockStore,
193 transport,
194 );
195
196 const schedules = scheduler.deriveSchedules();
197
198 expect(schedules.length).toBe(1);
199 expect(schedules[0]!.challengeType).toBe("combined"); // priority 80 >= 70
200 expect(schedules[0]!.requiredSuccesses).toBe(2); // minCopies(3) - 1
201 expect(schedules[0]!.intervalMs).toBe(60 * 2 * 1000); // 2x 60s
202 });
203
204 it("skips DIDs without preferred peers", () => {
205 const engine = new PolicyEngine({
206 version: 1,
207 policies: [
208 {
209 id: "no-peers",
210 name: "No peers",
211 target: {
212 type: "list",
213 dids: ["did:plc:lonely"],
214 },
215 replication: { minCopies: 1 },
216 sync: { intervalSec: 300 },
217 retention: { maxAgeSec: 0, keepHistory: false },
218 priority: 50,
219 enabled: true,
220 },
221 ],
222 });
223
224 syncStorage.upsertState({
225 did: "did:plc:lonely",
226 pdsEndpoint: "https://lonely.pds.example",
227 });
228
229 const scheduler = new ChallengeScheduler(
230 "did:plc:self",
231 engine,
232 syncStorage,
233 challengeStorage,
234 blockStore,
235 transport,
236 );
237
238 const schedules = scheduler.deriveSchedules();
239 expect(schedules.length).toBe(0);
240 });
241 });
242
243 describe("ChallengeStorage", () => {
244 it("records results and updates reliability", () => {
245 challengeStorage.recordResult(
246 "did:plc:challenger",
247 "did:plc:target",
248 "did:plc:subject",
249 "mst-proof",
250 {
251 challengeId: "challenge-1",
252 passed: true,
253 verifiedAt: new Date().toISOString(),
254 durationMs: 100,
255 },
256 );
257
258 const history = challengeStorage.getHistory("did:plc:target");
259 expect(history.length).toBe(1);
260 expect(history[0]!.passed).toBe(1);
261
262 const reliability = challengeStorage.getReliability(
263 "did:plc:target",
264 "did:plc:subject",
265 );
266 expect(reliability.length).toBe(1);
267 expect(reliability[0]!.total_challenges).toBe(1);
268 expect(reliability[0]!.successful_challenges).toBe(1);
269 expect(reliability[0]!.reliability).toBe(1.0);
270 });
271
272 it("tracks declining reliability", () => {
273 // First challenge: pass
274 challengeStorage.recordResult(
275 "did:plc:challenger",
276 "did:plc:target",
277 "did:plc:subject",
278 "mst-proof",
279 {
280 challengeId: "challenge-1",
281 passed: true,
282 verifiedAt: new Date().toISOString(),
283 durationMs: 100,
284 },
285 );
286
287 // Second challenge: fail
288 challengeStorage.recordResult(
289 "did:plc:challenger",
290 "did:plc:target",
291 "did:plc:subject",
292 "mst-proof",
293 {
294 challengeId: "challenge-2",
295 passed: false,
296 verifiedAt: new Date().toISOString(),
297 durationMs: 200,
298 },
299 );
300
301 const reliability = challengeStorage.getReliability(
302 "did:plc:target",
303 "did:plc:subject",
304 );
305 expect(reliability[0]!.total_challenges).toBe(2);
306 expect(reliability[0]!.successful_challenges).toBe(1);
307 expect(reliability[0]!.reliability).toBe(0.5);
308 });
309
310 it("getHistory filters by subject DID", () => {
311 challengeStorage.recordResult(
312 "did:plc:challenger",
313 "did:plc:target",
314 "did:plc:subject-a",
315 "mst-proof",
316 {
317 challengeId: "c-1",
318 passed: true,
319 verifiedAt: new Date().toISOString(),
320 durationMs: 50,
321 },
322 );
323
324 challengeStorage.recordResult(
325 "did:plc:challenger",
326 "did:plc:target",
327 "did:plc:subject-b",
328 "mst-proof",
329 {
330 challengeId: "c-2",
331 passed: false,
332 verifiedAt: new Date().toISOString(),
333 durationMs: 60,
334 },
335 );
336
337 const allHistory = challengeStorage.getHistory("did:plc:target");
338 expect(allHistory.length).toBe(2);
339
340 const filteredHistory = challengeStorage.getHistory(
341 "did:plc:target",
342 "did:plc:subject-a",
343 );
344 expect(filteredHistory.length).toBe(1);
345 expect(filteredHistory[0]!.subject_did).toBe(
346 "did:plc:subject-a",
347 );
348 });
349
350 it("getAllReliability returns sorted results", () => {
351 // High reliability peer
352 challengeStorage.recordResult(
353 "did:plc:c",
354 "did:plc:good-peer",
355 "did:plc:subject",
356 "mst-proof",
357 {
358 challengeId: "c-good",
359 passed: true,
360 verifiedAt: new Date().toISOString(),
361 durationMs: 50,
362 },
363 );
364
365 // Low reliability peer
366 challengeStorage.recordResult(
367 "did:plc:c",
368 "did:plc:bad-peer",
369 "did:plc:subject",
370 "mst-proof",
371 {
372 challengeId: "c-bad",
373 passed: false,
374 verifiedAt: new Date().toISOString(),
375 durationMs: 50,
376 },
377 );
378
379 const all = challengeStorage.getAllReliability();
380 expect(all.length).toBe(2);
381 expect(all[0]!.peer_did).toBe("did:plc:good-peer");
382 expect(all[0]!.reliability).toBe(1.0);
383 expect(all[1]!.peer_did).toBe("did:plc:bad-peer");
384 expect(all[1]!.reliability).toBe(0.0);
385 });
386 });
387
388 describe("SyncStorage record paths", () => {
389 it("tracks and retrieves record paths", () => {
390 syncStorage.upsertState({
391 did: "did:plc:test",
392 pdsEndpoint: "https://test.example",
393 });
394
395 syncStorage.trackRecordPaths("did:plc:test", [
396 "app.bsky.feed.post/abc",
397 "app.bsky.feed.post/def",
398 ]);
399
400 const paths = syncStorage.getRecordPaths("did:plc:test");
401 expect(paths).toContain("app.bsky.feed.post/abc");
402 expect(paths).toContain("app.bsky.feed.post/def");
403 expect(paths.length).toBe(2);
404 });
405
406 it("ignores duplicate record paths", () => {
407 syncStorage.upsertState({
408 did: "did:plc:test",
409 pdsEndpoint: "https://test.example",
410 });
411
412 syncStorage.trackRecordPaths("did:plc:test", [
413 "app.bsky.feed.post/abc",
414 ]);
415 syncStorage.trackRecordPaths("did:plc:test", [
416 "app.bsky.feed.post/abc",
417 "app.bsky.feed.post/def",
418 ]);
419
420 const paths = syncStorage.getRecordPaths("did:plc:test");
421 expect(paths.length).toBe(2);
422 });
423
424 it("clears record paths", () => {
425 syncStorage.upsertState({
426 did: "did:plc:test",
427 pdsEndpoint: "https://test.example",
428 });
429
430 syncStorage.trackRecordPaths("did:plc:test", [
431 "app.bsky.feed.post/abc",
432 ]);
433 syncStorage.clearRecordPaths("did:plc:test");
434
435 const paths = syncStorage.getRecordPaths("did:plc:test");
436 expect(paths.length).toBe(0);
437 });
438 });
439});