Compare commits

...

9 Commits

Author SHA1 Message Date
Tiago Farto
800eea661a chore: increase test coverage 2026-04-01 14:20:01 +00:00
Tiago Farto
e090588d3b Merge branch 'main' into fix/sentry_logging_api_bug 2026-04-01 14:08:51 +00:00
Tiago Farto
5477839b16 chore: address PR concerns 2026-04-01 14:07:58 +00:00
Matti Nannt
6c34c316d0 docs: remove non-official self-hosting options from README.md 2026-04-01 14:16:47 +02:00
Tiago Farto
695fe59ae2 chore: fix 2026-03-31 12:34:11 +00:00
Tiago Farto
6a34839782 chore: address PR concerns 2026-03-31 12:17:16 +00:00
Tiago Farto
19dbede5ca chore: additional test 2026-03-31 11:34:05 +00:00
Tiago Farto
7e9faa1cad chore: address pr concerns; fix more sentry features 2026-03-31 11:22:19 +00:00
Tiago Farto
47ac621e54 fix: Sentry logging api 2026-03-31 10:03:02 +00:00
26 changed files with 2600 additions and 606 deletions

View File

@@ -127,34 +127,10 @@ Formbricks has a hosted cloud offering with a generous free plan to get you up a
Formbricks is available Open-Source under AGPLv3 license. You can host Formbricks on your own servers using Docker without a subscription.
If you opt for self-hosting Formbricks, here are a few options to consider:
#### Docker
To get started with self-hosting with Docker, take a look at our [self-hosting docs](https://formbricks.com/docs/self-hosting/deployment).
#### Community-managed One Click Hosting
##### Railway
You can deploy Formbricks on [Railway](https://railway.app) using the button below.
[![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template/PPDzCd)
##### RepoCloud
Or you can also deploy Formbricks on [RepoCloud](https://repocloud.io) using the button below.
[![Deploy on RepoCloud](https://d16t0pc4846x52.cloudfront.net/deploy.png)](https://repocloud.io/details/?app_id=254)
##### Zeabur
Or you can also deploy Formbricks on [Zeabur](https://zeabur.com) using the button below.
[![Deploy to Zeabur](https://zeabur.com/button.svg)](https://zeabur.com/templates/G4TUJL)
<a id="development"></a>
## 👨‍💻 Development
### Prerequisites
@@ -248,14 +224,3 @@ We currently do not offer Formbricks white-labeled. That means that we don't sel
The Enterprise Edition allows us to fund the development of Formbricks sustainably. It guarantees that the free and open-source surveying infrastructure we're building will be around for decades to come.
<a id="readme-de"></a>
## Deutsch
Formbricks ist eine freie, quelloffene und datenschutzorientierte Plattform für Surveys und Experience Management. Mit In-App-, Website-, Link- und E-Mail-Umfragen sammelt ihr Feedback entlang der gesamten User Journey.
- Website & Cloud: [formbricks.com](https://formbricks.com/) und [Cloud starten](https://app.formbricks.com/auth/signup)
- Self-Hosting: [Deployment-Dokumentation](https://formbricks.com/docs/self-hosting/deployment)
- Beitrag & Community: [Beitragen](https://formbricks.com/docs/developer-docs/contributing/get-started), [GitHub Discussions](https://github.com/formbricks/formbricks/discussions) und [Issues](https://github.com/formbricks/formbricks/issues)
- Sicherheit & Lizenz: [`SECURITY.md`](./SECURITY.md) und [AGPLv3](https://github.com/formbricks/formbricks/blob/main/LICENSE)
<p align="right"><a href="#top">🔼 Back to top</a></p>

View File

@@ -86,9 +86,11 @@ export const GET = withV1ApiWrapper({
};
}
const error = err instanceof Error ? err : new Error(String(err));
logger.error(
{
error: err,
error,
url: req.url,
environmentId: params.environmentId,
},
@@ -96,9 +98,10 @@ export const GET = withV1ApiWrapper({
);
return {
response: responses.internalServerErrorResponse(
err instanceof Error ? err.message : "Unknown error occurred",
"An error occurred while processing your request.",
true
),
error,
};
}
},

View File

@@ -0,0 +1,488 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { responses } from "@/app/lib/api/response";
import { putResponseHandler } from "./put-response-handler";
const mocks = vi.hoisted(() => ({
formatValidationErrorsForV1Api: vi.fn((errors) => errors),
getResponse: vi.fn(),
getSurvey: vi.fn(),
getValidatedResponseUpdateInput: vi.fn(),
loggerError: vi.fn(),
sendToPipeline: vi.fn(),
updateResponseWithQuotaEvaluation: vi.fn(),
validateFileUploads: vi.fn(),
validateOtherOptionLengthForMultipleChoice: vi.fn(),
validateResponseData: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: mocks.loggerError,
},
}));
vi.mock("@/app/lib/pipelines", () => ({
sendToPipeline: mocks.sendToPipeline,
}));
vi.mock("@/lib/response/service", () => ({
getResponse: mocks.getResponse,
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: mocks.getSurvey,
}));
vi.mock("@/modules/api/lib/validation", () => ({
formatValidationErrorsForV1Api: mocks.formatValidationErrorsForV1Api,
validateResponseData: mocks.validateResponseData,
}));
vi.mock("@/modules/api/v2/lib/element", () => ({
validateOtherOptionLengthForMultipleChoice: mocks.validateOtherOptionLengthForMultipleChoice,
}));
vi.mock("@/modules/storage/utils", () => ({
validateFileUploads: mocks.validateFileUploads,
}));
vi.mock("./response", () => ({
updateResponseWithQuotaEvaluation: mocks.updateResponseWithQuotaEvaluation,
}));
vi.mock("./validated-response-update-input", () => ({
getValidatedResponseUpdateInput: mocks.getValidatedResponseUpdateInput,
}));
const environmentId = "environment_a";
const responseId = "response_123";
const surveyId = "survey_123";
const createRequest = () =>
new Request(`https://api.test/api/v1/client/${environmentId}/responses/${responseId}`, {
method: "PUT",
});
const createHandlerParams = (params?: Partial<{ environmentId: string; responseId: string }>) =>
({
req: createRequest(),
props: {
params: Promise.resolve({
environmentId,
responseId,
...params,
}),
},
}) as never;
const getBaseResponseUpdateInput = () => ({
data: {
q1: "updated-answer",
},
language: "en",
});
const getBaseExistingResponse = () =>
({
id: responseId,
surveyId,
data: {
q0: "existing-answer",
},
finished: false,
language: "en",
}) as const;
const getBaseSurvey = () =>
({
id: surveyId,
environmentId,
blocks: [],
questions: [],
}) as const;
const getBaseUpdatedResponse = () =>
({
id: responseId,
surveyId,
data: {
q0: "existing-answer",
q1: "updated-answer",
},
finished: false,
quotaFull: undefined,
}) as const;
describe("putResponseHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getValidatedResponseUpdateInput.mockResolvedValue({
responseUpdateInput: getBaseResponseUpdateInput(),
});
mocks.getResponse.mockResolvedValue(getBaseExistingResponse());
mocks.getSurvey.mockResolvedValue(getBaseSurvey());
mocks.updateResponseWithQuotaEvaluation.mockResolvedValue(getBaseUpdatedResponse());
mocks.validateFileUploads.mockReturnValue(true);
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue(null);
mocks.validateResponseData.mockReturnValue(null);
});
test("returns a bad request response when the response id is missing", async () => {
const result = await putResponseHandler(createHandlerParams({ responseId: "" }));
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toEqual({
code: "bad_request",
message: "Response ID is missing",
details: {},
});
expect(mocks.getValidatedResponseUpdateInput).not.toHaveBeenCalled();
});
test("returns the validation response from the parsed request input", async () => {
const validationResponse = responses.badRequestResponse(
"Malformed JSON in request body",
undefined,
true
);
mocks.getValidatedResponseUpdateInput.mockResolvedValue({
response: validationResponse,
});
const result = await putResponseHandler(createHandlerParams());
expect(result.response).toBe(validationResponse);
expect(mocks.getResponse).not.toHaveBeenCalled();
});
test("returns not found when the response does not exist", async () => {
mocks.getResponse.mockResolvedValue(null);
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(404);
await expect(result.response.json()).resolves.toEqual({
code: "not_found",
message: "Response not found",
details: {
resource_id: responseId,
resource_type: "Response",
},
});
});
test("maps resource lookup errors to a not found response", async () => {
mocks.getResponse.mockRejectedValue(new ResourceNotFoundError("Response", responseId));
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(404);
await expect(result.response.json()).resolves.toEqual({
code: "not_found",
message: "Response not found",
details: {
resource_id: responseId,
resource_type: "Response",
},
});
});
test("maps invalid lookup input errors to a bad request response", async () => {
mocks.getResponse.mockRejectedValue(new InvalidInputError("Invalid response id"));
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toEqual({
code: "bad_request",
message: "Invalid response id",
details: {},
});
});
test("maps database lookup errors to a reported internal server error", async () => {
const error = new DatabaseError("Lookup failed");
mocks.getResponse.mockRejectedValue(error);
const result = await putResponseHandler(createHandlerParams());
expect(result.error).toBe(error);
expect(result.response.status).toBe(500);
await expect(result.response.json()).resolves.toEqual({
code: "internal_server_error",
message: "Lookup failed",
details: {},
});
expect(mocks.loggerError).toHaveBeenCalledWith(
{
error,
url: createRequest().url,
},
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
});
test("maps unknown lookup failures to a generic internal server error", async () => {
const error = new Error("boom");
mocks.getResponse.mockRejectedValue(error);
const result = await putResponseHandler(createHandlerParams());
expect(result.error).toBe(error);
expect(result.response.status).toBe(500);
await expect(result.response.json()).resolves.toEqual({
code: "internal_server_error",
message: "Unknown error occurred",
details: {},
});
});
test("rejects updates when the response survey does not belong to the requested environment", async () => {
mocks.getSurvey.mockResolvedValue({
...getBaseSurvey(),
environmentId: "different_environment",
});
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(404);
await expect(result.response.json()).resolves.toEqual({
code: "not_found",
message: "Response not found",
details: {
resource_id: responseId,
resource_type: "Response",
},
});
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
});
test("rejects updates when the response is already finished", async () => {
mocks.getResponse.mockResolvedValue({
...getBaseExistingResponse(),
finished: true,
});
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toEqual({
code: "bad_request",
message: "Response is already finished",
details: {},
});
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
});
test("rejects invalid file upload updates", async () => {
mocks.validateFileUploads.mockReturnValue(false);
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toEqual({
code: "bad_request",
message: "Invalid file upload response",
details: {},
});
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
});
test("rejects updates when an other-option response exceeds the character limit", async () => {
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue("question_123");
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toEqual({
code: "bad_request",
message: "Response exceeds character limit",
details: {
questionId: "question_123",
},
});
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
});
test("returns validation details when merged response data is invalid", async () => {
mocks.validateResponseData.mockReturnValue([{ field: "q1", message: "Required" }]);
mocks.formatValidationErrorsForV1Api.mockReturnValue({
q1: "Required",
});
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toEqual({
code: "bad_request",
message: "Validation failed",
details: {
q1: "Required",
},
});
expect(mocks.formatValidationErrorsForV1Api).toHaveBeenCalledWith([{ field: "q1", message: "Required" }]);
});
test("returns not found when the response disappears during update", async () => {
mocks.updateResponseWithQuotaEvaluation.mockRejectedValue(
new ResourceNotFoundError("Response", responseId)
);
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(404);
await expect(result.response.json()).resolves.toEqual({
code: "not_found",
message: "Response not found",
details: {
resource_id: responseId,
resource_type: "Response",
},
});
});
test("returns a bad request response for invalid update input during persistence", async () => {
mocks.updateResponseWithQuotaEvaluation.mockRejectedValue(
new InvalidInputError("Response update payload is invalid")
);
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toEqual({
code: "bad_request",
message: "Response update payload is invalid",
details: {},
});
});
test("returns a reported internal server error for database update failures", async () => {
const error = new DatabaseError("Update failed");
mocks.updateResponseWithQuotaEvaluation.mockRejectedValue(error);
const result = await putResponseHandler(createHandlerParams());
expect(result.error).toBe(error);
expect(result.response.status).toBe(500);
await expect(result.response.json()).resolves.toEqual({
code: "internal_server_error",
message: "Update failed",
details: {},
});
expect(mocks.loggerError).toHaveBeenCalledWith(
{
error,
url: createRequest().url,
},
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
});
test("returns a generic internal server error for unexpected update failures", async () => {
const error = new Error("Unexpected persistence failure");
mocks.updateResponseWithQuotaEvaluation.mockRejectedValue(error);
const result = await putResponseHandler(createHandlerParams());
expect(result.error).toBe(error);
expect(result.response.status).toBe(500);
await expect(result.response.json()).resolves.toEqual({
code: "internal_server_error",
message: "Something went wrong",
details: {},
});
expect(mocks.loggerError).toHaveBeenCalledWith(
{
error,
url: createRequest().url,
},
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
});
test("returns a success payload and emits a responseUpdated pipeline event", async () => {
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(200);
await expect(result.response.json()).resolves.toEqual({
data: {
id: responseId,
quotaFull: false,
},
});
expect(mocks.sendToPipeline).toHaveBeenCalledTimes(1);
expect(mocks.sendToPipeline).toHaveBeenCalledWith({
event: "responseUpdated",
environmentId,
surveyId,
response: {
id: responseId,
surveyId,
data: {
q0: "existing-answer",
q1: "updated-answer",
},
finished: false,
},
});
});
test("emits both pipeline events and includes quota metadata when the response finishes", async () => {
mocks.updateResponseWithQuotaEvaluation.mockResolvedValue({
...getBaseUpdatedResponse(),
finished: true,
quotaFull: {
id: "quota_123",
action: "endSurvey",
endingCardId: "ending_card_123",
},
});
const result = await putResponseHandler(createHandlerParams());
expect(result.response.status).toBe(200);
await expect(result.response.json()).resolves.toEqual({
data: {
id: responseId,
quotaFull: true,
quota: {
id: "quota_123",
action: "endSurvey",
endingCardId: "ending_card_123",
},
},
});
expect(mocks.sendToPipeline).toHaveBeenCalledTimes(2);
expect(mocks.sendToPipeline).toHaveBeenNthCalledWith(1, {
event: "responseUpdated",
environmentId,
surveyId,
response: {
id: responseId,
surveyId,
data: {
q0: "existing-answer",
q1: "updated-answer",
},
finished: true,
},
});
expect(mocks.sendToPipeline).toHaveBeenNthCalledWith(2, {
event: "responseFinished",
environmentId,
surveyId,
response: {
id: responseId,
surveyId,
data: {
q0: "existing-answer",
q1: "updated-answer",
},
finished: true,
},
});
});
});

View File

@@ -0,0 +1,283 @@
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseUpdateInput } from "@formbricks/types/responses";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { THandlerParams } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./response";
import { getValidatedResponseUpdateInput } from "./validated-response-update-input";
type TRouteResult = {
response: Response;
error?: unknown;
};
type TExistingResponseResult = { existingResponse: TResponse } | TRouteResult;
type TSurveyResult = { survey: TSurvey } | TRouteResult;
type TUpdatedResponseResult =
| { updatedResponse: Awaited<ReturnType<typeof updateResponseWithQuotaEvaluation>> }
| TRouteResult;
export type TPutRouteParams = {
params: Promise<{
environmentId: string;
responseId: string;
}>;
};
const handleDatabaseError = (
error: Error,
url: string,
endpoint: string,
responseId: string
): TRouteResult => {
if (error instanceof ResourceNotFoundError) {
return { response: responses.notFoundResponse("Response", responseId, true) };
}
if (error instanceof InvalidInputError) {
return { response: responses.badRequestResponse(error.message, undefined, true) };
}
if (error instanceof DatabaseError) {
logger.error({ error, url }, `Error in ${endpoint}`);
return {
response: responses.internalServerErrorResponse(error.message, true),
error,
};
}
return {
response: responses.internalServerErrorResponse("Unknown error occurred", true),
error,
};
};
const validateResponse = (
response: TResponse,
survey: TSurvey,
responseUpdateInput: TResponseUpdateInput
) => {
const mergedData = {
...response.data,
...responseUpdateInput.data,
};
const validationErrors = validateResponseData(
survey.blocks,
mergedData,
responseUpdateInput.language ?? response.language ?? "en",
survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
),
};
}
};
const getExistingResponse = async (req: Request, responseId: string): Promise<TExistingResponseResult> => {
try {
const existingResponse = await getResponse(responseId);
return existingResponse
? { existingResponse }
: { response: responses.notFoundResponse("Response", responseId, true) };
} catch (error) {
return handleDatabaseError(
error instanceof Error ? error : new Error(String(error)),
req.url,
"PUT /api/v1/client/[environmentId]/responses/[responseId]",
responseId
);
}
};
const getSurveyForResponse = async (
req: Request,
responseId: string,
surveyId: string
): Promise<TSurveyResult> => {
try {
const survey = await getSurvey(surveyId);
return survey ? { survey } : { response: responses.notFoundResponse("Survey", surveyId, true) };
} catch (error) {
return handleDatabaseError(
error instanceof Error ? error : new Error(String(error)),
req.url,
"PUT /api/v1/client/[environmentId]/responses/[responseId]",
responseId
);
}
};
const validateUpdateRequest = (
existingResponse: TResponse,
survey: TSurvey,
responseUpdateInput: TResponseUpdateInput
): TRouteResult | undefined => {
if (existingResponse.finished) {
return {
response: responses.badRequestResponse("Response is already finished", undefined, true),
};
}
if (!validateFileUploads(responseUpdateInput.data, survey.questions)) {
return {
response: responses.badRequestResponse("Invalid file upload response", undefined, true),
};
}
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: responseUpdateInput.data,
surveyQuestions: survey.questions as unknown as TSurveyElement[],
responseLanguage: responseUpdateInput.language,
});
if (otherResponseInvalidQuestionId) {
return {
response: responses.badRequestResponse(
`Response exceeds character limit`,
{
questionId: otherResponseInvalidQuestionId,
},
true
),
};
}
return validateResponse(existingResponse, survey, responseUpdateInput);
};
const getUpdatedResponse = async (
req: Request,
responseId: string,
responseUpdateInput: TResponseUpdateInput
): Promise<TUpdatedResponseResult> => {
try {
const updatedResponse = await updateResponseWithQuotaEvaluation(responseId, responseUpdateInput);
return { updatedResponse };
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Response", responseId, true),
};
}
if (error instanceof InvalidInputError) {
return {
response: responses.badRequestResponse(error.message),
};
}
if (error instanceof DatabaseError) {
logger.error(
{ error, url: req.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return {
response: responses.internalServerErrorResponse(error.message),
error,
};
}
const unexpectedError = error instanceof Error ? error : new Error(String(error));
logger.error(
{ error: unexpectedError, url: req.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return {
response: responses.internalServerErrorResponse("Something went wrong"),
error: unexpectedError,
};
}
};
export const putResponseHandler = async ({
req,
props,
}: THandlerParams<TPutRouteParams>): Promise<TRouteResult> => {
const params = await props.params;
const { environmentId, responseId } = params;
if (!responseId) {
return {
response: responses.badRequestResponse("Response ID is missing", undefined, true),
};
}
const validatedUpdateInput = await getValidatedResponseUpdateInput(req);
if ("response" in validatedUpdateInput) {
return validatedUpdateInput;
}
const { responseUpdateInput } = validatedUpdateInput;
const existingResponseResult = await getExistingResponse(req, responseId);
if ("response" in existingResponseResult) {
return existingResponseResult;
}
const { existingResponse } = existingResponseResult;
const surveyResult = await getSurveyForResponse(req, responseId, existingResponse.surveyId);
if ("response" in surveyResult) {
return surveyResult;
}
const { survey } = surveyResult;
if (survey.environmentId !== environmentId) {
return {
response: responses.notFoundResponse("Response", responseId, true),
};
}
const validationResult = validateUpdateRequest(existingResponse, survey, responseUpdateInput);
if (validationResult) {
return validationResult;
}
const updatedResponseResult = await getUpdatedResponse(req, responseId, responseUpdateInput);
if ("response" in updatedResponseResult) {
return updatedResponseResult;
}
const { updatedResponse } = updatedResponseResult;
const { quotaFull, ...responseData } = updatedResponse;
sendToPipeline({
event: "responseUpdated",
environmentId: survey.environmentId,
surveyId: survey.id,
response: responseData,
});
if (updatedResponse.finished) {
sendToPipeline({
event: "responseFinished",
environmentId: survey.environmentId,
surveyId: survey.id,
response: responseData,
});
}
const quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = {
id: responseData.id,
...quotaObj,
};
return {
response: responses.successResponse(responseDataWithQuota, true),
};
};

View File

@@ -0,0 +1,84 @@
import { describe, expect, test } from "vitest";
import { getValidatedResponseUpdateInput } from "./validated-response-update-input";
describe("getValidatedResponseUpdateInput", () => {
test("returns a bad request response for malformed JSON", async () => {
const request = new Request("http://localhost/api/v1/client/test/responses/response-id", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: "{invalid-json",
});
const result = await getValidatedResponseUpdateInput(request);
expect("response" in result).toBe(true);
if (!("response" in result)) {
throw new Error("Expected a response result");
}
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toEqual(
expect.objectContaining({
code: "bad_request",
message: "Malformed JSON in request body",
details: {
error: expect.any(String),
},
})
);
});
test("returns parsed response update input for valid JSON", async () => {
const request = new Request("http://localhost/api/v1/client/test/responses/response-id", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
finished: true,
}),
});
const result = await getValidatedResponseUpdateInput(request);
expect(result).toEqual({
responseUpdateInput: {
finished: true,
},
});
});
test("returns a bad request response for schema-invalid JSON", async () => {
const request = new Request("http://localhost/api/v1/client/test/responses/response-id", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
finished: "not-boolean",
}),
});
const result = await getValidatedResponseUpdateInput(request);
expect("response" in result).toBe(true);
if (!("response" in result)) {
throw new Error("Expected a response result");
}
expect(result.response.status).toBe(400);
await expect(result.response.json()).resolves.toEqual(
expect.objectContaining({
code: "bad_request",
message: "Fields are missing or incorrectly formatted",
details: expect.objectContaining({
finished: expect.any(String),
}),
})
);
});
});

