503 lines
15 KiB
TypeScript
503 lines
15 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
|
|
import type { PermissionString } from "@calcom/features/pbac/domain/types/permission-registry";
|
|
import { prisma } from "@calcom/prisma";
|
|
import type { MembershipRole } from "@calcom/prisma/enums";
|
|
|
|
import { TRPCError } from "@trpc/server";
|
|
|
|
import authedProcedure from "../../../procedures/authedProcedure";
|
|
// Import after mocks are set up
|
|
import { createWebhookPbacProcedure, webhookProcedure } from "./util";
|
|
|
|
// Mock dependencies - use factory functions to avoid hoisting issues
|
|
vi.mock("@calcom/prisma", () => ({
|
|
prisma: {
|
|
webhook: {
|
|
findUnique: vi.fn(),
|
|
},
|
|
eventType: {
|
|
findUnique: vi.fn(),
|
|
},
|
|
user: {
|
|
findUnique: vi.fn(),
|
|
},
|
|
},
|
|
}));
|
|
|
|
const mockCheckPermission = vi.fn();
|
|
|
|
vi.mock("@calcom/features/pbac/services/permission-check.service", () => ({
|
|
PermissionCheckService: vi.fn().mockImplementation(function () {
|
|
return {
|
|
checkPermission: mockCheckPermission,
|
|
};
|
|
}),
|
|
}));
|
|
|
|
vi.mock("../../../procedures/authedProcedure", () => ({
|
|
default: {
|
|
input: vi.fn().mockReturnThis(),
|
|
use: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
// Cast the mocked items to properly typed versions
|
|
const mockPrisma = prisma as any;
|
|
const mockAuthedProcedure = authedProcedure as any;
|
|
|
|
describe("Webhook PBAC Procedures", () => {
|
|
const mockCtx = {
|
|
user: {
|
|
id: 1,
|
|
},
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
// Reset the mock to ensure clean state
|
|
mockCheckPermission.mockReset();
|
|
mockPrisma.webhook.findUnique.mockReset();
|
|
mockPrisma.eventType.findUnique.mockReset();
|
|
mockPrisma.user.findUnique.mockReset();
|
|
mockAuthedProcedure.use.mockClear();
|
|
});
|
|
|
|
describe("createWebhookPbacProcedure", () => {
|
|
const testPermission: PermissionString = "webhook.update";
|
|
const fallbackRoles: MembershipRole[] = ["ADMIN", "OWNER"];
|
|
|
|
it("should create a procedure with the specified permission", () => {
|
|
const procedure = createWebhookPbacProcedure(testPermission, fallbackRoles);
|
|
|
|
// Verify that authedProcedure methods were called
|
|
expect(mockAuthedProcedure.input).toHaveBeenCalled();
|
|
expect(mockAuthedProcedure.use).toHaveBeenCalled();
|
|
});
|
|
|
|
describe("middleware behavior", () => {
|
|
let middleware: any;
|
|
|
|
beforeEach(() => {
|
|
createWebhookPbacProcedure(testPermission, fallbackRoles);
|
|
// Get the middleware function that was passed to .use()
|
|
middleware = mockAuthedProcedure.use.mock.calls[0][0];
|
|
});
|
|
|
|
describe("when webhook ID is provided", () => {
|
|
it("should allow access when user has PBAC permission for team webhook", async () => {
|
|
const mockWebhook = {
|
|
id: "webhook-1",
|
|
teamId: 10,
|
|
userId: null,
|
|
eventTypeId: null,
|
|
user: null,
|
|
team: { id: 10 },
|
|
eventType: null,
|
|
};
|
|
|
|
mockPrisma.webhook.findUnique.mockResolvedValue(mockWebhook);
|
|
mockCheckPermission.mockResolvedValue(true);
|
|
|
|
const next = vi.fn().mockResolvedValue("success");
|
|
const result = await middleware({
|
|
ctx: mockCtx,
|
|
input: { id: "webhook-1", teamId: 10 },
|
|
next,
|
|
});
|
|
|
|
expect(result).toBe("success");
|
|
expect(mockCheckPermission).toHaveBeenCalledWith({
|
|
userId: 1,
|
|
teamId: 10,
|
|
permission: testPermission,
|
|
fallbackRoles,
|
|
});
|
|
});
|
|
|
|
it("should throw FORBIDDEN when user lacks PBAC permission for team webhook", async () => {
|
|
const mockWebhook = {
|
|
id: "webhook-1",
|
|
teamId: 10,
|
|
userId: null,
|
|
eventTypeId: null,
|
|
user: null,
|
|
team: { id: 10 },
|
|
eventType: null,
|
|
};
|
|
|
|
mockPrisma.webhook.findUnique.mockResolvedValue(mockWebhook);
|
|
mockCheckPermission.mockResolvedValue(false);
|
|
|
|
const next = vi.fn();
|
|
|
|
await expect(
|
|
middleware({
|
|
ctx: mockCtx,
|
|
input: { id: "webhook-1", teamId: 10 },
|
|
next,
|
|
})
|
|
).rejects.toThrow(
|
|
new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message: `Permission required: ${testPermission}`,
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should allow access for personal webhook when user is the owner", async () => {
|
|
const mockWebhook = {
|
|
id: "webhook-1",
|
|
teamId: null,
|
|
userId: 1,
|
|
eventTypeId: null,
|
|
user: { id: 1 },
|
|
team: null,
|
|
eventType: null,
|
|
};
|
|
|
|
mockPrisma.webhook.findUnique.mockResolvedValue(mockWebhook);
|
|
|
|
const next = vi.fn().mockResolvedValue("success");
|
|
const result = await middleware({
|
|
ctx: mockCtx,
|
|
input: { id: "webhook-1" },
|
|
next,
|
|
});
|
|
|
|
expect(result).toBe("success");
|
|
expect(mockCheckPermission).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("should throw FORBIDDEN for personal webhook when user is not the owner", async () => {
|
|
const mockWebhook = {
|
|
id: "webhook-1",
|
|
teamId: null,
|
|
userId: 2,
|
|
eventTypeId: null,
|
|
user: { id: 2 },
|
|
team: null,
|
|
eventType: null,
|
|
};
|
|
|
|
mockPrisma.webhook.findUnique.mockResolvedValue(mockWebhook);
|
|
|
|
const next = vi.fn();
|
|
|
|
await expect(
|
|
middleware({
|
|
ctx: mockCtx,
|
|
input: { id: "webhook-1" },
|
|
next,
|
|
})
|
|
).rejects.toThrow(
|
|
new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message: `Permission required: ${testPermission}`,
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should check team permissions for team event type webhook", async () => {
|
|
const mockWebhook = {
|
|
id: "webhook-1",
|
|
teamId: null,
|
|
userId: null,
|
|
eventTypeId: 100,
|
|
user: null,
|
|
team: null,
|
|
eventType: { id: 100, teamId: 10, userId: 2 },
|
|
};
|
|
|
|
const mockEventType = {
|
|
id: 100,
|
|
teamId: 10,
|
|
userId: 2,
|
|
team: { id: 10 },
|
|
};
|
|
|
|
mockPrisma.webhook.findUnique.mockResolvedValue(mockWebhook);
|
|
mockPrisma.eventType.findUnique.mockResolvedValue(mockEventType);
|
|
mockCheckPermission.mockResolvedValue(true);
|
|
|
|
const next = vi.fn().mockResolvedValue("success");
|
|
const result = await middleware({
|
|
ctx: mockCtx,
|
|
input: { id: "webhook-1", eventTypeId: 100 },
|
|
next,
|
|
});
|
|
|
|
expect(result).toBe("success");
|
|
expect(mockCheckPermission).toHaveBeenCalledWith({
|
|
userId: 1,
|
|
teamId: 10,
|
|
permission: testPermission,
|
|
fallbackRoles,
|
|
});
|
|
});
|
|
|
|
it("should throw NOT_FOUND when webhook doesn't exist", async () => {
|
|
mockPrisma.webhook.findUnique.mockResolvedValue(null);
|
|
|
|
const next = vi.fn();
|
|
|
|
await expect(
|
|
middleware({
|
|
ctx: mockCtx,
|
|
input: { id: "webhook-1" },
|
|
next,
|
|
})
|
|
).rejects.toThrow(new TRPCError({ code: "NOT_FOUND" }));
|
|
});
|
|
|
|
it("should throw UNAUTHORIZED when teamId doesn't match webhook teamId", async () => {
|
|
const mockWebhook = {
|
|
id: "webhook-1",
|
|
teamId: 10,
|
|
userId: null,
|
|
eventTypeId: null,
|
|
user: null,
|
|
team: { id: 10 },
|
|
eventType: null,
|
|
};
|
|
|
|
mockPrisma.webhook.findUnique.mockResolvedValue(mockWebhook);
|
|
|
|
const next = vi.fn();
|
|
|
|
await expect(
|
|
middleware({
|
|
ctx: mockCtx,
|
|
input: { id: "webhook-1", teamId: 20 },
|
|
next,
|
|
})
|
|
).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED" }));
|
|
});
|
|
});
|
|
|
|
describe("when creating new webhook (no ID provided)", () => {
|
|
it("should allow creation with team PBAC permission", async () => {
|
|
mockCheckPermission.mockResolvedValue(true);
|
|
|
|
const next = vi.fn().mockResolvedValue("success");
|
|
const result = await middleware({
|
|
ctx: mockCtx,
|
|
input: { teamId: 10 },
|
|
next,
|
|
});
|
|
|
|
expect(result).toBe("success");
|
|
expect(mockCheckPermission).toHaveBeenCalledWith({
|
|
userId: 1,
|
|
teamId: 10,
|
|
permission: testPermission,
|
|
fallbackRoles,
|
|
});
|
|
});
|
|
|
|
it("should throw FORBIDDEN when lacking team PBAC permission", async () => {
|
|
mockCheckPermission.mockResolvedValue(false);
|
|
|
|
const next = vi.fn();
|
|
|
|
await expect(
|
|
middleware({
|
|
ctx: mockCtx,
|
|
input: { teamId: 10 },
|
|
next,
|
|
})
|
|
).rejects.toThrow(
|
|
new TRPCError({
|
|
code: "FORBIDDEN",
|
|
message: `Permission required: ${testPermission}`,
|
|
})
|
|
);
|
|
});
|
|
|
|
it("should check team permissions for team event type creation", async () => {
|
|
const mockEventType = {
|
|
id: 100,
|
|
teamId: 10,
|
|
userId: 2,
|
|
team: { id: 10 },
|
|
};
|
|
|
|
mockPrisma.eventType.findUnique.mockResolvedValue(mockEventType);
|
|
mockCheckPermission.mockResolvedValue(true);
|
|
|
|
const next = vi.fn().mockResolvedValue("success");
|
|
const result = await middleware({
|
|
ctx: mockCtx,
|
|
input: { eventTypeId: 100 },
|
|
next,
|
|
});
|
|
|
|
expect(result).toBe("success");
|
|
expect(mockCheckPermission).toHaveBeenCalledWith({
|
|
userId: 1,
|
|
teamId: 10,
|
|
permission: testPermission,
|
|
fallbackRoles,
|
|
});
|
|
});
|
|
|
|
it("should allow personal event type webhook creation for owner", async () => {
|
|
const mockEventType = {
|
|
id: 100,
|
|
teamId: null,
|
|
userId: 1,
|
|
team: null,
|
|
};
|
|
|
|
mockPrisma.eventType.findUnique.mockResolvedValue(mockEventType);
|
|
|
|
const next = vi.fn().mockResolvedValue("success");
|
|
const result = await middleware({
|
|
ctx: mockCtx,
|
|
input: { eventTypeId: 100 },
|
|
next,
|
|
});
|
|
|
|
expect(result).toBe("success");
|
|
expect(mockCheckPermission).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("when no input is provided", () => {
|
|
it("should call next() directly", async () => {
|
|
const next = vi.fn().mockResolvedValue("success");
|
|
const result = await middleware({
|
|
ctx: mockCtx,
|
|
input: undefined,
|
|
next,
|
|
});
|
|
|
|
expect(result).toBe("success");
|
|
expect(next).toHaveBeenCalled();
|
|
expect(mockPrisma.webhook.findUnique).not.toHaveBeenCalled();
|
|
expect(mockPrisma.eventType.findUnique).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("webhookProcedure (legacy wrapper)", () => {
|
|
it("should work as expected", () => {
|
|
// The legacy webhookProcedure uses createWebhookPbacProcedure internally
|
|
// This is verified by the functional tests above
|
|
expect(createWebhookPbacProcedure).toBeDefined();
|
|
});
|
|
});
|
|
|
|
describe("Different permission scenarios", () => {
|
|
const permissions: { permission: PermissionString; operation: string }[] = [
|
|
{ permission: "webhook.read", operation: "read" },
|
|
{ permission: "webhook.create", operation: "create" },
|
|
{ permission: "webhook.update", operation: "update" },
|
|
{ permission: "webhook.delete", operation: "delete" },
|
|
];
|
|
|
|
permissions.forEach(({ permission, operation }) => {
|
|
it(`should use ${permission} for ${operation} operations`, async () => {
|
|
createWebhookPbacProcedure(permission);
|
|
const middleware =
|
|
mockAuthedProcedure.use.mock.calls[mockAuthedProcedure.use.mock.calls.length - 1][0];
|
|
|
|
const mockWebhook = {
|
|
id: "webhook-1",
|
|
teamId: 10,
|
|
userId: null,
|
|
eventTypeId: null,
|
|
user: null,
|
|
team: { id: 10 },
|
|
eventType: null,
|
|
};
|
|
|
|
mockPrisma.webhook.findUnique.mockResolvedValue(mockWebhook);
|
|
mockCheckPermission.mockResolvedValue(true);
|
|
|
|
const next = vi.fn().mockResolvedValue("success");
|
|
await middleware({
|
|
ctx: mockCtx,
|
|
input: { id: "webhook-1" },
|
|
next,
|
|
});
|
|
|
|
expect(mockCheckPermission).toHaveBeenCalledWith({
|
|
userId: 1,
|
|
teamId: 10,
|
|
permission,
|
|
fallbackRoles: ["ADMIN", "OWNER"],
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("Fallback role behavior", () => {
|
|
it("should use custom fallback roles when provided", async () => {
|
|
const customFallback: MembershipRole[] = ["OWNER"];
|
|
createWebhookPbacProcedure("webhook.delete", customFallback);
|
|
const middleware = mockAuthedProcedure.use.mock.calls[mockAuthedProcedure.use.mock.calls.length - 1][0];
|
|
|
|
const mockWebhook = {
|
|
id: "webhook-1",
|
|
teamId: 10,
|
|
userId: null,
|
|
eventTypeId: null,
|
|
user: null,
|
|
team: { id: 10 },
|
|
eventType: null,
|
|
};
|
|
|
|
mockPrisma.webhook.findUnique.mockResolvedValue(mockWebhook);
|
|
mockCheckPermission.mockResolvedValue(true);
|
|
|
|
const next = vi.fn().mockResolvedValue("success");
|
|
await middleware({
|
|
ctx: mockCtx,
|
|
input: { id: "webhook-1" },
|
|
next,
|
|
});
|
|
|
|
expect(mockCheckPermission).toHaveBeenCalledWith({
|
|
userId: 1,
|
|
teamId: 10,
|
|
permission: "webhook.delete",
|
|
fallbackRoles: customFallback,
|
|
});
|
|
});
|
|
|
|
it("should use default fallback roles when not provided", async () => {
|
|
createWebhookPbacProcedure("webhook.update");
|
|
const middleware = mockAuthedProcedure.use.mock.calls[mockAuthedProcedure.use.mock.calls.length - 1][0];
|
|
|
|
const mockWebhook = {
|
|
id: "webhook-1",
|
|
teamId: 10,
|
|
userId: null,
|
|
eventTypeId: null,
|
|
user: null,
|
|
team: { id: 10 },
|
|
eventType: null,
|
|
};
|
|
|
|
mockPrisma.webhook.findUnique.mockResolvedValue(mockWebhook);
|
|
mockCheckPermission.mockResolvedValue(true);
|
|
|
|
const next = vi.fn().mockResolvedValue("success");
|
|
await middleware({
|
|
ctx: mockCtx,
|
|
input: { id: "webhook-1" },
|
|
next,
|
|
});
|
|
|
|
expect(mockCheckPermission).toHaveBeenCalledWith({
|
|
userId: 1,
|
|
teamId: 10,
|
|
permission: "webhook.update",
|
|
fallbackRoles: ["ADMIN", "OWNER"],
|
|
});
|
|
});
|
|
});
|
|
});
|