mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-09 11:10:36 -05:00
Compare commits
2 Commits
4.9.3-rc.1
...
4.9.4-rc.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 5ec8218666 | |||
| e1a44817f2 |
@@ -0,0 +1,178 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { publicUserSelect } from "@/lib/user/public-user";
|
||||
import { GET } from "./route";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
headers: vi.fn(),
|
||||
getSessionUser: vi.fn(),
|
||||
parseApiKeyV2: vi.fn(),
|
||||
hashSha256: vi.fn(),
|
||||
verifySecret: vi.fn(),
|
||||
applyRateLimit: vi.fn(),
|
||||
notAuthenticatedResponse: vi.fn(
|
||||
() => new Response(JSON.stringify({ message: "Not authenticated" }), { status: 401 })
|
||||
),
|
||||
tooManyRequestsResponse: vi.fn(
|
||||
(message: string) => new Response(JSON.stringify({ message }), { status: 429 })
|
||||
),
|
||||
badRequestResponse: vi.fn((message: string) => new Response(JSON.stringify({ message }), { status: 400 })),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: mocks.headers,
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
apiKey: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/app/api/v1/management/me/lib/utils", () => ({
|
||||
getSessionUser: mocks.getSessionUser,
|
||||
}));
|
||||
|
||||
vi.mock("@/app/lib/api/response", () => ({
|
||||
responses: {
|
||||
notAuthenticatedResponse: mocks.notAuthenticatedResponse,
|
||||
tooManyRequestsResponse: mocks.tooManyRequestsResponse,
|
||||
badRequestResponse: mocks.badRequestResponse,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
hashSha256: mocks.hashSha256,
|
||||
parseApiKeyV2: mocks.parseApiKeyV2,
|
||||
verifySecret: mocks.verifySecret,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
||||
applyRateLimit: mocks.applyRateLimit,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
|
||||
rateLimitConfigs: {
|
||||
api: {
|
||||
v1: { windowMs: 60_000, max: 1000 },
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const getMockHeaders = (apiKey: string | null) => ({
|
||||
get: (headerName: string) => (headerName === "x-api-key" ? apiKey : null),
|
||||
});
|
||||
|
||||
describe("v1 management me route", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.headers.mockResolvedValue(getMockHeaders(null));
|
||||
mocks.getSessionUser.mockResolvedValue(undefined);
|
||||
mocks.parseApiKeyV2.mockReturnValue(null);
|
||||
mocks.hashSha256.mockReturnValue("hashed-api-key");
|
||||
mocks.verifySecret.mockResolvedValue(false);
|
||||
mocks.applyRateLimit.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
test("returns a sanitized authenticated user for session-based requests", async () => {
|
||||
const publicUser = {
|
||||
id: "user_123",
|
||||
name: "Test User",
|
||||
email: "test@example.com",
|
||||
emailVerified: new Date("2025-04-17T20:11:54.947Z"),
|
||||
createdAt: new Date("2025-04-17T20:09:14.021Z"),
|
||||
updatedAt: new Date("2026-04-22T22:12:39.104Z"),
|
||||
twoFactorEnabled: false,
|
||||
identityProvider: "email" as const,
|
||||
notificationSettings: {
|
||||
alert: {},
|
||||
unsubscribedOrganizationIds: [],
|
||||
},
|
||||
locale: "en-US" as const,
|
||||
lastLoginAt: new Date("2026-04-22T22:12:39.104Z"),
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
mocks.getSessionUser.mockResolvedValue({ id: publicUser.id });
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue(publicUser as never);
|
||||
|
||||
const response = await GET();
|
||||
const responseBody = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(responseBody).toStrictEqual(JSON.parse(JSON.stringify(publicUser)));
|
||||
expect(responseBody).not.toHaveProperty("password");
|
||||
expect(responseBody).not.toHaveProperty("twoFactorSecret");
|
||||
expect(responseBody).not.toHaveProperty("backupCodes");
|
||||
expect(responseBody).not.toHaveProperty("identityProviderAccountId");
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: publicUser.id },
|
||||
select: publicUserSelect,
|
||||
});
|
||||
expect(mocks.applyRateLimit).toHaveBeenCalledWith(expect.any(Object), publicUser.id);
|
||||
});
|
||||
|
||||
test("returns the existing unauthenticated response when no session is present", async () => {
|
||||
const response = await GET();
|
||||
const responseBody = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(responseBody).toEqual({ message: "Not authenticated" });
|
||||
expect(mocks.notAuthenticatedResponse).toHaveBeenCalled();
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("preserves the API key response path", async () => {
|
||||
const apiKeyData = {
|
||||
id: "api_key_123",
|
||||
organizationId: "org_123",
|
||||
hashedKey: "stored-hash",
|
||||
lastUsedAt: new Date(),
|
||||
apiKeyEnvironments: [
|
||||
{
|
||||
permission: "manage",
|
||||
environment: {
|
||||
id: "env_123",
|
||||
type: "development",
|
||||
createdAt: new Date("2025-01-01T00:00:00.000Z"),
|
||||
updatedAt: new Date("2025-01-02T00:00:00.000Z"),
|
||||
projectId: "project_123",
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
id: "project_123",
|
||||
name: "My Project",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mocks.headers.mockResolvedValue(getMockHeaders("api-key"));
|
||||
vi.mocked(prisma.apiKey.findFirst).mockResolvedValue(apiKeyData as never);
|
||||
|
||||
const response = await GET();
|
||||
const responseBody = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(responseBody).toStrictEqual({
|
||||
id: "env_123",
|
||||
type: "development",
|
||||
createdAt: "2025-01-01T00:00:00.000Z",
|
||||
updatedAt: "2025-01-02T00:00:00.000Z",
|
||||
appSetupCompleted: true,
|
||||
project: {
|
||||
id: "project_123",
|
||||
name: "My Project",
|
||||
},
|
||||
});
|
||||
expect(mocks.getSessionUser).not.toHaveBeenCalled();
|
||||
expect(mocks.applyRateLimit).toHaveBeenCalledWith(expect.any(Object), apiKeyData.id);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { getSessionUser } from "@/app/api/v1/management/me/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { CONTROL_HASH } from "@/lib/constants";
|
||||
import { hashSha256, parseApiKeyV2, verifySecret } from "@/lib/crypto";
|
||||
import { publicUserSelect } from "@/lib/user/public-user";
|
||||
import { applyRateLimit } from "@/modules/core/rate-limit/helpers";
|
||||
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
|
||||
|
||||
@@ -176,6 +177,7 @@ const handleSessionAuthentication = async () => {
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: sessionUser.id },
|
||||
select: publicUserSelect,
|
||||
});
|
||||
|
||||
return Response.json(user);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
export const publicUserSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
twoFactorEnabled: true,
|
||||
identityProvider: true,
|
||||
notificationSettings: true,
|
||||
locale: true,
|
||||
lastLoginAt: true,
|
||||
isActive: true,
|
||||
} as const satisfies Prisma.UserSelect;
|
||||
|
||||
export type TPublicUser = Prisma.UserGetPayload<{
|
||||
select: typeof publicUserSelect;
|
||||
}>;
|
||||
@@ -6,6 +6,7 @@ import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TOrganization } from "@formbricks/types/organizations";
|
||||
import { TUserLocale, TUserUpdateInput } from "@formbricks/types/user";
|
||||
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { publicUserSelect } from "./public-user";
|
||||
import { deleteUser, getUser, getUserByEmail, getUsersWithOrganization, updateUser } from "./service";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
@@ -47,11 +48,6 @@ describe("User Service", () => {
|
||||
locale: "en-US" as TUserLocale,
|
||||
lastLoginAt: new Date(),
|
||||
isActive: true,
|
||||
twoFactorSecret: null,
|
||||
backupCodes: null,
|
||||
password: null,
|
||||
identityProviderAccountId: null,
|
||||
groupId: null,
|
||||
};
|
||||
|
||||
const mockOrganizations: TOrganization[] = [
|
||||
@@ -102,8 +98,12 @@ describe("User Service", () => {
|
||||
expect(result).toEqual(mockPrismaUser);
|
||||
expect(prisma.user.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: "user1" },
|
||||
select: expect.any(Object),
|
||||
select: publicUserSelect,
|
||||
});
|
||||
expect(result).not.toHaveProperty("password");
|
||||
expect(result).not.toHaveProperty("twoFactorSecret");
|
||||
expect(result).not.toHaveProperty("backupCodes");
|
||||
expect(result).not.toHaveProperty("identityProviderAccountId");
|
||||
});
|
||||
|
||||
test("should return null when user not found", async () => {
|
||||
@@ -134,7 +134,7 @@ describe("User Service", () => {
|
||||
expect(result).toEqual(mockPrismaUser);
|
||||
expect(prisma.user.findFirst).toHaveBeenCalledWith({
|
||||
where: { email: "test@example.com" },
|
||||
select: expect.any(Object),
|
||||
select: publicUserSelect,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,7 +176,7 @@ describe("User Service", () => {
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user1" },
|
||||
data: updateData,
|
||||
select: expect.any(Object),
|
||||
select: publicUserSelect,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -204,7 +204,7 @@ describe("User Service", () => {
|
||||
expect(deleteOrganization).toHaveBeenCalledWith("org1");
|
||||
expect(prisma.user.delete).toHaveBeenCalledWith({
|
||||
where: { id: "user1" },
|
||||
select: expect.any(Object),
|
||||
select: publicUserSelect,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -236,7 +236,7 @@ describe("User Service", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
select: expect.any(Object),
|
||||
select: publicUserSelect,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,21 +10,7 @@ import { TUser, TUserLocale, TUserUpdateInput, ZUserUpdateInput } from "@formbri
|
||||
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
|
||||
import { deleteBrevoCustomerByEmail } from "@/modules/auth/lib/brevo";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
const responseSelection = {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
emailVerified: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
twoFactorEnabled: true,
|
||||
identityProvider: true,
|
||||
notificationSettings: true,
|
||||
locale: true,
|
||||
lastLoginAt: true,
|
||||
isActive: true,
|
||||
};
|
||||
import { publicUserSelect } from "./public-user";
|
||||
|
||||
// function to retrive basic information about a user's user
|
||||
export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
|
||||
@@ -35,7 +21,7 @@ export const getUser = reactCache(async (id: string): Promise<TUser | null> => {
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: responseSelection,
|
||||
select: publicUserSelect,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
@@ -59,7 +45,7 @@ export const getUserByEmail = reactCache(async (email: string): Promise<TUser |
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
select: responseSelection,
|
||||
select: publicUserSelect,
|
||||
});
|
||||
|
||||
return user;
|
||||
@@ -82,7 +68,7 @@ export const updateUser = async (personId: string, data: TUserUpdateInput): Prom
|
||||
id: personId,
|
||||
},
|
||||
data: data,
|
||||
select: responseSelection,
|
||||
select: publicUserSelect,
|
||||
});
|
||||
|
||||
return updatedUser;
|
||||
@@ -105,7 +91,7 @@ const deleteUserById = async (id: string): Promise<TUser> => {
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: responseSelection,
|
||||
select: publicUserSelect,
|
||||
});
|
||||
return user;
|
||||
} catch (error) {
|
||||
@@ -153,7 +139,7 @@ export const getUsersWithOrganization = async (organizationId: string): Promise<
|
||||
},
|
||||
},
|
||||
},
|
||||
select: responseSelection,
|
||||
select: publicUserSelect,
|
||||
});
|
||||
|
||||
return users;
|
||||
@@ -174,7 +160,7 @@ export const getUserLocale = reactCache(async (id: string): Promise<TUserLocale
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: responseSelection,
|
||||
select: publicUserSelect,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
|
||||
@@ -13,6 +13,10 @@ const mockUser = {
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
isActive: true,
|
||||
password: "$2b$12$hashedPassword",
|
||||
twoFactorSecret: "encrypted-2fa-secret",
|
||||
backupCodes: "encrypted-backup-codes",
|
||||
identityProviderAccountId: "provider-account-id",
|
||||
role: "admin",
|
||||
memberships: [{ organizationId: "org456", role: "admin" }],
|
||||
teamUsers: [{ team: { name: "Test Team", id: "team123", projectTeams: [{ projectId: "proj789" }] } }],
|
||||
@@ -60,6 +64,10 @@ describe("Users Lib", () => {
|
||||
updatedAt: expect.any(Date),
|
||||
},
|
||||
]);
|
||||
expect(result.data.data[0]).not.toHaveProperty("password");
|
||||
expect(result.data.data[0]).not.toHaveProperty("twoFactorSecret");
|
||||
expect(result.data.data[0]).not.toHaveProperty("backupCodes");
|
||||
expect(result.data.data[0]).not.toHaveProperty("identityProviderAccountId");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -84,6 +92,10 @@ describe("Users Lib", () => {
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.id).toBe(mockUser.id);
|
||||
expect(result.data).not.toHaveProperty("password");
|
||||
expect(result.data).not.toHaveProperty("twoFactorSecret");
|
||||
expect(result.data).not.toHaveProperty("backupCodes");
|
||||
expect(result.data).not.toHaveProperty("identityProviderAccountId");
|
||||
}
|
||||
});
|
||||
|
||||
@@ -151,6 +163,10 @@ describe("Users Lib", () => {
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.data.name).toBe("Updated User");
|
||||
expect(result.data).not.toHaveProperty("password");
|
||||
expect(result.data).not.toHaveProperty("twoFactorSecret");
|
||||
expect(result.data).not.toHaveProperty("backupCodes");
|
||||
expect(result.data).not.toHaveProperty("identityProviderAccountId");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { test } from "../../lib/fixtures";
|
||||
|
||||
test.describe("API Tests for Management Me", () => {
|
||||
test("Authenticated v1 me endpoint never exposes secret auth fields", async ({ page, users }) => {
|
||||
const name = `Security Me User ${Date.now()}`;
|
||||
const email = `security-me-${Date.now()}@example.com`;
|
||||
|
||||
try {
|
||||
const user = await users.create({ name, email });
|
||||
await user.login();
|
||||
|
||||
const response = await page.context().request.get("/api/v1/management/me");
|
||||
expect(response.ok()).toBe(true);
|
||||
|
||||
const responseBody = await response.json();
|
||||
|
||||
expect(responseBody).toMatchObject({
|
||||
id: expect.any(String),
|
||||
name,
|
||||
email,
|
||||
twoFactorEnabled: expect.any(Boolean),
|
||||
identityProvider: expect.any(String),
|
||||
notificationSettings: expect.any(Object),
|
||||
locale: expect.any(String),
|
||||
isActive: expect.any(Boolean),
|
||||
});
|
||||
expect(responseBody).not.toHaveProperty("password");
|
||||
expect(responseBody).not.toHaveProperty("twoFactorSecret");
|
||||
expect(responseBody).not.toHaveProperty("backupCodes");
|
||||
expect(responseBody).not.toHaveProperty("identityProviderAccountId");
|
||||
} catch (error) {
|
||||
logger.error(error, "Error during management me API security test");
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user