Files
calendar/packages/lib/delegationCredential/server.test.ts
T
Hariom BalharaandGitHub e3bd90c4f2 fix: Handle calendar-cache with Delegation Credentials
Fixes CAL-5372

# Delegation Credentials with CalendarCache.

Following content is a snapshot of the [internal document](https://calendso.slack.com/docs/T08B8KA2BNF/F08L5JYU3V3)



**Problem-1 :** 

CalendarCache needs SelectedCalendar records to work but SelectedCalendar record is only created when a user connects their calendar and then enables some calendar for conflict checking. Because with Delegation, no manual connection is done by any of the members, we need a way to create SelectedCalendar records automatically.

**Problem-2**

CalendarCache connects to credential(regular credential) which doesn’t exist for Delegation Credential scenario. Also, DelegationCredential is common for all the members(different from Credential which is different for different members) of the organization and we need to identify to which user the CalendarCache belongs.  

**Solution for both problems**
- Create credential records for Delegation Credentials as well - Through Cron(new - we could schedule it every 5mins)
- Now create SelectedCalendar  records for those Credential records -  Through another Cron(new - we could schedule it every 5mins)
- Now CalendarCache records will automatically be created for those SelectedCalendar records -existing cron

## Fixed some Delegation Credentials bugs unrelated to calendar-cache
- If DestinationCalendar wasn't set(which is possible only with Delegation Credentials), then Google Meet wasn't used as a conferencing app - [Added a test]
- If no SelectedCalendar is there but Google Calendar connection exists(possible only with Delegation Credential) then we were not doing conflict checking. It is expected to not do it for Regular Credentials, but for Delegation Credential we must check for conflict in that case too [Added a test]
- Earlier if a user has Regular Credential as well as Delegation Credential for the same external id which is the member email(say member1@acme.com) then availability were retrieved twice because we weren't deduplicating credentials as it wasn't a trivial thing to do. Now that is being done.


**Env Variables:**
Note this PR doesn't introduce any new env variable. The existing env variable has been added to .env.example. But if this env variable isn't already set, it must be set.

`CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY={SAME_AS_SET_FOR_V2_API}`

**Deployment Plan:**
1. Add Observability for SelectedCalendar when _error_ field is set
2. Follow https://github.com/calcom/cal.com/blob/calendar-cache-dwd-support/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/delegation-credential/delegation-credential.md#setting-up-delegation-credential-for-google-calendar-api to enable Delegation Credential for i.cal.com
3. Note that to be able to see the option to enable Delegation Credential for an organization, you need to enable `teamFeature` and `feature` for `delegation-credential`

## Automation Tests
- Introduced tests for calendar-cache.repository.ts
   - Tests all methods of the repository
- Added more tests for handleNewBooking/delegation-credential flow.
   - Added test to verify the bug fix when no DestinationCalendar exists and Google Meet should be used still 
- Added more tests for Google Calendar/CalendarService targeting DelegationCredential
- Added more tests for getCalendarsEvents. 
    - To test the new logic of calling getAvailability still if there are no selectedCalendars in case of Delegation Credential
    - Also introduced tests for `getAvailabitlityWithTimezones` which was an existing function but now has some new changes.
- Added tests for deduplication logic in CalendarManager.ts

## How to Test
Enable Calendar Cache and Delegation Credential feature for acme org through `features` and `teamFeatures` tables.
- Enable Delegation Credential for acme org
- Enable atleast 1 calendar for conflict checking for one of the users(say owner1)
- Ensure GOOGLE_WEBHOOK_TOKEN is set in .env file
- Ensure GOOGLE_WEBHOOK_URL is set to ngrok url of webapp in .env file
- Hit cron endpoint `curl http://localhost:3000/api/calendar-cache/cron\?apiKey\={API_KEY}` that would cache the freebusy result for the selected calendars



Followup 
- https://github.com/calcom/cal.com/pull/20698
- https://github.com/calcom/cal.com/pull/18619/files#r2046795643
2025-04-28 18:11:29 -03:00

636 lines
20 KiB
TypeScript

import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
import { describe, it, expect, beforeEach, vi } from "vitest";
import { metadata as googleCalendarMetadata } from "@calcom/app-store/googlecalendar/_metadata";
import { metadata as googleMeetMetadata } from "@calcom/app-store/googlevideo/_metadata";
import type { ServiceAccountKey } from "@calcom/lib/server/repository/delegationCredential";
import { DelegationCredentialRepository } from "@calcom/lib/server/repository/delegationCredential";
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
import { SMSLockState } from "@calcom/prisma/enums";
import type { CredentialForCalendarService, CredentialPayload } from "@calcom/types/Credential";
import {
getAllDelegationCredentialsForUser,
buildAllCredentials,
getDelegationCredentialOrRegularCredential,
enrichUsersWithDelegationCredentials,
enrichHostsWithDelegationCredentials,
enrichUserWithDelegationCredentialsWithoutOrgId,
enrichUserWithDelegationConferencingCredentialsWithoutOrgId,
} from "./server";
// Mock OrganizationRepository
vi.mock("@calcom/lib/server/repository/organization", () => ({
OrganizationRepository: {
findByMemberEmail: vi.fn(),
},
}));
// Mock DelegationCredentialRepository
vi.mock("@calcom/lib/server/repository/delegationCredential", () => ({
DelegationCredentialRepository: {
findUniqueByOrgMemberEmailIncludeSensitiveServiceAccountKey: vi.fn(),
findUniqueByOrganizationIdAndDomainIncludeSensitiveServiceAccountKey: vi.fn(),
},
}));
const mockUser = {
email: "test@example.com",
id: 123,
};
const mockServiceAccountKey: ServiceAccountKey = {
client_email: "test@example.iam.gserviceaccount.com",
client_id: "123456789",
private_key: "-----BEGIN PRIVATE KEY-----\nMIIE....\n-----END PRIVATE KEY-----\n",
};
const mockWorkspacePlatform = {
name: "Test Platform",
slug: "google",
};
const mockDelegationCredential = {
id: "delegationCredential-1",
enabled: true,
domain: "example.com",
organizationId: 1,
serviceAccountKey: mockServiceAccountKey,
workspacePlatform: mockWorkspacePlatform,
createdAt: new Date(),
updatedAt: new Date(),
};
// Mock organization data
const mockOrganization = {
id: 1,
name: "Test Org",
metadata: {},
bio: null,
timeZone: "UTC",
weekStart: "Monday",
hideBranding: false,
theme: null,
timeFormat: null,
brandColor: null,
darkBrandColor: null,
createdAt: new Date(),
includeManagedEventsInLimits: false,
smsLockState: SMSLockState.LOCKED,
smsLockReviewedByAdmin: false,
slug: "test-org",
logoUrl: null,
bannerUrl: null,
isPrivate: false,
isOrganization: true,
isOrganizationVerified: true,
isOrganizationConfigured: true,
isOrganizationAdminReviewed: true,
orgAutoAcceptEmail: "example.com",
orgProfileRedirectsToVerifiedDomain: false,
allowSEOIndexing: true,
calVideoLogo: null,
appLogo: null,
appIconLogo: null,
hideBookATeamMember: false,
hideTeam: false,
hideCalendarNotes: false,
eventTypeMatchParentTeam: false,
lockEventTypeCreationForUsers: false,
parentId: null,
pendingPayment: false,
isPlatform: false,
createdByOAuthClientId: null,
bookingLimits: null,
};
// Credential Builders
const buildDelegationCredential = (overrides = {}) => ({
type: "google_calendar",
appId: "google-calendar",
id: -1,
delegatedToId: mockDelegationCredential.id,
userId: mockUser.id,
user: { email: mockUser.email },
key: { access_token: "NOOP_UNUSED_DELEGATION_TOKEN" },
invalid: false,
teamId: null,
team: null,
// In-memory delegation credential has it set to null
delegationCredentialId: mockDelegationCredential.id,
delegatedTo: {
serviceAccountKey: mockServiceAccountKey,
},
...overrides,
});
const buildGoogleCalendarDelegationCredential = (overrides = {}) => ({
...buildDelegationCredential({
type: googleCalendarMetadata.type,
appId: googleCalendarMetadata.slug,
}),
...overrides,
});
const buildDelegationCredentialGoogleMeetCredential = (overrides = {}) => ({
...buildDelegationCredential({
type: googleMeetMetadata.type,
appId: googleMeetMetadata.slug,
}),
...overrides,
});
const buildRegularCredential = (overrides = {}): CredentialForCalendarService => ({
type: "google_calendar",
appId: "google-calendar",
id: 1,
userId: mockUser.id,
user: { email: mockUser.email },
key: JSON.stringify(mockServiceAccountKey),
invalid: false,
teamId: null,
delegatedToId: null,
delegatedTo: null,
// Regular credentials have it set to null always
delegationCredentialId: null,
...overrides,
});
const buildRegularGoogleCalendarCredential = (overrides = {}): CredentialPayload => ({
...buildRegularCredential({
appId: "google-calendar",
type: "google_calendar",
}),
...overrides,
});
const buildMockWorkspacePlatform = (overrides: Partial<typeof mockWorkspacePlatform> = {}) => ({
name: "Test Platform",
slug: "google",
...overrides,
});
const buildMockDelegationCredential = (overrides: Partial<typeof mockDelegationCredential> = {}) => ({
...mockDelegationCredential,
workspacePlatform: buildMockWorkspacePlatform(overrides.workspacePlatform || {}),
...overrides,
});
describe("getAllDelegationCredentialsForUser", () => {
setupAndTeardown();
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(OrganizationRepository.findByMemberEmail).mockResolvedValue(mockOrganization);
});
it("should return empty array when no DelegationCredential found", async () => {
vi.mocked(
DelegationCredentialRepository.findUniqueByOrgMemberEmailIncludeSensitiveServiceAccountKey
).mockResolvedValue(null);
const result = await getAllDelegationCredentialsForUser({ user: mockUser });
expect(result).toEqual([]);
});
it("should return empty array when DelegationCredential is disabled", async () => {
vi.mocked(
DelegationCredentialRepository.findUniqueByOrgMemberEmailIncludeSensitiveServiceAccountKey
).mockResolvedValue(buildMockDelegationCredential({ enabled: false }));
const result = await getAllDelegationCredentialsForUser({ user: mockUser });
expect(result).toEqual([]);
});
it("should return credentials for enabled Google DelegationCredential", async () => {
vi.mocked(
DelegationCredentialRepository.findUniqueByOrgMemberEmailIncludeSensitiveServiceAccountKey
).mockResolvedValue(buildMockDelegationCredential());
const result = await getAllDelegationCredentialsForUser({ user: mockUser });
expect(result).toHaveLength(2);
expect(result).toEqual([
buildGoogleCalendarDelegationCredential(),
buildDelegationCredentialGoogleMeetCredential(),
]);
});
it("should return empty array for non-Google platforms(as they are not supported yet)", async () => {
vi.mocked(
DelegationCredentialRepository.findUniqueByOrgMemberEmailIncludeSensitiveServiceAccountKey
).mockResolvedValue(
buildMockDelegationCredential({
workspacePlatform: {
name: "Microsoft",
slug: "microsoft",
},
})
);
const result = await getAllDelegationCredentialsForUser({ user: mockUser });
expect(result).toEqual([]);
});
});
describe("buildAllCredentials", () => {
const mockDelegationCredentialGoogleCalendarCred = buildGoogleCalendarDelegationCredential();
const mockRegularGoogleCalendarCred = buildRegularGoogleCalendarCredential();
it("should combine DelegationCredential and regular credentials", () => {
const result = buildAllCredentials({
delegationCredentials: [mockDelegationCredentialGoogleCalendarCred],
existingCredentials: [mockRegularGoogleCalendarCred],
});
expect(result).toHaveLength(2);
expect(result).toContainEqual(mockDelegationCredentialGoogleCalendarCred);
expect(result).toContainEqual(mockRegularGoogleCalendarCred);
});
it("should deduplicate DelegationCredential credentials with same delegatedToId and appId", () => {
const duplicateDelegationCredential = buildGoogleCalendarDelegationCredential();
const result = buildAllCredentials({
delegationCredentials: [mockDelegationCredentialGoogleCalendarCred, duplicateDelegationCredential],
existingCredentials: [],
});
expect(result).toHaveLength(1);
expect(result[0]).toEqual(mockDelegationCredentialGoogleCalendarCred);
});
it("should keep DelegationCredential credentials with same delegatedToId but different appId", () => {
const differentAppDelegationCredential = buildDelegationCredentialGoogleMeetCredential();
const result = buildAllCredentials({
delegationCredentials: [mockDelegationCredentialGoogleCalendarCred, differentAppDelegationCredential],
existingCredentials: [],
});
expect(result).toHaveLength(2);
expect(result).toContainEqual(mockDelegationCredentialGoogleCalendarCred);
expect(result).toContainEqual(differentAppDelegationCredential);
});
it("should filter out DelegationCredential credentials from existingCredentials", () => {
const delegatedCalendarCredential = buildRegularGoogleCalendarCredential({ id: -2 });
const result = buildAllCredentials({
delegationCredentials: [mockDelegationCredentialGoogleCalendarCred],
existingCredentials: [mockRegularGoogleCalendarCred, delegatedCalendarCredential],
});
expect(result).toHaveLength(2);
expect(result).toContainEqual(mockDelegationCredentialGoogleCalendarCred);
expect(result).toContainEqual(mockRegularGoogleCalendarCred);
});
});
describe("getDelegationCredentialOrRegularCredential", () => {
const mockDelegationCredentialGoogleCalendarCred = buildGoogleCalendarDelegationCredential();
const mockRegularGoogleCalendarCred = buildRegularGoogleCalendarCredential();
const credentials = [mockDelegationCredentialGoogleCalendarCred, mockRegularGoogleCalendarCred];
it("should find Delegation credential by delegationCredentialId", () => {
const result = getDelegationCredentialOrRegularCredential({
credentials,
id: { credentialId: null, delegationCredentialId: mockDelegationCredential.id },
});
expect(result).toEqual(mockDelegationCredentialGoogleCalendarCred);
});
it("should find regular credential by credentialId", () => {
const result = getDelegationCredentialOrRegularCredential({
credentials,
id: { credentialId: mockRegularGoogleCalendarCred.id, delegationCredentialId: null },
});
expect(result).toEqual(mockRegularGoogleCalendarCred);
});
it("should return null when no matching credential found", () => {
const result = getDelegationCredentialOrRegularCredential({
credentials,
id: { credentialId: 999, delegationCredentialId: null },
});
expect(result).toBeNull();
});
it("should not match null to null for delegatedToId", () => {
const result = getDelegationCredentialOrRegularCredential({
credentials,
id: { credentialId: null, delegationCredentialId: null },
});
expect(result).toBeNull();
});
});
describe("enrichUsersWithDelegationCredentials", () => {
const mockUsers = [
{
...mockUser,
credentials: [buildRegularGoogleCalendarCredential()],
},
{
id: 456,
email: "test2@example.com",
credentials: [buildRegularGoogleCalendarCredential({ id: 2 })],
},
];
beforeEach(() => {
vi.clearAllMocks();
});
it("should return users as is when orgId is null", async () => {
const result = await enrichUsersWithDelegationCredentials({
orgId: null,
users: mockUsers,
});
expect(result).toEqual(
mockUsers.map((user) => ({
...user,
credentials: user.credentials.map((cred) => ({
...cred,
delegatedTo: null,
delegatedToId: null,
})),
}))
);
});
it("should enrich users with DelegationCredential credentials when available", async () => {
vi.mocked(
DelegationCredentialRepository.findUniqueByOrganizationIdAndDomainIncludeSensitiveServiceAccountKey
).mockResolvedValue(buildMockDelegationCredential());
const result = await enrichUsersWithDelegationCredentials({
orgId: 1,
users: mockUsers,
});
expect(result).toHaveLength(2);
result.forEach((enrichedUser) => {
expect(enrichedUser.credentials).toHaveLength(3); // 1 regular + 2 DelegationCredential (calendar + meet)
expect(enrichedUser.credentials).toContainEqual(
expect.objectContaining({
type: googleCalendarMetadata.type,
appId: googleCalendarMetadata.slug,
})
);
expect(enrichedUser.credentials).toContainEqual(
expect.objectContaining({
type: googleMeetMetadata.type,
appId: googleMeetMetadata.slug,
})
);
});
});
it("should not add DelegationCredential credentials when DelegationCredential is disabled", async () => {
vi.mocked(
DelegationCredentialRepository.findUniqueByOrganizationIdAndDomainIncludeSensitiveServiceAccountKey
).mockResolvedValue(buildMockDelegationCredential({ enabled: false }));
const result = await enrichUsersWithDelegationCredentials({
orgId: 1,
users: mockUsers,
});
expect(result).toEqual(
mockUsers.map((user) => ({
...user,
credentials: user.credentials.map((cred) => ({
...cred,
delegatedTo: null,
delegatedToId: null,
})),
}))
);
});
it("should return empty array when users is empty", async () => {
const result = await enrichUsersWithDelegationCredentials({
orgId: 1,
users: [],
});
expect(result).toEqual([]);
});
});
describe("enrichHostsWithDelegationCredentials", () => {
const mockHosts = [
{
user: {
...mockUser,
credentials: [buildRegularGoogleCalendarCredential()],
},
metadata: { key: "value" },
},
{
user: {
id: 456,
email: "test2@example.com",
credentials: [buildRegularGoogleCalendarCredential({ id: 2 })],
},
metadata: { key: "value2" },
},
];
beforeEach(() => {
vi.clearAllMocks();
});
it("should return hosts as is when orgId is null", async () => {
const result = await enrichHostsWithDelegationCredentials({
orgId: null,
hosts: mockHosts,
});
expect(result).toEqual(
mockHosts.map((host) => ({
...host,
user: {
...host.user,
credentials: host.user.credentials.map((cred) => ({
...cred,
delegatedTo: null,
delegatedToId: null,
})),
},
}))
);
});
it("should enrich hosts with DelegationCredential credentials when available", async () => {
vi.mocked(
DelegationCredentialRepository.findUniqueByOrganizationIdAndDomainIncludeSensitiveServiceAccountKey
).mockResolvedValue(buildMockDelegationCredential());
const result = await enrichHostsWithDelegationCredentials({
orgId: 1,
hosts: mockHosts,
});
expect(result).toHaveLength(2);
result.forEach((enrichedHost) => {
expect(enrichedHost.metadata).toBeDefined(); // Preserve non-user data
expect(enrichedHost.user.credentials).toHaveLength(3); // 1 regular + 2 DelegationCredential (calendar + meet)
expect(enrichedHost.user.credentials).toContainEqual(
expect.objectContaining({
type: googleCalendarMetadata.type,
appId: googleCalendarMetadata.slug,
})
);
expect(enrichedHost.user.credentials).toContainEqual(
expect.objectContaining({
type: googleMeetMetadata.type,
appId: googleMeetMetadata.slug,
})
);
});
});
it("should not add DelegationCredential credentials when DelegationCredential is disabled", async () => {
vi.mocked(
DelegationCredentialRepository.findUniqueByOrganizationIdAndDomainIncludeSensitiveServiceAccountKey
).mockResolvedValue(buildMockDelegationCredential({ enabled: false }));
const result = await enrichHostsWithDelegationCredentials({
orgId: 1,
hosts: mockHosts,
});
expect(result).toEqual(
mockHosts.map((host) => ({
...host,
user: {
...host.user,
credentials: host.user.credentials.map((cred) => ({
...cred,
delegatedTo: null,
delegatedToId: null,
})),
},
}))
);
});
it("should return empty array when hosts is empty", async () => {
const result = await enrichHostsWithDelegationCredentials({
orgId: 1,
hosts: [],
});
expect(result).toEqual([]);
});
});
describe("enrichUserWithDelegationCredentialsWithoutOrgId", () => {
const mockUserWithCredentials = {
...mockUser,
credentials: [buildRegularGoogleCalendarCredential()],
};
beforeEach(() => {
vi.clearAllMocks();
});
it("should enrich user with DelegationCredential credentials when available", async () => {
vi.mocked(
DelegationCredentialRepository.findUniqueByOrgMemberEmailIncludeSensitiveServiceAccountKey
).mockResolvedValue(buildMockDelegationCredential());
const result = await enrichUserWithDelegationCredentialsWithoutOrgId({
user: mockUserWithCredentials,
});
expect(result.credentials).toHaveLength(3); // 1 regular + 2 DelegationCredential (calendar + meet)
expect(result.credentials).toContainEqual(
expect.objectContaining({
type: googleCalendarMetadata.type,
appId: googleCalendarMetadata.slug,
})
);
expect(result.credentials).toContainEqual(
expect.objectContaining({
type: googleMeetMetadata.type,
appId: googleMeetMetadata.slug,
})
);
});
it("should not add DelegationCredential credentials when DelegationCredential is not found", async () => {
vi.mocked(
DelegationCredentialRepository.findUniqueByOrgMemberEmailIncludeSensitiveServiceAccountKey
).mockResolvedValue(null);
const result = await enrichUserWithDelegationCredentialsWithoutOrgId({
user: mockUserWithCredentials,
});
expect(result).toEqual({
...mockUserWithCredentials,
credentials: mockUserWithCredentials.credentials.map((cred) => ({
...cred,
delegatedTo: null,
delegatedToId: null,
})),
});
});
it("should not add DelegationCredential credentials when DelegationCredential is disabled", async () => {
vi.mocked(
DelegationCredentialRepository.findUniqueByOrgMemberEmailIncludeSensitiveServiceAccountKey
).mockResolvedValue(buildMockDelegationCredential({ enabled: false }));
const result = await enrichUserWithDelegationCredentialsWithoutOrgId({
user: mockUserWithCredentials,
});
expect(result).toEqual({
...mockUserWithCredentials,
credentials: mockUserWithCredentials.credentials.map((cred) => ({
...cred,
delegatedTo: null,
delegatedToId: null,
})),
});
});
});
describe("enrichUserWithDelegationConferencingCredentialsWithoutOrgId", () => {
const mockUserWithCredentials = {
...mockUser,
credentials: [buildRegularGoogleCalendarCredential()],
};
beforeEach(() => {
vi.clearAllMocks();
});
it("should only return conferencing credentials", async () => {
vi.mocked(
DelegationCredentialRepository.findUniqueByOrgMemberEmailIncludeSensitiveServiceAccountKey
).mockResolvedValue(buildMockDelegationCredential());
const result = await enrichUserWithDelegationConferencingCredentialsWithoutOrgId({
user: mockUserWithCredentials,
});
expect(result.credentials).toHaveLength(1); // Only Google Meet
expect(result.credentials[0]).toEqual(
expect.objectContaining({
type: googleMeetMetadata.type,
appId: googleMeetMetadata.slug,
})
);
});
it("should return empty credentials array when no conferencing credentials found", async () => {
vi.mocked(
DelegationCredentialRepository.findUniqueByOrgMemberEmailIncludeSensitiveServiceAccountKey
).mockResolvedValue(null);
const result = await enrichUserWithDelegationConferencingCredentialsWithoutOrgId({
user: mockUserWithCredentials,
});
expect(result.credentials).toEqual([]);
});
});