Files
formbricks/apps/web/modules/auth/lib/authOptions.test.ts
Victor Hugo dos Santos 7aedb73378 chore: backport jwt and script fix (#6607)
Co-authored-by: Anshuman Pandey <54475686+pandeymangg@users.noreply.github.com>
2025-09-25 11:53:45 -03:00

462 lines
16 KiB
TypeScript

import { randomBytes } from "crypto";
import { Provider } from "next-auth/providers/index";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
import { createToken } from "@/lib/jwt";
// Import mocked rate limiting functions
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { authOptions } from "./authOptions";
import { mockUser } from "./mock-data";
import { hashPassword } from "./utils";
// Mock rate limiting dependencies
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
auth: {
login: { interval: 900, allowedPerInterval: 30, namespace: "auth:login" },
verifyEmail: { interval: 3600, allowedPerInterval: 10, namespace: "auth:verify" },
},
},
}));
// Mock constants that this test needs
vi.mock("@/lib/constants", () => ({
EMAIL_VERIFICATION_DISABLED: false,
SESSION_MAX_AGE: 86400,
NEXTAUTH_SECRET: "test-secret",
WEBAPP_URL: "http://localhost:3000",
ENCRYPTION_KEY: "12345678901234567890123456789012", // 32 bytes for AES-256
REDIS_URL: undefined,
AUDIT_LOG_ENABLED: false,
AUDIT_LOG_GET_USER_IP: false,
ENTERPRISE_LICENSE_KEY: undefined,
SENTRY_DSN: undefined,
BREVO_API_KEY: undefined,
RATE_LIMITING_DISABLED: false,
}));
// Mock next/headers
vi.mock("next/headers", () => ({
headers: () => ({
get: () => null,
has: () => false,
keys: () => [],
values: () => [],
entries: () => [],
forEach: () => {},
}),
cookies: () => ({
get: (name: string) => {
if (name === "next-auth.callback-url") {
return { value: "/" };
}
return null;
},
}),
}));
const mockUserId = "cm5yzxcp900000cl78fzocjal";
const mockPassword = randomBytes(12).toString("hex");
const mockHashedPassword = await hashPassword(mockPassword);
vi.mock("@formbricks/database", () => ({
prisma: {
user: {
create: vi.fn(),
update: vi.fn(),
findFirst: vi.fn(),
findUnique: vi.fn(),
},
},
}));
// Helper to get the provider by id from authOptions.providers.
function getProviderById(id: string): Provider {
const provider = authOptions.providers.find((p) => p.options.id === id);
if (!provider) {
throw new Error(`Provider with id ${id} not found`);
}
return provider;
}
describe("authOptions", () => {
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});
describe("CredentialsProvider (credentials) - email/password login", () => {
const credentialsProvider = getProviderById("credentials");
test("should throw error if credentials are not provided", async () => {
await expect(credentialsProvider.options.authorize(undefined, {})).rejects.toThrow(
"Invalid credentials"
);
});
test("should throw error if user not found", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(null);
const credentials = { email: mockUser.email, password: mockPassword };
await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow(
"Invalid credentials"
);
});
test("should throw error if user has no password stored", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUser.id,
email: mockUser.email,
password: null,
} as any);
const credentials = { email: mockUser.email, password: mockPassword };
await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow(
"User has no password stored"
);
});
test("should throw error if password verification fails", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUserId,
email: mockUser.email,
password: mockHashedPassword,
} as any);
const credentials = { email: mockUser.email, password: "wrongPassword" };
await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow(
"Invalid credentials"
);
});
test("should successfully login when credentials are valid", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
const fakeUser = {
id: mockUserId,
email: mockUser.email,
password: mockHashedPassword,
emailVerified: new Date(),
twoFactorEnabled: false,
};
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(fakeUser as any);
const credentials = { email: mockUser.email, password: mockPassword };
const result = await credentialsProvider.options.authorize(credentials, {});
expect(result).toEqual({
id: fakeUser.id,
email: fakeUser.email,
emailVerified: fakeUser.emailVerified,
});
});
describe("Rate Limiting", () => {
test("should apply rate limiting before credential validation", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUserId,
email: mockUser.email,
password: mockHashedPassword,
emailVerified: new Date(),
twoFactorEnabled: false,
} as any);
const credentials = { email: mockUser.email, password: mockPassword };
await credentialsProvider.options.authorize(credentials, {});
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.login);
expect(applyIPRateLimit).toHaveBeenCalledBefore(prisma.user.findUnique as any);
});
test("should block login when rate limit exceeded", async () => {
vi.mocked(applyIPRateLimit).mockRejectedValue(
new Error("Maximum number of requests reached. Please try again later.")
);
const credentials = { email: mockUser.email, password: mockPassword };
await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow(
"Maximum number of requests reached. Please try again later."
);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
test("should use correct rate limit configuration", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUserId,
email: mockUser.email,
password: mockHashedPassword,
emailVerified: new Date(),
twoFactorEnabled: false,
} as any);
const credentials = { email: mockUser.email, password: mockPassword };
await credentialsProvider.options.authorize(credentials, {});
expect(applyIPRateLimit).toHaveBeenCalledWith({
interval: 900,
allowedPerInterval: 30,
namespace: "auth:login",
});
});
});
describe("Two-Factor Backup Code login", () => {
test("should throw error if backup codes are missing", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
const mockUser = {
id: mockUserId,
email: "2fa@example.com",
password: mockHashedPassword,
twoFactorEnabled: true,
backupCodes: null,
};
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any);
const credentials = { email: mockUser.email, password: mockPassword, backupCode: "123456" };
await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow(
"No backup codes found"
);
});
});
});
describe("CredentialsProvider (token) - Token-based email verification", () => {
const tokenProvider = getProviderById("token");
test("should throw error if token is not provided", async () => {
await expect(tokenProvider.options.authorize({}, {})).rejects.toThrow(
"Either a user does not match the provided token or the token is invalid"
);
});
test("should throw error if token is invalid or user not found", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
const credentials = { token: "badtoken" };
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
"Either a user does not match the provided token or the token is invalid"
);
});
test("should throw error if email is already verified", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any);
const credentials = { token: createToken(mockUser.id) };
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
"Email already verified"
);
});
test("should update user and verify email when token is valid", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({ id: mockUser.id, emailVerified: null } as any);
vi.spyOn(prisma.user, "update").mockResolvedValue({
...mockUser,
password: mockHashedPassword,
backupCodes: null,
twoFactorSecret: null,
identityProviderAccountId: null,
groupId: null,
} as any);
const credentials = { token: createToken(mockUserId) };
const result = await tokenProvider.options.authorize(credentials, {});
expect(result.email).toBe(mockUser.email);
expect(result.emailVerified).toBeInstanceOf(Date);
});
describe("Rate Limiting", () => {
test("should apply rate limiting before token verification", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUser.id,
emailVerified: null,
} as any);
vi.spyOn(prisma.user, "update").mockResolvedValue({
...mockUser,
password: mockHashedPassword,
backupCodes: null,
twoFactorSecret: null,
identityProviderAccountId: null,
groupId: null,
} as any);
const credentials = { token: createToken(mockUserId) };
await tokenProvider.options.authorize(credentials, {});
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.verifyEmail);
});
test("should block verification when rate limit exceeded", async () => {
vi.mocked(applyIPRateLimit).mockRejectedValue(
new Error("Maximum number of requests reached. Please try again later.")
);
const credentials = { token: createToken(mockUserId) };
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
"Maximum number of requests reached. Please try again later."
);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
test("should use correct rate limit configuration", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUser.id,
emailVerified: null,
} as any);
vi.spyOn(prisma.user, "update").mockResolvedValue({
...mockUser,
password: mockHashedPassword,
backupCodes: null,
twoFactorSecret: null,
identityProviderAccountId: null,
groupId: null,
} as any);
const credentials = { token: createToken(mockUserId) };
await tokenProvider.options.authorize(credentials, {});
expect(applyIPRateLimit).toHaveBeenCalledWith({
interval: 3600,
allowedPerInterval: 10,
namespace: "auth:verify",
});
});
});
});
describe("Callbacks", () => {
describe("jwt callback", () => {
test("should add profile information to token if user is found", async () => {
vi.spyOn(prisma.user, "findFirst").mockResolvedValue({
id: mockUser.id,
locale: mockUser.locale,
email: mockUser.email,
emailVerified: mockUser.emailVerified,
} as any);
const token = { email: mockUser.email };
if (!authOptions.callbacks?.jwt) {
throw new Error("jwt callback is not defined");
}
const result = await authOptions.callbacks.jwt({ token } as any);
expect(result).toEqual({
...token,
profile: { id: mockUser.id },
});
});
test("should return token unchanged if no existing user is found", async () => {
vi.spyOn(prisma.user, "findFirst").mockResolvedValue(null);
const token = { email: "nonexistent@example.com" };
if (!authOptions.callbacks?.jwt) {
throw new Error("jwt callback is not defined");
}
const result = await authOptions.callbacks.jwt({ token } as any);
expect(result).toEqual(token);
});
});
describe("session callback", () => {
test("should add user profile to session", async () => {
const token = {
id: "user6",
profile: { id: "user6", email: "user6@example.com" },
};
const session = { user: {} };
if (!authOptions.callbacks?.session) {
throw new Error("session callback is not defined");
}
const result = await authOptions.callbacks.session({ session, token } as any);
expect(result.user).toEqual(token.profile);
});
});
describe("signIn callback", () => {
test("should throw error if email is not verified and email verification is enabled", async () => {
const user = { ...mockUser, emailVerified: null };
const account = { provider: "credentials" } as any;
// EMAIL_VERIFICATION_DISABLED is imported from constants.
if (!EMAIL_VERIFICATION_DISABLED && authOptions.callbacks?.signIn) {
await expect(authOptions.callbacks.signIn({ user, account })).rejects.toThrow(
"Email Verification is Pending"
);
}
});
});
});
describe("Two-Factor Authentication (TOTP)", () => {
const credentialsProvider = getProviderById("credentials");
test("should throw error if TOTP code is missing when 2FA is enabled", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
const mockUser = {
id: mockUserId,
email: "2fa@example.com",
password: mockHashedPassword,
twoFactorEnabled: true,
twoFactorSecret: "encrypted_secret",
};
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any);
const credentials = { email: mockUser.email, password: mockPassword };
await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow(
"second factor required"
);
});
test("should throw error if two factor secret is missing", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
const mockUser = {
id: mockUserId,
email: "2fa@example.com",
password: mockHashedPassword,
twoFactorEnabled: true,
twoFactorSecret: null,
};
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any);
const credentials = {
email: mockUser.email,
password: mockPassword,
totpCode: "123456",
};
await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow(
"Internal Server Error"
);
});
});
});