an app to share curated trails
sidetrail.app
1/**
2 * Tests for account takedown/suspension handling
3 *
4 * Tests that:
5 * - Takendown/deleted accounts have their content removed
6 * - Suspended/deactivated accounts keep their content but are filtered from listings
7 * - Commits from takendown/deleted accounts are ignored
8 * - Reactivation restores visibility
9 */
10
11import { describe, it, expect } from "vitest";
12import { emit, emitAccount, ALICE, BOB, setCurrentUser, generateTid } from "./helpers";
13import { getTestDb, trails, walks, completions, accounts } from "./helpers/test-db";
14import { eq, count } from "drizzle-orm";
15import {
16 loadTrails,
17 loadTrailActiveWalkers,
18 loadTrailDetail,
19 loadUserPublishedTrails,
20} from "../queries";
21
22// ============================================================================
23// Test Data Helpers
24// ============================================================================
25
26function createTrailRecord() {
27 return {
28 $type: "app.sidetrail.trail",
29 title: "Test Trail",
30 description: "A test trail",
31 stops: [
32 { tid: generateTid(), title: "Stop 1", content: "Content 1" },
33 { tid: generateTid(), title: "Stop 2", content: "Content 2" },
34 ],
35 accentColor: "#FF0000",
36 backgroundColor: "#FFFFFF",
37 createdAt: new Date().toISOString(),
38 };
39}
40
41function createWalkRecord(trailUri: string, trailCid: string) {
42 return {
43 $type: "app.sidetrail.walk",
44 trail: { uri: trailUri, cid: trailCid },
45 visitedStops: [],
46 createdAt: new Date().toISOString(),
47 };
48}
49
50function createCompletionRecord(trailUri: string, trailCid: string) {
51 return {
52 $type: "app.sidetrail.completion",
53 trail: { uri: trailUri, cid: trailCid },
54 createdAt: new Date().toISOString(),
55 };
56}
57
58// ============================================================================
59// Account Event Tests
60// ============================================================================
61
62describe("Account Events", () => {
63 describe("account takedown", () => {
64 it("deletes all content from takendown account", async () => {
65 // Alice creates a trail
66 const trail = await emit("app.sidetrail.trail", ALICE.did, createTrailRecord());
67
68 // Alice creates a walk
69 await emit("app.sidetrail.walk", ALICE.did, createWalkRecord(trail.uri, trail.cid));
70
71 // Alice creates a completion
72 await emit(
73 "app.sidetrail.completion",
74 ALICE.did,
75 createCompletionRecord(trail.uri, trail.cid),
76 );
77
78 // Verify content exists
79 const db = getTestDb();
80 let [trailCount] = await db.select({ count: count() }).from(trails);
81 let [walkCount] = await db.select({ count: count() }).from(walks);
82 let [completionCount] = await db.select({ count: count() }).from(completions);
83
84 expect(trailCount.count).toBe(1);
85 expect(walkCount.count).toBe(1);
86 expect(completionCount.count).toBe(1);
87
88 // Alice's account is taken down
89 await emitAccount(ALICE.did, false, "takendown");
90
91 // Verify all content deleted
92 [trailCount] = await db.select({ count: count() }).from(trails);
93 [walkCount] = await db.select({ count: count() }).from(walks);
94 [completionCount] = await db.select({ count: count() }).from(completions);
95
96 expect(trailCount.count).toBe(0);
97 expect(walkCount.count).toBe(0);
98 expect(completionCount.count).toBe(0);
99
100 // Verify account status recorded
101 const [accountStatus] = await db.select().from(accounts).where(eq(accounts.did, ALICE.did));
102
103 expect(accountStatus.active).toBe(0);
104 expect(accountStatus.status).toBe("takendown");
105 });
106
107 it("deletes all content from deleted account", async () => {
108 // Alice creates a trail
109 await emit("app.sidetrail.trail", ALICE.did, createTrailRecord());
110
111 // Alice's account is deleted
112 await emitAccount(ALICE.did, false, "deleted");
113
114 // Verify content deleted
115 const db = getTestDb();
116 const [trailCount] = await db.select({ count: count() }).from(trails);
117 expect(trailCount.count).toBe(0);
118
119 // Verify account status
120 const [accountStatus] = await db.select().from(accounts).where(eq(accounts.did, ALICE.did));
121
122 expect(accountStatus.active).toBe(0);
123 expect(accountStatus.status).toBe("deleted");
124 });
125 });
126
127 describe("account suspension/deactivation", () => {
128 it("keeps content for suspended account", async () => {
129 // Alice creates a trail
130 await emit("app.sidetrail.trail", ALICE.did, createTrailRecord());
131
132 // Alice's account is suspended
133 await emitAccount(ALICE.did, false, "suspended");
134
135 // Verify content still exists
136 const db = getTestDb();
137 const [trailCount] = await db.select({ count: count() }).from(trails);
138 expect(trailCount.count).toBe(1);
139
140 // Verify account marked inactive
141 const [accountStatus] = await db.select().from(accounts).where(eq(accounts.did, ALICE.did));
142
143 expect(accountStatus.active).toBe(0);
144 expect(accountStatus.status).toBe("suspended");
145 });
146
147 it("keeps content for deactivated account", async () => {
148 // Alice creates a trail
149 await emit("app.sidetrail.trail", ALICE.did, createTrailRecord());
150
151 // Alice deactivates her account
152 await emitAccount(ALICE.did, false, "deactivated");
153
154 // Verify content still exists
155 const db = getTestDb();
156 const [trailCount] = await db.select({ count: count() }).from(trails);
157 expect(trailCount.count).toBe(1);
158
159 // Verify account marked inactive
160 const [accountStatus] = await db.select().from(accounts).where(eq(accounts.did, ALICE.did));
161
162 expect(accountStatus.active).toBe(0);
163 expect(accountStatus.status).toBe("deactivated");
164 });
165 });
166
167 describe("account reactivation", () => {
168 it("marks account active again on reactivation", async () => {
169 // Alice creates a trail
170 await emit("app.sidetrail.trail", ALICE.did, createTrailRecord());
171
172 // Alice deactivates her account
173 await emitAccount(ALICE.did, false, "deactivated");
174
175 // Alice reactivates her account
176 await emitAccount(ALICE.did, true);
177
178 // Verify account is active
179 const db = getTestDb();
180 const [accountStatus] = await db.select().from(accounts).where(eq(accounts.did, ALICE.did));
181
182 expect(accountStatus.active).toBe(1);
183 expect(accountStatus.status).toBeNull();
184 });
185 });
186
187 describe("stale event handling", () => {
188 it("ignores stale account events with lower seq", async () => {
189 // Alice creates content first (accounts are only tracked if they have content)
190 await emit("app.sidetrail.trail", ALICE.did, createTrailRecord());
191
192 // First event: account suspended (seq 1)
193 await emitAccount(ALICE.did, false, "suspended");
194
195 // Second event: account active (seq 2)
196 await emitAccount(ALICE.did, true);
197
198 // Verify account is active with seq 2
199 const db = getTestDb();
200 const [accountStatus] = await db.select().from(accounts).where(eq(accounts.did, ALICE.did));
201
202 expect(accountStatus.active).toBe(1);
203 expect(accountStatus.seq).toBe(2);
204 });
205 });
206});
207
208// ============================================================================
209// Query Filtering Tests
210// ============================================================================
211
212describe("Query Filtering", () => {
213 describe("loadTrails", () => {
214 it("filters out trails from inactive accounts", async () => {
215 setCurrentUser(BOB);
216
217 // Alice creates a trail
218 await emit("app.sidetrail.trail", ALICE.did, createTrailRecord());
219
220 // Bob creates a trail
221 await emit("app.sidetrail.trail", BOB.did, createTrailRecord());
222
223 // Verify both trails visible
224 let trailsList = await loadTrails();
225 expect(trailsList.length).toBe(2);
226
227 // Alice's account is suspended
228 await emitAccount(ALICE.did, false, "suspended");
229
230 // Verify only Bob's trail visible
231 trailsList = await loadTrails();
232 expect(trailsList.length).toBe(1);
233 expect(trailsList[0].creator.did).toBe(BOB.did);
234 });
235
236 it("shows trails again after account reactivation", async () => {
237 setCurrentUser(BOB);
238
239 // Alice creates a trail
240 await emit("app.sidetrail.trail", ALICE.did, createTrailRecord());
241
242 // Alice's account is deactivated
243 await emitAccount(ALICE.did, false, "deactivated");
244
245 // Verify trail hidden
246 let trailsList = await loadTrails();
247 expect(trailsList.length).toBe(0);
248
249 // Alice reactivates
250 await emitAccount(ALICE.did, true);
251
252 // Verify trail visible again
253 trailsList = await loadTrails();
254 expect(trailsList.length).toBe(1);
255 });
256
257 it("shows trails from accounts with no status record (assume active)", async () => {
258 // Alice creates a trail but has no account status record
259 await emit("app.sidetrail.trail", ALICE.did, createTrailRecord());
260
261 // Verify trail is visible (no account record = assume active)
262 const trailsList = await loadTrails();
263 expect(trailsList.length).toBe(1);
264 });
265 });
266
267 describe("loadTrailActiveWalkers", () => {
268 it("filters out walkers from inactive accounts", async () => {
269 // Alice creates a trail
270 const trail = await emit("app.sidetrail.trail", ALICE.did, createTrailRecord());
271
272 // Bob starts walking
273 await emit("app.sidetrail.walk", BOB.did, createWalkRecord(trail.uri, trail.cid));
274
275 // Verify Bob appears as walker
276 let walkers = await loadTrailActiveWalkers(trail.uri);
277 expect(walkers.length).toBe(1);
278 expect(walkers[0].did).toBe(BOB.did);
279
280 // Bob's account is suspended
281 await emitAccount(BOB.did, false, "suspended");
282
283 // Verify Bob no longer appears
284 walkers = await loadTrailActiveWalkers(trail.uri);
285 expect(walkers.length).toBe(0);
286 });
287
288 it("shows walkers again after reactivation", async () => {
289 // Alice creates a trail
290 const trail = await emit("app.sidetrail.trail", ALICE.did, createTrailRecord());
291
292 // Bob starts walking
293 await emit("app.sidetrail.walk", BOB.did, createWalkRecord(trail.uri, trail.cid));
294
295 // Bob deactivates
296 await emitAccount(BOB.did, false, "deactivated");
297
298 // Bob reactivates
299 await emitAccount(BOB.did, true);
300
301 // Verify Bob appears again
302 const walkers = await loadTrailActiveWalkers(trail.uri);
303 expect(walkers.length).toBe(1);
304 expect(walkers[0].did).toBe(BOB.did);
305 });
306 });
307
308 describe("direct link access", () => {
309 it("trail detail returns 404 for suspended account", async () => {
310 // Alice creates a trail
311 await emit("app.sidetrail.trail", ALICE.did, createTrailRecord());
312
313 // Alice's account is suspended
314 await emitAccount(ALICE.did, false, "suspended");
315
316 // Direct link to trail should 404
317 await expect(loadTrailDetail("alice.test", "test_rkey")).rejects.toThrow("NEXT_NOT_FOUND");
318 });
319
320 it("trail detail returns 404 for deactivated account", async () => {
321 // Alice creates a trail
322 await emit("app.sidetrail.trail", ALICE.did, createTrailRecord());
323
324 // Alice deactivates
325 await emitAccount(ALICE.did, false, "deactivated");
326
327 // Direct link to trail should 404
328 await expect(loadTrailDetail("alice.test", "test_rkey")).rejects.toThrow("NEXT_NOT_FOUND");
329 });
330
331 it("profile returns 404 for inactive account", async () => {
332 // Alice creates a trail
333 await emit("app.sidetrail.trail", ALICE.did, createTrailRecord());
334
335 // Alice's account is suspended
336 await emitAccount(ALICE.did, false, "suspended");
337
338 // Direct link to profile should 404
339 await expect(loadUserPublishedTrails("alice.test")).rejects.toThrow("NEXT_NOT_FOUND");
340 });
341
342 it("trail detail works after reactivation", async () => {
343 // Alice creates a trail
344 const trail = await emit("app.sidetrail.trail", ALICE.did, createTrailRecord());
345
346 // Alice deactivates then reactivates
347 await emitAccount(ALICE.did, false, "deactivated");
348 await emitAccount(ALICE.did, true);
349
350 // Direct link should work again
351 const detail = await loadTrailDetail("alice.test", trail.rkey);
352 expect(detail.header.title).toBe("Test Trail");
353 });
354 });
355});
356
357// ============================================================================
358// Commit Blocking Tests
359// ============================================================================
360
361describe("Commit Blocking", () => {
362 it("ignores commits from takendown accounts", async () => {
363 // Alice creates content first (which creates her account record)
364 await emit("app.sidetrail.trail", ALICE.did, createTrailRecord());
365
366 // Alice's account is taken down (deletes content, marks account takendown)
367 await emitAccount(ALICE.did, false, "takendown");
368
369 // Try to create another trail - should be ignored
370 await emit("app.sidetrail.trail", ALICE.did, createTrailRecord());
371
372 // Verify no trails exist (original deleted, new one blocked)
373 const db = getTestDb();
374 const [trailCount] = await db.select({ count: count() }).from(trails);
375 expect(trailCount.count).toBe(0);
376 });
377
378 it("ignores commits from deleted accounts", async () => {
379 // Alice creates content first (which creates her account record)
380 await emit("app.sidetrail.trail", ALICE.did, createTrailRecord());
381
382 // Alice's account is deleted
383 await emitAccount(ALICE.did, false, "deleted");
384
385 // Try to create another trail - should be ignored
386 await emit("app.sidetrail.trail", ALICE.did, createTrailRecord());
387
388 // Verify no trails exist
389 const db = getTestDb();
390 const [trailCount] = await db.select({ count: count() }).from(trails);
391 expect(trailCount.count).toBe(0);
392 });
393
394 it("ignores commits from suspended accounts", async () => {
395 // Alice creates content first (which creates her account record)
396 await emit("app.sidetrail.trail", ALICE.did, createTrailRecord());
397
398 // Alice's account is suspended
399 await emitAccount(ALICE.did, false, "suspended");
400
401 // Try to create another trail - should be ignored
402 await emit("app.sidetrail.trail", ALICE.did, createTrailRecord());
403
404 // Verify only original trail exists (content kept but new commits blocked)
405 const db = getTestDb();
406 const [trailCount] = await db.select({ count: count() }).from(trails);
407 expect(trailCount.count).toBe(1);
408 });
409
410 it("ignores commits from deactivated accounts", async () => {
411 // Alice creates content first (which creates her account record)
412 await emit("app.sidetrail.trail", ALICE.did, createTrailRecord());
413
414 // Alice's account is deactivated
415 await emitAccount(ALICE.did, false, "deactivated");
416
417 // Try to create another trail - should be ignored
418 await emit("app.sidetrail.trail", ALICE.did, createTrailRecord());
419
420 // Verify only original trail exists (content kept but new commits blocked)
421 const db = getTestDb();
422 const [trailCount] = await db.select({ count: count() }).from(trails);
423 expect(trailCount.count).toBe(1);
424 });
425});