From af230f919b4c5a4f958b03879dd561bd528ab6e6 Mon Sep 17 00:00:00 2001 From: Morgan <33722304+ThyMinimalDev@users.noreply.github.com> Date: Wed, 4 Feb 2026 13:08:28 +0200 Subject: [PATCH] fix: ensure default calendars api v2 (#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 * fix: update tasker config to use small-2x machine with outOfMemory retry on medium-1x Co-Authored-By: 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 * ci: add CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY to E2E API v2 workflow Co-Authored-By: morgan@cal.com * fix: add encryption key to E2E test file for delegation credentials Co-Authored-By: morgan@cal.com * revert: remove CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY from workflow (moved to test file) Co-Authored-By: morgan@cal.com * fix: move encryption key to setEnvVars.ts for E2E tests Co-Authored-By: morgan@cal.com * fix: use valid format for service account encryption key Co-Authored-By: morgan@cal.com * fix: encrypt service account key in E2E test for delegation credentials Co-Authored-By: morgan@cal.com * fix: mock updateDelegationCredentialEnabled to bypass Google API call in E2E tests Co-Authored-By: morgan@cal.com * fix: get service from app.get() after initialization for proper spy setup in E2E tests Co-Authored-By: morgan@cal.com * fix: use jest.mock() to mock toggleDelegationCredentialEnabled and bypass Google API calls in E2E tests Co-Authored-By: morgan@cal.com * fix: use Service.prototype pattern for spying on ensureDefaultCalendars in E2E tests Co-Authored-By: morgan@cal.com * fix: move spy setup to beforeAll before app.init() for proper NestJS interception Co-Authored-By: morgan@cal.com --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ...legation-credential.controller.e2e-spec.ts | 272 ++++++++++++++++++ .../organizations-membership.service.ts | 21 +- apps/api/v2/test/setEnvVars.ts | 1 + docs/api-reference/v2/openapi.json | 7 +- .../calendars/lib/tasker/trigger/config.ts | 8 +- 5 files changed, 292 insertions(+), 17 deletions(-) create mode 100644 apps/api/v2/src/modules/organizations/delegation-credentials/organizations-delegation-credential.controller.e2e-spec.ts diff --git a/apps/api/v2/src/modules/organizations/delegation-credentials/organizations-delegation-credential.controller.e2e-spec.ts b/apps/api/v2/src/modules/organizations/delegation-credentials/organizations-delegation-credential.controller.e2e-spec.ts new file mode 100644 index 0000000000..ec6305618e --- /dev/null +++ b/apps/api/v2/src/modules/organizations/delegation-credentials/organizations-delegation-credential.controller.e2e-spec.ts @@ -0,0 +1,272 @@ +import { SUCCESS_STATUS } from "@calcom/platform-constants"; +import { encryptServiceAccountKey } from "@calcom/platform-libraries"; +import type { Team, User } from "@calcom/prisma/client"; + +// Mock the toggleDelegationCredentialEnabled function to bypass Google API calls +const mockToggleDelegationCredentialEnabled = jest.fn(); +jest.mock("@calcom/platform-libraries/app-store", () => { + const actual = jest.requireActual("@calcom/platform-libraries/app-store"); + return { + ...actual, + toggleDelegationCredentialEnabled: (...args: unknown[]) => mockToggleDelegationCredentialEnabled(...args), + }; +}); + +import { INestApplication } from "@nestjs/common"; +import { NestExpressApplication } from "@nestjs/platform-express"; +import { Test } from "@nestjs/testing"; +import request from "supertest"; +import { ApiKeysRepositoryFixture } from "test/fixtures/repository/api-keys.repository.fixture"; +import { PlatformBillingRepositoryFixture } from "test/fixtures/repository/billing.repository.fixture"; +import { MembershipRepositoryFixture } from "test/fixtures/repository/membership.repository.fixture"; +import { OrganizationRepositoryFixture } from "test/fixtures/repository/organization.repository.fixture"; +import { ProfileRepositoryFixture } from "test/fixtures/repository/profiles.repository.fixture"; +import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture"; +import { randomString } from "test/utils/randomString"; +import { AppModule } from "@/app.module"; +import { bootstrap } from "@/bootstrap"; +import { PrismaWriteService } from "@/modules/prisma/prisma-write.service"; +import { OrganizationsDelegationCredentialService } from "@/modules/organizations/delegation-credentials/services/organizations-delegation-credential.service"; +import { PrismaModule } from "@/modules/prisma/prisma.module"; +import { TokensModule } from "@/modules/tokens/tokens.module"; +import { UsersModule } from "@/modules/users/users.module"; +import { UpdateDelegationCredentialInput } from "@/modules/organizations/delegation-credentials/inputs/update-delegation-credential.input"; +import { UpdateDelegationCredentialOutput } from "@/modules/organizations/delegation-credentials/outputs/update-delegation-credential.output"; + +describe("Organizations Delegation Credentials Endpoints", () => { + describe("User Authentication - User is Org Admin", () => { + let app: INestApplication; + + let userRepositoryFixture: UserRepositoryFixture; + let organizationsRepositoryFixture: OrganizationRepositoryFixture; + let membershipRepositoryFixture: MembershipRepositoryFixture; + let platformBillingRepositoryFixture: PlatformBillingRepositoryFixture; + let apiKeysRepositoryFixture: ApiKeysRepositoryFixture; + let profilesRepositoryFixture: ProfileRepositoryFixture; + let prismaWriteService: PrismaWriteService; + + let org: Team; + let user: User; + let apiKey: string; + let delegationCredentialId: string; + let workspacePlatformId: number; + let ensureDefaultCalendarsSpy: jest.SpyInstance; + + const userEmail = `delegation-credentials-admin-${randomString()}@api.com`; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [AppModule, PrismaModule, UsersModule, TokensModule], + }).compile(); + + userRepositoryFixture = new UserRepositoryFixture(moduleRef); + organizationsRepositoryFixture = new OrganizationRepositoryFixture(moduleRef); + membershipRepositoryFixture = new MembershipRepositoryFixture(moduleRef); + platformBillingRepositoryFixture = new PlatformBillingRepositoryFixture(moduleRef); + apiKeysRepositoryFixture = new ApiKeysRepositoryFixture(moduleRef); + profilesRepositoryFixture = new ProfileRepositoryFixture(moduleRef); + prismaWriteService = moduleRef.get(PrismaWriteService); + + user = await userRepositoryFixture.create({ + email: userEmail, + username: userEmail, + }); + + org = await organizationsRepositoryFixture.create({ + name: `delegation-credentials-organization-${randomString()}`, + isOrganization: true, + isPlatform: true, + }); + + await profilesRepositoryFixture.create({ + uid: `${randomString()}-uid`, + username: userEmail, + user: { connect: { id: user.id } }, + organization: { connect: { id: org.id } }, + movedFromUser: { connect: { id: user.id } }, + }); + + await platformBillingRepositoryFixture.create(org.id, "SCALE"); + + await membershipRepositoryFixture.create({ + role: "ADMIN", + user: { connect: { id: user.id } }, + team: { connect: { id: org.id } }, + accepted: true, + }); + + const { keyString } = await apiKeysRepositoryFixture.createApiKey(user.id, null, org.id); + apiKey = `cal_test_${keyString}`; + + const workspacePlatform = await prismaWriteService.prisma.workspacePlatform.create({ + data: { + slug: "google", + name: "Google Workspace", + description: "Google Workspace for testing", + defaultServiceAccountKey: { + type: "service_account", + project_id: "test-project", + private_key_id: "test-key-id", + private_key: "test-private-key", + client_email: "test@test-project.iam.gserviceaccount.com", + client_id: "123456789", + auth_uri: "https://accounts.google.com/o/oauth2/auth", + token_uri: "https://oauth2.googleapis.com/token", + auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs", + client_x509_cert_url: "https://www.googleapis.com/robot/v1/metadata/x509/test", + }, + enabled: true, + }, + }); + workspacePlatformId = workspacePlatform.id; + + const testServiceAccountKey = { + type: "service_account" as const, + project_id: "test-project", + private_key_id: "test-key-id", + private_key: "test-private-key", + client_email: "test@test-project.iam.gserviceaccount.com", + client_id: "123456789", + auth_uri: "https://accounts.google.com/o/oauth2/auth", + token_uri: "https://oauth2.googleapis.com/token", + auth_provider_x509_cert_url: "https://www.googleapis.com/oauth2/v1/certs", + client_x509_cert_url: "https://www.googleapis.com/robot/v1/metadata/x509/test", + }; + + const encryptedServiceAccountKey = encryptServiceAccountKey(testServiceAccountKey); + + const delegationCredential = await prismaWriteService.prisma.delegationCredential.create({ + data: { + workspacePlatformId: workspacePlatform.id, + organizationId: org.id, + domain: "@test-domain.com", + serviceAccountKey: encryptedServiceAccountKey, + enabled: false, + }, + }); + delegationCredentialId = delegationCredential.id; + + // Set up spy on prototype BEFORE app.init() - this is critical for NestJS + ensureDefaultCalendarsSpy = jest + .spyOn(OrganizationsDelegationCredentialService.prototype, "ensureDefaultCalendars") + .mockResolvedValue(undefined); + + app = moduleRef.createNestApplication(); + bootstrap(app as NestExpressApplication); + + await app.init(); + }); + + afterEach(() => { + // Clear the spy call history after each test + ensureDefaultCalendarsSpy.mockClear(); + mockToggleDelegationCredentialEnabled.mockClear(); + }); + + it("should be defined", () => { + expect(userRepositoryFixture).toBeDefined(); + expect(organizationsRepositoryFixture).toBeDefined(); + expect(user).toBeDefined(); + expect(org).toBeDefined(); + }); + + it("should call ensureDefaultCalendars when enabling delegation credentials", async () => { + await prismaWriteService.prisma.delegationCredential.update({ + where: { id: delegationCredentialId }, + data: { enabled: false }, + }); + + // Mock toggleDelegationCredentialEnabled to return a valid response + mockToggleDelegationCredentialEnabled.mockResolvedValue({ + id: delegationCredentialId, + enabled: true, + }); + + const response = await request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/delegation-credentials/${delegationCredentialId}`) + .set("Authorization", `Bearer ${apiKey}`) + .send({ + enabled: true, + } satisfies UpdateDelegationCredentialInput) + .expect(200); + + const responseBody: UpdateDelegationCredentialOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.enabled).toEqual(true); + + expect(ensureDefaultCalendarsSpy).toHaveBeenCalledWith(org.id, "@test-domain.com"); + }); + + it("should not call ensureDefaultCalendars when disabling delegation credentials", async () => { + await prismaWriteService.prisma.delegationCredential.update({ + where: { id: delegationCredentialId }, + data: { enabled: true }, + }); + + // Mock toggleDelegationCredentialEnabled to return a valid response + mockToggleDelegationCredentialEnabled.mockResolvedValue({ + id: delegationCredentialId, + enabled: false, + }); + + const response = await request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/delegation-credentials/${delegationCredentialId}`) + .set("Authorization", `Bearer ${apiKey}`) + .send({ + enabled: false, + } satisfies UpdateDelegationCredentialInput) + .expect(200); + + const responseBody: UpdateDelegationCredentialOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.enabled).toEqual(false); + + expect(ensureDefaultCalendarsSpy).not.toHaveBeenCalled(); + }); + + it("should not call ensureDefaultCalendars when enabling already enabled delegation credentials", async () => { + await prismaWriteService.prisma.delegationCredential.update({ + where: { id: delegationCredentialId }, + data: { enabled: true }, + }); + + // Mock toggleDelegationCredentialEnabled to return a valid response + mockToggleDelegationCredentialEnabled.mockResolvedValue({ + id: delegationCredentialId, + enabled: true, + }); + + const response = await request(app.getHttpServer()) + .patch(`/v2/organizations/${org.id}/delegation-credentials/${delegationCredentialId}`) + .set("Authorization", `Bearer ${apiKey}`) + .send({ + enabled: true, + } satisfies UpdateDelegationCredentialInput) + .expect(200); + + const responseBody: UpdateDelegationCredentialOutput = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data.enabled).toEqual(true); + + expect(ensureDefaultCalendarsSpy).not.toHaveBeenCalled(); + }); + + afterAll(async () => { + if (org?.id) { + await prismaWriteService.prisma.delegationCredential.deleteMany({ + where: { organizationId: org.id }, + }); + await organizationsRepositoryFixture.delete(org.id); + } + if (workspacePlatformId) { + await prismaWriteService.prisma.workspacePlatform.delete({ + where: { id: workspacePlatformId }, + }); + } + if (user?.email) { + await userRepositoryFixture.deleteByEmail(user.email); + } + await app.close(); + }); + }); +}); diff --git a/apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts b/apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts index 4208099944..a1f614dbc1 100644 --- a/apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts +++ b/apps/api/v2/src/modules/organizations/memberships/services/organizations-membership.service.ts @@ -1,13 +1,12 @@ import { TeamService } from "@calcom/platform-libraries"; import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { UpdateOrgMembershipDto } from "../inputs/update-organization-membership.input"; +import { OrganizationsMembershipOutputService } from "./organizations-membership-output.service"; import { OAuthClientRepository } from "@/modules/oauth-clients/oauth-client.repository"; import { OrganizationsDelegationCredentialService } from "@/modules/organizations/delegation-credentials/services/organizations-delegation-credential.service"; import { CreateOrgMembershipDto } from "@/modules/organizations/memberships/inputs/create-organization-membership.input"; import { OrganizationsMembershipRepository } from "@/modules/organizations/memberships/organizations-membership.repository"; import { OrganizationMembershipOutput } from "@/modules/organizations/memberships/outputs/organization-membership.output"; -import { UsersRepository } from "@/modules/users/users.repository"; -import { UpdateOrgMembershipDto } from "../inputs/update-organization-membership.input"; -import { OrganizationsMembershipOutputService } from "./organizations-membership-output.service"; 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.`; 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 { private readonly organizationsMembershipRepository: OrganizationsMembershipRepository, private readonly organizationsMembershipOutputService: OrganizationsMembershipOutputService, private readonly oAuthClientsRepository: OAuthClientRepository, - private readonly usersRepository: UsersRepository, private readonly delegationCredentialService: OrganizationsDelegationCredentialService ) {} @@ -127,15 +125,12 @@ export class OrganizationsMembershipService { await this.canUserBeAddedToOrg(data.userId, organizationId); const membership = await this.organizationsMembershipRepository.createOrgMembership(organizationId, data); - if (this.delegationCredentialService && this.usersRepository) { - const user = await this.usersRepository.findById(data.userId); - if (user?.email) { - await this.delegationCredentialService.ensureDefaultCalendarsForUser( - organizationId, - data.userId, - user.email - ); - } + if (membership.user.email) { + await this.delegationCredentialService.ensureDefaultCalendarsForUser( + organizationId, + data.userId, + membership.user.email + ); } return this.organizationsMembershipOutputService.getOrgMembershipOutput(membership); diff --git a/apps/api/v2/test/setEnvVars.ts b/apps/api/v2/test/setEnvVars.ts index e2a1295c86..cb25ec4b5b 100644 --- a/apps/api/v2/test/setEnvVars.ts +++ b/apps/api/v2/test/setEnvVars.ts @@ -37,6 +37,7 @@ process.env = { "BIds0AQJ96xGBjTSMHTOqLBLutQE7Lu32KKdgSdy7A2cS4mKI2cgb3iGkhDJa5Siy-stezyuPm8qpbhmNxdNHMw", VAPID_PRIVATE_KEY: "6cJtkASCar5sZWguIAW7OjvyixpBw9p8zL8WDDwk9Jk", CALENDSO_ENCRYPTION_KEY: "22gfxhWUlcKliUeXcu8xNah2+HP/29ZX", + CALCOM_SERVICE_ACCOUNT_ENCRYPTION_KEY: "ae1ca912d1ff09f1527dae78e84f88b4", INTEGRATION_TEST_MODE: "true", e2e: "true", SLOTS_CACHE_TTL: "1", diff --git a/docs/api-reference/v2/openapi.json b/docs/api-reference/v2/openapi.json index f731e3cddd..99249ff663 100644 --- a/docs/api-reference/v2/openapi.json +++ b/docs/api-reference/v2/openapi.json @@ -2199,6 +2199,7 @@ "organization.manageBilling", "organization.changeMemberRole", "organization.impersonate", + "organization.passwordReset", "organization.update", "booking.read", "booking.readOrgBookings", @@ -27480,6 +27481,7 @@ "organization.manageBilling", "organization.changeMemberRole", "organization.impersonate", + "organization.passwordReset", "organization.update", "booking.read", "booking.readOrgBookings", @@ -27587,6 +27589,7 @@ "organization.manageBilling", "organization.changeMemberRole", "organization.impersonate", + "organization.passwordReset", "organization.update", "booking.read", "booking.readOrgBookings", @@ -27722,6 +27725,7 @@ "organization.manageBilling", "organization.changeMemberRole", "organization.impersonate", + "organization.passwordReset", "organization.update", "booking.read", "booking.readOrgBookings", @@ -27828,6 +27832,7 @@ "organization.manageBilling", "organization.changeMemberRole", "organization.impersonate", + "organization.passwordReset", "organization.update", "booking.read", "booking.readOrgBookings", @@ -31195,8 +31200,6 @@ } ], "type": "array", - "minItems": 1, - "maxItems": 10, "items": { "$ref": "#/components/schemas/Guest" } diff --git a/packages/features/calendars/lib/tasker/trigger/config.ts b/packages/features/calendars/lib/tasker/trigger/config.ts index a53989a963..dc5a5a4d31 100644 --- a/packages/features/calendars/lib/tasker/trigger/config.ts +++ b/packages/features/calendars/lib/tasker/trigger/config.ts @@ -8,12 +8,16 @@ export const calendarsQueue = queue({ }); export const calendarsTaskConfig: CalendarsTask = { + machine: "small-2x", queue: calendarsQueue, retry: { maxAttempts: 3, factor: 2, - minTimeoutInMs: 1000, - maxTimeoutInMs: 10000, + minTimeoutInMs: 60000, + maxTimeoutInMs: 300000, randomize: true, + outOfMemory: { + machine: "medium-1x", + }, }, };