mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-21 11:49:32 -05:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb82ca7ff4 | |||
| 6ef5b48590 | |||
| fb4bf7ca6d | |||
| 11b4d34b79 | |||
| c8b0bb2225 | |||
| f6aa27ba8c | |||
| 82765f7dd7 | |||
| d5bbafcf90 | |||
| db87a588b5 | |||
| c834587c8d | |||
| ef18aacfa2 | |||
| 025a766c57 | |||
| f476db3128 | |||
| 37023275ca | |||
| 9266f64588 | |||
| 032066194b | |||
| 0bef023302 | |||
| aa83ee336c | |||
| 4357f497a1 | |||
| 526c17af23 | |||
| a0ddadebad | |||
| bc0d04f5e8 | |||
| f0967c2e23 | |||
| 13c9677edd | |||
| c0bf2ab7cc | |||
| 65d0f4ac0e | |||
| 655c0b5e47 |
@@ -53,7 +53,7 @@ function {QuestionType}({
|
||||
}: {QuestionType}Props): React.JSX.Element {
|
||||
// Ensure value is always the correct type (handle undefined/null)
|
||||
const currentValue = value ?? {defaultValue};
|
||||
|
||||
|
||||
// Detect text direction from content
|
||||
const detectedDir = useTextDirection({
|
||||
dir,
|
||||
@@ -63,11 +63,11 @@ function {QuestionType}({
|
||||
return (
|
||||
<div className="w-full space-y-4" id={elementId} dir={detectedDir}>
|
||||
{/* Headline */}
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
htmlFor={inputId}
|
||||
<ElementHeader
|
||||
headline={headline}
|
||||
description={description}
|
||||
required={required}
|
||||
htmlFor={inputId}
|
||||
/>
|
||||
|
||||
{/* Question-specific controls */}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lint": "eslint . --config .eslintrc.cjs --ext .ts,.tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import { App } from "./App.tsx";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
|
||||
export const isPrismaKnownRequestError = (error: unknown): error is Prisma.PrismaClientKnownRequestError =>
|
||||
export const isPrismaKnownRequestError = (error: unknown): error is PrismaClientKnownRequestError =>
|
||||
error instanceof Prisma.PrismaClientKnownRequestError;
|
||||
|
||||
export const isSingleUseIdUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): boolean => {
|
||||
export const isSingleUseIdUniqueConstraintError = (error: PrismaClientKnownRequestError): boolean => {
|
||||
if (error.code !== PrismaErrorType.UniqueConstraintViolation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
+30
-2
@@ -9,12 +9,12 @@ const mocks = vi.hoisted(() => ({
|
||||
getSurvey: vi.fn(),
|
||||
getValidatedResponseUpdateInput: vi.fn(),
|
||||
loggerError: vi.fn(),
|
||||
resolveClientApiIds: vi.fn(),
|
||||
sendToPipeline: vi.fn(),
|
||||
updateResponseWithQuotaEvaluation: vi.fn(),
|
||||
validateFileUploads: vi.fn(),
|
||||
validateOtherOptionLengthForMultipleChoice: vi.fn(),
|
||||
validateResponseData: vi.fn(),
|
||||
resolveClientApiIds: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
@@ -128,11 +128,11 @@ describe("putResponseHandler", () => {
|
||||
});
|
||||
mocks.getResponse.mockResolvedValue(getBaseExistingResponse());
|
||||
mocks.getSurvey.mockResolvedValue(getBaseSurvey());
|
||||
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
|
||||
mocks.updateResponseWithQuotaEvaluation.mockResolvedValue(getBaseUpdatedResponse());
|
||||
mocks.validateFileUploads.mockReturnValue(true);
|
||||
mocks.validateOtherOptionLengthForMultipleChoice.mockReturnValue(null);
|
||||
mocks.validateResponseData.mockReturnValue(null);
|
||||
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
|
||||
});
|
||||
|
||||
test("returns a bad request response when the response id is missing", async () => {
|
||||
@@ -245,6 +245,34 @@ describe("putResponseHandler", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("returns not found when the workspace id cannot be resolved", async () => {
|
||||
mocks.resolveClientApiIds.mockResolvedValue(null);
|
||||
|
||||
const result = await putResponseHandler(createHandlerParams({ workspaceId: "unknown_workspace_or_env" }));
|
||||
|
||||
expect(result.response.status).toBe(404);
|
||||
await expect(result.response.json()).resolves.toEqual({
|
||||
code: "not_found",
|
||||
message: "Workspace not found",
|
||||
details: {
|
||||
resource_id: "unknown_workspace_or_env",
|
||||
resource_type: "Workspace",
|
||||
},
|
||||
});
|
||||
expect(mocks.getResponse).not.toHaveBeenCalled();
|
||||
expect(mocks.updateResponseWithQuotaEvaluation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("accepts updates when the route param is a legacy environment id that resolves to the survey workspace", async () => {
|
||||
mocks.resolveClientApiIds.mockResolvedValue({ workspaceId });
|
||||
|
||||
const result = await putResponseHandler(createHandlerParams({ workspaceId: "legacy_environment_id" }));
|
||||
|
||||
expect(mocks.resolveClientApiIds).toHaveBeenCalledWith("legacy_environment_id");
|
||||
expect(result.response.status).toBe(200);
|
||||
expect(mocks.updateResponseWithQuotaEvaluation).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("rejects updates when the response survey does not belong to the requested workspace", async () => {
|
||||
mocks.getSurvey.mockResolvedValue({
|
||||
...getBaseSurvey(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { PrismaClientKnownRequestError } from "@prisma/client/runtime/library";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
@@ -212,7 +213,7 @@ export const deleteConnector = async (connectorId: string, workspaceId: string):
|
||||
|
||||
// -- Composite functions --
|
||||
|
||||
const mapUniqueConstraintError = (error: Prisma.PrismaClientKnownRequestError): InvalidInputError => {
|
||||
const mapUniqueConstraintError = (error: PrismaClientKnownRequestError): InvalidInputError => {
|
||||
const target = error.meta?.target;
|
||||
const targetFields = Array.isArray(target) ? (target as string[]) : [];
|
||||
if (targetFields.includes("elementId") || targetFields.includes("surveyId")) {
|
||||
|
||||
@@ -38,6 +38,50 @@ describe("convertToCsv", () => {
|
||||
|
||||
parseSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("should defang formula injection payloads in cell values", async () => {
|
||||
const payloads = [
|
||||
'=HYPERLINK("https://evil.tld","Click")',
|
||||
"+1+1",
|
||||
"-2+3",
|
||||
"@SUM(A1:A2)",
|
||||
"\tleading-tab",
|
||||
"\rleading-cr",
|
||||
];
|
||||
const rows = payloads.map((p) => ({ name: p, age: 0 }));
|
||||
const csv = await convertToCsv(["name", "age"], rows);
|
||||
const lines = csv.trim().split("\n").slice(1); // drop header
|
||||
payloads.forEach((p, i) => {
|
||||
// each value should be prefixed with a single quote so the spreadsheet
|
||||
// app treats it as text rather than a formula
|
||||
expect(lines[i].startsWith(`"'${p.charAt(0)}`)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
test("should defang formula injection in field/header names", async () => {
|
||||
const csv = await convertToCsv(["=evil", "age"], [{ "=evil": "x", age: 1 }]);
|
||||
const lines = csv.trim().split("\n");
|
||||
expect(lines[0]).toBe('"\'=evil","age"');
|
||||
expect(lines[1]).toBe('"x",1');
|
||||
});
|
||||
|
||||
test("should not alter benign strings", async () => {
|
||||
const csv = await convertToCsv(["name"], [{ name: "Alice = Bob" }]);
|
||||
const lines = csv.trim().split("\n");
|
||||
expect(lines[1]).toBe('"Alice = Bob"');
|
||||
});
|
||||
|
||||
test("should preserve distinct columns whose labels collide after sanitization", async () => {
|
||||
// "=field" and "'=field" both render as "'=field" once defanged, but the
|
||||
// underlying row keys must stay distinct so neither cell is dropped.
|
||||
const csv = await convertToCsv(
|
||||
["=field", "'=field"],
|
||||
[{ "=field": "a", "'=field": "b" }]
|
||||
);
|
||||
const lines = csv.trim().split("\n");
|
||||
expect(lines[0]).toBe('"\'=field","\'=field"');
|
||||
expect(lines[1]).toBe('"a","b"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertToXlsxBuffer", () => {
|
||||
@@ -60,4 +104,54 @@ describe("convertToXlsxBuffer", () => {
|
||||
const cleaned = raw.map(({ __rowNum__, ...rest }) => rest);
|
||||
expect(cleaned).toEqual(data);
|
||||
});
|
||||
|
||||
test("should defang formula injection payloads in xlsx cells", () => {
|
||||
const payloads = [
|
||||
'=HYPERLINK("https://evil.tld","Click")',
|
||||
"+1+1",
|
||||
"-2+3",
|
||||
"@SUM(A1:A2)",
|
||||
"\tleading-tab",
|
||||
"\rleading-cr",
|
||||
];
|
||||
const rows = payloads.map((p) => ({ name: p }));
|
||||
const buffer = convertToXlsxBuffer(["name"], rows);
|
||||
const wb = xlsx.read(buffer, { type: "buffer" });
|
||||
const sheet = wb.Sheets["Sheet1"];
|
||||
payloads.forEach((p, i) => {
|
||||
const cell = sheet[`A${i + 2}`]; // row 1 is header
|
||||
// value stored as plain text, not as a formula (no `f` property)
|
||||
expect(cell.f).toBeUndefined();
|
||||
expect(cell.v).toBe(`'${p}`);
|
||||
});
|
||||
});
|
||||
|
||||
test("should defang formula injection in xlsx header names", () => {
|
||||
const buffer = convertToXlsxBuffer(["=evil", "name"], [{ "=evil": "x", name: "Alice" }]);
|
||||
const wb = xlsx.read(buffer, { type: "buffer" });
|
||||
const sheet = wb.Sheets["Sheet1"];
|
||||
const headerCell = sheet["A1"];
|
||||
expect(headerCell.f).toBeUndefined();
|
||||
expect(headerCell.v).toBe("'=evil");
|
||||
// benign header untouched
|
||||
expect(sheet["B1"].v).toBe("name");
|
||||
// data row mapped via original key
|
||||
expect(sheet["A2"].v).toBe("x");
|
||||
expect(sheet["B2"].v).toBe("Alice");
|
||||
});
|
||||
|
||||
test("should preserve distinct xlsx columns whose labels collide after sanitization", () => {
|
||||
// Original keys "=field" and "'=field" both render as "'=field"; ensure
|
||||
// both cells survive instead of one overwriting the other.
|
||||
const buffer = convertToXlsxBuffer(
|
||||
["=field", "'=field"],
|
||||
[{ "=field": "a", "'=field": "b" }]
|
||||
);
|
||||
const wb = xlsx.read(buffer, { type: "buffer" });
|
||||
const sheet = wb.Sheets["Sheet1"];
|
||||
expect(sheet["A1"].v).toBe("'=field");
|
||||
expect(sheet["B1"].v).toBe("'=field");
|
||||
expect(sheet["A2"].v).toBe("a");
|
||||
expect(sheet["B2"].v).toBe("b");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import type { TChartQuery } from "@formbricks/types/analysis";
|
||||
import { expandPresetDateRanges } from "./date-presets";
|
||||
|
||||
const queryWithDateRange = (dateRange: string | [string, string]): TChartQuery => ({
|
||||
measures: ["FeedbackRecords.count"],
|
||||
timeDimensions: [{ dimension: "FeedbackRecords.collectedAt", dateRange }],
|
||||
});
|
||||
|
||||
// Mid-month, mid-quarter date that exercises month/quarter/year boundaries cleanly.
|
||||
const NOW = new Date(2026, 4, 21, 14, 30, 0); // May 21, 2026 14:30 local
|
||||
|
||||
describe("expandPresetDateRanges", () => {
|
||||
test("includes today for 'last 7 days'", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("last 7 days"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-15", "2026-05-21"]);
|
||||
});
|
||||
|
||||
test("includes today for 'last 30 days'", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("last 30 days"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-22", "2026-05-21"]);
|
||||
});
|
||||
|
||||
test("expands 'today' to today..today", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("today"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-21", "2026-05-21"]);
|
||||
});
|
||||
|
||||
test("expands 'yesterday' to yesterday..yesterday", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("yesterday"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-20", "2026-05-20"]);
|
||||
});
|
||||
|
||||
test("'this month' runs from the 1st through today", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("this month"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-05-01", "2026-05-21"]);
|
||||
});
|
||||
|
||||
test("'last month' is the full previous calendar month", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("last month"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-01", "2026-04-30"]);
|
||||
});
|
||||
|
||||
test("'last month' handles year rollover", () => {
|
||||
const janFirst = new Date(2026, 0, 15, 10, 0, 0);
|
||||
const result = expandPresetDateRanges(queryWithDateRange("last month"), janFirst);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2025-12-01", "2025-12-31"]);
|
||||
});
|
||||
|
||||
test("'this quarter' starts at the first day of the calendar quarter", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("this quarter"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-04-01", "2026-05-21"]);
|
||||
});
|
||||
|
||||
test("'this year' starts on Jan 1", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("this year"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-01-01", "2026-05-21"]);
|
||||
});
|
||||
|
||||
test("leaves explicit [start, end] tuple unchanged", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange(["2026-01-01", "2026-01-15"]), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toEqual(["2026-01-01", "2026-01-15"]);
|
||||
});
|
||||
|
||||
test("leaves an unknown preset string unchanged so Cube can interpret it", () => {
|
||||
const result = expandPresetDateRanges(queryWithDateRange("from -3 days to now"), NOW);
|
||||
expect(result.timeDimensions?.[0].dateRange).toBe("from -3 days to now");
|
||||
});
|
||||
|
||||
test("returns input unchanged when there are no time dimensions", () => {
|
||||
const q: TChartQuery = { measures: ["FeedbackRecords.count"] };
|
||||
expect(expandPresetDateRanges(q, NOW)).toEqual(q);
|
||||
});
|
||||
|
||||
test("preserves other timeDimension fields (granularity, dimension)", () => {
|
||||
const q: TChartQuery = {
|
||||
measures: ["FeedbackRecords.count"],
|
||||
timeDimensions: [
|
||||
{ dimension: "FeedbackRecords.collectedAt", granularity: "day", dateRange: "last 7 days" },
|
||||
],
|
||||
};
|
||||
const result = expandPresetDateRanges(q, NOW);
|
||||
expect(result.timeDimensions?.[0]).toMatchObject({
|
||||
dimension: "FeedbackRecords.collectedAt",
|
||||
granularity: "day",
|
||||
dateRange: ["2026-05-15", "2026-05-21"],
|
||||
});
|
||||
});
|
||||
|
||||
test("does not mutate the input query", () => {
|
||||
const q = queryWithDateRange("last 7 days");
|
||||
const before = JSON.stringify(q);
|
||||
expandPresetDateRanges(q, NOW);
|
||||
expect(JSON.stringify(q)).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
FEEDBACK_FIELDS,
|
||||
@@ -6,6 +8,17 @@ import {
|
||||
getFilterOperatorsForType,
|
||||
} from "./schema-definition";
|
||||
|
||||
const chartCubeSchemaPath = fileURLToPath(
|
||||
new URL("../../../../../../charts/formbricks/cube/schema/FeedbackRecords.js", import.meta.url)
|
||||
);
|
||||
const dockerCubeSchemaPath = fileURLToPath(
|
||||
new URL("../../../../../../docker/cube/schema/FeedbackRecords.js", import.meta.url)
|
||||
);
|
||||
|
||||
const readChartCubeSchema = (): string => readFileSync(chartCubeSchemaPath, "utf8");
|
||||
const readDockerCubeSchema = (): string => readFileSync(dockerCubeSchemaPath, "utf8");
|
||||
const getCubeMemberName = (id: string): string => id.replace("FeedbackRecords.", "");
|
||||
|
||||
describe("schema-definition", () => {
|
||||
describe("getFilterOperatorsForType", () => {
|
||||
test("returns string operators", () => {
|
||||
@@ -94,5 +107,20 @@ describe("schema-definition", () => {
|
||||
);
|
||||
expect(ids).not.toContain("FeedbackRecords.averageScore");
|
||||
});
|
||||
|
||||
test("only exposes members present in the deployed Cube schema", () => {
|
||||
const chartCubeSchema = readChartCubeSchema();
|
||||
const exposedMembers = [...FEEDBACK_FIELDS.measures, ...FEEDBACK_FIELDS.dimensions].map(({ id }) =>
|
||||
getCubeMemberName(id)
|
||||
);
|
||||
|
||||
for (const member of exposedMembers) {
|
||||
expect(chartCubeSchema).toContain(` ${member}: {`);
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the Helm and Docker Cube schemas in sync", () => {
|
||||
expect(readChartCubeSchema()).toBe(readDockerCubeSchema());
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,8 +6,6 @@ import { processResponsePipelineJob } from "./process-response-pipeline-job";
|
||||
const {
|
||||
mockFetch,
|
||||
mockCaptureSurveyResponsePostHogEvent,
|
||||
mockCreatePinnedDispatcher,
|
||||
mockDispatcherDestroy,
|
||||
mockGetIntegrations,
|
||||
mockGetResponseCountBySurveyId,
|
||||
mockHandleIntegrations,
|
||||
@@ -23,16 +21,13 @@ const {
|
||||
mockSendFollowUpsForResponse,
|
||||
mockSendResponseFinishedEmail,
|
||||
mockSendTelemetryEvents,
|
||||
mockValidateAndResolveWebhookUrl,
|
||||
mockValidateWebhookUrl,
|
||||
} = vi.hoisted(() => {
|
||||
process.env.HUB_API_URL ??= "https://hub.test";
|
||||
const dispatcherDestroy = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
return {
|
||||
mockFetch: vi.fn(),
|
||||
mockCaptureSurveyResponsePostHogEvent: vi.fn(),
|
||||
mockCreatePinnedDispatcher: vi.fn(() => ({ destroy: dispatcherDestroy })),
|
||||
mockDispatcherDestroy: dispatcherDestroy,
|
||||
mockGetIntegrations: vi.fn(),
|
||||
mockGetResponseCountBySurveyId: vi.fn(),
|
||||
mockHandleIntegrations: vi.fn(),
|
||||
@@ -48,7 +43,7 @@ const {
|
||||
mockSendFollowUpsForResponse: vi.fn(),
|
||||
mockSendResponseFinishedEmail: vi.fn(),
|
||||
mockSendTelemetryEvents: vi.fn(),
|
||||
mockValidateAndResolveWebhookUrl: vi.fn(),
|
||||
mockValidateWebhookUrl: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -85,7 +80,6 @@ vi.mock(import("@/lib/constants"), async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
POSTHOG_KEY: undefined,
|
||||
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: false,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -110,8 +104,7 @@ vi.mock("./posthog", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils/validate-webhook-url", () => ({
|
||||
validateAndResolveWebhookUrl: mockValidateAndResolveWebhookUrl,
|
||||
createPinnedDispatcher: mockCreatePinnedDispatcher,
|
||||
validateWebhookUrl: mockValidateWebhookUrl,
|
||||
}));
|
||||
|
||||
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
|
||||
@@ -212,9 +205,7 @@ describe("processResponsePipelineJob", () => {
|
||||
mockPrismaUserFindMany.mockResolvedValue([]);
|
||||
mockGetResponseCountBySurveyId.mockResolvedValue(1);
|
||||
mockHandleIntegrations.mockResolvedValue(undefined);
|
||||
mockValidateAndResolveWebhookUrl.mockResolvedValue({ ip: "93.184.216.34", family: 4 });
|
||||
mockDispatcherDestroy.mockResolvedValue(undefined);
|
||||
mockCreatePinnedDispatcher.mockImplementation(() => ({ destroy: mockDispatcherDestroy }));
|
||||
mockValidateWebhookUrl.mockResolvedValue(undefined);
|
||||
mockQueueAuditEventWithoutRequest.mockResolvedValue(undefined);
|
||||
mockRecordResponseCreatedMeterEvent.mockResolvedValue(undefined);
|
||||
mockSendResponseFinishedEmail.mockResolvedValue(undefined);
|
||||
@@ -251,7 +242,7 @@ describe("processResponsePipelineJob", () => {
|
||||
triggers: { has: "responseCreated" },
|
||||
},
|
||||
});
|
||||
expect(mockValidateAndResolveWebhookUrl).toHaveBeenCalledWith("https://example.com/webhook");
|
||||
expect(mockValidateWebhookUrl).toHaveBeenCalledWith("https://example.com/webhook");
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://example.com/webhook",
|
||||
expect.objectContaining({
|
||||
@@ -490,7 +481,7 @@ describe("processResponsePipelineJob", () => {
|
||||
url: "https://example.com/webhook",
|
||||
},
|
||||
]);
|
||||
mockValidateAndResolveWebhookUrl.mockRejectedValue(webhookError);
|
||||
mockValidateWebhookUrl.mockRejectedValue(webhookError);
|
||||
|
||||
await expect(
|
||||
processResponsePipelineJob(
|
||||
@@ -536,7 +527,7 @@ describe("processResponsePipelineJob", () => {
|
||||
locale: "en",
|
||||
},
|
||||
]);
|
||||
mockValidateAndResolveWebhookUrl.mockRejectedValue(webhookError);
|
||||
mockValidateWebhookUrl.mockRejectedValue(webhookError);
|
||||
|
||||
await expect(
|
||||
processResponsePipelineJob(
|
||||
@@ -789,133 +780,6 @@ describe("processResponsePipelineJob", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("pins fetch to the resolved webhook IP via undici dispatcher", async () => {
|
||||
mockPrismaWebhookFindMany.mockResolvedValue([
|
||||
{
|
||||
id: "webhook_123",
|
||||
secret: null,
|
||||
url: "https://example.com/webhook",
|
||||
},
|
||||
]);
|
||||
const pinnedDispatcher = { destroy: mockDispatcherDestroy };
|
||||
mockValidateAndResolveWebhookUrl.mockResolvedValue({ ip: "203.0.113.10", family: 4 });
|
||||
mockCreatePinnedDispatcher.mockReturnValue(pinnedDispatcher);
|
||||
|
||||
await expect(processResponsePipelineJob(baseData, baseContext)).resolves.toBeUndefined();
|
||||
|
||||
expect(mockValidateAndResolveWebhookUrl).toHaveBeenCalledWith("https://example.com/webhook");
|
||||
expect(mockCreatePinnedDispatcher).toHaveBeenCalledWith({ ip: "203.0.113.10", family: 4 });
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://example.com/webhook",
|
||||
expect.objectContaining({
|
||||
dispatcher: pinnedDispatcher,
|
||||
redirect: "manual",
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("blocks 3xx redirects from webhook endpoints as delivery failures", async () => {
|
||||
mockPrismaWebhookFindMany.mockResolvedValue([
|
||||
{
|
||||
id: "webhook_123",
|
||||
secret: null,
|
||||
url: "https://example.com/webhook",
|
||||
},
|
||||
]);
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 302,
|
||||
});
|
||||
|
||||
await expect(processResponsePipelineJob(baseData, baseContext)).rejects.toThrow(
|
||||
"Webhook delivery blocked: redirect status 302"
|
||||
);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://example.com/webhook",
|
||||
expect.objectContaining({ redirect: "manual" })
|
||||
);
|
||||
expect(mockLoggerError).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
err: expect.any(Error),
|
||||
webhookId: "webhook_123",
|
||||
}),
|
||||
"Response pipeline webhook delivery failed"
|
||||
);
|
||||
});
|
||||
|
||||
test("destroys the pinned dispatcher after a successful webhook delivery", async () => {
|
||||
mockPrismaWebhookFindMany.mockResolvedValue([
|
||||
{
|
||||
id: "webhook_123",
|
||||
secret: null,
|
||||
url: "https://example.com/webhook",
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(processResponsePipelineJob(baseData, baseContext)).resolves.toBeUndefined();
|
||||
|
||||
expect(mockDispatcherDestroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("logs dispatcher cleanup failures without failing a successful webhook delivery", async () => {
|
||||
const cleanupError = new Error("destroy failed");
|
||||
mockPrismaWebhookFindMany.mockResolvedValue([
|
||||
{
|
||||
id: "webhook_123",
|
||||
secret: null,
|
||||
url: "https://example.com/webhook",
|
||||
},
|
||||
]);
|
||||
mockDispatcherDestroy.mockRejectedValue(cleanupError);
|
||||
|
||||
await expect(processResponsePipelineJob(baseData, baseContext)).resolves.toBeUndefined();
|
||||
|
||||
expect(mockLoggerWarn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
err: cleanupError,
|
||||
webhookId: "webhook_123",
|
||||
webhookUrl: "https://example.com/webhook",
|
||||
}),
|
||||
"Response pipeline webhook dispatcher cleanup failed"
|
||||
);
|
||||
});
|
||||
|
||||
test("destroys the pinned dispatcher when the webhook fetch throws", async () => {
|
||||
mockPrismaWebhookFindMany.mockResolvedValue([
|
||||
{
|
||||
id: "webhook_123",
|
||||
secret: null,
|
||||
url: "https://example.com/webhook",
|
||||
},
|
||||
]);
|
||||
mockFetch.mockRejectedValue(new Error("connect refused"));
|
||||
|
||||
await expect(processResponsePipelineJob(baseData, baseContext)).rejects.toThrow("connect refused");
|
||||
|
||||
expect(mockDispatcherDestroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("does not pin a dispatcher when the resolver returns null (internal URL flag)", async () => {
|
||||
mockPrismaWebhookFindMany.mockResolvedValue([
|
||||
{
|
||||
id: "webhook_123",
|
||||
secret: null,
|
||||
url: "http://localhost:3000/webhook",
|
||||
},
|
||||
]);
|
||||
mockValidateAndResolveWebhookUrl.mockResolvedValue(null);
|
||||
|
||||
await expect(processResponsePipelineJob(baseData, baseContext)).resolves.toBeUndefined();
|
||||
|
||||
expect(mockCreatePinnedDispatcher).not.toHaveBeenCalled();
|
||||
expect(mockDispatcherDestroy).not.toHaveBeenCalled();
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"http://localhost:3000/webhook",
|
||||
expect.objectContaining({ dispatcher: undefined })
|
||||
);
|
||||
});
|
||||
|
||||
test("classifies database pool exhaustion as retryable and logs a warning", async () => {
|
||||
const poolExhaustionError = new Error("Timed out fetching a new connection from the connection pool");
|
||||
mockPrismaSurveyFindUnique.mockRejectedValue(poolExhaustionError);
|
||||
|
||||
@@ -7,11 +7,11 @@ import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { type TUserLocale, ZUserLocale } from "@formbricks/types/user";
|
||||
import { handleConnectorPipeline } from "@/lib/connector/pipeline-handler";
|
||||
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS, POSTHOG_KEY } from "@/lib/constants";
|
||||
import { POSTHOG_KEY } from "@/lib/constants";
|
||||
import { generateStandardWebhookSignature } from "@/lib/crypto";
|
||||
import { getIntegrations } from "@/lib/integration/service";
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { createPinnedDispatcher, validateAndResolveWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
|
||||
import { queueAuditEventWithoutRequest } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { type TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import { recordResponseCreatedMeterEvent } from "@/modules/ee/billing/lib/metering";
|
||||
@@ -136,13 +136,9 @@ const createWebhookMessageId = ({
|
||||
webhookId: string;
|
||||
}): string => createHash("sha256").update(`${jobId}:${webhookId}:${event}`).digest("hex");
|
||||
|
||||
type WebhookFetchOptions = RequestInit & {
|
||||
dispatcher?: ReturnType<typeof createPinnedDispatcher>;
|
||||
};
|
||||
|
||||
const fetchWithTimeout = async (
|
||||
url: string,
|
||||
options: WebhookFetchOptions,
|
||||
options: RequestInit,
|
||||
timeoutMs: number = WEBHOOK_TIMEOUT_MS
|
||||
): Promise<Response> => {
|
||||
const abortController = new AbortController();
|
||||
@@ -157,7 +153,7 @@ const fetchWithTimeout = async (
|
||||
return await fetch(url, {
|
||||
...options,
|
||||
signal,
|
||||
} as RequestInit);
|
||||
});
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
@@ -226,47 +222,15 @@ const createWebhookDeliveryTask = async ({
|
||||
);
|
||||
}
|
||||
|
||||
const address = await validateAndResolveWebhookUrl(webhook.url);
|
||||
// Pin TCP connect to the validated IP — closes DNS-rebinding TOCTOU between
|
||||
// validation and fetch. Skip pinning when address is null (DANGEROUSLY flag +
|
||||
// blocked name resolved via /etc/hosts).
|
||||
const dispatcher = address ? createPinnedDispatcher(address) : undefined;
|
||||
// `redirect: "manual"` blocks 30x-based SSRF to private/internal hosts.
|
||||
// Gated on the same env var as URL validation for self-hosters who opted in.
|
||||
const redirectMode: RequestRedirect = DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS ? "follow" : "manual";
|
||||
await validateWebhookUrl(webhook.url);
|
||||
const response = await fetchWithTimeout(webhook.url, {
|
||||
method: "POST",
|
||||
headers: requestHeaders,
|
||||
body,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetchWithTimeout(webhook.url, {
|
||||
method: "POST",
|
||||
headers: requestHeaders,
|
||||
body,
|
||||
redirect: redirectMode,
|
||||
dispatcher,
|
||||
});
|
||||
|
||||
// With `redirect: "manual"`, undici returns the actual 30x (not opaqueredirect).
|
||||
// Treat as delivery failure so redirect-based SSRF cannot silently succeed.
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
throw new Error(`Webhook delivery blocked: redirect status ${response.status}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Webhook delivery failed with status ${response.status}`);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await dispatcher?.destroy();
|
||||
} catch (cleanupError) {
|
||||
logger.warn(
|
||||
{
|
||||
...logContext,
|
||||
err: cleanupError,
|
||||
webhookId: webhook.id,
|
||||
webhookUrl: webhook.url,
|
||||
},
|
||||
"Response pipeline webhook dispatcher cleanup failed"
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Webhook delivery failed with status ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
|
||||
@@ -31,7 +31,7 @@ export const SurveyCompletedMessage = async ({
|
||||
{(!workspace || workspace.linkSurveyBranding) && (
|
||||
<div>
|
||||
<Link href="https://formbricks.com">
|
||||
<Image src={footerLogo as string} alt="Brand logo" className="mx-auto w-40" />
|
||||
<Image src={footerLogo} alt="Brand logo" className="mx-auto w-40" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -76,7 +76,7 @@ export const SurveyInactive = async ({
|
||||
{(!workspace || workspace.linkSurveyBranding) && (
|
||||
<div>
|
||||
<Link href="https://formbricks.com">
|
||||
<Image src={footerLogo as string} alt="Brand logo" className="mx-auto w-40" />
|
||||
<Image src={footerLogo} alt="Brand logo" className="mx-auto w-40" />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -123,11 +123,7 @@ export const SurveyLoadingAnimation = ({
|
||||
isReadyToTransition ? "animate-surveyExit" : "animate-surveyLoading"
|
||||
)}>
|
||||
{isBrandingEnabled && (
|
||||
<Image
|
||||
src={Logo as string}
|
||||
alt="Logo"
|
||||
className={cn("w-32 transition-all duration-1000 md:w-40")}
|
||||
/>
|
||||
<Image src={Logo} alt="Logo" className={cn("w-32 transition-all duration-1000 md:w-40")} />
|
||||
)}
|
||||
<LoadingSpinner />
|
||||
</div>
|
||||
|
||||
@@ -46,7 +46,7 @@ const DropdownMenuSubContent: React.ComponentType<DropdownMenuPrimitive.Dropdown
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref as any}
|
||||
className={cn(
|
||||
"animate-in slide-in-from-left-1 z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-600 shadow-sm hover:text-slate-700",
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-600 shadow-sm animate-in slide-in-from-left-1 hover:text-slate-700",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -67,7 +67,7 @@ const DropdownMenuContent: React.ComponentType<DropdownMenuPrimitive.DropdownMen
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"animate-in data-[side=right]:slide-in-from-left-2 data-[side=left]:slide-in-from-right-2 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-700 shadow-sm",
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-slate-200 bg-white p-1 font-medium text-slate-700 shadow-sm animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -19,7 +19,7 @@ const PopoverContent: React.ForwardRefExoticComponent<
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-[side=right]:slide-in-from-left-2 data-[side=left]:slide-in-from-right-2 z-50 w-72 rounded-md border border-slate-100 bg-white p-4 shadow-md outline-none",
|
||||
"z-50 w-72 rounded-md border border-slate-100 bg-white p-4 shadow-md outline-none animate-in data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -23,7 +23,7 @@ const TooltipContent: React.ComponentType<TooltipPrimitive.TooltipContentProps>
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=top]:slide-in-from-bottom-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 z-50 overflow-hidden rounded-md border border-slate-100 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-md",
|
||||
"z-50 overflow-hidden rounded-md border border-slate-100 bg-white px-3 py-1.5 text-sm text-slate-700 shadow-md animate-in fade-in-50 data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
"build": "cross-env NODE_OPTIONS=--max-old-space-size=8192 next build",
|
||||
"build:dev": "pnpm run build",
|
||||
"start": "next start",
|
||||
"typecheck": "pnpm typegen && tsc --noEmit --project tsconfig.typecheck.json",
|
||||
"typegen": "cross-env DATABASE_URL=postgresql://postgres:postgres@localhost:5432/formbricks ENCRYPTION_KEY=example REDIS_URL=redis://localhost:6379 next typegen",
|
||||
"lint": "eslint . --fix --ext .ts,.js,.tsx,.jsx",
|
||||
"test": "dotenv -e ../../.env -- vitest run",
|
||||
"test:coverage": "dotenv -e ../../.env -- vitest run --coverage",
|
||||
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
import "@prisma/client";
|
||||
|
||||
declare module "@prisma/client" {
|
||||
namespace Prisma {
|
||||
// Prisma exposes this error class at runtime, but the generated client types do not declare it on Prisma.
|
||||
const PrismaClientKnownRequestError: typeof import("@prisma/client/runtime/library").PrismaClientKnownRequestError;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"exclude": [
|
||||
"../../.env",
|
||||
".next",
|
||||
"node_modules",
|
||||
"playwright",
|
||||
"**/*.test.ts",
|
||||
"**/*.test.tsx",
|
||||
"**/tests/**",
|
||||
"**/__mocks__/**",
|
||||
"**/__tests__/**"
|
||||
],
|
||||
"extends": "./tsconfig.json",
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.d.ts",
|
||||
"app/**/*.ts",
|
||||
"app/**/*.tsx",
|
||||
"lib/**/*.ts",
|
||||
"lib/**/*.tsx",
|
||||
"modules/**/*.ts",
|
||||
"modules/**/*.tsx",
|
||||
"scripts/**/*.ts",
|
||||
"../../packages/types/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -65,8 +65,6 @@ Cube is part of the baseline Formbricks v5 stack and is deployed by this chart b
|
||||
|
||||
The chart deploys Hub API and, by default, a `hub-worker` deployment. Hub API is insert-only for River jobs; webhook dispatch and embedding jobs are processed by `hub-worker`.
|
||||
|
||||
When the Formbricks migration job is enabled, Hub waits for the `formbricks-migration` Job to complete before its own goose/river init migrations run. This keeps fresh shared-database installs from creating Hub tables before Prisma has initialized the Formbricks schema.
|
||||
|
||||
Self-hosted embeddings are disabled by default. Set `hub.embeddings.enabled=true` to deploy an internal Hugging Face Text Embeddings Inference (TEI) service and wire Hub API plus Hub worker to it through the OpenAI-compatible endpoint added in Hub:
|
||||
|
||||
```yaml
|
||||
@@ -222,10 +220,6 @@ Autoscaling is opt-in for Hub API, Hub worker, and the embeddings runtime. If yo
|
||||
| hub.migration.activeDeadlineSeconds | int | `900` | |
|
||||
| hub.migration.backoffLimit | int | `3` | |
|
||||
| hub.migration.ttlSecondsAfterFinished | int | `300` | |
|
||||
| hub.migration.waitForFormbricksMigration.enabled | bool | `true` | |
|
||||
| hub.migration.waitForFormbricksMigration.intervalSeconds | int | `5` | |
|
||||
| hub.migration.waitForFormbricksMigration.maxAttempts | int | `180` | |
|
||||
| hub.migration.waitForFormbricksMigration.missingJobMaxAttempts | int | `12` | |
|
||||
| hub.pdb.enabled | bool | `false` | |
|
||||
| hub.replicas | int | `1` | |
|
||||
| hub.resources.limits.memory | string | `"512Mi"` | |
|
||||
|
||||
@@ -125,18 +125,10 @@ If `namespaceOverride` is provided, it will be used; otherwise, it defaults to `
|
||||
{{- printf "%s-app-secrets" (include "formbricks.name" .) -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "formbricks.migrationJobName" -}}
|
||||
{{- printf "%s-migration" (include "formbricks.name" .) | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "formbricks.hubSecretName" -}}
|
||||
{{- default (include "formbricks.appSecretName" .) .Values.hub.existingSecret -}}
|
||||
{{- end }}
|
||||
|
||||
{{- define "formbricks.hubMigrationWaitServiceAccountName" -}}
|
||||
{{- printf "%s-migration-wait" (include "formbricks.hubname" .) | trunc 63 | trimSuffix "-" -}}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Hub image reference. Pin by digest in production (hub.image.digest = "sha256:..."); falls back to
|
||||
hub.image.tag for local/dev. All Hub workloads (deployment, init container, migration job, future
|
||||
|
||||
@@ -32,134 +32,7 @@ spec:
|
||||
imagePullSecrets:
|
||||
{{- toYaml .Values.deployment.imagePullSecrets | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- if and .Values.migration.enabled .Values.hub.migration.waitForFormbricksMigration.enabled }}
|
||||
serviceAccountName: {{ include "formbricks.hubMigrationWaitServiceAccountName" . }}
|
||||
automountServiceAccountToken: true
|
||||
{{- end }}
|
||||
initContainers:
|
||||
{{- if and .Values.migration.enabled .Values.hub.migration.waitForFormbricksMigration.enabled }}
|
||||
- name: wait-for-formbricks-migration
|
||||
image: {{ .Values.deployment.image.repository }}:{{ .Values.deployment.image.tag | default .Chart.AppVersion | default "latest" }}
|
||||
imagePullPolicy: {{ .Values.deployment.image.pullPolicy }}
|
||||
command:
|
||||
- node
|
||||
- -e
|
||||
- |
|
||||
const fs = require("fs");
|
||||
const https = require("https");
|
||||
|
||||
const maxAttempts = Number.parseInt(process.env.FORMBRICKS_MIGRATION_WAIT_MAX_ATTEMPTS || "180", 10);
|
||||
const missingJobMaxAttempts = Number.parseInt(
|
||||
process.env.FORMBRICKS_MIGRATION_WAIT_MISSING_JOB_MAX_ATTEMPTS || "12",
|
||||
10
|
||||
);
|
||||
const intervalSeconds = Number.parseInt(process.env.FORMBRICKS_MIGRATION_WAIT_INTERVAL_SECONDS || "5", 10);
|
||||
const jobName = process.env.FORMBRICKS_MIGRATION_JOB_NAME;
|
||||
const namespace = fs
|
||||
.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/namespace", "utf8")
|
||||
.trim();
|
||||
const token = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8");
|
||||
const ca = fs.readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt");
|
||||
const host = process.env.KUBERNETES_SERVICE_HOST;
|
||||
const port = process.env.KUBERNETES_SERVICE_PORT || "443";
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const jobPath = `/apis/batch/v1/namespaces/${namespace}/jobs/${jobName}`;
|
||||
|
||||
const fetchJob = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
const request = https.request(
|
||||
{
|
||||
host,
|
||||
port,
|
||||
path: jobPath,
|
||||
method: "GET",
|
||||
ca,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
},
|
||||
(response) => {
|
||||
let body = "";
|
||||
|
||||
response.setEncoding("utf8");
|
||||
response.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
response.on("end", () => {
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
resolve(JSON.parse(body));
|
||||
return;
|
||||
}
|
||||
|
||||
const error = new Error(`Kubernetes API returned ${response.statusCode}: ${body}`);
|
||||
error.statusCode = response.statusCode;
|
||||
reject(error);
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
request.on("error", reject);
|
||||
request.end();
|
||||
});
|
||||
|
||||
(async () => {
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
const job = await fetchJob();
|
||||
const conditions = job.status?.conditions || [];
|
||||
const isComplete = conditions.some(
|
||||
(condition) => condition.type === "Complete" && condition.status === "True"
|
||||
);
|
||||
const isFailed = conditions.some(
|
||||
(condition) => condition.type === "Failed" && condition.status === "True"
|
||||
);
|
||||
|
||||
if (isComplete) {
|
||||
console.log(`${jobName} completed; starting Hub migrations.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFailed) {
|
||||
console.error(`${jobName} failed; refusing to start Hub migrations.`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Waiting for ${jobName} to complete (${attempt}/${maxAttempts})...`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (error && error.statusCode === 404 && attempt >= missingJobMaxAttempts) {
|
||||
console.log(`${jobName} was not found after ${attempt} attempts; assuming it was already cleaned up.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Waiting for ${jobName} to be available (${attempt}/${maxAttempts}): ${message}`);
|
||||
}
|
||||
|
||||
await sleep(intervalSeconds * 1000);
|
||||
}
|
||||
|
||||
console.error(`Timed out waiting for ${jobName} after ${maxAttempts} attempts.`);
|
||||
process.exitCode = 1;
|
||||
})()
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
env:
|
||||
- name: FORMBRICKS_MIGRATION_JOB_NAME
|
||||
value: {{ include "formbricks.migrationJobName" . | quote }}
|
||||
- name: FORMBRICKS_MIGRATION_WAIT_MAX_ATTEMPTS
|
||||
value: {{ .Values.hub.migration.waitForFormbricksMigration.maxAttempts | quote }}
|
||||
- name: FORMBRICKS_MIGRATION_WAIT_MISSING_JOB_MAX_ATTEMPTS
|
||||
value: {{ .Values.hub.migration.waitForFormbricksMigration.missingJobMaxAttempts | quote }}
|
||||
- name: FORMBRICKS_MIGRATION_WAIT_INTERVAL_SECONDS
|
||||
value: {{ .Values.hub.migration.waitForFormbricksMigration.intervalSeconds | quote }}
|
||||
{{- if .Values.migration.resources }}
|
||||
resources:
|
||||
{{- toYaml .Values.migration.resources | nindent 12 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
- name: hub-migrate
|
||||
image: {{ include "formbricks.hubImage" . }}
|
||||
imagePullPolicy: {{ .Values.hub.image.pullPolicy }}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
{{- if and .Values.migration.enabled .Values.hub.migration.waitForFormbricksMigration.enabled }}
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: {{ include "formbricks.hubMigrationWaitServiceAccountName" . }}
|
||||
labels:
|
||||
helm.sh/chart: {{ include "formbricks.chart" . }}
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: hub-migration-wait
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: {{ include "formbricks.hubMigrationWaitServiceAccountName" . }}
|
||||
labels:
|
||||
helm.sh/chart: {{ include "formbricks.chart" . }}
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: hub-migration-wait
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
|
||||
rules:
|
||||
- apiGroups:
|
||||
- batch
|
||||
resources:
|
||||
- jobs
|
||||
resourceNames:
|
||||
- {{ include "formbricks.migrationJobName" . }}
|
||||
verbs:
|
||||
- get
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: {{ include "formbricks.hubMigrationWaitServiceAccountName" . }}
|
||||
labels:
|
||||
helm.sh/chart: {{ include "formbricks.chart" . }}
|
||||
app.kubernetes.io/name: {{ include "formbricks.hubname" . }}
|
||||
app.kubernetes.io/instance: {{ .Release.Name }}
|
||||
app.kubernetes.io/component: hub-migration-wait
|
||||
app.kubernetes.io/managed-by: {{ .Release.Service }}
|
||||
app.kubernetes.io/part-of: {{ .Values.partOfOverride | default (include "formbricks.name" .) }}
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: {{ include "formbricks.hubMigrationWaitServiceAccountName" . }}
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: {{ include "formbricks.hubMigrationWaitServiceAccountName" . }}
|
||||
namespace: {{ include "formbricks.namespace" . }}
|
||||
{{- end }}
|
||||
@@ -3,7 +3,7 @@
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: {{ include "formbricks.migrationJobName" . }}
|
||||
name: {{ include "formbricks.name" . }}-migration
|
||||
labels:
|
||||
{{- include "formbricks.labels" . | nindent 4 }}
|
||||
annotations:
|
||||
|
||||
@@ -909,11 +909,6 @@ hub:
|
||||
ttlSecondsAfterFinished: 300
|
||||
backoffLimit: 3
|
||||
activeDeadlineSeconds: 900
|
||||
waitForFormbricksMigration:
|
||||
enabled: true
|
||||
maxAttempts: 180
|
||||
missingJobMaxAttempts: 12
|
||||
intervalSeconds: 5
|
||||
|
||||
resources:
|
||||
limits:
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
|
||||
"generate": "turbo run generate",
|
||||
"lint": "turbo run lint",
|
||||
"typecheck": "turbo run typecheck",
|
||||
"test": "turbo run test --no-cache",
|
||||
"test:coverage": "turbo run test:coverage --no-cache",
|
||||
"test:e2e": "playwright test",
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"lint": "eslint . --ext .ts,.js",
|
||||
"lint:fix": "eslint . --ext .ts,.js --fix",
|
||||
"lint:report": "eslint . --format json --output-file ../../lint-results/ai.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "rimraf dist && vite build && tsc --project tsconfig.build.json",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
|
||||
Vendored
+1
@@ -30,6 +30,7 @@
|
||||
"lint": "eslint . --ext .ts,.js",
|
||||
"lint:fix": "eslint . --ext .ts,.js --fix",
|
||||
"lint:report": "eslint . --format json --output-file ../../lint-results/cache.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "vite build",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
"generate": "prisma generate",
|
||||
"lint": "eslint ./src --fix",
|
||||
"generate-data-migration": "tsx ./src/scripts/generate-data-migration.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"create-migration": "dotenv -e ../../.env -- tsx ./src/scripts/create-migration.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
},
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"extends": "@formbricks/config-typescript/node16.json",
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", "tsup.config.ts"]
|
||||
"include": ["src/**/*.ts", "types/**/*.ts", "zod/**/*.ts", "migration/**/*.ts", "vite.config.ts"]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"dev": "email dev --port 3456",
|
||||
"build": "tsc --noEmit",
|
||||
"build": "pnpm typecheck",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src --fix --ext .ts,.tsx",
|
||||
"clean": "rimraf .turbo node_modules dist"
|
||||
},
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
|
||||
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
||||
"lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "tsc && vite build",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"build": "vite build",
|
||||
"build:dev": "vite build --mode dev",
|
||||
"go": "vite build --watch --mode dev",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
|
||||
"clean": "rimraf .turbo node_modules dist coverage",
|
||||
"test": "vitest run",
|
||||
|
||||
@@ -1,2 +1,37 @@
|
||||
import type { TWorkspaceStateSurvey } from "@/types/config";
|
||||
|
||||
export const mockSurveyId = "jgocyoxk9uifo6u381qahmes";
|
||||
export const mockSurveyName = "Test Survey";
|
||||
|
||||
export const mockSurvey: TWorkspaceStateSurvey = {
|
||||
id: mockSurveyId,
|
||||
welcomeCard: {
|
||||
enabled: false,
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
headline: { en: "Welcome" },
|
||||
},
|
||||
questions: [],
|
||||
variables: [],
|
||||
type: "app",
|
||||
showLanguageSwitch: false,
|
||||
endings: [],
|
||||
autoClose: null,
|
||||
status: "inProgress",
|
||||
recontactDays: null,
|
||||
displayLimit: null,
|
||||
displayOption: "displayMultiple",
|
||||
hiddenFields: { enabled: false },
|
||||
delay: 0,
|
||||
workspaceOverwrites: {},
|
||||
isBackButtonHidden: false,
|
||||
isAutoProgressingEnabled: false,
|
||||
recaptcha: { enabled: false, threshold: 0.5 },
|
||||
languages: [],
|
||||
triggers: [],
|
||||
displayPercentage: 100,
|
||||
};
|
||||
|
||||
export const createMockSurvey = (id = mockSurveyId): TWorkspaceStateSurvey => ({
|
||||
...mockSurvey,
|
||||
id,
|
||||
});
|
||||
|
||||
@@ -709,7 +709,10 @@ describe("time on page action handling", () => {
|
||||
clearTimeOnPageTimers();
|
||||
});
|
||||
|
||||
const createConfigWithTimeOnPageAction = (actionName: string, timeInSeconds: number) => ({
|
||||
const createConfigWithTimeOnPageAction = (
|
||||
actionName: string,
|
||||
timeInSeconds: number
|
||||
): { get: Mock; update: Mock } => ({
|
||||
get: vi.fn().mockReturnValue({
|
||||
workspace: {
|
||||
data: {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { SurveyStore } from "@/lib/survey/store";
|
||||
import { mockSurveyId, mockSurveyName } from "@/lib/survey/tests/__mocks__/store.mock";
|
||||
import type { TWorkspaceStateSurvey } from "@/types/config";
|
||||
import { createMockSurvey } from "@/lib/survey/tests/__mocks__/store.mock";
|
||||
|
||||
describe("SurveyStore", () => {
|
||||
let store: SurveyStore;
|
||||
@@ -27,10 +26,7 @@ describe("SurveyStore", () => {
|
||||
});
|
||||
|
||||
test("returns current survey when set", () => {
|
||||
const mockSurvey: TWorkspaceStateSurvey = {
|
||||
id: mockSurveyId,
|
||||
name: mockSurveyName,
|
||||
} as TWorkspaceStateSurvey;
|
||||
const mockSurvey = createMockSurvey();
|
||||
|
||||
store.setSurvey(mockSurvey);
|
||||
expect(store.getSurvey()).toBe(mockSurvey);
|
||||
@@ -40,10 +36,7 @@ describe("SurveyStore", () => {
|
||||
describe("setSurvey", () => {
|
||||
test("updates survey and notifies listeners when survey changes", () => {
|
||||
const listener = vi.fn();
|
||||
const mockSurvey: TWorkspaceStateSurvey = {
|
||||
id: mockSurveyId,
|
||||
name: mockSurveyName,
|
||||
} as TWorkspaceStateSurvey;
|
||||
const mockSurvey = createMockSurvey();
|
||||
|
||||
store.subscribe(listener);
|
||||
store.setSurvey(mockSurvey);
|
||||
@@ -54,10 +47,7 @@ describe("SurveyStore", () => {
|
||||
|
||||
test("does not notify listeners when setting same survey", () => {
|
||||
const listener = vi.fn();
|
||||
const mockSurvey: TWorkspaceStateSurvey = {
|
||||
id: mockSurveyId,
|
||||
name: mockSurveyName,
|
||||
} as TWorkspaceStateSurvey;
|
||||
const mockSurvey = createMockSurvey();
|
||||
|
||||
store.setSurvey(mockSurvey);
|
||||
store.subscribe(listener);
|
||||
@@ -70,10 +60,7 @@ describe("SurveyStore", () => {
|
||||
describe("resetSurvey", () => {
|
||||
test("resets survey to null and notifies listeners", () => {
|
||||
const listener = vi.fn();
|
||||
const mockSurvey: TWorkspaceStateSurvey = {
|
||||
id: mockSurveyId,
|
||||
name: mockSurveyName,
|
||||
} as TWorkspaceStateSurvey;
|
||||
const mockSurvey = createMockSurvey();
|
||||
|
||||
store.setSurvey(mockSurvey);
|
||||
store.subscribe(listener);
|
||||
@@ -96,27 +83,21 @@ describe("SurveyStore", () => {
|
||||
describe("subscribe", () => {
|
||||
test("adds listener and returns unsubscribe function", () => {
|
||||
const listener = vi.fn();
|
||||
const mockSurvey: TWorkspaceStateSurvey = {
|
||||
id: mockSurveyId,
|
||||
name: mockSurveyName,
|
||||
} as TWorkspaceStateSurvey;
|
||||
const mockSurvey = createMockSurvey();
|
||||
|
||||
const unsubscribe = store.subscribe(listener);
|
||||
store.setSurvey(mockSurvey);
|
||||
expect(listener).toHaveBeenCalledTimes(1);
|
||||
|
||||
unsubscribe();
|
||||
store.setSurvey({ ...mockSurvey, name: "Updated Survey" } as TWorkspaceStateSurvey);
|
||||
store.setSurvey({ ...mockSurvey, id: "updated-survey-id" });
|
||||
expect(listener).toHaveBeenCalledTimes(1); // Still 1, not called after unsubscribe
|
||||
});
|
||||
|
||||
test("multiple listeners receive updates", () => {
|
||||
const listener1 = vi.fn();
|
||||
const listener2 = vi.fn();
|
||||
const mockSurvey: TWorkspaceStateSurvey = {
|
||||
id: mockSurveyId,
|
||||
name: mockSurveyName,
|
||||
} as TWorkspaceStateSurvey;
|
||||
const mockSurvey = createMockSurvey();
|
||||
|
||||
store.subscribe(listener1);
|
||||
store.subscribe(listener2);
|
||||
|
||||
@@ -69,6 +69,20 @@ describe("widget-file", () => {
|
||||
configure: vi.fn(),
|
||||
};
|
||||
|
||||
const createMockFormbricksSurveys = (): NonNullable<Window["formbricksSurveys"]> => ({
|
||||
renderSurvey: vi.fn(),
|
||||
setNonce: vi.fn(),
|
||||
});
|
||||
|
||||
const getFormbricksSurveys = (): NonNullable<Window["formbricksSurveys"]> => {
|
||||
const formbricksSurveys = window.formbricksSurveys;
|
||||
if (!formbricksSurveys) {
|
||||
throw new Error("window.formbricksSurveys is not set");
|
||||
}
|
||||
|
||||
return formbricksSurveys;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
document.body.innerHTML = "";
|
||||
@@ -139,10 +153,7 @@ describe("widget-file", () => {
|
||||
(filterSurveys as Mock).mockReturnValue([]);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
window.formbricksSurveys = createMockFormbricksSurveys();
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -154,7 +165,7 @@ describe("widget-file", () => {
|
||||
|
||||
vi.advanceTimersByTime(mockSurvey.delay * 1000);
|
||||
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
|
||||
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
survey: mockSurvey,
|
||||
appUrl: "https://fake.app",
|
||||
@@ -302,10 +313,7 @@ describe("widget-file", () => {
|
||||
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
window.formbricksSurveys = createMockFormbricksSurveys();
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -319,7 +327,7 @@ describe("widget-file", () => {
|
||||
|
||||
vi.advanceTimersByTime(0);
|
||||
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
|
||||
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
contactId: "contact_abc",
|
||||
})
|
||||
@@ -362,10 +370,7 @@ describe("widget-file", () => {
|
||||
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
window.formbricksSurveys = createMockFormbricksSurveys();
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -378,7 +383,7 @@ describe("widget-file", () => {
|
||||
expect(mockUpdateQueue.waitForPendingWork).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(0);
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
|
||||
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -422,10 +427,7 @@ describe("widget-file", () => {
|
||||
mockUpdateQueue.waitForPendingWork.mockResolvedValue(true);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
window.formbricksSurveys = createMockFormbricksSurveys();
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -437,7 +439,7 @@ describe("widget-file", () => {
|
||||
vi.advanceTimersByTime(0);
|
||||
|
||||
// The contactId passed to renderSurvey should be read after the wait
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
|
||||
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
contactId: "contact_after_identification",
|
||||
})
|
||||
@@ -452,10 +454,7 @@ describe("widget-file", () => {
|
||||
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
window.formbricksSurveys = createMockFormbricksSurveys();
|
||||
|
||||
await widget.renderWidget({
|
||||
...mockSurvey,
|
||||
@@ -467,7 +466,7 @@ describe("widget-file", () => {
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
"User identification failed. Skipping survey with segment filters."
|
||||
);
|
||||
expect(window.formbricksSurveys.renderSurvey).not.toHaveBeenCalled();
|
||||
expect(getFormbricksSurveys().renderSurvey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("loadFormbricksSurveysExternally and waitForSurveysGlobal", () => {
|
||||
@@ -598,8 +597,7 @@ describe("widget-file", () => {
|
||||
(scriptEl.onload as () => void)();
|
||||
|
||||
// Set the global after script "loads" — simulates browser finishing execution
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = { renderSurvey: vi.fn(), setNonce: vi.fn() };
|
||||
window.formbricksSurveys = createMockFormbricksSurveys();
|
||||
|
||||
// Advance one polling interval for waitForSurveysGlobal to find it
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
@@ -609,8 +607,8 @@ describe("widget-file", () => {
|
||||
// Run remaining timers for survey.delay setTimeout
|
||||
vi.runAllTimers();
|
||||
|
||||
expect(window.formbricksSurveys.setNonce).toHaveBeenCalledWith("test-nonce-123");
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalledWith(
|
||||
expect(getFormbricksSurveys().setNonce).toHaveBeenCalledWith("test-nonce-123");
|
||||
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
appUrl: "https://fake.app",
|
||||
workspaceId: "env_123",
|
||||
@@ -629,13 +627,11 @@ describe("widget-file", () => {
|
||||
// After the previous successful test, surveysLoadPromise holds a resolved promise.
|
||||
// Calling renderWidget again (without formbricksSurveys on window, but with cached promise)
|
||||
// should reuse the cached promise rather than creating a new script element.
|
||||
// @ts-expect-error -- cleaning up mock to force dedup path
|
||||
delete window.formbricksSurveys;
|
||||
|
||||
const appendChildSpy = vi.spyOn(document.head, "appendChild");
|
||||
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = { renderSurvey: vi.fn(), setNonce: vi.fn() };
|
||||
window.formbricksSurveys = createMockFormbricksSurveys();
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -653,7 +649,7 @@ describe("widget-file", () => {
|
||||
});
|
||||
expect(scriptAppendCalls.length).toBe(0);
|
||||
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
|
||||
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
@@ -713,10 +709,7 @@ describe("widget-file", () => {
|
||||
getInstanceConfigMock.mockReturnValue(mockConfigValue as unknown as Config);
|
||||
widget.setIsSurveyRunning(false);
|
||||
|
||||
// @ts-expect-error -- mock window.formbricksSurveys
|
||||
window.formbricksSurveys = {
|
||||
renderSurvey: vi.fn(),
|
||||
};
|
||||
window.formbricksSurveys = createMockFormbricksSurveys();
|
||||
|
||||
vi.useFakeTimers();
|
||||
|
||||
@@ -731,7 +724,7 @@ describe("widget-file", () => {
|
||||
);
|
||||
|
||||
vi.advanceTimersByTime(0);
|
||||
expect(window.formbricksSurveys.renderSurvey).toHaveBeenCalled();
|
||||
expect(getFormbricksSurveys().renderSurvey).toHaveBeenCalled();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -239,7 +239,6 @@ const waitForSurveysGlobal = (): Promise<TFormbricksSurveys> => {
|
||||
const startTime = Date.now();
|
||||
|
||||
const check = (): void => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
|
||||
if (globalThis.window.formbricksSurveys) {
|
||||
const storedNonce = globalThis.window.__formbricksNonce;
|
||||
if (storedNonce) {
|
||||
@@ -262,7 +261,6 @@ const waitForSurveysGlobal = (): Promise<TFormbricksSurveys> => {
|
||||
};
|
||||
|
||||
const loadFormbricksSurveysExternally = (): Promise<TFormbricksSurveys> => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
|
||||
if (globalThis.window.formbricksSurveys) {
|
||||
return Promise.resolve(globalThis.window.formbricksSurveys);
|
||||
}
|
||||
@@ -300,7 +298,6 @@ let isPreloaded = false;
|
||||
|
||||
export const preloadSurveysScript = (appUrl: string): void => {
|
||||
// Don't preload if already loaded or already preloading
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- Runtime check for surveys package availability
|
||||
if (globalThis.window.formbricksSurveys) return;
|
||||
if (isPreloaded) return;
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
|
||||
"lint:fix": "eslint . --ext .ts,.js,.tsx,.jsx --fix",
|
||||
"lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "rimraf dist && vite build && tsc --project tsconfig.build.json",
|
||||
"test": "vitest run"
|
||||
},
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"lint": "eslint . --ext .ts,.js",
|
||||
"lint:fix": "eslint . --ext .ts,.js --fix",
|
||||
"lint:report": "eslint . --format json --output-file ../../lint-results/app-store.json",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "vite build && tsc --project tsconfig.build.json",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
|
||||
@@ -55,6 +55,7 @@
|
||||
"build": "vite build",
|
||||
"build:dev": "vite build --mode dev",
|
||||
"go": "vite build --watch --mode dev",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src --fix --ext .ts,.js,.tsx,.jsx",
|
||||
"preview": "vite preview",
|
||||
"clean": "rimraf .turbo node_modules dist coverage",
|
||||
|
||||
@@ -5,10 +5,10 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
export type ButtonVariant = "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | "custom";
|
||||
export type ButtonSize = "default" | "custom" | "sm" | "lg" | "icon";
|
||||
type ButtonVariantProps = {
|
||||
interface ButtonVariantProps {
|
||||
variant?: ButtonVariant | null;
|
||||
size?: ButtonSize | null;
|
||||
};
|
||||
}
|
||||
type ButtonVariantClassProps =
|
||||
| (ButtonVariantProps & { class?: string; className?: never })
|
||||
| (ButtonVariantProps & { class?: never; className?: string })
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"build:analyze": "cross-env NODE_OPTIONS=--max-old-space-size=8192 ANALYZE=true vite build && cross-env NODE_OPTIONS=--max-old-space-size=8192 tsc --project tsconfig.build.json",
|
||||
"build:dev": "cross-env NODE_OPTIONS=--max-old-space-size=8192 vite build --mode dev && cross-env NODE_OPTIONS=--max-old-space-size=8192 tsc --project tsconfig.build.json",
|
||||
"go": "concurrently -n \"ESM,UMD\" \"vite build --watch --mode dev\" \"BUILD_UMD=true vite build --watch --mode dev\"",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src --fix --ext .ts,.js,.tsx,.jsx",
|
||||
"preview": "vite preview",
|
||||
"clean": "rimraf .turbo node_modules dist",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "./button";
|
||||
|
||||
interface BackButtonProps {
|
||||
onClick: () => void;
|
||||
@@ -7,18 +7,11 @@ interface BackButtonProps {
|
||||
tabIndex?: number;
|
||||
}
|
||||
|
||||
export function BackButton({ onClick, backButtonLabel, tabIndex = 2 }: BackButtonProps) {
|
||||
export function BackButton({ onClick, backButtonLabel, tabIndex = 2 }: Readonly<BackButtonProps>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
dir="auto"
|
||||
tabIndex={tabIndex}
|
||||
type="button"
|
||||
className={cn(
|
||||
"hover:bg-input-bg text-heading focus:ring-focus rounded-custom mb-1 flex items-center px-3 py-3 text-base leading-4 font-medium focus:ring-2 focus:ring-offset-2 focus:outline-hidden"
|
||||
)}
|
||||
onClick={onClick}>
|
||||
<Button dir="auto" type="button" variant="secondary" tabIndex={tabIndex} onClick={onClick}>
|
||||
{backButtonLabel || t("common.back")}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ButtonHTMLAttributes } from "preact";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ButtonProps = {
|
||||
variant: "primary" | "secondary";
|
||||
} & ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
export function Button({ variant, className, children, ...rest }: Readonly<ButtonProps>) {
|
||||
return (
|
||||
<button
|
||||
dir="auto"
|
||||
type="button"
|
||||
className={cn([
|
||||
"border-border mb-1 flex items-center justify-center border leading-4",
|
||||
"focus:ring-focus shadow-xs focus:ring-2 focus:ring-offset-2 focus:outline-hidden",
|
||||
"enabled:hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60",
|
||||
[
|
||||
// secondary uses primary's bg color as text color and vice-versa
|
||||
'data-[variant="secondary"]:bg-(--fb-button-text-color)!',
|
||||
'data-[variant="secondary"]:text-(--fb-button-bg-color)!',
|
||||
],
|
||||
String(className || ""),
|
||||
])}
|
||||
data-variant={variant}
|
||||
style={{
|
||||
backgroundColor: "var(--fb-button-bg-color)",
|
||||
color: "var(--fb-button-text-color)",
|
||||
borderRadius: "var(--fb-button-border-radius)",
|
||||
height: "var(--fb-button-height)",
|
||||
fontSize: "var(--fb-button-font-size)",
|
||||
fontWeight: "var(--fb-button-font-weight)",
|
||||
paddingLeft: "var(--fb-button-padding-x)",
|
||||
paddingRight: "var(--fb-button-padding-x)",
|
||||
paddingTop: "var(--fb-button-padding-y)",
|
||||
paddingBottom: "var(--fb-button-padding-y)",
|
||||
}}
|
||||
{...rest}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ButtonHTMLAttributes, useRef } from "preact/compat";
|
||||
import { ButtonHTMLAttributes } from "preact";
|
||||
import { useRef } from "preact/compat";
|
||||
import { useCallback, useEffect, useState } from "preact/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "./button";
|
||||
|
||||
interface SubmitButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
buttonLabel?: string;
|
||||
@@ -66,32 +67,18 @@ export function SubmitButton({
|
||||
}, [focus]);
|
||||
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
{...props}
|
||||
dir="auto"
|
||||
variant="primary"
|
||||
className="button-custom border-submit-button-border"
|
||||
ref={buttonRef}
|
||||
type={type}
|
||||
tabIndex={tabIndex}
|
||||
autoFocus={focus}
|
||||
className={cn(
|
||||
"border-submit-button-border focus:ring-focus mb-1 flex items-center justify-center border leading-4 shadow-xs focus:ring-2 focus:ring-offset-2 focus:outline-hidden enabled:hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60",
|
||||
"button-custom"
|
||||
)}
|
||||
style={{
|
||||
borderRadius: "var(--fb-button-border-radius)",
|
||||
backgroundColor: "var(--fb-button-bg-color)",
|
||||
color: "var(--fb-button-text-color)",
|
||||
height: "var(--fb-button-height)",
|
||||
fontSize: "var(--fb-button-font-size)",
|
||||
fontWeight: "var(--fb-button-font-weight)",
|
||||
paddingLeft: "var(--fb-button-padding-x)",
|
||||
paddingRight: "var(--fb-button-padding-x)",
|
||||
paddingTop: "var(--fb-button-padding-y)",
|
||||
paddingBottom: "var(--fb-button-padding-y)",
|
||||
}}
|
||||
onClick={onClick}
|
||||
disabled={disabled}>
|
||||
{buttonLabel || (isLastQuestion ? t("common.finish") : t("common.next"))}
|
||||
</button>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,6 @@ describe("Survey Logic", () => {
|
||||
|
||||
const mockSurvey: TJsWorkspaceStateSurvey = {
|
||||
id: "survey1",
|
||||
name: "Test Survey",
|
||||
questions: [], // Deprecated - using blocks instead
|
||||
blocks: [
|
||||
{
|
||||
|
||||
@@ -552,4 +552,12 @@ describe("cn", () => {
|
||||
test("handles all undefined", () => {
|
||||
expect(cn(undefined, undefined)).toBe("");
|
||||
});
|
||||
|
||||
test("handles nested arrays of classes", () => {
|
||||
expect(cn(["foo", ["foo", ["foo", "bar"]]])).toBe("foo foo foo bar");
|
||||
});
|
||||
|
||||
test("handles nulls, booleans and undefined values", () => {
|
||||
expect(cn(null, true, false, undefined, [null, true, false, undefined])).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,8 +12,14 @@ import { type TSurveyElement, type TSurveyElementChoice } from "@formbricks/type
|
||||
import { type TShuffleOption } from "@formbricks/types/surveys/types";
|
||||
import { ApiResponse, ApiSuccessResponse } from "@/types/api";
|
||||
|
||||
export const cn = (...classes: (string | undefined)[]) => {
|
||||
return twMerge(classes.filter(Boolean).join(" "));
|
||||
type ClassValue = string | boolean | null | undefined | ClassValue[];
|
||||
export const cn = (...classes: ClassValue[]): string => {
|
||||
return twMerge(
|
||||
classes
|
||||
.map((c) => (Array.isArray(c) ? cn(...c) : c))
|
||||
.filter((c): c is string => typeof c === "string" && c.length > 0)
|
||||
.join(" ")
|
||||
);
|
||||
};
|
||||
|
||||
export const getSecureRandom = (): number => {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .ts,.js,.tsx,.jsx",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rimraf node_modules .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"clean": "rimraf .turbo node_modules dist",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint . --ext .ts,.js,.tsx,.jsx"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+32
@@ -15,6 +15,9 @@
|
||||
"@formbricks/ai#test:coverage": {
|
||||
"dependsOn": ["@formbricks/logger#build"]
|
||||
},
|
||||
"@formbricks/ai#typecheck": {
|
||||
"dependsOn": ["@formbricks/logger#build"]
|
||||
},
|
||||
"@formbricks/cache#build": {
|
||||
"dependsOn": ["@formbricks/logger#build"],
|
||||
"outputs": ["dist/**"]
|
||||
@@ -31,6 +34,9 @@
|
||||
"@formbricks/cache#test:coverage": {
|
||||
"dependsOn": ["@formbricks/logger#build"]
|
||||
},
|
||||
"@formbricks/cache#typecheck": {
|
||||
"dependsOn": ["@formbricks/logger#build"]
|
||||
},
|
||||
"@formbricks/database#build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**", "../../node_modules/.prisma/client/**"]
|
||||
@@ -38,6 +44,9 @@
|
||||
"@formbricks/database#lint": {
|
||||
"dependsOn": ["@formbricks/logger#build", "@formbricks/database#build"]
|
||||
},
|
||||
"@formbricks/database#typecheck": {
|
||||
"dependsOn": ["@formbricks/logger#build", "@formbricks/database#generate"]
|
||||
},
|
||||
"@formbricks/email#build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": []
|
||||
@@ -77,6 +86,9 @@
|
||||
"@formbricks/js-core#lint": {
|
||||
"dependsOn": ["@formbricks/database#build"]
|
||||
},
|
||||
"@formbricks/js-core#typecheck": {
|
||||
"dependsOn": ["@formbricks/database#build"]
|
||||
},
|
||||
"@formbricks/logger#build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**"]
|
||||
@@ -99,6 +111,9 @@
|
||||
"@formbricks/storage#test:coverage": {
|
||||
"dependsOn": ["@formbricks/logger#build"]
|
||||
},
|
||||
"@formbricks/storage#typecheck": {
|
||||
"dependsOn": ["@formbricks/logger#build"]
|
||||
},
|
||||
"@formbricks/survey-ui#build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["dist/**"]
|
||||
@@ -131,6 +146,9 @@
|
||||
"@formbricks/surveys#test:coverage": {
|
||||
"dependsOn": ["@formbricks/survey-ui#build"]
|
||||
},
|
||||
"@formbricks/surveys#typecheck": {
|
||||
"dependsOn": ["@formbricks/i18n-utils#build", "@formbricks/survey-ui#build"]
|
||||
},
|
||||
"@formbricks/web#dev": {
|
||||
"cache": false,
|
||||
"dependsOn": [
|
||||
@@ -178,6 +196,16 @@
|
||||
"@formbricks/surveys#build"
|
||||
]
|
||||
},
|
||||
"@formbricks/web#typecheck": {
|
||||
"dependsOn": [
|
||||
"@formbricks/ai#build",
|
||||
"@formbricks/cache#build",
|
||||
"@formbricks/database#build",
|
||||
"@formbricks/logger#build",
|
||||
"@formbricks/storage#build",
|
||||
"@formbricks/surveys#build"
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"env": [
|
||||
@@ -394,6 +422,10 @@
|
||||
},
|
||||
"test:coverage": {
|
||||
"outputs": []
|
||||
},
|
||||
"typecheck": {
|
||||
"dependsOn": ["@formbricks/database#generate", "^typecheck"],
|
||||
"outputs": []
|
||||
}
|
||||
},
|
||||
"ui": "stream"
|
||||
|
||||
Reference in New Issue
Block a user