View File

@@ -0,0 +1,28 @@
import { TResponseUpdateInput, ZResponseUpdateInput } from "@formbricks/types/responses";
import {
TParseAndValidateJsonBodyResult,
parseAndValidateJsonBody,
} from "@/app/lib/api/parse-and-validate-json-body";
export type TValidatedResponseUpdateInputResult =
| { response: Response }
| { responseUpdateInput: TResponseUpdateInput };
export const getValidatedResponseUpdateInput = async (
req: Request
): Promise<TValidatedResponseUpdateInputResult> => {
const validatedInput: TParseAndValidateJsonBodyResult<TResponseUpdateInput> =
await parseAndValidateJsonBody({
request: req,
schema: ZResponseUpdateInput,
malformedJsonMessage: "Malformed JSON in request body",
});
if ("response" in validatedInput) {
return {
response: validatedInput.response,
};
}
return { responseUpdateInput: validatedInput.data };
};

View File

@@ -1,235 +1,11 @@
import { logger } from "@formbricks/logger";
import { DatabaseError, InvalidInputError, ResourceNotFoundError } from "@formbricks/types/errors";
import { TResponse, TResponseUpdateInput, ZResponseUpdateInput } from "@formbricks/types/responses";
import { TSurveyElement } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { sendToPipeline } from "@/app/lib/pipelines";
import { getResponse } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
import { validateOtherOptionLengthForMultipleChoice } from "@/modules/api/v2/lib/element";
import { createQuotaFullObject } from "@/modules/ee/quotas/lib/helpers";
import { validateFileUploads } from "@/modules/storage/utils";
import { updateResponseWithQuotaEvaluation } from "./lib/response";
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { putResponseHandler } from "./lib/put-response-handler";
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse({}, true);
};
const handleDatabaseError = (error: Error, url: string, endpoint: string, responseId: string): Response => {
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse("Response", responseId, true);
}
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message, undefined, true);
}
if (error instanceof DatabaseError) {
logger.error({ error, url }, `Error in ${endpoint}`);
return responses.internalServerErrorResponse(error.message, true);
}
return responses.internalServerErrorResponse("Unknown error occurred", true);
};
const validateResponse = (
response: TResponse,
survey: TSurvey,
responseUpdateInput: TResponseUpdateInput
) => {
// Validate response data against validation rules
const mergedData = {
...response.data,
...responseUpdateInput.data,
};
const validationErrors = validateResponseData(
survey.blocks,
mergedData,
responseUpdateInput.language ?? response.language ?? "en",
survey.questions
);
if (validationErrors) {
return {
response: responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
),
};
}
};
export const PUT = withV1ApiWrapper({
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ responseId: string }> }>) => {
const params = await props.params;
const { responseId } = params;
if (!responseId) {
return {
response: responses.badRequestResponse("Response ID is missing", undefined, true),
};
}
const responseUpdate = await req.json();
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);
if (!inputValidation.success) {
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
),
};
}
let response;
try {
response = await getResponse(responseId);
} catch (error) {
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return {
response: handleDatabaseError(
error instanceof Error ? error : new Error(String(error)),
req.url,
endpoint,
responseId
),
};
}
if (!response) {
return {
response: responses.notFoundResponse("Response", responseId, true),
};
}
if (response.finished) {
return {
response: responses.badRequestResponse("Response is already finished", undefined, true),
};
}
// get survey to get environmentId
let survey;
try {
survey = await getSurvey(response.surveyId);
} catch (error) {
const endpoint = "PUT /api/v1/client/[environmentId]/responses/[responseId]";
return {
response: handleDatabaseError(
error instanceof Error ? error : new Error(String(error)),
req.url,
endpoint,
responseId
),
};
}
if (!survey) {
return {
response: responses.notFoundResponse("Survey", response.surveyId, true),
};
}
if (!validateFileUploads(inputValidation.data.data, survey.questions)) {
return {
response: responses.badRequestResponse("Invalid file upload response", undefined, true),
};
}
// Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: inputValidation.data.data,
surveyQuestions: survey.questions as unknown as TSurveyElement[],
responseLanguage: inputValidation.data.language,
});
if (otherResponseInvalidQuestionId) {
return {
response: responses.badRequestResponse(
`Response exceeds character limit`,
{
questionId: otherResponseInvalidQuestionId,
},
true
),
};
}
const validationResult = validateResponse(response, survey, inputValidation.data);
if (validationResult) {
return validationResult;
}
// update response with quota evaluation
let updatedResponse;
try {
updatedResponse = await updateResponseWithQuotaEvaluation(responseId, inputValidation.data);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return {
response: responses.notFoundResponse("Response", responseId, true),
};
}
if (error instanceof InvalidInputError) {
return {
response: responses.badRequestResponse(error.message),
};
}
if (error instanceof DatabaseError) {
logger.error(
{ error, url: req.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return {
response: responses.internalServerErrorResponse(error.message),
};
}
logger.error(
{ error, url: req.url },
"Error in PUT /api/v1/client/[environmentId]/responses/[responseId]"
);
return {
response: responses.internalServerErrorResponse("Something went wrong"),
};
}
const { quotaFull, ...responseData } = updatedResponse;
// send response update to pipeline
// don't await to not block the response
sendToPipeline({
event: "responseUpdated",
environmentId: survey.environmentId,
surveyId: survey.id,
response: responseData,
});
if (updatedResponse.finished) {
// send response to pipeline
// don't await to not block the response
sendToPipeline({
event: "responseFinished",
environmentId: survey.environmentId,
surveyId: survey.id,
response: responseData,
});
}
const quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = {
id: responseData.id,
...quotaObj,
};
return {
response: responses.successResponse(responseDataWithQuota, true),
};
},
handler: putResponseHandler,
});

