523 lines
16 KiB
TypeScript
523 lines
16 KiB
TypeScript
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<string, unknown>,
|
|
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 } },
|
|
},
|
|
});
|
|
});
|
|
});
|