import { SystemRole } from "@capakraken/shared"; import { describe, expect, it, vi } from "vitest"; import { commentRouter } from "../router/comment.js"; import { createCallerFactory } from "../trpc.js"; const createCaller = createCallerFactory(commentRouter); function createContext( db: Record, options: { role?: SystemRole; session?: boolean; } = {}, ) { const { role = SystemRole.USER, session = true } = options; return { session: session ? { user: { email: "user@example.com", name: "User", image: null }, expires: "2099-01-01T00:00:00.000Z", } : null, db: db as never, dbUser: session ? { id: role === SystemRole.ADMIN ? "user_admin" : "user_1", systemRole: role, permissionOverrides: null, } : null, }; } describe("comment router authorization", () => { it("requires authentication before listing estimate comments", async () => { const estimateFindUnique = vi.fn(); const commentFindMany = vi.fn(); const caller = createCaller(createContext({ estimate: { findUnique: estimateFindUnique, }, comment: { findMany: commentFindMany, }, }, { session: false })); await expect(caller.list({ entityType: "estimate", entityId: "est_1" })).rejects.toMatchObject({ code: "UNAUTHORIZED", message: "Authentication required", }); expect(estimateFindUnique).not.toHaveBeenCalled(); expect(commentFindMany).not.toHaveBeenCalled(); }); it("forbids plain users from reading or creating estimate comments", async () => { const estimateFindUnique = vi.fn(); const commentFindMany = vi.fn(); const commentCount = vi.fn(); const commentCreate = vi.fn(); const caller = createCaller(createContext({ estimate: { findUnique: estimateFindUnique, }, comment: { findMany: commentFindMany, count: commentCount, create: commentCreate, }, })); await expect(caller.list({ entityType: "estimate", entityId: "est_1" })).rejects.toMatchObject({ code: "FORBIDDEN", message: "Controller access required", }); await expect(caller.count({ entityType: "estimate", entityId: "est_1" })).rejects.toMatchObject({ code: "FORBIDDEN", message: "Controller access required", }); await expect(caller.create({ entityType: "estimate", entityId: "est_1", body: "Please review this estimate.", })).rejects.toMatchObject({ code: "FORBIDDEN", message: "Controller access required", }); expect(estimateFindUnique).not.toHaveBeenCalled(); expect(commentFindMany).not.toHaveBeenCalled(); expect(commentCount).not.toHaveBeenCalled(); expect(commentCreate).not.toHaveBeenCalled(); }); it("allows controllers to list, count, and create estimate comments", async () => { const estimateFindUnique = vi.fn().mockResolvedValue({ id: "est_1" }); const commentFindMany = vi.fn().mockResolvedValue([]); const commentCount = vi.fn().mockResolvedValue(2); const commentCreate = vi.fn().mockResolvedValue({ id: "comment_1", body: "Please review this estimate.", author: { id: "user_1", name: "Controller User", email: "user@example.com", image: null }, }); const caller = createCaller(createContext({ estimate: { findUnique: estimateFindUnique, }, comment: { findMany: commentFindMany, count: commentCount, create: commentCreate, }, notification: { create: vi.fn(), }, auditLog: { create: vi.fn(), }, }, { role: SystemRole.CONTROLLER })); const listResult = await caller.list({ entityType: "estimate", entityId: "est_1" }); const countResult = await caller.count({ entityType: "estimate", entityId: "est_1" }); const createResult = await caller.create({ entityType: "estimate", entityId: "est_1", body: "Please review this estimate.", }); expect(listResult).toEqual([]); expect(countResult).toBe(2); expect(createResult.id).toBe("comment_1"); expect(estimateFindUnique).toHaveBeenCalledTimes(3); expect(commentCreate).toHaveBeenCalledWith({ data: { entityType: "estimate", entityId: "est_1", authorId: "user_1", body: "Please review this estimate.", mentions: [], }, include: { author: { select: { id: true, name: true, email: true, image: true } }, }, }); }); it("returns estimate mention candidates only for the controller audience", async () => { const estimateFindUnique = vi.fn().mockResolvedValue({ id: "est_1" }); const userFindMany = vi.fn().mockResolvedValue([ { id: "user_admin", name: "Admin User", email: "admin@example.com" }, { id: "user_controller", name: "Controller User", email: "controller@example.com" }, ]); const caller = createCaller(createContext({ estimate: { findUnique: estimateFindUnique, }, user: { findMany: userFindMany, }, }, { role: SystemRole.CONTROLLER })); const result = await caller.listMentionCandidates({ entityType: "estimate", entityId: "est_1", query: "con", }); expect(result).toEqual([ { id: "user_admin", name: "Admin User", email: "admin@example.com" }, { id: "user_controller", name: "Controller User", email: "controller@example.com" }, ]); expect(estimateFindUnique).toHaveBeenCalledTimes(1); expect(userFindMany).toHaveBeenCalledWith({ where: { systemRole: { in: [SystemRole.ADMIN, SystemRole.MANAGER, SystemRole.CONTROLLER] }, OR: [ { name: { contains: "con", mode: "insensitive" } }, { email: { contains: "con", mode: "insensitive" } }, ], }, select: { id: true, name: true, email: true, }, orderBy: [{ name: "asc" }, { email: "asc" }], take: 20, }); }); it("forbids plain users from reading estimate mention candidates", async () => { const estimateFindUnique = vi.fn(); const userFindMany = vi.fn(); const caller = createCaller(createContext({ estimate: { findUnique: estimateFindUnique, }, user: { findMany: userFindMany, }, })); await expect(caller.listMentionCandidates({ entityType: "estimate", entityId: "est_1", query: "con", })).rejects.toMatchObject({ code: "FORBIDDEN", message: "Controller access required", }); expect(estimateFindUnique).not.toHaveBeenCalled(); expect(userFindMany).not.toHaveBeenCalled(); }); it("allows users to list, count, and create comments on their own resource", async () => { const resourceFindUnique = vi.fn().mockResolvedValue({ id: "res_1" }); const resourceFindFirst = vi.fn().mockResolvedValue({ id: "res_1" }); const commentFindMany = vi.fn().mockResolvedValue([]); const commentCount = vi.fn().mockResolvedValue(1); const commentCreate = vi.fn().mockResolvedValue({ id: "comment_resource_1", body: "Please update my profile summary.", author: { id: "user_1", name: "Resource User", email: "user@example.com", image: null }, }); const caller = createCaller(createContext({ resource: { findUnique: resourceFindUnique, findFirst: resourceFindFirst, }, comment: { findMany: commentFindMany, count: commentCount, create: commentCreate, }, notification: { create: vi.fn(), }, auditLog: { create: vi.fn(), }, })); const listResult = await caller.list({ entityType: "resource", entityId: "res_1" }); const countResult = await caller.count({ entityType: "resource", entityId: "res_1" }); const createResult = await caller.create({ entityType: "resource", entityId: "res_1", body: "Please update my profile summary.", }); expect(listResult).toEqual([]); expect(countResult).toBe(1); expect(createResult.id).toBe("comment_resource_1"); expect(resourceFindUnique).toHaveBeenCalledTimes(3); expect(resourceFindFirst).toHaveBeenCalledTimes(3); expect(commentCreate).toHaveBeenCalledWith({ data: { entityType: "resource", entityId: "res_1", authorId: "user_1", body: "Please update my profile summary.", mentions: [], }, include: { author: { select: { id: true, name: true, email: true, image: true } }, }, }); }); it("returns resource mention candidates for the own-resource audience only", async () => { const resourceFindUnique = vi .fn() .mockResolvedValueOnce({ id: "res_1" }) .mockResolvedValueOnce({ userId: "user_1" }); const resourceFindFirst = vi.fn().mockResolvedValue({ id: "res_1" }); const userFindMany = vi.fn().mockResolvedValue([ { id: "user_1", name: "Resource User", email: "user@example.com", systemRole: SystemRole.USER, permissionOverrides: null, }, { id: "manager_1", name: "Manager User", email: "manager@example.com", systemRole: SystemRole.MANAGER, permissionOverrides: null, }, { id: "viewer_1", name: "Viewer User", email: "viewer@example.com", systemRole: SystemRole.VIEWER, permissionOverrides: null, }, { id: "user_2", name: "Override Staff", email: "override@example.com", systemRole: SystemRole.USER, permissionOverrides: { granted: ["viewAllResources"] }, }, ]); const caller = createCaller(createContext({ resource: { findUnique: resourceFindUnique, findFirst: resourceFindFirst, }, user: { findMany: userFindMany, }, })); const result = await caller.listMentionCandidates({ entityType: "resource", entityId: "res_1", }); expect(result).toEqual([ { id: "user_1", name: "Resource User", email: "user@example.com" }, { id: "manager_1", name: "Manager User", email: "manager@example.com" }, { id: "user_2", name: "Override Staff", email: "override@example.com" }, ]); expect(resourceFindUnique).toHaveBeenCalledTimes(2); expect(resourceFindFirst).toHaveBeenCalledTimes(1); expect(userFindMany).toHaveBeenCalledWith({ where: undefined, select: { id: true, name: true, email: true, systemRole: true, permissionOverrides: true, }, orderBy: [{ name: "asc" }, { email: "asc" }], }); }); it("forbids users from reading or creating comments on foreign resources", async () => { const resourceFindUnique = vi.fn().mockResolvedValue({ id: "res_2" }); const resourceFindFirst = vi.fn().mockResolvedValue({ id: "res_1" }); const commentFindMany = vi.fn(); const commentCount = vi.fn(); const commentCreate = vi.fn(); const caller = createCaller(createContext({ resource: { findUnique: resourceFindUnique, findFirst: resourceFindFirst, }, comment: { findMany: commentFindMany, count: commentCount, create: commentCreate, }, })); await expect(caller.list({ entityType: "resource", entityId: "res_2" })).rejects.toMatchObject({ code: "FORBIDDEN", message: "You can only comment on your own resource unless you have staff access", }); await expect(caller.count({ entityType: "resource", entityId: "res_2" })).rejects.toMatchObject({ code: "FORBIDDEN", message: "You can only comment on your own resource unless you have staff access", }); await expect(caller.create({ entityType: "resource", entityId: "res_2", body: "This should not work.", })).rejects.toMatchObject({ code: "FORBIDDEN", message: "You can only comment on your own resource unless you have staff access", }); expect(commentFindMany).not.toHaveBeenCalled(); expect(commentCount).not.toHaveBeenCalled(); expect(commentCreate).not.toHaveBeenCalled(); }); it("forbids users from reading mention candidates on foreign resources", async () => { const resourceFindUnique = vi.fn().mockResolvedValue({ id: "res_2" }); const resourceFindFirst = vi.fn().mockResolvedValue({ id: "res_1" }); const userFindMany = vi.fn(); const caller = createCaller(createContext({ resource: { findUnique: resourceFindUnique, findFirst: resourceFindFirst, }, user: { findMany: userFindMany, }, })); await expect(caller.listMentionCandidates({ entityType: "resource", entityId: "res_2", query: "staff", })).rejects.toMatchObject({ code: "FORBIDDEN", message: "You can only comment on your own resource unless you have staff access", }); expect(userFindMany).not.toHaveBeenCalled(); }); it("rejects unsupported comment entity types before touching the database", async () => { const estimateFindUnique = vi.fn(); const commentFindMany = vi.fn(); const caller = createCaller(createContext({ estimate: { findUnique: estimateFindUnique, }, comment: { findMany: commentFindMany, }, }, { role: SystemRole.CONTROLLER })); await expect(caller.list({ entityType: "scope_item" as never, entityId: "scope_1", })).rejects.toMatchObject({ code: "BAD_REQUEST", }); expect(estimateFindUnique).not.toHaveBeenCalled(); expect(commentFindMany).not.toHaveBeenCalled(); }); it("rejects replies whose parent comment belongs to another entity", async () => { const estimateFindUnique = vi.fn().mockResolvedValue({ id: "est_1" }); const parentFindUnique = vi.fn().mockResolvedValue({ id: "comment_parent", entityType: "estimate", entityId: "est_2", }); const commentCreate = vi.fn(); const caller = createCaller(createContext({ estimate: { findUnique: estimateFindUnique, }, comment: { findUnique: parentFindUnique, create: commentCreate, }, }, { role: SystemRole.CONTROLLER })); await expect(caller.create({ entityType: "estimate", entityId: "est_1", parentId: "comment_parent", body: "Replying on the right estimate.", })).rejects.toMatchObject({ code: "BAD_REQUEST", message: "Parent comment does not belong to the requested entity", }); expect(commentCreate).not.toHaveBeenCalled(); }); it("requires comment authorship or admin rights after entity visibility is granted", async () => { const estimateFindUnique = vi.fn().mockResolvedValue({ id: "est_1" }); const commentFindUnique = vi.fn().mockResolvedValue({ id: "comment_1", authorId: "user_2", entityType: "estimate", entityId: "est_1", }); const commentUpdate = vi.fn(); const controllerCaller = createCaller(createContext({ estimate: { findUnique: estimateFindUnique, }, comment: { findUnique: commentFindUnique, update: commentUpdate, }, }, { role: SystemRole.CONTROLLER })); await expect(controllerCaller.resolve({ id: "comment_1", resolved: true })).rejects.toMatchObject({ code: "FORBIDDEN", message: "Only the comment author or an admin can resolve comments", }); const adminUpdate = vi.fn().mockResolvedValue({ id: "comment_1", body: "Needs review", resolved: true, author: { id: "user_2", name: "Other User", email: "other@example.com", image: null }, }); const adminCaller = createCaller(createContext({ estimate: { findUnique: vi.fn().mockResolvedValue({ id: "est_1" }), }, comment: { findUnique: vi.fn().mockResolvedValue({ id: "comment_1", authorId: "user_2", entityType: "estimate", entityId: "est_1", }), update: adminUpdate, }, auditLog: { create: vi.fn(), }, }, { role: SystemRole.ADMIN })); const result = await adminCaller.resolve({ id: "comment_1", resolved: true }); expect(result.resolved).toBe(true); expect(adminUpdate).toHaveBeenCalledWith({ where: { id: "comment_1" }, data: { resolved: true }, include: { author: { select: { id: true, name: true, email: true, image: true } }, }, }); }); });