View File

@@ -1,7 +1,7 @@
import { logger } from "@formbricks/logger";
import { TUploadPrivateFileRequest, ZUploadPrivateFileRequest } from "@formbricks/types/storage";
import { ZUploadPrivateFileRequest } from "@formbricks/types/storage";
import { parseAndValidateJsonBody } from "@/app/lib/api/parse-and-validate-json-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { THandlerParams, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
import { MAX_FILE_UPLOAD_SIZES } from "@/lib/constants";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
@@ -30,33 +30,27 @@ export const POST = withV1ApiWrapper({
handler: async ({ req, props }: THandlerParams<{ params: Promise<{ environmentId: string }> }>) => {
const params = await props.params;
const { environmentId } = params;
let jsonInput: TUploadPrivateFileRequest;
try {
jsonInput = await req.json();
} catch (error) {
logger.error({ error, url: req.url }, "Error parsing JSON input");
return {
response: responses.badRequestResponse("Malformed JSON input, please check your request body"),
};
}
const parsedInputResult = ZUploadPrivateFileRequest.safeParse({
...jsonInput,
environmentId,
const parsedInputResult = await parseAndValidateJsonBody({
request: req,
schema: ZUploadPrivateFileRequest,
buildInput: (jsonInput) => ({
...(jsonInput !== null && typeof jsonInput === "object" ? jsonInput : {}),
environmentId,
}),
});
if (!parsedInputResult.success) {
const errorDetails = transformErrorToDetails(parsedInputResult.error);
logger.error({ error: errorDetails }, "Fields are missing or incorrectly formatted");
if ("response" in parsedInputResult) {
if (parsedInputResult.issue === "invalid_json") {
logger.error({ error: parsedInputResult.details, url: req.url }, "Error parsing JSON input");
} else {
logger.error(
{ error: parsedInputResult.details, url: req.url },
"Fields are missing or incorrectly formatted"
);
}
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
errorDetails,
true
),
response: parsedInputResult.response,
};
}
@@ -105,9 +99,14 @@ export const POST = withV1ApiWrapper({
if (!signedUrlResponse.ok) {
logger.error({ error: signedUrlResponse.error }, "Error getting signed url for upload");
const errorResponse = getErrorResponseFromStorageError(signedUrlResponse.error, { fileName });
return {
response: errorResponse,
};
return errorResponse.status >= 500
? {
response: errorResponse,
error: signedUrlResponse.error,
}
: {
response: errorResponse,
};
}
return {

View File

@@ -0,0 +1,126 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
createDisplay: vi.fn(),
getIsContactsEnabled: vi.fn(),
getOrganizationIdFromEnvironmentId: vi.fn(),
reportApiError: vi.fn(),
}));
vi.mock("./lib/display", () => ({
createDisplay: mocks.createDisplay,
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsContactsEnabled: mocks.getIsContactsEnabled,
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromEnvironmentId: mocks.getOrganizationIdFromEnvironmentId,
}));
vi.mock("@/app/lib/api/api-error-reporter", () => ({
reportApiError: mocks.reportApiError,
}));
const environmentId = "cld1234567890abcdef123456";
const surveyId = "clg123456789012345678901234";
describe("api/v2 client displays route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getOrganizationIdFromEnvironmentId.mockResolvedValue("org_123");
mocks.getIsContactsEnabled.mockResolvedValue(true);
});
test("returns a v2 bad request response for malformed JSON without reporting an internal error", async () => {
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: "{",
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ environmentId }),
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual(
expect.objectContaining({
code: "bad_request",
message: "Invalid JSON in request body",
})
);
expect(mocks.createDisplay).not.toHaveBeenCalled();
expect(mocks.reportApiError).not.toHaveBeenCalled();
});
test("reports unexpected createDisplay failures while keeping the response payload unchanged", async () => {
const underlyingError = new Error("display persistence failed");
mocks.createDisplay.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
surveyId,
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
});
test("reports unexpected contact-license lookup failures with the same generic public response", async () => {
const underlyingError = new Error("license lookup failed");
mocks.getOrganizationIdFromEnvironmentId.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/displays`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
surveyId,
contactId: "clh123456789012345678901234",
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
expect(mocks.createDisplay).not.toHaveBeenCalled();
});
});

View File

@@ -1,8 +1,11 @@
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZDisplayCreateInputV2 } from "@/app/api/v2/client/[environmentId]/displays/types/display";
import {
TDisplayCreateInputV2,
ZDisplayCreateInputV2,
} from "@/app/api/v2/client/[environmentId]/displays/types/display";
import { reportApiError } from "@/app/lib/api/api-error-reporter";
import { parseAndValidateJsonBody } from "@/app/lib/api/parse-and-validate-json-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { createDisplay } from "./lib/display";
@@ -13,6 +16,29 @@ interface Context {
}>;
}
type TValidatedDisplayInputResult = { displayInputData: TDisplayCreateInputV2 } | { response: Response };
const parseAndValidateDisplayInput = async (
request: Request,
environmentId: string
): Promise<TValidatedDisplayInputResult> => {
const inputValidation = await parseAndValidateJsonBody({
request,
schema: ZDisplayCreateInputV2,
buildInput: (jsonInput) => ({
...(jsonInput !== null && typeof jsonInput === "object" ? jsonInput : {}),
environmentId,
}),
malformedJsonMessage: "Invalid JSON in request body",
});
if ("response" in inputValidation) {
return inputValidation;
}
return { displayInputData: inputValidation.data };
};
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
{},
@@ -25,38 +51,40 @@ export const OPTIONS = async (): Promise<Response> => {
export const POST = async (request: Request, context: Context): Promise<Response> => {
const params = await context.params;
const jsonInput = await request.json();
const inputValidation = ZDisplayCreateInputV2.safeParse({
...jsonInput,
environmentId: params.environmentId,
});
const validatedInput = await parseAndValidateDisplayInput(request, params.environmentId);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
if ("response" in validatedInput) {
return validatedInput.response;
}
if (inputValidation.data.contactId) {
const organizationId = await getOrganizationIdFromEnvironmentId(params.environmentId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
}
}
const { displayInputData } = validatedInput;
try {
const response = await createDisplay(inputValidation.data);
if (displayInputData.contactId) {
const organizationId = await getOrganizationIdFromEnvironmentId(params.environmentId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
return responses.forbiddenResponse(
"User identification is only available for enterprise users.",
true
);
}
}
const response = await createDisplay(displayInputData);
return responses.successResponse(response, true);
} catch (error) {
if (error instanceof ResourceNotFoundError) {
return responses.notFoundResponse("Survey", inputValidation.data.surveyId);
} else {
logger.error({ error, url: request.url }, "Error creating display");
return responses.internalServerErrorResponse("Something went wrong. Please try again.");
return responses.notFoundResponse("Survey", displayInputData.surveyId, true);
}
const response = responses.internalServerErrorResponse("Something went wrong. Please try again.", true);
reportApiError({
request,
status: response.status,
error,
});
return response;
}
};

View File

@@ -0,0 +1,135 @@
import * as Sentry from "@sentry/nextjs";
import { type NextRequest } from "next/server";
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
applyIPRateLimit: vi.fn(),
getEnvironmentState: vi.fn(),
contextualLoggerError: vi.fn(),
}));
vi.mock("@/app/api/v1/client/[environmentId]/environment/lib/environmentState", () => ({
getEnvironmentState: mocks.getEnvironmentState,
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: mocks.applyIPRateLimit,
applyRateLimit: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
api: {
client: { windowMs: 60000, max: 100 },
v1: { windowMs: 60000, max: 1000 },
},
},
}));
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
withScope: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: vi.fn(() => ({
error: mocks.contextualLoggerError,
warn: vi.fn(),
info: vi.fn(),
})),
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
},
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
AUDIT_LOG_ENABLED: false,
IS_PRODUCTION: true,
SENTRY_DSN: "test-dsn",
ENCRYPTION_KEY: "test-key",
REDIS_URL: "redis://localhost:6379",
};
});
const createMockRequest = (url: string, headers = new Map<string, string>()): NextRequest => {
const parsedUrl = new URL(url);
return {
method: "GET",
url,
headers: {
get: (key: string) => headers.get(key),
},
nextUrl: {
pathname: parsedUrl.pathname,
},
} as unknown as NextRequest;
};
describe("api/v2 client environment route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.applyIPRateLimit.mockResolvedValue(undefined);
});
test("reports v1-backed failures as v2 and keeps the response payload unchanged", async () => {
const underlyingError = new Error("Environment load failed");
mocks.getEnvironmentState.mockRejectedValue(underlyingError);
const request = createMockRequest(
"https://api.test/api/v2/client/ck12345678901234567890123/environment",
new Map([["x-request-id", "req-v2-env"]])
);
const { GET } = await import("./route");
const response = await GET(request, {
params: Promise.resolve({
environmentId: "ck12345678901234567890123",
}),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "An error occurred while processing your request.",
details: {},
});
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
underlyingError,
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v2",
correlationId: "req-v2-env",
method: "GET",
path: "/api/v2/client/ck12345678901234567890123/environment",
}),
extra: expect.objectContaining({
error: expect.objectContaining({
name: "Error",
message: "Environment load failed",
}),
originalError: expect.objectContaining({
name: "Error",
message: "Environment load failed",
}),
}),
contexts: expect.objectContaining({
apiRequest: expect.objectContaining({
apiVersion: "v2",
correlationId: "req-v2-env",
method: "GET",
path: "/api/v2/client/ck12345678901234567890123/environment",
status: 500,
}),
}),
})
);
});
});

View File

@@ -0,0 +1,144 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
const mocks = vi.hoisted(() => ({
checkSurveyValidity: vi.fn(),
createResponseWithQuotaEvaluation: vi.fn(),
getClientIpFromHeaders: vi.fn(),
getIsContactsEnabled: vi.fn(),
getOrganizationIdFromEnvironmentId: vi.fn(),
getSurvey: vi.fn(),
reportApiError: vi.fn(),
sendToPipeline: vi.fn(),
validateResponseData: vi.fn(),
}));
vi.mock("@/app/api/v2/client/[environmentId]/responses/lib/utils", () => ({
checkSurveyValidity: mocks.checkSurveyValidity,
}));
vi.mock("./lib/response", () => ({
createResponseWithQuotaEvaluation: mocks.createResponseWithQuotaEvaluation,
}));
vi.mock("@/app/lib/api/api-error-reporter", () => ({
reportApiError: mocks.reportApiError,
}));
vi.mock("@/app/lib/pipelines", () => ({
sendToPipeline: mocks.sendToPipeline,
}));
vi.mock("@/lib/survey/service", () => ({
getSurvey: mocks.getSurvey,
}));
vi.mock("@/lib/utils/client-ip", () => ({
getClientIpFromHeaders: mocks.getClientIpFromHeaders,
}));
vi.mock("@/lib/utils/helper", () => ({
getOrganizationIdFromEnvironmentId: mocks.getOrganizationIdFromEnvironmentId,
}));
vi.mock("@/modules/api/lib/validation", () => ({
formatValidationErrorsForV1Api: vi.fn((errors) => errors),
validateResponseData: mocks.validateResponseData,
}));
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsContactsEnabled: mocks.getIsContactsEnabled,
}));
const environmentId = "cld1234567890abcdef123456";
const surveyId = "clg123456789012345678901234";
describe("api/v2 client responses route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.checkSurveyValidity.mockResolvedValue(null);
mocks.getSurvey.mockResolvedValue({
id: surveyId,
environmentId,
blocks: [],
questions: [],
isCaptureIpEnabled: false,
});
mocks.validateResponseData.mockReturnValue(null);
mocks.getOrganizationIdFromEnvironmentId.mockResolvedValue("org_123");
mocks.getIsContactsEnabled.mockResolvedValue(true);
mocks.getClientIpFromHeaders.mockResolvedValue("127.0.0.1");
});
test("reports unexpected response creation failures while keeping the public payload generic", async () => {
const underlyingError = new Error("response persistence failed");
mocks.createResponseWithQuotaEvaluation.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/responses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-request-id": "req-v2-response",
},
body: JSON.stringify({
surveyId,
finished: false,
data: {},
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
});
test("reports unexpected pre-persistence failures with the same generic public response", async () => {
const underlyingError = new Error("survey lookup failed");
mocks.getSurvey.mockRejectedValue(underlyingError);
const request = new Request(`https://api.test/api/v2/client/${environmentId}/responses`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-request-id": "req-v2-response-pre-check",
},
body: JSON.stringify({
surveyId,
finished: false,
data: {},
}),
});
const { POST } = await import("./route");
const response = await POST(request, {
params: Promise.resolve({ environmentId }),
});
expect(response.status).toBe(500);
expect(await response.json()).toEqual({
code: "internal_server_error",
message: "Something went wrong. Please try again.",
details: {},
});
expect(mocks.reportApiError).toHaveBeenCalledWith({
request,
status: 500,
error: underlyingError,
});
expect(mocks.createResponseWithQuotaEvaluation).not.toHaveBeenCalled();
expect(mocks.sendToPipeline).not.toHaveBeenCalled();
});
});

