From 984cd64083f9975dbc5f8b42b195a702df1532b9 Mon Sep 17 00:00:00 2001 From: Volnei Munhoz Date: Mon, 10 Nov 2025 13:54:15 -0300 Subject: [PATCH] test: add routing-forms tests (#25044) * test: Add comprehensive security tests for routing forms vulnerability Add comprehensive test coverage for the getIncompleteBookingSettings handler vulnerability and ensure entityPrismaWhereClause changes won't break functionality. Tests added: 1. getIncompleteBookingSettings.handler.test.ts (15 tests) - Authorization tests for personal and team forms - Credential sanitization tests (key field should never be exposed) - Organization hierarchy tests (parent org credentials) - App filtering tests (only enabled apps) - Edge cases (no credentials, form not found, etc.) 2. entityPrismaWhereClause.integration.test.ts (13 tests) - Verifies formQuery, deleteForm, and forms handlers properly scope queries - Ensures accepted membership is required for team access - Validates consistent entityPrismaWhereClause usage across handlers - Prevents regressions when adding role-based filtering Expected Test Failures: The getIncompleteBookingSettings tests currently have 4 expected failures that document the existing vulnerability: - 2 authorization tests fail (handler doesn't check user access) - 2 sanitization tests fail (handler leaks the 'key' field with OAuth tokens) These failures prove the vulnerability exists and document the secure behavior that should be implemented. Test Results: - All 13 entityPrismaWhereClause integration tests pass - All 18 existing routing-forms test files pass (156 tests) - 4 security tests fail as expected (documenting the vulnerability) The tests ensure that: 1. Fixing the vulnerability by adding entityPrismaWhereClause won't break other handlers 2. The key field is never returned in credentials 3. Only authorized users can access forms 4. Team membership requires accepted: true 5. Organization hierarchy is properly handled Co-Authored-By: Volnei Munhoz * Add suggested fix * Add suggested fix * fix: enforce authorization scoping and credential sanitization in routing-forms handler Co-Authored-By: Volnei Munhoz * Fix types --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Alex van Andel --- ...ntityPrismaWhereClause.integration.test.ts | 475 ++++++++++++++++ ...tIncompleteBookingSettings.handler.test.ts | 523 ++++++++++++++++++ .../getIncompleteBookingSettings.handler.ts | 51 +- 3 files changed, 1044 insertions(+), 5 deletions(-) create mode 100644 packages/trpc/server/routers/apps/routing-forms/entityPrismaWhereClause.integration.test.ts create mode 100644 packages/trpc/server/routers/apps/routing-forms/getIncompleteBookingSettings.handler.test.ts diff --git a/packages/trpc/server/routers/apps/routing-forms/entityPrismaWhereClause.integration.test.ts b/packages/trpc/server/routers/apps/routing-forms/entityPrismaWhereClause.integration.test.ts new file mode 100644 index 0000000000..fa513a66ea --- /dev/null +++ b/packages/trpc/server/routers/apps/routing-forms/entityPrismaWhereClause.integration.test.ts @@ -0,0 +1,475 @@ +import { prisma } from "@calcom/prisma/__mocks__/prisma"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { formQueryHandler } from "./formQuery.handler"; +import { deleteFormHandler } from "./deleteForm.handler"; +import { formsHandler } from "./forms.handler"; + +vi.mock("@calcom/prisma", () => ({ + prisma, + default: prisma, +})); + +vi.mock("./permissions", () => ({ + checkPermissionOnExistingRoutingForm: vi.fn().mockResolvedValue(true), +})); + +vi.mock("@calcom/app-store/routing-forms/lib/getSerializableForm", () => ({ + getSerializableForm: vi.fn().mockImplementation(({ form }) => Promise.resolve(form)), +})); + +vi.mock("@calcom/app-store/routing-forms/lib/getConnectedForms", () => ({ + default: vi.fn().mockResolvedValue([]), +})); + +vi.mock("@calcom/features/pbac/lib/entityPermissionUtils.server", async () => { + const actual = await vi.importActual("@calcom/features/pbac/lib/entityPermissionUtils.server"); + return { + ...actual, + canEditEntity: vi.fn().mockResolvedValue(true), + }; +}); + +/** + * These tests ensure that entityPrismaWhereClause properly scopes queries + * and that changes to it (e.g., adding role-based filtering) don't break + * existing functionality across different handlers. + */ +describe("entityPrismaWhereClause Integration Tests", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("formQuery.handler - Read Access", () => { + it("should allow user to read their own personal form", async () => { + const user = { id: 1 }; + const formId = "personal-form-123"; + const mockForm = { + id: formId, + userId: user.id, + teamId: null, + name: "My Personal Form", + team: null, + _count: { responses: 5 }, + }; + + prisma.app_RoutingForms_Form.findFirst.mockResolvedValue(mockForm); + + const result = await formQueryHandler({ + ctx: { prisma, user }, + input: { id: formId }, + }); + + expect(result).toBeDefined(); + expect(prisma.app_RoutingForms_Form.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + AND: expect.arrayContaining([ + expect.objectContaining({ + OR: expect.arrayContaining([ + { userId: user.id }, + expect.objectContaining({ + team: expect.objectContaining({ + members: expect.objectContaining({ + some: expect.objectContaining({ + userId: user.id, + accepted: true, + }), + }), + }), + }), + ]), + }), + ]), + }), + }) + ); + }); + + it("should allow team member to read team form", async () => { + const teamMember = { id: 1 }; + const teamId = 100; + const formId = "team-form-123"; + const mockForm = { + id: formId, + userId: null, + teamId: teamId, + name: "Team Form", + team: { slug: "test-team", name: "Test Team" }, + _count: { responses: 10 }, + }; + + prisma.app_RoutingForms_Form.findFirst.mockResolvedValue(mockForm); + + const result = await formQueryHandler({ + ctx: { prisma, user: teamMember }, + input: { id: formId }, + }); + + expect(result).toBeDefined(); + expect(prisma.app_RoutingForms_Form.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + AND: expect.arrayContaining([ + expect.objectContaining({ + OR: expect.arrayContaining([ + { userId: teamMember.id }, + expect.objectContaining({ + team: expect.objectContaining({ + members: expect.objectContaining({ + some: expect.objectContaining({ + userId: teamMember.id, + accepted: true, + }), + }), + }), + }), + ]), + }), + ]), + }), + }) + ); + }); + + it("should return null when form is not found or user lacks access", async () => { + const user = { id: 1 }; + const formId = "inaccessible-form"; + + prisma.app_RoutingForms_Form.findFirst.mockResolvedValue(null); + + const result = await formQueryHandler({ + ctx: { prisma, user }, + input: { id: formId }, + }); + + expect(result).toBeNull(); + }); + + it("should require accepted membership for team forms", async () => { + const user = { id: 1 }; + const formId = "team-form-123"; + + prisma.app_RoutingForms_Form.findFirst.mockResolvedValue(null); + + await formQueryHandler({ + ctx: { prisma, user }, + input: { id: formId }, + }); + + expect(prisma.app_RoutingForms_Form.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + AND: expect.arrayContaining([ + expect.objectContaining({ + OR: expect.arrayContaining([ + expect.anything(), + expect.objectContaining({ + team: expect.objectContaining({ + members: expect.objectContaining({ + some: expect.objectContaining({ + accepted: true, + }), + }), + }), + }), + ]), + }), + ]), + }), + }) + ); + }); + }); + + describe("deleteForm.handler - Delete Access", () => { + it("should scope delete to user's accessible forms", async () => { + const user = { id: 1 }; + const formId = "form-to-delete"; + + prisma.app_RoutingForms_Form.deleteMany.mockResolvedValue({ count: 1 }); + + await deleteFormHandler({ + ctx: { prisma, user }, + input: { id: formId }, + }); + + expect(prisma.app_RoutingForms_Form.deleteMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + id: formId, + OR: expect.arrayContaining([ + { userId: user.id }, + expect.objectContaining({ + team: expect.objectContaining({ + members: expect.objectContaining({ + some: expect.objectContaining({ + userId: user.id, + accepted: true, + }), + }), + }), + }), + ]), + }), + }) + ); + }); + + it("should not delete forms user doesn't have access to", async () => { + const user = { id: 1 }; + const formId = "inaccessible-form"; + + prisma.app_RoutingForms_Form.deleteMany.mockResolvedValue({ count: 0 }); + + await expect( + deleteFormHandler({ + ctx: { prisma, user }, + input: { id: formId }, + }) + ).rejects.toThrow("Form seems to be already deleted"); + }); + }); + + describe("forms.handler - List Access", () => { + it("should list user's personal forms and team forms they're a member of", async () => { + const user = { id: 1 }; + const mockForms = [ + { + id: "personal-1", + userId: user.id, + teamId: null, + name: "Personal Form", + team: null, + _count: { responses: 5 }, + position: 1, + createdAt: new Date(), + }, + { + id: "team-1", + userId: null, + teamId: 100, + name: "Team Form", + team: { id: 100, name: "Test Team" }, + _count: { responses: 10 }, + position: 2, + createdAt: new Date(), + }, + ]; + + prisma.app_RoutingForms_Form.findMany.mockResolvedValue(mockForms); + prisma.app_RoutingForms_Form.count.mockResolvedValue(2); + + const result = await formsHandler({ + ctx: { prisma, user }, + input: {}, + }); + + expect(result.totalCount).toBe(2); + expect(result.filtered).toHaveLength(2); + }); + + it("should use entityPrismaWhereClause for totalCount", async () => { + const user = { id: 1 }; + + prisma.app_RoutingForms_Form.findMany.mockResolvedValue([]); + prisma.app_RoutingForms_Form.count.mockResolvedValue(0); + + await formsHandler({ + ctx: { prisma, user }, + input: {}, + }); + + expect(prisma.app_RoutingForms_Form.count).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + OR: expect.arrayContaining([ + { userId: user.id }, + expect.objectContaining({ + team: expect.objectContaining({ + members: expect.objectContaining({ + some: expect.objectContaining({ + userId: user.id, + accepted: true, + }), + }), + }), + }), + ]), + }), + }) + ); + }); + + it("should filter forms based on user and team filters", async () => { + const user = { id: 1 }; + const filters = { + userIds: [1], + teamIds: [100], + }; + + prisma.app_RoutingForms_Form.findMany.mockResolvedValue([]); + prisma.app_RoutingForms_Form.count.mockResolvedValue(0); + + await formsHandler({ + ctx: { prisma, user }, + input: { filters }, + }); + + expect(prisma.app_RoutingForms_Form.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + OR: expect.arrayContaining([ + expect.objectContaining({ + userId: expect.objectContaining({ + in: [1], + }), + teamId: null, + }), + expect.objectContaining({ + team: expect.objectContaining({ + id: expect.objectContaining({ + in: [100], + }), + members: expect.objectContaining({ + some: expect.objectContaining({ + userId: user.id, + accepted: true, + }), + }), + }), + }), + ]), + }), + }) + ); + }); + + it("should handle empty filters by showing all accessible forms", async () => { + const user = { id: 1 }; + + prisma.app_RoutingForms_Form.findMany.mockResolvedValue([]); + prisma.app_RoutingForms_Form.count.mockResolvedValue(0); + + await formsHandler({ + ctx: { prisma, user }, + input: {}, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const whereClause = (prisma.app_RoutingForms_Form.findMany as any).mock.calls[0][0].where; + expect(whereClause.OR).toBeDefined(); + expect(whereClause.OR[0].OR).toBeDefined(); + expect(whereClause.OR[0].OR[0]).toEqual({ userId: user.id }); + expect(whereClause.OR[0].OR[1].team.members.some.userId).toBe(user.id); + expect(whereClause.OR[0].OR[1].team.members.some.accepted).toBe(true); + }); + }); + + describe("Membership State Edge Cases", () => { + it("should require accepted: true for team membership", async () => { + const user = { id: 1 }; + const formId = "team-form"; + + prisma.app_RoutingForms_Form.findFirst.mockResolvedValue(null); + + await formQueryHandler({ + ctx: { prisma, user }, + input: { id: formId }, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const whereClause = (prisma.app_RoutingForms_Form.findFirst as any).mock.calls[0][0].where; + const teamMemberClause = whereClause.AND[0].OR[1].team.members.some; + + expect(teamMemberClause.accepted).toBe(true); + }); + + it("should scope by userId OR team membership in all handlers", async () => { + const user = { id: 1 }; + + prisma.app_RoutingForms_Form.findFirst.mockResolvedValue(null); + prisma.app_RoutingForms_Form.deleteMany.mockResolvedValue({ count: 0 }); + prisma.app_RoutingForms_Form.findMany.mockResolvedValue([]); + prisma.app_RoutingForms_Form.count.mockResolvedValue(0); + + await formQueryHandler({ + ctx: { prisma, user }, + input: { id: "test" }, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let whereClause = (prisma.app_RoutingForms_Form.findFirst as any).mock.calls[0][0].where; + expect(whereClause.AND[0].OR).toHaveLength(2); + expect(whereClause.AND[0].OR[0]).toEqual({ userId: user.id }); + + try { + await deleteFormHandler({ + ctx: { prisma, user }, + input: { id: "test" }, + }); + } catch { void 0; } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + whereClause = (prisma.app_RoutingForms_Form.deleteMany as any).mock.calls[0][0].where; + expect(whereClause.OR).toHaveLength(2); + expect(whereClause.OR[0]).toEqual({ userId: user.id }); + + await formsHandler({ + ctx: { prisma, user }, + input: {}, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + whereClause = (prisma.app_RoutingForms_Form.count as any).mock.calls[0][0].where; + expect(whereClause.OR).toHaveLength(2); + expect(whereClause.OR[0]).toEqual({ userId: user.id }); + }); + }); + + describe("Consistency Across Handlers", () => { + it("should use consistent entityPrismaWhereClause structure across all handlers", async () => { + const user = { id: 1 }; + + prisma.app_RoutingForms_Form.findFirst.mockResolvedValue(null); + prisma.app_RoutingForms_Form.deleteMany.mockResolvedValue({ count: 0 }); + prisma.app_RoutingForms_Form.findMany.mockResolvedValue([]); + prisma.app_RoutingForms_Form.count.mockResolvedValue(0); + + await formQueryHandler({ + ctx: { prisma, user }, + input: { id: "test" }, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const formQueryWhere = (prisma.app_RoutingForms_Form.findFirst as any).mock.calls[0][0].where + .AND[0]; + + try { + await deleteFormHandler({ + ctx: { prisma, user }, + input: { id: "test" }, + }); + } catch { void 0; } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const deleteFormWhere = (prisma.app_RoutingForms_Form.deleteMany as any).mock.calls[0][0].where; + + await formsHandler({ + ctx: { prisma, user }, + input: {}, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const formsWhere = (prisma.app_RoutingForms_Form.count as any).mock.calls[0][0].where; + + expect(formQueryWhere.OR).toBeDefined(); + expect(deleteFormWhere.OR).toBeDefined(); + expect(formsWhere.OR).toBeDefined(); + + expect(formQueryWhere.OR[0]).toEqual({ userId: user.id }); + expect(deleteFormWhere.OR[0]).toEqual({ userId: user.id }); + expect(formsWhere.OR[0]).toEqual({ userId: user.id }); + + expect(formQueryWhere.OR[1].team.members.some.accepted).toBe(true); + expect(deleteFormWhere.OR[1].team.members.some.accepted).toBe(true); + expect(formsWhere.OR[1].team.members.some.accepted).toBe(true); + }); + }); +}); diff --git a/packages/trpc/server/routers/apps/routing-forms/getIncompleteBookingSettings.handler.test.ts b/packages/trpc/server/routers/apps/routing-forms/getIncompleteBookingSettings.handler.test.ts new file mode 100644 index 0000000000..6575d907ce --- /dev/null +++ b/packages/trpc/server/routers/apps/routing-forms/getIncompleteBookingSettings.handler.test.ts @@ -0,0 +1,523 @@ +import { prisma } from "@calcom/prisma/__mocks__/prisma"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { TRPCError } from "@trpc/server"; +import getIncompleteBookingSettingsHandler from "./getIncompleteBookingSettings.handler"; + +vi.mock("@calcom/prisma", () => ({ + prisma, +})); + +vi.mock("@calcom/app-store/routing-forms/lib/enabledIncompleteBookingApps", () => ({ + enabledIncompleteBookingApps: ["salesforce"], +})); + +describe("getIncompleteBookingSettings.handler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("Authorization - Personal Forms", () => { + it("should throw NOT_FOUND when user tries to access another user's personal form", async () => { + const _userA = { id: 1 }; + const userB = { id: 2 }; + const formId = "form-123"; + + prisma.app_RoutingForms_IncompleteBookingActions.findMany.mockResolvedValue([]); + prisma.app_RoutingForms_Form.findFirst.mockResolvedValue(null); + + await expect( + getIncompleteBookingSettingsHandler({ + ctx: { prisma, user: userB }, + input: { formId }, + }) + ).rejects.toThrow(TRPCError); + + await expect( + getIncompleteBookingSettingsHandler({ + ctx: { prisma, user: userB }, + input: { formId }, + }) + ).rejects.toThrow("Form not found"); + }); + + it("should allow user to access their own personal form", async () => { + const user = { id: 1 }; + const formId = "form-123"; + const mockCredential = { + id: 1, + type: "salesforce_other_calendar", + userId: user.id, + teamId: null, + appId: "salesforce", + invalid: false, + delegationCredentialId: null, + }; + + prisma.app_RoutingForms_IncompleteBookingActions.findMany.mockResolvedValue([]); + prisma.app_RoutingForms_Form.findFirst.mockResolvedValue({ + id: formId, + userId: user.id, + teamId: null, + }); + prisma.credential.findFirst.mockResolvedValue(mockCredential); + + const result = await getIncompleteBookingSettingsHandler({ + ctx: { prisma, user }, + input: { formId }, + }); + + expect(result).toBeDefined(); + expect(result.credentials).toHaveLength(1); + expect(result.credentials[0]).toMatchObject({ + id: 1, + type: "salesforce_other_calendar", + userId: user.id, + teamId: null, + appId: "salesforce", + }); + }); + }); + + describe("Authorization - Team Forms", () => { + it("should throw NOT_FOUND when non-team-member tries to access team form", async () => { + const _teamMember = { id: 1 }; + const nonMember = { id: 2 }; + const _teamId = 100; + const formId = "team-form-123"; + + prisma.app_RoutingForms_IncompleteBookingActions.findMany.mockResolvedValue([]); + prisma.app_RoutingForms_Form.findFirst.mockResolvedValue(null); + + await expect( + getIncompleteBookingSettingsHandler({ + ctx: { prisma, user: nonMember }, + input: { formId }, + }) + ).rejects.toThrow(TRPCError); + }); + + it("should allow team member to access team form credentials", async () => { + const teamMember = { id: 1 }; + const teamId = 100; + const formId = "team-form-123"; + const mockCredentials = [ + { + id: 1, + type: "salesforce_other_calendar", + userId: null, + teamId: teamId, + appId: "salesforce", + invalid: false, + delegationCredentialId: null, + user: null, + team: { name: "Test Team" }, + }, + ]; + + prisma.app_RoutingForms_IncompleteBookingActions.findMany.mockResolvedValue([]); + prisma.app_RoutingForms_Form.findFirst.mockResolvedValue({ + id: formId, + userId: teamMember.id, + teamId: teamId, + }); + prisma.team.findUnique.mockResolvedValue({ + id: teamId, + parentId: null, + }); + prisma.credential.findMany.mockResolvedValue(mockCredentials); + + const result = await getIncompleteBookingSettingsHandler({ + ctx: { prisma, user: teamMember }, + input: { formId }, + }); + + expect(result).toBeDefined(); + expect(result.credentials).toHaveLength(1); + expect(result.credentials[0]).toMatchObject({ + id: 1, + type: "salesforce_other_calendar", + teamId: teamId, + appId: "salesforce", + }); + }); + }); + + describe("Credential Sanitization", () => { + it("should never return the 'key' field in credentials for personal forms", async () => { + const user = { id: 1 }; + const formId = "form-123"; + const mockCredentialWithKey = { + id: 1, + type: "salesforce_other_calendar", + userId: user.id, + teamId: null, + appId: "salesforce", + invalid: false, + delegationCredentialId: null, + key: { + access_token: "secret_access_token", + refresh_token: "secret_refresh_token", + instance_url: "https://example.salesforce.com", + }, + }; + + prisma.app_RoutingForms_IncompleteBookingActions.findMany.mockResolvedValue([]); + prisma.app_RoutingForms_Form.findFirst.mockResolvedValue({ + id: formId, + userId: user.id, + teamId: null, + }); + prisma.credential.findFirst.mockResolvedValue(mockCredentialWithKey); + + const result = await getIncompleteBookingSettingsHandler({ + ctx: { prisma, user }, + input: { formId }, + }); + + expect(result.credentials).toHaveLength(1); + expect(result.credentials[0]).not.toHaveProperty("key"); + expect(result.credentials[0].id).toBe(1); + }); + + it("should never return the 'key' field in credentials for team forms", async () => { + const teamMember = { id: 1 }; + const teamId = 100; + const formId = "team-form-123"; + const mockCredentialsWithKey = [ + { + id: 1, + type: "salesforce_other_calendar", + userId: null, + teamId: teamId, + appId: "salesforce", + invalid: false, + delegationCredentialId: null, + user: null, + team: { name: "Test Team" }, + key: { + access_token: "secret_team_access_token", + refresh_token: "secret_team_refresh_token", + }, + }, + ]; + + prisma.app_RoutingForms_IncompleteBookingActions.findMany.mockResolvedValue([]); + prisma.app_RoutingForms_Form.findFirst.mockResolvedValue({ + id: formId, + userId: teamMember.id, + teamId: teamId, + }); + prisma.team.findUnique.mockResolvedValue({ + id: teamId, + parentId: null, + }); + prisma.credential.findMany.mockResolvedValue(mockCredentialsWithKey); + + const result = await getIncompleteBookingSettingsHandler({ + ctx: { prisma, user: teamMember }, + input: { formId }, + }); + + expect(result.credentials).toHaveLength(1); + expect(result.credentials[0]).not.toHaveProperty("key"); + expect(result.credentials[0].id).toBe(1); + }); + + it("should use safeCredentialSelect fields for team credentials", async () => { + const teamMember = { id: 1 }; + const teamId = 100; + const formId = "team-form-123"; + + prisma.app_RoutingForms_IncompleteBookingActions.findMany.mockResolvedValue([]); + prisma.app_RoutingForms_Form.findFirst.mockResolvedValue({ + id: formId, + userId: teamMember.id, + teamId: teamId, + }); + prisma.team.findUnique.mockResolvedValue({ + id: teamId, + parentId: null, + }); + prisma.credential.findMany.mockResolvedValue([]); + + await getIncompleteBookingSettingsHandler({ + ctx: { prisma, user: teamMember }, + input: { formId }, + }); + + expect(prisma.credential.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + select: expect.objectContaining({ + id: true, + type: true, + userId: true, + teamId: true, + appId: true, + invalid: true, + delegationCredentialId: true, + }), + }) + ); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const callArgs = (prisma.credential.findMany as any).mock.calls[0][0]; + expect(callArgs.select).not.toHaveProperty("key"); + }); + }); + + describe("Organization Hierarchy", () => { + it("should include parent org credentials when team has a parent", async () => { + const teamMember = { id: 1 }; + const teamId = 100; + const parentOrgId = 200; + const formId = "team-form-123"; + const mockCredentials = [ + { + id: 1, + type: "salesforce_other_calendar", + userId: null, + teamId: teamId, + appId: "salesforce", + invalid: false, + delegationCredentialId: null, + user: null, + team: { name: "Test Team" }, + }, + { + id: 2, + type: "salesforce_other_calendar", + userId: null, + teamId: parentOrgId, + appId: "salesforce", + invalid: false, + delegationCredentialId: null, + user: null, + team: { name: "Parent Org" }, + }, + ]; + + prisma.app_RoutingForms_IncompleteBookingActions.findMany.mockResolvedValue([]); + prisma.app_RoutingForms_Form.findFirst.mockResolvedValue({ + id: formId, + userId: teamMember.id, + teamId: teamId, + }); + prisma.team.findUnique.mockResolvedValue({ + id: teamId, + parentId: parentOrgId, + }); + prisma.credential.findMany.mockResolvedValue(mockCredentials); + + const result = await getIncompleteBookingSettingsHandler({ + ctx: { prisma, user: teamMember }, + input: { formId }, + }); + + expect(prisma.credential.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + teamId: { + in: [teamId, parentOrgId], + }, + }), + }) + ); + + expect(result.credentials).toHaveLength(2); + expect(result.credentials.map((c) => c.teamId)).toContain(teamId); + expect(result.credentials.map((c) => c.teamId)).toContain(parentOrgId); + }); + + it("should only include team credentials when team has no parent", async () => { + const teamMember = { id: 1 }; + const teamId = 100; + const formId = "team-form-123"; + + prisma.app_RoutingForms_IncompleteBookingActions.findMany.mockResolvedValue([]); + prisma.app_RoutingForms_Form.findFirst.mockResolvedValue({ + id: formId, + userId: teamMember.id, + teamId: teamId, + }); + prisma.team.findUnique.mockResolvedValue({ + id: teamId, + parentId: null, + }); + prisma.credential.findMany.mockResolvedValue([]); + + await getIncompleteBookingSettingsHandler({ + ctx: { prisma, user: teamMember }, + input: { formId }, + }); + + expect(prisma.credential.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + teamId: { + in: [teamId], + }, + }), + }) + ); + }); + }); + + describe("App Filtering", () => { + it("should only return credentials for enabled incomplete booking apps", async () => { + const user = { id: 1 }; + const formId = "form-123"; + + prisma.app_RoutingForms_IncompleteBookingActions.findMany.mockResolvedValue([]); + prisma.app_RoutingForms_Form.findFirst.mockResolvedValue({ + id: formId, + userId: user.id, + teamId: null, + }); + prisma.credential.findFirst.mockResolvedValue(null); + + await getIncompleteBookingSettingsHandler({ + ctx: { prisma, user }, + input: { formId }, + }); + + expect(prisma.credential.findFirst).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + appId: { + in: ["salesforce"], + }, + }), + }) + ); + }); + + it("should filter team credentials by enabled apps", async () => { + const teamMember = { id: 1 }; + const teamId = 100; + const formId = "team-form-123"; + + prisma.app_RoutingForms_IncompleteBookingActions.findMany.mockResolvedValue([]); + prisma.app_RoutingForms_Form.findFirst.mockResolvedValue({ + id: formId, + userId: teamMember.id, + teamId: teamId, + }); + prisma.team.findUnique.mockResolvedValue({ + id: teamId, + parentId: null, + }); + prisma.credential.findMany.mockResolvedValue([]); + + await getIncompleteBookingSettingsHandler({ + ctx: { prisma, user: teamMember }, + input: { formId }, + }); + + expect(prisma.credential.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ + appId: { + in: ["salesforce"], + }, + }), + }) + ); + }); + }); + + describe("Edge Cases", () => { + it("should return empty credentials array when no credentials exist for personal form", async () => { + const user = { id: 1 }; + const formId = "form-123"; + + prisma.app_RoutingForms_IncompleteBookingActions.findMany.mockResolvedValue([]); + prisma.app_RoutingForms_Form.findFirst.mockResolvedValue({ + id: formId, + userId: user.id, + teamId: null, + }); + prisma.credential.findFirst.mockResolvedValue(null); + + const result = await getIncompleteBookingSettingsHandler({ + ctx: { prisma, user }, + input: { formId }, + }); + + expect(result.credentials).toEqual([]); + expect(result.incompleteBookingActions).toBeDefined(); + }); + + it("should return empty credentials array when no credentials exist for team form", async () => { + const teamMember = { id: 1 }; + const teamId = 100; + const formId = "team-form-123"; + + prisma.app_RoutingForms_IncompleteBookingActions.findMany.mockResolvedValue([]); + prisma.app_RoutingForms_Form.findFirst.mockResolvedValue({ + id: formId, + userId: teamMember.id, + teamId: teamId, + }); + prisma.team.findUnique.mockResolvedValue({ + id: teamId, + parentId: null, + }); + prisma.credential.findMany.mockResolvedValue([]); + + const result = await getIncompleteBookingSettingsHandler({ + ctx: { prisma, user: teamMember }, + input: { formId }, + }); + + expect(result.credentials).toEqual([]); + expect(result.incompleteBookingActions).toBeDefined(); + }); + + it("should throw NOT_FOUND when form does not exist", async () => { + const user = { id: 1 }; + const formId = "non-existent-form"; + + prisma.app_RoutingForms_IncompleteBookingActions.findMany.mockResolvedValue([]); + prisma.app_RoutingForms_Form.findFirst.mockResolvedValue(null); + + await expect( + getIncompleteBookingSettingsHandler({ + ctx: { prisma, user }, + input: { formId }, + }) + ).rejects.toThrow(TRPCError); + + await expect( + getIncompleteBookingSettingsHandler({ + ctx: { prisma, user }, + input: { formId }, + }) + ).rejects.toThrow("Form not found"); + }); + + it("should return incompleteBookingActions along with credentials", async () => { + const user = { id: 1 }; + const formId = "form-123"; + const mockActions = [ + { id: 1, formId, action: "create_contact" }, + { id: 2, formId, action: "update_lead" }, + ]; + + prisma.app_RoutingForms_IncompleteBookingActions.findMany.mockResolvedValue(mockActions); + prisma.app_RoutingForms_Form.findFirst.mockResolvedValue({ + id: formId, + userId: user.id, + teamId: null, + }); + prisma.credential.findFirst.mockResolvedValue(null); + + const result = await getIncompleteBookingSettingsHandler({ + ctx: { prisma, user }, + input: { formId }, + }); + + expect(result.incompleteBookingActions).toEqual(mockActions); + expect(result.incompleteBookingActions).toHaveLength(2); + }); + }); +}); diff --git a/packages/trpc/server/routers/apps/routing-forms/getIncompleteBookingSettings.handler.ts b/packages/trpc/server/routers/apps/routing-forms/getIncompleteBookingSettings.handler.ts index 13ce379521..36f7bfcd35 100644 --- a/packages/trpc/server/routers/apps/routing-forms/getIncompleteBookingSettings.handler.ts +++ b/packages/trpc/server/routers/apps/routing-forms/getIncompleteBookingSettings.handler.ts @@ -1,4 +1,6 @@ import { enabledIncompleteBookingApps } from "@calcom/app-store/routing-forms/lib/enabledIncompleteBookingApps"; +import { entityPrismaWhereClause } from "@calcom/features/pbac/lib/entityPermissionUtils.server"; +import type { Credential } from "@calcom/kysely/types"; import type { PrismaClient } from "@calcom/prisma"; import { safeCredentialSelect } from "@calcom/prisma/selects/credential"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; @@ -7,6 +9,11 @@ import { TRPCError } from "@trpc/server"; import type { TGetIncompleteBookingSettingsInputSchema } from "./getIncompleteBookingSettings.schema"; +type SanitizedCredential = Credential & { + user?: { email: string; name: string | null } | null; + team?: { name: string | null } | null; +}; + interface GetIncompleteBookingSettingsOptions { ctx: { prisma: PrismaClient; @@ -17,7 +24,7 @@ interface GetIncompleteBookingSettingsOptions { const getInCompleteBookingSettingsHandler = async (options: GetIncompleteBookingSettingsOptions) => { const { - ctx: { prisma }, + ctx: { prisma, user }, input, } = options; @@ -28,9 +35,9 @@ const getInCompleteBookingSettingsHandler = async (options: GetIncompleteBooking formId: input.formId, }, }), - prisma.app_RoutingForms_Form.findUnique({ + prisma.app_RoutingForms_Form.findFirst({ where: { - id: input.formId, + AND: [entityPrismaWhereClause({ userId: user.id }), { id: input.formId }], }, select: { userId: true, @@ -85,7 +92,20 @@ const getInCompleteBookingSettingsHandler = async (options: GetIncompleteBooking }, }); - return { incompleteBookingActions, credentials }; + const sanitized: SanitizedCredential[] = credentials.map( + (c) => + Object.fromEntries(Object.entries(c).filter(([k]) => k !== "key")) as unknown as SanitizedCredential + ); + + return { + incompleteBookingActions, + credentials: sanitized.map((c) => ({ + ...c, + id: Number(c.id), + teamId: c.teamId ? Number(c.teamId) : null, + userId: c.userId ? Number(c.userId) : null, + })), + }; } if (userId) { @@ -97,9 +117,30 @@ const getInCompleteBookingSettingsHandler = async (options: GetIncompleteBooking }, userId, }, + select: { + ...safeCredentialSelect, + }, }); - return { incompleteBookingActions, credentials: credential ? [{ ...credential, team: null }] : [] }; + const sanitized = credential + ? (Object.fromEntries( + Object.entries(credential).filter(([k]) => k !== "key") + ) as unknown as SanitizedCredential) + : null; + + return { + incompleteBookingActions, + credentials: sanitized + ? [ + { + ...sanitized, + team: null, + id: Number(sanitized.id), + userId: sanitized.userId ? Number(sanitized.userId) : null, + }, + ] + : [], + }; } };