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
4
fork

Configure Feed

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

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