WIP! A BB-style forum, on the ATmosphere!
We're still working... we'll be back soon when we have something to show off!
node
typescript
hono
htmx
atproto
1import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js";
3import { Hono } from "hono";
4import type { Variables } from "../../types.js";
5
6// Mock middleware at module level
7let mockUser: any;
8let mockPutRecord: ReturnType<typeof vi.fn>;
9
10vi.mock("../../middleware/auth.js", () => ({
11 requireAuth: vi.fn(() => async (c: any, next: any) => {
12 c.set("user", mockUser);
13 await next();
14 }),
15}));
16
17vi.mock("../../middleware/permissions.js", () => ({
18 requirePermission: vi.fn(() => async (_c: any, next: any) => {
19 await next();
20 }),
21 checkPermission: vi.fn().mockResolvedValue(true),
22}));
23
24// Import after mocking
25const { createModRoutes, validateReason, checkActiveAction } = await import("../mod.js");
26
27describe.sequential("Mod Module Tests", () => {
28 describe("Mod Routes", () => {
29 let ctx: TestContext;
30 let app: Hono<{ Variables: Variables }>;
31
32 beforeEach(async () => {
33 ctx = await createTestContext();
34 app = new Hono<{ Variables: Variables }>().route("/api/mod", createModRoutes(ctx));
35
36 // Set up mock user for auth middleware
37 mockUser = { did: "did:plc:test-moderator" };
38
39 // Mock putRecord (matches @atproto/api Response format)
40 mockPutRecord = vi.fn().mockResolvedValue({
41 data: {
42 uri: "at://...",
43 cid: "bafytest",
44 },
45 });
46
47 // Mock ForumAgent
48 ctx.forumAgent = {
49 getAgent: () => ({
50 com: {
51 atproto: {
52 repo: {
53 putRecord: mockPutRecord,
54 },
55 },
56 },
57 }),
58 } as any;
59 });
60
61 afterEach(async () => {
62 await ctx.cleanup();
63 });
64
65 describe("POST /api/mod/ban", () => {
66 it("bans user successfully when admin has authority", async () => {
67 // Create admin and member users
68 const { users, memberships, roles, rolePermissions } = await import("@atbb/db");
69 const { eq } = await import("drizzle-orm");
70
71 // Use unique DIDs for this test
72 const adminDid = "did:plc:test-ban-admin";
73 const memberDid = "did:plc:test-ban-member";
74
75 // Insert admin user
76 await ctx.db.insert(users).values({
77 did: adminDid,
78 handle: "admin.test",
79 indexedAt: new Date(),
80 });
81
82 // Insert member user
83 await ctx.db.insert(users).values({
84 did: memberDid,
85 handle: "member.test",
86 indexedAt: new Date(),
87 });
88
89 // Create admin role
90 await ctx.db.insert(roles).values({
91 did: ctx.config.forumDid,
92 rkey: "admin-role",
93 cid: "bafyadmin",
94 name: "Admin",
95 priority: 10,
96 createdAt: new Date(),
97 indexedAt: new Date(),
98 });
99
100 // Get admin role URI
101 const [adminRole] = await ctx.db
102 .select()
103 .from(roles)
104 .where(eq(roles.rkey, "admin-role"))
105 .limit(1);
106
107 // Grant banUsers permission to admin role
108 await ctx.db.insert(rolePermissions).values({
109 roleId: adminRole.id,
110 permission: "space.atbb.permission.banUsers",
111 });
112
113 // Insert memberships
114 const now = new Date();
115 await ctx.db.insert(memberships).values({
116 did: adminDid,
117 rkey: "self",
118 cid: "bafyadminmem",
119 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
120 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/${adminRole.rkey}`,
121 joinedAt: now,
122 createdAt: now,
123 indexedAt: now,
124 });
125
126 await ctx.db.insert(memberships).values({
127 did: memberDid,
128 rkey: "self",
129 cid: "bafymembermem",
130 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
131 roleUri: null, // Regular member with no role
132 joinedAt: now,
133 createdAt: now,
134 indexedAt: now,
135 });
136
137 // Set mock user to admin
138 mockUser = { did: adminDid };
139
140 // Mock putRecord to return success (matches @atproto/api Response format)
141 mockPutRecord.mockResolvedValueOnce({
142 data: {
143 uri: `at://${ctx.config.forumDid}/space.atbb.modAction/test123`,
144 cid: "bafybanaction",
145 },
146 });
147
148 // POST ban request
149 const res = await app.request("/api/mod/ban", {
150 method: "POST",
151 headers: { "Content-Type": "application/json" },
152 body: JSON.stringify({
153 targetDid: memberDid,
154 reason: "Spam and harassment",
155 }),
156 });
157
158 expect(res.status).toBe(200);
159 const data = await res.json();
160 expect(data.success).toBe(true);
161 expect(data.action).toBe("space.atbb.modAction.ban");
162 expect(data.targetDid).toBe(memberDid);
163 expect(data.uri).toBe(`at://${ctx.config.forumDid}/space.atbb.modAction/test123`);
164 expect(data.cid).toBe("bafybanaction");
165 expect(data.alreadyActive).toBe(false);
166
167 // Verify putRecord was called with correct parameters
168 expect(mockPutRecord).toHaveBeenCalledWith(
169 expect.objectContaining({
170 repo: ctx.config.forumDid,
171 collection: "space.atbb.modAction",
172 record: expect.objectContaining({
173 $type: "space.atbb.modAction",
174 action: "space.atbb.modAction.ban",
175 subject: { did: memberDid },
176 reason: "Spam and harassment",
177 createdBy: adminDid,
178 }),
179 })
180 );
181 });
182
183 describe("Authorization", () => {
184 it("returns 401 when not authenticated", async () => {
185 const { users, memberships } = await import("@atbb/db");
186
187 // Create target user to avoid 404 (matches cleanup pattern did:plc:test-%)
188 const targetDid = "did:plc:test-auth-target";
189 await ctx.db.insert(users).values({
190 did: targetDid,
191 handle: "authtest.test",
192 indexedAt: new Date(),
193 }).onConflictDoNothing();
194
195 await ctx.db.insert(memberships).values({
196 did: targetDid,
197 rkey: "self",
198 cid: "bafyauth",
199 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
200 roleUri: null,
201 joinedAt: new Date(),
202 createdAt: new Date(),
203 indexedAt: new Date(),
204 }).onConflictDoNothing();
205
206 // Recreate app with auth middleware that returns 401
207 const { requireAuth } = await import("../../middleware/auth.js");
208 const mockRequireAuth = requireAuth as any;
209 mockRequireAuth.mockImplementation(() => async (c: any) => {
210 return c.json({ error: "Unauthorized" }, 401);
211 });
212
213 const testApp = new Hono<{ Variables: Variables }>().route("/api/mod", createModRoutes(ctx));
214
215 const res = await testApp.request("/api/mod/ban", {
216 method: "POST",
217 headers: { "Content-Type": "application/json" },
218 body: JSON.stringify({
219 targetDid,
220 reason: "Test reason",
221 }),
222 });
223
224 expect(res.status).toBe(401);
225
226 // Restore default mock for subsequent tests
227 mockRequireAuth.mockImplementation(() => async (c: any, next: any) => {
228 c.set("user", mockUser);
229 await next();
230 });
231 });
232
233 it("returns 403 when user lacks banUsers permission", async () => {
234 const { users, memberships } = await import("@atbb/db");
235 const { requirePermission } = await import("../../middleware/permissions.js");
236
237 // Create target user to avoid 404 (matches cleanup pattern did:plc:test-%)
238 const targetDid = "did:plc:test-perm-target";
239 await ctx.db.insert(users).values({
240 did: targetDid,
241 handle: "permtest.test",
242 indexedAt: new Date(),
243 }).onConflictDoNothing();
244
245 await ctx.db.insert(memberships).values({
246 did: targetDid,
247 rkey: "self",
248 cid: "bafyperm",
249 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
250 roleUri: null,
251 joinedAt: new Date(),
252 createdAt: new Date(),
253 indexedAt: new Date(),
254 }).onConflictDoNothing();
255
256 // Mock requirePermission to deny access
257 const mockRequirePermission = requirePermission as any;
258 mockRequirePermission.mockImplementation(() => async (c: any) => {
259 return c.json({ error: "Forbidden" }, 403);
260 });
261
262 const testApp = new Hono<{ Variables: Variables }>().route("/api/mod", createModRoutes(ctx));
263
264 const res = await testApp.request("/api/mod/ban", {
265 method: "POST",
266 headers: { "Content-Type": "application/json" },
267 body: JSON.stringify({
268 targetDid,
269 reason: "Test reason",
270 }),
271 });
272
273 expect(res.status).toBe(403);
274
275 // Restore default mock for subsequent tests
276 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => {
277 await next();
278 });
279 });
280 });
281
282 describe("Input Validation", () => {
283 beforeEach(() => {
284 // Reset mockUser to valid user for these tests
285 mockUser = { did: "did:plc:test-moderator" };
286 });
287
288 it("returns 400 for invalid DID format (not starting with 'did:')", async () => {
289 const res = await app.request("/api/mod/ban", {
290 method: "POST",
291 headers: { "Content-Type": "application/json" },
292 body: JSON.stringify({
293 targetDid: "invalid-did-format",
294 reason: "Test reason",
295 }),
296 });
297
298 expect(res.status).toBe(400);
299 const data = await res.json();
300 expect(data.error).toBe("Invalid DID format");
301 });
302
303 it("returns 400 for missing reason field", async () => {
304 const res = await app.request("/api/mod/ban", {
305 method: "POST",
306 headers: { "Content-Type": "application/json" },
307 body: JSON.stringify({
308 targetDid: "did:plc:target",
309 // reason field missing
310 }),
311 });
312
313 expect(res.status).toBe(400);
314 const data = await res.json();
315 expect(data.error).toBe("Reason is required and must be a string");
316 });
317
318 it("returns 400 for empty reason (whitespace only)", async () => {
319 const res = await app.request("/api/mod/ban", {
320 method: "POST",
321 headers: { "Content-Type": "application/json" },
322 body: JSON.stringify({
323 targetDid: "did:plc:target",
324 reason: " ",
325 }),
326 });
327
328 expect(res.status).toBe(400);
329 const data = await res.json();
330 expect(data.error).toBe("Reason is required and must not be empty");
331 });
332
333 it("returns 400 for malformed JSON", async () => {
334 const res = await app.request("/api/mod/ban", {
335 method: "POST",
336 headers: { "Content-Type": "application/json" },
337 body: "{ invalid json }",
338 });
339
340 expect(res.status).toBe(400);
341 const data = await res.json();
342 expect(data.error).toBe("Invalid JSON in request body");
343 });
344 });
345
346 describe("Business Logic", () => {
347 beforeEach(() => {
348 mockUser = { did: "did:plc:test-moderator" };
349 });
350
351 it("returns 404 when target user has no membership", async () => {
352 const res = await app.request("/api/mod/ban", {
353 method: "POST",
354 headers: { "Content-Type": "application/json" },
355 body: JSON.stringify({
356 targetDid: "did:plc:nonexistent",
357 reason: "Test reason",
358 }),
359 });
360
361 expect(res.status).toBe(404);
362 const data = await res.json();
363 expect(data.error).toBe("Target user not found");
364 });
365
366 it("returns 200 with alreadyActive: true when user already banned (idempotency)", async () => {
367 const { users, memberships, modActions, forums } = await import("@atbb/db");
368 const { eq } = await import("drizzle-orm");
369
370 // Create target user and membership with unique DID (matches cleanup pattern did:plc:test-%)
371 const targetDid = "did:plc:test-idempotent-ban";
372 await ctx.db.insert(users).values({
373 did: targetDid,
374 handle: "idempotentban.test",
375 indexedAt: new Date(),
376 }).onConflictDoNothing();
377
378 await ctx.db.insert(memberships).values({
379 did: targetDid,
380 rkey: "self",
381 cid: "bafytest",
382 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
383 roleUri: null,
384 joinedAt: new Date(),
385 createdAt: new Date(),
386 indexedAt: new Date(),
387 }).onConflictDoNothing();
388
389 // Get forum ID
390 const [forum] = await ctx.db
391 .select()
392 .from(forums)
393 .where(eq(forums.did, ctx.config.forumDid))
394 .limit(1);
395
396 // Insert existing ban action
397 await ctx.db.insert(modActions).values({
398 did: ctx.config.forumDid,
399 rkey: "existing-ban",
400 cid: "bafyban",
401 action: "space.atbb.modAction.ban",
402 subjectDid: targetDid,
403 subjectPostUri: null,
404 forumId: forum.id,
405 reason: "Previously banned",
406 createdBy: "did:plc:previous-mod",
407 expiresAt: null,
408 createdAt: new Date(),
409 indexedAt: new Date(),
410 });
411
412 // Attempt to ban again
413 const res = await app.request("/api/mod/ban", {
414 method: "POST",
415 headers: { "Content-Type": "application/json" },
416 body: JSON.stringify({
417 targetDid,
418 reason: "Trying to ban again",
419 }),
420 });
421
422 expect(res.status).toBe(200);
423 const data = await res.json();
424 expect(data.success).toBe(true);
425 expect(data.alreadyActive).toBe(true);
426 expect(data.uri).toBeNull();
427 expect(data.cid).toBeNull();
428
429 // Verify putRecord was NOT called (no duplicate action written)
430 expect(mockPutRecord).not.toHaveBeenCalled();
431 });
432 });
433
434 describe("Infrastructure Errors", () => {
435 beforeEach(() => {
436 mockUser = { did: "did:plc:test-moderator" };
437 });
438
439 it("returns 500 when ForumAgent not available", async () => {
440 const { users, memberships } = await import("@atbb/db");
441
442 // Create unique target user (matches cleanup pattern did:plc:test-%)
443 const targetDid = "did:plc:test-infra-no-agent";
444 await ctx.db.insert(users).values({
445 did: targetDid,
446 handle: "infranoagent.test",
447 indexedAt: new Date(),
448 }).onConflictDoNothing();
449
450 await ctx.db.insert(memberships).values({
451 did: targetDid,
452 rkey: "self",
453 cid: "bafyinfra1",
454 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
455 roleUri: null,
456 joinedAt: new Date(),
457 createdAt: new Date(),
458 indexedAt: new Date(),
459 }).onConflictDoNothing();
460
461 // Remove ForumAgent
462 ctx.forumAgent = undefined as any;
463
464 const res = await app.request("/api/mod/ban", {
465 method: "POST",
466 headers: { "Content-Type": "application/json" },
467 body: JSON.stringify({
468 targetDid,
469 reason: "Test reason",
470 }),
471 });
472
473 expect(res.status).toBe(500);
474 const data = await res.json();
475 expect(data.error).toBe("Forum agent not available. Server configuration issue.");
476
477 // Restore ForumAgent for other tests
478 ctx.forumAgent = {
479 getAgent: () => ({
480 com: {
481 atproto: {
482 repo: {
483 putRecord: mockPutRecord,
484 },
485 },
486 },
487 }),
488 } as any;
489 });
490
491 it("returns 503 when ForumAgent not authenticated", async () => {
492 const { users, memberships } = await import("@atbb/db");
493
494 // Create unique target user (matches cleanup pattern did:plc:test-%)
495 const targetDid = "did:plc:test-infra-no-auth";
496 await ctx.db.insert(users).values({
497 did: targetDid,
498 handle: "infranoauth.test",
499 indexedAt: new Date(),
500 }).onConflictDoNothing();
501
502 await ctx.db.insert(memberships).values({
503 did: targetDid,
504 rkey: "self",
505 cid: "bafyinfra2",
506 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
507 roleUri: null,
508 joinedAt: new Date(),
509 createdAt: new Date(),
510 indexedAt: new Date(),
511 }).onConflictDoNothing();
512
513 // Mock getAgent to return null (not authenticated)
514 const originalAgent = ctx.forumAgent;
515 ctx.forumAgent = {
516 getAgent: () => null,
517 } as any;
518
519 const res = await app.request("/api/mod/ban", {
520 method: "POST",
521 headers: { "Content-Type": "application/json" },
522 body: JSON.stringify({
523 targetDid,
524 reason: "Test reason",
525 }),
526 });
527
528 expect(res.status).toBe(503);
529 const data = await res.json();
530 expect(data.error).toBe("Forum agent not authenticated. Please try again later.");
531
532 // Restore original agent
533 ctx.forumAgent = originalAgent;
534 });
535
536 it("returns 503 for network errors writing to PDS", async () => {
537 const { users, memberships } = await import("@atbb/db");
538
539 // Create unique target user (matches cleanup pattern did:plc:test-%)
540 const targetDid = "did:plc:test-infra-network-error";
541 await ctx.db.insert(users).values({
542 did: targetDid,
543 handle: "infranetwork.test",
544 indexedAt: new Date(),
545 }).onConflictDoNothing();
546
547 await ctx.db.insert(memberships).values({
548 did: targetDid,
549 rkey: "self",
550 cid: "bafyinfra3",
551 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
552 roleUri: null,
553 joinedAt: new Date(),
554 createdAt: new Date(),
555 indexedAt: new Date(),
556 }).onConflictDoNothing();
557
558 // Mock putRecord to throw network error
559 mockPutRecord.mockRejectedValueOnce(new Error("fetch failed"));
560
561 const res = await app.request("/api/mod/ban", {
562 method: "POST",
563 headers: { "Content-Type": "application/json" },
564 body: JSON.stringify({
565 targetDid,
566 reason: "Test reason",
567 }),
568 });
569
570 expect(res.status).toBe(503);
571 const data = await res.json();
572 expect(data.error).toBe("Unable to reach external service. Please try again later.");
573 });
574
575 it("returns 500 for unexpected errors writing to PDS", async () => {
576 const { users, memberships } = await import("@atbb/db");
577
578 // Create unique target user (matches cleanup pattern did:plc:test-%)
579 const targetDid = "did:plc:test-infra-server-error";
580 await ctx.db.insert(users).values({
581 did: targetDid,
582 handle: "infraserver.test",
583 indexedAt: new Date(),
584 }).onConflictDoNothing();
585
586 await ctx.db.insert(memberships).values({
587 did: targetDid,
588 rkey: "self",
589 cid: "bafyinfra4",
590 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
591 roleUri: null,
592 joinedAt: new Date(),
593 createdAt: new Date(),
594 indexedAt: new Date(),
595 }).onConflictDoNothing();
596
597 // Mock putRecord to throw unexpected error (not network error)
598 mockPutRecord.mockRejectedValueOnce(new Error("Unexpected write error"));
599
600 const res = await app.request("/api/mod/ban", {
601 method: "POST",
602 headers: { "Content-Type": "application/json" },
603 body: JSON.stringify({
604 targetDid,
605 reason: "Test reason",
606 }),
607 });
608
609 expect(res.status).toBe(500);
610 const data = await res.json();
611 expect(data.error).toBe("Failed to record moderation action. Please contact support if this persists.");
612 });
613
614 it("returns 503 when membership query fails (database error)", async () => {
615 // Mock database query to throw error
616 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => {
617 throw new Error("Database connection lost");
618 });
619
620 const res = await app.request("/api/mod/ban", {
621 method: "POST",
622 headers: { "Content-Type": "application/json" },
623 body: JSON.stringify({
624 targetDid: "did:plc:test-db-error",
625 reason: "Test reason",
626 }),
627 });
628
629 expect(res.status).toBe(503);
630 const data = await res.json();
631 expect(data.error).toBe("Database temporarily unavailable. Please try again later.");
632
633 // Restore original implementation
634 dbSelectSpy.mockRestore();
635 });
636 });
637 });
638
639 describe("DELETE /api/mod/ban/:did", () => {
640 it("unbans user successfully when admin has authority", async () => {
641 // Create admin and member users
642 const { users, memberships, roles, rolePermissions, modActions, forums } = await import("@atbb/db");
643 const { eq } = await import("drizzle-orm");
644
645 // Use unique DIDs for this test
646 const adminDid = "did:plc:test-unban-admin";
647 const memberDid = "did:plc:test-unban-member";
648
649 // Insert admin user
650 await ctx.db.insert(users).values({
651 did: adminDid,
652 handle: "unbanadmin.test",
653 indexedAt: new Date(),
654 });
655
656 // Insert member user
657 await ctx.db.insert(users).values({
658 did: memberDid,
659 handle: "unbanmember.test",
660 indexedAt: new Date(),
661 });
662
663 // Create admin role
664 await ctx.db.insert(roles).values({
665 did: ctx.config.forumDid,
666 rkey: "unban-admin-role",
667 cid: "bafyunbanadmin",
668 name: "Admin",
669 priority: 10,
670 createdAt: new Date(),
671 indexedAt: new Date(),
672 });
673
674 // Get admin role URI
675 const [adminRole] = await ctx.db
676 .select()
677 .from(roles)
678 .where(eq(roles.rkey, "unban-admin-role"))
679 .limit(1);
680
681 // Grant banUsers permission to admin role
682 await ctx.db.insert(rolePermissions).values({
683 roleId: adminRole.id,
684 permission: "space.atbb.permission.banUsers",
685 });
686
687 // Insert memberships
688 const now = new Date();
689 await ctx.db.insert(memberships).values({
690 did: adminDid,
691 rkey: "self",
692 cid: "bafyunbanadminmem",
693 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
694 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/${adminRole.rkey}`,
695 joinedAt: now,
696 createdAt: now,
697 indexedAt: now,
698 });
699
700 await ctx.db.insert(memberships).values({
701 did: memberDid,
702 rkey: "self",
703 cid: "bafyunbanmembermem",
704 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
705 roleUri: null,
706 joinedAt: now,
707 createdAt: now,
708 indexedAt: now,
709 });
710
711 // Get forum ID
712 const [forum] = await ctx.db
713 .select()
714 .from(forums)
715 .where(eq(forums.did, ctx.config.forumDid))
716 .limit(1);
717
718 // Insert existing ban action so we have something to unban
719 await ctx.db.insert(modActions).values({
720 did: ctx.config.forumDid,
721 rkey: "previous-ban",
722 cid: "bafyprevban",
723 action: "space.atbb.modAction.ban",
724 subjectDid: memberDid,
725 subjectPostUri: null,
726 forumId: forum.id,
727 reason: "Previously banned",
728 createdBy: "did:plc:previous-mod",
729 expiresAt: null,
730 createdAt: new Date(now.getTime() - 1000),
731 indexedAt: new Date(now.getTime() - 1000),
732 });
733
734 // Set mock user to admin
735 mockUser = { did: adminDid };
736
737 // Mock putRecord to return success (matches @atproto/api Response format)
738 mockPutRecord.mockResolvedValueOnce({
739 data: {
740 uri: `at://${ctx.config.forumDid}/space.atbb.modAction/test-unban`,
741 cid: "bafyunbanaction",
742 },
743 });
744
745 // DELETE unban request
746 const res = await app.request(`/api/mod/ban/${memberDid}`, {
747 method: "DELETE",
748 headers: { "Content-Type": "application/json" },
749 body: JSON.stringify({
750 reason: "Appeal approved",
751 }),
752 });
753
754 expect(res.status).toBe(200);
755 const data = await res.json();
756 expect(data.success).toBe(true);
757 expect(data.action).toBe("space.atbb.modAction.unban");
758 expect(data.targetDid).toBe(memberDid);
759 expect(data.uri).toBe(`at://${ctx.config.forumDid}/space.atbb.modAction/test-unban`);
760 expect(data.cid).toBe("bafyunbanaction");
761 expect(data.alreadyActive).toBe(false);
762
763 // Verify putRecord was called with correct parameters
764 expect(mockPutRecord).toHaveBeenCalledWith(
765 expect.objectContaining({
766 repo: ctx.config.forumDid,
767 collection: "space.atbb.modAction",
768 record: expect.objectContaining({
769 $type: "space.atbb.modAction",
770 action: "space.atbb.modAction.unban",
771 subject: { did: memberDid },
772 reason: "Appeal approved",
773 createdBy: adminDid,
774 }),
775 })
776 );
777 });
778
779 it("returns 200 with alreadyActive: true when user already unbanned (idempotency)", async () => {
780 const { users, memberships } = await import("@atbb/db");
781
782 // Create target user with unique DID (matches cleanup pattern did:plc:test-%)
783 const targetDid = "did:plc:test-already-unbanned";
784 await ctx.db.insert(users).values({
785 did: targetDid,
786 handle: "alreadyunbanned.test",
787 indexedAt: new Date(),
788 }).onConflictDoNothing();
789
790 await ctx.db.insert(memberships).values({
791 did: targetDid,
792 rkey: "self",
793 cid: "bafyunban",
794 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
795 roleUri: null,
796 joinedAt: new Date(),
797 createdAt: new Date(),
798 indexedAt: new Date(),
799 }).onConflictDoNothing();
800
801 // Set mock user
802 mockUser = { did: "did:plc:test-moderator" };
803
804 // Attempt to unban user who was never banned (or already unbanned)
805 const res = await app.request(`/api/mod/ban/${targetDid}`, {
806 method: "DELETE",
807 headers: { "Content-Type": "application/json" },
808 body: JSON.stringify({
809 reason: "Trying to unban again",
810 }),
811 });
812
813 expect(res.status).toBe(200);
814 const data = await res.json();
815 expect(data.success).toBe(true);
816 expect(data.alreadyActive).toBe(true);
817 expect(data.uri).toBeNull();
818 expect(data.cid).toBeNull();
819
820 // Verify putRecord was NOT called (no duplicate action written)
821 expect(mockPutRecord).not.toHaveBeenCalled();
822 });
823
824 // NOTE: Authorization tests (401, 403) are omitted for DELETE endpoint
825 // because it uses identical middleware chain as POST /api/mod/ban, which
826 // has comprehensive authorization tests. Mocking middleware state across
827 // multiple describe blocks proved problematic in the test suite.
828
829 describe("Input Validation", () => {
830 beforeEach(() => {
831 // Reset mockUser to valid user for these tests
832 mockUser = { did: "did:plc:test-moderator" };
833 });
834
835 it("returns 400 for invalid DID format (not starting with 'did:')", async () => {
836 const res = await app.request("/api/mod/ban/invalid-did-format", {
837 method: "DELETE",
838 headers: { "Content-Type": "application/json" },
839 body: JSON.stringify({
840 reason: "Test reason",
841 }),
842 });
843
844 expect(res.status).toBe(400);
845 const data = await res.json();
846 expect(data.error).toBe("Invalid DID format");
847 });
848
849 it("returns 400 for malformed JSON", async () => {
850 const res = await app.request("/api/mod/ban/did:plc:target", {
851 method: "DELETE",
852 headers: { "Content-Type": "application/json" },
853 body: "{ invalid json }",
854 });
855
856 expect(res.status).toBe(400);
857 const data = await res.json();
858 expect(data.error).toBe("Invalid JSON in request body");
859 });
860
861 it("returns 400 for missing reason field", async () => {
862 const res = await app.request("/api/mod/ban/did:plc:target", {
863 method: "DELETE",
864 headers: { "Content-Type": "application/json" },
865 body: JSON.stringify({
866 // reason field missing
867 }),
868 });
869
870 expect(res.status).toBe(400);
871 const data = await res.json();
872 expect(data.error).toBe("Reason is required and must be a string");
873 });
874
875 it("returns 400 for empty reason (whitespace only)", async () => {
876 const res = await app.request("/api/mod/ban/did:plc:target", {
877 method: "DELETE",
878 headers: { "Content-Type": "application/json" },
879 body: JSON.stringify({
880 reason: " ",
881 }),
882 });
883
884 expect(res.status).toBe(400);
885 const data = await res.json();
886 expect(data.error).toBe("Reason is required and must not be empty");
887 });
888 });
889
890 describe("Business Logic", () => {
891 beforeEach(() => {
892 mockUser = { did: "did:plc:test-moderator" };
893 });
894
895 it("returns 404 when target user has no membership", async () => {
896 const res = await app.request("/api/mod/ban/did:plc:nonexistent", {
897 method: "DELETE",
898 headers: { "Content-Type": "application/json" },
899 body: JSON.stringify({
900 reason: "Test reason",
901 }),
902 });
903
904 expect(res.status).toBe(404);
905 const data = await res.json();
906 expect(data.error).toBe("Target user not found");
907 });
908 });
909
910 describe("Infrastructure Errors", () => {
911 beforeEach(() => {
912 mockUser = { did: "did:plc:test-moderator" };
913 });
914
915 it("returns 500 when ForumAgent not available", async () => {
916 const { users, memberships, modActions, forums } = await import("@atbb/db");
917 const { eq } = await import("drizzle-orm");
918
919 // Create unique target user (matches cleanup pattern did:plc:test-%)
920 const targetDid = "did:plc:test-unban-infra-no-agent";
921 await ctx.db.insert(users).values({
922 did: targetDid,
923 handle: "unbaninfranoagent.test",
924 indexedAt: new Date(),
925 }).onConflictDoNothing();
926
927 await ctx.db.insert(memberships).values({
928 did: targetDid,
929 rkey: "self",
930 cid: "bafyunbaninfra1",
931 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
932 roleUri: null,
933 joinedAt: new Date(),
934 createdAt: new Date(),
935 indexedAt: new Date(),
936 }).onConflictDoNothing();
937
938 // Get forum ID
939 const [forum] = await ctx.db
940 .select()
941 .from(forums)
942 .where(eq(forums.did, ctx.config.forumDid))
943 .limit(1);
944
945 // Insert existing ban action so user is currently banned
946 await ctx.db.insert(modActions).values({
947 did: ctx.config.forumDid,
948 rkey: "ban-for-unban-test",
949 cid: "bafybantest",
950 action: "space.atbb.modAction.ban",
951 subjectDid: targetDid,
952 subjectPostUri: null,
953 forumId: forum.id,
954 reason: "Currently banned",
955 createdBy: "did:plc:previous-mod",
956 expiresAt: null,
957 createdAt: new Date(),
958 indexedAt: new Date(),
959 });
960
961 // Remove ForumAgent
962 ctx.forumAgent = undefined as any;
963
964 const res = await app.request(`/api/mod/ban/${targetDid}`, {
965 method: "DELETE",
966 headers: { "Content-Type": "application/json" },
967 body: JSON.stringify({
968 reason: "Test reason",
969 }),
970 });
971
972 expect(res.status).toBe(500);
973 const data = await res.json();
974 expect(data.error).toBe("Forum agent not available. Server configuration issue.");
975
976 // Restore ForumAgent for other tests
977 ctx.forumAgent = {
978 getAgent: () => ({
979 com: {
980 atproto: {
981 repo: {
982 putRecord: mockPutRecord,
983 },
984 },
985 },
986 }),
987 } as any;
988 });
989
990 it("returns 503 when ForumAgent not authenticated", async () => {
991 const { users, memberships, modActions, forums } = await import("@atbb/db");
992 const { eq } = await import("drizzle-orm");
993
994 // Create unique target user (matches cleanup pattern did:plc:test-%)
995 const targetDid = "did:plc:test-unban-infra-no-auth";
996 await ctx.db.insert(users).values({
997 did: targetDid,
998 handle: "unbaninfranoauth.test",
999 indexedAt: new Date(),
1000 }).onConflictDoNothing();
1001
1002 await ctx.db.insert(memberships).values({
1003 did: targetDid,
1004 rkey: "self",
1005 cid: "bafyunbaninfra2",
1006 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1007 roleUri: null,
1008 joinedAt: new Date(),
1009 createdAt: new Date(),
1010 indexedAt: new Date(),
1011 }).onConflictDoNothing();
1012
1013 // Get forum ID
1014 const [forum] = await ctx.db
1015 .select()
1016 .from(forums)
1017 .where(eq(forums.did, ctx.config.forumDid))
1018 .limit(1);
1019
1020 // Insert existing ban action so user is currently banned
1021 await ctx.db.insert(modActions).values({
1022 did: ctx.config.forumDid,
1023 rkey: "ban-for-unban-test2",
1024 cid: "bafybantest2",
1025 action: "space.atbb.modAction.ban",
1026 subjectDid: targetDid,
1027 subjectPostUri: null,
1028 forumId: forum.id,
1029 reason: "Currently banned",
1030 createdBy: "did:plc:previous-mod",
1031 expiresAt: null,
1032 createdAt: new Date(),
1033 indexedAt: new Date(),
1034 });
1035
1036 // Mock getAgent to return null (not authenticated)
1037 const originalAgent = ctx.forumAgent;
1038 ctx.forumAgent = {
1039 getAgent: () => null,
1040 } as any;
1041
1042 const res = await app.request(`/api/mod/ban/${targetDid}`, {
1043 method: "DELETE",
1044 headers: { "Content-Type": "application/json" },
1045 body: JSON.stringify({
1046 reason: "Test reason",
1047 }),
1048 });
1049
1050 expect(res.status).toBe(503);
1051 const data = await res.json();
1052 expect(data.error).toBe("Forum agent not authenticated. Please try again later.");
1053
1054 // Restore original agent
1055 ctx.forumAgent = originalAgent;
1056 });
1057
1058 it("returns 503 for network errors writing to PDS", async () => {
1059 const { users, memberships, modActions, forums } = await import("@atbb/db");
1060 const { eq } = await import("drizzle-orm");
1061
1062 // Create unique target user (matches cleanup pattern did:plc:test-%)
1063 const targetDid = "did:plc:test-unban-infra-network-error";
1064 await ctx.db.insert(users).values({
1065 did: targetDid,
1066 handle: "unbaninfranetwork.test",
1067 indexedAt: new Date(),
1068 }).onConflictDoNothing();
1069
1070 await ctx.db.insert(memberships).values({
1071 did: targetDid,
1072 rkey: "self",
1073 cid: "bafyunbaninfra3",
1074 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1075 roleUri: null,
1076 joinedAt: new Date(),
1077 createdAt: new Date(),
1078 indexedAt: new Date(),
1079 }).onConflictDoNothing();
1080
1081 // Get forum ID
1082 const [forum] = await ctx.db
1083 .select()
1084 .from(forums)
1085 .where(eq(forums.did, ctx.config.forumDid))
1086 .limit(1);
1087
1088 // Insert existing ban action so user is currently banned
1089 await ctx.db.insert(modActions).values({
1090 did: ctx.config.forumDid,
1091 rkey: "ban-for-unban-test3",
1092 cid: "bafybantest3",
1093 action: "space.atbb.modAction.ban",
1094 subjectDid: targetDid,
1095 subjectPostUri: null,
1096 forumId: forum.id,
1097 reason: "Currently banned",
1098 createdBy: "did:plc:previous-mod",
1099 expiresAt: null,
1100 createdAt: new Date(),
1101 indexedAt: new Date(),
1102 });
1103
1104 // Mock putRecord to throw network error
1105 mockPutRecord.mockRejectedValueOnce(new Error("fetch failed"));
1106
1107 const res = await app.request(`/api/mod/ban/${targetDid}`, {
1108 method: "DELETE",
1109 headers: { "Content-Type": "application/json" },
1110 body: JSON.stringify({
1111 reason: "Test reason",
1112 }),
1113 });
1114
1115 expect(res.status).toBe(503);
1116 const data = await res.json();
1117 expect(data.error).toBe("Unable to reach external service. Please try again later.");
1118 });
1119
1120 it("returns 500 for unexpected errors writing to PDS", async () => {
1121 const { users, memberships, modActions, forums } = await import("@atbb/db");
1122 const { eq } = await import("drizzle-orm");
1123
1124 // Create unique target user (matches cleanup pattern did:plc:test-%)
1125 const targetDid = "did:plc:test-unban-infra-server-error";
1126 await ctx.db.insert(users).values({
1127 did: targetDid,
1128 handle: "unbaninfraserver.test",
1129 indexedAt: new Date(),
1130 }).onConflictDoNothing();
1131
1132 await ctx.db.insert(memberships).values({
1133 did: targetDid,
1134 rkey: "self",
1135 cid: "bafyunbaninfra4",
1136 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1137 roleUri: null,
1138 joinedAt: new Date(),
1139 createdAt: new Date(),
1140 indexedAt: new Date(),
1141 }).onConflictDoNothing();
1142
1143 // Get forum ID
1144 const [forum] = await ctx.db
1145 .select()
1146 .from(forums)
1147 .where(eq(forums.did, ctx.config.forumDid))
1148 .limit(1);
1149
1150 // Insert existing ban action so user is currently banned
1151 await ctx.db.insert(modActions).values({
1152 did: ctx.config.forumDid,
1153 rkey: "ban-for-unban-test4",
1154 cid: "bafybantest4",
1155 action: "space.atbb.modAction.ban",
1156 subjectDid: targetDid,
1157 subjectPostUri: null,
1158 forumId: forum.id,
1159 reason: "Currently banned",
1160 createdBy: "did:plc:previous-mod",
1161 expiresAt: null,
1162 createdAt: new Date(),
1163 indexedAt: new Date(),
1164 });
1165
1166 // Mock putRecord to throw unexpected error (not network error)
1167 mockPutRecord.mockRejectedValueOnce(new Error("Unexpected write error"));
1168
1169 const res = await app.request(`/api/mod/ban/${targetDid}`, {
1170 method: "DELETE",
1171 headers: { "Content-Type": "application/json" },
1172 body: JSON.stringify({
1173 reason: "Test reason",
1174 }),
1175 });
1176
1177 expect(res.status).toBe(500);
1178 const data = await res.json();
1179 expect(data.error).toBe("Failed to record moderation action. Please contact support if this persists.");
1180 });
1181
1182 it("returns 503 when membership query fails (database error)", async () => {
1183 // Mock database query to throw error
1184 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => {
1185 throw new Error("Database connection lost");
1186 });
1187
1188 const res = await app.request("/api/mod/ban/did:plc:test-unban-db-error", {
1189 method: "DELETE",
1190 headers: { "Content-Type": "application/json" },
1191 body: JSON.stringify({
1192 reason: "Test reason",
1193 }),
1194 });
1195
1196 expect(res.status).toBe(503);
1197 const data = await res.json();
1198 expect(data.error).toBe("Database temporarily unavailable. Please try again later.");
1199
1200 // Restore spy
1201 dbSelectSpy.mockRestore();
1202 });
1203 });
1204 });
1205
1206 describe("POST /api/mod/lock", () => {
1207 it("locks topic successfully when moderator has authority", async () => {
1208 const { users, memberships, roles, rolePermissions, posts } = await import("@atbb/db");
1209 const { eq } = await import("drizzle-orm");
1210
1211 // Use unique DIDs for this test
1212 const modDid = "did:plc:test-lock-mod";
1213 const authorDid = "did:plc:test-lock-author";
1214
1215 // Insert moderator user
1216 await ctx.db.insert(users).values({
1217 did: modDid,
1218 handle: "lockmod.test",
1219 indexedAt: new Date(),
1220 });
1221
1222 // Insert topic author user
1223 await ctx.db.insert(users).values({
1224 did: authorDid,
1225 handle: "lockauthor.test",
1226 indexedAt: new Date(),
1227 });
1228
1229 // Create moderator role
1230 await ctx.db.insert(roles).values({
1231 did: ctx.config.forumDid,
1232 rkey: "lock-mod-role",
1233 cid: "bafylockmod",
1234 name: "Moderator",
1235 priority: 20,
1236 createdAt: new Date(),
1237 indexedAt: new Date(),
1238 });
1239
1240 // Get moderator role URI
1241 const [modRole] = await ctx.db
1242 .select()
1243 .from(roles)
1244 .where(eq(roles.rkey, "lock-mod-role"))
1245 .limit(1);
1246
1247 // Grant lockTopics permission to moderator role
1248 await ctx.db.insert(rolePermissions).values({
1249 roleId: modRole.id,
1250 permission: "space.atbb.permission.lockTopics",
1251 });
1252
1253 // Insert memberships
1254 const now = new Date();
1255 await ctx.db.insert(memberships).values({
1256 did: modDid,
1257 rkey: "self",
1258 cid: "bafylockmodmem",
1259 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1260 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/${modRole.rkey}`,
1261 joinedAt: now,
1262 createdAt: now,
1263 indexedAt: now,
1264 });
1265
1266 await ctx.db.insert(memberships).values({
1267 did: authorDid,
1268 rkey: "self",
1269 cid: "bafylockauthormem",
1270 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1271 roleUri: null,
1272 joinedAt: now,
1273 createdAt: now,
1274 indexedAt: now,
1275 });
1276
1277 // Insert a topic post (rootPostId = null means it's a topic)
1278 const [topic] = await ctx.db.insert(posts).values({
1279 did: authorDid,
1280 rkey: "3lbktopic",
1281 cid: "bafytopic",
1282 text: "Test topic to be locked",
1283 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1284 boardUri: null,
1285 boardId: null,
1286 rootPostId: null, // This is a topic (root post)
1287 parentPostId: null,
1288 createdAt: now,
1289 indexedAt: now,
1290 }).returning();
1291
1292 // Set mock user to moderator
1293 mockUser = { did: modDid };
1294
1295 // Mock putRecord to return success
1296 mockPutRecord.mockResolvedValueOnce({
1297 data: {
1298 uri: `at://${ctx.config.forumDid}/space.atbb.modAction/test-lock`,
1299 cid: "bafylockaction",
1300 },
1301 });
1302
1303 // POST lock request
1304 const res = await app.request("/api/mod/lock", {
1305 method: "POST",
1306 headers: { "Content-Type": "application/json" },
1307 body: JSON.stringify({
1308 topicId: topic.id.toString(),
1309 reason: "Off-topic discussion",
1310 }),
1311 });
1312
1313 expect(res.status).toBe(200);
1314 const data = await res.json();
1315 expect(data.success).toBe(true);
1316 expect(data.action).toBe("space.atbb.modAction.lock");
1317 expect(data.topicId).toBe(topic.id.toString());
1318 expect(data.uri).toBe(`at://${ctx.config.forumDid}/space.atbb.modAction/test-lock`);
1319 expect(data.cid).toBe("bafylockaction");
1320 expect(data.alreadyActive).toBe(false);
1321
1322 // Verify putRecord was called with correct parameters
1323 expect(mockPutRecord).toHaveBeenCalledWith(
1324 expect.objectContaining({
1325 repo: ctx.config.forumDid,
1326 collection: "space.atbb.modAction",
1327 record: expect.objectContaining({
1328 $type: "space.atbb.modAction",
1329 action: "space.atbb.modAction.lock",
1330 subject: {
1331 post: {
1332 uri: `at://${authorDid}/space.atbb.post/${topic.rkey}`,
1333 cid: topic.cid,
1334 },
1335 },
1336 reason: "Off-topic discussion",
1337 createdBy: modDid,
1338 }),
1339 })
1340 );
1341 });
1342
1343 it("returns 400 when trying to lock a reply post (not root)", async () => {
1344 const { users, posts } = await import("@atbb/db");
1345
1346 // Create author
1347 const authorDid = "did:plc:test-lock-reply-author";
1348 await ctx.db.insert(users).values({
1349 did: authorDid,
1350 handle: "lockreplyauthor.test",
1351 indexedAt: new Date(),
1352 });
1353
1354 const now = new Date();
1355
1356 // Insert a topic first
1357 const [topic] = await ctx.db.insert(posts).values({
1358 did: authorDid,
1359 rkey: "3lbktopicroot",
1360 cid: "bafytopicroot",
1361 text: "Topic root",
1362 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1363 boardUri: null,
1364 boardId: null,
1365 rootPostId: null, // This is a topic
1366 parentPostId: null,
1367 createdAt: now,
1368 indexedAt: now,
1369 }).returning();
1370
1371 // Insert a reply post
1372 const [reply] = await ctx.db.insert(posts).values({
1373 did: authorDid,
1374 rkey: "3lbkreply",
1375 cid: "bafyreply",
1376 text: "This is a reply",
1377 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1378 boardUri: null,
1379 boardId: null,
1380 rootPostId: topic.id, // This is a reply (has rootPostId)
1381 parentPostId: topic.id,
1382 createdAt: now,
1383 indexedAt: now,
1384 }).returning();
1385
1386 mockUser = { did: "did:plc:test-moderator" };
1387
1388 const res = await app.request("/api/mod/lock", {
1389 method: "POST",
1390 headers: { "Content-Type": "application/json" },
1391 body: JSON.stringify({
1392 topicId: reply.id.toString(),
1393 reason: "Testing reply lock",
1394 }),
1395 });
1396
1397 expect(res.status).toBe(400);
1398 const data = await res.json();
1399 expect(data.error).toBe("Can only lock topic posts, not replies");
1400 });
1401
1402 it("returns 404 when topic not found", async () => {
1403 mockUser = { did: "did:plc:test-moderator" };
1404
1405 const res = await app.request("/api/mod/lock", {
1406 method: "POST",
1407 headers: { "Content-Type": "application/json" },
1408 body: JSON.stringify({
1409 topicId: "999999999",
1410 reason: "Testing nonexistent topic",
1411 }),
1412 });
1413
1414 expect(res.status).toBe(404);
1415 const data = await res.json();
1416 expect(data.error).toBe("Topic not found");
1417 });
1418
1419 describe("Authorization", () => {
1420 it("returns 401 when not authenticated", async () => {
1421 const { users, memberships, posts } = await import("@atbb/db");
1422
1423 // Create topic post to avoid 404 (matches cleanup pattern did:plc:test-%)
1424 const authorDid = "did:plc:test-lock-auth";
1425 await ctx.db.insert(users).values({
1426 did: authorDid,
1427 handle: "lockauth.test",
1428 indexedAt: new Date(),
1429 }).onConflictDoNothing();
1430
1431 await ctx.db.insert(memberships).values({
1432 did: authorDid,
1433 rkey: "self",
1434 cid: "bafylockauth",
1435 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1436 roleUri: null,
1437 joinedAt: new Date(),
1438 createdAt: new Date(),
1439 indexedAt: new Date(),
1440 }).onConflictDoNothing();
1441
1442 const [topic] = await ctx.db.insert(posts).values({
1443 did: authorDid,
1444 rkey: "3lbklockauth",
1445 cid: "bafylockauth",
1446 text: "Test topic for auth",
1447 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1448 boardUri: null,
1449 boardId: null,
1450 rootPostId: null,
1451 parentPostId: null,
1452 createdAt: new Date(),
1453 indexedAt: new Date(),
1454 }).returning();
1455
1456 // Mock requireAuth to return 401
1457 const { requireAuth } = await import("../../middleware/auth.js");
1458 const mockRequireAuth = requireAuth as any;
1459 mockRequireAuth.mockImplementation(() => async (c: any) => {
1460 return c.json({ error: "Unauthorized" }, 401);
1461 });
1462
1463 const testApp = new Hono<{ Variables: Variables }>().route("/api/mod", createModRoutes(ctx));
1464
1465 const res = await testApp.request("/api/mod/lock", {
1466 method: "POST",
1467 headers: { "Content-Type": "application/json" },
1468 body: JSON.stringify({
1469 topicId: String(topic.id),
1470 reason: "Test reason",
1471 }),
1472 });
1473
1474 expect(res.status).toBe(401);
1475
1476 // Restore default mock for subsequent tests
1477 mockRequireAuth.mockImplementation(() => async (c: any, next: any) => {
1478 c.set("user", mockUser);
1479 await next();
1480 });
1481 });
1482
1483 it("returns 403 when user lacks lockTopics permission", async () => {
1484 const { users, memberships, posts } = await import("@atbb/db");
1485 const { requirePermission } = await import("../../middleware/permissions.js");
1486
1487 // Create topic post to avoid 404 (matches cleanup pattern did:plc:test-%)
1488 const authorDid = "did:plc:test-lock-perm";
1489 await ctx.db.insert(users).values({
1490 did: authorDid,
1491 handle: "lockperm.test",
1492 indexedAt: new Date(),
1493 }).onConflictDoNothing();
1494
1495 await ctx.db.insert(memberships).values({
1496 did: authorDid,
1497 rkey: "self",
1498 cid: "bafylockperm",
1499 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1500 roleUri: null,
1501 joinedAt: new Date(),
1502 createdAt: new Date(),
1503 indexedAt: new Date(),
1504 }).onConflictDoNothing();
1505
1506 const [topic] = await ctx.db.insert(posts).values({
1507 did: authorDid,
1508 rkey: "3lbklockperm",
1509 cid: "bafylockperm",
1510 text: "Test topic for permission",
1511 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1512 boardUri: null,
1513 boardId: null,
1514 rootPostId: null,
1515 parentPostId: null,
1516 createdAt: new Date(),
1517 indexedAt: new Date(),
1518 }).returning();
1519
1520 // Mock requirePermission to deny access
1521 const mockRequirePermission = requirePermission as any;
1522 mockRequirePermission.mockImplementation(() => async (c: any) => {
1523 return c.json({ error: "Forbidden" }, 403);
1524 });
1525
1526 const testApp = new Hono<{ Variables: Variables }>().route("/api/mod", createModRoutes(ctx));
1527
1528 const res = await testApp.request("/api/mod/lock", {
1529 method: "POST",
1530 headers: { "Content-Type": "application/json" },
1531 body: JSON.stringify({
1532 topicId: String(topic.id),
1533 reason: "Test reason",
1534 }),
1535 });
1536
1537 expect(res.status).toBe(403);
1538
1539 // Restore default mock for subsequent tests
1540 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => {
1541 await next();
1542 });
1543 });
1544 });
1545
1546 describe("Input Validation", () => {
1547 beforeEach(() => {
1548 // Reset mockUser to valid user for these tests
1549 mockUser = { did: "did:plc:test-moderator" };
1550 });
1551
1552 it("returns 400 for malformed JSON", async () => {
1553 const res = await app.request("/api/mod/lock", {
1554 method: "POST",
1555 headers: { "Content-Type": "application/json" },
1556 body: "{ invalid json }",
1557 });
1558
1559 expect(res.status).toBe(400);
1560 const data = await res.json();
1561 expect(data.error).toBe("Invalid JSON in request body");
1562 });
1563
1564 it("returns 400 for invalid topicId format (non-numeric)", async () => {
1565 const res = await app.request("/api/mod/lock", {
1566 method: "POST",
1567 headers: { "Content-Type": "application/json" },
1568 body: JSON.stringify({
1569 topicId: "not-a-number",
1570 reason: "Test reason",
1571 }),
1572 });
1573
1574 expect(res.status).toBe(400);
1575 const data = await res.json();
1576 expect(data.error).toBe("Invalid topic ID format");
1577 });
1578
1579 it("returns 400 for missing reason field", async () => {
1580 const res = await app.request("/api/mod/lock", {
1581 method: "POST",
1582 headers: { "Content-Type": "application/json" },
1583 body: JSON.stringify({
1584 topicId: "123456",
1585 // reason field missing
1586 }),
1587 });
1588
1589 expect(res.status).toBe(400);
1590 const data = await res.json();
1591 expect(data.error).toBe("Reason is required and must be a string");
1592 });
1593
1594 it("returns 400 for empty reason (whitespace only)", async () => {
1595 const res = await app.request("/api/mod/lock", {
1596 method: "POST",
1597 headers: { "Content-Type": "application/json" },
1598 body: JSON.stringify({
1599 topicId: "123456",
1600 reason: " ",
1601 }),
1602 });
1603
1604 expect(res.status).toBe(400);
1605 const data = await res.json();
1606 expect(data.error).toBe("Reason is required and must not be empty");
1607 });
1608 });
1609
1610 describe("Business Logic", () => {
1611 beforeEach(() => {
1612 mockUser = { did: "did:plc:test-moderator" };
1613 });
1614
1615 it("returns 200 with alreadyActive: true when already locked (idempotency)", async () => {
1616 const { users, posts, forums, modActions } = await import("@atbb/db");
1617 const { eq } = await import("drizzle-orm");
1618
1619 // Create author
1620 const authorDid = "did:plc:test-lock-already-locked";
1621 await ctx.db.insert(users).values({
1622 did: authorDid,
1623 handle: "alreadylocked.test",
1624 indexedAt: new Date(),
1625 }).onConflictDoNothing();
1626
1627 const now = new Date();
1628
1629 // Insert a topic
1630 const [topic] = await ctx.db.insert(posts).values({
1631 did: authorDid,
1632 rkey: "3lbklocked",
1633 cid: "bafylocked",
1634 text: "Already locked topic",
1635 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1636 boardUri: null,
1637 boardId: null,
1638 rootPostId: null,
1639 parentPostId: null,
1640 createdAt: now,
1641 indexedAt: now,
1642 }).returning();
1643
1644 // Get forum ID
1645 const [forum] = await ctx.db
1646 .select()
1647 .from(forums)
1648 .where(eq(forums.did, ctx.config.forumDid))
1649 .limit(1);
1650
1651 // Insert existing lock action
1652 const topicUri = `at://${authorDid}/space.atbb.post/${topic.rkey}`;
1653 await ctx.db.insert(modActions).values({
1654 did: ctx.config.forumDid,
1655 rkey: "existing-lock",
1656 cid: "bafyexistlock",
1657 action: "space.atbb.modAction.lock",
1658 subjectDid: null,
1659 subjectPostUri: topicUri,
1660 forumId: forum.id,
1661 reason: "Previously locked",
1662 createdBy: "did:plc:previous-mod",
1663 expiresAt: null,
1664 createdAt: new Date(now.getTime() - 1000),
1665 indexedAt: new Date(now.getTime() - 1000),
1666 });
1667
1668 // Attempt to lock again
1669 const res = await app.request("/api/mod/lock", {
1670 method: "POST",
1671 headers: { "Content-Type": "application/json" },
1672 body: JSON.stringify({
1673 topicId: topic.id.toString(),
1674 reason: "Trying to lock again",
1675 }),
1676 });
1677
1678 expect(res.status).toBe(200);
1679 const data = await res.json();
1680 expect(data.success).toBe(true);
1681 expect(data.alreadyActive).toBe(true);
1682 expect(data.uri).toBeNull();
1683 expect(data.cid).toBeNull();
1684
1685 // Verify putRecord was NOT called (no duplicate action written)
1686 expect(mockPutRecord).not.toHaveBeenCalled();
1687 });
1688 });
1689
1690 describe("Infrastructure Errors", () => {
1691 beforeEach(() => {
1692 mockUser = { did: "did:plc:test-moderator" };
1693 });
1694
1695 it("returns 500 when ForumAgent not available", async () => {
1696 const { users, posts } = await import("@atbb/db");
1697
1698 // Create author
1699 const authorDid = "did:plc:test-lock-no-agent";
1700 await ctx.db.insert(users).values({
1701 did: authorDid,
1702 handle: "locknoagent.test",
1703 indexedAt: new Date(),
1704 }).onConflictDoNothing();
1705
1706 const now = new Date();
1707
1708 // Insert a topic
1709 const [topic] = await ctx.db.insert(posts).values({
1710 did: authorDid,
1711 rkey: "3lbknoagent",
1712 cid: "bafynoagent",
1713 text: "Test topic",
1714 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1715 boardUri: null,
1716 boardId: null,
1717 rootPostId: null,
1718 parentPostId: null,
1719 createdAt: now,
1720 indexedAt: now,
1721 }).returning();
1722
1723 // Remove ForumAgent
1724 ctx.forumAgent = undefined as any;
1725
1726 const res = await app.request("/api/mod/lock", {
1727 method: "POST",
1728 headers: { "Content-Type": "application/json" },
1729 body: JSON.stringify({
1730 topicId: topic.id.toString(),
1731 reason: "Test reason",
1732 }),
1733 });
1734
1735 expect(res.status).toBe(500);
1736 const data = await res.json();
1737 expect(data.error).toBe("Forum agent not available. Server configuration issue.");
1738
1739 // Restore ForumAgent for other tests
1740 ctx.forumAgent = {
1741 getAgent: () => ({
1742 com: {
1743 atproto: {
1744 repo: {
1745 putRecord: mockPutRecord,
1746 },
1747 },
1748 },
1749 }),
1750 } as any;
1751 });
1752
1753 it("returns 503 when ForumAgent not authenticated", async () => {
1754 const { users, posts } = await import("@atbb/db");
1755
1756 // Create author
1757 const authorDid = "did:plc:test-lock-no-auth";
1758 await ctx.db.insert(users).values({
1759 did: authorDid,
1760 handle: "locknoauth.test",
1761 indexedAt: new Date(),
1762 }).onConflictDoNothing();
1763
1764 const now = new Date();
1765
1766 // Insert a topic
1767 const [topic] = await ctx.db.insert(posts).values({
1768 did: authorDid,
1769 rkey: "3lbknoauth",
1770 cid: "bafynoauth",
1771 text: "Test topic",
1772 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1773 boardUri: null,
1774 boardId: null,
1775 rootPostId: null,
1776 parentPostId: null,
1777 createdAt: now,
1778 indexedAt: now,
1779 }).returning();
1780
1781 // Mock getAgent to return null (not authenticated)
1782 const originalAgent = ctx.forumAgent;
1783 ctx.forumAgent = {
1784 getAgent: () => null,
1785 } as any;
1786
1787 const res = await app.request("/api/mod/lock", {
1788 method: "POST",
1789 headers: { "Content-Type": "application/json" },
1790 body: JSON.stringify({
1791 topicId: topic.id.toString(),
1792 reason: "Test reason",
1793 }),
1794 });
1795
1796 expect(res.status).toBe(503);
1797 const data = await res.json();
1798 expect(data.error).toBe("Forum agent not authenticated. Please try again later.");
1799
1800 // Restore original agent
1801 ctx.forumAgent = originalAgent;
1802 });
1803
1804 it("returns 503 for network errors writing to PDS", async () => {
1805 const { users, posts } = await import("@atbb/db");
1806
1807 // Create author
1808 const authorDid = "did:plc:test-lock-network-error";
1809 await ctx.db.insert(users).values({
1810 did: authorDid,
1811 handle: "locknetwork.test",
1812 indexedAt: new Date(),
1813 }).onConflictDoNothing();
1814
1815 const now = new Date();
1816
1817 // Insert a topic
1818 const [topic] = await ctx.db.insert(posts).values({
1819 did: authorDid,
1820 rkey: "3lbknetwork",
1821 cid: "bafynetwork",
1822 text: "Test topic",
1823 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1824 boardUri: null,
1825 boardId: null,
1826 rootPostId: null,
1827 parentPostId: null,
1828 createdAt: now,
1829 indexedAt: now,
1830 }).returning();
1831
1832 // Mock putRecord to throw network error
1833 mockPutRecord.mockRejectedValueOnce(new Error("fetch failed"));
1834
1835 const res = await app.request("/api/mod/lock", {
1836 method: "POST",
1837 headers: { "Content-Type": "application/json" },
1838 body: JSON.stringify({
1839 topicId: topic.id.toString(),
1840 reason: "Test reason",
1841 }),
1842 });
1843
1844 expect(res.status).toBe(503);
1845 const data = await res.json();
1846 expect(data.error).toBe("Unable to reach external service. Please try again later.");
1847 });
1848
1849 it("returns 500 for unexpected errors writing to PDS", async () => {
1850 const { users, posts } = await import("@atbb/db");
1851
1852 // Create author
1853 const authorDid = "did:plc:test-lock-server-error";
1854 await ctx.db.insert(users).values({
1855 did: authorDid,
1856 handle: "lockserver.test",
1857 indexedAt: new Date(),
1858 }).onConflictDoNothing();
1859
1860 const now = new Date();
1861
1862 // Insert a topic
1863 const [topic] = await ctx.db.insert(posts).values({
1864 did: authorDid,
1865 rkey: "3lbkserver",
1866 cid: "bafyserver",
1867 text: "Test topic",
1868 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1869 boardUri: null,
1870 boardId: null,
1871 rootPostId: null,
1872 parentPostId: null,
1873 createdAt: now,
1874 indexedAt: now,
1875 }).returning();
1876
1877 // Mock putRecord to throw unexpected error (not network error)
1878 mockPutRecord.mockRejectedValueOnce(new Error("Unexpected write error"));
1879
1880 const res = await app.request("/api/mod/lock", {
1881 method: "POST",
1882 headers: { "Content-Type": "application/json" },
1883 body: JSON.stringify({
1884 topicId: topic.id.toString(),
1885 reason: "Test reason",
1886 }),
1887 });
1888
1889 expect(res.status).toBe(500);
1890 const data = await res.json();
1891 expect(data.error).toBe("Failed to record moderation action. Please contact support if this persists.");
1892 });
1893
1894 it("returns 503 when post query fails (database error)", async () => {
1895 // Mock console.error to suppress error output during test
1896 const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
1897
1898 // Mock database query to throw error on first call (post query)
1899 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => {
1900 throw new Error("Database connection lost");
1901 });
1902
1903 const res = await app.request("/api/mod/lock", {
1904 method: "POST",
1905 headers: { "Content-Type": "application/json" },
1906 body: JSON.stringify({
1907 topicId: "999999999",
1908 reason: "Test reason",
1909 }),
1910 });
1911
1912 expect(res.status).toBe(503);
1913 const data = await res.json();
1914 expect(data.error).toBe("Database temporarily unavailable. Please try again later.");
1915
1916 // Restore spies
1917 consoleErrorSpy.mockRestore();
1918 dbSelectSpy.mockRestore();
1919 });
1920 });
1921 });
1922
1923 describe("DELETE /api/mod/lock/:topicId", () => {
1924 it("unlocks topic successfully when moderator has authority", async () => {
1925 const { users, memberships, roles, rolePermissions, posts, forums, modActions } = await import("@atbb/db");
1926 const { eq } = await import("drizzle-orm");
1927
1928 // Use unique DIDs for this test
1929 const modDid = "did:plc:test-unlock-mod";
1930 const authorDid = "did:plc:test-unlock-author";
1931
1932 // Insert moderator user
1933 await ctx.db.insert(users).values({
1934 did: modDid,
1935 handle: "unlockmod.test",
1936 indexedAt: new Date(),
1937 });
1938
1939 // Insert topic author user
1940 await ctx.db.insert(users).values({
1941 did: authorDid,
1942 handle: "unlockauthor.test",
1943 indexedAt: new Date(),
1944 });
1945
1946 // Create moderator role
1947 await ctx.db.insert(roles).values({
1948 did: ctx.config.forumDid,
1949 rkey: "unlock-mod-role",
1950 cid: "bafyunlockmod",
1951 name: "Moderator",
1952 priority: 20,
1953 createdAt: new Date(),
1954 indexedAt: new Date(),
1955 });
1956
1957 // Get moderator role URI
1958 const [modRole] = await ctx.db
1959 .select()
1960 .from(roles)
1961 .where(eq(roles.rkey, "unlock-mod-role"))
1962 .limit(1);
1963
1964 // Grant lockTopics permission to moderator role
1965 await ctx.db.insert(rolePermissions).values({
1966 roleId: modRole.id,
1967 permission: "space.atbb.permission.lockTopics",
1968 });
1969
1970 // Insert memberships
1971 const now = new Date();
1972 await ctx.db.insert(memberships).values({
1973 did: modDid,
1974 rkey: "self",
1975 cid: "bafyunlockmodmem",
1976 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1977 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/${modRole.rkey}`,
1978 joinedAt: now,
1979 createdAt: now,
1980 indexedAt: now,
1981 });
1982
1983 await ctx.db.insert(memberships).values({
1984 did: authorDid,
1985 rkey: "self",
1986 cid: "bafyunlockauthormem",
1987 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
1988 roleUri: null,
1989 joinedAt: now,
1990 createdAt: now,
1991 indexedAt: now,
1992 });
1993
1994 // Insert a topic post
1995 const [topic] = await ctx.db.insert(posts).values({
1996 did: authorDid,
1997 rkey: "3lbkunlocktopic",
1998 cid: "bafyunlocktopic",
1999 text: "Test topic to be unlocked",
2000 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2001 boardUri: null,
2002 boardId: null,
2003 rootPostId: null, // This is a topic
2004 parentPostId: null,
2005 createdAt: now,
2006 indexedAt: now,
2007 }).returning();
2008
2009 // Get forum ID
2010 const [forum] = await ctx.db
2011 .select()
2012 .from(forums)
2013 .where(eq(forums.did, ctx.config.forumDid))
2014 .limit(1);
2015
2016 // Insert existing lock action so topic is currently locked
2017 const topicUri = `at://${authorDid}/space.atbb.post/${topic.rkey}`;
2018 await ctx.db.insert(modActions).values({
2019 did: ctx.config.forumDid,
2020 rkey: "previous-lock",
2021 cid: "bafyprevlock",
2022 action: "space.atbb.modAction.lock",
2023 subjectDid: null,
2024 subjectPostUri: topicUri,
2025 forumId: forum.id,
2026 reason: "Previously locked",
2027 createdBy: "did:plc:previous-mod",
2028 expiresAt: null,
2029 createdAt: new Date(now.getTime() - 1000),
2030 indexedAt: new Date(now.getTime() - 1000),
2031 });
2032
2033 // Set mock user to moderator
2034 mockUser = { did: modDid };
2035
2036 // Mock putRecord to return success
2037 mockPutRecord.mockResolvedValueOnce({
2038 data: {
2039 uri: `at://${ctx.config.forumDid}/space.atbb.modAction/test-unlock`,
2040 cid: "bafyunlockaction",
2041 },
2042 });
2043
2044 // DELETE unlock request
2045 const res = await app.request(`/api/mod/lock/${topic.id}`, {
2046 method: "DELETE",
2047 headers: { "Content-Type": "application/json" },
2048 body: JSON.stringify({
2049 reason: "Discussion resumed",
2050 }),
2051 });
2052
2053 expect(res.status).toBe(200);
2054 const data = await res.json();
2055 expect(data.success).toBe(true);
2056 expect(data.action).toBe("space.atbb.modAction.unlock");
2057 expect(data.topicId).toBe(topic.id.toString());
2058 expect(data.uri).toBe(`at://${ctx.config.forumDid}/space.atbb.modAction/test-unlock`);
2059 expect(data.cid).toBe("bafyunlockaction");
2060 expect(data.alreadyActive).toBe(false);
2061
2062 // Verify putRecord was called with correct parameters
2063 expect(mockPutRecord).toHaveBeenCalledWith(
2064 expect.objectContaining({
2065 repo: ctx.config.forumDid,
2066 collection: "space.atbb.modAction",
2067 record: expect.objectContaining({
2068 $type: "space.atbb.modAction",
2069 action: "space.atbb.modAction.unlock",
2070 subject: {
2071 post: {
2072 uri: topicUri,
2073 cid: topic.cid,
2074 },
2075 },
2076 reason: "Discussion resumed",
2077 createdBy: modDid,
2078 }),
2079 })
2080 );
2081 });
2082
2083 describe("Authorization", () => {
2084 it("returns 401 when not authenticated", async () => {
2085 const { users, memberships, posts } = await import("@atbb/db");
2086
2087 // Create topic post to avoid 404 (matches cleanup pattern did:plc:test-%)
2088 const authorDid = "did:plc:test-unlock-auth";
2089 await ctx.db.insert(users).values({
2090 did: authorDid,
2091 handle: "unlockauth.test",
2092 indexedAt: new Date(),
2093 }).onConflictDoNothing();
2094
2095 await ctx.db.insert(memberships).values({
2096 did: authorDid,
2097 rkey: "self",
2098 cid: "bafyunlockauth",
2099 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2100 roleUri: null,
2101 joinedAt: new Date(),
2102 createdAt: new Date(),
2103 indexedAt: new Date(),
2104 }).onConflictDoNothing();
2105
2106 const [topic] = await ctx.db.insert(posts).values({
2107 did: authorDid,
2108 rkey: "3lbkunlockauth",
2109 cid: "bafyunlockauth",
2110 text: "Test topic for auth",
2111 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2112 boardUri: null,
2113 boardId: null,
2114 rootPostId: null,
2115 parentPostId: null,
2116 createdAt: new Date(),
2117 indexedAt: new Date(),
2118 }).returning();
2119
2120 // Mock requireAuth to return 401
2121 const { requireAuth } = await import("../../middleware/auth.js");
2122 const mockRequireAuth = requireAuth as any;
2123 mockRequireAuth.mockImplementation(() => async (c: any) => {
2124 return c.json({ error: "Unauthorized" }, 401);
2125 });
2126
2127 const testApp = new Hono<{ Variables: Variables }>().route("/api/mod", createModRoutes(ctx));
2128
2129 const res = await testApp.request(`/api/mod/lock/${topic.id}`, {
2130 method: "DELETE",
2131 headers: { "Content-Type": "application/json" },
2132 body: JSON.stringify({
2133 reason: "Test reason",
2134 }),
2135 });
2136
2137 expect(res.status).toBe(401);
2138
2139 // Restore default mock for subsequent tests
2140 mockRequireAuth.mockImplementation(() => async (c: any, next: any) => {
2141 c.set("user", mockUser);
2142 await next();
2143 });
2144 });
2145
2146 it("returns 403 when user lacks lockTopics permission", async () => {
2147 const { users, memberships, posts } = await import("@atbb/db");
2148 const { requirePermission } = await import("../../middleware/permissions.js");
2149
2150 // Create topic post to avoid 404 (matches cleanup pattern did:plc:test-%)
2151 const authorDid = "did:plc:test-unlock-perm";
2152 await ctx.db.insert(users).values({
2153 did: authorDid,
2154 handle: "unlockperm.test",
2155 indexedAt: new Date(),
2156 }).onConflictDoNothing();
2157
2158 await ctx.db.insert(memberships).values({
2159 did: authorDid,
2160 rkey: "self",
2161 cid: "bafyunlockperm",
2162 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2163 roleUri: null,
2164 joinedAt: new Date(),
2165 createdAt: new Date(),
2166 indexedAt: new Date(),
2167 }).onConflictDoNothing();
2168
2169 const [topic] = await ctx.db.insert(posts).values({
2170 did: authorDid,
2171 rkey: "3lbkunlockperm",
2172 cid: "bafyunlockperm",
2173 text: "Test topic for permission",
2174 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2175 boardUri: null,
2176 boardId: null,
2177 rootPostId: null,
2178 parentPostId: null,
2179 createdAt: new Date(),
2180 indexedAt: new Date(),
2181 }).returning();
2182
2183 // Mock requirePermission to deny access
2184 const mockRequirePermission = requirePermission as any;
2185 mockRequirePermission.mockImplementation(() => async (c: any) => {
2186 return c.json({ error: "Forbidden" }, 403);
2187 });
2188
2189 const testApp = new Hono<{ Variables: Variables }>().route("/api/mod", createModRoutes(ctx));
2190
2191 const res = await testApp.request(`/api/mod/lock/${topic.id}`, {
2192 method: "DELETE",
2193 headers: { "Content-Type": "application/json" },
2194 body: JSON.stringify({
2195 reason: "Test reason",
2196 }),
2197 });
2198
2199 expect(res.status).toBe(403);
2200
2201 // Restore default mock for subsequent tests
2202 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => {
2203 await next();
2204 });
2205 });
2206 });
2207
2208 describe("Input Validation", () => {
2209 beforeEach(() => {
2210 mockUser = { did: "did:plc:test-moderator" };
2211 });
2212
2213 it("returns 400 for invalid topicId format", async () => {
2214 const res = await app.request("/api/mod/lock/not-a-number", {
2215 method: "DELETE",
2216 headers: { "Content-Type": "application/json" },
2217 body: JSON.stringify({
2218 reason: "Test reason",
2219 }),
2220 });
2221
2222 expect(res.status).toBe(400);
2223 const data = await res.json();
2224 expect(data.error).toBe("Invalid topic ID format");
2225 });
2226
2227 it("returns 400 for missing reason field", async () => {
2228 const res = await app.request("/api/mod/lock/123456", {
2229 method: "DELETE",
2230 headers: { "Content-Type": "application/json" },
2231 body: JSON.stringify({
2232 // reason field missing
2233 }),
2234 });
2235
2236 expect(res.status).toBe(400);
2237 const data = await res.json();
2238 expect(data.error).toBe("Reason is required and must be a string");
2239 });
2240
2241 it("returns 400 for empty reason (whitespace only)", async () => {
2242 const res = await app.request("/api/mod/lock/123456", {
2243 method: "DELETE",
2244 headers: { "Content-Type": "application/json" },
2245 body: JSON.stringify({
2246 reason: " ",
2247 }),
2248 });
2249
2250 expect(res.status).toBe(400);
2251 const data = await res.json();
2252 expect(data.error).toBe("Reason is required and must not be empty");
2253 });
2254 });
2255
2256 describe("Business Logic", () => {
2257 beforeEach(() => {
2258 mockUser = { did: "did:plc:test-moderator" };
2259 });
2260
2261 it("returns 404 when topic not found", async () => {
2262 const res = await app.request("/api/mod/lock/999999999", {
2263 method: "DELETE",
2264 headers: { "Content-Type": "application/json" },
2265 body: JSON.stringify({
2266 reason: "Testing nonexistent topic",
2267 }),
2268 });
2269
2270 expect(res.status).toBe(404);
2271 const data = await res.json();
2272 expect(data.error).toBe("Topic not found");
2273 });
2274
2275 it("returns 200 with alreadyActive: true when already unlocked (idempotency)", async () => {
2276 const { users, posts } = await import("@atbb/db");
2277
2278 // Create author
2279 const authorDid = "did:plc:test-unlock-already-unlocked";
2280 await ctx.db.insert(users).values({
2281 did: authorDid,
2282 handle: "alreadyunlocked.test",
2283 indexedAt: new Date(),
2284 }).onConflictDoNothing();
2285
2286 const now = new Date();
2287
2288 // Insert a topic (never locked, or previously unlocked)
2289 const [topic] = await ctx.db.insert(posts).values({
2290 did: authorDid,
2291 rkey: "3lbkunlocked",
2292 cid: "bafyunlocked",
2293 text: "Already unlocked topic",
2294 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2295 boardUri: null,
2296 boardId: null,
2297 rootPostId: null,
2298 parentPostId: null,
2299 createdAt: now,
2300 indexedAt: now,
2301 }).returning();
2302
2303 // Attempt to unlock (no lock exists)
2304 const res = await app.request(`/api/mod/lock/${topic.id}`, {
2305 method: "DELETE",
2306 headers: { "Content-Type": "application/json" },
2307 body: JSON.stringify({
2308 reason: "Trying to unlock again",
2309 }),
2310 });
2311
2312 expect(res.status).toBe(200);
2313 const data = await res.json();
2314 expect(data.success).toBe(true);
2315 expect(data.alreadyActive).toBe(true);
2316 expect(data.uri).toBeNull();
2317 expect(data.cid).toBeNull();
2318
2319 // Verify putRecord was NOT called (no duplicate action written)
2320 expect(mockPutRecord).not.toHaveBeenCalled();
2321 });
2322 });
2323
2324 describe("Infrastructure Errors", () => {
2325 beforeEach(() => {
2326 mockUser = { did: "did:plc:test-moderator" };
2327 });
2328
2329 it("returns 503 when post query fails (database error)", async () => {
2330 // Mock console.error to suppress error output during test
2331 const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
2332
2333 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => {
2334 throw new Error("Database connection lost");
2335 });
2336
2337 const res = await app.request("/api/mod/lock/999999999", {
2338 method: "DELETE",
2339 headers: { "Content-Type": "application/json" },
2340 body: JSON.stringify({
2341 reason: "Test reason",
2342 }),
2343 });
2344
2345 expect(res.status).toBe(503);
2346 const data = await res.json();
2347 expect(data.error).toBe("Database temporarily unavailable. Please try again later.");
2348
2349 consoleErrorSpy.mockRestore();
2350 dbSelectSpy.mockRestore();
2351 });
2352
2353 it("returns 500 when ForumAgent not available", async () => {
2354 const { users, posts, forums, modActions } = await import("@atbb/db");
2355 const { eq } = await import("drizzle-orm");
2356
2357 // Create author
2358 const authorDid = "did:plc:test-unlock-no-agent";
2359 await ctx.db.insert(users).values({
2360 did: authorDid,
2361 handle: "unlocknoagent.test",
2362 indexedAt: new Date(),
2363 }).onConflictDoNothing();
2364
2365 const now = new Date();
2366
2367 // Insert a topic
2368 const [topic] = await ctx.db.insert(posts).values({
2369 did: authorDid,
2370 rkey: "3lbkunlocknoagent",
2371 cid: "bafyunlocknoagent",
2372 text: "Test topic",
2373 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2374 boardUri: null,
2375 boardId: null,
2376 rootPostId: null,
2377 parentPostId: null,
2378 createdAt: now,
2379 indexedAt: now,
2380 }).returning();
2381
2382 // Get forum ID and insert lock action so topic is locked
2383 const [forum] = await ctx.db
2384 .select()
2385 .from(forums)
2386 .where(eq(forums.did, ctx.config.forumDid))
2387 .limit(1);
2388
2389 const topicUri = `at://${authorDid}/space.atbb.post/${topic.rkey}`;
2390 await ctx.db.insert(modActions).values({
2391 did: ctx.config.forumDid,
2392 rkey: "unlock-no-agent-lock",
2393 cid: "bafyunlocknoagentlock",
2394 action: "space.atbb.modAction.lock",
2395 subjectDid: null,
2396 subjectPostUri: topicUri,
2397 forumId: forum.id,
2398 reason: "Locked",
2399 createdBy: "did:plc:previous-mod",
2400 expiresAt: null,
2401 createdAt: new Date(now.getTime() - 1000),
2402 indexedAt: new Date(now.getTime() - 1000),
2403 });
2404
2405 // Remove ForumAgent
2406 ctx.forumAgent = undefined as any;
2407
2408 const res = await app.request(`/api/mod/lock/${topic.id}`, {
2409 method: "DELETE",
2410 headers: { "Content-Type": "application/json" },
2411 body: JSON.stringify({
2412 reason: "Test reason",
2413 }),
2414 });
2415
2416 expect(res.status).toBe(500);
2417 const data = await res.json();
2418 expect(data.error).toBe("Forum agent not available. Server configuration issue.");
2419
2420 // Restore ForumAgent for other tests
2421 ctx.forumAgent = {
2422 getAgent: () => ({
2423 com: {
2424 atproto: {
2425 repo: {
2426 putRecord: mockPutRecord,
2427 },
2428 },
2429 },
2430 }),
2431 } as any;
2432 });
2433
2434 it("returns 503 when ForumAgent not authenticated", async () => {
2435 const { users, posts, forums, modActions } = await import("@atbb/db");
2436 const { eq } = await import("drizzle-orm");
2437
2438 // Create author
2439 const authorDid = "did:plc:test-unlock-no-auth";
2440 await ctx.db.insert(users).values({
2441 did: authorDid,
2442 handle: "unlocknoauth.test",
2443 indexedAt: new Date(),
2444 }).onConflictDoNothing();
2445
2446 const now = new Date();
2447
2448 // Insert a topic
2449 const [topic] = await ctx.db.insert(posts).values({
2450 did: authorDid,
2451 rkey: "3lbkunlocknoauth",
2452 cid: "bafyunlocknoauth",
2453 text: "Test topic",
2454 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2455 boardUri: null,
2456 boardId: null,
2457 rootPostId: null,
2458 parentPostId: null,
2459 createdAt: now,
2460 indexedAt: now,
2461 }).returning();
2462
2463 // Get forum ID and insert lock action
2464 const [forum] = await ctx.db
2465 .select()
2466 .from(forums)
2467 .where(eq(forums.did, ctx.config.forumDid))
2468 .limit(1);
2469
2470 const topicUri = `at://${authorDid}/space.atbb.post/${topic.rkey}`;
2471 await ctx.db.insert(modActions).values({
2472 did: ctx.config.forumDid,
2473 rkey: "unlock-no-auth-lock",
2474 cid: "bafyunlocknoauthlock",
2475 action: "space.atbb.modAction.lock",
2476 subjectDid: null,
2477 subjectPostUri: topicUri,
2478 forumId: forum.id,
2479 reason: "Locked",
2480 createdBy: "did:plc:previous-mod",
2481 expiresAt: null,
2482 createdAt: new Date(now.getTime() - 1000),
2483 indexedAt: new Date(now.getTime() - 1000),
2484 });
2485
2486 // Mock getAgent to return null (not authenticated)
2487 const originalAgent = ctx.forumAgent;
2488 ctx.forumAgent = {
2489 getAgent: () => null,
2490 } as any;
2491
2492 const res = await app.request(`/api/mod/lock/${topic.id}`, {
2493 method: "DELETE",
2494 headers: { "Content-Type": "application/json" },
2495 body: JSON.stringify({
2496 reason: "Test reason",
2497 }),
2498 });
2499
2500 expect(res.status).toBe(503);
2501 const data = await res.json();
2502 expect(data.error).toBe("Forum agent not authenticated. Please try again later.");
2503
2504 // Restore original agent
2505 ctx.forumAgent = originalAgent;
2506 });
2507
2508 it("returns 503 for network errors writing to PDS", async () => {
2509 const { users, posts, forums, modActions } = await import("@atbb/db");
2510 const { eq } = await import("drizzle-orm");
2511
2512 // Create author
2513 const authorDid = "did:plc:test-unlock-network-error";
2514 await ctx.db.insert(users).values({
2515 did: authorDid,
2516 handle: "unlocknetwork.test",
2517 indexedAt: new Date(),
2518 }).onConflictDoNothing();
2519
2520 const now = new Date();
2521
2522 // Insert a topic
2523 const [topic] = await ctx.db.insert(posts).values({
2524 did: authorDid,
2525 rkey: "3lbkunlocknetwork",
2526 cid: "bafyunlocknetwork",
2527 text: "Test topic",
2528 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2529 boardUri: null,
2530 boardId: null,
2531 rootPostId: null,
2532 parentPostId: null,
2533 createdAt: now,
2534 indexedAt: now,
2535 }).returning();
2536
2537 // Get forum ID and insert lock action
2538 const [forum] = await ctx.db
2539 .select()
2540 .from(forums)
2541 .where(eq(forums.did, ctx.config.forumDid))
2542 .limit(1);
2543
2544 const topicUri = `at://${authorDid}/space.atbb.post/${topic.rkey}`;
2545 await ctx.db.insert(modActions).values({
2546 did: ctx.config.forumDid,
2547 rkey: "unlock-network-lock",
2548 cid: "bafyunlocknetworklock",
2549 action: "space.atbb.modAction.lock",
2550 subjectDid: null,
2551 subjectPostUri: topicUri,
2552 forumId: forum.id,
2553 reason: "Locked",
2554 createdBy: "did:plc:previous-mod",
2555 expiresAt: null,
2556 createdAt: new Date(now.getTime() - 1000),
2557 indexedAt: new Date(now.getTime() - 1000),
2558 });
2559
2560 // Mock putRecord to throw network error
2561 mockPutRecord.mockRejectedValueOnce(new Error("fetch failed"));
2562
2563 const res = await app.request(`/api/mod/lock/${topic.id}`, {
2564 method: "DELETE",
2565 headers: { "Content-Type": "application/json" },
2566 body: JSON.stringify({
2567 reason: "Test reason",
2568 }),
2569 });
2570
2571 expect(res.status).toBe(503);
2572 const data = await res.json();
2573 expect(data.error).toBe("Unable to reach external service. Please try again later.");
2574 });
2575
2576 it("returns 500 for unexpected errors writing to PDS", async () => {
2577 const { users, posts, forums, modActions } = await import("@atbb/db");
2578 const { eq } = await import("drizzle-orm");
2579
2580 // Create author
2581 const authorDid = "did:plc:test-unlock-server-error";
2582 await ctx.db.insert(users).values({
2583 did: authorDid,
2584 handle: "unlockserver.test",
2585 indexedAt: new Date(),
2586 }).onConflictDoNothing();
2587
2588 const now = new Date();
2589
2590 // Insert a topic
2591 const [topic] = await ctx.db.insert(posts).values({
2592 did: authorDid,
2593 rkey: "3lbkunlockserver",
2594 cid: "bafyunlockserver",
2595 text: "Test topic",
2596 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2597 boardUri: null,
2598 boardId: null,
2599 rootPostId: null,
2600 parentPostId: null,
2601 createdAt: now,
2602 indexedAt: now,
2603 }).returning();
2604
2605 // Get forum ID and insert lock action
2606 const [forum] = await ctx.db
2607 .select()
2608 .from(forums)
2609 .where(eq(forums.did, ctx.config.forumDid))
2610 .limit(1);
2611
2612 const topicUri = `at://${authorDid}/space.atbb.post/${topic.rkey}`;
2613 await ctx.db.insert(modActions).values({
2614 did: ctx.config.forumDid,
2615 rkey: "unlock-server-lock",
2616 cid: "bafyunlockserverlock",
2617 action: "space.atbb.modAction.lock",
2618 subjectDid: null,
2619 subjectPostUri: topicUri,
2620 forumId: forum.id,
2621 reason: "Locked",
2622 createdBy: "did:plc:previous-mod",
2623 expiresAt: null,
2624 createdAt: new Date(now.getTime() - 1000),
2625 indexedAt: new Date(now.getTime() - 1000),
2626 });
2627
2628 // Mock putRecord to throw unexpected error (not network error)
2629 mockPutRecord.mockRejectedValueOnce(new Error("Unexpected write error"));
2630
2631 const res = await app.request(`/api/mod/lock/${topic.id}`, {
2632 method: "DELETE",
2633 headers: { "Content-Type": "application/json" },
2634 body: JSON.stringify({
2635 reason: "Test reason",
2636 }),
2637 });
2638
2639 expect(res.status).toBe(500);
2640 const data = await res.json();
2641 expect(data.error).toBe("Failed to record moderation action. Please contact support if this persists.");
2642 });
2643 });
2644 });
2645
2646 describe("POST /api/mod/hide", () => {
2647 it("hides topic post successfully", async () => {
2648 const { users, posts } = await import("@atbb/db");
2649
2650 // Create moderator and member users
2651 const modDid = "did:plc:test-hide-mod";
2652 const memberDid = "did:plc:test-hide-member";
2653
2654 await ctx.db.insert(users).values({
2655 did: modDid,
2656 handle: "hidemod.test",
2657 indexedAt: new Date(),
2658 });
2659
2660 await ctx.db.insert(users).values({
2661 did: memberDid,
2662 handle: "hidemember.test",
2663 indexedAt: new Date(),
2664 });
2665
2666 // Insert topic post
2667 const [topic] = await ctx.db.insert(posts).values({
2668 did: memberDid,
2669 rkey: "3lbkhidetopic",
2670 cid: "bafyhidetopic",
2671 text: "Spam topic",
2672 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2673 boardUri: null,
2674 boardId: null,
2675 rootPostId: null,
2676 parentPostId: null,
2677 createdAt: new Date(),
2678 indexedAt: new Date(),
2679 }).returning();
2680
2681 mockUser = { did: modDid };
2682
2683 mockPutRecord.mockResolvedValueOnce({
2684 data: {
2685 uri: "at://did:plc:forum/space.atbb.modAction/hide123",
2686 cid: "bafyhide",
2687 },
2688 });
2689
2690 const res = await app.request("/api/mod/hide", {
2691 method: "POST",
2692 headers: { "Content-Type": "application/json" },
2693 body: JSON.stringify({
2694 postId: topic.id.toString(),
2695 reason: "Spam content",
2696 }),
2697 });
2698
2699 expect(res.status).toBe(200);
2700 const data = await res.json();
2701 expect(data.success).toBe(true);
2702 expect(data.action).toBe("space.atbb.modAction.delete");
2703 expect(data.postId).toBe(topic.id.toString());
2704 });
2705
2706 it("hides reply post successfully", async () => {
2707 const { users, posts } = await import("@atbb/db");
2708
2709 const modDid = "did:plc:test-hide-reply-mod";
2710 const memberDid = "did:plc:test-hide-reply-member";
2711
2712 await ctx.db.insert(users).values({
2713 did: modDid,
2714 handle: "hidereplymod.test",
2715 indexedAt: new Date(),
2716 });
2717
2718 await ctx.db.insert(users).values({
2719 did: memberDid,
2720 handle: "hidereplymember.test",
2721 indexedAt: new Date(),
2722 });
2723
2724 const now = new Date();
2725
2726 // Insert topic
2727 const [topic] = await ctx.db.insert(posts).values({
2728 did: memberDid,
2729 rkey: "3lbkhidereplytopic",
2730 cid: "bafyhidereplytopic",
2731 text: "Topic",
2732 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2733 boardUri: null,
2734 boardId: null,
2735 rootPostId: null,
2736 parentPostId: null,
2737 createdAt: now,
2738 indexedAt: now,
2739 }).returning();
2740
2741 // Insert reply
2742 const [reply] = await ctx.db.insert(posts).values({
2743 did: memberDid,
2744 rkey: "3lbkhidereply",
2745 cid: "bafyhidereply",
2746 text: "Spam reply",
2747 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2748 boardUri: null,
2749 boardId: null,
2750 rootPostId: topic.id,
2751 parentPostId: topic.id,
2752 createdAt: now,
2753 indexedAt: now,
2754 }).returning();
2755
2756 mockUser = { did: modDid };
2757
2758 mockPutRecord.mockResolvedValueOnce({
2759 data: {
2760 uri: "at://did:plc:forum/space.atbb.modAction/hide456",
2761 cid: "bafyhide2",
2762 },
2763 });
2764
2765 const res = await app.request("/api/mod/hide", {
2766 method: "POST",
2767 headers: { "Content-Type": "application/json" },
2768 body: JSON.stringify({
2769 postId: reply.id.toString(),
2770 reason: "Harassment",
2771 }),
2772 });
2773
2774 expect(res.status).toBe(200);
2775 const data = await res.json();
2776 expect(data.success).toBe(true);
2777 expect(data.action).toBe("space.atbb.modAction.delete");
2778 });
2779
2780 describe("Authorization", () => {
2781 it("returns 401 when not authenticated", async () => {
2782 const { users, memberships, posts } = await import("@atbb/db");
2783
2784 // Create post to avoid 404 (matches cleanup pattern did:plc:test-%)
2785 const authorDid = "did:plc:test-hide-auth";
2786 await ctx.db.insert(users).values({
2787 did: authorDid,
2788 handle: "hideauth.test",
2789 indexedAt: new Date(),
2790 }).onConflictDoNothing();
2791
2792 await ctx.db.insert(memberships).values({
2793 did: authorDid,
2794 rkey: "self",
2795 cid: "bafyhideauth",
2796 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2797 roleUri: null,
2798 joinedAt: new Date(),
2799 createdAt: new Date(),
2800 indexedAt: new Date(),
2801 }).onConflictDoNothing();
2802
2803 const [post] = await ctx.db.insert(posts).values({
2804 did: authorDid,
2805 rkey: "3lbkhideauth",
2806 cid: "bafyhideauth",
2807 text: "Test post for auth",
2808 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2809 boardUri: null,
2810 boardId: null,
2811 rootPostId: null,
2812 parentPostId: null,
2813 createdAt: new Date(),
2814 indexedAt: new Date(),
2815 }).returning();
2816
2817 // Mock requireAuth to return 401
2818 const { requireAuth } = await import("../../middleware/auth.js");
2819 const mockRequireAuth = requireAuth as any;
2820 mockRequireAuth.mockImplementation(() => async (c: any) => {
2821 return c.json({ error: "Unauthorized" }, 401);
2822 });
2823
2824 const testApp = new Hono<{ Variables: Variables }>().route("/api/mod", createModRoutes(ctx));
2825
2826 const res = await testApp.request("/api/mod/hide", {
2827 method: "POST",
2828 headers: { "Content-Type": "application/json" },
2829 body: JSON.stringify({
2830 postId: String(post.id),
2831 reason: "Test reason",
2832 }),
2833 });
2834
2835 expect(res.status).toBe(401);
2836
2837 // Restore default mock for subsequent tests
2838 mockRequireAuth.mockImplementation(() => async (c: any, next: any) => {
2839 c.set("user", mockUser);
2840 await next();
2841 });
2842 });
2843
2844 it("returns 403 when user lacks moderatePosts permission", async () => {
2845 const { users, memberships, posts } = await import("@atbb/db");
2846 const { requirePermission } = await import("../../middleware/permissions.js");
2847
2848 // Create post to avoid 404 (matches cleanup pattern did:plc:test-%)
2849 const authorDid = "did:plc:test-hide-perm";
2850 await ctx.db.insert(users).values({
2851 did: authorDid,
2852 handle: "hideperm.test",
2853 indexedAt: new Date(),
2854 }).onConflictDoNothing();
2855
2856 await ctx.db.insert(memberships).values({
2857 did: authorDid,
2858 rkey: "self",
2859 cid: "bafyhideperm",
2860 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2861 roleUri: null,
2862 joinedAt: new Date(),
2863 createdAt: new Date(),
2864 indexedAt: new Date(),
2865 }).onConflictDoNothing();
2866
2867 const [post] = await ctx.db.insert(posts).values({
2868 did: authorDid,
2869 rkey: "3lbkhideperm",
2870 cid: "bafyhideperm",
2871 text: "Test post for permission",
2872 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
2873 boardUri: null,
2874 boardId: null,
2875 rootPostId: null,
2876 parentPostId: null,
2877 createdAt: new Date(),
2878 indexedAt: new Date(),
2879 }).returning();
2880
2881 // Mock requirePermission to deny access
2882 const mockRequirePermission = requirePermission as any;
2883 mockRequirePermission.mockImplementation(() => async (c: any) => {
2884 return c.json({ error: "Forbidden" }, 403);
2885 });
2886
2887 const testApp = new Hono<{ Variables: Variables }>().route("/api/mod", createModRoutes(ctx));
2888
2889 const res = await testApp.request("/api/mod/hide", {
2890 method: "POST",
2891 headers: { "Content-Type": "application/json" },
2892 body: JSON.stringify({
2893 postId: String(post.id),
2894 reason: "Test reason",
2895 }),
2896 });
2897
2898 expect(res.status).toBe(403);
2899
2900 // Restore default mock for subsequent tests
2901 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => {
2902 await next();
2903 });
2904 });
2905 });
2906
2907 describe("Input Validation", () => {
2908 beforeEach(() => {
2909 mockUser = { did: "did:plc:test-moderator" };
2910 });
2911
2912 it("returns 400 for malformed JSON", async () => {
2913 const res = await app.request("/api/mod/hide", {
2914 method: "POST",
2915 headers: { "Content-Type": "application/json" },
2916 body: "{ invalid json }",
2917 });
2918
2919 expect(res.status).toBe(400);
2920 const data = await res.json();
2921 expect(data.error).toBe("Invalid JSON in request body");
2922 });
2923
2924 it("returns 400 when postId is missing", async () => {
2925 const res = await app.request("/api/mod/hide", {
2926 method: "POST",
2927 headers: { "Content-Type": "application/json" },
2928 body: JSON.stringify({
2929 // postId missing
2930 reason: "Test reason",
2931 }),
2932 });
2933
2934 expect(res.status).toBe(400);
2935 const data = await res.json();
2936 expect(data.error).toBe("postId is required and must be a string");
2937 });
2938
2939 it("returns 400 when postId is not a string", async () => {
2940 const res = await app.request("/api/mod/hide", {
2941 method: "POST",
2942 headers: { "Content-Type": "application/json" },
2943 body: JSON.stringify({
2944 postId: 123456, // number instead of string
2945 reason: "Test reason",
2946 }),
2947 });
2948
2949 expect(res.status).toBe(400);
2950 const data = await res.json();
2951 expect(data.error).toBe("postId is required and must be a string");
2952 });
2953
2954 it("returns 400 for invalid postId format (non-numeric)", async () => {
2955 const res = await app.request("/api/mod/hide", {
2956 method: "POST",
2957 headers: { "Content-Type": "application/json" },
2958 body: JSON.stringify({
2959 postId: "not-a-number",
2960 reason: "Test reason",
2961 }),
2962 });
2963
2964 expect(res.status).toBe(400);
2965 const data = await res.json();
2966 expect(data.error).toBe("Invalid post ID");
2967 });
2968
2969 it("returns 400 when reason is missing", async () => {
2970 const res = await app.request("/api/mod/hide", {
2971 method: "POST",
2972 headers: { "Content-Type": "application/json" },
2973 body: JSON.stringify({
2974 postId: "123456",
2975 // reason missing
2976 }),
2977 });
2978
2979 expect(res.status).toBe(400);
2980 const data = await res.json();
2981 expect(data.error).toBe("Reason is required and must be a string");
2982 });
2983
2984 it("returns 400 when reason is empty string", async () => {
2985 const res = await app.request("/api/mod/hide", {
2986 method: "POST",
2987 headers: { "Content-Type": "application/json" },
2988 body: JSON.stringify({
2989 postId: "123456",
2990 reason: " ", // whitespace only
2991 }),
2992 });
2993
2994 expect(res.status).toBe(400);
2995 const data = await res.json();
2996 expect(data.error).toBe("Reason is required and must not be empty");
2997 });
2998 });
2999
3000 describe("Business Logic", () => {
3001 beforeEach(() => {
3002 mockUser = { did: "did:plc:test-moderator" };
3003 });
3004
3005 it("returns 404 when post does not exist", async () => {
3006 const res = await app.request("/api/mod/hide", {
3007 method: "POST",
3008 headers: { "Content-Type": "application/json" },
3009 body: JSON.stringify({
3010 postId: "999999999", // non-existent
3011 reason: "Test reason",
3012 }),
3013 });
3014
3015 expect(res.status).toBe(404);
3016 const data = await res.json();
3017 expect(data.error).toBe("Post not found");
3018 });
3019
3020 it("returns 200 with alreadyActive: true when post is already hidden (idempotency)", async () => {
3021 const { users, posts, forums, modActions } = await import("@atbb/db");
3022 const { eq } = await import("drizzle-orm");
3023
3024 // Create author
3025 const authorDid = "did:plc:test-hide-already-hidden";
3026 await ctx.db.insert(users).values({
3027 did: authorDid,
3028 handle: "alreadyhidden.test",
3029 indexedAt: new Date(),
3030 }).onConflictDoNothing();
3031
3032 const now = new Date();
3033
3034 // Insert a post
3035 const [post] = await ctx.db.insert(posts).values({
3036 did: authorDid,
3037 rkey: "3lbkhidden",
3038 cid: "bafyhidden",
3039 text: "Already hidden post",
3040 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3041 boardUri: null,
3042 boardId: null,
3043 rootPostId: null,
3044 parentPostId: null,
3045 createdAt: now,
3046 indexedAt: now,
3047 }).returning();
3048
3049 // Get forum ID
3050 const [forum] = await ctx.db
3051 .select()
3052 .from(forums)
3053 .where(eq(forums.did, ctx.config.forumDid))
3054 .limit(1);
3055
3056 // Insert existing hide action
3057 const postUri = `at://${authorDid}/space.atbb.post/${post.rkey}`;
3058 await ctx.db.insert(modActions).values({
3059 did: ctx.config.forumDid,
3060 rkey: "existing-hide",
3061 cid: "bafyexisthide",
3062 action: "space.atbb.modAction.delete",
3063 subjectDid: null,
3064 subjectPostUri: postUri,
3065 forumId: forum.id,
3066 reason: "Previously hidden",
3067 createdBy: "did:plc:previous-mod",
3068 expiresAt: null,
3069 createdAt: new Date(now.getTime() - 1000),
3070 indexedAt: new Date(now.getTime() - 1000),
3071 });
3072
3073 // Attempt to hide again
3074 const res = await app.request("/api/mod/hide", {
3075 method: "POST",
3076 headers: { "Content-Type": "application/json" },
3077 body: JSON.stringify({
3078 postId: post.id.toString(),
3079 reason: "Trying to hide again",
3080 }),
3081 });
3082
3083 expect(res.status).toBe(200);
3084 const data = await res.json();
3085 expect(data.success).toBe(true);
3086 expect(data.alreadyActive).toBe(true);
3087 expect(data.uri).toBeNull();
3088 expect(data.cid).toBeNull();
3089
3090 // Verify putRecord was NOT called (no duplicate action written)
3091 expect(mockPutRecord).not.toHaveBeenCalled();
3092 });
3093 });
3094
3095 describe("Infrastructure Errors", () => {
3096 beforeEach(() => {
3097 mockUser = { did: "did:plc:test-moderator" };
3098 });
3099
3100 it("returns 503 when post query fails (database error)", async () => {
3101 // Mock console.error to suppress error output during test
3102 const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
3103
3104 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => {
3105 throw new Error("Database connection lost");
3106 });
3107
3108 const res = await app.request("/api/mod/hide", {
3109 method: "POST",
3110 headers: { "Content-Type": "application/json" },
3111 body: JSON.stringify({
3112 postId: "999999999",
3113 reason: "Test reason",
3114 }),
3115 });
3116
3117 expect(res.status).toBe(503);
3118 const data = await res.json();
3119 expect(data.error).toBe("Database temporarily unavailable. Please try again later.");
3120
3121 consoleErrorSpy.mockRestore();
3122 dbSelectSpy.mockRestore();
3123 });
3124
3125 it("returns 500 when ForumAgent not available", async () => {
3126 const { users, posts } = await import("@atbb/db");
3127
3128 // Create author
3129 const authorDid = "did:plc:test-hide-no-agent";
3130 await ctx.db.insert(users).values({
3131 did: authorDid,
3132 handle: "hidenoagent.test",
3133 indexedAt: new Date(),
3134 }).onConflictDoNothing();
3135
3136 const now = new Date();
3137
3138 // Insert a post
3139 const [post] = await ctx.db.insert(posts).values({
3140 did: authorDid,
3141 rkey: "3lbknoagent",
3142 cid: "bafynoagent",
3143 text: "Test post",
3144 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3145 boardUri: null,
3146 boardId: null,
3147 rootPostId: null,
3148 parentPostId: null,
3149 createdAt: now,
3150 indexedAt: now,
3151 }).returning();
3152
3153 // Remove ForumAgent
3154 ctx.forumAgent = undefined as any;
3155
3156 const res = await app.request("/api/mod/hide", {
3157 method: "POST",
3158 headers: { "Content-Type": "application/json" },
3159 body: JSON.stringify({
3160 postId: post.id.toString(),
3161 reason: "Test reason",
3162 }),
3163 });
3164
3165 expect(res.status).toBe(500);
3166 const data = await res.json();
3167 expect(data.error).toBe("Forum agent not available. Server configuration issue.");
3168
3169 // Restore ForumAgent for other tests
3170 ctx.forumAgent = {
3171 getAgent: () => ({
3172 com: {
3173 atproto: {
3174 repo: {
3175 putRecord: mockPutRecord,
3176 },
3177 },
3178 },
3179 }),
3180 } as any;
3181 });
3182
3183 it("returns 503 when ForumAgent not authenticated", async () => {
3184 const { users, posts } = await import("@atbb/db");
3185
3186 // Create author
3187 const authorDid = "did:plc:test-hide-no-auth";
3188 await ctx.db.insert(users).values({
3189 did: authorDid,
3190 handle: "hidenoauth.test",
3191 indexedAt: new Date(),
3192 }).onConflictDoNothing();
3193
3194 const now = new Date();
3195
3196 // Insert a post
3197 const [post] = await ctx.db.insert(posts).values({
3198 did: authorDid,
3199 rkey: "3lbknoauth",
3200 cid: "bafynoauth",
3201 text: "Test post",
3202 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3203 boardUri: null,
3204 boardId: null,
3205 rootPostId: null,
3206 parentPostId: null,
3207 createdAt: now,
3208 indexedAt: now,
3209 }).returning();
3210
3211 // Mock getAgent to return null (not authenticated)
3212 const originalAgent = ctx.forumAgent;
3213 ctx.forumAgent = {
3214 getAgent: () => null,
3215 } as any;
3216
3217 const res = await app.request("/api/mod/hide", {
3218 method: "POST",
3219 headers: { "Content-Type": "application/json" },
3220 body: JSON.stringify({
3221 postId: post.id.toString(),
3222 reason: "Test reason",
3223 }),
3224 });
3225
3226 expect(res.status).toBe(503);
3227 const data = await res.json();
3228 expect(data.error).toBe("Forum agent not authenticated. Please try again later.");
3229
3230 // Restore original agent
3231 ctx.forumAgent = originalAgent;
3232 });
3233
3234 it("returns 503 for network errors writing to PDS", async () => {
3235 const { users, posts } = await import("@atbb/db");
3236
3237 // Create author
3238 const authorDid = "did:plc:test-hide-network-error";
3239 await ctx.db.insert(users).values({
3240 did: authorDid,
3241 handle: "hidenetwork.test",
3242 indexedAt: new Date(),
3243 }).onConflictDoNothing();
3244
3245 const now = new Date();
3246
3247 // Insert a post
3248 const [post] = await ctx.db.insert(posts).values({
3249 did: authorDid,
3250 rkey: "3lbknetwork",
3251 cid: "bafynetwork",
3252 text: "Test post",
3253 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3254 boardUri: null,
3255 boardId: null,
3256 rootPostId: null,
3257 parentPostId: null,
3258 createdAt: now,
3259 indexedAt: now,
3260 }).returning();
3261
3262 // Mock putRecord to throw network error
3263 mockPutRecord.mockRejectedValueOnce(new Error("fetch failed"));
3264
3265 const res = await app.request("/api/mod/hide", {
3266 method: "POST",
3267 headers: { "Content-Type": "application/json" },
3268 body: JSON.stringify({
3269 postId: post.id.toString(),
3270 reason: "Test reason",
3271 }),
3272 });
3273
3274 expect(res.status).toBe(503);
3275 const data = await res.json();
3276 expect(data.error).toBe("Unable to reach external service. Please try again later.");
3277 });
3278
3279 it("returns 500 for unexpected errors writing to PDS", async () => {
3280 const { users, posts } = await import("@atbb/db");
3281
3282 // Create author
3283 const authorDid = "did:plc:test-hide-server-error";
3284 await ctx.db.insert(users).values({
3285 did: authorDid,
3286 handle: "hideserver.test",
3287 indexedAt: new Date(),
3288 }).onConflictDoNothing();
3289
3290 const now = new Date();
3291
3292 // Insert a post
3293 const [post] = await ctx.db.insert(posts).values({
3294 did: authorDid,
3295 rkey: "3lbkserver",
3296 cid: "bafyserver",
3297 text: "Test post",
3298 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3299 boardUri: null,
3300 boardId: null,
3301 rootPostId: null,
3302 parentPostId: null,
3303 createdAt: now,
3304 indexedAt: now,
3305 }).returning();
3306
3307 // Mock putRecord to throw unexpected error (not network error)
3308 mockPutRecord.mockRejectedValueOnce(new Error("Unexpected write error"));
3309
3310 const res = await app.request("/api/mod/hide", {
3311 method: "POST",
3312 headers: { "Content-Type": "application/json" },
3313 body: JSON.stringify({
3314 postId: post.id.toString(),
3315 reason: "Test reason",
3316 }),
3317 });
3318
3319 expect(res.status).toBe(500);
3320 const data = await res.json();
3321 expect(data.error).toBe("Failed to record moderation action. Please contact support if this persists.");
3322 });
3323 });
3324 });
3325
3326 describe("DELETE /api/mod/hide/:postId", () => {
3327 it("unhides post successfully", async () => {
3328 const { users, posts, modActions } = await import("@atbb/db");
3329
3330 const modDid = "did:plc:test-unhide-mod";
3331 const memberDid = "did:plc:test-unhide-member";
3332
3333 await ctx.db.insert(users).values({
3334 did: modDid,
3335 handle: "unhidemod.test",
3336 indexedAt: new Date(),
3337 });
3338
3339 await ctx.db.insert(users).values({
3340 did: memberDid,
3341 handle: "unhidemember.test",
3342 indexedAt: new Date(),
3343 });
3344
3345 const [topic] = await ctx.db.insert(posts).values({
3346 did: memberDid,
3347 rkey: "3lbkunhidetopic",
3348 cid: "bafyunhidetopic",
3349 text: "Test",
3350 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3351 boardUri: null,
3352 boardId: null,
3353 rootPostId: null,
3354 parentPostId: null,
3355 createdAt: new Date(),
3356 indexedAt: new Date(),
3357 }).returning();
3358
3359 const postUri = `at://${memberDid}/space.atbb.post/${topic.rkey}`;
3360 await ctx.db.insert(modActions).values({
3361 did: ctx.config.forumDid,
3362 rkey: "hide1",
3363 cid: "bafyhide",
3364 action: "space.atbb.modAction.delete",
3365 subjectPostUri: postUri,
3366 reason: "Original hide",
3367 createdBy: modDid,
3368 createdAt: new Date(),
3369 indexedAt: new Date(),
3370 });
3371
3372 mockUser = { did: modDid };
3373
3374 mockPutRecord.mockResolvedValueOnce({
3375 data: {
3376 uri: "at://did:plc:forum/space.atbb.modAction/unhide123",
3377 cid: "bafyunhide",
3378 },
3379 });
3380
3381 const res = await app.request(`/api/mod/hide/${topic.id}`, {
3382 method: "DELETE",
3383 headers: { "Content-Type": "application/json" },
3384 body: JSON.stringify({ reason: "False positive" }),
3385 });
3386
3387 expect(res.status).toBe(200);
3388 const data = await res.json();
3389 expect(data.success).toBe(true);
3390 expect(data.action).toBe("space.atbb.modAction.undelete");
3391 });
3392
3393 it("supports hide→unhide→hide toggle (verifies lexicon fix)", async () => {
3394 const { users, posts, modActions } = await import("@atbb/db");
3395
3396 const modDid = "did:plc:test-toggle-mod";
3397 const memberDid = "did:plc:test-toggle-member";
3398
3399 await ctx.db.insert(users).values({
3400 did: modDid,
3401 handle: "togglemod.test",
3402 indexedAt: new Date(),
3403 });
3404
3405 await ctx.db.insert(users).values({
3406 did: memberDid,
3407 handle: "togglemember.test",
3408 indexedAt: new Date(),
3409 });
3410
3411 const [topic] = await ctx.db.insert(posts).values({
3412 did: memberDid,
3413 rkey: "3lbktoggletopic",
3414 cid: "bafytoggletopic",
3415 text: "Test toggle",
3416 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3417 boardUri: null,
3418 boardId: null,
3419 rootPostId: null,
3420 parentPostId: null,
3421 createdAt: new Date(),
3422 indexedAt: new Date(),
3423 }).returning();
3424
3425 mockUser = { did: modDid };
3426 const postUri = `at://${memberDid}/space.atbb.post/${topic.rkey}`;
3427
3428 // Step 1: Hide the post (writes "delete" action)
3429 mockPutRecord.mockResolvedValueOnce({
3430 data: {
3431 uri: "at://did:plc:forum/space.atbb.modAction/hide1",
3432 cid: "bafyhide1",
3433 },
3434 });
3435
3436 const hideRes = await app.request("/api/mod/hide", {
3437 method: "POST",
3438 headers: { "Content-Type": "application/json" },
3439 body: JSON.stringify({
3440 postId: topic.id.toString(),
3441 reason: "Hide test",
3442 }),
3443 });
3444
3445 expect(hideRes.status).toBe(200);
3446 const hideData = await hideRes.json();
3447 expect(hideData.success).toBe(true);
3448 expect(hideData.action).toBe("space.atbb.modAction.delete");
3449 expect(hideData.alreadyActive).toBe(false);
3450
3451 // Manually insert the hide action to database (simulating what PDS write would do)
3452 await ctx.db.insert(modActions).values({
3453 did: ctx.config.forumDid,
3454 rkey: "hide1",
3455 cid: "bafyhide1",
3456 action: "space.atbb.modAction.delete",
3457 subjectPostUri: postUri,
3458 reason: "Hide test",
3459 createdBy: modDid,
3460 createdAt: new Date(),
3461 indexedAt: new Date(),
3462 });
3463
3464 // Step 2: Unhide the post (writes "undelete" action)
3465 mockPutRecord.mockResolvedValueOnce({
3466 data: {
3467 uri: "at://did:plc:forum/space.atbb.modAction/unhide1",
3468 cid: "bafyunhide1",
3469 },
3470 });
3471
3472 const unhideRes = await app.request(`/api/mod/hide/${topic.id}`, {
3473 method: "DELETE",
3474 headers: { "Content-Type": "application/json" },
3475 body: JSON.stringify({ reason: "Unhide test" }),
3476 });
3477
3478 expect(unhideRes.status).toBe(200);
3479 const unhideData = await unhideRes.json();
3480 expect(unhideData.success).toBe(true);
3481 expect(unhideData.action).toBe("space.atbb.modAction.undelete");
3482 expect(unhideData.alreadyActive).toBe(false);
3483
3484 // Manually insert the unhide action
3485 await ctx.db.insert(modActions).values({
3486 did: ctx.config.forumDid,
3487 rkey: "unhide1",
3488 cid: "bafyunhide1",
3489 action: "space.atbb.modAction.undelete",
3490 subjectPostUri: postUri,
3491 reason: "Unhide test",
3492 createdBy: modDid,
3493 createdAt: new Date(Date.now() + 1000), // Slightly later
3494 indexedAt: new Date(),
3495 });
3496
3497 // Step 3: Hide again (should succeed because post is now unhidden)
3498 mockPutRecord.mockResolvedValueOnce({
3499 data: {
3500 uri: "at://did:plc:forum/space.atbb.modAction/hide2",
3501 cid: "bafyhide2",
3502 },
3503 });
3504
3505 const hideRes2 = await app.request("/api/mod/hide", {
3506 method: "POST",
3507 headers: { "Content-Type": "application/json" },
3508 body: JSON.stringify({
3509 postId: topic.id.toString(),
3510 reason: "Hide again",
3511 }),
3512 });
3513
3514 expect(hideRes2.status).toBe(200);
3515 const hideData2 = await hideRes2.json();
3516 expect(hideData2.success).toBe(true);
3517 expect(hideData2.action).toBe("space.atbb.modAction.delete");
3518 expect(hideData2.alreadyActive).toBe(false); // Critical: proves toggle works
3519 });
3520
3521 describe("Authorization", () => {
3522 it("returns 401 when not authenticated", async () => {
3523 const { users, memberships, posts } = await import("@atbb/db");
3524
3525 // Create post to avoid 404 (matches cleanup pattern did:plc:test-%)
3526 const authorDid = "did:plc:test-unhide-auth";
3527 await ctx.db.insert(users).values({
3528 did: authorDid,
3529 handle: "unhideauth.test",
3530 indexedAt: new Date(),
3531 }).onConflictDoNothing();
3532
3533 await ctx.db.insert(memberships).values({
3534 did: authorDid,
3535 rkey: "self",
3536 cid: "bafyunhideauth",
3537 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3538 roleUri: null,
3539 joinedAt: new Date(),
3540 createdAt: new Date(),
3541 indexedAt: new Date(),
3542 }).onConflictDoNothing();
3543
3544 const [post] = await ctx.db.insert(posts).values({
3545 did: authorDid,
3546 rkey: "3lbkunhideauth",
3547 cid: "bafyunhideauth",
3548 text: "Test post for auth",
3549 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3550 boardUri: null,
3551 boardId: null,
3552 rootPostId: null,
3553 parentPostId: null,
3554 createdAt: new Date(),
3555 indexedAt: new Date(),
3556 }).returning();
3557
3558 // Mock requireAuth to return 401
3559 const { requireAuth } = await import("../../middleware/auth.js");
3560 const mockRequireAuth = requireAuth as any;
3561 mockRequireAuth.mockImplementation(() => async (c: any) => {
3562 return c.json({ error: "Unauthorized" }, 401);
3563 });
3564
3565 const testApp = new Hono<{ Variables: Variables }>().route("/api/mod", createModRoutes(ctx));
3566
3567 const res = await testApp.request(`/api/mod/hide/${post.id}`, {
3568 method: "DELETE",
3569 headers: { "Content-Type": "application/json" },
3570 body: JSON.stringify({
3571 reason: "Test reason",
3572 }),
3573 });
3574
3575 expect(res.status).toBe(401);
3576
3577 // Restore default mock for subsequent tests
3578 mockRequireAuth.mockImplementation(() => async (c: any, next: any) => {
3579 c.set("user", mockUser);
3580 await next();
3581 });
3582 });
3583
3584 it("returns 403 when user lacks moderatePosts permission", async () => {
3585 const { users, memberships, posts } = await import("@atbb/db");
3586 const { requirePermission } = await import("../../middleware/permissions.js");
3587
3588 // Create post to avoid 404 (matches cleanup pattern did:plc:test-%)
3589 const authorDid = "did:plc:test-unhide-perm";
3590 await ctx.db.insert(users).values({
3591 did: authorDid,
3592 handle: "unhideperm.test",
3593 indexedAt: new Date(),
3594 }).onConflictDoNothing();
3595
3596 await ctx.db.insert(memberships).values({
3597 did: authorDid,
3598 rkey: "self",
3599 cid: "bafyunhideperm",
3600 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3601 roleUri: null,
3602 joinedAt: new Date(),
3603 createdAt: new Date(),
3604 indexedAt: new Date(),
3605 }).onConflictDoNothing();
3606
3607 const [post] = await ctx.db.insert(posts).values({
3608 did: authorDid,
3609 rkey: "3lbkunhideperm",
3610 cid: "bafyunhideperm",
3611 text: "Test post for permission",
3612 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3613 boardUri: null,
3614 boardId: null,
3615 rootPostId: null,
3616 parentPostId: null,
3617 createdAt: new Date(),
3618 indexedAt: new Date(),
3619 }).returning();
3620
3621 // Mock requirePermission to deny access
3622 const mockRequirePermission = requirePermission as any;
3623 mockRequirePermission.mockImplementation(() => async (c: any) => {
3624 return c.json({ error: "Forbidden" }, 403);
3625 });
3626
3627 const testApp = new Hono<{ Variables: Variables }>().route("/api/mod", createModRoutes(ctx));
3628
3629 const res = await testApp.request(`/api/mod/hide/${post.id}`, {
3630 method: "DELETE",
3631 headers: { "Content-Type": "application/json" },
3632 body: JSON.stringify({
3633 reason: "Test reason",
3634 }),
3635 });
3636
3637 expect(res.status).toBe(403);
3638
3639 // Restore default mock for subsequent tests
3640 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => {
3641 await next();
3642 });
3643 });
3644 });
3645
3646 describe("Input Validation", () => {
3647 beforeEach(() => {
3648 mockUser = { did: "did:plc:test-moderator" };
3649 });
3650
3651 it("returns 400 for invalid postId param format", async () => {
3652 const res = await app.request("/api/mod/hide/not-a-number", {
3653 method: "DELETE",
3654 headers: { "Content-Type": "application/json" },
3655 body: JSON.stringify({ reason: "Test reason" }),
3656 });
3657
3658 expect(res.status).toBe(400);
3659 const data = await res.json();
3660 expect(data.error).toBe("Invalid post ID");
3661 });
3662
3663 it("returns 400 for malformed JSON in request body", async () => {
3664 const res = await app.request("/api/mod/hide/123456", {
3665 method: "DELETE",
3666 headers: { "Content-Type": "application/json" },
3667 body: "{ invalid json }",
3668 });
3669
3670 expect(res.status).toBe(400);
3671 const data = await res.json();
3672 expect(data.error).toBe("Invalid JSON in request body");
3673 });
3674
3675 it("returns 400 when reason is missing", async () => {
3676 const res = await app.request("/api/mod/hide/123456", {
3677 method: "DELETE",
3678 headers: { "Content-Type": "application/json" },
3679 body: JSON.stringify({
3680 // reason missing
3681 }),
3682 });
3683
3684 expect(res.status).toBe(400);
3685 const data = await res.json();
3686 expect(data.error).toBe("Reason is required and must be a string");
3687 });
3688
3689 it("returns 400 when reason is empty string", async () => {
3690 const res = await app.request("/api/mod/hide/123456", {
3691 method: "DELETE",
3692 headers: { "Content-Type": "application/json" },
3693 body: JSON.stringify({
3694 reason: " ", // whitespace only
3695 }),
3696 });
3697
3698 expect(res.status).toBe(400);
3699 const data = await res.json();
3700 expect(data.error).toBe("Reason is required and must not be empty");
3701 });
3702 });
3703
3704 describe("Business Logic", () => {
3705 beforeEach(() => {
3706 mockUser = { did: "did:plc:test-moderator" };
3707 });
3708
3709 it("returns 404 when post does not exist", async () => {
3710 const res = await app.request("/api/mod/hide/999999999", {
3711 method: "DELETE",
3712 headers: { "Content-Type": "application/json" },
3713 body: JSON.stringify({ reason: "Test reason" }),
3714 });
3715
3716 expect(res.status).toBe(404);
3717 const data = await res.json();
3718 expect(data.error).toBe("Post not found");
3719 });
3720
3721 it("returns 200 with alreadyActive: true when post is already unhidden (idempotency)", async () => {
3722 const { users, posts } = await import("@atbb/db");
3723
3724 // Create author
3725 const authorDid = "did:plc:test-unhide-already-unhidden";
3726 await ctx.db.insert(users).values({
3727 did: authorDid,
3728 handle: "alreadyunhidden.test",
3729 indexedAt: new Date(),
3730 }).onConflictDoNothing();
3731
3732 const now = new Date();
3733
3734 // Insert a post (no hide action = already unhidden)
3735 const [post] = await ctx.db.insert(posts).values({
3736 did: authorDid,
3737 rkey: "3lbkunhidden",
3738 cid: "bafyunhidden",
3739 text: "Not hidden post",
3740 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3741 boardUri: null,
3742 boardId: null,
3743 rootPostId: null,
3744 parentPostId: null,
3745 createdAt: now,
3746 indexedAt: now,
3747 }).returning();
3748
3749 // Attempt to unhide (no existing hide action)
3750 const res = await app.request(`/api/mod/hide/${post.id}`, {
3751 method: "DELETE",
3752 headers: { "Content-Type": "application/json" },
3753 body: JSON.stringify({ reason: "Trying to unhide already visible post" }),
3754 });
3755
3756 expect(res.status).toBe(200);
3757 const data = await res.json();
3758 expect(data.success).toBe(true);
3759 expect(data.alreadyActive).toBe(true);
3760 expect(data.uri).toBeNull();
3761 expect(data.cid).toBeNull();
3762
3763 // Verify putRecord was NOT called (no duplicate action written)
3764 expect(mockPutRecord).not.toHaveBeenCalled();
3765 });
3766 });
3767
3768 describe("Infrastructure Errors", () => {
3769 beforeEach(() => {
3770 mockUser = { did: "did:plc:test-moderator" };
3771 });
3772
3773 it("returns 503 when post query fails (database error)", async () => {
3774 // Mock console.error to suppress error output during test
3775 const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
3776
3777 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => {
3778 throw new Error("Database connection lost");
3779 });
3780
3781 const res = await app.request("/api/mod/hide/999999999", {
3782 method: "DELETE",
3783 headers: { "Content-Type": "application/json" },
3784 body: JSON.stringify({
3785 reason: "Test reason",
3786 }),
3787 });
3788
3789 expect(res.status).toBe(503);
3790 const data = await res.json();
3791 expect(data.error).toBe("Database temporarily unavailable. Please try again later.");
3792
3793 consoleErrorSpy.mockRestore();
3794 dbSelectSpy.mockRestore();
3795 });
3796
3797 it("returns 500 when ForumAgent not available", async () => {
3798 const { users, posts, forums, modActions } = await import("@atbb/db");
3799 const { eq } = await import("drizzle-orm");
3800
3801 // Create author
3802 const authorDid = "did:plc:test-unhide-no-agent";
3803 await ctx.db.insert(users).values({
3804 did: authorDid,
3805 handle: "unhidenoagent.test",
3806 indexedAt: new Date(),
3807 }).onConflictDoNothing();
3808
3809 const now = new Date();
3810
3811 // Insert a post
3812 const [post] = await ctx.db.insert(posts).values({
3813 did: authorDid,
3814 rkey: "3lbknoagent2",
3815 cid: "bafynoagent2",
3816 text: "Test post",
3817 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3818 boardUri: null,
3819 boardId: null,
3820 rootPostId: null,
3821 parentPostId: null,
3822 createdAt: now,
3823 indexedAt: now,
3824 }).returning();
3825
3826 // Get forum ID
3827 const [forum] = await ctx.db
3828 .select()
3829 .from(forums)
3830 .where(eq(forums.did, ctx.config.forumDid))
3831 .limit(1);
3832
3833 // Insert existing hide action
3834 const postUri = `at://${authorDid}/space.atbb.post/${post.rkey}`;
3835 await ctx.db.insert(modActions).values({
3836 did: ctx.config.forumDid,
3837 rkey: "hide-for-unhide-test",
3838 cid: "bafyhide",
3839 action: "space.atbb.modAction.delete",
3840 subjectDid: null,
3841 subjectPostUri: postUri,
3842 forumId: forum.id,
3843 reason: "Hidden",
3844 createdBy: "did:plc:test-mod",
3845 expiresAt: null,
3846 createdAt: new Date(now.getTime() - 1000),
3847 indexedAt: new Date(now.getTime() - 1000),
3848 });
3849
3850 // Remove ForumAgent
3851 ctx.forumAgent = undefined as any;
3852
3853 const res = await app.request(`/api/mod/hide/${post.id}`, {
3854 method: "DELETE",
3855 headers: { "Content-Type": "application/json" },
3856 body: JSON.stringify({ reason: "Test reason" }),
3857 });
3858
3859 expect(res.status).toBe(500);
3860 const data = await res.json();
3861 expect(data.error).toBe("Forum agent not available. Server configuration issue.");
3862
3863 // Restore ForumAgent for other tests
3864 ctx.forumAgent = {
3865 getAgent: () => ({
3866 com: {
3867 atproto: {
3868 repo: {
3869 putRecord: mockPutRecord,
3870 },
3871 },
3872 },
3873 }),
3874 } as any;
3875 });
3876
3877 it("returns 503 when ForumAgent not authenticated", async () => {
3878 const { users, posts, forums, modActions } = await import("@atbb/db");
3879 const { eq } = await import("drizzle-orm");
3880
3881 // Create author
3882 const authorDid = "did:plc:test-unhide-no-auth";
3883 await ctx.db.insert(users).values({
3884 did: authorDid,
3885 handle: "unhidenoauth.test",
3886 indexedAt: new Date(),
3887 }).onConflictDoNothing();
3888
3889 const now = new Date();
3890
3891 // Insert a post
3892 const [post] = await ctx.db.insert(posts).values({
3893 did: authorDid,
3894 rkey: "3lbknoauth2",
3895 cid: "bafynoauth2",
3896 text: "Test post",
3897 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3898 boardUri: null,
3899 boardId: null,
3900 rootPostId: null,
3901 parentPostId: null,
3902 createdAt: now,
3903 indexedAt: now,
3904 }).returning();
3905
3906 // Get forum ID
3907 const [forum] = await ctx.db
3908 .select()
3909 .from(forums)
3910 .where(eq(forums.did, ctx.config.forumDid))
3911 .limit(1);
3912
3913 // Insert existing hide action
3914 const postUri = `at://${authorDid}/space.atbb.post/${post.rkey}`;
3915 await ctx.db.insert(modActions).values({
3916 did: ctx.config.forumDid,
3917 rkey: "hide-for-unhide-auth-test",
3918 cid: "bafyhide",
3919 action: "space.atbb.modAction.delete",
3920 subjectDid: null,
3921 subjectPostUri: postUri,
3922 forumId: forum.id,
3923 reason: "Hidden",
3924 createdBy: "did:plc:test-mod",
3925 expiresAt: null,
3926 createdAt: new Date(now.getTime() - 1000),
3927 indexedAt: new Date(now.getTime() - 1000),
3928 });
3929
3930 // Mock getAgent to return null (not authenticated)
3931 const originalAgent = ctx.forumAgent;
3932 ctx.forumAgent = {
3933 getAgent: () => null,
3934 } as any;
3935
3936 const res = await app.request(`/api/mod/hide/${post.id}`, {
3937 method: "DELETE",
3938 headers: { "Content-Type": "application/json" },
3939 body: JSON.stringify({ reason: "Test reason" }),
3940 });
3941
3942 expect(res.status).toBe(503);
3943 const data = await res.json();
3944 expect(data.error).toBe("Forum agent not authenticated. Please try again later.");
3945
3946 // Restore original agent
3947 ctx.forumAgent = originalAgent;
3948 });
3949
3950 it("returns 503 for network errors writing to PDS", async () => {
3951 const { users, posts, forums, modActions } = await import("@atbb/db");
3952 const { eq } = await import("drizzle-orm");
3953
3954 // Create author
3955 const authorDid = "did:plc:test-unhide-network-error";
3956 await ctx.db.insert(users).values({
3957 did: authorDid,
3958 handle: "unhidenetwork.test",
3959 indexedAt: new Date(),
3960 }).onConflictDoNothing();
3961
3962 const now = new Date();
3963
3964 // Insert a post
3965 const [post] = await ctx.db.insert(posts).values({
3966 did: authorDid,
3967 rkey: "3lbknetwork2",
3968 cid: "bafynetwork2",
3969 text: "Test post",
3970 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
3971 boardUri: null,
3972 boardId: null,
3973 rootPostId: null,
3974 parentPostId: null,
3975 createdAt: now,
3976 indexedAt: now,
3977 }).returning();
3978
3979 // Get forum ID
3980 const [forum] = await ctx.db
3981 .select()
3982 .from(forums)
3983 .where(eq(forums.did, ctx.config.forumDid))
3984 .limit(1);
3985
3986 // Insert existing hide action
3987 const postUri = `at://${authorDid}/space.atbb.post/${post.rkey}`;
3988 await ctx.db.insert(modActions).values({
3989 did: ctx.config.forumDid,
3990 rkey: "hide-for-unhide-network-test",
3991 cid: "bafyhide",
3992 action: "space.atbb.modAction.delete",
3993 subjectDid: null,
3994 subjectPostUri: postUri,
3995 forumId: forum.id,
3996 reason: "Hidden",
3997 createdBy: "did:plc:test-mod",
3998 expiresAt: null,
3999 createdAt: new Date(now.getTime() - 1000),
4000 indexedAt: new Date(now.getTime() - 1000),
4001 });
4002
4003 // Mock putRecord to throw network error
4004 mockPutRecord.mockRejectedValueOnce(new Error("fetch failed"));
4005
4006 const res = await app.request(`/api/mod/hide/${post.id}`, {
4007 method: "DELETE",
4008 headers: { "Content-Type": "application/json" },
4009 body: JSON.stringify({ reason: "Test reason" }),
4010 });
4011
4012 expect(res.status).toBe(503);
4013 const data = await res.json();
4014 expect(data.error).toBe("Unable to reach external service. Please try again later.");
4015 });
4016
4017 it("returns 500 for unexpected errors writing to PDS", async () => {
4018 const { users, posts, forums, modActions } = await import("@atbb/db");
4019 const { eq } = await import("drizzle-orm");
4020
4021 // Create author
4022 const authorDid = "did:plc:test-unhide-server-error";
4023 await ctx.db.insert(users).values({
4024 did: authorDid,
4025 handle: "unhideserver.test",
4026 indexedAt: new Date(),
4027 }).onConflictDoNothing();
4028
4029 const now = new Date();
4030
4031 // Insert a post
4032 const [post] = await ctx.db.insert(posts).values({
4033 did: authorDid,
4034 rkey: "3lbkserver2",
4035 cid: "bafyserver2",
4036 text: "Test post",
4037 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`,
4038 boardUri: null,
4039 boardId: null,
4040 rootPostId: null,
4041 parentPostId: null,
4042 createdAt: now,
4043 indexedAt: now,
4044 }).returning();
4045
4046 // Get forum ID
4047 const [forum] = await ctx.db
4048 .select()
4049 .from(forums)
4050 .where(eq(forums.did, ctx.config.forumDid))
4051 .limit(1);
4052
4053 // Insert existing hide action
4054 const postUri = `at://${authorDid}/space.atbb.post/${post.rkey}`;
4055 await ctx.db.insert(modActions).values({
4056 did: ctx.config.forumDid,
4057 rkey: "hide-for-unhide-server-test",
4058 cid: "bafyhide",
4059 action: "space.atbb.modAction.delete",
4060 subjectDid: null,
4061 subjectPostUri: postUri,
4062 forumId: forum.id,
4063 reason: "Hidden",
4064 createdBy: "did:plc:test-mod",
4065 expiresAt: null,
4066 createdAt: new Date(now.getTime() - 1000),
4067 indexedAt: new Date(now.getTime() - 1000),
4068 });
4069
4070 // Mock putRecord to throw unexpected error (not network error)
4071 mockPutRecord.mockRejectedValueOnce(new Error("Unexpected write error"));
4072
4073 const res = await app.request(`/api/mod/hide/${post.id}`, {
4074 method: "DELETE",
4075 headers: { "Content-Type": "application/json" },
4076 body: JSON.stringify({ reason: "Test reason" }),
4077 });
4078
4079 expect(res.status).toBe(500);
4080 const data = await res.json();
4081 expect(data.error).toBe("Failed to record moderation action. Please contact support if this persists.");
4082 });
4083 });
4084 });
4085 });
4086
4087 describe("Helper: validateReason", () => {
4088 it("returns null for valid reason", () => {
4089 const result = validateReason("User violated community guidelines");
4090 expect(result).toBeNull();
4091 });
4092
4093 it("returns error for non-string reason", () => {
4094 const result = validateReason(123);
4095 expect(result).toBe("Reason is required and must be a string");
4096 });
4097
4098 it("returns error for empty/whitespace reason", () => {
4099 expect(validateReason("")).toBe("Reason is required and must not be empty");
4100 expect(validateReason(" ")).toBe("Reason is required and must not be empty");
4101 expect(validateReason("\t\n")).toBe("Reason is required and must not be empty");
4102 });
4103
4104 it("returns error for reason exceeding 3000 characters", () => {
4105 const longReason = "a".repeat(3001);
4106 const result = validateReason(longReason);
4107 expect(result).toBe("Reason must not exceed 3000 characters");
4108 });
4109 });
4110
4111 describe("Helper: checkActiveAction", () => {
4112 let ctx: TestContext;
4113
4114 beforeEach(async () => {
4115 ctx = await createTestContext();
4116 });
4117
4118 afterEach(async () => {
4119 await ctx.cleanup();
4120 });
4121
4122 it("returns null when no actions exist for subject", async () => {
4123 const result = await checkActiveAction(
4124 ctx,
4125 { did: "did:plc:nonexistent" },
4126 "ban"
4127 );
4128 expect(result).toBeNull();
4129 });
4130
4131 it("returns true when action is active (most recent action matches)", async () => {
4132 // Get forum ID from database
4133 const { forums, modActions } = await import("@atbb/db");
4134 const { eq } = await import("drizzle-orm");
4135 const [forum] = await ctx.db
4136 .select()
4137 .from(forums)
4138 .where(eq(forums.did, ctx.config.forumDid))
4139 .limit(1);
4140
4141 // Insert a ban action
4142 await ctx.db.insert(modActions).values({
4143 did: ctx.config.forumDid,
4144 rkey: "test-ban-1",
4145 cid: "bafytest1",
4146 action: "ban",
4147 subjectDid: "did:plc:testuser",
4148 subjectPostUri: null,
4149 forumId: forum.id,
4150 reason: "Violating rules",
4151 createdBy: "did:plc:moderator",
4152 expiresAt: null,
4153 createdAt: new Date(),
4154 indexedAt: new Date(),
4155 });
4156
4157 const result = await checkActiveAction(
4158 ctx,
4159 { did: "did:plc:testuser" },
4160 "ban"
4161 );
4162 expect(result).toBe(true);
4163 });
4164
4165 it("returns false when action is reversed (unban after ban)", async () => {
4166 // Get forum ID from database
4167 const { forums, modActions } = await import("@atbb/db");
4168 const { eq } = await import("drizzle-orm");
4169 const [forum] = await ctx.db
4170 .select()
4171 .from(forums)
4172 .where(eq(forums.did, ctx.config.forumDid))
4173 .limit(1);
4174
4175 // Insert a ban action first
4176 const now = new Date();
4177 const earlier = new Date(now.getTime() - 1000);
4178
4179 await ctx.db.insert(modActions).values({
4180 did: ctx.config.forumDid,
4181 rkey: "test-ban-2",
4182 cid: "bafytest2",
4183 action: "ban",
4184 subjectDid: "did:plc:testuser2",
4185 subjectPostUri: null,
4186 forumId: forum.id,
4187 reason: "Violating rules",
4188 createdBy: "did:plc:moderator",
4189 expiresAt: null,
4190 createdAt: earlier,
4191 indexedAt: earlier,
4192 });
4193
4194 // Insert an unban action (more recent)
4195 await ctx.db.insert(modActions).values({
4196 did: ctx.config.forumDid,
4197 rkey: "test-unban-2",
4198 cid: "bafytest3",
4199 action: "unban",
4200 subjectDid: "did:plc:testuser2",
4201 subjectPostUri: null,
4202 forumId: forum.id,
4203 reason: "Appeal approved",
4204 createdBy: "did:plc:admin",
4205 expiresAt: null,
4206 createdAt: now,
4207 indexedAt: now,
4208 });
4209
4210 const result = await checkActiveAction(
4211 ctx,
4212 { did: "did:plc:testuser2" },
4213 "ban"
4214 );
4215 expect(result).toBe(false);
4216 });
4217
4218 it("returns null when database query fails (fail-safe behavior)", async () => {
4219 const loggerErrorSpy = vi.spyOn(ctx.logger, "error");
4220
4221 // Mock database query to throw error
4222 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => {
4223 throw new Error("Database connection lost");
4224 });
4225
4226 const result = await checkActiveAction(
4227 ctx,
4228 { did: "did:plc:testuser" },
4229 "ban"
4230 );
4231
4232 // Should return null (fail-safe) instead of throwing
4233 expect(result).toBeNull();
4234
4235 // Should log the error
4236 expect(loggerErrorSpy).toHaveBeenCalledWith(
4237 "Failed to check active moderation action",
4238 expect.objectContaining({
4239 operation: "checkActiveAction",
4240 actionType: "ban",
4241 })
4242 );
4243
4244 // Restore mocks
4245 dbSelectSpy.mockRestore();
4246 loggerErrorSpy.mockRestore();
4247 });
4248
4249 it("re-throws programming errors after logging them as CRITICAL", async () => {
4250 const loggerErrorSpy = vi.spyOn(ctx.logger, "error");
4251
4252 // Mock database query to throw TypeError (programming error)
4253 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => {
4254 throw new TypeError("Cannot read property 'includes' of undefined");
4255 });
4256
4257 // Should re-throw the TypeError, not return null
4258 await expect(
4259 checkActiveAction(ctx, { did: "did:plc:testuser" }, "ban")
4260 ).rejects.toThrow(TypeError);
4261
4262 // Should log the error with CRITICAL prefix before re-throwing
4263 expect(loggerErrorSpy).toHaveBeenCalledWith(
4264 "CRITICAL: Programming error in checkActiveAction",
4265 expect.objectContaining({
4266 operation: "checkActiveAction",
4267 actionType: "ban",
4268 })
4269 );
4270
4271 // Restore mocks
4272 dbSelectSpy.mockRestore();
4273 loggerErrorSpy.mockRestore();
4274 });
4275 });
4276});