View File

@@ -1,10 +1,10 @@
import { headers } from "next/headers";
import { UAParser } from "ua-parser-js";
import { logger } from "@formbricks/logger";
import { ZEnvironmentId } from "@formbricks/types/environment";
import { InvalidInputError } from "@formbricks/types/errors";
import { TResponseWithQuotaFull } from "@formbricks/types/quota";
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
import { reportApiError } from "@/app/lib/api/api-error-reporter";
import { parseAndValidateJsonBody } from "@/app/lib/api/parse-and-validate-json-body";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
import { sendToPipeline } from "@/app/lib/pipelines";
@@ -25,78 +25,86 @@ interface Context {
}>;
}
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
{},
true,
// Cache CORS preflight responses for 1 hour (conservative approach)
// Balances performance gains with flexibility for CORS policy changes
"public, s-maxage=3600, max-age=3600"
);
};
type TResponseSurvey = NonNullable<Awaited<ReturnType<typeof getSurvey>>>;
export const POST = async (request: Request, context: Context): Promise<Response> => {
const params = await context.params;
const requestHeaders = await headers();
let responseInput;
try {
responseInput = await request.json();
} catch (error) {
return responses.badRequestResponse(
"Invalid JSON in request body",
{ error: error instanceof Error ? error.message : "Unknown error occurred" },
true
);
}
type TValidatedResponseInputResult =
| {
environmentId: string;
responseInputData: TResponseInputV2;
}
| { response: Response };
const { environmentId } = params;
const getCountry = (requestHeaders: Headers): string | undefined =>
requestHeaders.get("CF-IPCountry") ||
requestHeaders.get("X-Vercel-IP-Country") ||
requestHeaders.get("CloudFront-Viewer-Country") ||
undefined;
const getUnexpectedPublicErrorResponse = (): Response =>
responses.internalServerErrorResponse("Something went wrong. Please try again.", true);
const parseAndValidateResponseInput = async (
request: Request,
environmentId: string
): Promise<TValidatedResponseInputResult> => {
const environmentIdValidation = ZEnvironmentId.safeParse(environmentId);
const responseInputValidation = ZResponseInputV2.safeParse({ ...responseInput, environmentId });
if (!environmentIdValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(environmentIdValidation.error),
true
);
return {
response: responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(environmentIdValidation.error),
true
),
};
}
if (!responseInputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(responseInputValidation.error),
true
);
const responseInputValidation = await parseAndValidateJsonBody({
request,
schema: ZResponseInputV2,
buildInput: (jsonInput) => ({
...(jsonInput !== null && typeof jsonInput === "object" ? jsonInput : {}),
environmentId,
}),
malformedJsonMessage: "Invalid JSON in request body",
});
if ("response" in responseInputValidation) {
return responseInputValidation;
}
const userAgent = request.headers.get("user-agent") || undefined;
const agent = new UAParser(userAgent);
return {
environmentId,
responseInputData: responseInputValidation.data,
};
};
const country =
requestHeaders.get("CF-IPCountry") ||
requestHeaders.get("X-Vercel-IP-Country") ||
requestHeaders.get("CloudFront-Viewer-Country") ||
undefined;
const responseInputData = responseInputValidation.data;
if (responseInputData.contactId) {
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
if (!isContactsEnabled) {
return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
}
const getContactsDisabledResponse = async (
environmentId: string,
contactId: string | null | undefined
): Promise<Response | null> => {
if (!contactId) {
return null;
}
// get and check survey
const survey = await getSurvey(responseInputData.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", responseInput.surveyId, true);
}
const surveyCheckResult = await checkSurveyValidity(survey, environmentId, responseInput);
if (surveyCheckResult) return surveyCheckResult;
const organizationId = await getOrganizationIdFromEnvironmentId(environmentId);
const isContactsEnabled = await getIsContactsEnabled(organizationId);
return isContactsEnabled
? null
: responses.forbiddenResponse("User identification is only available for enterprise users.", true);
};
const validateResponseSubmission = async (
environmentId: string,
responseInputData: TResponseInputV2,
survey: TResponseSurvey
): Promise<Response | null> => {
const surveyCheckResult = await checkSurveyValidity(survey, environmentId, responseInputData);
if (surveyCheckResult) {
return surveyCheckResult;
}
// Validate response data for "other" options exceeding character limit
const otherResponseInvalidQuestionId = validateOtherOptionLengthForMultipleChoice({
responseData: responseInputData.data,
surveyQuestions: getElementsFromBlocks(survey.blocks),
@@ -113,7 +121,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
);
}
// Validate response data against validation rules
const validationErrors = validateResponseData(
survey.blocks,
responseInputData.data,
@@ -121,15 +128,29 @@ export const POST = async (request: Request, context: Context): Promise<Response
survey.questions
);
if (validationErrors) {
return responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
);
}
return validationErrors
? responses.badRequestResponse(
"Validation failed",
formatValidationErrorsForV1Api(validationErrors),
true
)
: null;
};
const createResponseForRequest = async ({
request,
survey,
responseInputData,
country,
}: {
request: Request;
survey: TResponseSurvey;
responseInputData: TResponseInputV2;
country: string | undefined;
}): Promise<TResponseWithQuotaFull | Response> => {
const userAgent = request.headers.get("user-agent") || undefined;
const agent = new UAParser(userAgent);
let response: TResponseWithQuotaFull;
try {
const meta: TResponseInputV2["meta"] = {
source: responseInputData?.meta?.source,
@@ -139,54 +160,115 @@ export const POST = async (request: Request, context: Context): Promise<Response
device: agent.getDevice().type || "desktop",
os: agent.getOS().name,
},
country: country,
country,
action: responseInputData?.meta?.action,
};
// Capture IP address if the survey has IP capture enabled
// Server-derived IP always overwrites any client-provided value
if (survey.isCaptureIpEnabled) {
const ipAddress = await getClientIpFromHeaders();
meta.ipAddress = ipAddress;
meta.ipAddress = await getClientIpFromHeaders();
}
response = await createResponseWithQuotaEvaluation({
return await createResponseWithQuotaEvaluation({
...responseInputData,
meta,
});
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
return responses.badRequestResponse(error.message, undefined, true);
}
logger.error({ error, url: request.url }, "Error creating response");
return responses.internalServerErrorResponse(
error instanceof Error ? error.message : "Unknown error occurred"
);
const response = getUnexpectedPublicErrorResponse();
reportApiError({
request,
status: response.status,
error,
});
return response;
}
const { quotaFull, ...responseData } = response;
};
sendToPipeline({
event: "responseCreated",
environmentId,
surveyId: responseData.surveyId,
response: responseData,
});
export const OPTIONS = async (): Promise<Response> => {
return responses.successResponse(
{},
true,
// Cache CORS preflight responses for 1 hour (conservative approach)
// Balances performance gains with flexibility for CORS policy changes
"public, s-maxage=3600, max-age=3600"
);
};
export const POST = async (request: Request, context: Context): Promise<Response> => {
const params = await context.params;
const validatedInput = await parseAndValidateResponseInput(request, params.environmentId);
if ("response" in validatedInput) {
return validatedInput.response;
}
const { environmentId, responseInputData } = validatedInput;
const country = getCountry(request.headers);
try {
const contactsDisabledResponse = await getContactsDisabledResponse(
environmentId,
responseInputData.contactId
);
if (contactsDisabledResponse) {
return contactsDisabledResponse;
}
const survey = await getSurvey(responseInputData.surveyId);
if (!survey) {
return responses.notFoundResponse("Survey", responseInputData.surveyId, true);
}
const validationResponse = await validateResponseSubmission(environmentId, responseInputData, survey);
if (validationResponse) {
return validationResponse;
}
const createdResponse = await createResponseForRequest({
request,
survey,
responseInputData,
country,
});
if (createdResponse instanceof Response) {
return createdResponse;
}
const { quotaFull, ...responseData } = createdResponse;
if (responseData.finished) {
sendToPipeline({
event: "responseFinished",
event: "responseCreated",
environmentId,
surveyId: responseData.surveyId,
response: responseData,
});
if (responseData.finished) {
sendToPipeline({
event: "responseFinished",
environmentId,
surveyId: responseData.surveyId,
response: responseData,
});
}
const quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = {
id: responseData.id,
...quotaObj,
};
return responses.successResponse(responseDataWithQuota, true);
} catch (error) {
const response = getUnexpectedPublicErrorResponse();
reportApiError({
request,
status: response.status,
error,
});
return response;
}
const quotaObj = createQuotaFullObject(quotaFull);
const responseDataWithQuota = {
id: responseData.id,
...quotaObj,
};
return responses.successResponse(responseDataWithQuota, true);
};

View File

@@ -0,0 +1,221 @@
import * as Sentry from "@sentry/nextjs";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { reportApiError } from "./api-error-reporter";
const loggerMocks = vi.hoisted(() => {
const contextualError = vi.fn();
const rootError = vi.fn();
const withContext = vi.fn(() => ({
error: contextualError,
}));
return {
contextualError,
rootError,
withContext,
};
});
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
withScope: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
withContext: loggerMocks.withContext,
error: loggerMocks.rootError,
warn: vi.fn(),
info: vi.fn(),
},
}));
vi.mock("@/lib/constants", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/constants")>();
return {
...actual,
IS_PRODUCTION: true,
SENTRY_DSN: "dsn",
};
});
describe("reportApiError", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("captures real errors directly with structured context", () => {
const request = new Request("https://app.test/api/v2/client/environment", {
method: "POST",
headers: {
"x-request-id": "req-1",
},
});
const error = new Error("boom");
reportApiError({
request,
status: 500,
error,
});
expect(loggerMocks.withContext).toHaveBeenCalledWith(
expect.objectContaining({
apiVersion: "v2",
correlationId: "req-1",
method: "POST",
path: "/api/v2/client/environment",
status: 500,
error: expect.objectContaining({
name: "Error",
message: "boom",
}),
})
);
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
error,
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v2",
correlationId: "req-1",
method: "POST",
path: "/api/v2/client/environment",
}),
extra: expect.objectContaining({
error: expect.objectContaining({
name: "Error",
message: "boom",
}),
originalError: expect.objectContaining({
name: "Error",
message: "boom",
}),
}),
contexts: expect.objectContaining({
apiRequest: expect.objectContaining({
apiVersion: "v2",
correlationId: "req-1",
method: "POST",
path: "/api/v2/client/environment",
status: 500,
}),
}),
})
);
});
test("captures non-error payloads with a synthetic error while preserving additional data", () => {
const request = new Request("https://app.test/api/v1/management/surveys", {
headers: {
"x-request-id": "req-2",
},
});
const payload = {
type: "internal_server_error",
details: [{ field: "server", issue: "error occurred" }],
};
reportApiError({
request,
status: 500,
error: payload,
originalError: payload,
});
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.objectContaining({
message: "API V1 error, id: req-2",
}),
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v1",
correlationId: "req-2",
method: "GET",
path: "/api/v1/management/surveys",
}),
extra: expect.objectContaining({
error: payload,
originalError: payload,
}),
})
);
});
test("swallows Sentry failures after logging a fallback reporter error", () => {
vi.mocked(Sentry.captureException).mockImplementation(() => {
throw new Error("sentry down");
});
const request = new Request("https://app.test/api/v2/client/displays", {
headers: {
"x-request-id": "req-3",
},
});
expect(() =>
reportApiError({
request,
status: 500,
error: new Error("boom"),
})
).not.toThrow();
expect(loggerMocks.rootError).toHaveBeenCalledWith(
expect.objectContaining({
apiVersion: "v2",
correlationId: "req-3",
method: "GET",
path: "/api/v2/client/displays",
status: 500,
reportingError: expect.objectContaining({
name: "Error",
message: "sentry down",
}),
}),
"Failed to report API error"
);
});
test("serializes cyclic payloads without throwing", () => {
const request = new Request("https://app.test/api/v2/client/responses", {
headers: {
"x-request-id": "req-4",
},
});
const payload: Record<string, unknown> = {
type: "internal_server_error",
};
payload.self = payload;
expect(() =>
reportApiError({
request,
status: 500,
error: payload,
originalError: payload,
})
).not.toThrow();
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.objectContaining({
message: "API V2 error, id: req-4",
}),
expect.objectContaining({
extra: expect.objectContaining({
error: {
type: "internal_server_error",
self: "[Circular]",
},
originalError: {
type: "internal_server_error",
self: "[Circular]",
},
}),
})
);
});
});

