Skip to content

Commit 71e7f16

Browse files
hbjORbjdevin-ai-integration[bot]anikdhabal
authored
fix: prevent calendar credentials from leaking into video adapter calls (calcom#25200)
* fix: prevent calendar credentials from leaking into video adapter calls Split getCredentialAndWarnIfNotFound into two category-specific functions: - getVideoCredentialAndWarnIfNotFound: only searches video credentials - getCalendarCredentialAndWarnIfNotFound: only searches calendar credentials This prevents the bug where calendar credentials (like google_calendar) were being passed to getVideoAdapters() during booking deletion, causing 'Couldn't get adapter for googlecalendar' errors. The root cause was the fallback logic that searched both videoCredentials and calendarCredentials when a credential wasn't found by ID. Now each function only searches within its own credential category and validates the returned credential has the expected type suffix (_video/_conferencing or _calendar). Also fixed pre-existing ESLint warnings in EventManager.ts: - Prefixed unused delegatedCredentialLast with underscore - Replaced 'any' types with 'unknown' for better type safety - Fixed unused variable warning in updateAllCalendarEvents error handler Fixes the issue where deleteVideoEventForBookingReference could receive a calendar credential when the video credential was missing, leading to errors in getVideoAdapters. Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * refactor: remove shared helper and implement logic directly in each function - Removed getCredentialInternal shared helper - Implemented logic directly in getVideoCredentialAndWarnIfNotFound - Implemented logic directly in getCalendarCredentialAndWarnIfNotFound - Changed fallback to explicitly use this.videoCredentials and this.calendarCredentials Co-Authored-By: benny@cal.com <sldisek783@gmail.com> * remove duplicate * revert * revert * revert * refactor * add tests * address * clean up * simplify * simplify * rename * rename * rename --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
1 parent d6546c3 commit 71e7f16

2 files changed

Lines changed: 201 additions & 34 deletions

File tree

packages/features/bookings/lib/EventManager.test.ts

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { prisma } from "@calcom/prisma/__mocks__/prisma";
22

33
import { describe, expect, it, vi, beforeEach } from "vitest";
44

5+
import { CredentialRepository } from "@calcom/features/credentials/repositories/CredentialRepository";
56
import { symmetricDecrypt } from "@calcom/lib/crypto";
67
import type { DestinationCalendar } from "@calcom/prisma/client";
78
import type { CredentialForCalendarService } from "@calcom/types/Credential";
@@ -16,7 +17,14 @@ vi.mock("@calcom/lib/crypto", () => ({
1617
symmetricDecrypt: vi.fn(),
1718
}));
1819

20+
vi.mock("@calcom/features/credentials/repositories/CredentialRepository", () => ({
21+
CredentialRepository: {
22+
findCredentialForCalendarServiceById: vi.fn(),
23+
},
24+
}));
25+
1926
const mockedSymmetricDecrypt = vi.mocked(symmetricDecrypt);
27+
const mockedCredentialRepository = vi.mocked(CredentialRepository);
2028

2129
function buildCalDAVCredential(data: {
2230
id: number;
@@ -57,6 +65,46 @@ function buildDestinationCalendar(data: {
5765
};
5866
}
5967

68+
function buildCalendarCredential(data: {
69+
id: number;
70+
type?: string;
71+
userId?: number;
72+
delegatedToId?: string | null;
73+
}): CredentialForCalendarService {
74+
return {
75+
id: data.id,
76+
type: data.type || "google_calendar",
77+
key: {},
78+
userId: data.userId || 1,
79+
user: { email: "test@example.com" },
80+
teamId: null,
81+
appId: "google-calendar",
82+
invalid: false,
83+
delegatedTo: null,
84+
delegationCredentialId: null,
85+
delegatedToId: data.delegatedToId || null,
86+
};
87+
}
88+
89+
function buildVideoCredential(data: {
90+
id: number;
91+
type?: string;
92+
userId?: number;
93+
}): CredentialForCalendarService {
94+
return {
95+
id: data.id,
96+
type: data.type || "zoom_video",
97+
key: {},
98+
userId: data.userId || 1,
99+
user: { email: "test@example.com" },
100+
teamId: null,
101+
appId: "zoom",
102+
invalid: false,
103+
delegatedTo: null,
104+
delegationCredentialId: null,
105+
};
106+
}
107+
60108
describe("EventManager CalDAV credential validation", () => {
61109
let eventManager: EventManager;
62110

@@ -456,3 +504,102 @@ describe("EventManager CalDAV credential validation", () => {
456504
});
457505
});
458506
});
507+
508+
describe("EventManager credential lookup methods", () => {
509+
let eventManager: EventManager;
510+
511+
beforeEach(() => {
512+
vi.clearAllMocks();
513+
});
514+
515+
describe("getVideoCredential", () => {
516+
it("returns a cached credential when credentialId matches", async () => {
517+
const videoCredential = buildVideoCredential({ id: 42, type: "zoom_video" });
518+
eventManager = new EventManager({
519+
credentials: [videoCredential],
520+
destinationCalendar: null,
521+
});
522+
523+
const result = await (eventManager as any).getVideoCredential(42, "zoom_video");
524+
525+
expect(result).toMatchObject({ id: 42, type: "zoom_video" });
526+
expect(mockedCredentialRepository.findCredentialForCalendarServiceById).not.toHaveBeenCalled();
527+
});
528+
529+
it("fetches credential from repository when not cached locally", async () => {
530+
const dbCredential = buildVideoCredential({ id: 7, type: "zoom_video" });
531+
eventManager = new EventManager({
532+
credentials: [],
533+
destinationCalendar: null,
534+
});
535+
mockedCredentialRepository.findCredentialForCalendarServiceById.mockResolvedValue(dbCredential as any);
536+
537+
const result = await (eventManager as any).getVideoCredential(7, "zoom_video");
538+
539+
expect(result).toEqual(dbCredential);
540+
expect(mockedCredentialRepository.findCredentialForCalendarServiceById).toHaveBeenCalledWith({ id: 7 });
541+
});
542+
543+
it("falls back to credential type when credentialId is missing", async () => {
544+
const zoomCredential = buildVideoCredential({ id: 1, type: "zoom_video" });
545+
eventManager = new EventManager({
546+
credentials: [zoomCredential],
547+
destinationCalendar: null,
548+
});
549+
550+
const result = await (eventManager as any).getVideoCredential(null, "zoom_video");
551+
552+
expect(result).toMatchObject({ type: "zoom_video" });
553+
expect(mockedCredentialRepository.findCredentialForCalendarServiceById).not.toHaveBeenCalled();
554+
});
555+
});
556+
557+
describe("getCalendarCredential", () => {
558+
it("prefers delegation credentials when delegationCredentialId is provided", async () => {
559+
const delegatedCredential = buildCalendarCredential({
560+
id: 10,
561+
delegatedToId: "delegation-123",
562+
});
563+
eventManager = new EventManager({
564+
credentials: [delegatedCredential],
565+
destinationCalendar: null,
566+
});
567+
568+
const result = await (eventManager as any).getCalendarCredential(
569+
99,
570+
"google_calendar",
571+
"delegation-123"
572+
);
573+
574+
expect(result).toMatchObject({ id: 10, delegatedToId: "delegation-123" });
575+
expect(mockedCredentialRepository.findCredentialForCalendarServiceById).not.toHaveBeenCalled();
576+
});
577+
578+
it("fetches credential from repository when local cache misses", async () => {
579+
const dbCredential = buildCalendarCredential({ id: 5, type: "google_calendar" });
580+
eventManager = new EventManager({
581+
credentials: [],
582+
destinationCalendar: null,
583+
});
584+
mockedCredentialRepository.findCredentialForCalendarServiceById.mockResolvedValue(dbCredential as any);
585+
586+
const result = await (eventManager as any).getCalendarCredential(5, "google_calendar");
587+
588+
expect(result).toEqual(dbCredential);
589+
expect(mockedCredentialRepository.findCredentialForCalendarServiceById).toHaveBeenCalledWith({ id: 5 });
590+
});
591+
592+
it("falls back to matching credential type when credentialId is absent", async () => {
593+
const calendarCredential = buildCalendarCredential({ id: 22, type: "google_calendar" });
594+
eventManager = new EventManager({
595+
credentials: [calendarCredential],
596+
destinationCalendar: null,
597+
});
598+
599+
const result = await (eventManager as any).getCalendarCredential(null, "google_calendar");
600+
601+
expect(result).toMatchObject({ id: 22, type: "google_calendar" });
602+
expect(mockedCredentialRepository.findCredentialForCalendarServiceById).not.toHaveBeenCalled();
603+
});
604+
});
605+
});

packages/features/bookings/lib/EventManager.ts

Lines changed: 54 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -509,9 +509,8 @@ export default class EventManager {
509509
? reference.thirdPartyRecurringEventId
510510
: reference.uid;
511511

512-
const calendarCredential = await this.getCredentialAndWarnIfNotFound(
512+
const calendarCredential = await this.getCalendarCredential(
513513
credentialId,
514-
this.calendarCredentials,
515514
credentialType,
516515
reference.delegationCredentialId
517516
);
@@ -530,51 +529,72 @@ export default class EventManager {
530529
log.debug("deleteVideoEventForBookingReference", safeStringify({ bookingVideoReference: reference }));
531530
const { uid: bookingRefUid, credentialId } = reference;
532531

533-
const videoCredential = await this.getCredentialAndWarnIfNotFound(
534-
credentialId,
535-
this.videoCredentials,
536-
reference.type
537-
);
532+
const videoCredential = await this.getVideoCredential(credentialId, reference.type);
538533

539534
if (videoCredential) {
540535
await deleteMeeting(videoCredential, bookingRefUid);
541536
}
542537
}
543538

544-
private async getCredentialAndWarnIfNotFound(
539+
private async getVideoCredential(
540+
credentialId: number | null | undefined,
541+
type: string
542+
): Promise<CredentialForCalendarService | null | undefined> {
543+
const credential = this.videoCredentials.find((cred) => cred.id === credentialId);
544+
if (credential) {
545+
return credential;
546+
}
547+
548+
const foundCredential =
549+
typeof credentialId === "number" && credentialId > 0
550+
? await CredentialRepository.findCredentialForCalendarServiceById({ id: credentialId })
551+
: // Fallback for zero or nullish credentialId which could be the case of Global App e.g. dailyVideo
552+
this.videoCredentials.find((cred) => cred.type === type) || null;
553+
554+
if (!foundCredential) {
555+
log.error(
556+
"getVideoCredential: Could not find video credential",
557+
safeStringify({
558+
credentialId,
559+
type,
560+
videoCredentialIds: this.videoCredentials.map((cred) => cred.id),
561+
})
562+
);
563+
}
564+
565+
return foundCredential;
566+
}
567+
568+
private async getCalendarCredential(
545569
credentialId: number | null | undefined,
546-
credentials: CredentialForCalendarService[],
547570
type: string,
548571
delegationCredentialId?: string | null
549-
) {
572+
): Promise<CredentialForCalendarService | null | undefined> {
550573
if (delegationCredentialId) {
551574
return this.calendarCredentials.find((cred) => cred.delegatedToId === delegationCredentialId);
552575
}
553-
const credential = credentials.find((cred) => cred.id === credentialId);
576+
const credential = this.calendarCredentials.find((cred) => cred.id === credentialId);
554577
if (credential) {
555578
return credential;
556-
} else {
557-
const credential =
558-
typeof credentialId === "number" && credentialId > 0
559-
? await CredentialRepository.findCredentialForCalendarServiceById({ id: credentialId })
560-
: // Fallback for zero or nullish credentialId which could be the case of Global App e.g. dailyVideo
561-
this.videoCredentials.find((cred) => cred.type === type) ||
562-
this.calendarCredentials.find((cred) => cred.type === type) ||
563-
null;
564-
565-
if (!credential) {
566-
log.error(
567-
"getCredentialAndWarnIfNotFound: Could not find credential",
568-
safeStringify({
569-
credentialId,
570-
type,
571-
videoCredentials: this.videoCredentials,
572-
})
573-
);
574-
}
579+
}
575580

576-
return credential;
581+
const foundCredential =
582+
typeof credentialId === "number" && credentialId > 0
583+
? await CredentialRepository.findCredentialForCalendarServiceById({ id: credentialId })
584+
: this.calendarCredentials.find((cred) => cred.type === type) || null;
585+
586+
if (!foundCredential) {
587+
log.error(
588+
"getCalendarCredential: Could not find calendar credential",
589+
safeStringify({
590+
credentialId,
591+
type,
592+
calendarCredentialIds: this.calendarCredentials.map((cred) => cred.id),
593+
})
594+
);
577595
}
596+
597+
return foundCredential;
578598
}
579599

580600
/**
@@ -994,7 +1014,7 @@ export default class EventManager {
9941014
* @private
9951015
*/
9961016

997-
private getVideoCredential(event: CalendarEvent): CredentialForCalendarService | undefined {
1017+
private getVideoCredentialByCalendarEvent(event: CalendarEvent): CredentialForCalendarService | undefined {
9981018
if (!event.location) {
9991019
return undefined;
10001020
}
@@ -1038,7 +1058,7 @@ export default class EventManager {
10381058
* @private
10391059
*/
10401060
private async createVideoEvent(event: CalendarEvent) {
1041-
const credential = this.getVideoCredential(event);
1061+
const credential = this.getVideoCredentialByCalendarEvent(event);
10421062
if (credential) {
10431063
return createMeeting(credential, event);
10441064
} else {
@@ -1198,7 +1218,7 @@ export default class EventManager {
11981218
* @private
11991219
*/
12001220
private async updateVideoEvent(event: CalendarEvent, booking: PartialBooking) {
1201-
const credential = this.getVideoCredential(event);
1221+
const credential = this.getVideoCredentialByCalendarEvent(event);
12021222

12031223
if (credential) {
12041224
const bookingRef = booking ? booking.references.filter((ref) => ref.type === credential.type)[0] : null;

0 commit comments

Comments
 (0)