diff --git a/packages/app-store/_utils/oauth/updateProfilePhotoGoogle.test.ts b/packages/app-store/_utils/oauth/updateProfilePhotoGoogle.test.ts new file mode 100644 index 0000000000..a2cf7819f1 --- /dev/null +++ b/packages/app-store/_utils/oauth/updateProfilePhotoGoogle.test.ts @@ -0,0 +1,114 @@ +import { oauth2_v2 } from "@googleapis/oauth2"; +import type { OAuth2Client } from "googleapis-common"; +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import logger from "@calcom/lib/logger"; +import { uploadAvatar } from "@calcom/lib/server/avatar"; +import { UserRepository } from "@calcom/lib/server/repository/user"; +import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image"; + +import { updateProfilePhotoGoogle } from "./updateProfilePhotoGoogle"; + +vi.mock("@googleapis/oauth2", () => ({ + oauth2_v2: { + Oauth2: vi.fn(), + }, +})); + +vi.mock("@calcom/lib/logger", () => ({ + default: { + error: vi.fn(), + }, +})); + +vi.mock("@calcom/lib/server/avatar", () => ({ + uploadAvatar: vi.fn(), +})); + +vi.mock("@calcom/lib/server/resizeBase64Image", () => ({ + resizeBase64Image: vi.fn(), +})); + +vi.mock("@calcom/lib/server/repository/user", () => ({ + UserRepository: { + updateAvatar: vi.fn(), + }, +})); + +describe("updateProfilePhotoGoogle", () => { + const mockOAuth2Client = {} as OAuth2Client; + const userId = 123; + + let mockOauth2Instance: any; + + beforeEach(() => { + vi.clearAllMocks(); + + mockOauth2Instance = { + userinfo: { + get: vi.fn(), + }, + }; + + (oauth2_v2.Oauth2 as any).mockImplementation(() => mockOauth2Instance); + (uploadAvatar as any).mockResolvedValue("/api/avatar/processed-123.png"); + (resizeBase64Image as any).mockResolvedValue("data:image/png;base64,resized-data"); + (UserRepository.updateAvatar as any).mockResolvedValue(undefined); + }); + + it("should update avatar with valid URL", async () => { + const validUrl = "https://example.com/avatar.jpg"; + mockOauth2Instance.userinfo.get.mockResolvedValue({ + data: { picture: validUrl }, + }); + + await updateProfilePhotoGoogle(mockOAuth2Client, userId); + + expect(UserRepository.updateAvatar).toHaveBeenCalledWith({ + id: userId, + avatarUrl: validUrl, + }); + expect(uploadAvatar).not.toHaveBeenCalled(); + }); + + it("should process base64 data", async () => { + const base64Data = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; + mockOauth2Instance.userinfo.get.mockResolvedValue({ + data: { picture: base64Data }, + }); + + await updateProfilePhotoGoogle(mockOAuth2Client, userId); + + expect(resizeBase64Image).toHaveBeenCalledWith(base64Data); + expect(uploadAvatar).toHaveBeenCalledWith({ + avatar: "data:image/png;base64,resized-data", + userId, + }); + expect(UserRepository.updateAvatar).toHaveBeenCalledWith({ + id: userId, + avatarUrl: "/api/avatar/processed-123.png", + }); + }); + + it("should do nothing when no picture provided", async () => { + mockOauth2Instance.userinfo.get.mockResolvedValue({ + data: {}, + }); + + await updateProfilePhotoGoogle(mockOAuth2Client, userId); + + expect(UserRepository.updateAvatar).not.toHaveBeenCalled(); + expect(uploadAvatar).not.toHaveBeenCalled(); + }); + + it("should handle errors gracefully", async () => { + const error = new Error("OAuth API failed"); + mockOauth2Instance.userinfo.get.mockRejectedValue(error); + + await updateProfilePhotoGoogle(mockOAuth2Client, userId); + + expect(logger.error).toHaveBeenCalledWith("Error updating avatarUrl from google calendar connect", error); + expect(UserRepository.updateAvatar).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/app-store/_utils/oauth/updateProfilePhotoGoogle.ts b/packages/app-store/_utils/oauth/updateProfilePhotoGoogle.ts index 40b81b597e..2773e4ae80 100644 --- a/packages/app-store/_utils/oauth/updateProfilePhotoGoogle.ts +++ b/packages/app-store/_utils/oauth/updateProfilePhotoGoogle.ts @@ -1,6 +1,7 @@ import { oauth2_v2 } from "@googleapis/oauth2"; import type { OAuth2Client } from "googleapis-common"; +import { isBase64Image } from "@calcom/lib/isBase64Image"; import logger from "@calcom/lib/logger"; import { uploadAvatar } from "@calcom/lib/server/avatar"; import { UserRepository } from "@calcom/lib/server/repository/user"; @@ -16,11 +17,7 @@ export async function updateProfilePhotoGoogle(oAuth2Client: OAuth2Client, userI } // Handle base64 data - if ( - avatarUrl.startsWith("data:image/png;base64,") || - avatarUrl.startsWith("data:image/jpeg;base64,") || - avatarUrl.startsWith("data:image/jpg;base64,") - ) { + if (isBase64Image(avatarUrl)) { const resizedAvatarUrl = await uploadAvatar({ avatar: await resizeBase64Image(avatarUrl), userId, diff --git a/packages/lib/isBase64Image.ts b/packages/lib/isBase64Image.ts new file mode 100644 index 0000000000..1fdb482b3d --- /dev/null +++ b/packages/lib/isBase64Image.ts @@ -0,0 +1,12 @@ +/** + * Checks if a string is a supported base64 image format + */ +export function isBase64Image(input: string | null | undefined): boolean { + if (!input) return false; + + return ( + input.startsWith("data:image/png;base64,") || + input.startsWith("data:image/jpeg;base64,") || + input.startsWith("data:image/jpg;base64,") + ); +} diff --git a/packages/trpc/server/routers/viewer/me/updateProfile.handler.ts b/packages/trpc/server/routers/viewer/me/updateProfile.handler.ts index 1d93cdfade..7a00418512 100644 --- a/packages/trpc/server/routers/viewer/me/updateProfile.handler.ts +++ b/packages/trpc/server/routers/viewer/me/updateProfile.handler.ts @@ -9,6 +9,7 @@ import { StripeBillingService } from "@calcom/features/ee/billing/stripe-billlin import { FeaturesRepository } from "@calcom/features/flags/features.repository"; import hasKeyInMetadata from "@calcom/lib/hasKeyInMetadata"; import { HttpError } from "@calcom/lib/http-error"; +import { isBase64Image } from "@calcom/lib/isBase64Image"; import logger from "@calcom/lib/logger"; import { uploadAvatar } from "@calcom/lib/server/avatar"; import { checkUsername } from "@calcom/lib/server/checkUsername"; @@ -152,12 +153,7 @@ export const updateProfileHandler = async ({ ctx, input }: UpdateProfileOptions) } // if defined AND a base 64 string, upload and update the avatar URL - if ( - input.avatarUrl && - (input.avatarUrl.startsWith("data:image/png;base64,") || - input.avatarUrl.startsWith("data:image/jpeg;base64,") || - input.avatarUrl.startsWith("data:image/jpg;base64,")) - ) { + if (input.avatarUrl && isBase64Image(input.avatarUrl)) { data.avatarUrl = await uploadAvatar({ avatar: await resizeBase64Image(input.avatarUrl), userId: user.id, diff --git a/packages/trpc/server/routers/viewer/organizations/update.handler.ts b/packages/trpc/server/routers/viewer/organizations/update.handler.ts index 5f1beff0e0..98460451ea 100644 --- a/packages/trpc/server/routers/viewer/organizations/update.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/update.handler.ts @@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client"; import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; import { getMetadataHelpers } from "@calcom/lib/getMetadataHelpers"; +import { isBase64Image } from "@calcom/lib/isBase64Image"; import { uploadLogo } from "@calcom/lib/server/avatar"; import { isOrganisationAdmin } from "@calcom/lib/server/queries/organisations"; import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image"; @@ -161,12 +162,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { metadata: mergeMetadata({ ...input.metadata }), }; - if ( - input.banner && - (input.banner.startsWith("data:image/png;base64,") || - input.banner.startsWith("data:image/jpeg;base64,") || - input.banner.startsWith("data:image/jpg;base64,")) - ) { + if (input.banner && isBase64Image(input.banner)) { const banner = await resizeBase64Image(input.banner, { maxSize: 1500 }); data.bannerUrl = await uploadLogo({ logo: banner, @@ -177,12 +173,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { data.bannerUrl = null; } - if ( - input.logoUrl && - (input.logoUrl.startsWith("data:image/png;base64,") || - input.logoUrl.startsWith("data:image/jpeg;base64,") || - input.logoUrl.startsWith("data:image/jpg;base64,")) - ) { + if (input.logoUrl && isBase64Image(input.logoUrl)) { data.logoUrl = await uploadLogo({ logo: await resizeBase64Image(input.logoUrl), teamId: currentOrgId, diff --git a/packages/trpc/server/routers/viewer/organizations/updateUser.handler.ts b/packages/trpc/server/routers/viewer/organizations/updateUser.handler.ts index ae8d6f4171..c0d49b7ea2 100644 --- a/packages/trpc/server/routers/viewer/organizations/updateUser.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/updateUser.handler.ts @@ -2,6 +2,7 @@ import type { Prisma, PrismaPromise, User, Membership, Profile } from "@prisma/c import { ensureOrganizationIsReviewed } from "@calcom/ee/organizations/lib/ensureOrganizationIsReviewed"; import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner"; +import { isBase64Image } from "@calcom/lib/isBase64Image"; import { uploadAvatar } from "@calcom/lib/server/avatar"; import { checkRegularUsername } from "@calcom/lib/server/checkRegularUsername"; import { isOrganisationAdmin, isOrganisationOwner } from "@calcom/lib/server/queries/organisations"; @@ -119,12 +120,7 @@ export const updateUserHandler = async ({ ctx, input }: UpdateUserOptions) => { timeZone: input.timeZone, }; - if ( - input.avatar && - (input.avatar.startsWith("data:image/png;base64,") || - input.avatar.startsWith("data:image/jpeg;base64,") || - input.avatar.startsWith("data:image/jpg;base64,")) - ) { + if (input.avatar && isBase64Image(input.avatar)) { const avatar = await resizeBase64Image(input.avatar); data.avatarUrl = await uploadAvatar({ avatar, diff --git a/packages/trpc/server/routers/viewer/teams/create.handler.ts b/packages/trpc/server/routers/viewer/teams/create.handler.ts index 96f0e551cc..1313d4b4d3 100644 --- a/packages/trpc/server/routers/viewer/teams/create.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/create.handler.ts @@ -1,5 +1,6 @@ import { generateTeamCheckoutSession } from "@calcom/features/ee/teams/lib/payments"; import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants"; +import { isBase64Image } from "@calcom/lib/isBase64Image"; import { uploadLogo } from "@calcom/lib/server/avatar"; import { ProfileRepository } from "@calcom/lib/server/repository/profile"; import { resizeBase64Image } from "@calcom/lib/server/resizeBase64Image"; @@ -106,12 +107,7 @@ export const createHandler = async ({ ctx, input }: CreateOptions) => { }, }); // Upload logo, create doesn't allow logo removal - if ( - input.logo && - (input.logo.startsWith("data:image/png;base64,") || - input.logo.startsWith("data:image/jpeg;base64,") || - input.logo.startsWith("data:image/jpg;base64,")) - ) { + if (input.logo && isBase64Image(input.logo)) { const logoUrl = await uploadLogo({ logo: await resizeBase64Image(input.logo), teamId: createdTeam.id, diff --git a/packages/trpc/server/routers/viewer/teams/update.handler.ts b/packages/trpc/server/routers/viewer/teams/update.handler.ts index 75dd5d92f1..aa08d7c4a2 100644 --- a/packages/trpc/server/routers/viewer/teams/update.handler.ts +++ b/packages/trpc/server/routers/viewer/teams/update.handler.ts @@ -4,6 +4,7 @@ import { getOrgFullOrigin } from "@calcom/ee/organizations/lib/orgDomains"; import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema"; import { validateIntervalLimitOrder } from "@calcom/lib/intervalLimits/validateIntervalLimitOrder"; +import { isBase64Image } from "@calcom/lib/isBase64Image"; import { uploadLogo } from "@calcom/lib/server/avatar"; import { isTeamAdmin } from "@calcom/lib/server/queries/teams"; import { prisma } from "@calcom/prisma"; @@ -70,12 +71,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => { rrTimestampBasis: input.rrTimestampBasis, }; - if ( - input.logo && - (input.logo.startsWith("data:image/png;base64,") || - input.logo.startsWith("data:image/jpeg;base64,") || - input.logo.startsWith("data:image/jpg;base64,")) - ) { + if (input.logo && isBase64Image(input.logo)) { data.logoUrl = await uploadLogo({ teamId: input.id, logo: input.logo }); } else if (typeof input.logo !== "undefined" && !input.logo) { data.logoUrl = null;