mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-04 03:16:15 -05:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 66b5185e33 | |||
| 319523131f | |||
| b5eaa4c7fd | |||
| 995c03bc01 | |||
| b4395a48c5 | |||
| 461e3893fe | |||
| 735a9f84ec | |||
| 8cb8d734cf | |||
| 44d5530b48 | |||
| a314eb391e | |||
| 6c34c316d0 | |||
| 4f26278f16 |
@@ -0,0 +1,9 @@
|
||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||
version = 1
|
||||
name = "formbricks"
|
||||
|
||||
[setup]
|
||||
script = '''
|
||||
pnpm install
|
||||
pnpm dev:setup
|
||||
'''
|
||||
@@ -191,6 +191,9 @@ ENTERPRISE_LICENSE_KEY=
|
||||
# Ignore Rate Limiting across the Formbricks app
|
||||
# RATE_LIMITING_DISABLED=1
|
||||
|
||||
# Disable telemetry reporting (usage stats sent to Formbricks). Ignored when an EE license is active.
|
||||
# TELEMETRY_DISABLED=1
|
||||
|
||||
# Allow webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x)
|
||||
# WARNING: Only enable this if you understand the SSRF risks. Useful for self-hosted instances
|
||||
# that need to send webhooks to internal services.
|
||||
|
||||
@@ -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.
|
||||
|
||||
[](https://railway.app/new/template/PPDzCd)
|
||||
|
||||
##### RepoCloud
|
||||
|
||||
Or you can also deploy Formbricks on [RepoCloud](https://repocloud.io) using the button below.
|
||||
|
||||
[](https://repocloud.io/details/?app_id=254)
|
||||
|
||||
##### Zeabur
|
||||
|
||||
Or you can also deploy Formbricks on [Zeabur](https://zeabur.com) using the button below.
|
||||
|
||||
[](https://zeabur.com/templates/G4TUJL)
|
||||
|
||||
<a id="development"></a>
|
||||
|
||||
## 👨💻 Development
|
||||
|
||||
### Prerequisites
|
||||
@@ -247,4 +223,4 @@ 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.
|
||||
|
||||
<p align="right"><a href="#top">🔼 Back to top</a></p>
|
||||
<a id="readme-de"></a>
|
||||
|
||||
@@ -51,8 +51,20 @@ vi.mock("@/lib/env", () => ({
|
||||
RECAPTCHA_SECRET_KEY: "secret-key",
|
||||
GITHUB_ID: "github-id",
|
||||
SAML_DATABASE_URL: "postgresql://saml.example.com/formbricks",
|
||||
ENTERPRISE_LICENSE_KEY: "test-license-key",
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/constants", () => ({
|
||||
E2E_TESTING: false,
|
||||
IS_DEVELOPMENT: false,
|
||||
TELEMETRY_DISABLED: false,
|
||||
}));
|
||||
vi.mock("@/lib/hash-string", () => ({
|
||||
hashString: vi.fn((s: string) => `hashed-${s}`),
|
||||
}));
|
||||
vi.mock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock fetch
|
||||
const fetchMock = vi.fn();
|
||||
@@ -199,6 +211,14 @@ describe("sendTelemetryEvents", () => {
|
||||
test("should handle telemetry send failure and apply cooldown", async () => {
|
||||
// Reset module to clear nextTelemetryCheck state from previous tests
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
E2E_TESTING: false,
|
||||
IS_DEVELOPMENT: false,
|
||||
TELEMETRY_DISABLED: false,
|
||||
}));
|
||||
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: false }),
|
||||
}));
|
||||
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
|
||||
|
||||
// Ensure we can acquire lock by setting last sent time far in the past
|
||||
@@ -221,6 +241,7 @@ describe("sendTelemetryEvents", () => {
|
||||
expect.objectContaining({
|
||||
error: networkError,
|
||||
message: "Network error",
|
||||
hashedLicenseKey: "hashed-test-license-key",
|
||||
}),
|
||||
"Failed to send telemetry - applying 1h cooldown"
|
||||
);
|
||||
@@ -242,6 +263,14 @@ describe("sendTelemetryEvents", () => {
|
||||
test("should skip if no organization exists", async () => {
|
||||
// Reset module to clear nextTelemetryCheck state from previous tests
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
E2E_TESTING: false,
|
||||
IS_DEVELOPMENT: false,
|
||||
TELEMETRY_DISABLED: false,
|
||||
}));
|
||||
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: false }),
|
||||
}));
|
||||
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
|
||||
|
||||
// Ensure we can acquire lock by setting last sent time far in the past
|
||||
@@ -276,4 +305,113 @@ describe("sendTelemetryEvents", () => {
|
||||
// This might be a bug, but we test the actual behavior
|
||||
expect(mockCacheService.set).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should skip telemetry when TELEMETRY_DISABLED is true and no active EE license", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
E2E_TESTING: false,
|
||||
IS_DEVELOPMENT: false,
|
||||
TELEMETRY_DISABLED: true,
|
||||
}));
|
||||
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: false }),
|
||||
}));
|
||||
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
|
||||
|
||||
await freshSendTelemetryEvents();
|
||||
|
||||
// Should return early without touching cache or sending telemetry
|
||||
expect(getCacheService).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should send telemetry when TELEMETRY_DISABLED is true but EE license is active", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
E2E_TESTING: false,
|
||||
IS_DEVELOPMENT: false,
|
||||
TELEMETRY_DISABLED: true,
|
||||
}));
|
||||
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: true }),
|
||||
}));
|
||||
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
|
||||
|
||||
// Re-setup mocks after resetModules
|
||||
vi.mocked(getCacheService).mockResolvedValue({
|
||||
ok: true,
|
||||
data: mockCacheService as any,
|
||||
});
|
||||
mockCacheService.tryLock.mockResolvedValue({ ok: true, data: true });
|
||||
mockCacheService.del.mockResolvedValue({ ok: true, data: undefined });
|
||||
mockCacheService.get.mockResolvedValue({ ok: true, data: null });
|
||||
mockCacheService.set.mockResolvedValue({ ok: true, data: undefined });
|
||||
|
||||
vi.mocked(prisma.organization.findFirst).mockResolvedValue({
|
||||
id: "org-123",
|
||||
createdAt: new Date("2023-01-01"),
|
||||
} as any);
|
||||
vi.mocked(prisma.$queryRaw).mockResolvedValue([
|
||||
{
|
||||
organizationCount: BigInt(1),
|
||||
userCount: BigInt(5),
|
||||
teamCount: BigInt(2),
|
||||
projectCount: BigInt(3),
|
||||
surveyCount: BigInt(10),
|
||||
inProgressSurveyCount: BigInt(4),
|
||||
completedSurveyCount: BigInt(6),
|
||||
responseCountAllTime: BigInt(100),
|
||||
responseCountSinceLastUpdate: BigInt(10),
|
||||
displayCount: BigInt(50),
|
||||
contactCount: BigInt(20),
|
||||
segmentCount: BigInt(4),
|
||||
newestResponseAt: new Date("2024-01-01T00:00:00.000Z"),
|
||||
},
|
||||
] as any);
|
||||
vi.mocked(prisma.integration.findMany).mockResolvedValue([{ type: IntegrationType.notion }] as any);
|
||||
vi.mocked(prisma.account.findMany).mockResolvedValue([{ provider: "github" }] as any);
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
|
||||
await freshSendTelemetryEvents();
|
||||
|
||||
// EE license active — telemetry should bypass TELEMETRY_DISABLED and send
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should unconditionally skip when E2E_TESTING is true even with active EE license", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
E2E_TESTING: true,
|
||||
IS_DEVELOPMENT: false,
|
||||
TELEMETRY_DISABLED: false,
|
||||
}));
|
||||
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: true }),
|
||||
}));
|
||||
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
|
||||
|
||||
await freshSendTelemetryEvents();
|
||||
|
||||
// E2E_TESTING is a hard skip — no EE bypass, no cache, no fetch
|
||||
expect(getCacheService).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should unconditionally skip when IS_DEVELOPMENT is true", async () => {
|
||||
vi.resetModules();
|
||||
vi.doMock("@/lib/constants", () => ({
|
||||
E2E_TESTING: false,
|
||||
IS_DEVELOPMENT: true,
|
||||
TELEMETRY_DISABLED: false,
|
||||
}));
|
||||
vi.doMock("@/modules/ee/license-check/lib/license", () => ({
|
||||
getEnterpriseLicense: vi.fn().mockResolvedValue({ active: true }),
|
||||
}));
|
||||
const { sendTelemetryEvents: freshSendTelemetryEvents } = await import("./telemetry");
|
||||
|
||||
await freshSendTelemetryEvents();
|
||||
|
||||
expect(getCacheService).not.toHaveBeenCalled();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,11 @@ import { IntegrationType } from "@prisma/client";
|
||||
import { createCacheKey, getCacheService } from "@formbricks/cache";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { E2E_TESTING, IS_DEVELOPMENT, TELEMETRY_DISABLED } from "@/lib/constants";
|
||||
import { env } from "@/lib/env";
|
||||
import { hashString } from "@/lib/hash-string";
|
||||
import { getInstanceInfo } from "@/lib/instance";
|
||||
import { getEnterpriseLicense } from "@/modules/ee/license-check/lib/license";
|
||||
import packageJson from "@/package.json";
|
||||
|
||||
const TELEMETRY_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
@@ -24,8 +27,31 @@ let nextTelemetryCheck = 0;
|
||||
* 2. Redis check (shared across instances, persists across restarts)
|
||||
* 3. Distributed lock (prevents concurrent execution in multi-instance deployments)
|
||||
*/
|
||||
// Hashed license key for log context — allows correlating log entries to a specific license
|
||||
// without exposing the raw key. Computed once at module load.
|
||||
const hashedLicenseKey = env.ENTERPRISE_LICENSE_KEY ? hashString(env.ENTERPRISE_LICENSE_KEY) : null;
|
||||
|
||||
/**
|
||||
* Returns true if telemetry is disabled via env var AND there is no active EE license.
|
||||
* EE customers cannot opt out — telemetry is always enforced for license compliance.
|
||||
*/
|
||||
const isTelemetryDisabledForCE = async (): Promise<boolean> => {
|
||||
if (!TELEMETRY_DISABLED) return false;
|
||||
const license = await getEnterpriseLicense();
|
||||
return !license.active;
|
||||
};
|
||||
|
||||
export const sendTelemetryEvents = async () => {
|
||||
try {
|
||||
// ============================================================
|
||||
// CHECK 0: Non-Production Hard Skip
|
||||
// ============================================================
|
||||
// Purpose: Unconditionally skip telemetry in dev and test/CI environments.
|
||||
// No EE bypass — these are internal flags, not customer-facing.
|
||||
if (E2E_TESTING || IS_DEVELOPMENT) {
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// ============================================================
|
||||
@@ -39,7 +65,18 @@ export const sendTelemetryEvents = async () => {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CHECK 2: Redis Check (Shared State)
|
||||
// CHECK 2: Telemetry Disabled Check
|
||||
// ============================================================
|
||||
// Purpose: Allow CE self-hosters to opt out of telemetry via env var.
|
||||
// EE bypass: If an active Enterprise License is detected, telemetry is always sent
|
||||
// regardless of the TELEMETRY_DISABLED setting to enforce license compliance.
|
||||
// Placed after in-memory check to avoid calling getEnterpriseLicense() on every invocation.
|
||||
if (await isTelemetryDisabledForCE()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CHECK 3: Redis Check (Shared State)
|
||||
// ============================================================
|
||||
// Purpose: Check if telemetry was sent recently by ANY instance (shared across cluster).
|
||||
// This persists across restarts and works in multi-instance deployments.
|
||||
@@ -66,7 +103,7 @@ export const sendTelemetryEvents = async () => {
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// CHECK 3: Distributed Lock (Prevent Concurrent Execution)
|
||||
// CHECK 4: Distributed Lock (Prevent Concurrent Execution)
|
||||
// ============================================================
|
||||
// Purpose: Ensure only ONE instance executes telemetry at a time in a cluster.
|
||||
// How it works:
|
||||
@@ -100,7 +137,7 @@ export const sendTelemetryEvents = async () => {
|
||||
// Log as warning since telemetry is non-essential
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
logger.warn(
|
||||
{ error: e, message: errorMessage, lastSent, now },
|
||||
{ error: e, message: errorMessage, lastSent, now, hashedLicenseKey },
|
||||
"Failed to send telemetry - applying 1h cooldown"
|
||||
);
|
||||
|
||||
@@ -118,7 +155,7 @@ export const sendTelemetryEvents = async () => {
|
||||
// Log as warning since telemetry is non-essential functionality
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(
|
||||
{ error, message: errorMessage, timestamp: Date.now() },
|
||||
{ error, message: errorMessage, timestamp: Date.now(), hashedLicenseKey },
|
||||
"Unexpected error in sendTelemetryEvents wrapper - telemetry check skipped"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
+488
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
+283
@@ -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),
|
||||
};
|
||||
};
|
||||
+84
@@ -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),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
+28
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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]",
|
||||
},
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -155,6 +155,7 @@ export const ENTERPRISE_LICENSE_KEY = env.ENTERPRISE_LICENSE_KEY;
|
||||
|
||||
export const REDIS_URL = env.REDIS_URL;
|
||||
export const RATE_LIMITING_DISABLED = env.RATE_LIMITING_DISABLED === "1";
|
||||
export const TELEMETRY_DISABLED = env.TELEMETRY_DISABLED === "1";
|
||||
|
||||
export const BREVO_API_KEY = env.BREVO_API_KEY;
|
||||
export const BREVO_LIST_ID = env.BREVO_LIST_ID;
|
||||
|
||||
@@ -68,6 +68,7 @@ export const env = createEnv({
|
||||
.optional()
|
||||
.or(z.string().refine((str) => str === "")),
|
||||
RATE_LIMITING_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
TELEMETRY_DISABLED: z.enum(["1", "0"]).optional(),
|
||||
S3_ACCESS_KEY: z.string().optional(),
|
||||
S3_BUCKET_NAME: z.string().optional(),
|
||||
S3_REGION: z.string().optional(),
|
||||
@@ -189,6 +190,7 @@ export const env = createEnv({
|
||||
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: process.env.PASSWORD_RESET_TOKEN_LIFETIME_MINUTES,
|
||||
PRIVACY_URL: process.env.PRIVACY_URL,
|
||||
RATE_LIMITING_DISABLED: process.env.RATE_LIMITING_DISABLED,
|
||||
TELEMETRY_DISABLED: process.env.TELEMETRY_DISABLED,
|
||||
S3_ACCESS_KEY: process.env.S3_ACCESS_KEY,
|
||||
S3_BUCKET_NAME: process.env.S3_BUCKET_NAME,
|
||||
S3_REGION: process.env.S3_REGION,
|
||||
|
||||
@@ -433,6 +433,7 @@
|
||||
"team_name": "Teamname",
|
||||
"team_role": "Team-Rolle",
|
||||
"teams": "Teams",
|
||||
"terms_of_service": "Nutzungsbedingungen",
|
||||
"text": "Text",
|
||||
"time": "Zeit",
|
||||
"time_to_finish": "Zeit zum Fertigstellen",
|
||||
|
||||
@@ -433,6 +433,7 @@
|
||||
"team_name": "Team name",
|
||||
"team_role": "Team role",
|
||||
"teams": "Teams",
|
||||
"terms_of_service": "Terms of Service",
|
||||
"text": "Text",
|
||||
"time": "Time",
|
||||
"time_to_finish": "Time to finish",
|
||||
|
||||
@@ -433,6 +433,7 @@
|
||||
"team_name": "Nombre del equipo",
|
||||
"team_role": "Rol del equipo",
|
||||
"teams": "Equipos",
|
||||
"terms_of_service": "Términos de servicio",
|
||||
"text": "Texto",
|
||||
"time": "Hora",
|
||||
"time_to_finish": "Tiempo para finalizar",
|
||||
|
||||
@@ -433,6 +433,7 @@
|
||||
"team_name": "Nom de l'équipe",
|
||||
"team_role": "Rôle dans l'équipe",
|
||||
"teams": "Équipes",
|
||||
"terms_of_service": "Conditions d'utilisation",
|
||||
"text": "Texte",
|
||||
"time": "Temps",
|
||||
"time_to_finish": "Temps de finir",
|
||||
|
||||
@@ -433,6 +433,7 @@
|
||||
"team_name": "Csapat neve",
|
||||
"team_role": "Csapatszerep",
|
||||
"teams": "Csapatok",
|
||||
"terms_of_service": "Felhasználási feltételek",
|
||||
"text": "Szöveg",
|
||||
"time": "Idő",
|
||||
"time_to_finish": "Idő a befejezésig",
|
||||
|
||||
@@ -433,6 +433,7 @@
|
||||
"team_name": "チーム名",
|
||||
"team_role": "チームの役割",
|
||||
"teams": "チーム",
|
||||
"terms_of_service": "利用規約",
|
||||
"text": "テキスト",
|
||||
"time": "時間",
|
||||
"time_to_finish": "所要時間",
|
||||
|
||||
@@ -433,6 +433,7 @@
|
||||
"team_name": "Teamnaam",
|
||||
"team_role": "Teamrol",
|
||||
"teams": "Teams",
|
||||
"terms_of_service": "Gebruiksvoorwaarden",
|
||||
"text": "Tekst",
|
||||
"time": "Tijd",
|
||||
"time_to_finish": "Tijd om af te ronden",
|
||||
|
||||
@@ -433,6 +433,7 @@
|
||||
"team_name": "Nome da equipe",
|
||||
"team_role": "Função na equipe",
|
||||
"teams": "Equipes",
|
||||
"terms_of_service": "Termos de Serviço",
|
||||
"text": "Texto",
|
||||
"time": "tempo",
|
||||
"time_to_finish": "Hora de terminar",
|
||||
|
||||
@@ -433,6 +433,7 @@
|
||||
"team_name": "Nome da equipa",
|
||||
"team_role": "Função na equipa",
|
||||
"teams": "Equipas",
|
||||
"terms_of_service": "Termos de Serviço",
|
||||
"text": "Texto",
|
||||
"time": "Tempo",
|
||||
"time_to_finish": "Tempo para concluir",
|
||||
|
||||
@@ -433,6 +433,7 @@
|
||||
"team_name": "Nume echipă",
|
||||
"team_role": "Rol în echipă",
|
||||
"teams": "Echipe",
|
||||
"terms_of_service": "Termeni și condiții",
|
||||
"text": "Text",
|
||||
"time": "Timp",
|
||||
"time_to_finish": "Timp până la finalizare",
|
||||
|
||||
@@ -433,6 +433,7 @@
|
||||
"team_name": "Название команды",
|
||||
"team_role": "Роль в команде",
|
||||
"teams": "Команды",
|
||||
"terms_of_service": "Условия использования",
|
||||
"text": "Текст",
|
||||
"time": "Время",
|
||||
"time_to_finish": "Время до завершения",
|
||||
|
||||
@@ -433,6 +433,7 @@
|
||||
"team_name": "Teamnamn",
|
||||
"team_role": "Teamroll",
|
||||
"teams": "Åtkomstkontroll",
|
||||
"terms_of_service": "Användarvillkor",
|
||||
"text": "Text",
|
||||
"time": "Tid",
|
||||
"time_to_finish": "Tid att slutföra",
|
||||
|
||||
@@ -433,6 +433,7 @@
|
||||
"team_name": "团队 名称",
|
||||
"team_role": "团队角色",
|
||||
"teams": "团队",
|
||||
"terms_of_service": "服务条款",
|
||||
"text": "文本",
|
||||
"time": "时间",
|
||||
"time_to_finish": "完成 时间",
|
||||
|
||||
@@ -433,6 +433,7 @@
|
||||
"team_name": "團隊名稱",
|
||||
"team_role": "團隊角色",
|
||||
"teams": "團隊",
|
||||
"terms_of_service": "服務條款",
|
||||
"text": "文字",
|
||||
"time": "時間",
|
||||
"time_to_finish": "完成時間",
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
import type { TUser } from "@formbricks/types/user";
|
||||
import { hashPassword } from "@/lib/auth";
|
||||
import { hashString } from "@/lib/hash-string";
|
||||
import { deleteSessionsByUserId } from "@/modules/auth/lib/auth-session-repository";
|
||||
import { sendPasswordResetLinkEmail, sendPasswordResetNotifyEmail } from "@/modules/email";
|
||||
import {
|
||||
ACCOUNT_RECOVERY_LINK_EMAIL_ERROR_CODE,
|
||||
@@ -26,6 +27,14 @@ type TPasswordResetAuditUserFixture = Pick<
|
||||
"id" | "email" | "locale" | "emailVerified"
|
||||
>;
|
||||
|
||||
type TPasswordResetSessionRecord = {
|
||||
id: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
sessionToken: string;
|
||||
userId: string;
|
||||
expires: Date;
|
||||
};
|
||||
type TPasswordResetTransactionStub = {
|
||||
user: {
|
||||
findUnique: (args: { where: { id: string } }) => Promise<TPasswordResetAuditUserFixture | null>;
|
||||
@@ -39,6 +48,26 @@ type TPasswordResetTransactionStub = {
|
||||
const testState = vi.hoisted(() => {
|
||||
const tokenStore = new Map<string, TPasswordResetTokenRecord>();
|
||||
const users = new Map<string, TPasswordResetTestUser>();
|
||||
const sessionStore = new Map<string, TPasswordResetSessionRecord>();
|
||||
|
||||
const cloneTokenRecord = (record: TPasswordResetTokenRecord): TPasswordResetTokenRecord => ({
|
||||
...record,
|
||||
expiresAt: new Date(record.expiresAt),
|
||||
createdAt: new Date(record.createdAt),
|
||||
updatedAt: new Date(record.updatedAt),
|
||||
});
|
||||
|
||||
const cloneUser = (user: TPasswordResetTestUser): TPasswordResetTestUser => ({
|
||||
...user,
|
||||
emailVerified: user.emailVerified ? new Date(user.emailVerified) : null,
|
||||
});
|
||||
|
||||
const cloneSessionRecord = (session: TPasswordResetSessionRecord): TPasswordResetSessionRecord => ({
|
||||
...session,
|
||||
createdAt: new Date(session.createdAt),
|
||||
updatedAt: new Date(session.updatedAt),
|
||||
expires: new Date(session.expires),
|
||||
});
|
||||
|
||||
const selectAuditUser = (user: TPasswordResetTestUser): TPasswordResetAuditUserFixture => ({
|
||||
id: user.id,
|
||||
@@ -91,7 +120,33 @@ const testState = vi.hoisted(() => {
|
||||
return 1;
|
||||
});
|
||||
|
||||
const mockDeleteSessionsByUserId = vi.fn(async (userId: string) => {
|
||||
const matchingSessionEntries = [...sessionStore.entries()].filter(
|
||||
([, sessionRecord]) => sessionRecord.userId === userId
|
||||
);
|
||||
|
||||
for (const [sessionId] of matchingSessionEntries) {
|
||||
sessionStore.delete(sessionId);
|
||||
}
|
||||
|
||||
return matchingSessionEntries.length;
|
||||
});
|
||||
|
||||
const restoreMap = <TKey, TValue>(
|
||||
store: Map<TKey, TValue>,
|
||||
snapshot: ReadonlyArray<readonly [TKey, TValue]>
|
||||
): void => {
|
||||
store.clear();
|
||||
snapshot.forEach(([key, value]) => store.set(key, value));
|
||||
};
|
||||
const mockTransaction = vi.fn(async <T>(callback: (tx: TPasswordResetTransactionStub) => Promise<T>) => {
|
||||
const tokenSnapshot = [...tokenStore.entries()].map(
|
||||
([key, record]) => [key, cloneTokenRecord(record)] as const
|
||||
);
|
||||
const userSnapshot = [...users.entries()].map(([key, user]) => [key, cloneUser(user)] as const);
|
||||
const sessionSnapshot = [...sessionStore.entries()].map(
|
||||
([key, session]) => [key, cloneSessionRecord(session)] as const
|
||||
);
|
||||
const tx: TPasswordResetTransactionStub = {
|
||||
user: {
|
||||
findUnique: vi.fn(async ({ where }) => {
|
||||
@@ -116,15 +171,24 @@ const testState = vi.hoisted(() => {
|
||||
},
|
||||
};
|
||||
|
||||
return await callback(tx);
|
||||
try {
|
||||
return await callback(tx);
|
||||
} catch (error) {
|
||||
restoreMap(tokenStore, tokenSnapshot);
|
||||
restoreMap(users, userSnapshot);
|
||||
restoreMap(sessionStore, sessionSnapshot);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
tokenStore,
|
||||
users,
|
||||
sessionStore,
|
||||
mockUpsertActiveToken,
|
||||
mockFindByTokenHash,
|
||||
mockDeleteByTokenHash,
|
||||
mockDeleteSessionsByUserId,
|
||||
mockConsumeActiveToken,
|
||||
mockTransaction,
|
||||
};
|
||||
@@ -176,6 +240,9 @@ vi.mock("./password-reset-token-repository", () => ({
|
||||
consumeActiveToken: testState.mockConsumeActiveToken,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/auth/lib/auth-session-repository", () => ({
|
||||
deleteSessionsByUserId: testState.mockDeleteSessionsByUserId,
|
||||
}));
|
||||
describe("password-reset-service", () => {
|
||||
const user = {
|
||||
id: "cm8z6bn2q000008l34h8g7k9m",
|
||||
@@ -241,10 +308,26 @@ describe("password-reset-service", () => {
|
||||
return storedUser;
|
||||
};
|
||||
|
||||
const createSessionRecord = (userId: string, sessionId: string): TPasswordResetSessionRecord => {
|
||||
const now = new Date("2026-03-30T12:00:00.000Z");
|
||||
|
||||
return {
|
||||
id: sessionId,
|
||||
userId,
|
||||
sessionToken: `session-token-${sessionId}`,
|
||||
expires: new Date("2026-03-31T12:00:00.000Z"),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
};
|
||||
|
||||
const getSessionsForUser = (userId: string): TPasswordResetSessionRecord[] =>
|
||||
[...testState.sessionStore.values()].filter((session) => session.userId === userId);
|
||||
beforeEach(() => {
|
||||
constantsState.debugShowResetLink = false;
|
||||
testState.tokenStore.clear();
|
||||
testState.users.clear();
|
||||
testState.sessionStore.clear();
|
||||
testState.users.set(user.id, {
|
||||
...user,
|
||||
emailVerified: null,
|
||||
@@ -343,6 +426,21 @@ describe("password-reset-service", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("revokes all active sessions for the user after a successful reset", async () => {
|
||||
const otherUserId = "cm8z6bn2q000008l34h8g7k9n";
|
||||
testState.sessionStore.set("session-user-1", createSessionRecord(user.id, "session-user-1"));
|
||||
testState.sessionStore.set("session-user-2", createSessionRecord(user.id, "session-user-2"));
|
||||
testState.sessionStore.set("session-other-1", createSessionRecord(otherUserId, "session-other-1"));
|
||||
|
||||
await requestPasswordReset(user, "public");
|
||||
const token = parseTokenFromResetLink();
|
||||
|
||||
await completePasswordReset(token, "Password123");
|
||||
|
||||
expect(deleteSessionsByUserId).toHaveBeenCalledWith(user.id, expect.any(Object));
|
||||
expect(getSessionsForUser(user.id)).toHaveLength(0);
|
||||
expect(getSessionsForUser(otherUserId)).toHaveLength(1);
|
||||
});
|
||||
test("allows only one successful result for concurrent token submissions", async () => {
|
||||
await requestPasswordReset(user, "public");
|
||||
const token = parseTokenFromResetLink();
|
||||
@@ -424,12 +522,14 @@ describe("password-reset-service", () => {
|
||||
test("does not roll back a successful password reset when the notification email fails", async () => {
|
||||
await requestPasswordReset(user, "public");
|
||||
const token = parseTokenFromResetLink();
|
||||
testState.sessionStore.set("session-user-1", createSessionRecord(user.id, "session-user-1"));
|
||||
vi.mocked(sendPasswordResetNotifyEmail).mockResolvedValueOnce(false);
|
||||
|
||||
const result = await completePasswordReset(token, "Password123");
|
||||
|
||||
expect(result.userId).toBe(user.id);
|
||||
expect(getStoredUser(user.id).password).toBe("hashed:Password123");
|
||||
expect(getSessionsForUser(user.id)).toHaveLength(0);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stage: "notify_email",
|
||||
@@ -474,4 +574,29 @@ describe("password-reset-service", () => {
|
||||
|
||||
expect(hashPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("rolls back the reset when session revocation fails", async () => {
|
||||
await requestPasswordReset(user, "public");
|
||||
const token = parseTokenFromResetLink();
|
||||
testState.sessionStore.set("session-user-1", createSessionRecord(user.id, "session-user-1"));
|
||||
testState.mockDeleteSessionsByUserId.mockRejectedValueOnce(new Error("Session revoke failed"));
|
||||
|
||||
await expect(completePasswordReset(token, "Password123")).rejects.toThrow("Session revoke failed");
|
||||
|
||||
expect(getStoredUser(user.id).password).toBe("old-password-hash");
|
||||
expect(getStoredToken(user.id).tokenHash).toBe(`hash:${token}`);
|
||||
expect(getSessionsForUser(user.id)).toHaveLength(1);
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stage: "session_revoke",
|
||||
userId: user.id,
|
||||
}),
|
||||
"Password reset completion failed"
|
||||
);
|
||||
|
||||
await expect(completePasswordReset(token, "Password123")).resolves.toMatchObject({
|
||||
userId: user.id,
|
||||
});
|
||||
expect(getStoredUser(user.id).password).toBe("hashed:Password123");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { hashPassword } from "@/lib/auth";
|
||||
import { DEBUG_SHOW_RESET_LINK, PASSWORD_RESET_TOKEN_LIFETIME_MINUTES, WEBAPP_URL } from "@/lib/constants";
|
||||
import { hashString } from "@/lib/hash-string";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { deleteSessionsByUserId } from "@/modules/auth/lib/auth-session-repository";
|
||||
import { sendPasswordResetLinkEmail, sendPasswordResetNotifyEmail } from "@/modules/email";
|
||||
import {
|
||||
consumeActiveToken,
|
||||
@@ -65,6 +66,17 @@ class PasswordResetNotificationEmailError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
class PasswordResetSessionRevocationError extends Error {
|
||||
userId: string;
|
||||
cause: unknown;
|
||||
|
||||
constructor(userId: string, cause: unknown) {
|
||||
super("ERR_ACCOUNT_RECOVERY_SESSION_REVOKE_FAILED");
|
||||
this.name = "PasswordResetSessionRevocationError";
|
||||
this.userId = userId;
|
||||
this.cause = cause;
|
||||
}
|
||||
}
|
||||
export const getPasswordResetTokenLifetimeInMinutes = (): number => PASSWORD_RESET_TOKEN_LIFETIME_MINUTES;
|
||||
|
||||
const buildPasswordResetLink = (token: string): string =>
|
||||
@@ -192,6 +204,11 @@ const updatePasswordWithActiveResetToken = async (
|
||||
select: passwordResetAuditSelection,
|
||||
});
|
||||
|
||||
try {
|
||||
await deleteSessionsByUserId(tokenRecord.userId, tx);
|
||||
} catch (error) {
|
||||
throw new PasswordResetSessionRevocationError(tokenRecord.userId, error);
|
||||
}
|
||||
return {
|
||||
userId: tokenRecord.userId,
|
||||
oldUser,
|
||||
@@ -319,6 +336,17 @@ export const completePasswordReset = async (
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (error instanceof PasswordResetSessionRevocationError) {
|
||||
logger.error(
|
||||
{
|
||||
error: error.cause instanceof Error ? error.cause : error,
|
||||
stage: "session_revoke",
|
||||
userId: error.userId,
|
||||
},
|
||||
"Password reset completion failed"
|
||||
);
|
||||
throw error.cause instanceof Error ? error.cause : error;
|
||||
}
|
||||
logger.error({ error, stage: "password_update" }, "Password reset completion failed");
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ export const resetPasswordAction = actionClient.inputSchema(ZResetPasswordAction
|
||||
}
|
||||
|
||||
const result = await completePasswordReset(parsedInput.token, parsedInput.password);
|
||||
|
||||
ctx.auditLoggingCtx.userId = result.userId;
|
||||
ctx.auditLoggingCtx.oldObject = { ...result.oldUser, passwordResetMarker: false };
|
||||
ctx.auditLoggingCtx.newObject = { ...result.updatedUser, passwordResetMarker: true };
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { deleteSessionsByUserId } from "./auth-session-repository";
|
||||
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
session: {
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("auth-session-repository", () => {
|
||||
const userId = "cm8z6bn2q000008l34h8g7k9m";
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
test("deletes all sessions for the target user", async () => {
|
||||
vi.mocked(prisma.session.deleteMany).mockResolvedValue({ count: 2 });
|
||||
|
||||
const result = await deleteSessionsByUserId(userId);
|
||||
|
||||
expect(result).toBe(2);
|
||||
expect(prisma.session.deleteMany).toHaveBeenCalledWith({
|
||||
where: { userId },
|
||||
});
|
||||
});
|
||||
|
||||
test("returns zero when the user has no sessions", async () => {
|
||||
vi.mocked(prisma.session.deleteMany).mockResolvedValue({ count: 0 });
|
||||
|
||||
const result = await deleteSessionsByUserId(userId);
|
||||
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
test("uses the provided transaction client when available", async () => {
|
||||
const txDeleteMany = vi.fn().mockResolvedValue({ count: 3 });
|
||||
const tx = {
|
||||
session: {
|
||||
deleteMany: txDeleteMany,
|
||||
},
|
||||
} as unknown as Prisma.TransactionClient;
|
||||
|
||||
const result = await deleteSessionsByUserId(userId, tx);
|
||||
|
||||
expect(result).toBe(3);
|
||||
expect(txDeleteMany).toHaveBeenCalledWith({
|
||||
where: { userId },
|
||||
});
|
||||
expect(prisma.session.deleteMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("wraps prisma known errors in DatabaseError", async () => {
|
||||
vi.mocked(prisma.session.deleteMany).mockRejectedValue(
|
||||
new Prisma.PrismaClientKnownRequestError("database failed", {
|
||||
code: "P2021",
|
||||
clientVersion: "test",
|
||||
})
|
||||
);
|
||||
|
||||
await expect(deleteSessionsByUserId(userId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import "server-only";
|
||||
import { Prisma, PrismaClient } from "@prisma/client";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
|
||||
type TAuthSessionDbClient = PrismaClient | Prisma.TransactionClient;
|
||||
|
||||
const getDbClient = (tx?: Prisma.TransactionClient): TAuthSessionDbClient => tx ?? prisma;
|
||||
|
||||
const handleDatabaseError = (error: unknown): never => {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
};
|
||||
|
||||
export const deleteSessionsByUserId = async (
|
||||
userId: string,
|
||||
tx?: Prisma.TransactionClient
|
||||
): Promise<number> => {
|
||||
validateInputs([userId, ZId]);
|
||||
|
||||
try {
|
||||
const result = await getDbClient(tx).session.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
},
|
||||
});
|
||||
|
||||
return result.count;
|
||||
} catch (error) {
|
||||
return handleDatabaseError(error);
|
||||
}
|
||||
};
|
||||
@@ -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> => {
|
||||
|
||||
@@ -369,8 +369,8 @@ export const MultipleChoiceElementForm = ({
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<div className="mt-2 flex items-center justify-between space-x-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{specialChoices.map((specialChoice) => {
|
||||
if (element.choices.some((c) => c.id === specialChoice.id)) return null;
|
||||
return (
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
|
||||
interface LegalFooterProps {
|
||||
IMPRINT_URL?: string;
|
||||
PRIVACY_URL?: string;
|
||||
TERMS_URL?: string;
|
||||
IS_FORMBRICKS_CLOUD: boolean;
|
||||
surveyUrl: string;
|
||||
}
|
||||
@@ -13,11 +14,12 @@ interface LegalFooterProps {
|
||||
export const LegalFooter = ({
|
||||
IMPRINT_URL,
|
||||
PRIVACY_URL,
|
||||
TERMS_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
surveyUrl,
|
||||
}: LegalFooterProps) => {
|
||||
const { t } = useTranslation();
|
||||
if (!IMPRINT_URL && !PRIVACY_URL && !IS_FORMBRICKS_CLOUD) return null;
|
||||
if (!IMPRINT_URL && !PRIVACY_URL && !TERMS_URL && !IS_FORMBRICKS_CLOUD) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-0 z-[1500] h-10 w-full" role="contentinfo">
|
||||
@@ -33,7 +35,13 @@ export const LegalFooter = ({
|
||||
{t("common.privacy")}
|
||||
</Link>
|
||||
)}
|
||||
{PRIVACY_URL && IS_FORMBRICKS_CLOUD && <span className="px-2">|</span>}
|
||||
{(IMPRINT_URL || PRIVACY_URL) && TERMS_URL && <span className="px-2">|</span>}
|
||||
{TERMS_URL && (
|
||||
<Link href={TERMS_URL} target="_blank" className="hover:underline" tabIndex={-1}>
|
||||
{t("common.terms_of_service")}
|
||||
</Link>
|
||||
)}
|
||||
{(IMPRINT_URL || PRIVACY_URL || TERMS_URL) && IS_FORMBRICKS_CLOUD && <span className="px-2">|</span>}
|
||||
{IS_FORMBRICKS_CLOUD && (
|
||||
<Link
|
||||
href={`https://app.formbricks.com/s/clxbivtla014iye2vfrn436xd?surveyUrl=${surveyUrl}`}
|
||||
|
||||
@@ -22,6 +22,7 @@ interface LinkSurveyWrapperProps {
|
||||
handleResetSurvey: () => void;
|
||||
IMPRINT_URL?: string;
|
||||
PRIVACY_URL?: string;
|
||||
TERMS_URL?: string;
|
||||
IS_FORMBRICKS_CLOUD: boolean;
|
||||
publicDomain: string;
|
||||
isBrandingEnabled: boolean;
|
||||
@@ -40,6 +41,7 @@ export const LinkSurveyWrapper = ({
|
||||
handleResetSurvey,
|
||||
IMPRINT_URL,
|
||||
PRIVACY_URL,
|
||||
TERMS_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
publicDomain,
|
||||
isBrandingEnabled,
|
||||
@@ -101,6 +103,7 @@ export const LinkSurveyWrapper = ({
|
||||
<LegalFooter
|
||||
IMPRINT_URL={IMPRINT_URL}
|
||||
PRIVACY_URL={PRIVACY_URL}
|
||||
TERMS_URL={TERMS_URL}
|
||||
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
|
||||
surveyUrl={publicDomain + "/s/" + surveyId}
|
||||
/>
|
||||
|
||||
@@ -19,6 +19,7 @@ interface PinScreenProps {
|
||||
publicDomain: string;
|
||||
IMPRINT_URL?: string;
|
||||
PRIVACY_URL?: string;
|
||||
TERMS_URL?: string;
|
||||
IS_FORMBRICKS_CLOUD: boolean;
|
||||
verifiedEmail?: string;
|
||||
languageCode: string;
|
||||
@@ -40,6 +41,7 @@ export const PinScreen = (props: PinScreenProps) => {
|
||||
singleUseResponse,
|
||||
IMPRINT_URL,
|
||||
PRIVACY_URL,
|
||||
TERMS_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
verifiedEmail,
|
||||
languageCode,
|
||||
@@ -134,6 +136,7 @@ export const PinScreen = (props: PinScreenProps) => {
|
||||
verifiedEmail={verifiedEmail}
|
||||
IMPRINT_URL={IMPRINT_URL}
|
||||
PRIVACY_URL={PRIVACY_URL}
|
||||
TERMS_URL={TERMS_URL}
|
||||
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -30,6 +30,7 @@ interface SurveyClientWrapperProps {
|
||||
verifiedEmail?: string;
|
||||
IMPRINT_URL?: string;
|
||||
PRIVACY_URL?: string;
|
||||
TERMS_URL?: string;
|
||||
IS_FORMBRICKS_CLOUD: boolean;
|
||||
}
|
||||
|
||||
@@ -53,6 +54,7 @@ export const SurveyClientWrapper = ({
|
||||
verifiedEmail,
|
||||
IMPRINT_URL,
|
||||
PRIVACY_URL,
|
||||
TERMS_URL,
|
||||
IS_FORMBRICKS_CLOUD,
|
||||
}: SurveyClientWrapperProps) => {
|
||||
const searchParams = useSearchParams();
|
||||
@@ -146,6 +148,7 @@ export const SurveyClientWrapper = ({
|
||||
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
|
||||
IMPRINT_URL={IMPRINT_URL}
|
||||
PRIVACY_URL={PRIVACY_URL}
|
||||
TERMS_URL={TERMS_URL}
|
||||
isBrandingEnabled={project.linkSurveyBranding}
|
||||
dir={logoDir}>
|
||||
<SurveyInline
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
IS_RECAPTCHA_CONFIGURED,
|
||||
PRIVACY_URL,
|
||||
RECAPTCHA_SITE_KEY,
|
||||
TERMS_URL,
|
||||
} from "@/lib/constants";
|
||||
import { getPublicDomain } from "@/lib/getPublicUrl";
|
||||
import { PinScreen } from "@/modules/survey/link/components/pin-screen";
|
||||
@@ -141,6 +142,7 @@ export const renderSurvey = async ({
|
||||
singleUseResponse={singleUseResponse}
|
||||
IMPRINT_URL={IMPRINT_URL}
|
||||
PRIVACY_URL={PRIVACY_URL}
|
||||
TERMS_URL={TERMS_URL}
|
||||
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
|
||||
verifiedEmail={verifiedEmail}
|
||||
languageCode={languageCode}
|
||||
@@ -173,6 +175,7 @@ export const renderSurvey = async ({
|
||||
verifiedEmail={verifiedEmail}
|
||||
IMPRINT_URL={IMPRINT_URL}
|
||||
PRIVACY_URL={PRIVACY_URL}
|
||||
TERMS_URL={TERMS_URL}
|
||||
IS_FORMBRICKS_CLOUD={IS_FORMBRICKS_CLOUD}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -48,7 +48,7 @@ export function LanguageIndicator({
|
||||
<button
|
||||
aria-expanded={showLanguageDropdown}
|
||||
aria-haspopup="true"
|
||||
className="relative z-20 flex max-w-[120px] items-center justify-center rounded-md bg-slate-900 p-1 px-2 text-xs text-white hover:bg-slate-700"
|
||||
className="relative z-20 flex max-w-20 items-center justify-center rounded-md bg-slate-900 p-1 px-2 text-xs text-white hover:bg-slate-700"
|
||||
onClick={toggleDropdown}
|
||||
tabIndex={-1}
|
||||
type="button">
|
||||
|
||||
@@ -240,6 +240,7 @@ vi.mock("@/lib/constants", () => ({
|
||||
MAIL_FROM: "mock@mail.com",
|
||||
MAIL_FROM_NAME: "Mock Mail",
|
||||
RATE_LIMITING_DISABLED: false,
|
||||
TELEMETRY_DISABLED: false,
|
||||
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES: 30,
|
||||
CONTROL_HASH: "$2b$12$fzHf9le13Ss9UJ04xzmsjODXpFJxz6vsnupoepF5FiqDECkX2BH5q",
|
||||
}));
|
||||
|
||||
@@ -169,6 +169,9 @@ x-environment: &environment
|
||||
# Set the below to 1 to disable Rate Limiting across Formbricks
|
||||
# RATE_LIMITING_DISABLED: 1
|
||||
|
||||
# Set the below to 1 to disable telemetry reporting (ignored when EE license is active)
|
||||
# TELEMETRY_DISABLED: 1
|
||||
|
||||
# Set the below to send OpenTelemetry data via OTLP to your collector
|
||||
# OTEL_EXPORTER_OTLP_ENDPOINT: http://localhost:4318
|
||||
# OTEL_EXPORTER_OTLP_PROTOCOL: http/protobuf
|
||||
|
||||
@@ -33,6 +33,7 @@ These variables are present inside your machine's docker-compose file. Restart t
|
||||
| PASSWORD_RESET_TOKEN_LIFETIME_MINUTES | Configures how long password reset links remain valid in minutes. Accepted values are integers from 5 to 120. | optional | 30 |
|
||||
| EMAIL_VERIFICATION_DISABLED | Disables email verification if set to 1. | optional | |
|
||||
| RATE_LIMITING_DISABLED | Disables rate limiting if set to 1. | optional | |
|
||||
| TELEMETRY_DISABLED | Disables telemetry reporting if set to 1. Ignored when an Enterprise License is active. | optional | |
|
||||
| DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS | Allows webhook URLs to point to internal/private network addresses (e.g. localhost, 192.168.x.x) if set to 1. Useful for self-hosted instances that need to send webhooks to internal services. | optional | |
|
||||
| INVITE_DISABLED | Disables the ability for invited users to create an account if set to 1. | optional | |
|
||||
| MAIL_FROM | Email address to send emails from. | optional (required if email services are to be enabled) | |
|
||||
|
||||
@@ -111,6 +111,13 @@ const formbricks = {
|
||||
setNonce,
|
||||
};
|
||||
|
||||
// Explicitly assign to globalThis so the wrapper SDK (@formbricks/js) can
|
||||
// find us even when the UMD environment detection is fooled by a leaked
|
||||
// `exports` or `module` global on the page (e.g. from another UMD bundle,
|
||||
// a tag manager, or a browser extension). This runs inside the UMD factory,
|
||||
// so it executes regardless of which branch the wrapper picks.
|
||||
(globalThis as unknown as Record<string, unknown>).formbricks = formbricks;
|
||||
|
||||
type TFormbricks = typeof formbricks;
|
||||
export type { TFormbricks };
|
||||
export default formbricks;
|
||||
|
||||
@@ -59,7 +59,7 @@ export function LanguageSwitch({
|
||||
handleI18nLanguage(calculatedLanguageCode);
|
||||
|
||||
if (setDir) {
|
||||
const calculateDir = isRTLLanguage(survey, calculatedLanguageCode) ? "rtl" : "auto";
|
||||
const calculateDir = isRTLLanguage(survey, calculatedLanguageCode) ? "rtl" : "ltr";
|
||||
setDir?.(calculateDir);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,13 @@ export function RenderSurvey(props: SurveyContainerProps) {
|
||||
const onFinishedTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const closeTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isRTL = isRTLLanguage(props.survey, props.languageCode);
|
||||
const [dir, setDir] = useState<"ltr" | "rtl" | "auto">(isRTL ? "rtl" : "auto");
|
||||
const [dir, setDir] = useState<"ltr" | "rtl" | "auto">(isRTL ? "rtl" : "ltr");
|
||||
|
||||
useEffect(() => {
|
||||
const isRTL = isRTLLanguage(props.survey, props.languageCode);
|
||||
setDir(isRTL ? "rtl" : "auto");
|
||||
}, [props.languageCode, props.survey]);
|
||||
setDir(isRTL ? "rtl" : "ltr");
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- Only recalculate direction when languageCode changes, not on survey auto-save
|
||||
}, [props.languageCode]);
|
||||
|
||||
const close = () => {
|
||||
if (onFinishedTimeoutRef.current) {
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
import { ComponentChildren } from "preact";
|
||||
import { useEffect } from "preact/hooks";
|
||||
import { useEffect, useRef } from "preact/hooks";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import i18n from "../../lib/i18n.config";
|
||||
|
||||
export const I18nProvider = ({ language, children }: { language: string; children?: ComponentChildren }) => {
|
||||
const isFirstRender = useRef(true);
|
||||
const prevLanguage = useRef(language);
|
||||
|
||||
// Set language synchronously on initial render so children get the correct translations immediately.
|
||||
// This is safe because all translations are pre-loaded (bundled) in i18n.config.ts.
|
||||
if (i18n.language !== language) {
|
||||
i18n.changeLanguage(language);
|
||||
}
|
||||
|
||||
// Handle language prop changes after initial render
|
||||
useEffect(() => {
|
||||
// On subsequent renders, skip this to avoid overriding language changes made by the user via LanguageSwitch.
|
||||
if (isFirstRender.current) {
|
||||
if (i18n.language !== language) {
|
||||
i18n.changeLanguage(language);
|
||||
}
|
||||
isFirstRender.current = false;
|
||||
}
|
||||
|
||||
// Only update language when the prop itself changes, not when i18n was changed internally by user action
|
||||
useEffect(() => {
|
||||
if (prevLanguage.current !== language) {
|
||||
i18n.changeLanguage(language);
|
||||
prevLanguage.current = language;
|
||||
}
|
||||
}, [language]);
|
||||
|
||||
// work around for react-i18next not supporting preact
|
||||
|
||||
+5
-2
@@ -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": {
|
||||
@@ -236,6 +238,7 @@
|
||||
"TURNSTILE_SITE_KEY",
|
||||
"RECAPTCHA_SITE_KEY",
|
||||
"RECAPTCHA_SECRET_KEY",
|
||||
"TELEMETRY_DISABLED",
|
||||
"TERMS_URL",
|
||||
"VERCEL",
|
||||
"VERCEL_URL",
|
||||
|
||||
Reference in New Issue
Block a user