View File

@@ -0,0 +1,282 @@
import * as Sentry from "@sentry/nextjs";
import { logger } from "@formbricks/logger";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
type TRequestLike = Pick<Request, "method" | "url" | "headers">;
type TApiErrorContext = {
apiVersion: TApiVersion;
correlationId: string;
method: string;
path: string;
status: number;
};
type TSentryCaptureContext = NonNullable<Parameters<typeof Sentry.captureException>[1]>;
export type TApiVersion = "v1" | "v2" | "v3" | "unknown";
const getPathname = (url: string): string => {
if (url.startsWith("/")) {
return url;
}
try {
return new URL(url).pathname;
} catch {
return url;
}
};
export const getApiVersionFromPath = (pathname: string): TApiVersion => {
const match = /^\/api\/(v\d+)(?:\/|$)/.exec(pathname);
if (!match) {
return "unknown";
}
switch (match[1]) {
case "v1":
case "v2":
case "v3":
return match[1];
default:
return "unknown";
}
};
const serializeError = (value: unknown, seen = new WeakSet<object>()): unknown => {
if (value === null || value === undefined) {
return value;
}
if (typeof value !== "object") {
return value;
}
if (seen.has(value)) {
return "[Circular]";
}
seen.add(value);
if (value instanceof Error) {
const serializedError: Record<string, unknown> = {
name: value.name,
message: value.message,
};
if (value.stack) {
serializedError.stack = value.stack;
}
if ("cause" in value && value.cause !== undefined) {
serializedError.cause = serializeError(value.cause, seen);
}
for (const [key, entryValue] of Object.entries(value as unknown as Record<string, unknown>)) {
serializedError[key] = serializeError(entryValue, seen);
}
return serializedError;
}
if (Array.isArray(value)) {
return value.map((item) => serializeError(item, seen));
}
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, entryValue]) => [
key,
serializeError(entryValue, seen),
])
);
};
const getSerializedValueType = (value: unknown): string => {
if (value === null) {
return "null";
}
if (Array.isArray(value)) {
return "array";
}
if (value instanceof Error) {
return value.name;
}
return typeof value;
};
export const serializeErrorSafely = (value: unknown): unknown => {
try {
return serializeError(value);
} catch (serializationError) {
return {
name: "ErrorSerializationFailed",
message: "Failed to serialize API error payload",
originalType: getSerializedValueType(value),
serializationError:
serializationError instanceof Error ? serializationError.message : String(serializationError),
};
}
};
const getSyntheticError = (apiVersion: TApiVersion, correlationId: string): Error => {
if (apiVersion === "unknown") {
return new Error(`API error, id: ${correlationId}`);
}
return new Error(`API ${apiVersion.toUpperCase()} error, id: ${correlationId}`);
};
const getLogMessage = (apiVersion: TApiVersion): string => {
switch (apiVersion) {
case "v1":
return "API V1 Error Details";
case "v2":
return "API V2 Error Details";
case "v3":
return "API V3 Error Details";
default:
return "API Error Details";
}
};
const buildApiErrorContext = ({
request,
status,
apiVersion,
}: {
request: TRequestLike;
status: number;
apiVersion?: TApiVersion;
}): TApiErrorContext => {
const path = getPathname(request.url);
return {
apiVersion: apiVersion ?? getApiVersionFromPath(path),
correlationId: request.headers.get("x-request-id") ?? "",
method: request.method,
path,
status,
};
};
export const buildSentryCaptureContext = ({
context,
errorPayload,
originalErrorPayload,
}: {
context: TApiErrorContext;
errorPayload: unknown;
originalErrorPayload: unknown;
}): TSentryCaptureContext => ({
level: "error",
tags: {
apiVersion: context.apiVersion,
correlationId: context.correlationId,
method: context.method,
path: context.path,
},
extra: {
error: errorPayload,
originalError: originalErrorPayload,
},
contexts: {
apiRequest: {
apiVersion: context.apiVersion,
correlationId: context.correlationId,
method: context.method,
path: context.path,
status: context.status,
},
},
});
export const emitApiErrorLog = (context: TApiErrorContext, errorPayload?: unknown): void => {
const logContext =
errorPayload === undefined
? context
: {
...context,
error: errorPayload,
};
logger.withContext(logContext).error(getLogMessage(context.apiVersion));
};
export const emitApiErrorToSentry = ({
error,
captureContext,
}: {
error: Error;
captureContext: TSentryCaptureContext;
}): void => {
Sentry.captureException(error, captureContext);
};
const logReporterFailure = (context: TApiErrorContext, reportingError: unknown): void => {
try {
logger.error(
{
apiVersion: context.apiVersion,
correlationId: context.correlationId,
method: context.method,
path: context.path,
status: context.status,
reportingError: serializeErrorSafely(reportingError),
},
"Failed to report API error"
);
} catch {
// Swallow reporter failures so API responses are never affected by observability issues.
}
};
export const reportApiError = ({
request,
status,
error,
apiVersion,
originalError,
}: {
request: TRequestLike;
status: number;
error?: unknown;
apiVersion?: TApiVersion;
originalError?: unknown;
}): void => {
const context = buildApiErrorContext({
request,
status,
apiVersion,
});
const capturedError =
error instanceof Error ? error : getSyntheticError(context.apiVersion, context.correlationId);
const logErrorPayload = error === undefined ? undefined : serializeErrorSafely(error);
const errorPayload = serializeErrorSafely(error ?? capturedError);
const originalErrorPayload = serializeErrorSafely(originalError ?? error);
try {
emitApiErrorLog(context, logErrorPayload);
} catch (reportingError) {
logReporterFailure(context, reportingError);
}
if (SENTRY_DSN && IS_PRODUCTION && status >= 500) {
try {
emitApiErrorToSentry({
error: capturedError,
captureContext: buildSentryCaptureContext({
context,
errorPayload,
originalErrorPayload,
}),
});
} catch (reportingError) {
logReporterFailure(context, reportingError);
}
}
};

