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, + }, + ] + : [], + }; } };