WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto
at atb-52-css-token-extraction 2449 lines 85 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"; 5import { memberships, roles, rolePermissions, users, forums, categories, boards, posts, modActions } from "@atbb/db"; 6import { eq } from "drizzle-orm"; 7 8// Mock middleware at module level 9let mockUser: any; 10let mockGetUserRole: ReturnType<typeof vi.fn>; 11let mockPutRecord: ReturnType<typeof vi.fn>; 12let mockDeleteRecord: ReturnType<typeof vi.fn>; 13let mockRequireAnyPermissionPass = true; 14 15// Create the mock function at module level 16mockGetUserRole = vi.fn(); 17 18vi.mock("../../middleware/auth.js", () => ({ 19 requireAuth: vi.fn(() => async (c: any, next: any) => { 20 if (!mockUser) { 21 return c.json({ error: "Unauthorized" }, 401); 22 } 23 c.set("user", mockUser); 24 await next(); 25 }), 26})); 27 28vi.mock("../../middleware/permissions.js", () => ({ 29 requirePermission: vi.fn(() => async (_c: any, next: any) => { 30 await next(); 31 }), 32 requireAnyPermission: vi.fn(() => async (c: any, next: any) => { 33 if (!mockRequireAnyPermissionPass) { 34 return c.json({ error: "Insufficient permissions" }, 403); 35 } 36 await next(); 37 }), 38 getUserRole: (...args: any[]) => mockGetUserRole(...args), 39 checkPermission: vi.fn().mockResolvedValue(true), 40})); 41 42// Import after mocking 43const { createAdminRoutes } = await import("../admin.js"); 44 45describe.sequential("Admin Routes", () => { 46 let ctx: TestContext; 47 let app: Hono<{ Variables: Variables }>; 48 49 beforeEach(async () => { 50 ctx = await createTestContext(); 51 app = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 52 53 // Set up mock user for auth middleware 54 mockUser = { did: "did:plc:test-admin" }; 55 mockGetUserRole.mockClear(); 56 mockRequireAnyPermissionPass = true; 57 58 // Mock putRecord 59 mockPutRecord = vi.fn().mockResolvedValue({ data: { uri: "at://...", cid: "bafytest" } }); 60 mockDeleteRecord = vi.fn().mockResolvedValue({}); 61 62 // Mock ForumAgent 63 ctx.forumAgent = { 64 getAgent: () => ({ 65 com: { 66 atproto: { 67 repo: { 68 putRecord: mockPutRecord, 69 deleteRecord: mockDeleteRecord, 70 }, 71 }, 72 }, 73 }), 74 } as any; 75 }); 76 77 afterEach(async () => { 78 await ctx.cleanup(); 79 }); 80 81 describe("POST /api/admin/members/:did/role", () => { 82 beforeEach(async () => { 83 // Create test roles: Owner (priority 0), Admin (priority 10), Moderator (priority 20) 84 const [ownerRole] = await ctx.db.insert(roles).values({ 85 did: ctx.config.forumDid, 86 rkey: "owner", 87 cid: "bafyowner", 88 name: "Owner", 89 description: "Forum owner", 90 priority: 0, 91 createdAt: new Date(), 92 indexedAt: new Date(), 93 }).returning({ id: roles.id }); 94 await ctx.db.insert(rolePermissions).values([{ roleId: ownerRole.id, permission: "*" }]); 95 96 const [adminRole] = await ctx.db.insert(roles).values({ 97 did: ctx.config.forumDid, 98 rkey: "admin", 99 cid: "bafyadmin", 100 name: "Admin", 101 description: "Administrator", 102 priority: 10, 103 createdAt: new Date(), 104 indexedAt: new Date(), 105 }).returning({ id: roles.id }); 106 await ctx.db.insert(rolePermissions).values([{ roleId: adminRole.id, permission: "space.atbb.permission.manageRoles" }]); 107 108 const [moderatorRole] = await ctx.db.insert(roles).values({ 109 did: ctx.config.forumDid, 110 rkey: "moderator", 111 cid: "bafymoderator", 112 name: "Moderator", 113 description: "Moderator", 114 priority: 20, 115 createdAt: new Date(), 116 indexedAt: new Date(), 117 }).returning({ id: roles.id }); 118 await ctx.db.insert(rolePermissions).values([{ roleId: moderatorRole.id, permission: "space.atbb.permission.createPosts" }]); 119 120 // Create target user and membership (use onConflictDoNothing to handle test re-runs) 121 await ctx.db.insert(users).values({ 122 did: "did:plc:test-target", 123 handle: "target.test", 124 indexedAt: new Date(), 125 }).onConflictDoNothing(); 126 127 await ctx.db.insert(memberships).values({ 128 did: "did:plc:test-target", 129 rkey: "self", 130 cid: "bafymember", 131 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 132 joinedAt: new Date(), 133 createdAt: new Date(), 134 indexedAt: new Date(), 135 }).onConflictDoNothing(); 136 }); 137 138 it("assigns role successfully when admin has authority", async () => { 139 // Admin (priority 10) assigns Moderator (priority 20) - allowed 140 mockGetUserRole.mockResolvedValue({ 141 id: 2n, 142 name: "Admin", 143 priority: 10, 144 permissions: ["space.atbb.permission.manageRoles"], 145 }); 146 147 const res = await app.request("/api/admin/members/did:plc:test-target/role", { 148 method: "POST", 149 headers: { "Content-Type": "application/json" }, 150 body: JSON.stringify({ 151 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, 152 }), 153 }); 154 155 expect(res.status).toBe(200); 156 const data = await res.json(); 157 expect(data).toMatchObject({ 158 success: true, 159 roleAssigned: "Moderator", 160 targetDid: "did:plc:test-target", 161 }); 162 expect(mockPutRecord).toHaveBeenCalledWith( 163 expect.objectContaining({ 164 repo: "did:plc:test-target", 165 collection: "space.atbb.membership", 166 record: expect.objectContaining({ 167 role: expect.objectContaining({ 168 role: expect.objectContaining({ 169 uri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, 170 cid: "bafymoderator", 171 }), 172 }), 173 }), 174 }) 175 ); 176 }); 177 178 it("returns 403 when assigning role with equal authority", async () => { 179 // Admin (priority 10) tries to assign Admin (priority 10) - blocked 180 mockGetUserRole.mockResolvedValue({ 181 id: 2n, 182 name: "Admin", 183 priority: 10, 184 permissions: ["space.atbb.permission.manageRoles"], 185 }); 186 187 const res = await app.request("/api/admin/members/did:plc:test-target/role", { 188 method: "POST", 189 headers: { "Content-Type": "application/json" }, 190 body: JSON.stringify({ 191 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/admin`, 192 }), 193 }); 194 195 expect(res.status).toBe(403); 196 const data = await res.json(); 197 expect(data.error).toContain("equal or higher authority"); 198 // Priority values must not be leaked in responses (security: CLAUDE.md) 199 expect(data.yourPriority).toBeUndefined(); 200 expect(data.targetRolePriority).toBeUndefined(); 201 expect(mockPutRecord).not.toHaveBeenCalled(); 202 }); 203 204 it("returns 403 when assigning role with higher authority", async () => { 205 // Admin (priority 10) tries to assign Owner (priority 0) - blocked 206 mockGetUserRole.mockResolvedValue({ 207 id: 2n, 208 name: "Admin", 209 priority: 10, 210 permissions: ["space.atbb.permission.manageRoles"], 211 }); 212 213 const res = await app.request("/api/admin/members/did:plc:test-target/role", { 214 method: "POST", 215 headers: { "Content-Type": "application/json" }, 216 body: JSON.stringify({ 217 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/owner`, 218 }), 219 }); 220 221 expect(res.status).toBe(403); 222 const data = await res.json(); 223 expect(data.error).toContain("equal or higher authority"); 224 // Priority values must not be leaked in responses (security: CLAUDE.md) 225 expect(data.yourPriority).toBeUndefined(); 226 expect(data.targetRolePriority).toBeUndefined(); 227 expect(mockPutRecord).not.toHaveBeenCalled(); 228 }); 229 230 it("returns 404 when role not found", async () => { 231 mockGetUserRole.mockResolvedValue({ 232 id: 2n, 233 name: "Admin", 234 priority: 10, 235 permissions: ["space.atbb.permission.manageRoles"], 236 }); 237 238 const res = await app.request("/api/admin/members/did:plc:test-target/role", { 239 method: "POST", 240 headers: { "Content-Type": "application/json" }, 241 body: JSON.stringify({ 242 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/nonexistent`, 243 }), 244 }); 245 246 expect(res.status).toBe(404); 247 const data = await res.json(); 248 expect(data.error).toBe("Role not found"); 249 expect(mockPutRecord).not.toHaveBeenCalled(); 250 }); 251 252 it("returns 404 when target user not a member", async () => { 253 mockGetUserRole.mockResolvedValue({ 254 id: 2n, 255 name: "Admin", 256 priority: 10, 257 permissions: ["space.atbb.permission.manageRoles"], 258 }); 259 260 const res = await app.request("/api/admin/members/did:plc:nonmember/role", { 261 method: "POST", 262 headers: { "Content-Type": "application/json" }, 263 body: JSON.stringify({ 264 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, 265 }), 266 }); 267 268 expect(res.status).toBe(404); 269 const data = await res.json(); 270 expect(data.error).toBe("User is not a member of this forum"); 271 expect(mockPutRecord).not.toHaveBeenCalled(); 272 }); 273 274 it("returns 403 when user has no role assigned", async () => { 275 // getUserRole returns null (user has no role) 276 mockGetUserRole.mockResolvedValue(null); 277 278 const res = await app.request("/api/admin/members/did:plc:test-target/role", { 279 method: "POST", 280 headers: { "Content-Type": "application/json" }, 281 body: JSON.stringify({ 282 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, 283 }), 284 }); 285 286 expect(res.status).toBe(403); 287 const data = await res.json(); 288 expect(data.error).toBe("You do not have a role assigned"); 289 expect(mockPutRecord).not.toHaveBeenCalled(); 290 }); 291 292 it("returns 400 for missing roleUri field", async () => { 293 const res = await app.request("/api/admin/members/did:plc:test-target/role", { 294 method: "POST", 295 headers: { "Content-Type": "application/json" }, 296 body: JSON.stringify({}), 297 }); 298 299 expect(res.status).toBe(400); 300 const data = await res.json(); 301 expect(data.error).toContain("roleUri is required"); 302 }); 303 304 it("returns 400 for invalid roleUri format", async () => { 305 const res = await app.request("/api/admin/members/did:plc:test-target/role", { 306 method: "POST", 307 headers: { "Content-Type": "application/json" }, 308 body: JSON.stringify({ roleUri: "invalid-uri" }), 309 }); 310 311 expect(res.status).toBe(400); 312 const data = await res.json(); 313 expect(data.error).toBe("Invalid roleUri format"); 314 }); 315 316 it("returns 400 for malformed JSON", async () => { 317 const res = await app.request("/api/admin/members/did:plc:test-target/role", { 318 method: "POST", 319 headers: { "Content-Type": "application/json" }, 320 body: "{ invalid json }", 321 }); 322 323 expect(res.status).toBe(400); 324 const data = await res.json(); 325 expect(data.error).toContain("Invalid JSON"); 326 }); 327 328 it("returns 503 when PDS connection fails (network error)", async () => { 329 mockGetUserRole.mockResolvedValue({ 330 id: 2n, 331 name: "Admin", 332 priority: 10, 333 permissions: ["space.atbb.permission.manageRoles"], 334 }); 335 336 mockPutRecord.mockRejectedValue(new Error("fetch failed")); 337 338 const res = await app.request("/api/admin/members/did:plc:test-target/role", { 339 method: "POST", 340 headers: { "Content-Type": "application/json" }, 341 body: JSON.stringify({ 342 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, 343 }), 344 }); 345 346 expect(res.status).toBe(503); 347 const data = await res.json(); 348 expect(data.error).toContain("Unable to reach external service"); 349 }); 350 351 it("returns 500 when ForumAgent unavailable", async () => { 352 mockGetUserRole.mockResolvedValue({ 353 id: 2n, 354 name: "Admin", 355 priority: 10, 356 permissions: ["space.atbb.permission.manageRoles"], 357 }); 358 359 ctx.forumAgent = null; 360 361 const res = await app.request("/api/admin/members/did:plc:test-target/role", { 362 method: "POST", 363 headers: { "Content-Type": "application/json" }, 364 body: JSON.stringify({ 365 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, 366 }), 367 }); 368 369 expect(res.status).toBe(500); 370 const data = await res.json(); 371 expect(data.error).toContain("Forum agent not available"); 372 }); 373 374 it("returns 500 for unexpected server errors", async () => { 375 mockGetUserRole.mockResolvedValue({ 376 id: 2n, 377 name: "Admin", 378 priority: 10, 379 permissions: ["space.atbb.permission.manageRoles"], 380 }); 381 382 mockPutRecord.mockRejectedValue(new Error("Unexpected write error")); 383 384 const res = await app.request("/api/admin/members/did:plc:test-target/role", { 385 method: "POST", 386 headers: { "Content-Type": "application/json" }, 387 body: JSON.stringify({ 388 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, 389 }), 390 }); 391 392 expect(res.status).toBe(500); 393 const data = await res.json(); 394 expect(data.error).toContain("Failed to assign role"); 395 expect(data.error).not.toContain("PDS"); 396 }); 397 }); 398 399 describe("GET /api/admin/roles", () => { 400 it("lists all roles sorted by priority", async () => { 401 // Create test roles 402 const [ownerRole] = await ctx.db.insert(roles).values({ 403 did: ctx.config.forumDid, 404 rkey: "owner", 405 cid: "bafyowner", 406 name: "Owner", 407 description: "Forum owner", 408 priority: 0, 409 createdAt: new Date(), 410 indexedAt: new Date(), 411 }).returning({ id: roles.id }); 412 await ctx.db.insert(rolePermissions).values([{ roleId: ownerRole.id, permission: "*" }]); 413 414 const [moderatorRole] = await ctx.db.insert(roles).values({ 415 did: ctx.config.forumDid, 416 rkey: "moderator", 417 cid: "bafymoderator", 418 name: "Moderator", 419 description: "Moderator", 420 priority: 20, 421 createdAt: new Date(), 422 indexedAt: new Date(), 423 }).returning({ id: roles.id }); 424 await ctx.db.insert(rolePermissions).values([{ roleId: moderatorRole.id, permission: "space.atbb.permission.createPosts" }]); 425 426 const [adminRole] = await ctx.db.insert(roles).values({ 427 did: ctx.config.forumDid, 428 rkey: "admin", 429 cid: "bafyadmin", 430 name: "Admin", 431 description: "Administrator", 432 priority: 10, 433 createdAt: new Date(), 434 indexedAt: new Date(), 435 }).returning({ id: roles.id }); 436 await ctx.db.insert(rolePermissions).values([{ roleId: adminRole.id, permission: "space.atbb.permission.manageRoles" }]); 437 438 const res = await app.request("/api/admin/roles"); 439 440 expect(res.status).toBe(200); 441 const data = await res.json(); 442 expect(data.roles).toHaveLength(3); 443 444 // Verify sorted by priority (Owner first, Moderator last) 445 expect(data.roles[0].name).toBe("Owner"); 446 expect(data.roles[0].priority).toBe(0); 447 expect(data.roles[0].permissions).toEqual(["*"]); 448 449 expect(data.roles[1].name).toBe("Admin"); 450 expect(data.roles[1].priority).toBe(10); 451 452 expect(data.roles[2].name).toBe("Moderator"); 453 expect(data.roles[2].priority).toBe(20); 454 455 // Verify BigInt serialization 456 expect(typeof data.roles[0].id).toBe("string"); 457 }); 458 459 it("returns empty array when no roles exist", async () => { 460 const res = await app.request("/api/admin/roles"); 461 462 expect(res.status).toBe(200); 463 const data = await res.json(); 464 expect(data.roles).toEqual([]); 465 }); 466 467 it("includes uri field constructed from did and rkey", async () => { 468 // Seed a role matching the pattern used in this describe block 469 await ctx.db.insert(roles).values({ 470 did: ctx.config.forumDid, 471 rkey: "moderator", 472 cid: "bafymoderator", 473 name: "Moderator", 474 description: "Moderator", 475 priority: 20, 476 createdAt: new Date(), 477 indexedAt: new Date(), 478 }); 479 480 const res = await app.request("/api/admin/roles"); 481 482 expect(res.status).toBe(200); 483 const data = await res.json() as { roles: Array<{ name: string; uri: string; id: string }> }; 484 expect(data.roles).toHaveLength(1); 485 expect(data.roles[0].uri).toBe(`at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`); 486 }); 487 }); 488 489 describe.sequential("GET /api/admin/members", () => { 490 beforeEach(async () => { 491 // Clean database to ensure no data pollution from other tests 492 await ctx.cleanDatabase(); 493 494 // Re-insert forum (deleted by cleanDatabase) 495 await ctx.db.insert(forums).values({ 496 did: ctx.config.forumDid, 497 rkey: "self", 498 cid: "bafytest", 499 name: "Test Forum", 500 description: "A test forum", 501 indexedAt: new Date(), 502 }); 503 504 // Create test role 505 const [moderatorRole] = await ctx.db.insert(roles).values({ 506 did: ctx.config.forumDid, 507 rkey: "moderator", 508 cid: "bafymoderator", 509 name: "Moderator", 510 description: "Moderator", 511 priority: 20, 512 createdAt: new Date(), 513 indexedAt: new Date(), 514 }).returning({ id: roles.id }); 515 await ctx.db.insert(rolePermissions).values([{ roleId: moderatorRole.id, permission: "space.atbb.permission.createPosts" }]); 516 }); 517 518 it("lists members with assigned roles", async () => { 519 // Create user and membership with role 520 await ctx.db.insert(users).values({ 521 did: "did:plc:test-member-role", 522 handle: "member.test", 523 indexedAt: new Date(), 524 }).onConflictDoNothing(); 525 526 await ctx.db.insert(memberships).values({ 527 did: "did:plc:test-member-role", 528 rkey: "self", 529 cid: "bafymember", 530 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 531 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, 532 joinedAt: new Date("2026-01-15T00:00:00.000Z"), 533 createdAt: new Date(), 534 indexedAt: new Date(), 535 }).onConflictDoNothing(); 536 537 const res = await app.request("/api/admin/members"); 538 539 expect(res.status).toBe(200); 540 const data = await res.json(); 541 expect(data.members).toHaveLength(1); 542 expect(data.members[0]).toMatchObject({ 543 did: "did:plc:test-member-role", 544 handle: "member.test", 545 role: "Moderator", 546 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, 547 joinedAt: "2026-01-15T00:00:00.000Z", 548 }); 549 }); 550 551 it("shows Guest for members with no role", async () => { 552 // Create user and membership without role 553 await ctx.db.insert(users).values({ 554 did: "did:plc:test-guest", 555 handle: "guest.test", 556 indexedAt: new Date(), 557 }).onConflictDoNothing(); 558 559 await ctx.db.insert(memberships).values({ 560 did: "did:plc:test-guest", 561 rkey: "self", 562 cid: "bafymember", 563 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 564 roleUri: null, 565 joinedAt: new Date("2026-01-15T00:00:00.000Z"), 566 createdAt: new Date(), 567 indexedAt: new Date(), 568 }).onConflictDoNothing(); 569 570 const res = await app.request("/api/admin/members"); 571 572 expect(res.status).toBe(200); 573 const data = await res.json(); 574 expect(data.members).toHaveLength(1); 575 expect(data.members[0]).toMatchObject({ 576 did: "did:plc:test-guest", 577 handle: "guest.test", 578 role: "Guest", 579 roleUri: null, 580 }); 581 }); 582 583 it("shows DID as handle fallback when handle not found", async () => { 584 // Create user without handle (to test DID fallback) 585 await ctx.db.insert(users).values({ 586 did: "did:plc:test-unknown", 587 handle: null, // No handle to test fallback 588 indexedAt: new Date(), 589 }).onConflictDoNothing(); 590 591 // Create membership for this user 592 await ctx.db.insert(memberships).values({ 593 did: "did:plc:test-unknown", 594 rkey: "self", 595 cid: "bafymember", 596 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 597 roleUri: null, 598 joinedAt: new Date(), 599 createdAt: new Date(), 600 indexedAt: new Date(), 601 }).onConflictDoNothing(); 602 603 const res = await app.request("/api/admin/members"); 604 605 expect(res.status).toBe(200); 606 const data = await res.json(); 607 expect(data.members).toHaveLength(1); 608 expect(data.members[0]).toMatchObject({ 609 did: "did:plc:test-unknown", 610 handle: "did:plc:test-unknown", // DID used as fallback 611 role: "Guest", 612 }); 613 }); 614 }); 615 describe.sequential("GET /api/admin/members/me", () => { 616 beforeEach(async () => { 617 // Clean database to ensure no data pollution from other tests 618 await ctx.cleanDatabase(); 619 620 // Re-insert forum (deleted by cleanDatabase) 621 await ctx.db.insert(forums).values({ 622 did: ctx.config.forumDid, 623 rkey: "self", 624 cid: "bafytest", 625 name: "Test Forum", 626 description: "A test forum", 627 indexedAt: new Date(), 628 }); 629 630 // Set mock user 631 mockUser = { did: "did:plc:test-me" }; 632 }); 633 634 it("returns 401 when not authenticated", async () => { 635 mockUser = null; // signals the requireAuth mock to return 401 636 const res = await app.request("/api/admin/members/me"); 637 expect(res.status).toBe(401); 638 }); 639 640 it("returns 404 when authenticated user has no membership record", async () => { 641 // mockUser is set to did:plc:test-me but no membership record exists 642 const res = await app.request("/api/admin/members/me"); 643 644 expect(res.status).toBe(404); 645 const data = await res.json(); 646 expect(data.error).toBe("Membership not found"); 647 }); 648 649 it("returns 200 with membership, role, and permissions for a user with a linked role", async () => { 650 // Insert role 651 const [moderatorRole] = await ctx.db.insert(roles).values({ 652 did: ctx.config.forumDid, 653 rkey: "moderator", 654 cid: "bafymoderator", 655 name: "Moderator", 656 description: "Moderator role", 657 priority: 20, 658 createdAt: new Date(), 659 indexedAt: new Date(), 660 }).returning({ id: roles.id }); 661 await ctx.db.insert(rolePermissions).values([ 662 { roleId: moderatorRole.id, permission: "space.atbb.permission.createPosts" }, 663 { roleId: moderatorRole.id, permission: "space.atbb.permission.lockTopics" }, 664 ]); 665 666 // Insert user 667 await ctx.db.insert(users).values({ 668 did: "did:plc:test-me", 669 handle: "me.test", 670 indexedAt: new Date(), 671 }).onConflictDoNothing(); 672 673 // Insert membership linked to role 674 await ctx.db.insert(memberships).values({ 675 did: "did:plc:test-me", 676 rkey: "self", 677 cid: "bafymembership", 678 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 679 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, 680 joinedAt: new Date("2026-01-15T00:00:00.000Z"), 681 createdAt: new Date(), 682 indexedAt: new Date(), 683 }).onConflictDoNothing(); 684 685 const res = await app.request("/api/admin/members/me"); 686 687 expect(res.status).toBe(200); 688 const data = await res.json(); 689 expect(data).toMatchObject({ 690 did: "did:plc:test-me", 691 handle: "me.test", 692 role: "Moderator", 693 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, 694 permissions: ["space.atbb.permission.createPosts", "space.atbb.permission.lockTopics"], 695 }); 696 }); 697 698 it("returns 200 with empty permissions array when membership exists but role has no permissions", async () => { 699 // Insert role with empty permissions 700 await ctx.db.insert(roles).values({ 701 did: ctx.config.forumDid, 702 rkey: "guest-role", 703 cid: "bafyguestrole", 704 name: "Guest Role", 705 description: "Role with no permissions", 706 priority: 100, 707 createdAt: new Date(), 708 indexedAt: new Date(), 709 }); 710 // No rolePermissions inserted — role has no permissions 711 712 // Insert user 713 await ctx.db.insert(users).values({ 714 did: "did:plc:test-me", 715 handle: "me.test", 716 indexedAt: new Date(), 717 }).onConflictDoNothing(); 718 719 // Insert membership linked to role 720 await ctx.db.insert(memberships).values({ 721 did: "did:plc:test-me", 722 rkey: "self", 723 cid: "bafymembership", 724 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 725 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/guest-role`, 726 joinedAt: new Date(), 727 createdAt: new Date(), 728 indexedAt: new Date(), 729 }).onConflictDoNothing(); 730 731 const res = await app.request("/api/admin/members/me"); 732 733 expect(res.status).toBe(200); 734 const data = await res.json(); 735 expect(data.permissions).toEqual([]); 736 expect(data.role).toBe("Guest Role"); 737 }); 738 739 it("only returns the current user's membership, not other users'", async () => { 740 // Insert role 741 const [adminRole] = await ctx.db.insert(roles).values({ 742 did: ctx.config.forumDid, 743 rkey: "admin", 744 cid: "bafyadmin", 745 name: "Admin", 746 description: "Admin role", 747 priority: 10, 748 createdAt: new Date(), 749 indexedAt: new Date(), 750 }).returning({ id: roles.id }); 751 await ctx.db.insert(rolePermissions).values([{ roleId: adminRole.id, permission: "*" }]); 752 753 // Insert current user with membership 754 await ctx.db.insert(users).values({ 755 did: "did:plc:test-me", 756 handle: "me.test", 757 indexedAt: new Date(), 758 }).onConflictDoNothing(); 759 760 await ctx.db.insert(memberships).values({ 761 did: "did:plc:test-me", 762 rkey: "self", 763 cid: "bafymymembership", 764 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 765 roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/admin`, 766 joinedAt: new Date(), 767 createdAt: new Date(), 768 indexedAt: new Date(), 769 }).onConflictDoNothing(); 770 771 // Insert another user with a different role 772 await ctx.db.insert(users).values({ 773 did: "did:plc:test-other", 774 handle: "other.test", 775 indexedAt: new Date(), 776 }).onConflictDoNothing(); 777 778 await ctx.db.insert(memberships).values({ 779 did: "did:plc:test-other", 780 rkey: "self", 781 cid: "bafyothermembership", 782 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 783 roleUri: null, 784 joinedAt: new Date(), 785 createdAt: new Date(), 786 indexedAt: new Date(), 787 }).onConflictDoNothing(); 788 789 const res = await app.request("/api/admin/members/me"); 790 791 expect(res.status).toBe(200); 792 const data = await res.json(); 793 // Should return only our user's data 794 expect(data.did).toBe("did:plc:test-me"); 795 expect(data.handle).toBe("me.test"); 796 expect(data.role).toBe("Admin"); 797 }); 798 799 it("returns 'Guest' as role when membership has no roleUri", async () => { 800 const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; 801 await ctx.db.insert(users).values({ 802 did: "did:plc:test-guest", 803 handle: "guest.bsky.social", 804 indexedAt: new Date(), 805 }); 806 await ctx.db.insert(memberships).values({ 807 did: "did:plc:test-guest", 808 rkey: "guestrkey", 809 cid: "bafymembership-guest", 810 forumUri, 811 roleUri: null, 812 joinedAt: new Date(), 813 createdAt: new Date(), 814 indexedAt: new Date(), 815 }); 816 817 mockUser = { did: "did:plc:test-guest" }; 818 const res = await app.request("/api/admin/members/me"); 819 expect(res.status).toBe(200); 820 const data = await res.json(); 821 expect(data.did).toBe("did:plc:test-guest"); 822 expect(data.role).toBe("Guest"); 823 expect(data.roleUri).toBeNull(); 824 expect(data.permissions).toEqual([]); 825 }); 826 }); 827 828 describe.sequential("POST /api/admin/categories", () => { 829 beforeEach(async () => { 830 await ctx.cleanDatabase(); 831 832 mockUser = { did: "did:plc:test-admin" }; 833 mockPutRecord.mockClear(); 834 mockDeleteRecord.mockClear(); 835 mockPutRecord.mockResolvedValue({ data: { uri: `at://${ctx.config.forumDid}/space.atbb.forum.category/tid123`, cid: "bafycategory" } }); 836 }); 837 838 it("creates category with valid body → 201 and putRecord called", async () => { 839 const res = await app.request("/api/admin/categories", { 840 method: "POST", 841 headers: { "Content-Type": "application/json" }, 842 body: JSON.stringify({ name: "General Discussion", description: "Talk about anything.", sortOrder: 1 }), 843 }); 844 845 expect(res.status).toBe(201); 846 const data = await res.json(); 847 expect(data.uri).toContain("/space.atbb.forum.category/"); 848 expect(data.cid).toBe("bafycategory"); 849 expect(mockPutRecord).toHaveBeenCalledWith( 850 expect.objectContaining({ 851 repo: ctx.config.forumDid, 852 collection: "space.atbb.forum.category", 853 rkey: expect.any(String), 854 record: expect.objectContaining({ 855 $type: "space.atbb.forum.category", 856 name: "General Discussion", 857 description: "Talk about anything.", 858 sortOrder: 1, 859 createdAt: expect.any(String), 860 }), 861 }) 862 ); 863 }); 864 865 it("creates category without optional fields → 201", async () => { 866 const res = await app.request("/api/admin/categories", { 867 method: "POST", 868 headers: { "Content-Type": "application/json" }, 869 body: JSON.stringify({ name: "Minimal" }), 870 }); 871 872 expect(res.status).toBe(201); 873 expect(mockPutRecord).toHaveBeenCalledWith( 874 expect.objectContaining({ 875 record: expect.objectContaining({ name: "Minimal" }), 876 }) 877 ); 878 }); 879 880 it("returns 400 when name is missing → no PDS write", async () => { 881 const res = await app.request("/api/admin/categories", { 882 method: "POST", 883 headers: { "Content-Type": "application/json" }, 884 body: JSON.stringify({ description: "No name field" }), 885 }); 886 887 expect(res.status).toBe(400); 888 const data = await res.json(); 889 expect(data.error).toContain("name"); 890 expect(mockPutRecord).not.toHaveBeenCalled(); 891 }); 892 893 it("returns 400 when name is empty string → no PDS write", async () => { 894 const res = await app.request("/api/admin/categories", { 895 method: "POST", 896 headers: { "Content-Type": "application/json" }, 897 body: JSON.stringify({ name: " " }), 898 }); 899 900 expect(res.status).toBe(400); 901 expect(mockPutRecord).not.toHaveBeenCalled(); 902 }); 903 904 it("returns 400 for malformed JSON", async () => { 905 const res = await app.request("/api/admin/categories", { 906 method: "POST", 907 headers: { "Content-Type": "application/json" }, 908 body: "{ bad json }", 909 }); 910 911 expect(res.status).toBe(400); 912 const data = await res.json(); 913 expect(data.error).toContain("Invalid JSON"); 914 expect(mockPutRecord).not.toHaveBeenCalled(); 915 }); 916 917 it("returns 401 when unauthenticated → no PDS write", async () => { 918 mockUser = null; 919 920 const res = await app.request("/api/admin/categories", { 921 method: "POST", 922 headers: { "Content-Type": "application/json" }, 923 body: JSON.stringify({ name: "Test" }), 924 }); 925 926 expect(res.status).toBe(401); 927 expect(mockPutRecord).not.toHaveBeenCalled(); 928 }); 929 930 it("returns 503 when PDS network error", async () => { 931 mockPutRecord.mockRejectedValue(new Error("fetch failed")); 932 933 const res = await app.request("/api/admin/categories", { 934 method: "POST", 935 headers: { "Content-Type": "application/json" }, 936 body: JSON.stringify({ name: "Test" }), 937 }); 938 939 expect(res.status).toBe(503); 940 const data = await res.json(); 941 expect(data.error).toContain("Unable to reach external service"); 942 expect(mockPutRecord).toHaveBeenCalled(); 943 }); 944 945 it("returns 500 when ForumAgent unavailable", async () => { 946 ctx.forumAgent = null; 947 948 const res = await app.request("/api/admin/categories", { 949 method: "POST", 950 headers: { "Content-Type": "application/json" }, 951 body: JSON.stringify({ name: "Test" }), 952 }); 953 954 expect(res.status).toBe(500); 955 const data = await res.json(); 956 expect(data.error).toContain("Forum agent not available"); 957 }); 958 959 it("returns 503 when ForumAgent not authenticated", async () => { 960 const originalAgent = ctx.forumAgent; 961 ctx.forumAgent = { getAgent: () => null } as any; 962 963 const res = await app.request("/api/admin/categories", { 964 method: "POST", 965 headers: { "Content-Type": "application/json" }, 966 body: JSON.stringify({ name: "Test" }), 967 }); 968 969 expect(res.status).toBe(503); 970 const data = await res.json(); 971 expect(data.error).toBe("Forum agent not authenticated. Please try again later."); 972 expect(mockPutRecord).not.toHaveBeenCalled(); 973 974 ctx.forumAgent = originalAgent; 975 }); 976 977 it("returns 403 when user lacks manageCategories permission", async () => { 978 const { requirePermission } = await import("../../middleware/permissions.js"); 979 const mockRequirePermission = requirePermission as any; 980 mockRequirePermission.mockImplementation(() => async (c: any) => { 981 return c.json({ error: "Forbidden" }, 403); 982 }); 983 984 const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 985 const res = await testApp.request("/api/admin/categories", { 986 method: "POST", 987 headers: { "Content-Type": "application/json" }, 988 body: JSON.stringify({ name: "Test" }), 989 }); 990 991 expect(res.status).toBe(403); 992 expect(mockPutRecord).not.toHaveBeenCalled(); 993 994 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 995 await next(); 996 }); 997 }); 998 }); 999 1000 describe.sequential("PUT /api/admin/categories/:id", () => { 1001 let categoryId: string; 1002 1003 beforeEach(async () => { 1004 await ctx.cleanDatabase(); 1005 1006 await ctx.db.insert(forums).values({ 1007 did: ctx.config.forumDid, 1008 rkey: "self", 1009 cid: "bafytest", 1010 name: "Test Forum", 1011 description: "A test forum", 1012 indexedAt: new Date(), 1013 }); 1014 1015 const [cat] = await ctx.db.insert(categories).values({ 1016 did: ctx.config.forumDid, 1017 rkey: "tid-test-cat", 1018 cid: "bafycat", 1019 name: "Original Name", 1020 description: "Original description", 1021 sortOrder: 1, 1022 createdAt: new Date("2026-01-01T00:00:00.000Z"), 1023 indexedAt: new Date(), 1024 }).returning({ id: categories.id }); 1025 1026 categoryId = cat.id.toString(); 1027 1028 mockUser = { did: "did:plc:test-admin" }; 1029 mockPutRecord.mockClear(); 1030 mockDeleteRecord.mockClear(); 1031 mockPutRecord.mockResolvedValue({ data: { uri: `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`, cid: "bafynewcid" } }); 1032 }); 1033 1034 it("updates category name → 200 and putRecord called with same rkey", async () => { 1035 const res = await app.request(`/api/admin/categories/${categoryId}`, { 1036 method: "PUT", 1037 headers: { "Content-Type": "application/json" }, 1038 body: JSON.stringify({ name: "Updated Name", description: "New desc", sortOrder: 2 }), 1039 }); 1040 1041 expect(res.status).toBe(200); 1042 const data = await res.json(); 1043 expect(data.uri).toContain("/space.atbb.forum.category/"); 1044 expect(data.cid).toBe("bafynewcid"); 1045 expect(mockPutRecord).toHaveBeenCalledWith( 1046 expect.objectContaining({ 1047 repo: ctx.config.forumDid, 1048 collection: "space.atbb.forum.category", 1049 rkey: "tid-test-cat", 1050 record: expect.objectContaining({ 1051 $type: "space.atbb.forum.category", 1052 name: "Updated Name", 1053 description: "New desc", 1054 sortOrder: 2, 1055 }), 1056 }) 1057 ); 1058 }); 1059 1060 it("preserves original createdAt, description, and sortOrder when not provided", async () => { 1061 // category was created with description: "Original description", sortOrder: 1 1062 const res = await app.request(`/api/admin/categories/${categoryId}`, { 1063 method: "PUT", 1064 headers: { "Content-Type": "application/json" }, 1065 body: JSON.stringify({ name: "Updated Name" }), 1066 }); 1067 1068 expect(res.status).toBe(200); 1069 expect(mockPutRecord).toHaveBeenCalledWith( 1070 expect.objectContaining({ 1071 record: expect.objectContaining({ 1072 createdAt: "2026-01-01T00:00:00.000Z", 1073 description: "Original description", 1074 sortOrder: 1, 1075 }), 1076 }) 1077 ); 1078 }); 1079 1080 it("returns 400 when name is missing", async () => { 1081 const res = await app.request(`/api/admin/categories/${categoryId}`, { 1082 method: "PUT", 1083 headers: { "Content-Type": "application/json" }, 1084 body: JSON.stringify({ description: "No name" }), 1085 }); 1086 1087 expect(res.status).toBe(400); 1088 const data = await res.json(); 1089 expect(data.error).toContain("name"); 1090 expect(mockPutRecord).not.toHaveBeenCalled(); 1091 }); 1092 1093 it("returns 400 when name is whitespace-only", async () => { 1094 const res = await app.request(`/api/admin/categories/${categoryId}`, { 1095 method: "PUT", 1096 headers: { "Content-Type": "application/json" }, 1097 body: JSON.stringify({ name: " " }), 1098 }); 1099 1100 expect(res.status).toBe(400); 1101 expect(mockPutRecord).not.toHaveBeenCalled(); 1102 }); 1103 1104 it("returns 400 for malformed JSON", async () => { 1105 const res = await app.request(`/api/admin/categories/${categoryId}`, { 1106 method: "PUT", 1107 headers: { "Content-Type": "application/json" }, 1108 body: "{ bad json }", 1109 }); 1110 1111 expect(res.status).toBe(400); 1112 const data = await res.json(); 1113 expect(data.error).toContain("Invalid JSON"); 1114 expect(mockPutRecord).not.toHaveBeenCalled(); 1115 }); 1116 1117 it("returns 400 for invalid category ID format", async () => { 1118 const res = await app.request("/api/admin/categories/not-a-number", { 1119 method: "PUT", 1120 headers: { "Content-Type": "application/json" }, 1121 body: JSON.stringify({ name: "Test" }), 1122 }); 1123 1124 expect(res.status).toBe(400); 1125 const data = await res.json(); 1126 expect(data.error).toContain("Invalid category ID"); 1127 expect(mockPutRecord).not.toHaveBeenCalled(); 1128 }); 1129 1130 it("returns 404 when category not found", async () => { 1131 const res = await app.request("/api/admin/categories/99999", { 1132 method: "PUT", 1133 headers: { "Content-Type": "application/json" }, 1134 body: JSON.stringify({ name: "Test" }), 1135 }); 1136 1137 expect(res.status).toBe(404); 1138 const data = await res.json(); 1139 expect(data.error).toContain("Category not found"); 1140 expect(mockPutRecord).not.toHaveBeenCalled(); 1141 }); 1142 1143 it("returns 401 when unauthenticated", async () => { 1144 mockUser = null; 1145 1146 const res = await app.request(`/api/admin/categories/${categoryId}`, { 1147 method: "PUT", 1148 headers: { "Content-Type": "application/json" }, 1149 body: JSON.stringify({ name: "Test" }), 1150 }); 1151 1152 expect(res.status).toBe(401); 1153 expect(mockPutRecord).not.toHaveBeenCalled(); 1154 }); 1155 1156 it("returns 503 when PDS network error", async () => { 1157 mockPutRecord.mockRejectedValue(new Error("fetch failed")); 1158 1159 const res = await app.request(`/api/admin/categories/${categoryId}`, { 1160 method: "PUT", 1161 headers: { "Content-Type": "application/json" }, 1162 body: JSON.stringify({ name: "Test" }), 1163 }); 1164 1165 expect(res.status).toBe(503); 1166 const data = await res.json(); 1167 expect(data.error).toContain("Unable to reach external service"); 1168 expect(mockPutRecord).toHaveBeenCalled(); 1169 }); 1170 1171 it("returns 500 when ForumAgent unavailable", async () => { 1172 ctx.forumAgent = null; 1173 1174 const res = await app.request(`/api/admin/categories/${categoryId}`, { 1175 method: "PUT", 1176 headers: { "Content-Type": "application/json" }, 1177 body: JSON.stringify({ name: "Test" }), 1178 }); 1179 1180 expect(res.status).toBe(500); 1181 const data = await res.json(); 1182 expect(data.error).toContain("Forum agent not available"); 1183 }); 1184 1185 it("returns 503 when ForumAgent not authenticated", async () => { 1186 const originalAgent = ctx.forumAgent; 1187 ctx.forumAgent = { getAgent: () => null } as any; 1188 1189 const res = await app.request(`/api/admin/categories/${categoryId}`, { 1190 method: "PUT", 1191 headers: { "Content-Type": "application/json" }, 1192 body: JSON.stringify({ name: "Test" }), 1193 }); 1194 1195 expect(res.status).toBe(503); 1196 const data = await res.json(); 1197 expect(data.error).toBe("Forum agent not authenticated. Please try again later."); 1198 expect(mockPutRecord).not.toHaveBeenCalled(); 1199 1200 ctx.forumAgent = originalAgent; 1201 }); 1202 1203 it("returns 503 when category lookup query fails", async () => { 1204 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 1205 throw new Error("Database connection lost"); 1206 }); 1207 1208 const res = await app.request(`/api/admin/categories/${categoryId}`, { 1209 method: "PUT", 1210 headers: { "Content-Type": "application/json" }, 1211 body: JSON.stringify({ name: "Test" }), 1212 }); 1213 1214 expect(res.status).toBe(503); 1215 const data = await res.json(); 1216 expect(data.error).toBe("Database temporarily unavailable. Please try again later."); 1217 expect(mockPutRecord).not.toHaveBeenCalled(); 1218 1219 dbSelectSpy.mockRestore(); 1220 }); 1221 1222 it("returns 403 when user lacks manageCategories permission", async () => { 1223 const { requirePermission } = await import("../../middleware/permissions.js"); 1224 const mockRequirePermission = requirePermission as any; 1225 mockRequirePermission.mockImplementation(() => async (c: any) => { 1226 return c.json({ error: "Forbidden" }, 403); 1227 }); 1228 1229 const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 1230 const res = await testApp.request(`/api/admin/categories/${categoryId}`, { 1231 method: "PUT", 1232 headers: { "Content-Type": "application/json" }, 1233 body: JSON.stringify({ name: "Test" }), 1234 }); 1235 1236 expect(res.status).toBe(403); 1237 expect(mockPutRecord).not.toHaveBeenCalled(); 1238 1239 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 1240 await next(); 1241 }); 1242 }); 1243 }); 1244 1245 describe.sequential("DELETE /api/admin/categories/:id", () => { 1246 let categoryId: string; 1247 1248 beforeEach(async () => { 1249 await ctx.cleanDatabase(); 1250 1251 await ctx.db.insert(forums).values({ 1252 did: ctx.config.forumDid, 1253 rkey: "self", 1254 cid: "bafytest", 1255 name: "Test Forum", 1256 description: "A test forum", 1257 indexedAt: new Date(), 1258 }); 1259 1260 const [cat] = await ctx.db.insert(categories).values({ 1261 did: ctx.config.forumDid, 1262 rkey: "tid-test-del", 1263 cid: "bafycat", 1264 name: "Delete Me", 1265 description: null, 1266 sortOrder: 1, 1267 createdAt: new Date(), 1268 indexedAt: new Date(), 1269 }).returning({ id: categories.id }); 1270 1271 categoryId = cat.id.toString(); 1272 1273 mockUser = { did: "did:plc:test-admin" }; 1274 mockDeleteRecord.mockClear(); 1275 mockDeleteRecord.mockResolvedValue({}); 1276 }); 1277 1278 it("deletes empty category → 200 and deleteRecord called", async () => { 1279 const res = await app.request(`/api/admin/categories/${categoryId}`, { 1280 method: "DELETE", 1281 }); 1282 1283 expect(res.status).toBe(200); 1284 const data = await res.json(); 1285 expect(data.success).toBe(true); 1286 expect(mockDeleteRecord).toHaveBeenCalledWith({ 1287 repo: ctx.config.forumDid, 1288 collection: "space.atbb.forum.category", 1289 rkey: "tid-test-del", 1290 }); 1291 }); 1292 1293 it("returns 409 when category has boards → deleteRecord NOT called", async () => { 1294 await ctx.db.insert(boards).values({ 1295 did: ctx.config.forumDid, 1296 rkey: "tid-board-1", 1297 cid: "bafyboard", 1298 name: "Blocked Board", 1299 categoryId: BigInt(categoryId), 1300 categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-del`, 1301 createdAt: new Date(), 1302 indexedAt: new Date(), 1303 }); 1304 1305 const res = await app.request(`/api/admin/categories/${categoryId}`, { 1306 method: "DELETE", 1307 }); 1308 1309 expect(res.status).toBe(409); 1310 const data = await res.json(); 1311 expect(data.error).toContain("boards"); 1312 expect(mockDeleteRecord).not.toHaveBeenCalled(); 1313 }); 1314 1315 it("returns 400 for invalid category ID", async () => { 1316 const res = await app.request("/api/admin/categories/not-a-number", { 1317 method: "DELETE", 1318 }); 1319 1320 expect(res.status).toBe(400); 1321 const data = await res.json(); 1322 expect(data.error).toContain("Invalid category ID"); 1323 expect(mockDeleteRecord).not.toHaveBeenCalled(); 1324 }); 1325 1326 it("returns 404 when category not found", async () => { 1327 const res = await app.request("/api/admin/categories/99999", { 1328 method: "DELETE", 1329 }); 1330 1331 expect(res.status).toBe(404); 1332 const data = await res.json(); 1333 expect(data.error).toContain("Category not found"); 1334 expect(mockDeleteRecord).not.toHaveBeenCalled(); 1335 }); 1336 1337 it("returns 401 when unauthenticated", async () => { 1338 mockUser = null; 1339 1340 const res = await app.request(`/api/admin/categories/${categoryId}`, { 1341 method: "DELETE", 1342 }); 1343 1344 expect(res.status).toBe(401); 1345 expect(mockDeleteRecord).not.toHaveBeenCalled(); 1346 }); 1347 1348 it("returns 503 when PDS network error on delete", async () => { 1349 mockDeleteRecord.mockRejectedValue(new Error("fetch failed")); 1350 1351 const res = await app.request(`/api/admin/categories/${categoryId}`, { 1352 method: "DELETE", 1353 }); 1354 1355 expect(res.status).toBe(503); 1356 const data = await res.json(); 1357 expect(data.error).toContain("Unable to reach external service"); 1358 expect(mockDeleteRecord).toHaveBeenCalled(); 1359 }); 1360 1361 it("returns 500 when ForumAgent unavailable", async () => { 1362 ctx.forumAgent = null; 1363 1364 const res = await app.request(`/api/admin/categories/${categoryId}`, { 1365 method: "DELETE", 1366 }); 1367 1368 expect(res.status).toBe(500); 1369 const data = await res.json(); 1370 expect(data.error).toContain("Forum agent not available"); 1371 }); 1372 1373 it("returns 503 when ForumAgent not authenticated", async () => { 1374 const originalAgent = ctx.forumAgent; 1375 ctx.forumAgent = { getAgent: () => null } as any; 1376 1377 const res = await app.request(`/api/admin/categories/${categoryId}`, { 1378 method: "DELETE", 1379 }); 1380 1381 expect(res.status).toBe(503); 1382 const data = await res.json(); 1383 expect(data.error).toBe("Forum agent not authenticated. Please try again later."); 1384 expect(mockDeleteRecord).not.toHaveBeenCalled(); 1385 1386 ctx.forumAgent = originalAgent; 1387 }); 1388 1389 it("returns 503 when category lookup query fails", async () => { 1390 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 1391 throw new Error("Database connection lost"); 1392 }); 1393 1394 const res = await app.request(`/api/admin/categories/${categoryId}`, { 1395 method: "DELETE", 1396 }); 1397 1398 expect(res.status).toBe(503); 1399 const data = await res.json(); 1400 expect(data.error).toBe("Database temporarily unavailable. Please try again later."); 1401 expect(mockDeleteRecord).not.toHaveBeenCalled(); 1402 1403 dbSelectSpy.mockRestore(); 1404 }); 1405 1406 it("returns 503 when board count query fails", async () => { 1407 const originalSelect = ctx.db.select.bind(ctx.db); 1408 let callCount = 0; 1409 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementation((...args: any[]) => { 1410 callCount++; 1411 if (callCount === 1) { 1412 // First call: category lookup — pass through to real DB 1413 return (originalSelect as any)(...args); 1414 } 1415 // Second call: board count preflight — throw DB error 1416 throw new Error("Database connection lost"); 1417 }); 1418 1419 const res = await app.request(`/api/admin/categories/${categoryId}`, { 1420 method: "DELETE", 1421 }); 1422 1423 expect(res.status).toBe(503); 1424 const data = await res.json(); 1425 expect(data.error).toBe("Database temporarily unavailable. Please try again later."); 1426 expect(mockDeleteRecord).not.toHaveBeenCalled(); 1427 1428 dbSelectSpy.mockRestore(); 1429 }); 1430 1431 it("returns 403 when user lacks manageCategories permission", async () => { 1432 const { requirePermission } = await import("../../middleware/permissions.js"); 1433 const mockRequirePermission = requirePermission as any; 1434 mockRequirePermission.mockImplementation(() => async (c: any) => { 1435 return c.json({ error: "Forbidden" }, 403); 1436 }); 1437 1438 const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 1439 const res = await testApp.request(`/api/admin/categories/${categoryId}`, { 1440 method: "DELETE", 1441 }); 1442 1443 expect(res.status).toBe(403); 1444 expect(mockDeleteRecord).not.toHaveBeenCalled(); 1445 1446 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 1447 await next(); 1448 }); 1449 }); 1450 }); 1451 1452 describe.sequential("POST /api/admin/boards", () => { 1453 let categoryUri: string; 1454 1455 beforeEach(async () => { 1456 await ctx.cleanDatabase(); 1457 1458 mockUser = { did: "did:plc:test-admin" }; 1459 mockPutRecord.mockClear(); 1460 mockDeleteRecord.mockClear(); 1461 mockPutRecord.mockResolvedValue({ 1462 data: { 1463 uri: `at://${ctx.config.forumDid}/space.atbb.forum.board/tid123`, 1464 cid: "bafyboard", 1465 }, 1466 }); 1467 1468 // Insert a category the tests can reference 1469 await ctx.db.insert(categories).values({ 1470 did: ctx.config.forumDid, 1471 rkey: "tid-test-cat", 1472 cid: "bafycat", 1473 name: "Test Category", 1474 createdAt: new Date("2026-01-01T00:00:00.000Z"), 1475 indexedAt: new Date(), 1476 }); 1477 categoryUri = `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`; 1478 }); 1479 1480 it("creates board with valid body → 201 and putRecord called with categoryRef", async () => { 1481 const res = await app.request("/api/admin/boards", { 1482 method: "POST", 1483 headers: { "Content-Type": "application/json" }, 1484 body: JSON.stringify({ name: "General Chat", description: "Talk here.", sortOrder: 1, categoryUri }), 1485 }); 1486 1487 expect(res.status).toBe(201); 1488 const data = await res.json(); 1489 expect(data.uri).toContain("/space.atbb.forum.board/"); 1490 expect(data.cid).toBe("bafyboard"); 1491 expect(mockPutRecord).toHaveBeenCalledWith( 1492 expect.objectContaining({ 1493 repo: ctx.config.forumDid, 1494 collection: "space.atbb.forum.board", 1495 rkey: expect.any(String), 1496 record: expect.objectContaining({ 1497 $type: "space.atbb.forum.board", 1498 name: "General Chat", 1499 description: "Talk here.", 1500 sortOrder: 1, 1501 category: { category: { uri: categoryUri, cid: "bafycat" } }, 1502 createdAt: expect.any(String), 1503 }), 1504 }) 1505 ); 1506 }); 1507 1508 it("creates board without optional fields → 201", async () => { 1509 const res = await app.request("/api/admin/boards", { 1510 method: "POST", 1511 headers: { "Content-Type": "application/json" }, 1512 body: JSON.stringify({ name: "Minimal", categoryUri }), 1513 }); 1514 1515 expect(res.status).toBe(201); 1516 expect(mockPutRecord).toHaveBeenCalledWith( 1517 expect.objectContaining({ 1518 record: expect.objectContaining({ name: "Minimal" }), 1519 }) 1520 ); 1521 }); 1522 1523 it("returns 400 when name is missing → no PDS write", async () => { 1524 const res = await app.request("/api/admin/boards", { 1525 method: "POST", 1526 headers: { "Content-Type": "application/json" }, 1527 body: JSON.stringify({ categoryUri }), 1528 }); 1529 1530 expect(res.status).toBe(400); 1531 const data = await res.json(); 1532 expect(data.error).toContain("name"); 1533 expect(mockPutRecord).not.toHaveBeenCalled(); 1534 }); 1535 1536 it("returns 400 when name is empty string → no PDS write", async () => { 1537 const res = await app.request("/api/admin/boards", { 1538 method: "POST", 1539 headers: { "Content-Type": "application/json" }, 1540 body: JSON.stringify({ name: " ", categoryUri }), 1541 }); 1542 1543 expect(res.status).toBe(400); 1544 expect(mockPutRecord).not.toHaveBeenCalled(); 1545 }); 1546 1547 it("returns 400 when categoryUri is missing → no PDS write", async () => { 1548 const res = await app.request("/api/admin/boards", { 1549 method: "POST", 1550 headers: { "Content-Type": "application/json" }, 1551 body: JSON.stringify({ name: "Test Board" }), 1552 }); 1553 1554 expect(res.status).toBe(400); 1555 const data = await res.json(); 1556 expect(data.error).toContain("categoryUri"); 1557 expect(mockPutRecord).not.toHaveBeenCalled(); 1558 }); 1559 1560 it("returns 404 when categoryUri references unknown category → no PDS write", async () => { 1561 const res = await app.request("/api/admin/boards", { 1562 method: "POST", 1563 headers: { "Content-Type": "application/json" }, 1564 body: JSON.stringify({ name: "Test Board", categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/unknown999` }), 1565 }); 1566 1567 expect(res.status).toBe(404); 1568 const data = await res.json(); 1569 expect(data.error).toContain("Category not found"); 1570 expect(mockPutRecord).not.toHaveBeenCalled(); 1571 }); 1572 1573 it("returns 400 for malformed JSON", async () => { 1574 const res = await app.request("/api/admin/boards", { 1575 method: "POST", 1576 headers: { "Content-Type": "application/json" }, 1577 body: "{ bad json }", 1578 }); 1579 1580 expect(res.status).toBe(400); 1581 const data = await res.json(); 1582 expect(data.error).toContain("Invalid JSON"); 1583 expect(mockPutRecord).not.toHaveBeenCalled(); 1584 }); 1585 1586 it("returns 401 when unauthenticated → no PDS write", async () => { 1587 mockUser = null; 1588 1589 const res = await app.request("/api/admin/boards", { 1590 method: "POST", 1591 headers: { "Content-Type": "application/json" }, 1592 body: JSON.stringify({ name: "Test", categoryUri }), 1593 }); 1594 1595 expect(res.status).toBe(401); 1596 expect(mockPutRecord).not.toHaveBeenCalled(); 1597 }); 1598 1599 it("returns 503 when PDS network error", async () => { 1600 mockPutRecord.mockRejectedValue(new Error("fetch failed")); 1601 1602 const res = await app.request("/api/admin/boards", { 1603 method: "POST", 1604 headers: { "Content-Type": "application/json" }, 1605 body: JSON.stringify({ name: "Test", categoryUri }), 1606 }); 1607 1608 expect(res.status).toBe(503); 1609 const data = await res.json(); 1610 expect(data.error).toContain("Unable to reach external service"); 1611 expect(mockPutRecord).toHaveBeenCalled(); 1612 }); 1613 1614 it("returns 500 when ForumAgent unavailable", async () => { 1615 ctx.forumAgent = null; 1616 1617 const res = await app.request("/api/admin/boards", { 1618 method: "POST", 1619 headers: { "Content-Type": "application/json" }, 1620 body: JSON.stringify({ name: "Test", categoryUri }), 1621 }); 1622 1623 expect(res.status).toBe(500); 1624 const data = await res.json(); 1625 expect(data.error).toContain("Forum agent not available"); 1626 }); 1627 1628 it("returns 503 when ForumAgent not authenticated", async () => { 1629 const originalAgent = ctx.forumAgent; 1630 ctx.forumAgent = { getAgent: () => null } as any; 1631 1632 const res = await app.request("/api/admin/boards", { 1633 method: "POST", 1634 headers: { "Content-Type": "application/json" }, 1635 body: JSON.stringify({ name: "Test", categoryUri }), 1636 }); 1637 1638 expect(res.status).toBe(503); 1639 const data = await res.json(); 1640 expect(data.error).toBe("Forum agent not authenticated. Please try again later."); 1641 expect(mockPutRecord).not.toHaveBeenCalled(); 1642 1643 ctx.forumAgent = originalAgent; 1644 }); 1645 1646 it("returns 503 when category lookup query fails", async () => { 1647 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 1648 throw new Error("Database connection lost"); 1649 }); 1650 1651 const res = await app.request("/api/admin/boards", { 1652 method: "POST", 1653 headers: { "Content-Type": "application/json" }, 1654 body: JSON.stringify({ name: "Test Board", categoryUri }), 1655 }); 1656 1657 expect(res.status).toBe(503); 1658 const data = await res.json(); 1659 expect(data.error).toBe("Database temporarily unavailable. Please try again later."); 1660 expect(mockPutRecord).not.toHaveBeenCalled(); 1661 1662 dbSelectSpy.mockRestore(); 1663 }); 1664 1665 it("returns 403 when user lacks manageCategories permission", async () => { 1666 const { requirePermission } = await import("../../middleware/permissions.js"); 1667 const mockRequirePermission = requirePermission as any; 1668 mockRequirePermission.mockImplementation(() => async (c: any) => { 1669 return c.json({ error: "Forbidden" }, 403); 1670 }); 1671 1672 const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 1673 const res = await testApp.request("/api/admin/boards", { 1674 method: "POST", 1675 headers: { "Content-Type": "application/json" }, 1676 body: JSON.stringify({ name: "Test", categoryUri }), 1677 }); 1678 1679 expect(res.status).toBe(403); 1680 expect(mockPutRecord).not.toHaveBeenCalled(); 1681 1682 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 1683 await next(); 1684 }); 1685 }); 1686 }); 1687 1688 describe.sequential("PUT /api/admin/boards/:id", () => { 1689 let boardId: string; 1690 let categoryUri: string; 1691 1692 beforeEach(async () => { 1693 await ctx.cleanDatabase(); 1694 1695 mockUser = { did: "did:plc:test-admin" }; 1696 mockPutRecord.mockClear(); 1697 mockDeleteRecord.mockClear(); 1698 mockPutRecord.mockResolvedValue({ 1699 data: { 1700 uri: `at://${ctx.config.forumDid}/space.atbb.forum.board/tid-test-board`, 1701 cid: "bafyboardupdated", 1702 }, 1703 }); 1704 1705 // Insert a category and a board 1706 const [cat] = await ctx.db.insert(categories).values({ 1707 did: ctx.config.forumDid, 1708 rkey: "tid-test-cat", 1709 cid: "bafycat", 1710 name: "Test Category", 1711 createdAt: new Date("2026-01-01T00:00:00.000Z"), 1712 indexedAt: new Date(), 1713 }).returning({ id: categories.id }); 1714 1715 categoryUri = `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`; 1716 1717 const [brd] = await ctx.db.insert(boards).values({ 1718 did: ctx.config.forumDid, 1719 rkey: "tid-test-board", 1720 cid: "bafyboard", 1721 name: "Original Name", 1722 description: "Original description", 1723 sortOrder: 1, 1724 categoryId: cat.id, 1725 categoryUri, 1726 createdAt: new Date("2026-01-01T00:00:00.000Z"), 1727 indexedAt: new Date(), 1728 }).returning({ id: boards.id }); 1729 1730 boardId = brd.id.toString(); 1731 }); 1732 1733 it("updates board with all fields → 200 and putRecord called with same rkey", async () => { 1734 const res = await app.request(`/api/admin/boards/${boardId}`, { 1735 method: "PUT", 1736 headers: { "Content-Type": "application/json" }, 1737 body: JSON.stringify({ name: "Renamed Board", description: "New description", sortOrder: 2 }), 1738 }); 1739 1740 expect(res.status).toBe(200); 1741 const data = await res.json(); 1742 expect(data.uri).toContain("/space.atbb.forum.board/"); 1743 expect(data.cid).toBe("bafyboardupdated"); 1744 expect(mockPutRecord).toHaveBeenCalledWith( 1745 expect.objectContaining({ 1746 repo: ctx.config.forumDid, 1747 collection: "space.atbb.forum.board", 1748 rkey: "tid-test-board", 1749 record: expect.objectContaining({ 1750 $type: "space.atbb.forum.board", 1751 name: "Renamed Board", 1752 description: "New description", 1753 sortOrder: 2, 1754 category: { category: { uri: categoryUri, cid: "bafycat" } }, 1755 }), 1756 }) 1757 ); 1758 }); 1759 1760 it("updates board without optional fields → falls back to existing values", async () => { 1761 const res = await app.request(`/api/admin/boards/${boardId}`, { 1762 method: "PUT", 1763 headers: { "Content-Type": "application/json" }, 1764 body: JSON.stringify({ name: "Renamed Only" }), 1765 }); 1766 1767 expect(res.status).toBe(200); 1768 expect(mockPutRecord).toHaveBeenCalledWith( 1769 expect.objectContaining({ 1770 record: expect.objectContaining({ 1771 name: "Renamed Only", 1772 description: "Original description", 1773 sortOrder: 1, 1774 }), 1775 }) 1776 ); 1777 }); 1778 1779 it("returns 400 when name is missing", async () => { 1780 const res = await app.request(`/api/admin/boards/${boardId}`, { 1781 method: "PUT", 1782 headers: { "Content-Type": "application/json" }, 1783 body: JSON.stringify({ description: "No name" }), 1784 }); 1785 1786 expect(res.status).toBe(400); 1787 const data = await res.json(); 1788 expect(data.error).toContain("name"); 1789 expect(mockPutRecord).not.toHaveBeenCalled(); 1790 }); 1791 1792 it("returns 400 when name is empty string", async () => { 1793 const res = await app.request(`/api/admin/boards/${boardId}`, { 1794 method: "PUT", 1795 headers: { "Content-Type": "application/json" }, 1796 body: JSON.stringify({ name: " " }), 1797 }); 1798 1799 expect(res.status).toBe(400); 1800 expect(mockPutRecord).not.toHaveBeenCalled(); 1801 }); 1802 1803 it("returns 400 for non-numeric ID", async () => { 1804 const res = await app.request("/api/admin/boards/not-a-number", { 1805 method: "PUT", 1806 headers: { "Content-Type": "application/json" }, 1807 body: JSON.stringify({ name: "Test" }), 1808 }); 1809 1810 expect(res.status).toBe(400); 1811 expect(mockPutRecord).not.toHaveBeenCalled(); 1812 }); 1813 1814 it("returns 404 when board not found", async () => { 1815 const res = await app.request("/api/admin/boards/99999", { 1816 method: "PUT", 1817 headers: { "Content-Type": "application/json" }, 1818 body: JSON.stringify({ name: "Test" }), 1819 }); 1820 1821 expect(res.status).toBe(404); 1822 const data = await res.json(); 1823 expect(data.error).toContain("Board not found"); 1824 expect(mockPutRecord).not.toHaveBeenCalled(); 1825 }); 1826 1827 it("returns 400 for malformed JSON", async () => { 1828 const res = await app.request(`/api/admin/boards/${boardId}`, { 1829 method: "PUT", 1830 headers: { "Content-Type": "application/json" }, 1831 body: "{ bad json }", 1832 }); 1833 1834 expect(res.status).toBe(400); 1835 const data = await res.json(); 1836 expect(data.error).toContain("Invalid JSON"); 1837 expect(mockPutRecord).not.toHaveBeenCalled(); 1838 }); 1839 1840 it("returns 401 when unauthenticated", async () => { 1841 mockUser = null; 1842 1843 const res = await app.request(`/api/admin/boards/${boardId}`, { 1844 method: "PUT", 1845 headers: { "Content-Type": "application/json" }, 1846 body: JSON.stringify({ name: "Test" }), 1847 }); 1848 1849 expect(res.status).toBe(401); 1850 expect(mockPutRecord).not.toHaveBeenCalled(); 1851 }); 1852 1853 it("returns 503 when PDS network error", async () => { 1854 mockPutRecord.mockRejectedValue(new Error("fetch failed")); 1855 1856 const res = await app.request(`/api/admin/boards/${boardId}`, { 1857 method: "PUT", 1858 headers: { "Content-Type": "application/json" }, 1859 body: JSON.stringify({ name: "Test" }), 1860 }); 1861 1862 expect(res.status).toBe(503); 1863 const data = await res.json(); 1864 expect(data.error).toContain("Unable to reach external service"); 1865 }); 1866 1867 it("returns 500 when ForumAgent unavailable", async () => { 1868 ctx.forumAgent = null; 1869 1870 const res = await app.request(`/api/admin/boards/${boardId}`, { 1871 method: "PUT", 1872 headers: { "Content-Type": "application/json" }, 1873 body: JSON.stringify({ name: "Test" }), 1874 }); 1875 1876 expect(res.status).toBe(500); 1877 const data = await res.json(); 1878 expect(data.error).toContain("Forum agent not available"); 1879 }); 1880 1881 it("returns 503 when ForumAgent not authenticated", async () => { 1882 const originalAgent = ctx.forumAgent; 1883 ctx.forumAgent = { getAgent: () => null } as any; 1884 1885 const res = await app.request(`/api/admin/boards/${boardId}`, { 1886 method: "PUT", 1887 headers: { "Content-Type": "application/json" }, 1888 body: JSON.stringify({ name: "Test" }), 1889 }); 1890 1891 expect(res.status).toBe(503); 1892 const data = await res.json(); 1893 expect(data.error).toBe("Forum agent not authenticated. Please try again later."); 1894 expect(mockPutRecord).not.toHaveBeenCalled(); 1895 1896 ctx.forumAgent = originalAgent; 1897 }); 1898 1899 it("returns 403 when user lacks manageCategories permission", async () => { 1900 const { requirePermission } = await import("../../middleware/permissions.js"); 1901 const mockRequirePermission = requirePermission as any; 1902 mockRequirePermission.mockImplementation(() => async (c: any) => { 1903 return c.json({ error: "Forbidden" }, 403); 1904 }); 1905 1906 const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 1907 const res = await testApp.request(`/api/admin/boards/${boardId}`, { 1908 method: "PUT", 1909 headers: { "Content-Type": "application/json" }, 1910 body: JSON.stringify({ name: "Test" }), 1911 }); 1912 1913 expect(res.status).toBe(403); 1914 expect(mockPutRecord).not.toHaveBeenCalled(); 1915 1916 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 1917 await next(); 1918 }); 1919 }); 1920 1921 it("returns 503 when board lookup query fails", async () => { 1922 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 1923 throw new Error("Database connection lost"); 1924 }); 1925 1926 const res = await app.request(`/api/admin/boards/${boardId}`, { 1927 method: "PUT", 1928 headers: { "Content-Type": "application/json" }, 1929 body: JSON.stringify({ name: "Updated Name" }), 1930 }); 1931 1932 expect(res.status).toBe(503); 1933 const data = await res.json(); 1934 expect(data.error).toBe("Database temporarily unavailable. Please try again later."); 1935 expect(mockPutRecord).not.toHaveBeenCalled(); 1936 1937 dbSelectSpy.mockRestore(); 1938 }); 1939 1940 it("returns 503 when category CID lookup query fails", async () => { 1941 const originalSelect = ctx.db.select.bind(ctx.db); 1942 let callCount = 0; 1943 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementation((...args: any[]) => { 1944 callCount++; 1945 if (callCount === 1) { 1946 // First call: board lookup — pass through to real DB 1947 return (originalSelect as any)(...args); 1948 } 1949 // Second call: category CID fetch — throw DB error 1950 throw new Error("Database connection lost"); 1951 }); 1952 1953 const res = await app.request(`/api/admin/boards/${boardId}`, { 1954 method: "PUT", 1955 headers: { "Content-Type": "application/json" }, 1956 body: JSON.stringify({ name: "Updated Name" }), 1957 }); 1958 1959 expect(res.status).toBe(503); 1960 const data = await res.json(); 1961 expect(data.error).toBe("Database temporarily unavailable. Please try again later."); 1962 expect(mockPutRecord).not.toHaveBeenCalled(); 1963 1964 dbSelectSpy.mockRestore(); 1965 }); 1966 }); 1967 1968 describe.sequential("DELETE /api/admin/boards/:id", () => { 1969 let boardId: string; 1970 let categoryUri: string; 1971 1972 beforeEach(async () => { 1973 await ctx.cleanDatabase(); 1974 1975 mockUser = { did: "did:plc:test-admin" }; 1976 mockPutRecord.mockClear(); 1977 mockDeleteRecord.mockClear(); 1978 mockDeleteRecord.mockResolvedValue({}); 1979 1980 // Insert a category and a board 1981 const [cat] = await ctx.db.insert(categories).values({ 1982 did: ctx.config.forumDid, 1983 rkey: "tid-test-cat", 1984 cid: "bafycat", 1985 name: "Test Category", 1986 createdAt: new Date("2026-01-01T00:00:00.000Z"), 1987 indexedAt: new Date(), 1988 }).returning({ id: categories.id }); 1989 1990 categoryUri = `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`; 1991 1992 const [brd] = await ctx.db.insert(boards).values({ 1993 did: ctx.config.forumDid, 1994 rkey: "tid-test-board", 1995 cid: "bafyboard", 1996 name: "Test Board", 1997 categoryId: cat.id, 1998 categoryUri, 1999 createdAt: new Date("2026-01-01T00:00:00.000Z"), 2000 indexedAt: new Date(), 2001 }).returning({ id: boards.id }); 2002 2003 boardId = brd.id.toString(); 2004 }); 2005 2006 it("deletes empty board → 200 and deleteRecord called", async () => { 2007 const res = await app.request(`/api/admin/boards/${boardId}`, { 2008 method: "DELETE", 2009 }); 2010 2011 expect(res.status).toBe(200); 2012 const data = await res.json(); 2013 expect(data.success).toBe(true); 2014 expect(mockDeleteRecord).toHaveBeenCalledWith({ 2015 repo: ctx.config.forumDid, 2016 collection: "space.atbb.forum.board", 2017 rkey: "tid-test-board", 2018 }); 2019 }); 2020 2021 it("returns 409 when board has posts → deleteRecord NOT called", async () => { 2022 // Insert a user and a post referencing this board 2023 await ctx.db.insert(users).values({ 2024 did: "did:plc:test-user", 2025 handle: "testuser.bsky.social", 2026 indexedAt: new Date(), 2027 }); 2028 2029 const [brd] = await ctx.db.select().from(boards).where(eq(boards.rkey, "tid-test-board")).limit(1); 2030 2031 await ctx.db.insert(posts).values({ 2032 did: "did:plc:test-user", 2033 rkey: "tid-test-post", 2034 cid: "bafypost", 2035 text: "Hello world", 2036 boardId: brd.id, 2037 boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/tid-test-board`, 2038 forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, 2039 createdAt: new Date(), 2040 indexedAt: new Date(), 2041 }); 2042 2043 const res = await app.request(`/api/admin/boards/${boardId}`, { 2044 method: "DELETE", 2045 }); 2046 2047 expect(res.status).toBe(409); 2048 const data = await res.json(); 2049 expect(data.error).toContain("posts"); 2050 expect(mockDeleteRecord).not.toHaveBeenCalled(); 2051 }); 2052 2053 it("returns 400 for non-numeric ID", async () => { 2054 const res = await app.request("/api/admin/boards/not-a-number", { 2055 method: "DELETE", 2056 }); 2057 2058 expect(res.status).toBe(400); 2059 const data = await res.json(); 2060 expect(data.error).toContain("Invalid board ID"); 2061 expect(mockDeleteRecord).not.toHaveBeenCalled(); 2062 }); 2063 2064 it("returns 404 when board not found", async () => { 2065 const res = await app.request("/api/admin/boards/99999", { 2066 method: "DELETE", 2067 }); 2068 2069 expect(res.status).toBe(404); 2070 const data = await res.json(); 2071 expect(data.error).toContain("Board not found"); 2072 expect(mockDeleteRecord).not.toHaveBeenCalled(); 2073 }); 2074 2075 it("returns 503 when board lookup query fails", async () => { 2076 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 2077 throw new Error("Database connection lost"); 2078 }); 2079 2080 const res = await app.request(`/api/admin/boards/${boardId}`, { 2081 method: "DELETE", 2082 }); 2083 2084 expect(res.status).toBe(503); 2085 const data = await res.json(); 2086 expect(data.error).toContain("Please try again later"); 2087 expect(mockDeleteRecord).not.toHaveBeenCalled(); 2088 2089 dbSelectSpy.mockRestore(); 2090 }); 2091 2092 it("returns 503 when post count query fails", async () => { 2093 const originalSelect = ctx.db.select.bind(ctx.db); 2094 let callCount = 0; 2095 const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementation((...args: any[]) => { 2096 callCount++; 2097 if (callCount === 1) { 2098 // First call: board lookup — pass through to real DB 2099 return (originalSelect as any)(...args); 2100 } 2101 // Second call: post count preflight — throw DB error 2102 throw new Error("Database connection lost"); 2103 }); 2104 2105 const res = await app.request(`/api/admin/boards/${boardId}`, { 2106 method: "DELETE", 2107 }); 2108 2109 expect(res.status).toBe(503); 2110 const data = await res.json(); 2111 expect(data.error).toContain("Please try again later"); 2112 expect(mockDeleteRecord).not.toHaveBeenCalled(); 2113 2114 dbSelectSpy.mockRestore(); 2115 }); 2116 2117 it("returns 401 when unauthenticated", async () => { 2118 mockUser = null; 2119 2120 const res = await app.request(`/api/admin/boards/${boardId}`, { 2121 method: "DELETE", 2122 }); 2123 2124 expect(res.status).toBe(401); 2125 expect(mockDeleteRecord).not.toHaveBeenCalled(); 2126 }); 2127 2128 it("returns 503 when PDS network error", async () => { 2129 mockDeleteRecord.mockRejectedValue(new Error("fetch failed")); 2130 2131 const res = await app.request(`/api/admin/boards/${boardId}`, { 2132 method: "DELETE", 2133 }); 2134 2135 expect(res.status).toBe(503); 2136 const data = await res.json(); 2137 expect(data.error).toContain("Unable to reach external service"); 2138 }); 2139 2140 it("returns 500 when ForumAgent unavailable", async () => { 2141 ctx.forumAgent = null; 2142 2143 const res = await app.request(`/api/admin/boards/${boardId}`, { 2144 method: "DELETE", 2145 }); 2146 2147 expect(res.status).toBe(500); 2148 const data = await res.json(); 2149 expect(data.error).toContain("Forum agent not available"); 2150 }); 2151 2152 it("returns 503 when ForumAgent not authenticated", async () => { 2153 const originalAgent = ctx.forumAgent; 2154 ctx.forumAgent = { getAgent: () => null } as any; 2155 2156 const res = await app.request(`/api/admin/boards/${boardId}`, { 2157 method: "DELETE", 2158 }); 2159 2160 expect(res.status).toBe(503); 2161 const data = await res.json(); 2162 expect(data.error).toBe("Forum agent not authenticated. Please try again later."); 2163 expect(mockDeleteRecord).not.toHaveBeenCalled(); 2164 2165 ctx.forumAgent = originalAgent; 2166 }); 2167 2168 it("returns 403 when user lacks manageCategories permission", async () => { 2169 const { requirePermission } = await import("../../middleware/permissions.js"); 2170 const mockRequirePermission = requirePermission as any; 2171 mockRequirePermission.mockImplementation(() => async (c: any) => { 2172 return c.json({ error: "Forbidden" }, 403); 2173 }); 2174 2175 const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 2176 const res = await testApp.request(`/api/admin/boards/${boardId}`, { 2177 method: "DELETE", 2178 }); 2179 2180 expect(res.status).toBe(403); 2181 expect(mockDeleteRecord).not.toHaveBeenCalled(); 2182 2183 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 2184 await next(); 2185 }); 2186 }); 2187 }); 2188 2189 describe("GET /api/admin/modlog", () => { 2190 beforeEach(async () => { 2191 await ctx.cleanDatabase(); 2192 }); 2193 2194 it("returns 401 when not authenticated", async () => { 2195 mockUser = null; 2196 const res = await app.request("/api/admin/modlog"); 2197 expect(res.status).toBe(401); 2198 }); 2199 2200 it("returns 403 when user lacks all mod permissions", async () => { 2201 mockRequireAnyPermissionPass = false; 2202 const res = await app.request("/api/admin/modlog"); 2203 expect(res.status).toBe(403); 2204 }); 2205 2206 it("returns empty list when no mod actions exist", async () => { 2207 const res = await app.request("/api/admin/modlog"); 2208 expect(res.status).toBe(200); 2209 const data = await res.json() as any; 2210 expect(data.actions).toEqual([]); 2211 expect(data.total).toBe(0); 2212 expect(data.offset).toBe(0); 2213 expect(data.limit).toBe(50); 2214 }); 2215 2216 it("returns paginated mod actions with moderator and subject handles", async () => { 2217 await ctx.db.insert(users).values([ 2218 { did: "did:plc:mod-alice", handle: "alice.bsky.social", indexedAt: new Date() }, 2219 { did: "did:plc:subject-bob", handle: "bob.bsky.social", indexedAt: new Date() }, 2220 ]); 2221 2222 await ctx.db.insert(modActions).values({ 2223 did: ctx.config.forumDid, 2224 rkey: "modaction-ban-1", 2225 cid: "cid-ban-1", 2226 action: "space.atbb.modAction.ban", 2227 subjectDid: "did:plc:subject-bob", 2228 subjectPostUri: null, 2229 createdBy: "did:plc:mod-alice", 2230 reason: "Spam", 2231 createdAt: new Date("2026-02-26T12:01:00Z"), 2232 indexedAt: new Date(), 2233 }); 2234 2235 const res = await app.request("/api/admin/modlog"); 2236 expect(res.status).toBe(200); 2237 2238 const data = await res.json() as any; 2239 expect(data.total).toBe(1); 2240 expect(data.actions).toHaveLength(1); 2241 2242 const action = data.actions[0]; 2243 expect(typeof action.id).toBe("string"); 2244 expect(action.action).toBe("space.atbb.modAction.ban"); 2245 expect(action.moderatorDid).toBe("did:plc:mod-alice"); 2246 expect(action.moderatorHandle).toBe("alice.bsky.social"); 2247 expect(action.subjectDid).toBe("did:plc:subject-bob"); 2248 expect(action.subjectHandle).toBe("bob.bsky.social"); 2249 expect(action.subjectPostUri).toBeNull(); 2250 expect(action.reason).toBe("Spam"); 2251 expect(action.createdAt).toBe("2026-02-26T12:01:00.000Z"); 2252 }); 2253 2254 it("returns null subjectHandle and populated subjectPostUri for post-targeting actions", async () => { 2255 await ctx.db.insert(users).values({ 2256 did: "did:plc:mod-carol", 2257 handle: "carol.bsky.social", 2258 indexedAt: new Date(), 2259 }); 2260 2261 await ctx.db.insert(modActions).values({ 2262 did: ctx.config.forumDid, 2263 rkey: "modaction-hide-1", 2264 cid: "cid-hide-1", 2265 action: "space.atbb.modAction.hide", 2266 subjectDid: null, 2267 subjectPostUri: "at://did:plc:user/space.atbb.post/abc123", 2268 createdBy: "did:plc:mod-carol", 2269 reason: "Inappropriate", 2270 createdAt: new Date("2026-02-26T11:30:00Z"), 2271 indexedAt: new Date(), 2272 }); 2273 2274 const res = await app.request("/api/admin/modlog"); 2275 expect(res.status).toBe(200); 2276 2277 const data = await res.json() as any; 2278 const action = data.actions.find((a: any) => a.action === "space.atbb.modAction.hide"); 2279 expect(action).toBeDefined(); 2280 expect(action.subjectDid).toBeNull(); 2281 expect(action.subjectHandle).toBeNull(); 2282 expect(action.subjectPostUri).toBe("at://did:plc:user/space.atbb.post/abc123"); 2283 }); 2284 2285 it("falls back to moderatorDid when moderator has no handle indexed", async () => { 2286 await ctx.db.insert(users).values({ 2287 did: "did:plc:mod-nohandle", 2288 handle: null, 2289 indexedAt: new Date(), 2290 }); 2291 2292 await ctx.db.insert(modActions).values({ 2293 did: ctx.config.forumDid, 2294 rkey: "modaction-nohandle-1", 2295 cid: "cid-nohandle-1", 2296 action: "space.atbb.modAction.ban", 2297 subjectDid: null, 2298 subjectPostUri: null, 2299 createdBy: "did:plc:mod-nohandle", 2300 reason: "Test", 2301 createdAt: new Date(), 2302 indexedAt: new Date(), 2303 }); 2304 2305 const res = await app.request("/api/admin/modlog"); 2306 expect(res.status).toBe(200); 2307 2308 const data = await res.json() as any; 2309 const action = data.actions.find((a: any) => a.moderatorDid === "did:plc:mod-nohandle"); 2310 expect(action).toBeDefined(); 2311 expect(action.moderatorHandle).toBe("did:plc:mod-nohandle"); 2312 }); 2313 2314 it("falls back to moderatorDid when moderator has no users row at all", async () => { 2315 // Insert a mod action whose createdBy DID has NO entry in the users table 2316 await ctx.db.insert(modActions).values({ 2317 did: ctx.config.forumDid, 2318 rkey: "modaction-nouser-1", 2319 cid: "cid-nouser-1", 2320 action: "space.atbb.modAction.ban", 2321 subjectDid: null, 2322 subjectPostUri: null, 2323 createdBy: "did:plc:mod-completely-unknown", 2324 reason: "No users row", 2325 createdAt: new Date(), 2326 indexedAt: new Date(), 2327 }); 2328 2329 const res = await app.request("/api/admin/modlog"); 2330 expect(res.status).toBe(200); 2331 2332 const data = await res.json() as any; 2333 // The action must appear in the results (not silently dropped by an inner join) 2334 const action = data.actions.find( 2335 (a: any) => a.moderatorDid === "did:plc:mod-completely-unknown" 2336 ); 2337 expect(action).toBeDefined(); 2338 expect(action.moderatorHandle).toBe("did:plc:mod-completely-unknown"); 2339 }); 2340 2341 it("returns actions in createdAt DESC order", async () => { 2342 await ctx.db.insert(users).values({ 2343 did: "did:plc:mod-order", 2344 handle: "order.bsky.social", 2345 indexedAt: new Date(), 2346 }); 2347 2348 const now = Date.now(); 2349 await ctx.db.insert(modActions).values([ 2350 { 2351 did: ctx.config.forumDid, 2352 rkey: "modaction-old", 2353 cid: "cid-old", 2354 action: "space.atbb.modAction.ban", 2355 subjectDid: null, 2356 subjectPostUri: null, 2357 createdBy: "did:plc:mod-order", 2358 reason: "Old action", 2359 createdAt: new Date(now - 10000), 2360 indexedAt: new Date(), 2361 }, 2362 { 2363 did: ctx.config.forumDid, 2364 rkey: "modaction-new", 2365 cid: "cid-new", 2366 action: "space.atbb.modAction.hide", 2367 subjectDid: null, 2368 subjectPostUri: null, 2369 createdBy: "did:plc:mod-order", 2370 reason: "New action", 2371 createdAt: new Date(now), 2372 indexedAt: new Date(), 2373 }, 2374 ]); 2375 2376 const res = await app.request("/api/admin/modlog"); 2377 const data = await res.json() as any; 2378 2379 const orderActions = data.actions.filter((a: any) => 2380 a.moderatorDid === "did:plc:mod-order" 2381 ); 2382 expect(orderActions).toHaveLength(2); 2383 expect(orderActions[0].reason).toBe("New action"); 2384 expect(orderActions[1].reason).toBe("Old action"); 2385 }); 2386 2387 it("respects limit and offset query params", async () => { 2388 await ctx.db.insert(users).values({ 2389 did: "did:plc:mod-pagination", 2390 handle: "pagination.bsky.social", 2391 indexedAt: new Date(), 2392 }); 2393 2394 await ctx.db.insert(modActions).values([ 2395 { did: ctx.config.forumDid, rkey: "pag-1", cid: "c1", action: "space.atbb.modAction.ban", subjectDid: null, subjectPostUri: null, createdBy: "did:plc:mod-pagination", reason: "A", createdAt: new Date(3000), indexedAt: new Date() }, 2396 { did: ctx.config.forumDid, rkey: "pag-2", cid: "c2", action: "space.atbb.modAction.ban", subjectDid: null, subjectPostUri: null, createdBy: "did:plc:mod-pagination", reason: "B", createdAt: new Date(2000), indexedAt: new Date() }, 2397 { did: ctx.config.forumDid, rkey: "pag-3", cid: "c3", action: "space.atbb.modAction.ban", subjectDid: null, subjectPostUri: null, createdBy: "did:plc:mod-pagination", reason: "C", createdAt: new Date(1000), indexedAt: new Date() }, 2398 ]); 2399 2400 const page1 = await app.request("/api/admin/modlog?limit=2&offset=0"); 2401 const data1 = await page1.json() as any; 2402 expect(data1.actions).toHaveLength(2); 2403 expect(data1.limit).toBe(2); 2404 expect(data1.offset).toBe(0); 2405 expect(data1.total).toBe(3); 2406 expect(data1.actions[0].reason).toBe("A"); 2407 2408 const page2 = await app.request("/api/admin/modlog?limit=2&offset=2"); 2409 const data2 = await page2.json() as any; 2410 expect(data2.actions).toHaveLength(1); 2411 expect(data2.total).toBe(3); 2412 expect(data2.actions[0].reason).toBe("C"); 2413 }); 2414 2415 it("returns 400 for non-numeric limit", async () => { 2416 const res = await app.request("/api/admin/modlog?limit=abc"); 2417 expect(res.status).toBe(400); 2418 const data = await res.json() as any; 2419 expect(data.error).toMatch(/limit/i); 2420 }); 2421 2422 it("returns 400 for negative limit", async () => { 2423 const res = await app.request("/api/admin/modlog?limit=-1"); 2424 expect(res.status).toBe(400); 2425 }); 2426 2427 it("returns 400 for negative offset", async () => { 2428 const res = await app.request("/api/admin/modlog?offset=-5"); 2429 expect(res.status).toBe(400); 2430 }); 2431 2432 it("caps limit at 100", async () => { 2433 const res = await app.request("/api/admin/modlog?limit=999"); 2434 expect(res.status).toBe(200); 2435 const data = await res.json() as any; 2436 expect(data.limit).toBe(100); 2437 }); 2438 2439 it("uses default limit=50 and offset=0 when not provided", async () => { 2440 const res = await app.request("/api/admin/modlog"); 2441 expect(res.status).toBe(200); 2442 const data = await res.json() as any; 2443 expect(data.limit).toBe(50); 2444 expect(data.offset).toBe(0); 2445 }); 2446 }); 2447 2448}); 2449