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 root/atb-56-theme-caching-layer 3469 lines 124 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, themes, themePolicies, themePolicyAvailableThemes } 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 describe("GET /api/admin/themes", () => { 2449 beforeEach(async () => { 2450 await ctx.cleanDatabase(); 2451 }); 2452 2453 it("returns empty array when no themes exist", async () => { 2454 const res = await app.request("/api/admin/themes"); 2455 expect(res.status).toBe(200); 2456 const body = await res.json(); 2457 expect(body).toHaveProperty("themes"); 2458 expect(body.themes).toEqual([]); 2459 }); 2460 2461 it("returns all themes regardless of policy availability", async () => { 2462 // Insert two themes but only add one to policy 2463 await ctx.db.insert(themes).values([ 2464 { 2465 did: ctx.config.forumDid, 2466 rkey: "3lbltheme1aa", 2467 cid: "bafytheme1", 2468 name: "Neobrutal Light", 2469 colorScheme: "light", 2470 tokens: { "color-bg": "#f5f0e8" }, 2471 createdAt: new Date(), 2472 indexedAt: new Date(), 2473 }, 2474 { 2475 did: ctx.config.forumDid, 2476 rkey: "3lbltheme2bb", 2477 cid: "bafytheme2", 2478 name: "Neobrutal Dark", 2479 colorScheme: "dark", 2480 tokens: { "color-bg": "#1a1a1a" }, 2481 createdAt: new Date(), 2482 indexedAt: new Date(), 2483 }, 2484 ]); 2485 2486 const res = await app.request("/api/admin/themes"); 2487 expect(res.status).toBe(200); 2488 const body = await res.json(); 2489 2490 // Returns BOTH themes — not filtered by policy 2491 expect(body.themes).toHaveLength(2); 2492 expect(body.themes[0]).toMatchObject({ 2493 name: "Neobrutal Light", 2494 colorScheme: "light", 2495 }); 2496 expect(body.themes[0]).toHaveProperty("tokens"); 2497 expect(body.themes[0]).toHaveProperty("uri"); 2498 expect(body.themes[0].uri).toContain("space.atbb.forum.theme"); 2499 }); 2500 2501 it("returns 401 when not authenticated", async () => { 2502 mockUser = null; 2503 const res = await app.request("/api/admin/themes"); 2504 expect(res.status).toBe(401); 2505 }); 2506 }); 2507 2508 describe("POST /api/admin/themes", () => { 2509 it("creates theme and returns 201 with uri and cid", async () => { 2510 const res = await app.request("/api/admin/themes", { 2511 method: "POST", 2512 headers: { "Content-Type": "application/json" }, 2513 body: JSON.stringify({ 2514 name: "Neobrutal Light", 2515 colorScheme: "light", 2516 tokens: { "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }, 2517 }), 2518 }); 2519 expect(res.status).toBe(201); 2520 const body = await res.json(); 2521 expect(body.uri).toBeDefined(); 2522 expect(body.cid).toBeDefined(); 2523 expect(mockPutRecord).toHaveBeenCalledOnce(); 2524 }); 2525 2526 it("includes cssOverrides and fontUrls when provided", async () => { 2527 const res = await app.request("/api/admin/themes", { 2528 method: "POST", 2529 headers: { "Content-Type": "application/json" }, 2530 body: JSON.stringify({ 2531 name: "Custom Theme", 2532 colorScheme: "dark", 2533 tokens: { "color-bg": "#1a1a1a" }, 2534 cssOverrides: ".card { border-radius: 4px; }", 2535 fontUrls: ["https://fonts.googleapis.com/css2?family=Space+Grotesk"], 2536 }), 2537 }); 2538 expect(res.status).toBe(201); 2539 const call = mockPutRecord.mock.calls[0][0]; 2540 // Sanitizer reformats CSS to compact form (no extra spaces) 2541 expect(call.record.cssOverrides).toBe(".card{border-radius:4px}"); 2542 expect(call.record.fontUrls).toEqual(["https://fonts.googleapis.com/css2?family=Space+Grotesk"]); 2543 }); 2544 2545 it("strips dangerous CSS constructs from cssOverrides before PDS write", async () => { 2546 const res = await app.request("/api/admin/themes", { 2547 method: "POST", 2548 headers: { "Content-Type": "application/json" }, 2549 body: JSON.stringify({ 2550 name: "Dangerous Theme", 2551 colorScheme: "light", 2552 tokens: { "color-bg": "#ffffff" }, 2553 cssOverrides: '@import "https://evil.com/steal.css"; .ok { color: red; }', 2554 }), 2555 }); 2556 expect(res.status).toBe(201); 2557 const call = mockPutRecord.mock.calls[0][0]; 2558 expect(call.record.cssOverrides).not.toContain("@import"); 2559 expect(call.record.cssOverrides).not.toContain("evil.com"); 2560 expect(call.record.cssOverrides).toContain("color:red"); 2561 }); 2562 2563 it("returns 400 when name is missing", async () => { 2564 const res = await app.request("/api/admin/themes", { 2565 method: "POST", 2566 headers: { "Content-Type": "application/json" }, 2567 body: JSON.stringify({ colorScheme: "light", tokens: {} }), 2568 }); 2569 expect(res.status).toBe(400); 2570 const body = await res.json(); 2571 expect(body.error).toMatch(/name/i); 2572 }); 2573 2574 it("returns 400 when name is empty string", async () => { 2575 const res = await app.request("/api/admin/themes", { 2576 method: "POST", 2577 headers: { "Content-Type": "application/json" }, 2578 body: JSON.stringify({ name: " ", colorScheme: "light", tokens: {} }), 2579 }); 2580 expect(res.status).toBe(400); 2581 }); 2582 2583 it("returns 400 when colorScheme is invalid", async () => { 2584 const res = await app.request("/api/admin/themes", { 2585 method: "POST", 2586 headers: { "Content-Type": "application/json" }, 2587 body: JSON.stringify({ name: "Test", colorScheme: "purple", tokens: {} }), 2588 }); 2589 expect(res.status).toBe(400); 2590 const body = await res.json(); 2591 expect(body.error).toMatch(/colorScheme/i); 2592 }); 2593 2594 it("returns 400 when colorScheme is missing", async () => { 2595 const res = await app.request("/api/admin/themes", { 2596 method: "POST", 2597 headers: { "Content-Type": "application/json" }, 2598 body: JSON.stringify({ name: "Test", tokens: {} }), 2599 }); 2600 expect(res.status).toBe(400); 2601 }); 2602 2603 it("returns 400 when tokens is missing", async () => { 2604 const res = await app.request("/api/admin/themes", { 2605 method: "POST", 2606 headers: { "Content-Type": "application/json" }, 2607 body: JSON.stringify({ name: "Test", colorScheme: "light" }), 2608 }); 2609 expect(res.status).toBe(400); 2610 const body = await res.json(); 2611 expect(body.error).toMatch(/tokens/i); 2612 }); 2613 2614 it("returns 400 when tokens is an array (not an object)", async () => { 2615 const res = await app.request("/api/admin/themes", { 2616 method: "POST", 2617 headers: { "Content-Type": "application/json" }, 2618 body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: ["a", "b"] }), 2619 }); 2620 expect(res.status).toBe(400); 2621 }); 2622 2623 it("returns 400 when a token value is not a string", async () => { 2624 const res = await app.request("/api/admin/themes", { 2625 method: "POST", 2626 headers: { "Content-Type": "application/json" }, 2627 body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: { "color-bg": 123 } }), 2628 }); 2629 expect(res.status).toBe(400); 2630 const body = await res.json(); 2631 expect(body.error).toMatch(/tokens/i); 2632 }); 2633 2634 it("returns 400 when a fontUrl is not HTTPS", async () => { 2635 const res = await app.request("/api/admin/themes", { 2636 method: "POST", 2637 headers: { "Content-Type": "application/json" }, 2638 body: JSON.stringify({ 2639 name: "Test", 2640 colorScheme: "light", 2641 tokens: {}, 2642 fontUrls: ["http://example.com/font.css"], 2643 }), 2644 }); 2645 expect(res.status).toBe(400); 2646 const body = await res.json(); 2647 expect(body.error).toMatch(/https/i); 2648 }); 2649 2650 it("returns 500 when ForumAgent is not configured", async () => { 2651 ctx.forumAgent = null; 2652 const res = await app.request("/api/admin/themes", { 2653 method: "POST", 2654 headers: { "Content-Type": "application/json" }, 2655 body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }), 2656 }); 2657 expect(res.status).toBe(500); 2658 const body = await res.json(); 2659 expect(body.error).toContain("Forum agent not available"); 2660 }); 2661 2662 it("returns 503 when ForumAgent not authenticated", async () => { 2663 const originalAgent = ctx.forumAgent; 2664 ctx.forumAgent = { getAgent: () => null } as any; 2665 const res = await app.request("/api/admin/themes", { 2666 method: "POST", 2667 headers: { "Content-Type": "application/json" }, 2668 body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }), 2669 }); 2670 expect(res.status).toBe(503); 2671 const body = await res.json(); 2672 expect(body.error).toBe("Forum agent not authenticated. Please try again later."); 2673 expect(mockPutRecord).not.toHaveBeenCalled(); 2674 ctx.forumAgent = originalAgent; 2675 }); 2676 2677 it("returns 401 when not authenticated", async () => { 2678 mockUser = null; 2679 const res = await app.request("/api/admin/themes", { 2680 method: "POST", 2681 headers: { "Content-Type": "application/json" }, 2682 body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }), 2683 }); 2684 expect(res.status).toBe(401); 2685 expect(mockPutRecord).not.toHaveBeenCalled(); 2686 }); 2687 2688 it("returns 403 when user lacks manageThemes permission", async () => { 2689 const { requirePermission } = await import("../../middleware/permissions.js"); 2690 const mockRequirePermission = requirePermission as any; 2691 mockRequirePermission.mockImplementation(() => async (c: any) => { 2692 return c.json({ error: "Forbidden" }, 403); 2693 }); 2694 2695 const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 2696 const res = await testApp.request("/api/admin/themes", { 2697 method: "POST", 2698 headers: { "Content-Type": "application/json" }, 2699 body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }), 2700 }); 2701 2702 expect(res.status).toBe(403); 2703 expect(mockPutRecord).not.toHaveBeenCalled(); 2704 2705 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 2706 await next(); 2707 }); 2708 }); 2709 2710 it("returns 503 when PDS write fails with a network error", async () => { 2711 mockPutRecord.mockRejectedValueOnce(new Error("fetch failed")); 2712 const res = await app.request("/api/admin/themes", { 2713 method: "POST", 2714 headers: { "Content-Type": "application/json" }, 2715 body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }), 2716 }); 2717 expect(res.status).toBe(503); 2718 }); 2719 }); 2720 2721 describe("PUT /api/admin/themes/:rkey", () => { 2722 const TEST_RKEY = "3lblputtest1"; 2723 const TEST_CREATED_AT = new Date("2026-01-01T00:00:00Z"); 2724 2725 beforeEach(async () => { 2726 await ctx.db.insert(themes).values({ 2727 did: ctx.config.forumDid, 2728 rkey: TEST_RKEY, 2729 cid: "bafythemeput", 2730 name: "Original Theme", 2731 colorScheme: "light", 2732 tokens: { "color-bg": "#ffffff", "color-text": "#000000" }, 2733 cssOverrides: ".existing { color: red; }", 2734 fontUrls: ["https://fonts.example.com/existing.css"], 2735 createdAt: TEST_CREATED_AT, 2736 indexedAt: new Date(), 2737 }); 2738 }); 2739 2740 it("updates theme and returns 200 with uri and cid", async () => { 2741 const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2742 method: "PUT", 2743 headers: { "Content-Type": "application/json" }, 2744 body: JSON.stringify({ 2745 name: "Updated Theme", 2746 colorScheme: "dark", 2747 tokens: { "color-bg": "#1a1a1a", "color-text": "#ffffff" }, 2748 }), 2749 }); 2750 expect(res.status).toBe(200); 2751 const body = await res.json(); 2752 expect(body.uri).toBeDefined(); 2753 expect(body.cid).toBeDefined(); 2754 expect(mockPutRecord).toHaveBeenCalledOnce(); 2755 const call = mockPutRecord.mock.calls[0][0]; 2756 expect(call.record.name).toBe("Updated Theme"); 2757 expect(call.record.colorScheme).toBe("dark"); 2758 expect(call.rkey).toBe(TEST_RKEY); 2759 }); 2760 2761 it("preserves existing cssOverrides when not provided in request body", async () => { 2762 const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2763 method: "PUT", 2764 headers: { "Content-Type": "application/json" }, 2765 body: JSON.stringify({ 2766 name: "Updated Theme", 2767 colorScheme: "light", 2768 tokens: { "color-bg": "#f0f0f0" }, 2769 }), 2770 }); 2771 expect(res.status).toBe(200); 2772 const call = mockPutRecord.mock.calls[0][0]; 2773 // Sanitizer reformats CSS to compact form (no extra spaces) 2774 expect(call.record.cssOverrides).toBe(".existing{color:red}"); 2775 }); 2776 2777 it("strips dangerous CSS constructs from cssOverrides before PDS write on update", async () => { 2778 const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2779 method: "PUT", 2780 headers: { "Content-Type": "application/json" }, 2781 body: JSON.stringify({ 2782 name: "Updated Theme", 2783 colorScheme: "light", 2784 tokens: { "color-bg": "#f0f0f0" }, 2785 cssOverrides: 'body { background: url("https://evil.com/track.gif"); color: blue; }', 2786 }), 2787 }); 2788 expect(res.status).toBe(200); 2789 const call = mockPutRecord.mock.calls[0][0]; 2790 expect(call.record.cssOverrides).not.toContain("evil.com"); 2791 expect(call.record.cssOverrides).toContain("color:blue"); 2792 }); 2793 2794 it("preserves existing fontUrls when not provided in request body", async () => { 2795 const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2796 method: "PUT", 2797 headers: { "Content-Type": "application/json" }, 2798 body: JSON.stringify({ 2799 name: "Updated Theme", 2800 colorScheme: "light", 2801 tokens: { "color-bg": "#f0f0f0" }, 2802 }), 2803 }); 2804 expect(res.status).toBe(200); 2805 const call = mockPutRecord.mock.calls[0][0]; 2806 expect(call.record.fontUrls).toEqual(["https://fonts.example.com/existing.css"]); 2807 }); 2808 2809 it("preserves original createdAt in the PDS record", async () => { 2810 const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2811 method: "PUT", 2812 headers: { "Content-Type": "application/json" }, 2813 body: JSON.stringify({ 2814 name: "Updated Theme", 2815 colorScheme: "light", 2816 tokens: { "color-bg": "#f0f0f0" }, 2817 }), 2818 }); 2819 expect(res.status).toBe(200); 2820 const call = mockPutRecord.mock.calls[0][0]; 2821 expect(call.record.createdAt).toBe("2026-01-01T00:00:00.000Z"); 2822 }); 2823 2824 it("returns 404 for unknown rkey", async () => { 2825 const res = await app.request("/api/admin/themes/nonexistentkey", { 2826 method: "PUT", 2827 headers: { "Content-Type": "application/json" }, 2828 body: JSON.stringify({ 2829 name: "Updated Theme", 2830 colorScheme: "light", 2831 tokens: { "color-bg": "#f0f0f0" }, 2832 }), 2833 }); 2834 expect(res.status).toBe(404); 2835 const body = await res.json(); 2836 expect(body.error).toMatch(/not found/i); 2837 }); 2838 2839 it("returns 400 when name is missing", async () => { 2840 const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2841 method: "PUT", 2842 headers: { "Content-Type": "application/json" }, 2843 body: JSON.stringify({ colorScheme: "light", tokens: {} }), 2844 }); 2845 expect(res.status).toBe(400); 2846 const body = await res.json(); 2847 expect(body.error).toMatch(/name/i); 2848 }); 2849 2850 it("returns 400 when colorScheme is invalid", async () => { 2851 const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2852 method: "PUT", 2853 headers: { "Content-Type": "application/json" }, 2854 body: JSON.stringify({ name: "Test", colorScheme: "sepia", tokens: {} }), 2855 }); 2856 expect(res.status).toBe(400); 2857 const body = await res.json(); 2858 expect(body.error).toMatch(/colorScheme/i); 2859 }); 2860 2861 it("returns 400 when tokens is an array", async () => { 2862 const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2863 method: "PUT", 2864 headers: { "Content-Type": "application/json" }, 2865 body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: ["a", "b"] }), 2866 }); 2867 expect(res.status).toBe(400); 2868 const body = await res.json(); 2869 expect(body.error).toMatch(/tokens/i); 2870 }); 2871 2872 it("returns 401 when not authenticated", async () => { 2873 mockUser = null; 2874 const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2875 method: "PUT", 2876 headers: { "Content-Type": "application/json" }, 2877 body: JSON.stringify({ 2878 name: "Updated Theme", 2879 colorScheme: "light", 2880 tokens: { "color-bg": "#f0f0f0" }, 2881 }), 2882 }); 2883 expect(res.status).toBe(401); 2884 expect(mockPutRecord).not.toHaveBeenCalled(); 2885 }); 2886 2887 it("returns 403 when user lacks manageThemes permission", async () => { 2888 const { requirePermission } = await import("../../middleware/permissions.js"); 2889 const mockRequirePermission = requirePermission as any; 2890 mockRequirePermission.mockImplementation(() => async (c: any) => { 2891 return c.json({ error: "Forbidden" }, 403); 2892 }); 2893 2894 const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 2895 const res = await testApp.request(`/api/admin/themes/${TEST_RKEY}`, { 2896 method: "PUT", 2897 headers: { "Content-Type": "application/json" }, 2898 body: JSON.stringify({ 2899 name: "Updated Theme", 2900 colorScheme: "light", 2901 tokens: { "color-bg": "#f0f0f0" }, 2902 }), 2903 }); 2904 2905 expect(res.status).toBe(403); 2906 expect(mockPutRecord).not.toHaveBeenCalled(); 2907 2908 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 2909 await next(); 2910 }); 2911 }); 2912 2913 it("returns 503 when ForumAgent not authenticated", async () => { 2914 const originalAgent = ctx.forumAgent; 2915 ctx.forumAgent = { getAgent: () => null } as any; 2916 const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2917 method: "PUT", 2918 headers: { "Content-Type": "application/json" }, 2919 body: JSON.stringify({ name: "Updated", colorScheme: "light", tokens: {} }), 2920 }); 2921 expect(res.status).toBe(503); 2922 const body = await res.json(); 2923 expect(body.error).toBe("Forum agent not authenticated. Please try again later."); 2924 expect(mockPutRecord).not.toHaveBeenCalled(); 2925 ctx.forumAgent = originalAgent; 2926 }); 2927 2928 it("returns 503 when PDS write fails with a network error", async () => { 2929 mockPutRecord.mockRejectedValueOnce(new Error("fetch failed")); 2930 const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { 2931 method: "PUT", 2932 headers: { "Content-Type": "application/json" }, 2933 body: JSON.stringify({ 2934 name: "Updated Theme", 2935 colorScheme: "light", 2936 tokens: { "color-bg": "#f0f0f0" }, 2937 }), 2938 }); 2939 expect(res.status).toBe(503); 2940 }); 2941 }); 2942 2943 describe("DELETE /api/admin/themes/:rkey", () => { 2944 const themeRkey = "3lbldeltest1"; 2945 2946 beforeEach(async () => { 2947 await ctx.db.insert(themes).values({ 2948 did: ctx.config.forumDid, 2949 rkey: themeRkey, 2950 cid: "bafydeltest", 2951 name: "Theme To Delete", 2952 colorScheme: "light", 2953 tokens: { "color-bg": "#ffffff" }, 2954 createdAt: new Date(), 2955 indexedAt: new Date(), 2956 }); 2957 }); 2958 2959 it("deletes theme and returns 200 with success: true", async () => { 2960 const res = await app.request(`/api/admin/themes/${themeRkey}`, { 2961 method: "DELETE", 2962 }); 2963 expect(res.status).toBe(200); 2964 const body = await res.json(); 2965 expect(body.success).toBe(true); 2966 expect(mockDeleteRecord).toHaveBeenCalledWith({ 2967 repo: ctx.config.forumDid, 2968 collection: "space.atbb.forum.theme", 2969 rkey: themeRkey, 2970 }); 2971 }); 2972 2973 it("returns 404 for unknown rkey", async () => { 2974 const res = await app.request("/api/admin/themes/doesnotexist", { 2975 method: "DELETE", 2976 }); 2977 expect(res.status).toBe(404); 2978 }); 2979 2980 it("returns 409 when theme is the defaultLightTheme in policy", async () => { 2981 await ctx.db.insert(themePolicies).values({ 2982 did: ctx.config.forumDid, 2983 rkey: "self", 2984 cid: "bafypolicydel", 2985 defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`, 2986 defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, 2987 allowUserChoice: true, 2988 indexedAt: new Date(), 2989 }); 2990 2991 const res = await app.request(`/api/admin/themes/${themeRkey}`, { 2992 method: "DELETE", 2993 }); 2994 expect(res.status).toBe(409); 2995 const body = await res.json(); 2996 expect(body.error).toMatch(/default/i); 2997 }); 2998 2999 it("returns 409 when theme is the defaultDarkTheme in policy", async () => { 3000 await ctx.db.insert(themePolicies).values({ 3001 did: ctx.config.forumDid, 3002 rkey: "self", 3003 cid: "bafypolicydel2", 3004 defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, 3005 defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`, 3006 allowUserChoice: true, 3007 indexedAt: new Date(), 3008 }); 3009 3010 const res = await app.request(`/api/admin/themes/${themeRkey}`, { 3011 method: "DELETE", 3012 }); 3013 expect(res.status).toBe(409); 3014 const body = await res.json(); 3015 expect(body.error).toMatch(/default/i); 3016 }); 3017 3018 it("deletes successfully when theme exists in policy availableThemes but not as a default", async () => { 3019 const [policy] = await ctx.db.insert(themePolicies).values({ 3020 did: ctx.config.forumDid, 3021 rkey: "self", 3022 cid: "bafypolicyavail", 3023 defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, 3024 defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, 3025 allowUserChoice: true, 3026 indexedAt: new Date(), 3027 }).returning(); 3028 await ctx.db.insert(themePolicyAvailableThemes).values({ 3029 policyId: policy.id, 3030 themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`, 3031 themeCid: "bafydeltest", 3032 }); 3033 3034 const res = await app.request(`/api/admin/themes/${themeRkey}`, { 3035 method: "DELETE", 3036 }); 3037 expect(res.status).toBe(200); 3038 }); 3039 3040 it("returns 401 when not authenticated", async () => { 3041 mockUser = null; 3042 const res = await app.request(`/api/admin/themes/${themeRkey}`, { 3043 method: "DELETE", 3044 }); 3045 expect(res.status).toBe(401); 3046 expect(mockDeleteRecord).not.toHaveBeenCalled(); 3047 }); 3048 3049 it("returns 403 when user lacks manageThemes permission", async () => { 3050 const { requirePermission } = await import("../../middleware/permissions.js"); 3051 const mockRequirePermission = requirePermission as any; 3052 mockRequirePermission.mockImplementation(() => async (c: any) => { 3053 return c.json({ error: "Forbidden" }, 403); 3054 }); 3055 3056 const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 3057 const res = await testApp.request(`/api/admin/themes/${themeRkey}`, { 3058 method: "DELETE", 3059 }); 3060 3061 expect(res.status).toBe(403); 3062 expect(mockDeleteRecord).not.toHaveBeenCalled(); 3063 3064 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 3065 await next(); 3066 }); 3067 }); 3068 3069 it("returns 503 when ForumAgent not authenticated", async () => { 3070 const originalAgent = ctx.forumAgent; 3071 ctx.forumAgent = { getAgent: () => null } as any; 3072 const res = await app.request(`/api/admin/themes/${themeRkey}`, { 3073 method: "DELETE", 3074 }); 3075 expect(res.status).toBe(503); 3076 const body = await res.json(); 3077 expect(body.error).toBe("Forum agent not authenticated. Please try again later."); 3078 expect(mockDeleteRecord).not.toHaveBeenCalled(); 3079 ctx.forumAgent = originalAgent; 3080 }); 3081 3082 it("returns 503 when PDS delete fails with a network error", async () => { 3083 mockDeleteRecord.mockRejectedValueOnce(new Error("fetch failed")); 3084 const res = await app.request(`/api/admin/themes/${themeRkey}`, { 3085 method: "DELETE", 3086 }); 3087 expect(res.status).toBe(503); 3088 }); 3089 }); 3090 3091 describe("POST /api/admin/themes/:rkey/duplicate", () => { 3092 beforeEach(async () => { 3093 await ctx.cleanDatabase(); 3094 await ctx.db.insert(themes).values({ 3095 did: ctx.config.forumDid, 3096 rkey: "3lblsource1aa", 3097 cid: "bafysource1", 3098 name: "Neobrutal Light", 3099 colorScheme: "light", 3100 tokens: { "color-bg": "#f5f0e8", "color-primary": "#ff5c00" }, 3101 createdAt: new Date(), 3102 indexedAt: new Date(), 3103 }); 3104 }); 3105 3106 it("calls putRecord with a new rkey and '(Copy)' name", async () => { 3107 mockPutRecord.mockResolvedValueOnce({ 3108 data: { uri: "at://did:plc:test-forum/space.atbb.forum.theme/3lblcopy001a", cid: "bafycopy1" }, 3109 }); 3110 3111 const res = await app.request("/api/admin/themes/3lblsource1aa/duplicate", { 3112 method: "POST", 3113 }); 3114 3115 expect(res.status).toBe(201); 3116 const body = await res.json(); 3117 expect(body.name).toBe("Neobrutal Light (Copy)"); 3118 expect(body.rkey).toBeDefined(); 3119 expect(body.rkey).not.toBe("3lblsource1aa"); 3120 expect(body.uri).toContain("space.atbb.forum.theme"); 3121 3122 expect(mockPutRecord).toHaveBeenCalledOnce(); 3123 const putCall = mockPutRecord.mock.calls[0][0]; 3124 expect(putCall.record.name).toBe("Neobrutal Light (Copy)"); 3125 expect(putCall.record.colorScheme).toBe("light"); 3126 expect(putCall.record.tokens).toEqual({ "color-bg": "#f5f0e8", "color-primary": "#ff5c00" }); 3127 expect(putCall.collection).toBe("space.atbb.forum.theme"); 3128 }); 3129 3130 it("returns 404 when source rkey does not exist", async () => { 3131 const res = await app.request("/api/admin/themes/nonexistent/duplicate", { 3132 method: "POST", 3133 }); 3134 expect(res.status).toBe(404); 3135 }); 3136 3137 it("returns 401 when not authenticated", async () => { 3138 mockUser = null; 3139 const res = await app.request("/api/admin/themes/3lblsource1aa/duplicate", { 3140 method: "POST", 3141 }); 3142 expect(res.status).toBe(401); 3143 }); 3144 3145 it("copies cssOverrides and fontUrls when they are set on the source", async () => { 3146 // Insert a theme with optional fields populated 3147 await ctx.db.insert(themes).values({ 3148 did: ctx.config.forumDid, 3149 rkey: "3lblsource2bb", 3150 cid: "bafysource2", 3151 name: "Custom Theme", 3152 colorScheme: "dark", 3153 tokens: { "color-bg": "#1a1a1a" }, 3154 cssOverrides: "body { font-size: 18px; }", 3155 fontUrls: ["https://fonts.googleapis.com/css2?family=Roboto"], 3156 createdAt: new Date(), 3157 indexedAt: new Date(), 3158 }); 3159 3160 const res = await app.request("/api/admin/themes/3lblsource2bb/duplicate", { 3161 method: "POST", 3162 }); 3163 3164 expect(res.status).toBe(201); 3165 expect(mockPutRecord).toHaveBeenCalledOnce(); 3166 const putCall = mockPutRecord.mock.calls[0][0]; 3167 // Sanitizer reformats CSS to compact form on duplication 3168 expect(putCall.record.cssOverrides).toBe("body{font-size:18px}"); 3169 expect(putCall.record.fontUrls).toEqual(["https://fonts.googleapis.com/css2?family=Roboto"]); 3170 expect(putCall.record.name).toBe("Custom Theme (Copy)"); 3171 }); 3172 }); 3173 3174 describe("PUT /api/admin/theme-policy", () => { 3175 const lightUri = `at://did:plc:test-forum/space.atbb.forum.theme/3lbllight1`; 3176 const darkUri = `at://did:plc:test-forum/space.atbb.forum.theme/3lbldark11`; 3177 3178 const validBody = { 3179 availableThemes: [ 3180 { uri: lightUri, cid: "bafylight" }, 3181 { uri: darkUri, cid: "bafydark" }, 3182 ], 3183 defaultLightThemeUri: lightUri, 3184 defaultDarkThemeUri: darkUri, 3185 allowUserChoice: true, 3186 }; 3187 3188 it("creates policy (upsert) and returns 200 with uri and cid", async () => { 3189 const res = await app.request("/api/admin/theme-policy", { 3190 method: "PUT", 3191 headers: { "Content-Type": "application/json" }, 3192 body: JSON.stringify(validBody), 3193 }); 3194 expect(res.status).toBe(200); 3195 const body = await res.json(); 3196 expect(body.uri).toBeDefined(); 3197 expect(body.cid).toBeDefined(); 3198 expect(mockPutRecord).toHaveBeenCalledOnce(); 3199 }); 3200 3201 it("writes PDS record with flat themeRef structure (no theme: wrapper)", async () => { 3202 await app.request("/api/admin/theme-policy", { 3203 method: "PUT", 3204 headers: { "Content-Type": "application/json" }, 3205 body: JSON.stringify(validBody), 3206 }); 3207 const call = mockPutRecord.mock.calls[0][0]; 3208 expect(call.record.$type).toBe("space.atbb.forum.themePolicy"); 3209 expect(call.rkey).toBe("self"); 3210 // Flat themeRef: { uri, cid } — no nested theme: {} wrapper 3211 expect(call.record.availableThemes[0]).toEqual({ uri: lightUri, cid: "bafylight" }); 3212 expect(call.record.defaultLightTheme).toEqual({ uri: lightUri, cid: "bafylight" }); 3213 expect(call.record.defaultDarkTheme).toEqual({ uri: darkUri, cid: "bafydark" }); 3214 expect(call.record.allowUserChoice).toBe(true); 3215 expect(typeof call.record.updatedAt).toBe("string"); 3216 expect(call.collection).toBe("space.atbb.forum.themePolicy"); 3217 expect(call.repo).toBe(ctx.config.forumDid); 3218 }); 3219 3220 it("overwrites existing policy (upsert) and returns 200 with uri and cid", async () => { 3221 await ctx.db.insert(themePolicies).values({ 3222 did: ctx.config.forumDid, 3223 rkey: "self", 3224 cid: "bafyexisting", 3225 defaultLightThemeUri: lightUri, 3226 defaultDarkThemeUri: darkUri, 3227 allowUserChoice: false, 3228 indexedAt: new Date(), 3229 }); 3230 3231 const res = await app.request("/api/admin/theme-policy", { 3232 method: "PUT", 3233 headers: { "Content-Type": "application/json" }, 3234 body: JSON.stringify(validBody), 3235 }); 3236 expect(res.status).toBe(200); 3237 const body = await res.json(); 3238 expect(body.uri).toBeDefined(); 3239 expect(body.cid).toBeDefined(); 3240 expect(mockPutRecord).toHaveBeenCalledOnce(); 3241 }); 3242 3243 it("defaults allowUserChoice to true when not provided", async () => { 3244 const { allowUserChoice: _, ...bodyWithout } = validBody; 3245 await app.request("/api/admin/theme-policy", { 3246 method: "PUT", 3247 headers: { "Content-Type": "application/json" }, 3248 body: JSON.stringify(bodyWithout), 3249 }); 3250 const call = mockPutRecord.mock.calls[0][0]; 3251 expect(call.record.allowUserChoice).toBe(true); 3252 }); 3253 3254 it("returns 400 when availableThemes is missing", async () => { 3255 const { availableThemes: _, ...bodyWithout } = validBody; 3256 const res = await app.request("/api/admin/theme-policy", { 3257 method: "PUT", 3258 headers: { "Content-Type": "application/json" }, 3259 body: JSON.stringify(bodyWithout), 3260 }); 3261 expect(res.status).toBe(400); 3262 const body = await res.json(); 3263 expect(body.error).toMatch(/availableThemes/i); 3264 }); 3265 3266 it("returns 400 when availableThemes is empty array", async () => { 3267 const res = await app.request("/api/admin/theme-policy", { 3268 method: "PUT", 3269 headers: { "Content-Type": "application/json" }, 3270 body: JSON.stringify({ ...validBody, availableThemes: [] }), 3271 }); 3272 expect(res.status).toBe(400); 3273 const body = await res.json(); 3274 expect(body.error).toMatch(/availableThemes/i); 3275 }); 3276 3277 it("accepts uri-only entries as live refs — writes themeRef without cid", async () => { 3278 // URI without cid is a valid live ref (e.g. canonical atbb.space preset) 3279 // No DB lookup or insertion required — CID is simply absent in the PDS record 3280 const liveUri = `at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light`; 3281 3282 const res = await app.request("/api/admin/theme-policy", { 3283 method: "PUT", 3284 headers: { "Content-Type": "application/json" }, 3285 body: JSON.stringify({ 3286 defaultLightThemeUri: liveUri, 3287 defaultDarkThemeUri: liveUri, 3288 allowUserChoice: true, 3289 availableThemes: [{ uri: liveUri }], // no cid — live ref 3290 }), 3291 }); 3292 3293 expect(res.status).toBe(200); 3294 expect(mockPutRecord).toHaveBeenCalledOnce(); 3295 const putCall = mockPutRecord.mock.calls[0][0]; 3296 // Live ref has uri but no cid field 3297 expect(putCall.record.availableThemes[0]).toEqual({ uri: liveUri }); 3298 expect(putCall.record.defaultLightTheme).toEqual({ uri: liveUri }); 3299 expect(putCall.record.defaultDarkTheme).toEqual({ uri: liveUri }); 3300 }); 3301 3302 it("accepts live refs to external URIs not in local DB (e.g. atbb.space canonical presets)", async () => { 3303 // Previously the route rejected URIs not found in the local DB. 3304 // Now URI-only entries are valid live refs regardless of whether the theme is local. 3305 const externalUri = `at://did:web:atbb.space/space.atbb.forum.theme/clean-light`; 3306 3307 const res = await app.request("/api/admin/theme-policy", { 3308 method: "PUT", 3309 headers: { "Content-Type": "application/json" }, 3310 body: JSON.stringify({ 3311 defaultLightThemeUri: externalUri, 3312 defaultDarkThemeUri: externalUri, 3313 allowUserChoice: true, 3314 availableThemes: [{ uri: externalUri }], 3315 }), 3316 }); 3317 3318 expect(res.status).toBe(200); 3319 expect(mockPutRecord).toHaveBeenCalledOnce(); 3320 }); 3321 3322 it("treats cid: \"\" as absent and writes a live ref (no cid in PDS record)", async () => { 3323 const themeUri = `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme2aa`; 3324 3325 const res = await app.request("/api/admin/theme-policy", { 3326 method: "PUT", 3327 headers: { "Content-Type": "application/json" }, 3328 body: JSON.stringify({ 3329 defaultLightThemeUri: themeUri, 3330 defaultDarkThemeUri: themeUri, 3331 allowUserChoice: true, 3332 availableThemes: [{ uri: themeUri, cid: "" }], // empty string → treated as absent 3333 }), 3334 }); 3335 3336 expect(res.status).toBe(200); 3337 expect(mockPutRecord).toHaveBeenCalledOnce(); 3338 const putCall = mockPutRecord.mock.calls[0][0]; 3339 // Empty string cid is dropped — written as live ref 3340 expect(putCall.record.availableThemes[0]).toEqual({ uri: themeUri }); 3341 }); 3342 3343 it("uses provided cid as-is when entry includes one (pinned ref)", async () => { 3344 await app.request("/api/admin/theme-policy", { 3345 method: "PUT", 3346 headers: { "Content-Type": "application/json" }, 3347 body: JSON.stringify(validBody), 3348 }); 3349 const putCall = mockPutRecord.mock.calls[0][0]; 3350 // Pinned refs include both uri and cid 3351 expect(putCall.record.availableThemes[0]).toEqual({ uri: lightUri, cid: "bafylight" }); 3352 expect(putCall.record.availableThemes[1]).toEqual({ uri: darkUri, cid: "bafydark" }); 3353 }); 3354 3355 it("returns 400 when defaultLightThemeUri is not in availableThemes", async () => { 3356 const res = await app.request("/api/admin/theme-policy", { 3357 method: "PUT", 3358 headers: { "Content-Type": "application/json" }, 3359 body: JSON.stringify({ 3360 ...validBody, 3361 defaultLightThemeUri: "at://did:plc:test-forum/space.atbb.forum.theme/notinlist", 3362 }), 3363 }); 3364 expect(res.status).toBe(400); 3365 const body = await res.json(); 3366 expect(body.error).toMatch(/defaultLightThemeUri/i); 3367 }); 3368 3369 it("returns 400 when defaultDarkThemeUri is not in availableThemes", async () => { 3370 const res = await app.request("/api/admin/theme-policy", { 3371 method: "PUT", 3372 headers: { "Content-Type": "application/json" }, 3373 body: JSON.stringify({ 3374 ...validBody, 3375 defaultDarkThemeUri: "at://did:plc:test-forum/space.atbb.forum.theme/notinlist", 3376 }), 3377 }); 3378 expect(res.status).toBe(400); 3379 const body = await res.json(); 3380 expect(body.error).toMatch(/defaultDarkThemeUri/i); 3381 }); 3382 3383 it("returns 400 when defaultLightThemeUri is missing", async () => { 3384 const { defaultLightThemeUri: _, ...bodyWithout } = validBody; 3385 const res = await app.request("/api/admin/theme-policy", { 3386 method: "PUT", 3387 headers: { "Content-Type": "application/json" }, 3388 body: JSON.stringify(bodyWithout), 3389 }); 3390 expect(res.status).toBe(400); 3391 const body = await res.json(); 3392 expect(body.error).toMatch(/defaultLightThemeUri/i); 3393 }); 3394 3395 it("returns 400 when defaultDarkThemeUri is missing", async () => { 3396 const { defaultDarkThemeUri: _, ...bodyWithout } = validBody; 3397 const res = await app.request("/api/admin/theme-policy", { 3398 method: "PUT", 3399 headers: { "Content-Type": "application/json" }, 3400 body: JSON.stringify(bodyWithout), 3401 }); 3402 expect(res.status).toBe(400); 3403 const body = await res.json(); 3404 expect(body.error).toMatch(/defaultDarkThemeUri/i); 3405 }); 3406 3407 it("returns 401 when not authenticated", async () => { 3408 mockUser = null; 3409 const res = await app.request("/api/admin/theme-policy", { 3410 method: "PUT", 3411 headers: { "Content-Type": "application/json" }, 3412 body: JSON.stringify(validBody), 3413 }); 3414 expect(res.status).toBe(401); 3415 expect(mockPutRecord).not.toHaveBeenCalled(); 3416 }); 3417 3418 it("returns 403 when user lacks manageThemes permission", async () => { 3419 const { requirePermission } = await import("../../middleware/permissions.js"); 3420 const mockRequirePermission = requirePermission as any; 3421 mockRequirePermission.mockImplementation(() => async (c: any) => { 3422 return c.json({ error: "Forbidden" }, 403); 3423 }); 3424 3425 const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); 3426 const res = await testApp.request("/api/admin/theme-policy", { 3427 method: "PUT", 3428 headers: { "Content-Type": "application/json" }, 3429 body: JSON.stringify(validBody), 3430 }); 3431 3432 expect(res.status).toBe(403); 3433 expect(mockPutRecord).not.toHaveBeenCalled(); 3434 3435 mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { 3436 await next(); 3437 }); 3438 }); 3439 3440 it("returns 500 when ForumAgent is not configured", async () => { 3441 ctx.forumAgent = null; 3442 const res = await app.request("/api/admin/theme-policy", { 3443 method: "PUT", 3444 headers: { "Content-Type": "application/json" }, 3445 body: JSON.stringify(validBody), 3446 }); 3447 expect(res.status).toBe(500); 3448 const body = await res.json(); 3449 expect(body.error).toContain("Forum agent not available"); 3450 }); 3451 3452 it("returns 503 when ForumAgent not authenticated", async () => { 3453 const originalAgent = ctx.forumAgent; 3454 ctx.forumAgent = { getAgent: () => null } as any; 3455 const res = await app.request("/api/admin/theme-policy", { 3456 method: "PUT", 3457 headers: { "Content-Type": "application/json" }, 3458 body: JSON.stringify(validBody), 3459 }); 3460 expect(res.status).toBe(503); 3461 const body = await res.json(); 3462 expect(body.error).toBe("Forum agent not authenticated. Please try again later."); 3463 expect(mockPutRecord).not.toHaveBeenCalled(); 3464 ctx.forumAgent = originalAgent; 3465 }); 3466 }); 3467 3468}); 3469