import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { createTestContext, type TestContext } from "../../lib/__tests__/test-context.js"; import { Hono } from "hono"; import type { Variables } from "../../types.js"; import { memberships, roles, rolePermissions, users, forums, categories, boards, posts, modActions, themes, themePolicies, themePolicyAvailableThemes } from "@atbb/db"; import { eq } from "drizzle-orm"; // Mock middleware at module level let mockUser: any; let mockGetUserRole: ReturnType; let mockPutRecord: ReturnType; let mockDeleteRecord: ReturnType; let mockRequireAnyPermissionPass = true; // Create the mock function at module level mockGetUserRole = vi.fn(); vi.mock("../../middleware/auth.js", () => ({ requireAuth: vi.fn(() => async (c: any, next: any) => { if (!mockUser) { return c.json({ error: "Unauthorized" }, 401); } c.set("user", mockUser); await next(); }), })); vi.mock("../../middleware/permissions.js", () => ({ requirePermission: vi.fn(() => async (_c: any, next: any) => { await next(); }), requireAnyPermission: vi.fn(() => async (c: any, next: any) => { if (!mockRequireAnyPermissionPass) { return c.json({ error: "Insufficient permissions" }, 403); } await next(); }), getUserRole: (...args: any[]) => mockGetUserRole(...args), checkPermission: vi.fn().mockResolvedValue(true), })); // Import after mocking const { createAdminRoutes } = await import("../admin.js"); describe.sequential("Admin Routes", () => { let ctx: TestContext; let app: Hono<{ Variables: Variables }>; beforeEach(async () => { ctx = await createTestContext(); app = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); // Set up mock user for auth middleware mockUser = { did: "did:plc:test-admin" }; mockGetUserRole.mockClear(); mockRequireAnyPermissionPass = true; // Mock putRecord mockPutRecord = vi.fn().mockResolvedValue({ data: { uri: "at://...", cid: "bafytest" } }); mockDeleteRecord = vi.fn().mockResolvedValue({}); // Mock ForumAgent ctx.forumAgent = { getAgent: () => ({ com: { atproto: { repo: { putRecord: mockPutRecord, deleteRecord: mockDeleteRecord, }, }, }, }), } as any; }); afterEach(async () => { await ctx.cleanup(); }); describe("POST /api/admin/members/:did/role", () => { beforeEach(async () => { // Create test roles: Owner (priority 0), Admin (priority 10), Moderator (priority 20) const [ownerRole] = await ctx.db.insert(roles).values({ did: ctx.config.forumDid, rkey: "owner", cid: "bafyowner", name: "Owner", description: "Forum owner", priority: 0, createdAt: new Date(), indexedAt: new Date(), }).returning({ id: roles.id }); await ctx.db.insert(rolePermissions).values([{ roleId: ownerRole.id, permission: "*" }]); const [adminRole] = await ctx.db.insert(roles).values({ did: ctx.config.forumDid, rkey: "admin", cid: "bafyadmin", name: "Admin", description: "Administrator", priority: 10, createdAt: new Date(), indexedAt: new Date(), }).returning({ id: roles.id }); await ctx.db.insert(rolePermissions).values([{ roleId: adminRole.id, permission: "space.atbb.permission.manageRoles" }]); const [moderatorRole] = await ctx.db.insert(roles).values({ did: ctx.config.forumDid, rkey: "moderator", cid: "bafymoderator", name: "Moderator", description: "Moderator", priority: 20, createdAt: new Date(), indexedAt: new Date(), }).returning({ id: roles.id }); await ctx.db.insert(rolePermissions).values([{ roleId: moderatorRole.id, permission: "space.atbb.permission.createPosts" }]); // Create target user and membership (use onConflictDoNothing to handle test re-runs) await ctx.db.insert(users).values({ did: "did:plc:test-target", handle: "target.test", indexedAt: new Date(), }).onConflictDoNothing(); await ctx.db.insert(memberships).values({ did: "did:plc:test-target", rkey: "self", cid: "bafymember", forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, joinedAt: new Date(), createdAt: new Date(), indexedAt: new Date(), }).onConflictDoNothing(); }); it("assigns role successfully when admin has authority", async () => { // Admin (priority 10) assigns Moderator (priority 20) - allowed mockGetUserRole.mockResolvedValue({ id: 2n, name: "Admin", priority: 10, permissions: ["space.atbb.permission.manageRoles"], }); const res = await app.request("/api/admin/members/did:plc:test-target/role", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, }), }); expect(res.status).toBe(200); const data = await res.json(); expect(data).toMatchObject({ success: true, roleAssigned: "Moderator", targetDid: "did:plc:test-target", }); expect(mockPutRecord).toHaveBeenCalledWith( expect.objectContaining({ repo: "did:plc:test-target", collection: "space.atbb.membership", record: expect.objectContaining({ role: expect.objectContaining({ role: expect.objectContaining({ uri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, cid: "bafymoderator", }), }), }), }) ); }); it("returns 403 when assigning role with equal authority", async () => { // Admin (priority 10) tries to assign Admin (priority 10) - blocked mockGetUserRole.mockResolvedValue({ id: 2n, name: "Admin", priority: 10, permissions: ["space.atbb.permission.manageRoles"], }); const res = await app.request("/api/admin/members/did:plc:test-target/role", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/admin`, }), }); expect(res.status).toBe(403); const data = await res.json(); expect(data.error).toContain("equal or higher authority"); // Priority values must not be leaked in responses (security: CLAUDE.md) expect(data.yourPriority).toBeUndefined(); expect(data.targetRolePriority).toBeUndefined(); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 403 when assigning role with higher authority", async () => { // Admin (priority 10) tries to assign Owner (priority 0) - blocked mockGetUserRole.mockResolvedValue({ id: 2n, name: "Admin", priority: 10, permissions: ["space.atbb.permission.manageRoles"], }); const res = await app.request("/api/admin/members/did:plc:test-target/role", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/owner`, }), }); expect(res.status).toBe(403); const data = await res.json(); expect(data.error).toContain("equal or higher authority"); // Priority values must not be leaked in responses (security: CLAUDE.md) expect(data.yourPriority).toBeUndefined(); expect(data.targetRolePriority).toBeUndefined(); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 404 when role not found", async () => { mockGetUserRole.mockResolvedValue({ id: 2n, name: "Admin", priority: 10, permissions: ["space.atbb.permission.manageRoles"], }); const res = await app.request("/api/admin/members/did:plc:test-target/role", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/nonexistent`, }), }); expect(res.status).toBe(404); const data = await res.json(); expect(data.error).toBe("Role not found"); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 404 when target user not a member", async () => { mockGetUserRole.mockResolvedValue({ id: 2n, name: "Admin", priority: 10, permissions: ["space.atbb.permission.manageRoles"], }); const res = await app.request("/api/admin/members/did:plc:nonmember/role", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, }), }); expect(res.status).toBe(404); const data = await res.json(); expect(data.error).toBe("User is not a member of this forum"); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 403 when user has no role assigned", async () => { // getUserRole returns null (user has no role) mockGetUserRole.mockResolvedValue(null); const res = await app.request("/api/admin/members/did:plc:test-target/role", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, }), }); expect(res.status).toBe(403); const data = await res.json(); expect(data.error).toBe("You do not have a role assigned"); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 400 for missing roleUri field", async () => { const res = await app.request("/api/admin/members/did:plc:test-target/role", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }); expect(res.status).toBe(400); const data = await res.json(); expect(data.error).toContain("roleUri is required"); }); it("returns 400 for invalid roleUri format", async () => { const res = await app.request("/api/admin/members/did:plc:test-target/role", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ roleUri: "invalid-uri" }), }); expect(res.status).toBe(400); const data = await res.json(); expect(data.error).toBe("Invalid roleUri format"); }); it("returns 400 for malformed JSON", async () => { const res = await app.request("/api/admin/members/did:plc:test-target/role", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{ invalid json }", }); expect(res.status).toBe(400); const data = await res.json(); expect(data.error).toContain("Invalid JSON"); }); it("returns 503 when PDS connection fails (network error)", async () => { mockGetUserRole.mockResolvedValue({ id: 2n, name: "Admin", priority: 10, permissions: ["space.atbb.permission.manageRoles"], }); mockPutRecord.mockRejectedValue(new Error("fetch failed")); const res = await app.request("/api/admin/members/did:plc:test-target/role", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, }), }); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toContain("Unable to reach external service"); }); it("returns 500 when ForumAgent unavailable", async () => { mockGetUserRole.mockResolvedValue({ id: 2n, name: "Admin", priority: 10, permissions: ["space.atbb.permission.manageRoles"], }); ctx.forumAgent = null; const res = await app.request("/api/admin/members/did:plc:test-target/role", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, }), }); expect(res.status).toBe(500); const data = await res.json(); expect(data.error).toContain("Forum agent not available"); }); it("returns 500 for unexpected server errors", async () => { mockGetUserRole.mockResolvedValue({ id: 2n, name: "Admin", priority: 10, permissions: ["space.atbb.permission.manageRoles"], }); mockPutRecord.mockRejectedValue(new Error("Unexpected write error")); const res = await app.request("/api/admin/members/did:plc:test-target/role", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, }), }); expect(res.status).toBe(500); const data = await res.json(); expect(data.error).toContain("Failed to assign role"); expect(data.error).not.toContain("PDS"); }); }); describe("GET /api/admin/roles", () => { it("lists all roles sorted by priority", async () => { // Create test roles const [ownerRole] = await ctx.db.insert(roles).values({ did: ctx.config.forumDid, rkey: "owner", cid: "bafyowner", name: "Owner", description: "Forum owner", priority: 0, createdAt: new Date(), indexedAt: new Date(), }).returning({ id: roles.id }); await ctx.db.insert(rolePermissions).values([{ roleId: ownerRole.id, permission: "*" }]); const [moderatorRole] = await ctx.db.insert(roles).values({ did: ctx.config.forumDid, rkey: "moderator", cid: "bafymoderator", name: "Moderator", description: "Moderator", priority: 20, createdAt: new Date(), indexedAt: new Date(), }).returning({ id: roles.id }); await ctx.db.insert(rolePermissions).values([{ roleId: moderatorRole.id, permission: "space.atbb.permission.createPosts" }]); const [adminRole] = await ctx.db.insert(roles).values({ did: ctx.config.forumDid, rkey: "admin", cid: "bafyadmin", name: "Admin", description: "Administrator", priority: 10, createdAt: new Date(), indexedAt: new Date(), }).returning({ id: roles.id }); await ctx.db.insert(rolePermissions).values([{ roleId: adminRole.id, permission: "space.atbb.permission.manageRoles" }]); const res = await app.request("/api/admin/roles"); expect(res.status).toBe(200); const data = await res.json(); expect(data.roles).toHaveLength(3); // Verify sorted by priority (Owner first, Moderator last) expect(data.roles[0].name).toBe("Owner"); expect(data.roles[0].priority).toBe(0); expect(data.roles[0].permissions).toEqual(["*"]); expect(data.roles[1].name).toBe("Admin"); expect(data.roles[1].priority).toBe(10); expect(data.roles[2].name).toBe("Moderator"); expect(data.roles[2].priority).toBe(20); // Verify BigInt serialization expect(typeof data.roles[0].id).toBe("string"); }); it("returns empty array when no roles exist", async () => { const res = await app.request("/api/admin/roles"); expect(res.status).toBe(200); const data = await res.json(); expect(data.roles).toEqual([]); }); it("includes uri field constructed from did and rkey", async () => { // Seed a role matching the pattern used in this describe block await ctx.db.insert(roles).values({ did: ctx.config.forumDid, rkey: "moderator", cid: "bafymoderator", name: "Moderator", description: "Moderator", priority: 20, createdAt: new Date(), indexedAt: new Date(), }); const res = await app.request("/api/admin/roles"); expect(res.status).toBe(200); const data = await res.json() as { roles: Array<{ name: string; uri: string; id: string }> }; expect(data.roles).toHaveLength(1); expect(data.roles[0].uri).toBe(`at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`); }); }); describe.sequential("GET /api/admin/members", () => { beforeEach(async () => { // Clean database to ensure no data pollution from other tests await ctx.cleanDatabase(); // Re-insert forum (deleted by cleanDatabase) await ctx.db.insert(forums).values({ did: ctx.config.forumDid, rkey: "self", cid: "bafytest", name: "Test Forum", description: "A test forum", indexedAt: new Date(), }); // Create test role const [moderatorRole] = await ctx.db.insert(roles).values({ did: ctx.config.forumDid, rkey: "moderator", cid: "bafymoderator", name: "Moderator", description: "Moderator", priority: 20, createdAt: new Date(), indexedAt: new Date(), }).returning({ id: roles.id }); await ctx.db.insert(rolePermissions).values([{ roleId: moderatorRole.id, permission: "space.atbb.permission.createPosts" }]); }); it("lists members with assigned roles", async () => { // Create user and membership with role await ctx.db.insert(users).values({ did: "did:plc:test-member-role", handle: "member.test", indexedAt: new Date(), }).onConflictDoNothing(); await ctx.db.insert(memberships).values({ did: "did:plc:test-member-role", rkey: "self", cid: "bafymember", forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, joinedAt: new Date("2026-01-15T00:00:00.000Z"), createdAt: new Date(), indexedAt: new Date(), }).onConflictDoNothing(); const res = await app.request("/api/admin/members"); expect(res.status).toBe(200); const data = await res.json(); expect(data.members).toHaveLength(1); expect(data.members[0]).toMatchObject({ did: "did:plc:test-member-role", handle: "member.test", role: "Moderator", roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, joinedAt: "2026-01-15T00:00:00.000Z", }); }); it("shows Guest for members with no role", async () => { // Create user and membership without role await ctx.db.insert(users).values({ did: "did:plc:test-guest", handle: "guest.test", indexedAt: new Date(), }).onConflictDoNothing(); await ctx.db.insert(memberships).values({ did: "did:plc:test-guest", rkey: "self", cid: "bafymember", forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, roleUri: null, joinedAt: new Date("2026-01-15T00:00:00.000Z"), createdAt: new Date(), indexedAt: new Date(), }).onConflictDoNothing(); const res = await app.request("/api/admin/members"); expect(res.status).toBe(200); const data = await res.json(); expect(data.members).toHaveLength(1); expect(data.members[0]).toMatchObject({ did: "did:plc:test-guest", handle: "guest.test", role: "Guest", roleUri: null, }); }); it("shows DID as handle fallback when handle not found", async () => { // Create user without handle (to test DID fallback) await ctx.db.insert(users).values({ did: "did:plc:test-unknown", handle: null, // No handle to test fallback indexedAt: new Date(), }).onConflictDoNothing(); // Create membership for this user await ctx.db.insert(memberships).values({ did: "did:plc:test-unknown", rkey: "self", cid: "bafymember", forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, roleUri: null, joinedAt: new Date(), createdAt: new Date(), indexedAt: new Date(), }).onConflictDoNothing(); const res = await app.request("/api/admin/members"); expect(res.status).toBe(200); const data = await res.json(); expect(data.members).toHaveLength(1); expect(data.members[0]).toMatchObject({ did: "did:plc:test-unknown", handle: "did:plc:test-unknown", // DID used as fallback role: "Guest", }); }); }); describe.sequential("GET /api/admin/members/me", () => { beforeEach(async () => { // Clean database to ensure no data pollution from other tests await ctx.cleanDatabase(); // Re-insert forum (deleted by cleanDatabase) await ctx.db.insert(forums).values({ did: ctx.config.forumDid, rkey: "self", cid: "bafytest", name: "Test Forum", description: "A test forum", indexedAt: new Date(), }); // Set mock user mockUser = { did: "did:plc:test-me" }; }); it("returns 401 when not authenticated", async () => { mockUser = null; // signals the requireAuth mock to return 401 const res = await app.request("/api/admin/members/me"); expect(res.status).toBe(401); }); it("returns 404 when authenticated user has no membership record", async () => { // mockUser is set to did:plc:test-me but no membership record exists const res = await app.request("/api/admin/members/me"); expect(res.status).toBe(404); const data = await res.json(); expect(data.error).toBe("Membership not found"); }); it("returns 200 with membership, role, and permissions for a user with a linked role", async () => { // Insert role const [moderatorRole] = await ctx.db.insert(roles).values({ did: ctx.config.forumDid, rkey: "moderator", cid: "bafymoderator", name: "Moderator", description: "Moderator role", priority: 20, createdAt: new Date(), indexedAt: new Date(), }).returning({ id: roles.id }); await ctx.db.insert(rolePermissions).values([ { roleId: moderatorRole.id, permission: "space.atbb.permission.createPosts" }, { roleId: moderatorRole.id, permission: "space.atbb.permission.lockTopics" }, ]); // Insert user await ctx.db.insert(users).values({ did: "did:plc:test-me", handle: "me.test", indexedAt: new Date(), }).onConflictDoNothing(); // Insert membership linked to role await ctx.db.insert(memberships).values({ did: "did:plc:test-me", rkey: "self", cid: "bafymembership", forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, joinedAt: new Date("2026-01-15T00:00:00.000Z"), createdAt: new Date(), indexedAt: new Date(), }).onConflictDoNothing(); const res = await app.request("/api/admin/members/me"); expect(res.status).toBe(200); const data = await res.json(); expect(data).toMatchObject({ did: "did:plc:test-me", handle: "me.test", role: "Moderator", roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/moderator`, permissions: ["space.atbb.permission.createPosts", "space.atbb.permission.lockTopics"], }); }); it("returns 200 with empty permissions array when membership exists but role has no permissions", async () => { // Insert role with empty permissions await ctx.db.insert(roles).values({ did: ctx.config.forumDid, rkey: "guest-role", cid: "bafyguestrole", name: "Guest Role", description: "Role with no permissions", priority: 100, createdAt: new Date(), indexedAt: new Date(), }); // No rolePermissions inserted — role has no permissions // Insert user await ctx.db.insert(users).values({ did: "did:plc:test-me", handle: "me.test", indexedAt: new Date(), }).onConflictDoNothing(); // Insert membership linked to role await ctx.db.insert(memberships).values({ did: "did:plc:test-me", rkey: "self", cid: "bafymembership", forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/guest-role`, joinedAt: new Date(), createdAt: new Date(), indexedAt: new Date(), }).onConflictDoNothing(); const res = await app.request("/api/admin/members/me"); expect(res.status).toBe(200); const data = await res.json(); expect(data.permissions).toEqual([]); expect(data.role).toBe("Guest Role"); }); it("only returns the current user's membership, not other users'", async () => { // Insert role const [adminRole] = await ctx.db.insert(roles).values({ did: ctx.config.forumDid, rkey: "admin", cid: "bafyadmin", name: "Admin", description: "Admin role", priority: 10, createdAt: new Date(), indexedAt: new Date(), }).returning({ id: roles.id }); await ctx.db.insert(rolePermissions).values([{ roleId: adminRole.id, permission: "*" }]); // Insert current user with membership await ctx.db.insert(users).values({ did: "did:plc:test-me", handle: "me.test", indexedAt: new Date(), }).onConflictDoNothing(); await ctx.db.insert(memberships).values({ did: "did:plc:test-me", rkey: "self", cid: "bafymymembership", forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, roleUri: `at://${ctx.config.forumDid}/space.atbb.forum.role/admin`, joinedAt: new Date(), createdAt: new Date(), indexedAt: new Date(), }).onConflictDoNothing(); // Insert another user with a different role await ctx.db.insert(users).values({ did: "did:plc:test-other", handle: "other.test", indexedAt: new Date(), }).onConflictDoNothing(); await ctx.db.insert(memberships).values({ did: "did:plc:test-other", rkey: "self", cid: "bafyothermembership", forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, roleUri: null, joinedAt: new Date(), createdAt: new Date(), indexedAt: new Date(), }).onConflictDoNothing(); const res = await app.request("/api/admin/members/me"); expect(res.status).toBe(200); const data = await res.json(); // Should return only our user's data expect(data.did).toBe("did:plc:test-me"); expect(data.handle).toBe("me.test"); expect(data.role).toBe("Admin"); }); it("returns 'Guest' as role when membership has no roleUri", async () => { const forumUri = `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`; await ctx.db.insert(users).values({ did: "did:plc:test-guest", handle: "guest.bsky.social", indexedAt: new Date(), }); await ctx.db.insert(memberships).values({ did: "did:plc:test-guest", rkey: "guestrkey", cid: "bafymembership-guest", forumUri, roleUri: null, joinedAt: new Date(), createdAt: new Date(), indexedAt: new Date(), }); mockUser = { did: "did:plc:test-guest" }; const res = await app.request("/api/admin/members/me"); expect(res.status).toBe(200); const data = await res.json(); expect(data.did).toBe("did:plc:test-guest"); expect(data.role).toBe("Guest"); expect(data.roleUri).toBeNull(); expect(data.permissions).toEqual([]); }); }); describe.sequential("POST /api/admin/categories", () => { beforeEach(async () => { await ctx.cleanDatabase(); mockUser = { did: "did:plc:test-admin" }; mockPutRecord.mockClear(); mockDeleteRecord.mockClear(); mockPutRecord.mockResolvedValue({ data: { uri: `at://${ctx.config.forumDid}/space.atbb.forum.category/tid123`, cid: "bafycategory" } }); }); it("creates category with valid body → 201 and putRecord called", async () => { const res = await app.request("/api/admin/categories", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "General Discussion", description: "Talk about anything.", sortOrder: 1 }), }); expect(res.status).toBe(201); const data = await res.json(); expect(data.uri).toContain("/space.atbb.forum.category/"); expect(data.cid).toBe("bafycategory"); expect(mockPutRecord).toHaveBeenCalledWith( expect.objectContaining({ repo: ctx.config.forumDid, collection: "space.atbb.forum.category", rkey: expect.any(String), record: expect.objectContaining({ $type: "space.atbb.forum.category", name: "General Discussion", description: "Talk about anything.", sortOrder: 1, createdAt: expect.any(String), }), }) ); }); it("creates category without optional fields → 201", async () => { const res = await app.request("/api/admin/categories", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Minimal" }), }); expect(res.status).toBe(201); expect(mockPutRecord).toHaveBeenCalledWith( expect.objectContaining({ record: expect.objectContaining({ name: "Minimal" }), }) ); }); it("returns 400 when name is missing → no PDS write", async () => { const res = await app.request("/api/admin/categories", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ description: "No name field" }), }); expect(res.status).toBe(400); const data = await res.json(); expect(data.error).toContain("name"); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 400 when name is empty string → no PDS write", async () => { const res = await app.request("/api/admin/categories", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: " " }), }); expect(res.status).toBe(400); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 400 for malformed JSON", async () => { const res = await app.request("/api/admin/categories", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{ bad json }", }); expect(res.status).toBe(400); const data = await res.json(); expect(data.error).toContain("Invalid JSON"); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 401 when unauthenticated → no PDS write", async () => { mockUser = null; const res = await app.request("/api/admin/categories", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test" }), }); expect(res.status).toBe(401); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 503 when PDS network error", async () => { mockPutRecord.mockRejectedValue(new Error("fetch failed")); const res = await app.request("/api/admin/categories", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test" }), }); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toContain("Unable to reach external service"); expect(mockPutRecord).toHaveBeenCalled(); }); it("returns 500 when ForumAgent unavailable", async () => { ctx.forumAgent = null; const res = await app.request("/api/admin/categories", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test" }), }); expect(res.status).toBe(500); const data = await res.json(); expect(data.error).toContain("Forum agent not available"); }); it("returns 503 when ForumAgent not authenticated", async () => { const originalAgent = ctx.forumAgent; ctx.forumAgent = { getAgent: () => null } as any; const res = await app.request("/api/admin/categories", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test" }), }); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toBe("Forum agent not authenticated. Please try again later."); expect(mockPutRecord).not.toHaveBeenCalled(); ctx.forumAgent = originalAgent; }); it("returns 403 when user lacks manageCategories permission", async () => { const { requirePermission } = await import("../../middleware/permissions.js"); const mockRequirePermission = requirePermission as any; mockRequirePermission.mockImplementation(() => async (c: any) => { return c.json({ error: "Forbidden" }, 403); }); const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); const res = await testApp.request("/api/admin/categories", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test" }), }); expect(res.status).toBe(403); expect(mockPutRecord).not.toHaveBeenCalled(); mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { await next(); }); }); }); describe.sequential("PUT /api/admin/categories/:id", () => { let categoryId: string; beforeEach(async () => { await ctx.cleanDatabase(); await ctx.db.insert(forums).values({ did: ctx.config.forumDid, rkey: "self", cid: "bafytest", name: "Test Forum", description: "A test forum", indexedAt: new Date(), }); const [cat] = await ctx.db.insert(categories).values({ did: ctx.config.forumDid, rkey: "tid-test-cat", cid: "bafycat", name: "Original Name", description: "Original description", sortOrder: 1, createdAt: new Date("2026-01-01T00:00:00.000Z"), indexedAt: new Date(), }).returning({ id: categories.id }); categoryId = cat.id.toString(); mockUser = { did: "did:plc:test-admin" }; mockPutRecord.mockClear(); mockDeleteRecord.mockClear(); mockPutRecord.mockResolvedValue({ data: { uri: `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`, cid: "bafynewcid" } }); }); it("updates category name → 200 and putRecord called with same rkey", async () => { const res = await app.request(`/api/admin/categories/${categoryId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Updated Name", description: "New desc", sortOrder: 2 }), }); expect(res.status).toBe(200); const data = await res.json(); expect(data.uri).toContain("/space.atbb.forum.category/"); expect(data.cid).toBe("bafynewcid"); expect(mockPutRecord).toHaveBeenCalledWith( expect.objectContaining({ repo: ctx.config.forumDid, collection: "space.atbb.forum.category", rkey: "tid-test-cat", record: expect.objectContaining({ $type: "space.atbb.forum.category", name: "Updated Name", description: "New desc", sortOrder: 2, }), }) ); }); it("preserves original createdAt, description, and sortOrder when not provided", async () => { // category was created with description: "Original description", sortOrder: 1 const res = await app.request(`/api/admin/categories/${categoryId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Updated Name" }), }); expect(res.status).toBe(200); expect(mockPutRecord).toHaveBeenCalledWith( expect.objectContaining({ record: expect.objectContaining({ createdAt: "2026-01-01T00:00:00.000Z", description: "Original description", sortOrder: 1, }), }) ); }); it("returns 400 when name is missing", async () => { const res = await app.request(`/api/admin/categories/${categoryId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ description: "No name" }), }); expect(res.status).toBe(400); const data = await res.json(); expect(data.error).toContain("name"); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 400 when name is whitespace-only", async () => { const res = await app.request(`/api/admin/categories/${categoryId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: " " }), }); expect(res.status).toBe(400); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 400 for malformed JSON", async () => { const res = await app.request(`/api/admin/categories/${categoryId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: "{ bad json }", }); expect(res.status).toBe(400); const data = await res.json(); expect(data.error).toContain("Invalid JSON"); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 400 for invalid category ID format", async () => { const res = await app.request("/api/admin/categories/not-a-number", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test" }), }); expect(res.status).toBe(400); const data = await res.json(); expect(data.error).toContain("Invalid category ID"); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 404 when category not found", async () => { const res = await app.request("/api/admin/categories/99999", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test" }), }); expect(res.status).toBe(404); const data = await res.json(); expect(data.error).toContain("Category not found"); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 401 when unauthenticated", async () => { mockUser = null; const res = await app.request(`/api/admin/categories/${categoryId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test" }), }); expect(res.status).toBe(401); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 503 when PDS network error", async () => { mockPutRecord.mockRejectedValue(new Error("fetch failed")); const res = await app.request(`/api/admin/categories/${categoryId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test" }), }); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toContain("Unable to reach external service"); expect(mockPutRecord).toHaveBeenCalled(); }); it("returns 500 when ForumAgent unavailable", async () => { ctx.forumAgent = null; const res = await app.request(`/api/admin/categories/${categoryId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test" }), }); expect(res.status).toBe(500); const data = await res.json(); expect(data.error).toContain("Forum agent not available"); }); it("returns 503 when ForumAgent not authenticated", async () => { const originalAgent = ctx.forumAgent; ctx.forumAgent = { getAgent: () => null } as any; const res = await app.request(`/api/admin/categories/${categoryId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test" }), }); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toBe("Forum agent not authenticated. Please try again later."); expect(mockPutRecord).not.toHaveBeenCalled(); ctx.forumAgent = originalAgent; }); it("returns 503 when category lookup query fails", async () => { const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { throw new Error("Database connection lost"); }); const res = await app.request(`/api/admin/categories/${categoryId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test" }), }); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toBe("Database temporarily unavailable. Please try again later."); expect(mockPutRecord).not.toHaveBeenCalled(); dbSelectSpy.mockRestore(); }); it("returns 403 when user lacks manageCategories permission", async () => { const { requirePermission } = await import("../../middleware/permissions.js"); const mockRequirePermission = requirePermission as any; mockRequirePermission.mockImplementation(() => async (c: any) => { return c.json({ error: "Forbidden" }, 403); }); const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); const res = await testApp.request(`/api/admin/categories/${categoryId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test" }), }); expect(res.status).toBe(403); expect(mockPutRecord).not.toHaveBeenCalled(); mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { await next(); }); }); }); describe.sequential("DELETE /api/admin/categories/:id", () => { let categoryId: string; beforeEach(async () => { await ctx.cleanDatabase(); await ctx.db.insert(forums).values({ did: ctx.config.forumDid, rkey: "self", cid: "bafytest", name: "Test Forum", description: "A test forum", indexedAt: new Date(), }); const [cat] = await ctx.db.insert(categories).values({ did: ctx.config.forumDid, rkey: "tid-test-del", cid: "bafycat", name: "Delete Me", description: null, sortOrder: 1, createdAt: new Date(), indexedAt: new Date(), }).returning({ id: categories.id }); categoryId = cat.id.toString(); mockUser = { did: "did:plc:test-admin" }; mockDeleteRecord.mockClear(); mockDeleteRecord.mockResolvedValue({}); }); it("deletes empty category → 200 and deleteRecord called", async () => { const res = await app.request(`/api/admin/categories/${categoryId}`, { method: "DELETE", }); expect(res.status).toBe(200); const data = await res.json(); expect(data.success).toBe(true); expect(mockDeleteRecord).toHaveBeenCalledWith({ repo: ctx.config.forumDid, collection: "space.atbb.forum.category", rkey: "tid-test-del", }); }); it("returns 409 when category has boards → deleteRecord NOT called", async () => { await ctx.db.insert(boards).values({ did: ctx.config.forumDid, rkey: "tid-board-1", cid: "bafyboard", name: "Blocked Board", categoryId: BigInt(categoryId), categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-del`, createdAt: new Date(), indexedAt: new Date(), }); const res = await app.request(`/api/admin/categories/${categoryId}`, { method: "DELETE", }); expect(res.status).toBe(409); const data = await res.json(); expect(data.error).toContain("boards"); expect(mockDeleteRecord).not.toHaveBeenCalled(); }); it("returns 400 for invalid category ID", async () => { const res = await app.request("/api/admin/categories/not-a-number", { method: "DELETE", }); expect(res.status).toBe(400); const data = await res.json(); expect(data.error).toContain("Invalid category ID"); expect(mockDeleteRecord).not.toHaveBeenCalled(); }); it("returns 404 when category not found", async () => { const res = await app.request("/api/admin/categories/99999", { method: "DELETE", }); expect(res.status).toBe(404); const data = await res.json(); expect(data.error).toContain("Category not found"); expect(mockDeleteRecord).not.toHaveBeenCalled(); }); it("returns 401 when unauthenticated", async () => { mockUser = null; const res = await app.request(`/api/admin/categories/${categoryId}`, { method: "DELETE", }); expect(res.status).toBe(401); expect(mockDeleteRecord).not.toHaveBeenCalled(); }); it("returns 503 when PDS network error on delete", async () => { mockDeleteRecord.mockRejectedValue(new Error("fetch failed")); const res = await app.request(`/api/admin/categories/${categoryId}`, { method: "DELETE", }); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toContain("Unable to reach external service"); expect(mockDeleteRecord).toHaveBeenCalled(); }); it("returns 500 when ForumAgent unavailable", async () => { ctx.forumAgent = null; const res = await app.request(`/api/admin/categories/${categoryId}`, { method: "DELETE", }); expect(res.status).toBe(500); const data = await res.json(); expect(data.error).toContain("Forum agent not available"); }); it("returns 503 when ForumAgent not authenticated", async () => { const originalAgent = ctx.forumAgent; ctx.forumAgent = { getAgent: () => null } as any; const res = await app.request(`/api/admin/categories/${categoryId}`, { method: "DELETE", }); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toBe("Forum agent not authenticated. Please try again later."); expect(mockDeleteRecord).not.toHaveBeenCalled(); ctx.forumAgent = originalAgent; }); it("returns 503 when category lookup query fails", async () => { const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { throw new Error("Database connection lost"); }); const res = await app.request(`/api/admin/categories/${categoryId}`, { method: "DELETE", }); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toBe("Database temporarily unavailable. Please try again later."); expect(mockDeleteRecord).not.toHaveBeenCalled(); dbSelectSpy.mockRestore(); }); it("returns 503 when board count query fails", async () => { const originalSelect = ctx.db.select.bind(ctx.db); let callCount = 0; const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementation((...args: any[]) => { callCount++; if (callCount === 1) { // First call: category lookup — pass through to real DB return (originalSelect as any)(...args); } // Second call: board count preflight — throw DB error throw new Error("Database connection lost"); }); const res = await app.request(`/api/admin/categories/${categoryId}`, { method: "DELETE", }); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toBe("Database temporarily unavailable. Please try again later."); expect(mockDeleteRecord).not.toHaveBeenCalled(); dbSelectSpy.mockRestore(); }); it("returns 403 when user lacks manageCategories permission", async () => { const { requirePermission } = await import("../../middleware/permissions.js"); const mockRequirePermission = requirePermission as any; mockRequirePermission.mockImplementation(() => async (c: any) => { return c.json({ error: "Forbidden" }, 403); }); const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); const res = await testApp.request(`/api/admin/categories/${categoryId}`, { method: "DELETE", }); expect(res.status).toBe(403); expect(mockDeleteRecord).not.toHaveBeenCalled(); mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { await next(); }); }); }); describe.sequential("POST /api/admin/boards", () => { let categoryUri: string; beforeEach(async () => { await ctx.cleanDatabase(); mockUser = { did: "did:plc:test-admin" }; mockPutRecord.mockClear(); mockDeleteRecord.mockClear(); mockPutRecord.mockResolvedValue({ data: { uri: `at://${ctx.config.forumDid}/space.atbb.forum.board/tid123`, cid: "bafyboard", }, }); // Insert a category the tests can reference await ctx.db.insert(categories).values({ did: ctx.config.forumDid, rkey: "tid-test-cat", cid: "bafycat", name: "Test Category", createdAt: new Date("2026-01-01T00:00:00.000Z"), indexedAt: new Date(), }); categoryUri = `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`; }); it("creates board with valid body → 201 and putRecord called with categoryRef", async () => { const res = await app.request("/api/admin/boards", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "General Chat", description: "Talk here.", sortOrder: 1, categoryUri }), }); expect(res.status).toBe(201); const data = await res.json(); expect(data.uri).toContain("/space.atbb.forum.board/"); expect(data.cid).toBe("bafyboard"); expect(mockPutRecord).toHaveBeenCalledWith( expect.objectContaining({ repo: ctx.config.forumDid, collection: "space.atbb.forum.board", rkey: expect.any(String), record: expect.objectContaining({ $type: "space.atbb.forum.board", name: "General Chat", description: "Talk here.", sortOrder: 1, category: { category: { uri: categoryUri, cid: "bafycat" } }, createdAt: expect.any(String), }), }) ); }); it("creates board without optional fields → 201", async () => { const res = await app.request("/api/admin/boards", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Minimal", categoryUri }), }); expect(res.status).toBe(201); expect(mockPutRecord).toHaveBeenCalledWith( expect.objectContaining({ record: expect.objectContaining({ name: "Minimal" }), }) ); }); it("returns 400 when name is missing → no PDS write", async () => { const res = await app.request("/api/admin/boards", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ categoryUri }), }); expect(res.status).toBe(400); const data = await res.json(); expect(data.error).toContain("name"); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 400 when name is empty string → no PDS write", async () => { const res = await app.request("/api/admin/boards", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: " ", categoryUri }), }); expect(res.status).toBe(400); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 400 when categoryUri is missing → no PDS write", async () => { const res = await app.request("/api/admin/boards", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test Board" }), }); expect(res.status).toBe(400); const data = await res.json(); expect(data.error).toContain("categoryUri"); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 404 when categoryUri references unknown category → no PDS write", async () => { const res = await app.request("/api/admin/boards", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test Board", categoryUri: `at://${ctx.config.forumDid}/space.atbb.forum.category/unknown999` }), }); expect(res.status).toBe(404); const data = await res.json(); expect(data.error).toContain("Category not found"); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 400 for malformed JSON", async () => { const res = await app.request("/api/admin/boards", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{ bad json }", }); expect(res.status).toBe(400); const data = await res.json(); expect(data.error).toContain("Invalid JSON"); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 401 when unauthenticated → no PDS write", async () => { mockUser = null; const res = await app.request("/api/admin/boards", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test", categoryUri }), }); expect(res.status).toBe(401); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 503 when PDS network error", async () => { mockPutRecord.mockRejectedValue(new Error("fetch failed")); const res = await app.request("/api/admin/boards", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test", categoryUri }), }); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toContain("Unable to reach external service"); expect(mockPutRecord).toHaveBeenCalled(); }); it("returns 500 when ForumAgent unavailable", async () => { ctx.forumAgent = null; const res = await app.request("/api/admin/boards", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test", categoryUri }), }); expect(res.status).toBe(500); const data = await res.json(); expect(data.error).toContain("Forum agent not available"); }); it("returns 503 when ForumAgent not authenticated", async () => { const originalAgent = ctx.forumAgent; ctx.forumAgent = { getAgent: () => null } as any; const res = await app.request("/api/admin/boards", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test", categoryUri }), }); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toBe("Forum agent not authenticated. Please try again later."); expect(mockPutRecord).not.toHaveBeenCalled(); ctx.forumAgent = originalAgent; }); it("returns 503 when category lookup query fails", async () => { const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { throw new Error("Database connection lost"); }); const res = await app.request("/api/admin/boards", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test Board", categoryUri }), }); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toBe("Database temporarily unavailable. Please try again later."); expect(mockPutRecord).not.toHaveBeenCalled(); dbSelectSpy.mockRestore(); }); it("returns 403 when user lacks manageCategories permission", async () => { const { requirePermission } = await import("../../middleware/permissions.js"); const mockRequirePermission = requirePermission as any; mockRequirePermission.mockImplementation(() => async (c: any) => { return c.json({ error: "Forbidden" }, 403); }); const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); const res = await testApp.request("/api/admin/boards", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test", categoryUri }), }); expect(res.status).toBe(403); expect(mockPutRecord).not.toHaveBeenCalled(); mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { await next(); }); }); }); describe.sequential("PUT /api/admin/boards/:id", () => { let boardId: string; let categoryUri: string; beforeEach(async () => { await ctx.cleanDatabase(); mockUser = { did: "did:plc:test-admin" }; mockPutRecord.mockClear(); mockDeleteRecord.mockClear(); mockPutRecord.mockResolvedValue({ data: { uri: `at://${ctx.config.forumDid}/space.atbb.forum.board/tid-test-board`, cid: "bafyboardupdated", }, }); // Insert a category and a board const [cat] = await ctx.db.insert(categories).values({ did: ctx.config.forumDid, rkey: "tid-test-cat", cid: "bafycat", name: "Test Category", createdAt: new Date("2026-01-01T00:00:00.000Z"), indexedAt: new Date(), }).returning({ id: categories.id }); categoryUri = `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`; const [brd] = await ctx.db.insert(boards).values({ did: ctx.config.forumDid, rkey: "tid-test-board", cid: "bafyboard", name: "Original Name", description: "Original description", sortOrder: 1, categoryId: cat.id, categoryUri, createdAt: new Date("2026-01-01T00:00:00.000Z"), indexedAt: new Date(), }).returning({ id: boards.id }); boardId = brd.id.toString(); }); it("updates board with all fields → 200 and putRecord called with same rkey", async () => { const res = await app.request(`/api/admin/boards/${boardId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Renamed Board", description: "New description", sortOrder: 2 }), }); expect(res.status).toBe(200); const data = await res.json(); expect(data.uri).toContain("/space.atbb.forum.board/"); expect(data.cid).toBe("bafyboardupdated"); expect(mockPutRecord).toHaveBeenCalledWith( expect.objectContaining({ repo: ctx.config.forumDid, collection: "space.atbb.forum.board", rkey: "tid-test-board", record: expect.objectContaining({ $type: "space.atbb.forum.board", name: "Renamed Board", description: "New description", sortOrder: 2, category: { category: { uri: categoryUri, cid: "bafycat" } }, }), }) ); }); it("updates board without optional fields → falls back to existing values", async () => { const res = await app.request(`/api/admin/boards/${boardId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Renamed Only" }), }); expect(res.status).toBe(200); expect(mockPutRecord).toHaveBeenCalledWith( expect.objectContaining({ record: expect.objectContaining({ name: "Renamed Only", description: "Original description", sortOrder: 1, }), }) ); }); it("returns 400 when name is missing", async () => { const res = await app.request(`/api/admin/boards/${boardId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ description: "No name" }), }); expect(res.status).toBe(400); const data = await res.json(); expect(data.error).toContain("name"); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 400 when name is empty string", async () => { const res = await app.request(`/api/admin/boards/${boardId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: " " }), }); expect(res.status).toBe(400); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 400 for non-numeric ID", async () => { const res = await app.request("/api/admin/boards/not-a-number", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test" }), }); expect(res.status).toBe(400); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 404 when board not found", async () => { const res = await app.request("/api/admin/boards/99999", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test" }), }); expect(res.status).toBe(404); const data = await res.json(); expect(data.error).toContain("Board not found"); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 400 for malformed JSON", async () => { const res = await app.request(`/api/admin/boards/${boardId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: "{ bad json }", }); expect(res.status).toBe(400); const data = await res.json(); expect(data.error).toContain("Invalid JSON"); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 401 when unauthenticated", async () => { mockUser = null; const res = await app.request(`/api/admin/boards/${boardId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test" }), }); expect(res.status).toBe(401); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 503 when PDS network error", async () => { mockPutRecord.mockRejectedValue(new Error("fetch failed")); const res = await app.request(`/api/admin/boards/${boardId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test" }), }); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toContain("Unable to reach external service"); }); it("returns 500 when ForumAgent unavailable", async () => { ctx.forumAgent = null; const res = await app.request(`/api/admin/boards/${boardId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test" }), }); expect(res.status).toBe(500); const data = await res.json(); expect(data.error).toContain("Forum agent not available"); }); it("returns 503 when ForumAgent not authenticated", async () => { const originalAgent = ctx.forumAgent; ctx.forumAgent = { getAgent: () => null } as any; const res = await app.request(`/api/admin/boards/${boardId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test" }), }); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toBe("Forum agent not authenticated. Please try again later."); expect(mockPutRecord).not.toHaveBeenCalled(); ctx.forumAgent = originalAgent; }); it("returns 403 when user lacks manageCategories permission", async () => { const { requirePermission } = await import("../../middleware/permissions.js"); const mockRequirePermission = requirePermission as any; mockRequirePermission.mockImplementation(() => async (c: any) => { return c.json({ error: "Forbidden" }, 403); }); const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); const res = await testApp.request(`/api/admin/boards/${boardId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test" }), }); expect(res.status).toBe(403); expect(mockPutRecord).not.toHaveBeenCalled(); mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { await next(); }); }); it("returns 503 when board lookup query fails", async () => { const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { throw new Error("Database connection lost"); }); const res = await app.request(`/api/admin/boards/${boardId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Updated Name" }), }); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toBe("Database temporarily unavailable. Please try again later."); expect(mockPutRecord).not.toHaveBeenCalled(); dbSelectSpy.mockRestore(); }); it("returns 503 when category CID lookup query fails", async () => { const originalSelect = ctx.db.select.bind(ctx.db); let callCount = 0; const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementation((...args: any[]) => { callCount++; if (callCount === 1) { // First call: board lookup — pass through to real DB return (originalSelect as any)(...args); } // Second call: category CID fetch — throw DB error throw new Error("Database connection lost"); }); const res = await app.request(`/api/admin/boards/${boardId}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Updated Name" }), }); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toBe("Database temporarily unavailable. Please try again later."); expect(mockPutRecord).not.toHaveBeenCalled(); dbSelectSpy.mockRestore(); }); }); describe.sequential("DELETE /api/admin/boards/:id", () => { let boardId: string; let categoryUri: string; beforeEach(async () => { await ctx.cleanDatabase(); mockUser = { did: "did:plc:test-admin" }; mockPutRecord.mockClear(); mockDeleteRecord.mockClear(); mockDeleteRecord.mockResolvedValue({}); // Insert a category and a board const [cat] = await ctx.db.insert(categories).values({ did: ctx.config.forumDid, rkey: "tid-test-cat", cid: "bafycat", name: "Test Category", createdAt: new Date("2026-01-01T00:00:00.000Z"), indexedAt: new Date(), }).returning({ id: categories.id }); categoryUri = `at://${ctx.config.forumDid}/space.atbb.forum.category/tid-test-cat`; const [brd] = await ctx.db.insert(boards).values({ did: ctx.config.forumDid, rkey: "tid-test-board", cid: "bafyboard", name: "Test Board", categoryId: cat.id, categoryUri, createdAt: new Date("2026-01-01T00:00:00.000Z"), indexedAt: new Date(), }).returning({ id: boards.id }); boardId = brd.id.toString(); }); it("deletes empty board → 200 and deleteRecord called", async () => { const res = await app.request(`/api/admin/boards/${boardId}`, { method: "DELETE", }); expect(res.status).toBe(200); const data = await res.json(); expect(data.success).toBe(true); expect(mockDeleteRecord).toHaveBeenCalledWith({ repo: ctx.config.forumDid, collection: "space.atbb.forum.board", rkey: "tid-test-board", }); }); it("returns 409 when board has posts → deleteRecord NOT called", async () => { // Insert a user and a post referencing this board await ctx.db.insert(users).values({ did: "did:plc:test-user", handle: "testuser.bsky.social", indexedAt: new Date(), }); const [brd] = await ctx.db.select().from(boards).where(eq(boards.rkey, "tid-test-board")).limit(1); await ctx.db.insert(posts).values({ did: "did:plc:test-user", rkey: "tid-test-post", cid: "bafypost", text: "Hello world", boardId: brd.id, boardUri: `at://${ctx.config.forumDid}/space.atbb.forum.board/tid-test-board`, forumUri: `at://${ctx.config.forumDid}/space.atbb.forum.forum/self`, createdAt: new Date(), indexedAt: new Date(), }); const res = await app.request(`/api/admin/boards/${boardId}`, { method: "DELETE", }); expect(res.status).toBe(409); const data = await res.json(); expect(data.error).toContain("posts"); expect(mockDeleteRecord).not.toHaveBeenCalled(); }); it("returns 400 for non-numeric ID", async () => { const res = await app.request("/api/admin/boards/not-a-number", { method: "DELETE", }); expect(res.status).toBe(400); const data = await res.json(); expect(data.error).toContain("Invalid board ID"); expect(mockDeleteRecord).not.toHaveBeenCalled(); }); it("returns 404 when board not found", async () => { const res = await app.request("/api/admin/boards/99999", { method: "DELETE", }); expect(res.status).toBe(404); const data = await res.json(); expect(data.error).toContain("Board not found"); expect(mockDeleteRecord).not.toHaveBeenCalled(); }); it("returns 503 when board lookup query fails", async () => { const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { throw new Error("Database connection lost"); }); const res = await app.request(`/api/admin/boards/${boardId}`, { method: "DELETE", }); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toContain("Please try again later"); expect(mockDeleteRecord).not.toHaveBeenCalled(); dbSelectSpy.mockRestore(); }); it("returns 503 when post count query fails", async () => { const originalSelect = ctx.db.select.bind(ctx.db); let callCount = 0; const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementation((...args: any[]) => { callCount++; if (callCount === 1) { // First call: board lookup — pass through to real DB return (originalSelect as any)(...args); } // Second call: post count preflight — throw DB error throw new Error("Database connection lost"); }); const res = await app.request(`/api/admin/boards/${boardId}`, { method: "DELETE", }); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toContain("Please try again later"); expect(mockDeleteRecord).not.toHaveBeenCalled(); dbSelectSpy.mockRestore(); }); it("returns 401 when unauthenticated", async () => { mockUser = null; const res = await app.request(`/api/admin/boards/${boardId}`, { method: "DELETE", }); expect(res.status).toBe(401); expect(mockDeleteRecord).not.toHaveBeenCalled(); }); it("returns 503 when PDS network error", async () => { mockDeleteRecord.mockRejectedValue(new Error("fetch failed")); const res = await app.request(`/api/admin/boards/${boardId}`, { method: "DELETE", }); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toContain("Unable to reach external service"); }); it("returns 500 when ForumAgent unavailable", async () => { ctx.forumAgent = null; const res = await app.request(`/api/admin/boards/${boardId}`, { method: "DELETE", }); expect(res.status).toBe(500); const data = await res.json(); expect(data.error).toContain("Forum agent not available"); }); it("returns 503 when ForumAgent not authenticated", async () => { const originalAgent = ctx.forumAgent; ctx.forumAgent = { getAgent: () => null } as any; const res = await app.request(`/api/admin/boards/${boardId}`, { method: "DELETE", }); expect(res.status).toBe(503); const data = await res.json(); expect(data.error).toBe("Forum agent not authenticated. Please try again later."); expect(mockDeleteRecord).not.toHaveBeenCalled(); ctx.forumAgent = originalAgent; }); it("returns 403 when user lacks manageCategories permission", async () => { const { requirePermission } = await import("../../middleware/permissions.js"); const mockRequirePermission = requirePermission as any; mockRequirePermission.mockImplementation(() => async (c: any) => { return c.json({ error: "Forbidden" }, 403); }); const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); const res = await testApp.request(`/api/admin/boards/${boardId}`, { method: "DELETE", }); expect(res.status).toBe(403); expect(mockDeleteRecord).not.toHaveBeenCalled(); mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { await next(); }); }); }); describe("GET /api/admin/modlog", () => { beforeEach(async () => { await ctx.cleanDatabase(); }); it("returns 401 when not authenticated", async () => { mockUser = null; const res = await app.request("/api/admin/modlog"); expect(res.status).toBe(401); }); it("returns 403 when user lacks all mod permissions", async () => { mockRequireAnyPermissionPass = false; const res = await app.request("/api/admin/modlog"); expect(res.status).toBe(403); }); it("returns empty list when no mod actions exist", async () => { const res = await app.request("/api/admin/modlog"); expect(res.status).toBe(200); const data = await res.json() as any; expect(data.actions).toEqual([]); expect(data.total).toBe(0); expect(data.offset).toBe(0); expect(data.limit).toBe(50); }); it("returns paginated mod actions with moderator and subject handles", async () => { await ctx.db.insert(users).values([ { did: "did:plc:mod-alice", handle: "alice.bsky.social", indexedAt: new Date() }, { did: "did:plc:subject-bob", handle: "bob.bsky.social", indexedAt: new Date() }, ]); await ctx.db.insert(modActions).values({ did: ctx.config.forumDid, rkey: "modaction-ban-1", cid: "cid-ban-1", action: "space.atbb.modAction.ban", subjectDid: "did:plc:subject-bob", subjectPostUri: null, createdBy: "did:plc:mod-alice", reason: "Spam", createdAt: new Date("2026-02-26T12:01:00Z"), indexedAt: new Date(), }); const res = await app.request("/api/admin/modlog"); expect(res.status).toBe(200); const data = await res.json() as any; expect(data.total).toBe(1); expect(data.actions).toHaveLength(1); const action = data.actions[0]; expect(typeof action.id).toBe("string"); expect(action.action).toBe("space.atbb.modAction.ban"); expect(action.moderatorDid).toBe("did:plc:mod-alice"); expect(action.moderatorHandle).toBe("alice.bsky.social"); expect(action.subjectDid).toBe("did:plc:subject-bob"); expect(action.subjectHandle).toBe("bob.bsky.social"); expect(action.subjectPostUri).toBeNull(); expect(action.reason).toBe("Spam"); expect(action.createdAt).toBe("2026-02-26T12:01:00.000Z"); }); it("returns null subjectHandle and populated subjectPostUri for post-targeting actions", async () => { await ctx.db.insert(users).values({ did: "did:plc:mod-carol", handle: "carol.bsky.social", indexedAt: new Date(), }); await ctx.db.insert(modActions).values({ did: ctx.config.forumDid, rkey: "modaction-hide-1", cid: "cid-hide-1", action: "space.atbb.modAction.hide", subjectDid: null, subjectPostUri: "at://did:plc:user/space.atbb.post/abc123", createdBy: "did:plc:mod-carol", reason: "Inappropriate", createdAt: new Date("2026-02-26T11:30:00Z"), indexedAt: new Date(), }); const res = await app.request("/api/admin/modlog"); expect(res.status).toBe(200); const data = await res.json() as any; const action = data.actions.find((a: any) => a.action === "space.atbb.modAction.hide"); expect(action).toBeDefined(); expect(action.subjectDid).toBeNull(); expect(action.subjectHandle).toBeNull(); expect(action.subjectPostUri).toBe("at://did:plc:user/space.atbb.post/abc123"); }); it("falls back to moderatorDid when moderator has no handle indexed", async () => { await ctx.db.insert(users).values({ did: "did:plc:mod-nohandle", handle: null, indexedAt: new Date(), }); await ctx.db.insert(modActions).values({ did: ctx.config.forumDid, rkey: "modaction-nohandle-1", cid: "cid-nohandle-1", action: "space.atbb.modAction.ban", subjectDid: null, subjectPostUri: null, createdBy: "did:plc:mod-nohandle", reason: "Test", createdAt: new Date(), indexedAt: new Date(), }); const res = await app.request("/api/admin/modlog"); expect(res.status).toBe(200); const data = await res.json() as any; const action = data.actions.find((a: any) => a.moderatorDid === "did:plc:mod-nohandle"); expect(action).toBeDefined(); expect(action.moderatorHandle).toBe("did:plc:mod-nohandle"); }); it("falls back to moderatorDid when moderator has no users row at all", async () => { // Insert a mod action whose createdBy DID has NO entry in the users table await ctx.db.insert(modActions).values({ did: ctx.config.forumDid, rkey: "modaction-nouser-1", cid: "cid-nouser-1", action: "space.atbb.modAction.ban", subjectDid: null, subjectPostUri: null, createdBy: "did:plc:mod-completely-unknown", reason: "No users row", createdAt: new Date(), indexedAt: new Date(), }); const res = await app.request("/api/admin/modlog"); expect(res.status).toBe(200); const data = await res.json() as any; // The action must appear in the results (not silently dropped by an inner join) const action = data.actions.find( (a: any) => a.moderatorDid === "did:plc:mod-completely-unknown" ); expect(action).toBeDefined(); expect(action.moderatorHandle).toBe("did:plc:mod-completely-unknown"); }); it("returns actions in createdAt DESC order", async () => { await ctx.db.insert(users).values({ did: "did:plc:mod-order", handle: "order.bsky.social", indexedAt: new Date(), }); const now = Date.now(); await ctx.db.insert(modActions).values([ { did: ctx.config.forumDid, rkey: "modaction-old", cid: "cid-old", action: "space.atbb.modAction.ban", subjectDid: null, subjectPostUri: null, createdBy: "did:plc:mod-order", reason: "Old action", createdAt: new Date(now - 10000), indexedAt: new Date(), }, { did: ctx.config.forumDid, rkey: "modaction-new", cid: "cid-new", action: "space.atbb.modAction.hide", subjectDid: null, subjectPostUri: null, createdBy: "did:plc:mod-order", reason: "New action", createdAt: new Date(now), indexedAt: new Date(), }, ]); const res = await app.request("/api/admin/modlog"); const data = await res.json() as any; const orderActions = data.actions.filter((a: any) => a.moderatorDid === "did:plc:mod-order" ); expect(orderActions).toHaveLength(2); expect(orderActions[0].reason).toBe("New action"); expect(orderActions[1].reason).toBe("Old action"); }); it("respects limit and offset query params", async () => { await ctx.db.insert(users).values({ did: "did:plc:mod-pagination", handle: "pagination.bsky.social", indexedAt: new Date(), }); await ctx.db.insert(modActions).values([ { 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() }, { 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() }, { 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() }, ]); const page1 = await app.request("/api/admin/modlog?limit=2&offset=0"); const data1 = await page1.json() as any; expect(data1.actions).toHaveLength(2); expect(data1.limit).toBe(2); expect(data1.offset).toBe(0); expect(data1.total).toBe(3); expect(data1.actions[0].reason).toBe("A"); const page2 = await app.request("/api/admin/modlog?limit=2&offset=2"); const data2 = await page2.json() as any; expect(data2.actions).toHaveLength(1); expect(data2.total).toBe(3); expect(data2.actions[0].reason).toBe("C"); }); it("returns 400 for non-numeric limit", async () => { const res = await app.request("/api/admin/modlog?limit=abc"); expect(res.status).toBe(400); const data = await res.json() as any; expect(data.error).toMatch(/limit/i); }); it("returns 400 for negative limit", async () => { const res = await app.request("/api/admin/modlog?limit=-1"); expect(res.status).toBe(400); }); it("returns 400 for negative offset", async () => { const res = await app.request("/api/admin/modlog?offset=-5"); expect(res.status).toBe(400); }); it("caps limit at 100", async () => { const res = await app.request("/api/admin/modlog?limit=999"); expect(res.status).toBe(200); const data = await res.json() as any; expect(data.limit).toBe(100); }); it("uses default limit=50 and offset=0 when not provided", async () => { const res = await app.request("/api/admin/modlog"); expect(res.status).toBe(200); const data = await res.json() as any; expect(data.limit).toBe(50); expect(data.offset).toBe(0); }); }); describe("GET /api/admin/themes", () => { beforeEach(async () => { await ctx.cleanDatabase(); }); it("returns empty array when no themes exist", async () => { const res = await app.request("/api/admin/themes"); expect(res.status).toBe(200); const body = await res.json(); expect(body).toHaveProperty("themes"); expect(body.themes).toEqual([]); }); it("returns all themes regardless of policy availability", async () => { // Insert two themes but only add one to policy await ctx.db.insert(themes).values([ { did: ctx.config.forumDid, rkey: "3lbltheme1aa", cid: "bafytheme1", name: "Neobrutal Light", colorScheme: "light", tokens: { "color-bg": "#f5f0e8" }, createdAt: new Date(), indexedAt: new Date(), }, { did: ctx.config.forumDid, rkey: "3lbltheme2bb", cid: "bafytheme2", name: "Neobrutal Dark", colorScheme: "dark", tokens: { "color-bg": "#1a1a1a" }, createdAt: new Date(), indexedAt: new Date(), }, ]); const res = await app.request("/api/admin/themes"); expect(res.status).toBe(200); const body = await res.json(); // Returns BOTH themes — not filtered by policy expect(body.themes).toHaveLength(2); expect(body.themes[0]).toMatchObject({ name: "Neobrutal Light", colorScheme: "light", }); expect(body.themes[0]).toHaveProperty("tokens"); expect(body.themes[0]).toHaveProperty("uri"); expect(body.themes[0].uri).toContain("space.atbb.forum.theme"); }); it("returns 401 when not authenticated", async () => { mockUser = null; const res = await app.request("/api/admin/themes"); expect(res.status).toBe(401); }); }); describe("POST /api/admin/themes", () => { it("creates theme and returns 201 with uri and cid", async () => { const res = await app.request("/api/admin/themes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Neobrutal Light", colorScheme: "light", tokens: { "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }, }), }); expect(res.status).toBe(201); const body = await res.json(); expect(body.uri).toBeDefined(); expect(body.cid).toBeDefined(); expect(mockPutRecord).toHaveBeenCalledOnce(); }); it("includes cssOverrides and fontUrls when provided", async () => { const res = await app.request("/api/admin/themes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Custom Theme", colorScheme: "dark", tokens: { "color-bg": "#1a1a1a" }, cssOverrides: ".card { border-radius: 4px; }", fontUrls: ["https://fonts.googleapis.com/css2?family=Space+Grotesk"], }), }); expect(res.status).toBe(201); const call = mockPutRecord.mock.calls[0][0]; // Sanitizer reformats CSS to compact form (no extra spaces) expect(call.record.cssOverrides).toBe(".card{border-radius:4px}"); expect(call.record.fontUrls).toEqual(["https://fonts.googleapis.com/css2?family=Space+Grotesk"]); }); it("strips dangerous CSS constructs from cssOverrides before PDS write", async () => { const res = await app.request("/api/admin/themes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Dangerous Theme", colorScheme: "light", tokens: { "color-bg": "#ffffff" }, cssOverrides: '@import "https://evil.com/steal.css"; .ok { color: red; }', }), }); expect(res.status).toBe(201); const call = mockPutRecord.mock.calls[0][0]; expect(call.record.cssOverrides).not.toContain("@import"); expect(call.record.cssOverrides).not.toContain("evil.com"); expect(call.record.cssOverrides).toContain("color:red"); }); it("returns 400 when name is missing", async () => { const res = await app.request("/api/admin/themes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ colorScheme: "light", tokens: {} }), }); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/name/i); }); it("returns 400 when name is empty string", async () => { const res = await app.request("/api/admin/themes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: " ", colorScheme: "light", tokens: {} }), }); expect(res.status).toBe(400); }); it("returns 400 when colorScheme is invalid", async () => { const res = await app.request("/api/admin/themes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test", colorScheme: "purple", tokens: {} }), }); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/colorScheme/i); }); it("returns 400 when colorScheme is missing", async () => { const res = await app.request("/api/admin/themes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test", tokens: {} }), }); expect(res.status).toBe(400); }); it("returns 400 when tokens is missing", async () => { const res = await app.request("/api/admin/themes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test", colorScheme: "light" }), }); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/tokens/i); }); it("returns 400 when tokens is an array (not an object)", async () => { const res = await app.request("/api/admin/themes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: ["a", "b"] }), }); expect(res.status).toBe(400); }); it("returns 400 when a token value is not a string", async () => { const res = await app.request("/api/admin/themes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: { "color-bg": 123 } }), }); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/tokens/i); }); it("returns 400 when a fontUrl is not HTTPS", async () => { const res = await app.request("/api/admin/themes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {}, fontUrls: ["http://example.com/font.css"], }), }); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/https/i); }); it("returns 500 when ForumAgent is not configured", async () => { ctx.forumAgent = null; const res = await app.request("/api/admin/themes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }), }); expect(res.status).toBe(500); const body = await res.json(); expect(body.error).toContain("Forum agent not available"); }); it("returns 503 when ForumAgent not authenticated", async () => { const originalAgent = ctx.forumAgent; ctx.forumAgent = { getAgent: () => null } as any; const res = await app.request("/api/admin/themes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }), }); expect(res.status).toBe(503); const body = await res.json(); expect(body.error).toBe("Forum agent not authenticated. Please try again later."); expect(mockPutRecord).not.toHaveBeenCalled(); ctx.forumAgent = originalAgent; }); it("returns 401 when not authenticated", async () => { mockUser = null; const res = await app.request("/api/admin/themes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }), }); expect(res.status).toBe(401); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 403 when user lacks manageThemes permission", async () => { const { requirePermission } = await import("../../middleware/permissions.js"); const mockRequirePermission = requirePermission as any; mockRequirePermission.mockImplementation(() => async (c: any) => { return c.json({ error: "Forbidden" }, 403); }); const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); const res = await testApp.request("/api/admin/themes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }), }); expect(res.status).toBe(403); expect(mockPutRecord).not.toHaveBeenCalled(); mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { await next(); }); }); it("returns 503 when PDS write fails with a network error", async () => { mockPutRecord.mockRejectedValueOnce(new Error("fetch failed")); const res = await app.request("/api/admin/themes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: {} }), }); expect(res.status).toBe(503); }); }); describe("PUT /api/admin/themes/:rkey", () => { const TEST_RKEY = "3lblputtest1"; const TEST_CREATED_AT = new Date("2026-01-01T00:00:00Z"); beforeEach(async () => { await ctx.db.insert(themes).values({ did: ctx.config.forumDid, rkey: TEST_RKEY, cid: "bafythemeput", name: "Original Theme", colorScheme: "light", tokens: { "color-bg": "#ffffff", "color-text": "#000000" }, cssOverrides: ".existing { color: red; }", fontUrls: ["https://fonts.example.com/existing.css"], createdAt: TEST_CREATED_AT, indexedAt: new Date(), }); }); it("updates theme and returns 200 with uri and cid", async () => { const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Updated Theme", colorScheme: "dark", tokens: { "color-bg": "#1a1a1a", "color-text": "#ffffff" }, }), }); expect(res.status).toBe(200); const body = await res.json(); expect(body.uri).toBeDefined(); expect(body.cid).toBeDefined(); expect(mockPutRecord).toHaveBeenCalledOnce(); const call = mockPutRecord.mock.calls[0][0]; expect(call.record.name).toBe("Updated Theme"); expect(call.record.colorScheme).toBe("dark"); expect(call.rkey).toBe(TEST_RKEY); }); it("preserves existing cssOverrides when not provided in request body", async () => { const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Updated Theme", colorScheme: "light", tokens: { "color-bg": "#f0f0f0" }, }), }); expect(res.status).toBe(200); const call = mockPutRecord.mock.calls[0][0]; // Sanitizer reformats CSS to compact form (no extra spaces) expect(call.record.cssOverrides).toBe(".existing{color:red}"); }); it("strips dangerous CSS constructs from cssOverrides before PDS write on update", async () => { const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Updated Theme", colorScheme: "light", tokens: { "color-bg": "#f0f0f0" }, cssOverrides: 'body { background: url("https://evil.com/track.gif"); color: blue; }', }), }); expect(res.status).toBe(200); const call = mockPutRecord.mock.calls[0][0]; expect(call.record.cssOverrides).not.toContain("evil.com"); expect(call.record.cssOverrides).toContain("color:blue"); }); it("preserves existing fontUrls when not provided in request body", async () => { const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Updated Theme", colorScheme: "light", tokens: { "color-bg": "#f0f0f0" }, }), }); expect(res.status).toBe(200); const call = mockPutRecord.mock.calls[0][0]; expect(call.record.fontUrls).toEqual(["https://fonts.example.com/existing.css"]); }); it("preserves original createdAt in the PDS record", async () => { const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Updated Theme", colorScheme: "light", tokens: { "color-bg": "#f0f0f0" }, }), }); expect(res.status).toBe(200); const call = mockPutRecord.mock.calls[0][0]; expect(call.record.createdAt).toBe("2026-01-01T00:00:00.000Z"); }); it("returns 404 for unknown rkey", async () => { const res = await app.request("/api/admin/themes/nonexistentkey", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Updated Theme", colorScheme: "light", tokens: { "color-bg": "#f0f0f0" }, }), }); expect(res.status).toBe(404); const body = await res.json(); expect(body.error).toMatch(/not found/i); }); it("returns 400 when name is missing", async () => { const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ colorScheme: "light", tokens: {} }), }); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/name/i); }); it("returns 400 when colorScheme is invalid", async () => { const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test", colorScheme: "sepia", tokens: {} }), }); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/colorScheme/i); }); it("returns 400 when tokens is an array", async () => { const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Test", colorScheme: "light", tokens: ["a", "b"] }), }); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/tokens/i); }); it("returns 401 when not authenticated", async () => { mockUser = null; const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Updated Theme", colorScheme: "light", tokens: { "color-bg": "#f0f0f0" }, }), }); expect(res.status).toBe(401); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 403 when user lacks manageThemes permission", async () => { const { requirePermission } = await import("../../middleware/permissions.js"); const mockRequirePermission = requirePermission as any; mockRequirePermission.mockImplementation(() => async (c: any) => { return c.json({ error: "Forbidden" }, 403); }); const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); const res = await testApp.request(`/api/admin/themes/${TEST_RKEY}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Updated Theme", colorScheme: "light", tokens: { "color-bg": "#f0f0f0" }, }), }); expect(res.status).toBe(403); expect(mockPutRecord).not.toHaveBeenCalled(); mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { await next(); }); }); it("returns 503 when ForumAgent not authenticated", async () => { const originalAgent = ctx.forumAgent; ctx.forumAgent = { getAgent: () => null } as any; const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Updated", colorScheme: "light", tokens: {} }), }); expect(res.status).toBe(503); const body = await res.json(); expect(body.error).toBe("Forum agent not authenticated. Please try again later."); expect(mockPutRecord).not.toHaveBeenCalled(); ctx.forumAgent = originalAgent; }); it("returns 503 when PDS write fails with a network error", async () => { mockPutRecord.mockRejectedValueOnce(new Error("fetch failed")); const res = await app.request(`/api/admin/themes/${TEST_RKEY}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: "Updated Theme", colorScheme: "light", tokens: { "color-bg": "#f0f0f0" }, }), }); expect(res.status).toBe(503); }); }); describe("DELETE /api/admin/themes/:rkey", () => { const themeRkey = "3lbldeltest1"; beforeEach(async () => { await ctx.db.insert(themes).values({ did: ctx.config.forumDid, rkey: themeRkey, cid: "bafydeltest", name: "Theme To Delete", colorScheme: "light", tokens: { "color-bg": "#ffffff" }, createdAt: new Date(), indexedAt: new Date(), }); }); it("deletes theme and returns 200 with success: true", async () => { const res = await app.request(`/api/admin/themes/${themeRkey}`, { method: "DELETE", }); expect(res.status).toBe(200); const body = await res.json(); expect(body.success).toBe(true); expect(mockDeleteRecord).toHaveBeenCalledWith({ repo: ctx.config.forumDid, collection: "space.atbb.forum.theme", rkey: themeRkey, }); }); it("returns 404 for unknown rkey", async () => { const res = await app.request("/api/admin/themes/doesnotexist", { method: "DELETE", }); expect(res.status).toBe(404); }); it("returns 409 when theme is the defaultLightTheme in policy", async () => { await ctx.db.insert(themePolicies).values({ did: ctx.config.forumDid, rkey: "self", cid: "bafypolicydel", defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`, defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, allowUserChoice: true, indexedAt: new Date(), }); const res = await app.request(`/api/admin/themes/${themeRkey}`, { method: "DELETE", }); expect(res.status).toBe(409); const body = await res.json(); expect(body.error).toMatch(/default/i); }); it("returns 409 when theme is the defaultDarkTheme in policy", async () => { await ctx.db.insert(themePolicies).values({ did: ctx.config.forumDid, rkey: "self", cid: "bafypolicydel2", defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`, allowUserChoice: true, indexedAt: new Date(), }); const res = await app.request(`/api/admin/themes/${themeRkey}`, { method: "DELETE", }); expect(res.status).toBe(409); const body = await res.json(); expect(body.error).toMatch(/default/i); }); it("deletes successfully when theme exists in policy availableThemes but not as a default", async () => { const [policy] = await ctx.db.insert(themePolicies).values({ did: ctx.config.forumDid, rkey: "self", cid: "bafypolicyavail", defaultLightThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, defaultDarkThemeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/other`, allowUserChoice: true, indexedAt: new Date(), }).returning(); await ctx.db.insert(themePolicyAvailableThemes).values({ policyId: policy.id, themeUri: `at://${ctx.config.forumDid}/space.atbb.forum.theme/${themeRkey}`, themeCid: "bafydeltest", }); const res = await app.request(`/api/admin/themes/${themeRkey}`, { method: "DELETE", }); expect(res.status).toBe(200); }); it("returns 401 when not authenticated", async () => { mockUser = null; const res = await app.request(`/api/admin/themes/${themeRkey}`, { method: "DELETE", }); expect(res.status).toBe(401); expect(mockDeleteRecord).not.toHaveBeenCalled(); }); it("returns 403 when user lacks manageThemes permission", async () => { const { requirePermission } = await import("../../middleware/permissions.js"); const mockRequirePermission = requirePermission as any; mockRequirePermission.mockImplementation(() => async (c: any) => { return c.json({ error: "Forbidden" }, 403); }); const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); const res = await testApp.request(`/api/admin/themes/${themeRkey}`, { method: "DELETE", }); expect(res.status).toBe(403); expect(mockDeleteRecord).not.toHaveBeenCalled(); mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { await next(); }); }); it("returns 503 when ForumAgent not authenticated", async () => { const originalAgent = ctx.forumAgent; ctx.forumAgent = { getAgent: () => null } as any; const res = await app.request(`/api/admin/themes/${themeRkey}`, { method: "DELETE", }); expect(res.status).toBe(503); const body = await res.json(); expect(body.error).toBe("Forum agent not authenticated. Please try again later."); expect(mockDeleteRecord).not.toHaveBeenCalled(); ctx.forumAgent = originalAgent; }); it("returns 503 when PDS delete fails with a network error", async () => { mockDeleteRecord.mockRejectedValueOnce(new Error("fetch failed")); const res = await app.request(`/api/admin/themes/${themeRkey}`, { method: "DELETE", }); expect(res.status).toBe(503); }); }); describe("POST /api/admin/themes/:rkey/duplicate", () => { beforeEach(async () => { await ctx.cleanDatabase(); await ctx.db.insert(themes).values({ did: ctx.config.forumDid, rkey: "3lblsource1aa", cid: "bafysource1", name: "Neobrutal Light", colorScheme: "light", tokens: { "color-bg": "#f5f0e8", "color-primary": "#ff5c00" }, createdAt: new Date(), indexedAt: new Date(), }); }); it("calls putRecord with a new rkey and '(Copy)' name", async () => { mockPutRecord.mockResolvedValueOnce({ data: { uri: "at://did:plc:test-forum/space.atbb.forum.theme/3lblcopy001a", cid: "bafycopy1" }, }); const res = await app.request("/api/admin/themes/3lblsource1aa/duplicate", { method: "POST", }); expect(res.status).toBe(201); const body = await res.json(); expect(body.name).toBe("Neobrutal Light (Copy)"); expect(body.rkey).toBeDefined(); expect(body.rkey).not.toBe("3lblsource1aa"); expect(body.uri).toContain("space.atbb.forum.theme"); expect(mockPutRecord).toHaveBeenCalledOnce(); const putCall = mockPutRecord.mock.calls[0][0]; expect(putCall.record.name).toBe("Neobrutal Light (Copy)"); expect(putCall.record.colorScheme).toBe("light"); expect(putCall.record.tokens).toEqual({ "color-bg": "#f5f0e8", "color-primary": "#ff5c00" }); expect(putCall.collection).toBe("space.atbb.forum.theme"); }); it("returns 404 when source rkey does not exist", async () => { const res = await app.request("/api/admin/themes/nonexistent/duplicate", { method: "POST", }); expect(res.status).toBe(404); }); it("returns 401 when not authenticated", async () => { mockUser = null; const res = await app.request("/api/admin/themes/3lblsource1aa/duplicate", { method: "POST", }); expect(res.status).toBe(401); }); it("copies cssOverrides and fontUrls when they are set on the source", async () => { // Insert a theme with optional fields populated await ctx.db.insert(themes).values({ did: ctx.config.forumDid, rkey: "3lblsource2bb", cid: "bafysource2", name: "Custom Theme", colorScheme: "dark", tokens: { "color-bg": "#1a1a1a" }, cssOverrides: "body { font-size: 18px; }", fontUrls: ["https://fonts.googleapis.com/css2?family=Roboto"], createdAt: new Date(), indexedAt: new Date(), }); const res = await app.request("/api/admin/themes/3lblsource2bb/duplicate", { method: "POST", }); expect(res.status).toBe(201); expect(mockPutRecord).toHaveBeenCalledOnce(); const putCall = mockPutRecord.mock.calls[0][0]; // Sanitizer reformats CSS to compact form on duplication expect(putCall.record.cssOverrides).toBe("body{font-size:18px}"); expect(putCall.record.fontUrls).toEqual(["https://fonts.googleapis.com/css2?family=Roboto"]); expect(putCall.record.name).toBe("Custom Theme (Copy)"); }); }); describe("PUT /api/admin/theme-policy", () => { const lightUri = `at://did:plc:test-forum/space.atbb.forum.theme/3lbllight1`; const darkUri = `at://did:plc:test-forum/space.atbb.forum.theme/3lbldark11`; const validBody = { availableThemes: [ { uri: lightUri, cid: "bafylight" }, { uri: darkUri, cid: "bafydark" }, ], defaultLightThemeUri: lightUri, defaultDarkThemeUri: darkUri, allowUserChoice: true, }; it("creates policy (upsert) and returns 200 with uri and cid", async () => { const res = await app.request("/api/admin/theme-policy", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(validBody), }); expect(res.status).toBe(200); const body = await res.json(); expect(body.uri).toBeDefined(); expect(body.cid).toBeDefined(); expect(mockPutRecord).toHaveBeenCalledOnce(); }); it("writes PDS record with flat themeRef structure (no theme: wrapper)", async () => { await app.request("/api/admin/theme-policy", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(validBody), }); const call = mockPutRecord.mock.calls[0][0]; expect(call.record.$type).toBe("space.atbb.forum.themePolicy"); expect(call.rkey).toBe("self"); // Flat themeRef: { uri, cid } — no nested theme: {} wrapper expect(call.record.availableThemes[0]).toEqual({ uri: lightUri, cid: "bafylight" }); expect(call.record.defaultLightTheme).toEqual({ uri: lightUri, cid: "bafylight" }); expect(call.record.defaultDarkTheme).toEqual({ uri: darkUri, cid: "bafydark" }); expect(call.record.allowUserChoice).toBe(true); expect(typeof call.record.updatedAt).toBe("string"); expect(call.collection).toBe("space.atbb.forum.themePolicy"); expect(call.repo).toBe(ctx.config.forumDid); }); it("overwrites existing policy (upsert) and returns 200 with uri and cid", async () => { await ctx.db.insert(themePolicies).values({ did: ctx.config.forumDid, rkey: "self", cid: "bafyexisting", defaultLightThemeUri: lightUri, defaultDarkThemeUri: darkUri, allowUserChoice: false, indexedAt: new Date(), }); const res = await app.request("/api/admin/theme-policy", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(validBody), }); expect(res.status).toBe(200); const body = await res.json(); expect(body.uri).toBeDefined(); expect(body.cid).toBeDefined(); expect(mockPutRecord).toHaveBeenCalledOnce(); }); it("defaults allowUserChoice to true when not provided", async () => { const { allowUserChoice: _, ...bodyWithout } = validBody; await app.request("/api/admin/theme-policy", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(bodyWithout), }); const call = mockPutRecord.mock.calls[0][0]; expect(call.record.allowUserChoice).toBe(true); }); it("returns 400 when availableThemes is missing", async () => { const { availableThemes: _, ...bodyWithout } = validBody; const res = await app.request("/api/admin/theme-policy", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(bodyWithout), }); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/availableThemes/i); }); it("returns 400 when availableThemes is empty array", async () => { const res = await app.request("/api/admin/theme-policy", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...validBody, availableThemes: [] }), }); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/availableThemes/i); }); it("accepts uri-only entries as live refs — writes themeRef without cid", async () => { // URI without cid is a valid live ref (e.g. canonical atbb.space preset) // No DB lookup or insertion required — CID is simply absent in the PDS record const liveUri = `at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light`; const res = await app.request("/api/admin/theme-policy", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ defaultLightThemeUri: liveUri, defaultDarkThemeUri: liveUri, allowUserChoice: true, availableThemes: [{ uri: liveUri }], // no cid — live ref }), }); expect(res.status).toBe(200); expect(mockPutRecord).toHaveBeenCalledOnce(); const putCall = mockPutRecord.mock.calls[0][0]; // Live ref has uri but no cid field expect(putCall.record.availableThemes[0]).toEqual({ uri: liveUri }); expect(putCall.record.defaultLightTheme).toEqual({ uri: liveUri }); expect(putCall.record.defaultDarkTheme).toEqual({ uri: liveUri }); }); it("accepts live refs to external URIs not in local DB (e.g. atbb.space canonical presets)", async () => { // Previously the route rejected URIs not found in the local DB. // Now URI-only entries are valid live refs regardless of whether the theme is local. const externalUri = `at://did:web:atbb.space/space.atbb.forum.theme/clean-light`; const res = await app.request("/api/admin/theme-policy", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ defaultLightThemeUri: externalUri, defaultDarkThemeUri: externalUri, allowUserChoice: true, availableThemes: [{ uri: externalUri }], }), }); expect(res.status).toBe(200); expect(mockPutRecord).toHaveBeenCalledOnce(); }); it("treats cid: \"\" as absent and writes a live ref (no cid in PDS record)", async () => { const themeUri = `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme2aa`; const res = await app.request("/api/admin/theme-policy", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ defaultLightThemeUri: themeUri, defaultDarkThemeUri: themeUri, allowUserChoice: true, availableThemes: [{ uri: themeUri, cid: "" }], // empty string → treated as absent }), }); expect(res.status).toBe(200); expect(mockPutRecord).toHaveBeenCalledOnce(); const putCall = mockPutRecord.mock.calls[0][0]; // Empty string cid is dropped — written as live ref expect(putCall.record.availableThemes[0]).toEqual({ uri: themeUri }); }); it("uses provided cid as-is when entry includes one (pinned ref)", async () => { await app.request("/api/admin/theme-policy", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(validBody), }); const putCall = mockPutRecord.mock.calls[0][0]; // Pinned refs include both uri and cid expect(putCall.record.availableThemes[0]).toEqual({ uri: lightUri, cid: "bafylight" }); expect(putCall.record.availableThemes[1]).toEqual({ uri: darkUri, cid: "bafydark" }); }); it("returns 400 when defaultLightThemeUri is not in availableThemes", async () => { const res = await app.request("/api/admin/theme-policy", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...validBody, defaultLightThemeUri: "at://did:plc:test-forum/space.atbb.forum.theme/notinlist", }), }); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/defaultLightThemeUri/i); }); it("returns 400 when defaultDarkThemeUri is not in availableThemes", async () => { const res = await app.request("/api/admin/theme-policy", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...validBody, defaultDarkThemeUri: "at://did:plc:test-forum/space.atbb.forum.theme/notinlist", }), }); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/defaultDarkThemeUri/i); }); it("returns 400 when defaultLightThemeUri is missing", async () => { const { defaultLightThemeUri: _, ...bodyWithout } = validBody; const res = await app.request("/api/admin/theme-policy", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(bodyWithout), }); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/defaultLightThemeUri/i); }); it("returns 400 when defaultDarkThemeUri is missing", async () => { const { defaultDarkThemeUri: _, ...bodyWithout } = validBody; const res = await app.request("/api/admin/theme-policy", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(bodyWithout), }); expect(res.status).toBe(400); const body = await res.json(); expect(body.error).toMatch(/defaultDarkThemeUri/i); }); it("returns 401 when not authenticated", async () => { mockUser = null; const res = await app.request("/api/admin/theme-policy", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(validBody), }); expect(res.status).toBe(401); expect(mockPutRecord).not.toHaveBeenCalled(); }); it("returns 403 when user lacks manageThemes permission", async () => { const { requirePermission } = await import("../../middleware/permissions.js"); const mockRequirePermission = requirePermission as any; mockRequirePermission.mockImplementation(() => async (c: any) => { return c.json({ error: "Forbidden" }, 403); }); const testApp = new Hono<{ Variables: Variables }>().route("/api/admin", createAdminRoutes(ctx)); const res = await testApp.request("/api/admin/theme-policy", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(validBody), }); expect(res.status).toBe(403); expect(mockPutRecord).not.toHaveBeenCalled(); mockRequirePermission.mockImplementation(() => async (_c: any, next: any) => { await next(); }); }); it("returns 500 when ForumAgent is not configured", async () => { ctx.forumAgent = null; const res = await app.request("/api/admin/theme-policy", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(validBody), }); expect(res.status).toBe(500); const body = await res.json(); expect(body.error).toContain("Forum agent not available"); }); it("returns 503 when ForumAgent not authenticated", async () => { const originalAgent = ctx.forumAgent; ctx.forumAgent = { getAgent: () => null } as any; const res = await app.request("/api/admin/theme-policy", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(validBody), }); expect(res.status).toBe(503); const body = await res.json(); expect(body.error).toBe("Forum agent not authenticated. Please try again later."); expect(mockPutRecord).not.toHaveBeenCalled(); ctx.forumAgent = originalAgent; }); }); });