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
at atb-52-css-token-extraction 4276 lines 143 kB view raw
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});