View File

@@ -0,0 +1,111 @@
import { describe, expect, test } from "vitest";
import { z } from "zod";
import { parseAndValidateJsonBody } from "./parse-and-validate-json-body";
describe("parseAndValidateJsonBody", () => {
test("returns a malformed JSON response when request parsing fails", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: "{invalid-json",
});
const result = await parseAndValidateJsonBody({
request,
schema: z.object({
finished: z.boolean(),
}),
malformedJsonMessage: "Malformed JSON in request body",
});
expect("response" in result).toBe(true);
if (!("response" in result)) {
throw new Error("Expected a response result");
}
expect(result.issue).toBe("invalid_json");
expect(result.details).toEqual({
error: expect.any(String),
});
await expect(result.response.json()).resolves.toEqual({
code: "bad_request",
message: "Malformed JSON in request body",
details: {
error: expect.any(String),
},
});
});
test("returns a validation response when the parsed JSON does not match the schema", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
finished: "not-boolean",
}),
});
const result = await parseAndValidateJsonBody({
request,
schema: z.object({
finished: z.boolean(),
}),
});
expect("response" in result).toBe(true);
if (!("response" in result)) {
throw new Error("Expected a response result");
}
expect(result.issue).toBe("invalid_body");
expect(result.details).toEqual(
expect.objectContaining({
finished: expect.any(String),
})
);
await expect(result.response.json()).resolves.toEqual({
code: "bad_request",
message: "Fields are missing or incorrectly formatted",
details: expect.objectContaining({
finished: expect.any(String),
}),
});
});
test("returns parsed data when JSON parsing and schema validation succeed", async () => {
const request = new Request("http://localhost/api/test", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
finished: true,
}),
});
const result = await parseAndValidateJsonBody({
request,
schema: z.object({
finished: z.boolean(),
environmentId: z.string(),
}),
buildInput: (jsonInput) => ({
...(jsonInput as Record<string, unknown>),
environmentId: "env_123",
}),
});
expect(result).toEqual({
data: {
environmentId: "env_123",
finished: true,
},
});
});
});

View File

@@ -0,0 +1,71 @@
import { z } from "zod";
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
type TJsonBodyValidationIssue = "invalid_json" | "invalid_body";
type TJsonBodyValidationError = {
details: Record<string, string> | { error: string };
issue: TJsonBodyValidationIssue;
response: Response;
};
type TJsonBodyValidationSuccess<TData> = {
data: TData;
};
export type TParseAndValidateJsonBodyResult<TData> =
| TJsonBodyValidationError
| TJsonBodyValidationSuccess<TData>;
type TParseAndValidateJsonBodyOptions<TSchema extends z.ZodTypeAny> = {
request: Request;
schema: TSchema;
buildInput?: (jsonInput: unknown) => unknown;
malformedJsonMessage?: string;
validationMessage?: string;
};
const DEFAULT_MALFORMED_JSON_MESSAGE = "Malformed JSON input, please check your request body";
const DEFAULT_VALIDATION_MESSAGE = "Fields are missing or incorrectly formatted";
const getErrorMessage = (error: unknown): string =>
error instanceof Error ? error.message : "Unknown error occurred";
export const parseAndValidateJsonBody = async <TSchema extends z.ZodTypeAny>({
request,
schema,
buildInput,
malformedJsonMessage = DEFAULT_MALFORMED_JSON_MESSAGE,
validationMessage = DEFAULT_VALIDATION_MESSAGE,
}: TParseAndValidateJsonBodyOptions<TSchema>): Promise<
TParseAndValidateJsonBodyResult<z.output<TSchema>>
> => {
let jsonInput: unknown;
try {
jsonInput = await request.json();
} catch (error) {
const details = { error: getErrorMessage(error) };
return {
details,
issue: "invalid_json",
response: responses.badRequestResponse(malformedJsonMessage, details, true),
};
}
const inputValidation = schema.safeParse(buildInput ? buildInput(jsonInput) : jsonInput);
if (!inputValidation.success) {
const details = transformErrorToDetails(inputValidation.error);
return {
details,
issue: "invalid_body",
response: responses.badRequestResponse(validationMessage, details, true),
};
}
return { data: inputValidation.data };
};

View File

