Skip to content

Commit af230f9

Browse files
fix: ensure default calendars api v2 (calcom#27603)
* fix: ensure default calendars * test: add E2E tests for delegation credential controller and update tasker config - Add E2E tests to verify ensureDefaultCalendars is called when enabling delegation credentials - Update calendars tasker config to use medium-1x machine for retry on OOM - Set minimum retry backoff to 60 seconds (1 minute between retries) Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: update tasker config to use small-2x machine with outOfMemory retry on medium-1x Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: update E2E tests to properly spy on service instance and use valid workspace platform slug Co-Authored-By: morgan@cal.com <morgan@cal.com> * ci: add CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY to E2E API v2 workflow Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: add encryption key to E2E test file for delegation credentials Co-Authored-By: morgan@cal.com <morgan@cal.com> * revert: remove CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY from workflow (moved to test file) Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: move encryption key to setEnvVars.ts for E2E tests Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: use valid format for service account encryption key Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: encrypt service account key in E2E test for delegation credentials Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: mock updateDelegationCredentialEnabled to bypass Google API call in E2E tests Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: get service from app.get() after initialization for proper spy setup in E2E tests Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: use jest.mock() to mock toggleDelegationCredentialEnabled and bypass Google API calls in E2E tests Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: use Service.prototype pattern for spying on ensureDefaultCalendars in E2E tests Co-Authored-By: morgan@cal.com <morgan@cal.com> * fix: move spy setup to beforeAll before app.init() for proper NestJS interception Co-Authored-By: morgan@cal.com <morgan@cal.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent a28ab04 commit af230f9

5 files changed

Lines changed: 292 additions & 17 deletions

File tree

Lines changed: 272 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,272 @@
1+
import { SUCCESS_STATUS } from "@calcom/platform-constants";
2+
import { encryptServiceAccountKey } from "@calcom/platform-libraries";
3+
import type { Team, User } from "@calcom/prisma/client";
4+
5+
// Mock the toggleDelegationCredentialEnabled function to bypass Google API calls
6+
const mockToggleDelegationCredentialEnabled = jest.fn();
7+
jest.mock("@calcom/platform-libraries/app-store", () => {
8+
const actual = jest.requireActual("@calcom/platform-libraries/app-store");
9+
return {
10+
...actual,
11+
toggleDelegationCredentialEnabled: (...args: unknown[]) => mockToggleDelegationCredentialEnabled(...args),
12+
};
13+
});
14+
15+
import { INestApplication } from "@nestjs/common";
16+
import { NestExpressApplication } from "@nestjs/platform-express";
17+
import { Test } from "@nestjs/testing";
18+
import request from "supertest";
19+
import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture";
20+
import { PlatformBillingRepositoryFixture } from "test/fixtures/repository/billing.repository.fixture";
21+
import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture";
22+
import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture";
23+
import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture";
24+
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
25+
import { randomString } from "test/utils/randomString";
26+
import { AppModule } from "@/app.module";
27+
import { bootstrap } from "@/bootstrap";
28+
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
29+
import { OrganizationsDelegationCredentialService } from "@/modules/organizations/delegation-credentials/services/organizations-delegation-credential.service";
30+
import { PrismaModule } from "@/modules/prisma/prisma.module";
31+
import { TokensModule } from "@/modules/tokens/tokens.module";
32+
import { UsersModule } from "@/modules/users/users.module";
33+
import { UpdateDelegationCredentialInput } from "@/modules/organizations/delegation-credentials/inputs/update-delegation-credential.input";
34+
import { UpdateDelegationCredentialOutput } from "@/modules/organizations/delegation-credentials/outputs/update-delegation-credential.output";
35+
36+
describe("Organizations Delegation Credentials Endpoints", () => {
37+
describe("User Authentication - User is Org Admin", () => {
38+
let app: INestApplication;
39+
40+
let userRepositoryFixture: UserRepositoryFixture;
41+
let organizationsRepositoryFixture: OrganizationRepositoryFixture;
42+
let membershipRepositoryFixture: MembershipRepositoryFixture;
43+
let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture;
44+
let apiKeysRepositoryFixture: ApiKeysRepositoryFixture;
45+
let profilesRepositoryFixture: ProfileRepositoryFixture;
46+
let prismaWriteService: PrismaWriteService;
47+
48+
let org: Team;
49+
let user: User;
50+
let apiKey: string;
51+
let delegationCredentialId: string;
52+
let workspacePlatformId: number;
53+
let ensureDefaultCalendarsSpy: jest.SpyInstance;
54+
55+
const userEmail = `delegation-credentials-admin-${randomString()}@api.com`;
56+
57+
beforeAll(async () => {
58+
const moduleRef = await Test.createTestingModule({
59+
imports: [AppModule, PrismaModule, UsersModule, TokensModule],
60+
}).compile();
61+
62+
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
63+
organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef);
64+
membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef);
65+
platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef);
66+
apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef);
67+
profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef);
68+
prismaWriteService = moduleRef.get(PrismaWriteService);
69+
70+
user = await userRepositoryFixture.create({
71+
email: userEmail,
72+
username: userEmail,
73+
});
74+
75+
org = await organizationsRepositoryFixture.create({
76+
name: `delegation-credentials-organization-${randomString()}`,
77+
isOrganization: true,
78+
isPlatform: true,
79+
});
80+
81+
await profilesRepositoryFixture.create({
82+
uid: `${randomString()}-uid`,
83+
username: userEmail,
84+
user: { connect: { id: user.id } },
85+
organization: { connect: { id: org.id } },
86+
movedFromUser: { connect: { id: user.id } },
87+
});
88+
89+
await platformBillingRepositoryFixture.create(org.id, "SCALE");
90+
91+
await membershipRepositoryFixture.create({
92+
role: "ADMIN",
93+
user: { connect: { id: user.id } },
94+
team: { connect: { id: org.id } },
95+
accepted: true,
96+
});
97+
98+
const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null, org.id);
99+
apiKey = `cal_test_${keyString}`;
100+
101+
const workspacePlatform = await prismaWriteService.prisma.workspacePlatform.create({
102+
data: {
103+
slug: "google",
104+
name: "Google Workspace",
105+
description: "Google Workspace for testing",
106+
defaultServiceAccountKey: {
107+
type: "service_account",
108+
project_id: "test-project",
109+
private_key_id: "test-key-id",
110+
private_key: "test-private-key",
111+
client_email: "test@test-project.iam.gserviceaccount.com",
112+
client_id: "123456789",
113+
auth_uri: "https://accounts.google.com/o/oauth2/auth",
114+
token_uri: "https://oauth2.googleapis.com/token",
115+
auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs",
116+
client_x509_cert_url: "https://www.googleapis.com/robot/v1/metadata/x509/test",
117+
},
118+
enabled: true,
119+
},
120+
});
121+
workspacePlatformId = workspacePlatform.id;
122+
123+
const testServiceAccountKey = {
124+
type: "service_account" as const,
125+
project_id: "test-project",
126+
private_key_id: "test-key-id",
127+
private_key: "test-private-key",
128+
client_email: "test@test-project.iam.gserviceaccount.com",
129+
client_id: "123456789",
130+
auth_uri: "https://accounts.google.com/o/oauth2/auth",
131+
token_uri: "https://oauth2.googleapis.com/token",
132+
auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs",
133+
client_x509_cert_url: "https://www.googleapis.com/robot/v1/metadata/x509/test",
134+
};
135+
136+
const encryptedServiceAccountKey = encryptServiceAccountKey(testServiceAccountKey);
137+
138+
const delegationCredential = await prismaWriteService.prisma.delegationCredential.create({
139+
data: {
140+
workspacePlatformId: workspacePlatform.id,
141+
organizationId: org.id,
142+
domain: "@test-domain.com",
143+
serviceAccountKey: encryptedServiceAccountKey,
144+
enabled: false,
145+
},
146+
});
147+
delegationCredentialId = delegationCredential.id;
148+
149+
// Set up spy on prototype BEFORE app.init() - this is critical for NestJS
150+
ensureDefaultCalendarsSpy = jest
151+
.spyOn(OrganizationsDelegationCredentialService.prototype, "ensureDefaultCalendars")
152+
.mockResolvedValue(undefined);
153+
154+
app = moduleRef.createNestApplication();
155+
bootstrap(app as NestExpressApplication);
156+
157+
await app.init();
158+
});
159+
160+
afterEach(() => {
161+
// Clear the spy call history after each test
162+
ensureDefaultCalendarsSpy.mockClear();
163+
mockToggleDelegationCredentialEnabled.mockClear();
164+
});
165+
166+
it("should be defined", () => {
167+
expect(userRepositoryFixture).toBeDefined();
168+
expect(organizationsRepositoryFixture).toBeDefined();
169+
expect(user).toBeDefined();
170+
expect(org).toBeDefined();
171+
});
172+
173+
it("should call ensureDefaultCalendars when enabling delegation credentials", async () => {
174+
await prismaWriteService.prisma.delegationCredential.update({
175+
where: { id: delegationCredentialId },
176+
data: { enabled: false },
177+
});
178+
179+
// Mock toggleDelegationCredentialEnabled to return a valid response
180+
mockToggleDelegationCredentialEnabled.mockResolvedValue({
181+
id: delegationCredentialId,
182+
enabled: true,
183+
});
184+
185+
const response = await request(app.getHttpServer())
186+
.patch(`/v2/organizations/${org.id}/delegation-credentials/${delegationCredentialId}`)
187+
.set("Authorization", `Bearer ${apiKey}`)
188+
.send({
189+
enabled: true,
190+
} satisfies UpdateDelegationCredentialInput)
191+
.expect(200);
192+
193+
const responseBody: UpdateDelegationCredentialOutput = response.body;
194+
expect(responseBody.status).toEqual(SUCCESS_STATUS);
195+
expect(responseBody.data.enabled).toEqual(true);
196+
197+
expect(ensureDefaultCalendarsSpy).toHaveBeenCalledWith(org.id, "@test-domain.com");
198+
});
199+
200+
it("should not call ensureDefaultCalendars when disabling delegation credentials", async () => {
201+
await prismaWriteService.prisma.delegationCredential.update({
202+
where: { id: delegationCredentialId },
203+
data: { enabled: true },
204+
});
205+
206+
// Mock toggleDelegationCredentialEnabled to return a valid response
207+
mockToggleDelegationCredentialEnabled.mockResolvedValue({
208+
id: delegationCredentialId,
209+
enabled: false,
210+
});
211+
212+
const response = await request(app.getHttpServer())
213+
.patch(`/v2/organizations/${org.id}/delegation-credentials/${delegationCredentialId}`)
214+
.set("Authorization", `Bearer ${apiKey}`)
215+
.send({
216+
enabled: false,
217+
} satisfies UpdateDelegationCredentialInput)
218+
.expect(200);
219+
220+
const responseBody: UpdateDelegationCredentialOutput = response.body;
221+
expect(responseBody.status).toEqual(SUCCESS_STATUS);
222+
expect(responseBody.data.enabled).toEqual(false);
223+
224+
expect(ensureDefaultCalendarsSpy).not.toHaveBeenCalled();
225+
});
226+
227+
it("should not call ensureDefaultCalendars when enabling already enabled delegation credentials", async () => {
228+
await prismaWriteService.prisma.delegationCredential.update({
229+
where: { id: delegationCredentialId },
230+
data: { enabled: true },
231+
});
232+
233+
// Mock toggleDelegationCredentialEnabled to return a valid response
234+
mockToggleDelegationCredentialEnabled.mockResolvedValue({
235+
id: delegationCredentialId,
236+
enabled: true,
237+
});
238+
239+
const response = await request(app.getHttpServer())
240+
.patch(`/v2/organizations/${org.id}/delegation-credentials/${delegationCredentialId}`)
241+
.set("Authorization", `Bearer ${apiKey}`)
242+
.send({
243+
enabled: true,
244+
} satisfies UpdateDelegationCredentialInput)
245+
.expect(200);
246+
247+
const responseBody: UpdateDelegationCredentialOutput = response.body;
248+
expect(responseBody.status).toEqual(SUCCESS_STATUS);
249+
expect(responseBody.data.enabled).toEqual(true);
250+
251+
expect(ensureDefaultCalendarsSpy).not.toHaveBeenCalled();
252+
});
253+
254+
afterAll(async () => {
255+
if (org?.id) {
256+
await prismaWriteService.prisma.delegationCredential.deleteMany({
257+
where: { organizationId: org.id },
258+
});
259+
await organizationsRepositoryFixture.delete(org.id);
260+
}
261+
if (workspacePlatformId) {
262+
await prismaWriteService.prisma.workspacePlatform.delete({
263+
where: { id: workspacePlatformId },
264+
});
265+
}
266+
if (user?.email) {
267+
await userRepositoryFixture.deleteByEmail(user.email);
268+
}
269+
await app.close();
270+
});
271+
});
272+
});

apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
import { TeamService } from "@calcom/platform-libraries";
22
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
3+
import { UpdateOrgMembershipDto } from "../inputs/update-organization-membership.input";
4+
import { OrganizationsMembershipOutputService } from "./organizations-membership-output.service";
35
import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository";
46
import { OrganizationsDelegationCredentialService } from "@/modules/organizations/delegation-credentials/services/organizations-delegation-credential.service";
57
import { CreateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/create-organization-membership.input";
68
import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository";
79
import { OrganizationMembershipOutput } from "@/modules/organizations/memberships/outputs/organization-membership.output";
8-
import { UsersRepository } from "@/modules/users/users.repository";
9-
import { UpdateOrgMembershipDto } from "../inputs/update-organization-membership.input";
10-
import { OrganizationsMembershipOutputService } from "./organizations-membership-output.service";
1110

1211
export const PLATFORM_USER_BEING_ADDED_TO_REGULAR_ORG_ERROR = `Can't add user to organization - the user is platform managed user but organization is not because organization probably was not created using OAuth credentials.`;
1312
export const REGULAR_USER_BEING_ADDED_TO_PLATFORM_ORG_ERROR = `Can't add user to organization - the user is not platform managed user but organization is platform managed. Both have to be created using OAuth credentials.`;
@@ -19,7 +18,6 @@ export class OrganizationsMembershipService {
1918
private readonly organizationsMembershipRepository: OrganizationsMembershipRepository,
2019
private readonly organizationsMembershipOutputService: OrganizationsMembershipOutputService,
2120
private readonly oAuthClientsRepository: OAuthClientRepository,
22-
private readonly usersRepository: UsersRepository,
2321
private readonly delegationCredentialService: OrganizationsDelegationCredentialService
2422
) {}
2523

@@ -127,15 +125,12 @@ export class OrganizationsMembershipService {
127125
await this.canUserBeAddedToOrg(data.userId, organizationId);
128126
const membership = await this.organizationsMembershipRepository.createOrgMembership(organizationId, data);
129127

130-
if (this.delegationCredentialService && this.usersRepository) {
131-
const user = await this.usersRepository.findById(data.userId);
132-
if (user?.email) {
133-
await this.delegationCredentialService.ensureDefaultCalendarsForUser(
134-
organizationId,
135-
data.userId,
136-
user.email
137-
);
138-
}
128+
if (membership.user.email) {
129+
await this.delegationCredentialService.ensureDefaultCalendarsForUser(
130+
organizationId,
131+
data.userId,
132+
membership.user.email
133+
);
139134
}
140135

141136
return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership);

apps/api/v2/test/setEnvVars.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ process.env = {
3737
"BIds0AQJ96xGBjTSMHTOqLBLutQE7Lu32KKdgSdy7A2cS4mKI2cgb3iGkhDJa5Siy-stezyuPm8qpbhmNxdNHMw",
3838
VAPID_PRIVATE_KEY: "6cJtkASCar5sZWguIAW7OjvyixpBw9p8zL8WDDwk9Jk",
3939
CALENDSO_ENCRYPTION_KEY: "22gfxhWUlcKliUeXcu8xNah2+HP/29ZX",
40+
CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY: "ae1ca912d1ff09f1527dae78e84f88b4",
4041
INTEGRATION_TEST_MODE: "true",
4142
e2e: "true",
4243
SLOTS_CACHE_TTL: "1",

docs/api-reference/v2/openapi.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2199,6 +2199,7 @@
21992199
"organization.manageBilling",
22002200
"organization.changeMemberRole",
22012201
"organization.impersonate",
2202+
"organization.passwordReset",
22022203
"organization.update",
22032204
"booking.read",
22042205
"booking.readOrgBookings",
@@ -27480,6 +27481,7 @@
2748027481
"organization.manageBilling",
2748127482
"organization.changeMemberRole",
2748227483
"organization.impersonate",
27484+
"organization.passwordReset",
2748327485
"organization.update",
2748427486
"booking.read",
2748527487
"booking.readOrgBookings",
@@ -27587,6 +27589,7 @@
2758727589
"organization.manageBilling",
2758827590
"organization.changeMemberRole",
2758927591
"organization.impersonate",
27592+
"organization.passwordReset",
2759027593
"organization.update",
2759127594
"booking.read",
2759227595
"booking.readOrgBookings",
@@ -27722,6 +27725,7 @@
2772227725
"organization.manageBilling",
2772327726
"organization.changeMemberRole",
2772427727
"organization.impersonate",
27728+
"organization.passwordReset",
2772527729
"organization.update",
2772627730
"booking.read",
2772727731
"booking.readOrgBookings",
@@ -27828,6 +27832,7 @@
2782827832
"organization.manageBilling",
2782927833
"organization.changeMemberRole",
2783027834
"organization.impersonate",
27835+
"organization.passwordReset",
2783127836
"organization.update",
2783227837
"booking.read",
2783327838
"booking.readOrgBookings",
@@ -31195,8 +31200,6 @@
3119531200
}
3119631201
],
3119731202
"type": "array",
31198-
"minItems": 1,
31199-
"maxItems": 10,
3120031203
"items": {
3120131204
"$ref": "#/components/schemas/Guest"
3120231205
}

packages/features/calendars/lib/tasker/trigger/config.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,16 @@ export const calendarsQueue = queue({
88
});
99

1010
export const calendarsTaskConfig: CalendarsTask = {
11+
machine: "small-2x",
1112
queue: calendarsQueue,
1213
retry: {
1314
maxAttempts: 3,
1415
factor: 2,
15-
minTimeoutInMs: 1000,
16-
maxTimeoutInMs: 10000,
16+
minTimeoutInMs: 60000,
17+
maxTimeoutInMs: 300000,
1718
randomize: true,
19+
outOfMemory: {
20+
machine: "medium-1x",
21+
},
1822
},
1923
};

0 commit comments

Comments
 (0)