Files
calendar/packages/features/auth/lib/next-auth-options.test.ts
T
Anik Dhabal BabuGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>bot_apk
f86767c125 fix: correct admin password banner message and auto-sign-out after 2FA enable (#28129)
* fix: correct admin password banner message to require both password length and 2FA

The banner message incorrectly used 'or' implying only one condition was needed,
but the code requires BOTH a password of at least 15 characters AND 2FA enabled.

Updated the message to clearly state both requirements and added a hint that
users need to log out and log back in after updating their security settings.

Fixes #9527

Co-Authored-By: unknown <>

* fix: auto-sign-out INACTIVE_ADMIN users after enabling 2FA

When an INACTIVE_ADMIN user enables 2FA, automatically sign them out so
they can log back in with refreshed session role, dismissing the banner.
This matches the existing behavior for password changes.

Co-Authored-By: unknown <>

* feat: add dynamic admin banner message based on inactiveAdminReason

Co-Authored-By: unknown <>

* Remove and add various localization strings

* Update common.json

* Add cookie consent checkbox message and remove entries

* test: add unit tests for AdminPasswordBanner and inactiveAdminReason logic

Co-Authored-By: unknown <>

* fix: add expires field to session mock to fix type check

Co-Authored-By: unknown <>

* fix: wrap CALENDSO_ENCRYPTION_KEY mutations in try/finally to prevent env state leaks

Addresses Cubic AI review feedback (confidence 9/10): when a test fails
early, the CALENDSO_ENCRYPTION_KEY env var was not being restored, which
could leak state into subsequent tests. Wrapped the env mutation in
try/finally blocks to guarantee cleanup.

Co-Authored-By: bot_apk <apk@cognition.ai>

* fix: add missing 'expires' field to buildSession in AdminPasswordBanner test

The Session type requires 'expires' to be a string, but buildSession was
not providing it, causing a type error caught by CI type-check.

Co-Authored-By: bot_apk <apk@cognition.ai>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: bot_apk <apk@cognition.ai>
2026-03-10 12:46:31 -03:00

1412 lines
44 KiB
TypeScript

import { IdentityProvider, UserPermissionRole } from "@calcom/prisma/enums";
import type { User } from "next-auth";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ErrorCode } from "./ErrorCode";
// Mock dependencies
vi.mock("@calcom/prisma", () => ({
prisma: {
user: {
update: vi.fn(),
},
},
default: {
user: {
update: vi.fn(),
},
},
}));
const mockFindByEmailAndIncludeProfilesAndPassword = vi.fn();
vi.mock("@calcom/features/users/repositories/UserRepository", () => {
return {
UserRepository: vi.fn().mockImplementation(function () {
return {
findByEmailAndIncludeProfilesAndPassword: mockFindByEmailAndIncludeProfilesAndPassword,
};
}),
};
});
vi.mock("./verifyPassword", () => ({
verifyPassword: vi.fn(),
}));
vi.mock("@calcom/lib/checkRateLimitAndThrowError", () => ({
checkRateLimitAndThrowError: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@calcom/lib/server/PiiHasher", () => ({
hashEmail: vi.fn((email: string) => `hashed_${email}`),
}));
vi.mock("@calcom/lib/totp", () => ({
totpAuthenticatorCheck: vi.fn(),
}));
vi.mock("@calcom/lib/crypto", () => ({
symmetricDecrypt: vi.fn(),
symmetricEncrypt: vi.fn(),
}));
vi.mock("@calcom/lib/auth/isPasswordValid", () => ({
isPasswordValid: vi.fn(),
}));
vi.mock("@calcom/lib/env", () => ({
isENVDev: false,
}));
vi.mock("@calcom/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@calcom/lib/constants")>();
return {
...actual,
IS_TEAM_BILLING_ENABLED: false,
ENABLE_PROFILE_SWITCHER: false,
WEBAPP_URL: "http://localhost:3000",
};
});
vi.mock("@calcom/lib/logger", () => ({
default: {
getSubLogger: vi.fn(() => ({
debug: vi.fn(),
error: vi.fn(),
info: vi.fn(),
})),
},
}));
vi.mock("@calcom/lib/safeStringify", () => ({
safeStringify: vi.fn((obj) => JSON.stringify(obj)),
}));
vi.mock("./next-auth-custom-adapter", () => ({
default: vi.fn(() => ({
linkAccount: vi.fn(),
})),
}));
vi.mock("@calcom/i18n/server", () => ({
getTranslation: vi.fn().mockResolvedValue((key: string) => key),
}));
vi.mock("@calcom/features/profile/repositories/ProfileRepository", () => ({
ProfileRepository: {
findAllProfilesForUserIncludingMovedUser: vi.fn(),
findByUpIdWithAuth: vi.fn(),
},
}));
// Additional mocks for signIn/JWT callback tests
const mockCredentialRepoFindFirst = vi.fn();
const mockCredentialRepoFindFirstByUserIdAndType = vi.fn();
const mockCredentialRepoCreate = vi.fn();
const mockBuildCredentialCreateData = vi.fn();
const mockUpdateProfilePhotoMicrosoft = vi.fn();
const mockUpdateProfilePhotoGoogle = vi.fn();
const mockGetIdentityProvider = vi.fn();
const mockGetBillingProviderService = vi.fn();
const mockHostedCalRef = { value: false };
const mockValidateSamlAccountConversion = vi.fn();
const mockWaitUntil = vi.fn();
const mockPrismaUserFindFirst = vi.fn();
const mockPrismaUserCreate = vi.fn();
const mockPrismaUserUpdate = vi.fn();
const mockPrismaTeamFindFirst = vi.fn();
const mockPrismaSelectedCalendarCreate = vi.fn();
const mockLinkAccount = vi.fn();
vi.mock("@calcom/features/credentials/repositories/CredentialRepository", () => ({
CredentialRepository: {
findFirstByAppIdAndUserId: (...args: unknown[]) => mockCredentialRepoFindFirst(...args),
findFirstByUserIdAndType: (...args: unknown[]) => mockCredentialRepoFindFirstByUserIdAndType(...args),
create: (...args: unknown[]) => mockCredentialRepoCreate(...args),
},
}));
vi.mock("@calcom/features/credentials/services/CredentialDataService", () => ({
buildCredentialCreateData: (...args: unknown[]) => mockBuildCredentialCreateData(...args),
}));
vi.mock("@calcom/app-store/_utils/oauth/updateProfilePhotoMicrosoft", () => ({
updateProfilePhotoMicrosoft: (...args: unknown[]) => mockUpdateProfilePhotoMicrosoft(...args),
}));
vi.mock("@calcom/app-store/_utils/oauth/updateProfilePhotoGoogle", () => ({
updateProfilePhotoGoogle: (...args: unknown[]) => mockUpdateProfilePhotoGoogle(...args),
}));
vi.mock("@calcom/features/auth/lib/identityProviders", () => ({
getIdentityProvider: (...args: unknown[]) => mockGetIdentityProvider(...args),
}));
vi.mock("@calcom/features/auth/lib/outlook", () => ({
OUTLOOK_CLIENT_ID: "mock-client-id",
OUTLOOK_CLIENT_SECRET: "mock-client-secret",
OUTLOOK_LOGIN_ENABLED: true,
}));
vi.mock("@calcom/features/ee/billing/di/containers/Billing", () => ({
getBillingProviderService: (...args: unknown[]) => mockGetBillingProviderService(...args),
}));
vi.mock("@calcom/features/auth/lib/samlAccountLinking", () => ({
validateSamlAccountConversion: (...args: unknown[]) => mockValidateSamlAccountConversion(...args),
}));
vi.mock("@calcom/features/ee/sso/lib/saml", () => ({
clientSecretVerifier: "mock-secret",
get hostedCal() {
return mockHostedCalRef.value;
},
isSAMLLoginEnabled: false,
}));
vi.mock("@calcom/features/ee/organizations/lib/orgDomains", () => ({
getOrgFullOrigin: vi.fn((slug: string) => `https://${slug}.cal.com`),
subdomainSuffix: vi.fn(() => ".cal.com"),
}));
vi.mock("@vercel/functions", () => ({
waitUntil: (...args: unknown[]) => mockWaitUntil(...args),
}));
vi.mock("@calcom/features/ee/organizations/di/OrganizationRepository.container", () => ({
getOrganizationRepository: vi.fn(() => ({})),
}));
vi.mock("@calcom/features/ee/deployment/repositories/DeploymentRepository", () => ({
DeploymentRepository: vi.fn().mockImplementation(() => ({})),
}));
vi.mock("@calcom/features/ee/impersonation/lib/ImpersonationProvider", () => ({
default: vi.fn(),
}));
vi.mock("@calcom/features/ee/dsync/lib/users/createUsersAndConnectToOrg", () => ({
default: vi.fn(),
}));
vi.mock("@calcom/lib/default-cookies", () => ({
defaultCookies: vi.fn(() => ({})),
}));
vi.mock("@calcom/lib/random", () => ({
randomString: vi.fn(() => "abc123"),
}));
vi.mock("@calcom/lib/slugify", () => ({
default: vi.fn((s: string) => s.toLowerCase().replace(/\s+/g, "-")),
}));
vi.mock("@calcom/app-store/googlecalendar/lib/CalendarService", () => ({
createGoogleCalendarServiceWithGoogleType: vi.fn(),
}));
vi.mock("googleapis-common", () => ({
OAuth2Client: vi.fn(),
}));
vi.mock("@googleapis/calendar", () => ({
calendar_v3: {
Calendar: vi.fn(),
},
}));
vi.mock("next-auth/jwt", () => ({
encode: vi.fn(),
}));
vi.mock("./signJwt", () => ({
default: vi.fn().mockResolvedValue("mock-jwt"),
}));
vi.mock("./dub", () => ({
dub: { track: { lead: vi.fn() } },
}));
vi.mock("../signup/utils/getOrgUsernameFromEmail", () => ({
getOrgUsernameFromEmail: vi.fn((email: string) => email.split("@")[0]),
}));
vi.mock("@calcom/ee/common/server/LicenseKeyService", () => ({
LicenseKeySingleton: {
getInstance: vi.fn().mockResolvedValue({
checkLicense: vi.fn().mockResolvedValue(true),
}),
},
}));
vi.mock("next-auth/providers/azure-ad", () => ({ default: vi.fn() }));
vi.mock("next-auth/providers/credentials", () => ({ default: vi.fn(() => ({ id: "credentials" })) }));
vi.mock("next-auth/providers/email", () => ({ default: vi.fn() }));
vi.mock("next-auth/providers/google", () => ({ default: vi.fn() }));
describe("CredentialsProvider authorize", () => {
let authorizeCredentials: typeof import("./next-auth-options").authorizeCredentials;
let verifyPassword: any;
beforeEach(async () => {
vi.clearAllMocks();
mockFindByEmailAndIncludeProfilesAndPassword.mockReset();
const verifyPasswordModule = await import("./verifyPassword");
verifyPassword = verifyPasswordModule.verifyPassword;
// Import the exported authorize function directly
const authModule = await import("./next-auth-options");
authorizeCredentials = authModule.authorizeCredentials;
});
const createMockUser = (overrides: Partial<any> = {}) => ({
id: 1,
email: "test@example.com",
name: "Test User",
username: "testuser",
role: UserPermissionRole.USER,
locked: false,
identityProvider: IdentityProvider.CAL,
twoFactorEnabled: false,
twoFactorSecret: null,
backupCodes: null,
password: {
hash: "$2a$10$hashedpassword",
},
allProfiles: [
{
id: 1,
upId: "usr_123",
username: "testuser",
},
],
teams: [],
...overrides,
});
describe("Password validation", () => {
it("should throw error when user has no password hash with CAL identity provider", async () => {
const mockUser = createMockUser({
password: null,
identityProvider: IdentityProvider.CAL,
});
mockFindByEmailAndIncludeProfilesAndPassword.mockResolvedValue(mockUser);
await expect(
authorizeCredentials({
email: "test@example.com",
password: "password123",
} as any)
).rejects.toThrow(ErrorCode.IncorrectEmailPassword);
});
it("should throw error when user has no password hash with Google identity provider", async () => {
const mockUser = createMockUser({
password: null,
identityProvider: IdentityProvider.GOOGLE,
});
mockFindByEmailAndIncludeProfilesAndPassword.mockResolvedValue(mockUser);
await expect(
authorizeCredentials({
email: "test@example.com",
password: "password123",
} as any)
).rejects.toThrow(ErrorCode.IncorrectEmailPassword);
});
it("should throw error when user has no password hash with SAML identity provider", async () => {
const mockUser = createMockUser({
password: null,
identityProvider: IdentityProvider.SAML,
});
mockFindByEmailAndIncludeProfilesAndPassword.mockResolvedValue(mockUser);
await expect(
authorizeCredentials({
email: "test@example.com",
password: "password123",
} as any)
).rejects.toThrow(ErrorCode.IncorrectEmailPassword);
});
it("should throw error when user has no password hash even with TOTP code provided", async () => {
const mockUser = createMockUser({
password: null,
identityProvider: IdentityProvider.CAL,
});
mockFindByEmailAndIncludeProfilesAndPassword.mockResolvedValue(mockUser);
await expect(
authorizeCredentials({
email: "test@example.com",
password: "password123",
totpCode: "123456",
} as any)
).rejects.toThrow(ErrorCode.IncorrectEmailPassword);
});
it("should throw error when user has no password hash with Google identity provider and TOTP code", async () => {
const mockUser = createMockUser({
password: null,
identityProvider: IdentityProvider.GOOGLE,
});
mockFindByEmailAndIncludeProfilesAndPassword.mockResolvedValue(mockUser);
await expect(
authorizeCredentials({
email: "test@example.com",
password: "password123",
totpCode: "123456",
} as any)
).rejects.toThrow(ErrorCode.IncorrectEmailPassword);
});
it("should throw error when user has no password hash with SAML identity provider and TOTP code", async () => {
const mockUser = createMockUser({
password: null,
identityProvider: IdentityProvider.SAML,
});
mockFindByEmailAndIncludeProfilesAndPassword.mockResolvedValue(mockUser);
await expect(
authorizeCredentials({
email: "test@example.com",
password: "password123",
totpCode: "123456",
} as any)
).rejects.toThrow(ErrorCode.IncorrectEmailPassword);
});
});
describe("Inactive admin reason", () => {
it("sets reason to 'both' when password is invalid and 2FA is disabled", async () => {
const { isPasswordValid } = await import("@calcom/lib/auth/isPasswordValid");
vi.mocked(isPasswordValid).mockReturnValue(false);
vi.mocked(verifyPassword).mockResolvedValue(true);
const mockUser = createMockUser({
role: UserPermissionRole.ADMIN,
identityProvider: IdentityProvider.CAL,
twoFactorEnabled: false,
});
mockFindByEmailAndIncludeProfilesAndPassword.mockResolvedValue(mockUser);
const result = await authorizeCredentials({
email: mockUser.email,
password: "password123",
totpCode: "",
backupCode: "",
});
expect(result?.role).toBe("INACTIVE_ADMIN");
expect(result?.inactiveAdminReason).toBe("both");
});
it("sets reason to '2fa' when password is valid and 2FA is disabled", async () => {
const { isPasswordValid } = await import("@calcom/lib/auth/isPasswordValid");
vi.mocked(isPasswordValid).mockReturnValue(true);
vi.mocked(verifyPassword).mockResolvedValue(true);
const mockUser = createMockUser({
role: UserPermissionRole.ADMIN,
identityProvider: IdentityProvider.CAL,
twoFactorEnabled: false,
});
mockFindByEmailAndIncludeProfilesAndPassword.mockResolvedValue(mockUser);
const result = await authorizeCredentials({
email: mockUser.email,
password: "password123",
totpCode: "",
backupCode: "",
});
expect(result?.role).toBe("INACTIVE_ADMIN");
expect(result?.inactiveAdminReason).toBe("2fa");
});
it("sets reason to 'password' when password is invalid and 2FA is enabled", async () => {
const originalKey = process.env.CALENDSO_ENCRYPTION_KEY;
process.env.CALENDSO_ENCRYPTION_KEY = "test";
try {
const { isPasswordValid } = await import("@calcom/lib/auth/isPasswordValid");
vi.mocked(isPasswordValid).mockReturnValue(false);
vi.mocked(verifyPassword).mockResolvedValue(true);
const { symmetricDecrypt } = await import("@calcom/lib/crypto");
vi.mocked(symmetricDecrypt).mockReturnValue("a".repeat(32));
const { totpAuthenticatorCheck } = await import("@calcom/lib/totp");
vi.mocked(totpAuthenticatorCheck).mockReturnValue(true);
const mockUser = createMockUser({
role: UserPermissionRole.ADMIN,
identityProvider: IdentityProvider.CAL,
twoFactorEnabled: true,
twoFactorSecret: "encrypted_secret",
});
mockFindByEmailAndIncludeProfilesAndPassword.mockResolvedValue(mockUser);
const result = await authorizeCredentials({
email: mockUser.email,
password: "password123",
totpCode: "123456",
backupCode: "",
});
expect(result?.role).toBe("INACTIVE_ADMIN");
expect(result?.inactiveAdminReason).toBe("password");
} finally {
process.env.CALENDSO_ENCRYPTION_KEY = originalKey;
}
});
it("does not set inactiveAdminReason when admin requirements are met", async () => {
const originalKey = process.env.CALENDSO_ENCRYPTION_KEY;
process.env.CALENDSO_ENCRYPTION_KEY = "test";
try {
const { isPasswordValid } = await import("@calcom/lib/auth/isPasswordValid");
vi.mocked(isPasswordValid).mockReturnValue(true);
vi.mocked(verifyPassword).mockResolvedValue(true);
const { symmetricDecrypt } = await import("@calcom/lib/crypto");
vi.mocked(symmetricDecrypt).mockReturnValue("a".repeat(32));
const { totpAuthenticatorCheck } = await import("@calcom/lib/totp");
vi.mocked(totpAuthenticatorCheck).mockReturnValue(true);
const mockUser = createMockUser({
role: UserPermissionRole.ADMIN,
identityProvider: IdentityProvider.CAL,
twoFactorEnabled: true,
twoFactorSecret: "encrypted_secret",
});
mockFindByEmailAndIncludeProfilesAndPassword.mockResolvedValue(mockUser);
const result = await authorizeCredentials({
email: mockUser.email,
password: "password123",
totpCode: "123456",
backupCode: "",
});
expect(result?.role).toBe(UserPermissionRole.ADMIN);
expect(result?.inactiveAdminReason).toBeUndefined();
} finally {
process.env.CALENDSO_ENCRYPTION_KEY = originalKey;
}
});
});
});
describe("Azure AD signIn callback", () => {
let getOptions: typeof import("./next-auth-options").getOptions;
let signInCallback: NonNullable<ReturnType<typeof getOptions>["callbacks"]>["signIn"];
beforeEach(async () => {
vi.clearAllMocks();
mockGetIdentityProvider.mockImplementation((provider: string) => {
const map: Record<string, string> = {
"azure-ad": "AZUREAD",
google: "GOOGLE",
saml: "SAML",
"saml-idp": "SAML",
cal: "CAL",
};
return map[provider] ?? null;
});
// Mock prisma methods used in signIn callback
const prismaModule = await import("@calcom/prisma");
const prismaDefault = (prismaModule as any).default;
prismaDefault.user = {
...prismaDefault.user,
findFirst: mockPrismaUserFindFirst,
create: mockPrismaUserCreate,
update: mockPrismaUserUpdate,
};
prismaDefault.team = {
findFirst: mockPrismaTeamFindFirst,
};
prismaDefault.selectedCalendar = {
create: mockPrismaSelectedCalendarCreate,
};
mockPrismaUserFindFirst.mockResolvedValue(null);
mockPrismaUserCreate.mockResolvedValue({ id: 100, email: "new@example.com", twoFactorEnabled: false });
mockPrismaUserUpdate.mockResolvedValue({});
mockPrismaTeamFindFirst.mockResolvedValue(null);
mockUpdateProfilePhotoMicrosoft.mockResolvedValue(undefined);
// Setup mock adapter
const adapterModule = await import("./next-auth-custom-adapter");
(adapterModule.default as any).mockReturnValue({ linkAccount: mockLinkAccount });
mockLinkAccount.mockResolvedValue(undefined);
mockGetBillingProviderService.mockReturnValue({
createCustomer: vi.fn().mockResolvedValue({ stripeCustomerId: "cus_test" }),
});
mockWaitUntil.mockImplementation((p: Promise<any>) => p.catch(() => {}));
const authModule = await import("./next-auth-options");
getOptions = authModule.getOptions;
const options = getOptions({ getDubId: () => undefined, getTrackingData: () => ({}) as any });
signInCallback = options.callbacks!.signIn! as any;
});
describe("Azure AD email verification (xms_edov)", () => {
const baseParams = {
user: { id: "1", email: "user@example.com", name: "User", emailVerified: null },
account: {
provider: "azure-ad",
providerAccountId: "azure-123",
type: "oauth" as const,
},
credentials: undefined,
email: undefined,
};
it("allows login when xms_edov is true (boolean)", async () => {
mockPrismaUserFindFirst.mockResolvedValue({
id: 1,
email: "user@example.com",
accounts: [{ provider: "azure-ad" }],
twoFactorEnabled: false,
identityProvider: "AZUREAD",
});
const result = await signInCallback({
...baseParams,
profile: { email_verified: false, xms_edov: true } as any,
} as any);
expect(result).toBe(true);
});
it("allows login when xms_edov is string 'true'", async () => {
mockPrismaUserFindFirst.mockResolvedValue({
id: 1,
email: "user@example.com",
accounts: [{ provider: "azure-ad" }],
twoFactorEnabled: false,
identityProvider: "AZUREAD",
});
const result = await signInCallback({
...baseParams,
profile: { xms_edov: "true" } as any,
} as any);
expect(result).toBe(true);
});
it("allows login when xms_edov is string '1'", async () => {
mockPrismaUserFindFirst.mockResolvedValue({
id: 1,
email: "user@example.com",
accounts: [{ provider: "azure-ad" }],
twoFactorEnabled: false,
identityProvider: "AZUREAD",
});
const result = await signInCallback({
...baseParams,
profile: { xms_edov: "1" } as any,
} as any);
expect(result).toBe(true);
});
it("allows login when xms_edov is number 1", async () => {
mockPrismaUserFindFirst.mockResolvedValue({
id: 1,
email: "user@example.com",
accounts: [{ provider: "azure-ad" }],
twoFactorEnabled: false,
identityProvider: "AZUREAD",
});
const result = await signInCallback({
...baseParams,
profile: { xms_edov: 1 } as any,
} as any);
expect(result).toBe(true);
});
it("rejects login when xms_edov is false", async () => {
const result = await signInCallback({
...baseParams,
profile: { xms_edov: false } as any,
} as any);
expect(result).toBe("/auth/error?error=unverified-email");
});
it("rejects login when xms_edov is undefined", async () => {
const result = await signInCallback({
...baseParams,
profile: {} as any,
} as any);
expect(result).toBe("/auth/error?error=unverified-email");
});
it("rejects login when xms_edov is 0", async () => {
const result = await signInCallback({
...baseParams,
profile: { xms_edov: 0 } as any,
} as any);
expect(result).toBe("/auth/error?error=unverified-email");
});
it("rejects login when xms_edov is string '0'", async () => {
const result = await signInCallback({
...baseParams,
profile: { xms_edov: "0" } as any,
} as any);
expect(result).toBe("/auth/error?error=unverified-email");
});
it("allows non-AZUREAD with email_verified=true", async () => {
mockPrismaUserFindFirst.mockResolvedValue({
id: 1,
email: "user@example.com",
accounts: [{ provider: "google" }],
twoFactorEnabled: false,
identityProvider: "GOOGLE",
});
const result = await signInCallback({
user: { id: "1", email: "user@example.com", name: "User", emailVerified: null },
account: { provider: "google", providerAccountId: "google-123", type: "oauth" as const },
profile: { email_verified: true } as any,
credentials: undefined,
email: undefined,
} as any);
expect(result).toBe(true);
});
it("rejects non-AZUREAD with email_verified=false", async () => {
const result = await signInCallback({
user: { id: "1", email: "user@example.com", name: "User", emailVerified: null },
account: { provider: "google", providerAccountId: "google-123", type: "oauth" as const },
profile: { email_verified: false } as any,
credentials: undefined,
email: undefined,
} as any);
expect(result).toBe("/auth/error?error=unverified-email");
});
});
describe("Azure AD profile photo on new user (signIn callback)", () => {
const baseUser = { id: "1", email: "newuser@example.com", name: "New User", emailVerified: null };
it("calls updateProfilePhotoMicrosoft for new Azure AD user with access_token", async () => {
// No existing user
mockPrismaUserFindFirst.mockResolvedValue(null);
mockPrismaUserCreate.mockResolvedValue({
id: 100,
email: "newuser@example.com",
twoFactorEnabled: false,
});
await signInCallback({
user: baseUser,
account: {
provider: "azure-ad",
providerAccountId: "azure-new-123",
type: "oauth" as const,
access_token: "new-user-token",
},
profile: { xms_edov: true } as any,
credentials: undefined,
email: undefined,
} as any);
expect(mockUpdateProfilePhotoMicrosoft).toHaveBeenCalledWith("new-user-token", 100);
});
it("skips updateProfilePhotoMicrosoft for new Azure AD user without access_token", async () => {
mockPrismaUserFindFirst.mockResolvedValue(null);
mockPrismaUserCreate.mockResolvedValue({
id: 101,
email: "newuser@example.com",
twoFactorEnabled: false,
});
await signInCallback({
user: baseUser,
account: {
provider: "azure-ad",
providerAccountId: "azure-new-456",
type: "oauth" as const,
},
profile: { xms_edov: true } as any,
credentials: undefined,
email: undefined,
} as any);
expect(mockUpdateProfilePhotoMicrosoft).not.toHaveBeenCalled();
});
it("skips updateProfilePhotoMicrosoft for new Google user", async () => {
mockPrismaUserFindFirst.mockResolvedValue(null);
mockPrismaUserCreate.mockResolvedValue({
id: 102,
email: "newuser@example.com",
twoFactorEnabled: false,
});
await signInCallback({
user: baseUser,
account: {
provider: "google",
providerAccountId: "google-new-789",
type: "oauth" as const,
access_token: "google-token",
},
profile: { email_verified: true } as any,
credentials: undefined,
email: undefined,
} as any);
expect(mockUpdateProfilePhotoMicrosoft).not.toHaveBeenCalled();
});
it("photo update failure does not crash signup", async () => {
mockPrismaUserFindFirst.mockResolvedValue(null);
mockPrismaUserCreate.mockResolvedValue({
id: 103,
email: "newuser@example.com",
twoFactorEnabled: false,
});
mockUpdateProfilePhotoMicrosoft.mockRejectedValue(new Error("Photo update failed"));
const result = await signInCallback({
user: baseUser,
account: {
provider: "azure-ad",
providerAccountId: "azure-fail-123",
type: "oauth" as const,
access_token: "fail-token",
},
profile: { xms_edov: true } as any,
credentials: undefined,
email: undefined,
} as any);
// Should still return something (true or error redirect), not throw
expect(result).toBeDefined();
});
});
describe("Azure AD identity provider conversion (signIn callback)", () => {
beforeEach(() => {
mockHostedCalRef.value = true;
});
afterEach(() => {
mockHostedCalRef.value = false;
});
it("CAL user with verified email converts to AZUREAD on Azure AD login", async () => {
// No existing user with AZUREAD identity
mockPrismaUserFindFirst
.mockResolvedValueOnce(null) // First call: lookup by identityProvider + providerAccountId
.mockResolvedValueOnce(null) // Legacy lookup
.mockResolvedValueOnce({
// Lookup by email
id: 50,
email: "user@example.com",
emailVerified: new Date(),
identityProvider: "CAL",
password: { hash: "hashed" },
twoFactorEnabled: false,
});
const result = await signInCallback({
user: { id: "1", email: "user@example.com", name: "User", emailVerified: null },
account: { provider: "azure-ad", providerAccountId: "azure-conv-1", type: "oauth" as const },
profile: { xms_edov: true } as any,
credentials: undefined,
email: undefined,
} as any);
expect(mockPrismaUserUpdate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
identityProvider: "AZUREAD",
identityProviderId: "azure-conv-1",
}),
})
);
expect(result).toBe(true);
});
it("GOOGLE user converts to AZUREAD on Azure AD login", async () => {
mockPrismaUserFindFirst.mockResolvedValueOnce(null).mockResolvedValueOnce(null).mockResolvedValueOnce({
id: 51,
email: "user@example.com",
emailVerified: new Date(),
identityProvider: "GOOGLE",
password: null,
twoFactorEnabled: false,
});
const result = await signInCallback({
user: { id: "1", email: "user@example.com", name: "User", emailVerified: null },
account: { provider: "azure-ad", providerAccountId: "azure-conv-2", type: "oauth" as const },
profile: { xms_edov: true } as any,
credentials: undefined,
email: undefined,
} as any);
// Google -> Azure AD conversion goes through the GOOGLE -> (SAML | AZUREAD) branch
expect(mockPrismaUserUpdate).toHaveBeenCalled();
expect(result).toBe(true);
});
it("AZUREAD user converts to GOOGLE on Google login", async () => {
mockPrismaUserFindFirst.mockResolvedValueOnce(null).mockResolvedValueOnce(null).mockResolvedValueOnce({
id: 52,
email: "user@example.com",
emailVerified: new Date(),
identityProvider: "AZUREAD",
password: null,
twoFactorEnabled: false,
});
const result = await signInCallback({
user: { id: "1", email: "user@example.com", name: "User", emailVerified: null },
account: { provider: "google", providerAccountId: "google-conv-1", type: "oauth" as const },
profile: { email_verified: true } as any,
credentials: undefined,
email: undefined,
} as any);
expect(mockPrismaUserUpdate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
identityProvider: "GOOGLE",
identityProviderId: "google-conv-1",
}),
})
);
expect(result).toBe(true);
});
it("unverified CAL account blocks Azure AD linking (anti-hijack)", async () => {
mockPrismaUserFindFirst
.mockResolvedValueOnce(null)
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({
id: 53,
email: "user@example.com",
emailVerified: null,
identityProvider: "CAL",
password: { hash: "hashed" },
twoFactorEnabled: false,
});
const result = await signInCallback({
user: { id: "1", email: "user@example.com", name: "User", emailVerified: null },
account: { provider: "azure-ad", providerAccountId: "azure-hijack-1", type: "oauth" as const },
profile: { xms_edov: true } as any,
credentials: undefined,
email: undefined,
} as any);
expect(result).toBe("/auth/error?error=unverified-email");
});
it("AZUREAD user converts to SAML with validation", async () => {
mockPrismaUserFindFirst.mockResolvedValueOnce(null).mockResolvedValueOnce(null).mockResolvedValueOnce({
id: 54,
email: "user@example.com",
emailVerified: new Date(),
identityProvider: "AZUREAD",
password: null,
twoFactorEnabled: false,
});
mockValidateSamlAccountConversion.mockResolvedValue({ allowed: true });
const result = await signInCallback({
user: {
id: "1",
email: "user@example.com",
name: "User",
emailVerified: null,
samlTenant: "tenant-1",
},
account: { provider: "saml-idp", providerAccountId: "saml-conv-1", type: "credentials" as const },
profile: { email_verified: true } as any,
credentials: undefined,
email: undefined,
} as any);
// saml-idp is credentials type, so it returns true directly
expect(result).toBe(true);
});
it("AZUREAD to SAML blocked when SAML validation fails", async () => {
mockPrismaUserFindFirst.mockResolvedValueOnce(null).mockResolvedValueOnce(null).mockResolvedValueOnce({
id: 55,
email: "user@example.com",
emailVerified: new Date(),
identityProvider: "AZUREAD",
password: null,
twoFactorEnabled: false,
});
mockValidateSamlAccountConversion.mockResolvedValue({
allowed: false,
errorUrl: "/auth/error?error=saml-tenant-mismatch",
});
const result = await signInCallback({
user: { id: "1", email: "user@example.com", name: "User", emailVerified: null },
account: { provider: "saml", providerAccountId: "saml-block-1", type: "oauth" as const },
profile: { email_verified: true } as any,
credentials: undefined,
email: undefined,
} as any);
expect(result).toBe("/auth/error?error=saml-tenant-mismatch");
});
});
});
describe("Azure AD JWT callback", () => {
let getOptions: typeof import("./next-auth-options").getOptions;
let jwtCallback: NonNullable<ReturnType<typeof getOptions>["callbacks"]>["jwt"];
let originalFetch: typeof globalThis.fetch;
beforeEach(async () => {
vi.clearAllMocks();
originalFetch = globalThis.fetch;
mockGetIdentityProvider.mockImplementation((provider: string) => {
const map: Record<string, string> = {
"azure-ad": "AZUREAD",
google: "GOOGLE",
saml: "SAML",
"saml-idp": "SAML",
cal: "CAL",
};
return map[provider] ?? null;
});
mockCredentialRepoCreate.mockResolvedValue({ id: 1 });
mockBuildCredentialCreateData.mockImplementation((data: any) => data);
mockUpdateProfilePhotoMicrosoft.mockResolvedValue(undefined);
// Setup prisma mocks
const prismaModule = await import("@calcom/prisma");
const prismaDefault = (prismaModule as any).default;
prismaDefault.user = {
...prismaDefault.user,
findFirst: mockPrismaUserFindFirst,
};
prismaDefault.selectedCalendar = {
create: mockPrismaSelectedCalendarCreate,
};
prismaDefault.membership = {
findUnique: vi.fn().mockResolvedValue(null),
};
mockPrismaSelectedCalendarCreate.mockResolvedValue({});
const { ProfileRepository } = await import("@calcom/features/profile/repositories/ProfileRepository");
(ProfileRepository.findAllProfilesForUserIncludingMovedUser as any).mockResolvedValue([
{ id: 1, upId: "usr_1" },
]);
(ProfileRepository.findByUpIdWithAuth as any).mockResolvedValue({
id: 1,
username: "testuser",
organization: null,
});
const authModule = await import("./next-auth-options");
getOptions = authModule.getOptions;
const options = getOptions({ getDubId: () => undefined, getTrackingData: () => ({}) as any });
jwtCallback = options.callbacks!.jwt! as any;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe("Azure AD calendar auto-install", () => {
const baseToken = { sub: "1", email: "user@example.com", upId: "usr_1" } as any;
const baseUser = {
id: "1",
email: "user@example.com",
name: "Test",
username: "test",
role: "USER",
locale: "en",
} as any;
it("creates office365-calendar credential on first login with all scopes", async () => {
mockPrismaUserFindFirst.mockResolvedValue({
id: 1,
email: "user@example.com",
name: "Test",
username: "test",
role: "USER",
locale: "en",
avatarUrl: null,
identityProvider: "AZUREAD",
identityProviderId: "azure-123",
teams: [],
movedToProfileId: null,
});
mockCredentialRepoFindFirst.mockResolvedValue(null);
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ value: [{ id: "cal-id-1", isDefaultCalendar: true }] }),
}) as unknown as typeof fetch;
await jwtCallback({
token: baseToken,
user: baseUser,
account: {
provider: "azure-ad",
providerAccountId: "azure-123",
type: "oauth",
access_token: "at-123",
refresh_token: "rt-123",
expires_at: 1700000000,
scope: "User.Read Calendars.Read Calendars.ReadWrite offline_access",
},
trigger: undefined,
session: undefined,
} as any);
expect(mockBuildCredentialCreateData).toHaveBeenCalledWith(
expect.objectContaining({
appId: "office365-calendar",
type: "office365_calendar",
})
);
expect(mockCredentialRepoCreate).toHaveBeenCalled();
});
it("skips creation when office365-calendar credential already exists", async () => {
mockPrismaUserFindFirst.mockResolvedValue({
id: 1,
email: "user@example.com",
name: "Test",
username: "test",
role: "USER",
locale: "en",
avatarUrl: null,
identityProvider: "AZUREAD",
identityProviderId: "azure-123",
teams: [],
movedToProfileId: null,
});
mockCredentialRepoFindFirst.mockResolvedValue({ id: 99 });
await jwtCallback({
token: baseToken,
user: baseUser,
account: {
provider: "azure-ad",
providerAccountId: "azure-123",
type: "oauth",
access_token: "at-123",
scope: "User.Read Calendars.Read Calendars.ReadWrite",
},
trigger: undefined,
session: undefined,
} as any);
expect(mockBuildCredentialCreateData).not.toHaveBeenCalledWith(
expect.objectContaining({ appId: "office365-calendar" })
);
});
it("skips creation when calendar scopes are missing", async () => {
mockPrismaUserFindFirst.mockResolvedValue({
id: 1,
email: "user@example.com",
name: "Test",
username: "test",
role: "USER",
locale: "en",
avatarUrl: null,
identityProvider: "AZUREAD",
identityProviderId: "azure-123",
teams: [],
movedToProfileId: null,
});
mockCredentialRepoFindFirst.mockResolvedValue(null);
await jwtCallback({
token: baseToken,
user: baseUser,
account: {
provider: "azure-ad",
providerAccountId: "azure-123",
type: "oauth",
access_token: "at-123",
scope: "User.Read",
},
trigger: undefined,
session: undefined,
} as any);
expect(mockCredentialRepoCreate).not.toHaveBeenCalled();
});
it("offline_access is excluded from scope check", async () => {
mockPrismaUserFindFirst.mockResolvedValue({
id: 1,
email: "user@example.com",
name: "Test",
username: "test",
role: "USER",
locale: "en",
avatarUrl: null,
identityProvider: "AZUREAD",
identityProviderId: "azure-123",
teams: [],
movedToProfileId: null,
});
mockCredentialRepoFindFirst.mockResolvedValue(null);
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ value: [] }),
}) as unknown as typeof fetch;
// Scope without offline_access — should still pass scope check
await jwtCallback({
token: baseToken,
user: baseUser,
account: {
provider: "azure-ad",
providerAccountId: "azure-123",
type: "oauth",
access_token: "at-123",
scope: "User.Read Calendars.Read Calendars.ReadWrite",
},
trigger: undefined,
session: undefined,
} as any);
expect(mockCredentialRepoCreate).toHaveBeenCalled();
});
it("fetches default calendar and creates selectedCalendar", async () => {
mockPrismaUserFindFirst.mockResolvedValue({
id: 1,
email: "user@example.com",
name: "Test",
username: "test",
role: "USER",
locale: "en",
avatarUrl: null,
identityProvider: "AZUREAD",
identityProviderId: "azure-123",
teams: [],
movedToProfileId: null,
});
mockCredentialRepoFindFirst.mockResolvedValue(null);
mockCredentialRepoCreate.mockResolvedValue({ id: 77 });
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
value: [
{ id: "non-default-cal", isDefaultCalendar: false },
{ id: "default-cal-id", isDefaultCalendar: true },
],
}),
}) as unknown as typeof fetch;
await jwtCallback({
token: baseToken,
user: baseUser,
account: {
provider: "azure-ad",
providerAccountId: "azure-123",
type: "oauth",
access_token: "at-123",
scope: "User.Read Calendars.Read Calendars.ReadWrite",
},
trigger: undefined,
session: undefined,
} as any);
expect(mockPrismaSelectedCalendarCreate).toHaveBeenCalledWith({
data: expect.objectContaining({
integration: "office365_calendar",
externalId: "default-cal-id",
credentialId: 77,
}),
});
});
it("Graph API fetch failure logs error but does not crash", async () => {
mockPrismaUserFindFirst.mockResolvedValue({
id: 1,
email: "user@example.com",
name: "Test",
username: "test",
role: "USER",
locale: "en",
avatarUrl: null,
identityProvider: "AZUREAD",
identityProviderId: "azure-123",
teams: [],
movedToProfileId: null,
});
mockCredentialRepoFindFirst.mockResolvedValue(null);
globalThis.fetch = vi.fn().mockRejectedValue(new Error("Network error")) as unknown as typeof fetch;
// Should not throw
const result = await jwtCallback({
token: baseToken,
user: baseUser,
account: {
provider: "azure-ad",
providerAccountId: "azure-123",
type: "oauth",
access_token: "at-123",
scope: "User.Read Calendars.Read Calendars.ReadWrite",
},
trigger: undefined,
session: undefined,
} as any);
expect(result).toBeDefined();
});
it("calls updateProfilePhotoMicrosoft after calendar install", async () => {
mockPrismaUserFindFirst.mockResolvedValue({
id: 1,
email: "user@example.com",
name: "Test",
username: "test",
role: "USER",
locale: "en",
avatarUrl: null,
identityProvider: "AZUREAD",
identityProviderId: "azure-123",
teams: [],
movedToProfileId: null,
});
mockCredentialRepoFindFirst.mockResolvedValue(null);
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ value: [] }),
}) as unknown as typeof fetch;
await jwtCallback({
token: baseToken,
user: baseUser,
account: {
provider: "azure-ad",
providerAccountId: "azure-123",
type: "oauth",
access_token: "photo-token",
scope: "User.Read Calendars.Read Calendars.ReadWrite",
},
trigger: undefined,
session: undefined,
} as any);
expect(mockUpdateProfilePhotoMicrosoft).toHaveBeenCalledWith("photo-token", 1);
});
it("calls updateProfilePhotoMicrosoft even when calendar already installed (else-if branch)", async () => {
mockPrismaUserFindFirst.mockResolvedValue({
id: 1,
email: "user@example.com",
name: "Test",
username: "test",
role: "USER",
locale: "en",
avatarUrl: null,
identityProvider: "AZUREAD",
identityProviderId: "azure-123",
teams: [],
movedToProfileId: null,
});
// Calendar credential already exists
mockCredentialRepoFindFirst.mockResolvedValue({ id: 99 });
await jwtCallback({
token: baseToken,
user: baseUser,
account: {
provider: "azure-ad",
providerAccountId: "azure-123",
type: "oauth",
access_token: "photo-token-2",
scope: "User.Read Calendars.Read Calendars.ReadWrite",
},
trigger: undefined,
session: undefined,
} as any);
expect(mockUpdateProfilePhotoMicrosoft).toHaveBeenCalledWith("photo-token-2", 1);
});
it("converts expires_at from seconds to milliseconds in credential key", async () => {
mockPrismaUserFindFirst.mockResolvedValue({
id: 1,
email: "user@example.com",
name: "Test",
username: "test",
role: "USER",
locale: "en",
avatarUrl: null,
identityProvider: "AZUREAD",
identityProviderId: "azure-123",
teams: [],
movedToProfileId: null,
});
mockCredentialRepoFindFirst.mockResolvedValue(null);
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ value: [] }),
}) as unknown as typeof fetch;
const expiresAtSeconds = 1700000000;
await jwtCallback({
token: baseToken,
user: baseUser,
account: {
provider: "azure-ad",
providerAccountId: "azure-123",
type: "oauth",
access_token: "at-123",
refresh_token: "rt-123",
expires_at: expiresAtSeconds,
scope: "User.Read Calendars.Read Calendars.ReadWrite",
},
trigger: undefined,
session: undefined,
} as any);
expect(mockBuildCredentialCreateData).toHaveBeenCalledWith(
expect.objectContaining({
key: expect.objectContaining({
expiry_date: expiresAtSeconds * 1000,
}),
})
);
});
});
});