@@ -6,7 +6,6 @@ import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { AuthenticationMethod } from "@/app/middleware/endpoint-validator";
import { responses } from "./response";
// Mocks
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
__esModule: true,
queueAuditEvent: vi.fn(),
@@ -14,24 +13,13 @@ vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
withScope: vi.fn((callback) => {
callback(mockSentryScope);
return mockSentryScope;
}),
withScope: vi.fn(),
}));
// Define these outside the mock factory so they can be referenced in tests and reset by clearAllMocks.
const mockContextualLoggerError = vi.fn();
const mockContextualLoggerWarn = vi.fn();
const mockContextualLoggerInfo = vi.fn();
// Mock Sentry scope that can be referenced in tests
const mockSentryScope = {
setTag: vi.fn(),
setExtra: vi.fn(),
setContext: vi.fn(),
setLevel: vi.fn(),
};
const V1_MANAGEMENT_SURVEYS_URL = "https://api.test/api/v1/management/surveys";
vi.mock("@formbricks/logger", () => {
const mockWithContextInstance = vi.fn(() => ({
@@ -86,7 +74,6 @@ vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
}));
function createMockRequest({ method = "GET", url = "https://api.test/endpoint", headers = new Map() } = {}) {
// Parse the URL to get the pathname
const parsedUrl = url.startsWith("/") ? new URL(url, "http://localhost:3000") : new URL(url);
return {
@@ -122,12 +109,6 @@ describe("withV1ApiWrapper", () => {
}));
vi.clearAllMocks();
// Reset mock Sentry scope calls
mockSentryScope.setTag.mockClear();
mockSentryScope.setExtra.mockClear();
mockSentryScope.setContext.mockClear();
mockSentryScope.setLevel.mockClear();
});
test("logs and audits on error response with API key authentication", async () => {
@@ -155,7 +136,7 @@ describe("withV1ApiWrapper", () => {
});
const req = createMockRequest({
url: "https://api.test/v1/management/surveys",
url: V1_MANAGEMENT_SURVEYS_URL,
headers: new Map([["x-request-id", "abc-123"]]),
});
const { withV1ApiWrapper } = await import("./with-api-logging");
@@ -177,9 +158,33 @@ describe("withV1ApiWrapper", () => {
organizationId: "org-1",
})
);
expect(Sentry.withScope).toHaveBeenCalled();
expect(mockSentryScope.setExtra).toHaveBeenCalledWith("originalError", undefined);
expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error));
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.any(Error),
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v1",
correlationId: "abc-123",
method: "GET",
path: "/api/v1/management/surveys",
}),
extra: expect.objectContaining({
error: expect.objectContaining({
message: "API V1 error, id: abc-123",
}),
originalError: undefined,
}),
contexts: expect.objectContaining({
apiRequest: expect.objectContaining({
apiVersion: "v1",
correlationId: "abc-123",
method: "GET",
path: "/api/v1/management/surveys",
status: 500,
}),
}),
})
);
});
test("does not log Sentry if not 500", async () => {
@@ -206,7 +211,7 @@ describe("withV1ApiWrapper", () => {
};
});
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
await wrapped(req, undefined);
@@ -251,7 +256,7 @@ describe("withV1ApiWrapper", () => {
});
const req = createMockRequest({
url: "https://api.test/v1/management/surveys",
url: V1_MANAGEMENT_SURVEYS_URL,
headers: new Map([["x-request-id", "err-1"]]),
});
const { withV1ApiWrapper } = await import("./with-api-logging");
@@ -280,8 +285,78 @@ describe("withV1ApiWrapper", () => {
organizationId: "org-1",
})
);
expect(Sentry.withScope).toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(expect.any(Error));
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.objectContaining({
message: "fail!",
}),
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v1",
correlationId: "err-1",
method: "GET",
path: "/api/v1/management/surveys",
}),
extra: expect.objectContaining({
error: expect.objectContaining({
message: "fail!",
}),
originalError: expect.objectContaining({
message: "fail!",
}),
}),
})
);
});
test("uses handler result error for handled 500 responses", async () => {
const { authenticateRequest } = await import("@/app/api/v1/auth");
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
await import("@/app/middleware/endpoint-validator");
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
vi.mocked(isManagementApiRoute).mockReturnValue({
isManagementApi: true,
authenticationMethod: AuthenticationMethod.ApiKey,
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
const handledError = new Error("handled failure");
const handler = vi.fn().mockResolvedValue({
response: responses.internalServerErrorResponse("fail"),
error: handledError,
});
const req = createMockRequest({
url: "https://api.test/api/v2/client/environment",
headers: new Map([["x-request-id", "handled-1"]]),
});
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
expect(res.status).toBe(500);
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
handledError,
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v2",
correlationId: "handled-1",
method: "GET",
path: "/api/v2/client/environment",
}),
extra: expect.objectContaining({
error: expect.objectContaining({
message: "handled failure",
}),
originalError: expect.objectContaining({
message: "handled failure",
}),
}),
})
);
});
test("does not log on success response but still audits", async () => {
@@ -308,7 +383,7 @@ describe("withV1ApiWrapper", () => {
};
});
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
await wrapped(req, undefined);
@@ -358,7 +433,7 @@ describe("withV1ApiWrapper", () => {
response: responses.internalServerErrorResponse("fail"),
});
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
const wrapped = withV1ApiWrapper({ handler, action: "created", targetType: "survey" });
await wrapped(req, undefined);
@@ -378,7 +453,7 @@ describe("withV1ApiWrapper", () => {
});
vi.mocked(isIntegrationRoute).mockReturnValue(false);
vi.mocked(authenticateRequest).mockResolvedValue(null);
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
vi.mocked(applyIPRateLimit).mockResolvedValue({ allowed: true });
const handler = vi.fn().mockResolvedValue({
response: responses.successResponse({ data: "test" }),
@@ -412,7 +487,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(authenticateRequest).mockResolvedValue(null);
const handler = vi.fn();
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
@@ -471,7 +546,7 @@ describe("withV1ApiWrapper", () => {
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
const handler = vi.fn();
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
const res = await wrapped(req, undefined);
@@ -499,7 +574,7 @@ describe("withV1ApiWrapper", () => {
response: responses.successResponse({ data: "test" }),
});
const req = createMockRequest({ url: "https://api.test/v1/management/surveys" });
const req = createMockRequest({ url: V1_MANAGEMENT_SURVEYS_URL });
const { withV1ApiWrapper } = await import("./with-api-logging");
const wrapped = withV1ApiWrapper({ handler });
await wrapped(req, undefined);
@@ -518,7 +593,7 @@ describe("buildAuditLogBaseObject", () => {
test("creates audit log base object with correct structure", async () => {
const { buildAuditLogBaseObject } = await import("./with-api-logging");
const result = buildAuditLogBaseObject("created", "survey", "https://api.test/v1/management/surveys");
const result = buildAuditLogBaseObject("created", "survey", V1_MANAGEMENT_SURVEYS_URL);
expect(result).toEqual({
action: "created",
@@ -530,7 +605,7 @@ describe("buildAuditLogBaseObject", () => {
oldObject: undefined,
newObject: undefined,
userType: "api",
apiUrl: "https://api.test/v1/management/surveys",
apiUrl: V1_MANAGEMENT_SURVEYS_URL,
});
});
});

View File

@@ -1,9 +1,9 @@
import * as Sentry from "@sentry/nextjs";
import { Session, getServerSession } from "next-auth";
import { NextRequest } from "next/server";
import { logger } from "@formbricks/logger";
import { TAuthenticationApiKey } from "@formbricks/types/auth";
import { authenticateRequest } from "@/app/api/v1/auth";
import { reportApiError } from "@/app/lib/api/api-error-reporter";
import { responses } from "@/app/lib/api/response";
import {
AuthenticationMethod,
@@ -11,7 +11,7 @@ import {
isIntegrationRoute,
isManagementApiRoute,
} from "@/app/middleware/endpoint-validator";
import { AUDIT_LOG_ENABLED, IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { AUDIT_LOG_ENABLED } from "@/lib/constants";
import { authOptions } from "@/modules/auth/lib/authOptions";
import { applyIPRateLimit, applyRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
@@ -33,7 +33,10 @@ export interface THandlerParams<TProps = unknown> {
}
// Interface for wrapper function parameters
export interface TWithV1ApiWrapperParams<TResult extends { response: Response }, TProps = unknown> {
export interface TWithV1ApiWrapperParams<
TResult extends { response: Response; error?: unknown },
TProps = unknown,
> {
handler: (params: THandlerParams<TProps>) => Promise<TResult>;
action?: TAuditAction;
targetType?: TAuditTarget;
@@ -93,7 +96,7 @@ const handleRateLimiting = async (
/**
* Execute handler with error handling
*/
const executeHandler = async <TResult extends { response: Response }, TProps>(
const executeHandler = async <TResult extends { response: Response; error?: unknown }, TProps>(
handler: (params: THandlerParams<TProps>) => Promise<TResult>,
req: NextRequest,
props: TProps,
@@ -158,34 +161,12 @@ const handleAuthentication = async (
/**
* Log error details to system logger and Sentry
*/
const logErrorDetails = (res: Response, req: NextRequest, correlationId: string, error?: any): void => {
const logContext = {
correlationId,
method: req.method,
path: req.url,
const logErrorDetails = (res: Response, req: NextRequest, error?: unknown): void => {
reportApiError({
request: req,
status: res.status,
...(error && { error }),
};
logger.withContext(logContext).error("V1 API Error Details");
if (SENTRY_DSN && IS_PRODUCTION && res.status >= 500) {
// Set correlation ID as a tag for easy filtering
Sentry.withScope((scope) => {
scope.setTag("correlationId", correlationId);
scope.setLevel("error");
// If we have an actual error, capture it with full stacktrace
// Otherwise, create a generic error with context
if (error instanceof Error) {
Sentry.captureException(error);
} else {
scope.setExtra("originalError", error);
const genericError = new Error(`API V1 error, id: ${correlationId}`);
Sentry.captureException(genericError);
}
});
}
error,
});
};
/**
@@ -195,7 +176,7 @@ const processResponse = async (
res: Response,
req: NextRequest,
auditLog?: TApiAuditLog,
error?: any
error?: unknown
): Promise<void> => {
const correlationId = req.headers.get("x-request-id") ?? "";
@@ -210,7 +191,7 @@ const processResponse = async (
// Handle error logging
if (!res.ok) {
logErrorDetails(res, req, correlationId, error);
logErrorDetails(res, req, error);
}
// Queue audit event if enabled and audit log exists
@@ -267,7 +248,7 @@ const getRouteType = (
* @returns Wrapped handler function that returns the final HTTP response
*
*/
export const withV1ApiWrapper = <TResult extends { response: Response }, TProps = unknown>(
export const withV1ApiWrapper = <TResult extends { response: Response; error?: unknown }, TProps = unknown>(
params: TWithV1ApiWrapperParams<TResult, TProps>
): ((req: NextRequest, props: TProps) => Promise<Response>) => {
const { handler, action, targetType, customRateLimitConfig, unauthenticatedResponse } = params;
@@ -312,9 +293,10 @@ export const withV1ApiWrapper = <TResult extends { response: Response }, TProps
// === Handler Execution ===
const { result, error } = await executeHandler(handler, req, props, auditLog, authentication);
const res = result.response;
const reportedError = result.error ?? error;
// === Response Processing & Logging ===
await processResponse(res, req, auditLog, error);
await processResponse(res, req, auditLog, reportedError);
return res;
};

View File

@@ -44,12 +44,22 @@ export const authenticatedApiClient = async <S extends ExtendedSchemas>({
return response;
} catch (err) {
if (err !== null && typeof err === "object" && "type" in err) {
return handleApiError(request, err as ApiErrorResponseV2);
return handleApiError(request, err as ApiErrorResponseV2, undefined, err);
}
return handleApiError(request, {
type: "internal_server_error",
details: [{ field: "error", issue: "An error occurred while processing your request." }],
});
return handleApiError(
request,
{
type: "internal_server_error",
details: [
{
field: "error",
issue: "An error occurred while processing your request. Please try again later.",
},
],
},
undefined,
err
);
}
};

View File

@@ -1,5 +1,5 @@
import { describe, expect, test, vi } from "vitest";
import { logApiRequest } from "@/modules/api/v2/lib/utils";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { handleApiError, logApiRequest } from "@/modules/api/v2/lib/utils";
import { apiWrapper } from "../api-wrapper";
import { authenticatedApiClient } from "../authenticated-api-client";
@@ -8,10 +8,15 @@ vi.mock("../api-wrapper", () => ({
}));
vi.mock("@/modules/api/v2/lib/utils", () => ({
handleApiError: vi.fn(),
logApiRequest: vi.fn(),
}));
describe("authenticatedApiClient", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("should log request and return response", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
@@ -29,4 +34,60 @@ describe("authenticatedApiClient", () => {
expect(response.status).toBe(200);
expect(logApiRequest).toHaveBeenCalled();
});
test("passes the original ApiErrorResponseV2 through to handleApiError", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
const apiError = {
type: "internal_server_error",
details: [{ field: "error", issue: "boom" }],
} as const;
const handledResponse = new Response("error", { status: 500 });
vi.mocked(apiWrapper).mockRejectedValue(apiError);
vi.mocked(handleApiError).mockReturnValue(handledResponse);
const handler = vi.fn();
const response = await authenticatedApiClient({
request,
handler,
});
expect(response).toBe(handledResponse);
expect(handleApiError).toHaveBeenCalledWith(request, apiError, undefined, apiError);
});
test("passes unknown thrown errors to handleApiError as originalError", async () => {
const request = new Request("http://localhost", {
headers: { "x-api-key": "valid-api-key" },
});
const thrownError = new Error("boom");
const handledResponse = new Response("error", { status: 500 });
vi.mocked(apiWrapper).mockRejectedValue(thrownError);
vi.mocked(handleApiError).mockReturnValue(handledResponse);
const handler = vi.fn();
const response = await authenticatedApiClient({
request,
handler,
});
expect(response).toBe(handledResponse);
expect(handleApiError).toHaveBeenCalledWith(
request,
{
type: "internal_server_error",
details: [
{
field: "error",
issue: "An error occurred while processing your request. Please try again later.",
},
],
},
undefined,
thrownError
);
});
});

View File

@@ -1,26 +1,18 @@
import * as Sentry from "@sentry/nextjs";
import { describe, expect, test, vi } from "vitest";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { ZodError } from "zod";
import { logger } from "@formbricks/logger";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
import { formatZodError, handleApiError, logApiError, logApiRequest } from "../utils";
const mockRequest = new Request("http://localhost");
const mockRequest = new Request("http://localhost/api/v2/test");
// Add the request id header
mockRequest.headers.set("x-request-id", "123");
vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
withScope: vi.fn((callback: (scope: any) => void) => {
const mockScope = {
setTag: vi.fn(),
setContext: vi.fn(),
setLevel: vi.fn(),
setExtra: vi.fn(),
};
callback(mockScope);
}),
withScope: vi.fn(),
}));
// Mock SENTRY_DSN constant while preserving untouched exports.
@@ -37,6 +29,10 @@ vi.mock("@/lib/constants", async (importOriginal) => {
});
describe("utils", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("handleApiError", () => {
test('return bad request response for "bad_request" error', async () => {
const details = [{ field: "param", issue: "invalid" }];
@@ -122,6 +118,57 @@ describe("utils", () => {
expect(body.error.details).toEqual([
{ field: "error", issue: "An error occurred while processing your request. Please try again later." },
]);
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.objectContaining({
message: "API V2 error, id: 123",
}),
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v2",
correlationId: "123",
method: "GET",
path: "/api/v2/test",
}),
extra: expect.objectContaining({
error,
originalError: error,
}),
contexts: expect.objectContaining({
apiRequest: expect.objectContaining({
apiVersion: "v2",
correlationId: "123",
method: "GET",
path: "/api/v2/test",
status: 500,
}),
}),
})
);
});
test("preserves originalError separately when provided", () => {
const error: ApiErrorResponseV2 = {
type: "internal_server_error",
details: [{ field: "server", issue: "error occurred" }],
};
const originalError = new Error("boom");
handleApiError(mockRequest, error, undefined, originalError);
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.objectContaining({
message: "API V2 error, id: 123",
}),
expect.objectContaining({
extra: expect.objectContaining({
error,
originalError: expect.objectContaining({
message: "boom",
}),
}),
})
);
});
});
@@ -156,13 +203,11 @@ describe("utils", () => {
describe("logApiRequest", () => {
test("logs API request details", () => {
// Mock the withContext method and its returned info method
const infoMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
info: infoMock,
});
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
@@ -172,23 +217,18 @@ describe("utils", () => {
logApiRequest(mockRequest, 200);
// Verify withContext was called
expect(withContextMock).toHaveBeenCalled();
// Verify info was called on the child logger
expect(infoMock).toHaveBeenCalledWith("API Request Details");
// Restore the original method
logger.withContext = originalWithContext;
});
test("logs API request details without correlationId and without safe query params", () => {
// Mock the withContext method and its returned info method
const infoMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
info: infoMock,
});
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
@@ -198,7 +238,6 @@ describe("utils", () => {
logApiRequest(mockRequest, 200);
// Verify withContext was called with the expected context
expect(withContextMock).toHaveBeenCalledWith(
expect.objectContaining({
method: "GET",
@@ -208,23 +247,19 @@ describe("utils", () => {
})
);
// Verify info was called on the child logger
expect(infoMock).toHaveBeenCalledWith("API Request Details");
// Restore the original method
logger.withContext = originalWithContext;
});
});
describe("logApiError", () => {
test("logs API error details with method and path", () => {
// Mock the withContext method and its returned error method
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
error: errorMock,
});
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
@@ -238,33 +273,30 @@ describe("utils", () => {
logApiError(mockRequest, error);
// Verify withContext was called with the expected context including method and path
expect(withContextMock).toHaveBeenCalledWith({
apiVersion: "v2",
correlationId: "123",
method: "POST",
path: "/api/v2/management/surveys",
error,
status: 500,
});
// Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Restore the original method
logger.withContext = originalWithContext;
});
test("logs API error details without correlationId", () => {
// Mock the withContext method and its returned error method
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
error: errorMock,
});
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
const mockRequest = new Request("http://localhost/api/test");
const mockRequest = new Request("http://localhost/api/v2/test");
mockRequest.headers.delete("x-request-id");
const error: ApiErrorResponseV2 = {
@@ -274,44 +306,26 @@ describe("utils", () => {
logApiError(mockRequest, error);
// Verify withContext was called with the expected context
expect(withContextMock).toHaveBeenCalledWith({
apiVersion: "v2",
correlationId: "",
method: "GET",
path: "/api/test",
path: "/api/v2/test",
error,
status: 500,
});
// Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Restore the original method
logger.withContext = originalWithContext;
});
test("log API error details with SENTRY_DSN set includes method and path tags", () => {
// Mock the withContext method and its returned error method
test("sends internal server errors to Sentry with direct capture context", () => {
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
error: errorMock,
});
// Mock Sentry's captureException method
vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any);
// Capture the scope mock for tag verification
const scopeSetTagMock = vi.fn();
vi.mocked(Sentry.withScope).mockImplementation((callback: (scope: any) => void) => {
const mockScope = {
setTag: scopeSetTagMock,
setContext: vi.fn(),
setLevel: vi.fn(),
setExtra: vi.fn(),
};
callback(mockScope);
});
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
@@ -325,31 +339,49 @@ describe("utils", () => {
logApiError(mockRequest, error);
// Verify withContext was called with the expected context including method and path
expect(withContextMock).toHaveBeenCalledWith({
apiVersion: "v2",
correlationId: "123",
method: "DELETE",
path: "/api/v2/management/surveys",
error,
status: 500,
});
// Verify error was called on the child logger
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Verify Sentry scope tags include method and path
expect(scopeSetTagMock).toHaveBeenCalledWith("correlationId", "123");
expect(scopeSetTagMock).toHaveBeenCalledWith("method", "DELETE");
expect(scopeSetTagMock).toHaveBeenCalledWith("path", "/api/v2/management/surveys");
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(Sentry.captureException).toHaveBeenCalledWith(
expect.objectContaining({
message: "API V2 error, id: 123",
}),
expect.objectContaining({
tags: expect.objectContaining({
apiVersion: "v2",
correlationId: "123",
method: "DELETE",
path: "/api/v2/management/surveys",
}),
extra: expect.objectContaining({
error,
originalError: error,
}),
contexts: expect.objectContaining({
apiRequest: expect.objectContaining({
apiVersion: "v2",
correlationId: "123",
method: "DELETE",
path: "/api/v2/management/surveys",
status: 500,
}),
}),
})
);
// Verify Sentry.captureException was called
expect(Sentry.captureException).toHaveBeenCalled();
// Restore the original method
logger.withContext = originalWithContext;
});
test("does not send to Sentry for non-internal_server_error types", () => {
// Mock the withContext method and its returned error method
const errorMock = vi.fn();
const withContextMock = vi.fn().mockReturnValue({
error: errorMock,
@@ -357,7 +389,6 @@ describe("utils", () => {
vi.mocked(Sentry.captureException).mockClear();
// Replace the original withContext with our mock
const originalWithContext = logger.withContext;
logger.withContext = withContextMock;
@@ -371,13 +402,10 @@ describe("utils", () => {
logApiError(mockRequest, error);
// Verify Sentry.captureException was NOT called for non-500 errors
expect(Sentry.captureException).not.toHaveBeenCalled();
// But structured logging should still happen
expect(Sentry.withScope).not.toHaveBeenCalled();
expect(errorMock).toHaveBeenCalledWith("API V2 Error Details");
// Restore the original method
logger.withContext = originalWithContext;
});
});

View File

@@ -1,37 +1,38 @@
// Function is this file can be used in edge runtime functions, like api routes.
import * as Sentry from "@sentry/nextjs";
import { logger } from "@formbricks/logger";
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { reportApiError } from "@/app/lib/api/api-error-reporter";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
export const logApiErrorEdge = (request: Request, error: ApiErrorResponseV2): void => {
const correlationId = request.headers.get("x-request-id") ?? "";
const method = request.method;
const url = new URL(request.url);
const path = url.pathname;
// Send the error to Sentry if the DSN is set and the error type is internal_server_error
// This is useful for tracking down issues without overloading Sentry with errors
if (SENTRY_DSN && IS_PRODUCTION && error.type === "internal_server_error") {
// Use Sentry scope to add correlation ID and request context as tags for easy filtering
Sentry.withScope((scope) => {
scope.setTag("correlationId", correlationId);
scope.setTag("method", method);
scope.setTag("path", path);
scope.setLevel("error");
scope.setExtra("originalError", error);
const err = new Error(`API V2 error, id: ${correlationId}`);
Sentry.captureException(err);
});
const getStatusFromApiError = (error: ApiErrorResponseV2): number => {
switch (error.type) {
case "bad_request":
return 400;
case "unauthorized":
return 401;
case "forbidden":
return 403;
case "not_found":
return 404;
case "conflict":
return 409;
case "unprocessable_entity":
return 422;
case "too_many_requests":
return 429;
case "internal_server_error":
default:
return 500;
}
logger
.withContext({
correlationId,
method,
path,
error,
})
.error("API V2 Error Details");
};
export const logApiErrorEdge = (
request: Request,
error: ApiErrorResponseV2,
originalError: unknown = error
): void => {
reportApiError({
request,
status: getStatusFromApiError(error),
error,
originalError,
});
};

View File

@@ -12,9 +12,10 @@ import { logApiErrorEdge } from "./utils-edge";
export const handleApiError = (
request: Request,
err: ApiErrorResponseV2,
auditLog?: TApiAuditLog
auditLog?: TApiAuditLog,
originalError: unknown = err
): Response => {
logApiError(request, err, auditLog);
logApiError(request, err, auditLog, originalError);
switch (err.type) {
case "bad_request":
@@ -64,9 +65,9 @@ export const logApiRequest = (request: Request, responseStatus: number, auditLog
const startTime = request.headers.get("x-start-time") || "";
const queryParams = Object.fromEntries(url.searchParams.entries());
const sensitiveParams = ["apikey", "token", "secret"];
const sensitiveParams = new Set(["apikey", "token", "secret"]);
const safeQueryParams = Object.fromEntries(
Object.entries(queryParams).filter(([key]) => !sensitiveParams.includes(key.toLowerCase()))
Object.entries(queryParams).filter(([key]) => !sensitiveParams.has(key.toLowerCase()))
);
logger
@@ -74,7 +75,7 @@ export const logApiRequest = (request: Request, responseStatus: number, auditLog
method,
path,
responseStatus,
duration: `${Date.now() - parseInt(startTime)} ms`,
duration: `${Date.now() - Number.parseInt(startTime, 10)} ms`,
correlationId,
queryParams: safeQueryParams,
})
@@ -83,8 +84,13 @@ export const logApiRequest = (request: Request, responseStatus: number, auditLog
logAuditLog(request, auditLog);
};
export const logApiError = (request: Request, error: ApiErrorResponseV2, auditLog?: TApiAuditLog): void => {
logApiErrorEdge(request, error);
export const logApiError = (
request: Request,
error: ApiErrorResponseV2,
auditLog?: TApiAuditLog,
originalError: unknown = error
): void => {
logApiErrorEdge(request, error, originalError);
logAuditLog(request, auditLog);
};

View File

@@ -10,7 +10,7 @@ import { getOrganizationIdFromEnvironmentId } from "@/lib/utils/helper";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { updateUser } from "./lib/update-user";
const handleError = (err: unknown, url: string): { response: Response } => {
const handleError = (err: unknown, url: string): { response: Response; error?: unknown } => {
if (err instanceof ResourceNotFoundError) {
return { response: responses.notFoundResponse(err.resourceType, err.resourceId) };
}
@@ -20,7 +20,10 @@ const handleError = (err: unknown, url: string): { response: Response } => {
}
logger.error({ error: err, url }, "Error in POST /api/v1/client/[environmentId]/user");
return { response: responses.internalServerErrorResponse("Unable to fetch user state", true) };
return {
response: responses.internalServerErrorResponse("Unable to fetch user state", true),
error: err,
};
};
export const OPTIONS = async (): Promise<Response> => {

View File

@@ -114,7 +114,8 @@
"@formbricks/logger#build",
"@formbricks/database#build",
"@formbricks/storage#build",
"@formbricks/cache#build"
"@formbricks/cache#build",
"@formbricks/surveys#build"
]
},
"@formbricks/web#test:coverage": {
@@ -122,7 +123,8 @@
"@formbricks/logger#build",
"@formbricks/database#build",
"@formbricks/storage#build",
"@formbricks/cache#build"
"@formbricks/cache#build",
"@formbricks/surveys#build"
]
},
"build": {