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 <volnei.munhoz@gmail.com>

* Add suggested fix

* Add suggested fix

* fix: enforce authorization scoping and credential sanitization in routing-forms handler

Co-Authored-By: Volnei Munhoz <volnei.munhoz@gmail.com>

* Fix types

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
Volnei Munhoz
2025-11-10 16:54:15 +00:00
committed by GitHub
co-authored by Volnei Munhoz Devin AI Alex van Andel
parent db8c7942a9
commit 984cd64083
3 changed files with 1044 additions and 5 deletions
@@ -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);
});
});
});
@@ -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);
});
});
});
@@ -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,
},
]
: [],
};
}
};