test: unit test for auth module (#4612)

Co-authored-by: Matti Nannt <mail@matthiasnannt.com>
This commit is contained in:
Dhruwang Jariwala
2025-01-27 18:43:40 +05:30
committed by GitHub
parent d8386328e7
commit eac97db665
7 changed files with 588 additions and 5 deletions
@@ -0,0 +1,280 @@
import { randomBytes } from "crypto";
import { Provider } from "next-auth/providers/index";
import { afterEach, describe, expect, it, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { EMAIL_VERIFICATION_DISABLED } from "@formbricks/lib/constants";
import { createToken } from "@formbricks/lib/jwt";
import { authOptions } from "./authOptions";
import { mockUser } from "./mock-data";
import { hashPassword } from "./utils";
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.restoreAllMocks();
});
describe("CredentialsProvider (credentials) - email/password login", () => {
const credentialsProvider = getProviderById("credentials");
it("should throw error if credentials are not provided", async () => {
await expect(credentialsProvider.options.authorize(undefined, {})).rejects.toThrow(
"Invalid credentials"
);
});
it("should throw error if user not found", async () => {
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(null);
const credentials = { email: mockUser.email, password: mockPassword };
await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow(
"Invalid credentials"
);
});
it("should throw error if user has no password stored", async () => {
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUser.id,
email: mockUser.email,
password: null,
});
const credentials = { email: mockUser.email, password: mockPassword };
await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow(
"User has no password stored"
);
});
it("should throw error if password verification fails", async () => {
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUserId,
email: mockUser.email,
password: mockHashedPassword,
});
const credentials = { email: mockUser.email, password: "wrongPassword" };
await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow(
"Invalid credentials"
);
});
it("should successfully login when credentials are valid", async () => {
const fakeUser = {
id: mockUserId,
email: mockUser.email,
password: mockHashedPassword,
emailVerified: new Date(),
imageUrl: "http://example.com/avatar.png",
twoFactorEnabled: false,
};
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(fakeUser);
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,
imageUrl: fakeUser.imageUrl,
});
});
describe("Two-Factor Backup Code login", () => {
it("should throw error if backup codes are missing", async () => {
const mockUser = {
id: mockUserId,
email: "2fa@example.com",
password: mockHashedPassword,
twoFactorEnabled: true,
backupCodes: null,
};
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser);
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");
it("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"
);
});
it("should throw error if token is invalid or user not found", async () => {
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"
);
});
it("should throw error if email is already verified", async () => {
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser);
const credentials = { token: createToken(mockUser.id, mockUser.email) };
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
"Email already verified"
);
});
it("should update user and verify email when token is valid", async () => {
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({ id: mockUser.id, emailVerified: null });
vi.spyOn(prisma.user, "update").mockResolvedValue({
...mockUser,
password: mockHashedPassword,
backupCodes: null,
twoFactorSecret: null,
identityProviderAccountId: null,
groupId: null,
});
const credentials = { token: createToken(mockUserId, mockUser.email) };
const result = await tokenProvider.options.authorize(credentials, {});
expect(result.email).toBe(mockUser.email);
expect(result.emailVerified).toBeInstanceOf(Date);
});
});
describe("Callbacks", () => {
describe("jwt callback", () => {
it("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,
});
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 },
});
});
it("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", () => {
it("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", () => {
it("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");
it("should throw error if TOTP code is missing when 2FA is enabled", async () => {
const mockUser = {
id: mockUserId,
email: "2fa@example.com",
password: mockHashedPassword,
twoFactorEnabled: true,
twoFactorSecret: "encrypted_secret",
};
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser);
const credentials = { email: mockUser.email, password: mockPassword };
await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow(
"second factor required"
);
});
it("should throw error if two factor secret is missing", async () => {
const mockUser = {
id: mockUserId,
email: "2fa@example.com",
password: mockHashedPassword,
twoFactorEnabled: true,
twoFactorSecret: null,
};
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser);
const credentials = {
email: mockUser.email,
password: mockPassword,
totpCode: "123456",
};
await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow(
"Internal Server Error"
);
});
});
});
+7 -5
View File
@@ -39,6 +39,9 @@ export const authOptions: NextAuthOptions = {
backupCode: { label: "Backup Code", type: "input", placeholder: "Two-factor backup code" },
},
async authorize(credentials, _req) {
if (!credentials) {
throw new Error("Invalid credentials");
}
let user;
try {
user = await prisma.user.findUnique({
@@ -50,11 +53,11 @@ export const authOptions: NextAuthOptions = {
console.error(e);
throw Error("Internal server error. Please try again later");
}
if (!user || !credentials) {
if (!user) {
throw new Error("Invalid credentials");
}
if (!user.password) {
throw new Error("Invalid credentials");
throw new Error("User has no password stored");
}
const isValid = await verifyPassword(credentials.password, user.password);
@@ -102,12 +105,12 @@ export const authOptions: NextAuthOptions = {
const secret = symmetricDecrypt(user.twoFactorSecret, ENCRYPTION_KEY);
if (secret.length !== 32) {
throw new Error("Internal Server Error");
throw new Error("Invalid two factor secret");
}
const isValidToken = (await import("./totp")).totpAuthenticatorCheck(credentials.totpCode, secret);
if (!isValidToken) {
throw new Error("Invalid second factor code");
throw new Error("Invalid two factor code");
}
}
@@ -146,7 +149,6 @@ export const authOptions: NextAuthOptions = {
},
});
} catch (e) {
console.error(e);
throw new Error("Either a user does not match the provided token or the token is invalid");
}
+21
View File
@@ -0,0 +1,21 @@
import { TUser } from "@formbricks/types/user";
export const mockUser: TUser = {
id: "cm5xj580r00000cmgdj9ohups",
name: "mock User",
email: "john.doe@example.com",
emailVerified: new Date("2024-01-01T00:00:00.000Z"),
imageUrl: "https://www.google.com",
createdAt: new Date("2024-01-01T00:00:00.000Z"),
updatedAt: new Date("2024-01-01T00:00:00.000Z"),
twoFactorEnabled: false,
identityProvider: "google",
objective: "improve_user_retention",
notificationSettings: {
alert: {},
weeklySummary: {},
unsubscribedOrganizationIds: [],
},
role: "other",
locale: "en-US",
};
+159
View File
@@ -0,0 +1,159 @@
import { Prisma } from "@prisma/client";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { createCustomerIoCustomer } from "@formbricks/lib/customerio";
import { userCache } from "@formbricks/lib/user/cache";
import { InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { mockUser } from "./mock-data";
import { createUser, getUser, getUserByEmail, updateUser } from "./user";
const mockPrismaUser = {
...mockUser,
password: "password",
identityProviderAccountId: "identityProviderAccountId",
twoFactorSecret: "twoFactorSecret",
backupCodes: "backupCodes",
groupId: "groupId",
};
vi.mock("@formbricks/database", () => ({
prisma: {
user: {
create: vi.fn(),
update: vi.fn(),
findFirst: vi.fn(),
findUnique: vi.fn(),
},
},
}));
vi.mock("@formbricks/lib/customerio", () => ({
createCustomerIoCustomer: vi.fn(),
}));
vi.mock("@formbricks/lib/user/cache", () => ({
userCache: {
revalidate: vi.fn(),
tag: {
byEmail: vi.fn(),
byId: vi.fn(),
},
},
}));
describe("User Management", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("createUser", () => {
it("creates a user successfully", async () => {
vi.mocked(prisma.user.create).mockResolvedValueOnce(mockPrismaUser);
const result = await createUser({
email: mockUser.email,
name: mockUser.name,
locale: mockUser.locale,
});
expect(result).toEqual(mockPrismaUser);
expect(createCustomerIoCustomer).toHaveBeenCalledWith({
id: mockPrismaUser.id,
email: mockPrismaUser.email,
});
expect(userCache.revalidate).toHaveBeenCalled();
});
it("throws InvalidInputError when email already exists", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: "P2002",
clientVersion: "0.0.1",
});
vi.mocked(prisma.user.create).mockRejectedValueOnce(errToThrow);
await expect(
createUser({
email: mockUser.email,
name: mockUser.name,
locale: mockUser.locale,
})
).rejects.toThrow(InvalidInputError);
});
});
describe("updateUser", () => {
const mockUpdateData = { name: "Updated Name" };
it("updates a user successfully", async () => {
vi.mocked(prisma.user.update).mockResolvedValueOnce({ ...mockPrismaUser, name: mockUpdateData.name });
const result = await updateUser(mockUser.id, mockUpdateData);
expect(result).toEqual({ ...mockPrismaUser, name: mockUpdateData.name });
expect(userCache.revalidate).toHaveBeenCalled();
});
it("throws ResourceNotFoundError when user doesn't exist", async () => {
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error message", {
code: "P2016",
clientVersion: "0.0.1",
});
vi.mocked(prisma.user.update).mockRejectedValueOnce(errToThrow);
await expect(updateUser(mockUser.id, mockUpdateData)).rejects.toThrow(ResourceNotFoundError);
});
});
describe("getUserByEmail", () => {
const mockEmail = "test@example.com";
it("retrieves a user by email successfully", async () => {
const mockUser = {
id: "user123",
email: mockEmail,
locale: "en",
emailVerified: null,
};
vi.mocked(prisma.user.findFirst).mockResolvedValueOnce(mockUser);
const result = await getUserByEmail(mockEmail);
expect(result).toEqual(mockUser);
});
it("throws DatabaseError on prisma error", async () => {
vi.mocked(prisma.user.findFirst).mockRejectedValueOnce(new Error("Database error"));
await expect(getUserByEmail(mockEmail)).rejects.toThrow();
});
});
describe("getUser", () => {
const mockUserId = "cm5xj580r00000cmgdj9ohups";
it("retrieves a user by id successfully", async () => {
const mockUser = {
id: mockUserId,
};
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce(mockUser);
const result = await getUser(mockUserId);
expect(result).toEqual(mockUser);
});
it("returns null when user doesn't exist", async () => {
vi.mocked(prisma.user.findUnique).mockResolvedValueOnce(null);
const result = await getUser(mockUserId);
expect(result).toBeNull();
});
it("throws DatabaseError on prisma error", async () => {
vi.mocked(prisma.user.findUnique).mockRejectedValueOnce(new Error("Database error"));
await expect(getUser(mockUserId)).rejects.toThrow();
});
});
});
+38
View File
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { hashPassword, verifyPassword } from "./utils";
describe("Password Utils", () => {
const password = "password";
const hashedPassword = "$2a$12$LZsLq.9nkZlU0YDPx2aLNelnwD/nyavqbewLN.5.Q5h/UxRD8Ymcy";
describe("hashPassword", () => {
it("should hash a password", async () => {
const hashedPassword = await hashPassword(password);
expect(typeof hashedPassword).toBe("string");
expect(hashedPassword).not.toBe(password);
expect(hashedPassword.length).toBe(60);
});
it("should generate different hashes for the same password", async () => {
const hash1 = await hashPassword(password);
const hash2 = await hashPassword(password);
expect(hash1).not.toBe(hash2);
});
});
describe("verifyPassword", () => {
it("should verify a correct password", async () => {
const isValid = await verifyPassword(password, hashedPassword);
expect(isValid).toBe(true);
});
it("should reject an incorrect password", async () => {
const isValid = await verifyPassword("WrongPassword123!", hashedPassword);
expect(isValid).toBe(false);
});
});
});
@@ -0,0 +1,79 @@
import posthog from "posthog-js";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { captureFailedSignup, verifyTurnstileToken } from "./utils";
beforeEach(() => {
global.fetch = vi.fn();
vi.useFakeTimers();
});
afterEach(() => {
vi.resetAllMocks();
vi.useRealTimers();
});
describe("verifyTurnstileToken", () => {
const secretKey = "test-secret";
const token = "test-token";
it("should return true when verification is successful", async () => {
const mockResponse = { success: true };
(global.fetch as any).mockResolvedValue({
ok: true,
json: vi.fn().mockResolvedValue(mockResponse),
});
const result = await verifyTurnstileToken(secretKey, token);
expect(result).toBe(true);
expect(fetch).toHaveBeenCalledWith(
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ secret: secretKey, response: token }),
signal: expect.any(AbortSignal),
})
);
});
it("should return false when response is not ok", async () => {
(global.fetch as any).mockResolvedValue({
ok: false,
status: 400,
});
const result = await verifyTurnstileToken(secretKey, token);
expect(result).toBe(false);
});
it("should return false when verification fails", async () => {
(global.fetch as any).mockRejectedValue(new Error("Network error"));
const result = await verifyTurnstileToken(secretKey, token);
expect(result).toBe(false);
});
it("should return false when request times out", async () => {
const mockAbortError = new Error("The operation was aborted");
mockAbortError.name = "AbortError";
(global.fetch as any).mockRejectedValue(mockAbortError);
const result = await verifyTurnstileToken(secretKey, token);
expect(result).toBe(false);
});
});
describe("captureFailedSignup", () => {
it("should capture TELEMETRY_FAILED_SIGNUP event with email and name", () => {
const captureSpy = vi.spyOn(posthog, "capture");
const email = "test@example.com";
const name = "Test User";
captureFailedSignup(email, name);
expect(captureSpy).toHaveBeenCalledWith("TELEMETRY_FAILED_SIGNUP", {
email,
name,
});
});
});
+4
View File
@@ -56,6 +56,10 @@ export const verifyToken = async (token: string): Promise<JwtPayload> => {
const decoded = jwt.decode(token);
const payload: JwtPayload = decoded as JwtPayload;
if (!payload) {
throw new Error("Token is invalid");
}
const { id } = payload;
if (!id) {
throw new Error("Token missing required field: id");