Files
formbricks/apps/web/app/api/v3/surveys/route.test.ts
T
2026-03-20 09:55:33 +00:00

358 lines
13 KiB
TypeScript

import { ApiKeyPermission, EnvironmentType } from "@prisma/client";
import { NextRequest } from "next/server";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
import { requireV3WorkspaceAccess } from "@/app/api/v3/lib/auth";
import { getSurveyCount } from "@/modules/survey/list/lib/survey";
import { getSurveyListPage } from "@/modules/survey/list/lib/survey-page";
import { GET } from "./route";
const { mockAuthenticateRequest } = vi.hoisted(() => ({
mockAuthenticateRequest: vi.fn(),
}));
vi.mock("next-auth", () => ({
getServerSession: vi.fn(),
}));
vi.mock("@/app/api/v1/auth", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/app/api/v1/auth")>();
return { ...actual, authenticateRequest: mockAuthenticateRequest };
});
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyRateLimit: vi.fn().mockResolvedValue(undefined),
applyIPRateLimit: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return { ...actual, AUDIT_LOG_ENABLED: false };
});
vi.mock("@/app/api/v3/lib/auth", () => ({
requireV3WorkspaceAccess: vi.fn(),
}));
vi.mock("@/modules/survey/list/lib/survey-page", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/modules/survey/list/lib/survey-page")>();
return {
...actual,
getSurveyListPage: vi.fn(),
};
});
vi.mock("@/modules/survey/list/lib/survey", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/modules/survey/list/lib/survey")>();
return {
...actual,
getSurveyCount: vi.fn(),
};
});
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
warn: vi.fn(),
error: vi.fn(),
})),
},
}));
const getServerSession = vi.mocked((await import("next-auth")).getServerSession);
const validWorkspaceId = "clxx1234567890123456789012";
const resolvedEnvironmentId = "clzz9876543210987654321098";
function createRequest(url: string, requestId?: string, extraHeaders?: Record<string, string>): NextRequest {
const headers: Record<string, string> = { ...extraHeaders };
if (requestId) headers["x-request-id"] = requestId;
return new NextRequest(url, { headers });
}
const apiKeyAuth = {
type: "apiKey" as const,
apiKeyId: "key_1",
organizationId: "org_1",
organizationAccess: {
accessControl: { read: true, write: false },
},
environmentPermissions: [
{
environmentId: validWorkspaceId,
environmentType: EnvironmentType.development,
projectId: "proj_1",
projectName: "P",
permission: ApiKeyPermission.read,
},
],
};
describe("GET /api/v3/surveys", () => {
beforeEach(() => {
vi.resetAllMocks();
getServerSession.mockResolvedValue({
user: { id: "user_1", name: "User", email: "u@example.com" },
expires: "2026-01-01",
} as any);
mockAuthenticateRequest.mockResolvedValue(null);
vi.mocked(requireV3WorkspaceAccess).mockImplementation(async (auth, workspaceId) => {
if (auth && "apiKeyId" in auth) {
const p = auth.environmentPermissions.find((e) => e.environmentId === workspaceId);
if (!p) {
return new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
);
}
return {
environmentId: workspaceId,
projectId: p.projectId,
organizationId: auth.organizationId,
};
}
return {
environmentId: resolvedEnvironmentId,
projectId: "proj_1",
organizationId: "org_1",
};
});
vi.mocked(getSurveyListPage).mockResolvedValue({ surveys: [], nextCursor: null });
vi.mocked(getSurveyCount).mockResolvedValue(0);
});
afterEach(() => {
vi.clearAllMocks();
});
test("returns 401 when no session and no API key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(null);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
const res = await GET(req, {} as any);
expect(res.status).toBe(401);
expect(res.headers.get("Content-Type")).toBe("application/problem+json");
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 200 with session and valid workspaceId", async () => {
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-456");
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("application/json");
expect(res.headers.get("X-Request-Id")).toBe("req-456");
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ user: expect.any(Object) }),
validWorkspaceId,
"read",
"req-456",
"/api/v3/surveys"
);
expect(getSurveyListPage).toHaveBeenCalledWith(resolvedEnvironmentId, {
limit: 20,
cursor: null,
sortBy: "updatedAt",
filterCriteria: undefined,
});
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, undefined);
});
test("returns 200 with x-api-key when workspace is on the key", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue(apiKeyAuth as any);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-k", {
"x-api-key": "fbk_test",
});
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
expect(requireV3WorkspaceAccess).toHaveBeenCalledWith(
expect.objectContaining({ apiKeyId: "key_1" }),
validWorkspaceId,
"read",
"req-k",
"/api/v3/surveys"
);
expect(getSurveyListPage).toHaveBeenCalledWith(validWorkspaceId, {
limit: 20,
cursor: null,
sortBy: "updatedAt",
filterCriteria: undefined,
});
expect(getSurveyCount).toHaveBeenCalledWith(validWorkspaceId, undefined);
});
test("returns 403 when API key does not include workspace", async () => {
getServerSession.mockResolvedValue(null);
mockAuthenticateRequest.mockResolvedValue({
...apiKeyAuth,
environmentPermissions: [
{
environmentId: "claa1111111111111111111111",
environmentType: EnvironmentType.development,
projectId: "proj_x",
projectName: "X",
permission: ApiKeyPermission.read,
},
],
} as any);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, undefined, {
"x-api-key": "fbk_test",
});
const res = await GET(req, {} as any);
expect(res.status).toBe(403);
});
test("returns 400 when the createdBy filter is used", async () => {
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filter[createdBy][in]=you`
);
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.invalid_params?.some((p: { name: string }) => p.name === "filter[createdBy][in]")).toBe(true);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 400 when workspaceId is missing", async () => {
const req = createRequest("http://localhost/api/v3/surveys");
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 400 when workspaceId is not cuid2", async () => {
const req = createRequest("http://localhost/api/v3/surveys?workspaceId=not-a-cuid");
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
});
test("returns 400 when limit exceeds max", async () => {
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&limit=101`);
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
});
test("reflects limit, nextCursor, and totalCount in meta", async () => {
vi.mocked(getSurveyListPage).mockResolvedValue({
surveys: [],
nextCursor: "cursor-123",
});
vi.mocked(getSurveyCount).mockResolvedValue(42);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&limit=10`);
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
const body = await res.json();
expect(body.meta).toEqual({ limit: 10, nextCursor: "cursor-123", totalCount: 42 });
expect(getSurveyListPage).toHaveBeenCalledWith(resolvedEnvironmentId, {
limit: 10,
cursor: null,
sortBy: "updatedAt",
filterCriteria: undefined,
});
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, undefined);
});
test("passes filter query to getSurveyListPage", async () => {
const filterCriteria = { status: ["inProgress"] };
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filter[status][in]=inProgress&sortBy=updatedAt`
);
const res = await GET(req, {} as any);
expect(res.status).toBe(200);
expect(getSurveyListPage).toHaveBeenCalledWith(resolvedEnvironmentId, {
limit: 20,
cursor: null,
sortBy: "updatedAt",
filterCriteria,
});
expect(getSurveyCount).toHaveBeenCalledWith(resolvedEnvironmentId, filterCriteria);
});
test("returns 400 when filterCriteria is used", async () => {
const req = createRequest(
`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}&filterCriteria=${encodeURIComponent("{}")}`
);
const res = await GET(req, {} as any);
expect(res.status).toBe(400);
expect(requireV3WorkspaceAccess).not.toHaveBeenCalled();
});
test("returns 403 when auth returns 403", async () => {
vi.mocked(requireV3WorkspaceAccess).mockResolvedValueOnce(
new Response(
JSON.stringify({
title: "Forbidden",
status: 403,
detail: "You are not authorized to access this resource",
requestId: "req-789",
}),
{ status: 403, headers: { "Content-Type": "application/problem+json" } }
)
);
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
const res = await GET(req, {} as any);
expect(res.status).toBe(403);
});
test("list items expose workspaceId instead of environmentId and omit internal fields", async () => {
vi.mocked(getSurveyListPage).mockResolvedValue({
surveys: [
{
id: "s1",
name: "Survey 1",
environmentId: "env_1",
type: "link",
status: "draft",
createdAt: new Date(),
updatedAt: new Date(),
responseCount: 0,
creator: { name: "Test" },
singleUse: null,
} as any,
],
nextCursor: null,
});
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`);
const res = await GET(req, {} as any);
const body = await res.json();
expect(body.data[0]).not.toHaveProperty("blocks");
expect(body.data[0]).not.toHaveProperty("singleUse");
expect(body.data[0]).not.toHaveProperty("_count");
expect(body.data[0]).not.toHaveProperty("environmentId");
expect(body.data[0].id).toBe("s1");
expect(body.data[0].workspaceId).toBe("env_1");
});
test("returns 403 when getSurveyListPage throws ResourceNotFoundError", async () => {
vi.mocked(getSurveyListPage).mockRejectedValueOnce(new ResourceNotFoundError("survey", "s1"));
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-nf");
const res = await GET(req, {} as any);
expect(res.status).toBe(403);
const body = await res.json();
expect(body.code).toBe("forbidden");
});
test("returns 500 when getSurveyListPage throws DatabaseError", async () => {
vi.mocked(getSurveyListPage).mockRejectedValueOnce(new DatabaseError("db down"));
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-db");
const res = await GET(req, {} as any);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
test("returns 500 on unexpected error from getSurveyListPage", async () => {
vi.mocked(getSurveyListPage).mockRejectedValueOnce(new Error("boom"));
const req = createRequest(`http://localhost/api/v3/surveys?workspaceId=${validWorkspaceId}`, "req-err");
const res = await GET(req, {} as any);
expect(res.status).toBe(500);
const body = await res.json();
expect(body.code).toBe("internal_server_error");
});
});