mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-06 19:35:53 -05:00
457 lines
13 KiB
TypeScript
457 lines
13 KiB
TypeScript
import { NextRequest } from "next/server";
|
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
|
import { z } from "zod";
|
|
import { TooManyRequestsError } from "@formbricks/types/errors";
|
|
import { withV3ApiWrapper } from "./api-wrapper";
|
|
|
|
const { mockAuthenticateRequest, mockGetServerSession } = vi.hoisted(() => ({
|
|
mockAuthenticateRequest: vi.fn(),
|
|
mockGetServerSession: vi.fn(),
|
|
}));
|
|
|
|
const { mockQueueAuditEvent, mockBuildAuditLogBaseObject } = vi.hoisted(() => ({
|
|
mockQueueAuditEvent: vi.fn().mockImplementation(async () => undefined),
|
|
mockBuildAuditLogBaseObject: vi.fn((action: string, targetType: string, apiUrl: string) => ({
|
|
action,
|
|
targetType,
|
|
userId: "unknown",
|
|
targetId: "unknown",
|
|
organizationId: "unknown",
|
|
status: "failure",
|
|
oldObject: undefined,
|
|
newObject: undefined,
|
|
userType: "api",
|
|
apiUrl,
|
|
})),
|
|
}));
|
|
|
|
vi.mock("next-auth", () => ({
|
|
getServerSession: mockGetServerSession,
|
|
}));
|
|
|
|
vi.mock("@/app/api/v1/auth", () => ({
|
|
authenticateRequest: mockAuthenticateRequest,
|
|
}));
|
|
|
|
vi.mock("@/modules/auth/lib/authOptions", () => ({
|
|
authOptions: {},
|
|
}));
|
|
|
|
vi.mock("@/modules/core/rate-limit/helpers", () => ({
|
|
applyRateLimit: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
|
queueAuditEvent: mockQueueAuditEvent,
|
|
}));
|
|
|
|
vi.mock("@/app/lib/api/with-api-logging", () => ({
|
|
buildAuditLogBaseObject: mockBuildAuditLogBaseObject,
|
|
}));
|
|
|
|
vi.mock("@formbricks/logger", () => ({
|
|
logger: {
|
|
withContext: vi.fn(() => ({
|
|
error: vi.fn(),
|
|
warn: vi.fn(),
|
|
})),
|
|
},
|
|
}));
|
|
|
|
describe("withV3ApiWrapper", () => {
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
mockGetServerSession.mockResolvedValue(null);
|
|
mockAuthenticateRequest.mockResolvedValue(null);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
test("passes an audit log to the handler and queues success after the response", async () => {
|
|
const { queueAuditEvent } = await import("@/modules/ee/audit-logs/lib/handler");
|
|
|
|
mockGetServerSession.mockResolvedValue({
|
|
user: { id: "user_1", name: "Test", email: "t@example.com" },
|
|
expires: "2026-01-01",
|
|
});
|
|
|
|
const handler = vi.fn(async ({ auditLog }) => {
|
|
expect(auditLog).toEqual(
|
|
expect.objectContaining({
|
|
action: "deleted",
|
|
targetType: "survey",
|
|
userId: "user_1",
|
|
userType: "user",
|
|
status: "failure",
|
|
})
|
|
);
|
|
|
|
if (auditLog) {
|
|
auditLog.targetId = "survey_1";
|
|
auditLog.organizationId = "org_1";
|
|
auditLog.oldObject = { id: "survey_1" };
|
|
}
|
|
|
|
return Response.json({ ok: true });
|
|
});
|
|
|
|
const wrapped = withV3ApiWrapper({
|
|
auth: "both",
|
|
action: "deleted",
|
|
targetType: "survey",
|
|
handler,
|
|
});
|
|
|
|
const response = await wrapped(
|
|
new NextRequest("http://localhost/api/v3/surveys/survey_1", {
|
|
method: "DELETE",
|
|
headers: { "x-request-id": "req-audit" },
|
|
}),
|
|
{} as never
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(queueAuditEvent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
action: "deleted",
|
|
targetType: "survey",
|
|
targetId: "survey_1",
|
|
organizationId: "org_1",
|
|
userId: "user_1",
|
|
userType: "user",
|
|
status: "success",
|
|
oldObject: { id: "survey_1" },
|
|
})
|
|
);
|
|
});
|
|
|
|
test("queues a failure audit log when the handler returns a non-ok response", async () => {
|
|
const { queueAuditEvent } = await import("@/modules/ee/audit-logs/lib/handler");
|
|
|
|
mockAuthenticateRequest.mockResolvedValue({
|
|
type: "apiKey",
|
|
apiKeyId: "key_1",
|
|
organizationId: "org_1",
|
|
organizationAccess: { accessControl: { read: true, write: true } },
|
|
environmentPermissions: [],
|
|
});
|
|
|
|
const wrapped = withV3ApiWrapper({
|
|
auth: "both",
|
|
action: "deleted",
|
|
targetType: "survey",
|
|
handler: async ({ auditLog }) => {
|
|
if (auditLog) {
|
|
auditLog.targetId = "survey_2";
|
|
}
|
|
|
|
return new Response("forbidden", { status: 403 });
|
|
},
|
|
});
|
|
|
|
const response = await wrapped(
|
|
new NextRequest("http://localhost/api/v3/surveys/survey_2", {
|
|
method: "DELETE",
|
|
headers: {
|
|
"x-request-id": "req-failure-audit",
|
|
"x-api-key": "fbk_test",
|
|
},
|
|
}),
|
|
{} as never
|
|
);
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(queueAuditEvent).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
action: "deleted",
|
|
targetType: "survey",
|
|
targetId: "survey_2",
|
|
organizationId: "org_1",
|
|
userId: "key_1",
|
|
userType: "api",
|
|
status: "failure",
|
|
eventId: "req-failure-audit",
|
|
})
|
|
);
|
|
});
|
|
|
|
test("uses session auth first in both mode and injects request id into plain responses", async () => {
|
|
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
|
mockGetServerSession.mockResolvedValue({
|
|
user: { id: "user_1", name: "Test", email: "t@example.com" },
|
|
expires: "2026-01-01",
|
|
});
|
|
|
|
const handler = vi.fn(async ({ authentication, requestId, instance }) => {
|
|
expect(authentication).toMatchObject({ user: { id: "user_1" } });
|
|
expect(requestId).toBe("req-1");
|
|
expect(instance).toBe("/api/v3/surveys");
|
|
return Response.json({ ok: true });
|
|
});
|
|
|
|
const wrapped = withV3ApiWrapper({
|
|
auth: "both",
|
|
handler,
|
|
});
|
|
|
|
const response = await wrapped(
|
|
new NextRequest("http://localhost/api/v3/surveys?limit=10", {
|
|
headers: { "x-request-id": "req-1" },
|
|
}),
|
|
{} as never
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("X-Request-Id")).toBe("req-1");
|
|
expect(handler).toHaveBeenCalledOnce();
|
|
expect(vi.mocked(applyRateLimit)).toHaveBeenCalledWith(
|
|
expect.objectContaining({ namespace: "api:v3" }),
|
|
"user_1"
|
|
);
|
|
expect(mockAuthenticateRequest).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("falls back to api key auth in both mode", async () => {
|
|
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
|
mockAuthenticateRequest.mockResolvedValue({
|
|
type: "apiKey",
|
|
apiKeyId: "key_1",
|
|
organizationId: "org_1",
|
|
organizationAccess: { accessControl: { read: true, write: false } },
|
|
environmentPermissions: [],
|
|
});
|
|
|
|
const handler = vi.fn(async ({ authentication }) => {
|
|
expect(authentication).toMatchObject({ apiKeyId: "key_1" });
|
|
return Response.json({ ok: true });
|
|
});
|
|
|
|
const wrapped = withV3ApiWrapper({
|
|
auth: "both",
|
|
handler,
|
|
});
|
|
|
|
const response = await wrapped(
|
|
new NextRequest("http://localhost/api/v3/surveys", {
|
|
headers: { "x-api-key": "fbk_test" },
|
|
}),
|
|
{} as never
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(vi.mocked(applyRateLimit)).toHaveBeenCalledWith(
|
|
expect.objectContaining({ namespace: "api:v3" }),
|
|
"key_1"
|
|
);
|
|
expect(mockGetServerSession).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("returns 401 problem response when authentication is required but missing", async () => {
|
|
const handler = vi.fn(async () => Response.json({ ok: true }));
|
|
const wrapped = withV3ApiWrapper({
|
|
auth: "both",
|
|
handler,
|
|
});
|
|
|
|
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {} as never);
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(handler).not.toHaveBeenCalled();
|
|
expect(response.headers.get("Content-Type")).toBe("application/problem+json");
|
|
});
|
|
|
|
test("returns 400 problem response for invalid query input", async () => {
|
|
mockGetServerSession.mockResolvedValue({
|
|
user: { id: "user_1" },
|
|
expires: "2026-01-01",
|
|
});
|
|
|
|
const handler = vi.fn(async () => Response.json({ ok: true }));
|
|
const wrapped = withV3ApiWrapper({
|
|
auth: "both",
|
|
schemas: {
|
|
query: z.object({
|
|
limit: z.coerce.number().int().positive(),
|
|
}),
|
|
},
|
|
handler,
|
|
});
|
|
|
|
const response = await wrapped(
|
|
new NextRequest("http://localhost/api/v3/surveys?limit=oops", {
|
|
headers: { "x-request-id": "req-invalid" },
|
|
}),
|
|
{} as never
|
|
);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(handler).not.toHaveBeenCalled();
|
|
const body = await response.json();
|
|
expect(body.invalid_params).toEqual(expect.arrayContaining([expect.objectContaining({ name: "limit" })]));
|
|
expect(body.requestId).toBe("req-invalid");
|
|
});
|
|
|
|
test("parses body, repeated query params, and async route params", async () => {
|
|
const handler = vi.fn(async ({ parsedInput }) => {
|
|
expect(parsedInput).toEqual({
|
|
body: { name: "Survey API" },
|
|
query: { tag: ["a", "b"] },
|
|
params: { workspaceId: "ws_123" },
|
|
});
|
|
|
|
return Response.json(
|
|
{ ok: true },
|
|
{
|
|
headers: {
|
|
"X-Request-Id": "handler-request-id",
|
|
},
|
|
}
|
|
);
|
|
});
|
|
|
|
const wrapped = withV3ApiWrapper({
|
|
auth: "none",
|
|
schemas: {
|
|
body: z.object({
|
|
name: z.string(),
|
|
}),
|
|
query: z.object({
|
|
tag: z.array(z.string()),
|
|
}),
|
|
params: z.object({
|
|
workspaceId: z.string(),
|
|
}),
|
|
},
|
|
handler,
|
|
});
|
|
|
|
const response = await wrapped(
|
|
new NextRequest("http://localhost/api/v3/surveys?tag=a&tag=b", {
|
|
method: "POST",
|
|
body: JSON.stringify({ name: "Survey API" }),
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
}),
|
|
{
|
|
params: Promise.resolve({
|
|
workspaceId: "ws_123",
|
|
}),
|
|
} as never
|
|
);
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.headers.get("X-Request-Id")).toBe("handler-request-id");
|
|
expect(handler).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
test("returns 400 problem response for malformed JSON input", async () => {
|
|
const handler = vi.fn(async () => Response.json({ ok: true }));
|
|
const wrapped = withV3ApiWrapper({
|
|
auth: "none",
|
|
schemas: {
|
|
body: z.object({
|
|
name: z.string(),
|
|
}),
|
|
},
|
|
handler,
|
|
});
|
|
|
|
const response = await wrapped(
|
|
new NextRequest("http://localhost/api/v3/surveys", {
|
|
method: "POST",
|
|
body: "{",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
}),
|
|
{} as never
|
|
);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(handler).not.toHaveBeenCalled();
|
|
const body = await response.json();
|
|
expect(body.invalid_params).toEqual([
|
|
{
|
|
name: "body",
|
|
reason: "Malformed JSON input, please check your request body",
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("returns 400 problem response for invalid route params", async () => {
|
|
const handler = vi.fn(async () => Response.json({ ok: true }));
|
|
const wrapped = withV3ApiWrapper({
|
|
auth: "none",
|
|
schemas: {
|
|
params: z.object({
|
|
workspaceId: z.string().min(3),
|
|
}),
|
|
},
|
|
handler,
|
|
});
|
|
|
|
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {
|
|
params: Promise.resolve({
|
|
workspaceId: "x",
|
|
}),
|
|
} as never);
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(handler).not.toHaveBeenCalled();
|
|
const body = await response.json();
|
|
expect(body.invalid_params).toEqual(
|
|
expect.arrayContaining([expect.objectContaining({ name: "workspaceId" })])
|
|
);
|
|
});
|
|
|
|
test("returns 429 problem response when rate limited", async () => {
|
|
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
|
mockGetServerSession.mockResolvedValue({
|
|
user: { id: "user_1" },
|
|
expires: "2026-01-01",
|
|
});
|
|
vi.mocked(applyRateLimit).mockRejectedValueOnce(new TooManyRequestsError("Too many requests", 60));
|
|
|
|
const wrapped = withV3ApiWrapper({
|
|
auth: "both",
|
|
handler: async () => Response.json({ ok: true }),
|
|
});
|
|
|
|
const response = await wrapped(new NextRequest("http://localhost/api/v3/surveys"), {} as never);
|
|
|
|
expect(response.status).toBe(429);
|
|
expect(response.headers.get("Retry-After")).toBe("60");
|
|
const body = await response.json();
|
|
expect(body.code).toBe("too_many_requests");
|
|
});
|
|
|
|
test("returns 500 problem response when the handler throws unexpectedly", async () => {
|
|
mockGetServerSession.mockResolvedValue({
|
|
user: { id: "user_1" },
|
|
expires: "2026-01-01",
|
|
});
|
|
|
|
const wrapped = withV3ApiWrapper({
|
|
auth: "both",
|
|
handler: async () => {
|
|
throw new Error("boom");
|
|
},
|
|
});
|
|
|
|
const response = await wrapped(
|
|
new NextRequest("http://localhost/api/v3/surveys", {
|
|
headers: { "x-request-id": "req-boom" },
|
|
}),
|
|
{} as never
|
|
);
|
|
|
|
expect(response.status).toBe(500);
|
|
const body = await response.json();
|
|
expect(body.code).toBe("internal_server_error");
|
|
expect(body.requestId).toBe("req-boom");
|
|
});
|
|
});
|