Compare commits

..

6 Commits

Author SHA1 Message Date
Dhruwang d5c0fd6114 Merge branch 'main' of https://github.com/formbricks/formbricks into fix/language-selector-native-names 2026-03-06 15:15:36 +05:30
Bhagya Amarasinghe fc1c91896a fix: add server-side SSRF validation for webhook URLs (#7414)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2026-03-06 07:36:49 +00:00
Balázs Úr f5c7dbdc71 fix: mark duplicated survey name as translatable (#7379)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-06 06:37:05 +00:00
Balázs Úr b88ea5cc66 fix: use proper plural forms (#7322)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2026-03-06 06:30:27 +00:00
Tafsir c70150dac8 refactor: pre-sort languages and use translated fallback in profile selector 2026-02-25 07:18:26 +06:00
Tafsir e158be53e4 fix: display native language names in profile language selector 2026-02-25 07:08:48 +06:00
42 changed files with 838 additions and 160 deletions
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
import { z } from "zod";
import { TUser, TUserUpdateInput, ZUser, ZUserEmail } from "@formbricks/types/user";
import { PasswordConfirmationModal } from "@/app/(app)/environments/[environmentId]/settings/(account)/profile/components/password-confirmation-modal";
import { appLanguages } from "@/lib/i18n/utils";
import { appLanguages, sortedAppLanguages } from "@/lib/i18n/utils";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { useSignOut } from "@/modules/auth/hooks/use-sign-out";
import { Button } from "@/modules/ui/components/button";
@@ -198,41 +198,54 @@ export const EditProfileDetailsForm = ({
<FormField
control={form.control}
name="locale"
render={({ field }) => (
<FormItem className="mt-4">
<FormLabel>{t("common.language")}</FormLabel>
<FormControl>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
className="h-10 w-full border border-slate-300 px-3 text-left">
<div className="flex w-full items-center justify-between">
{appLanguages.find((l) => l.code === field.value)?.label["en-US"] ?? "NA"}
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
align="start">
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
{appLanguages.map((lang) => (
<DropdownMenuRadioItem
key={lang.code}
value={lang.code}
className="min-h-8 cursor-pointer">
{lang.label["en-US"]}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
<FormError />
</FormItem>
)}
render={({ field }) => {
const selectedLanguage = appLanguages.find((l) => l.code === field.value);
return (
<FormItem className="mt-4">
<FormLabel>{t("common.language")}</FormLabel>
<FormControl>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="ghost"
className="h-10 w-full border border-slate-300 px-3 text-left">
<div className="flex w-full items-center justify-between">
{selectedLanguage ? (
<>
{selectedLanguage.label["en-US"]}
{selectedLanguage.label.native !== selectedLanguage.label["en-US"] &&
` (${selectedLanguage.label.native})`}
</>
) : (
t("common.select")
)}
<ChevronDownIcon className="h-4 w-4 text-slate-500" />
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
className="min-w-[var(--radix-dropdown-menu-trigger-width)] bg-white text-slate-700"
align="start">
<DropdownMenuRadioGroup value={field.value} onValueChange={field.onChange}>
{sortedAppLanguages.map((lang) => (
<DropdownMenuRadioItem
key={lang.code}
value={lang.code}
className="min-h-8 cursor-pointer">
{lang.label["en-US"]}
{lang.label.native !== lang.label["en-US"] && ` (${lang.label.native})`}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</FormControl>
<FormError />
</FormItem>
);
}}
/>
{isPasswordResetEnabled && (
@@ -3,6 +3,7 @@ import { getServerSession } from "next-auth";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey } from "@/lib/survey/service";
import { getTranslate } from "@/lingodotdev/server";
import { authOptions } from "@/modules/auth/lib/authOptions";
type Props = {
@@ -14,10 +15,11 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
const session = await getServerSession(authOptions);
const survey = await getSurvey(params.surveyId);
const responseCount = await getResponseCountBySurveyId(params.surveyId);
const t = await getTranslate();
if (session) {
return {
title: `${responseCount} Responses | ${survey?.name} Results`,
title: `${t("common.count_responses", { count: responseCount })} | ${t("environments.surveys.summary.survey_results", { surveyName: survey?.name })}`,
};
}
return {
@@ -30,8 +30,7 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{elementSummary.booked.count}{" "}
{elementSummary.booked.count === 1 ? t("common.response") : t("common.responses")}
{t("common.count_responses", { count: elementSummary.booked.count })}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.booked.percentage / 100} />
@@ -47,8 +46,7 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{elementSummary.skipped.count}{" "}
{elementSummary.skipped.count === 1 ? t("common.response") : t("common.responses")}
{t("common.count_responses", { count: elementSummary.skipped.count })}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.skipped.percentage / 100} />
@@ -64,7 +64,7 @@ export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSum
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{summaryItem.count} {summaryItem.count === 1 ? t("common.response") : t("common.responses")}
{t("common.count_responses", { count: summaryItem.count })}
</p>
</div>
<div className="group-hover:opacity-80">
@@ -48,7 +48,7 @@ export const ElementSummaryHeader = ({
{showResponses && (
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
{`${elementSummary.responseCount} ${t("common.responses")}`}
{t("common.count_responses", { count: elementSummary.responseCount })}
</div>
)}
{additionalInfo}
@@ -41,8 +41,7 @@ export const HiddenFieldsSummary = ({ environment, elementSummary, locale }: Hid
</div>
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
{elementSummary.responseCount}{" "}
{elementSummary.responseCount === 1 ? t("common.response") : t("common.responses")}
{t("common.count_responses", { count: elementSummary.responseCount })}
</div>
</div>
</div>
@@ -31,7 +31,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
if (label) {
return label;
} else if (percentage !== undefined && totalResponsesForRow !== undefined) {
return `${Math.round((percentage / 100) * totalResponsesForRow)} ${t("common.responses")}`;
return t("common.count_responses", { count: Math.round((percentage / 100) * totalResponsesForRow) });
}
return "";
};
@@ -75,7 +75,7 @@ export const MultipleChoiceSummary = ({
elementSummary.type === "multipleChoiceMulti" ? (
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
{`${elementSummary.selectionCount} ${t("common.selections")}`}
{t("common.count_selections", { count: elementSummary.selectionCount })}
</div>
) : undefined
}
@@ -110,7 +110,7 @@ export const MultipleChoiceSummary = ({
</div>
<div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
{t("common.count_selections", { count: result.count })}
</p>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
@@ -123,8 +123,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{elementSummary[group]?.count}{" "}
{elementSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
{t("common.count_responses", { count: elementSummary[group]?.count })}
</p>
</div>
<ProgressBar
@@ -37,7 +37,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
elementSummary.element.allowMulti ? (
<div className="flex items-center rounded-lg bg-slate-100 p-2">
<InboxIcon className="mr-2 h-4 w-4" />
{`${elementSummary.selectionCount} ${t("common.selections")}`}
{t("common.count_selections", { count: elementSummary.selectionCount })}
</div>
) : undefined
}
@@ -74,7 +74,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
</div>
<div className="flex w-full space-x-2">
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
{t("common.count_selections", { count: result.count })}
</p>
<p className="self-end rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
@@ -198,7 +198,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
{t("common.count_responses", { count: result.count })}
</p>
</div>
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
@@ -215,8 +215,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
<div className="text flex justify-between px-2">
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
<p className="flex w-32 items-end justify-end text-slate-600">
{elementSummary.dismissed.count}{" "}
{elementSummary.dismissed.count === 1 ? t("common.response") : t("common.responses")}
{t("common.count_responses", { count: elementSummary.dismissed.count })}
</p>
</div>
</div>
+12 -7
View File
@@ -15,6 +15,7 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { sendResponseFinishedEmail } from "@/modules/email";
@@ -135,13 +136,17 @@ export const POST = async (request: Request) => {
);
}
return fetchWithTimeout(webhook.url, {
method: "POST",
headers: requestHeaders,
body,
}).catch((error) => {
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
});
return validateWebhookUrl(webhook.url)
.then(() =>
fetchWithTimeout(webhook.url, {
method: "POST",
headers: requestHeaders,
body,
})
)
.catch((error) => {
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
});
});
if (event === "responseFinished") {
@@ -2,10 +2,11 @@ import { Prisma, WebhookSource } from "@prisma/client";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
import { DatabaseError, InvalidInputError, ValidationError } from "@formbricks/types/errors";
import { createWebhook } from "@/app/api/v1/webhooks/lib/webhook";
import { TWebhookInput } from "@/app/api/v1/webhooks/types/webhooks";
import { validateInputs } from "@/lib/utils/validate";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
vi.mock("@formbricks/database", () => ({
prisma: {
@@ -23,6 +24,10 @@ vi.mock("@/lib/crypto", () => ({
generateWebhookSecret: vi.fn(() => "whsec_test_secret_1234567890"),
}));
vi.mock("@/lib/utils/validate-webhook-url", () => ({
validateWebhookUrl: vi.fn().mockResolvedValue(undefined),
}));
describe("createWebhook", () => {
afterEach(() => {
cleanup();
@@ -75,6 +80,41 @@ describe("createWebhook", () => {
expect(result).toEqual(createdWebhook);
});
test("should call validateWebhookUrl with the provided URL", async () => {
const webhookInput: TWebhookInput = {
environmentId: "test-env-id",
name: "Test Webhook",
url: "https://example.com",
source: "user",
triggers: ["responseCreated"],
surveyIds: ["survey1"],
};
vi.mocked(prisma.webhook.create).mockResolvedValueOnce({} as any);
await createWebhook(webhookInput);
expect(validateWebhookUrl).toHaveBeenCalledWith("https://example.com");
});
test("should throw InvalidInputError and skip Prisma create when URL fails SSRF validation", async () => {
const webhookInput: TWebhookInput = {
environmentId: "test-env-id",
name: "Test Webhook",
url: "http://169.254.169.254/latest/meta-data/",
source: "user",
triggers: ["responseCreated"],
surveyIds: ["survey1"],
};
vi.mocked(validateWebhookUrl).mockRejectedValueOnce(
new InvalidInputError("Webhook URL must not point to private or internal IP addresses")
);
await expect(createWebhook(webhookInput)).rejects.toThrow(InvalidInputError);
expect(prisma.webhook.create).not.toHaveBeenCalled();
});
test("should throw a ValidationError if the input data does not match the ZWebhookInput schema", async () => {
const invalidWebhookInput = {
environmentId: "test-env-id",
@@ -6,9 +6,11 @@ import { TWebhookInput, ZWebhookInput } from "@/app/api/v1/webhooks/types/webhoo
import { ITEMS_PER_PAGE } from "@/lib/constants";
import { generateWebhookSecret } from "@/lib/crypto";
import { validateInputs } from "@/lib/utils/validate";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
export const createWebhook = async (webhookInput: TWebhookInput): Promise<Webhook> => {
validateInputs([webhookInput, ZWebhookInput]);
await validateWebhookUrl(webhookInput.url);
try {
const secret = generateWebhookSecret();
+3
View File
@@ -150,7 +150,9 @@ checksums:
common/copy_link: 57a37acfe6d7ed71d00fbbc8079fbb35
common/count_attributes: 042fba9baffef5afe2c24f13d4f50697
common/count_contacts: b1c413a4b06961b71b6aeee95d6775d7
common/count_members: 8cabb9805075f20e3977b919b3b2fdc5
common/count_responses: 690118a456c01c5b4d437ae82b50b131
common/count_selections: c0f581d21468af2f46dad171921f71ba
common/create_new_organization: 51dae7b33143686ee218abf5bea764a5
common/create_segment: 9d8291cd4d778b53b73bbc84fd91c181
common/create_survey: 1cfbba08d34876566d84b2960054a987
@@ -1928,6 +1930,7 @@ checksums:
environments/surveys/summary/starts: 3153990a4ade414f501a7e63ab771362
environments/surveys/summary/starts_tooltip: 0a7dd01320490dbbea923053fa1ccad6
environments/surveys/summary/survey_reset_successfully: f53db36a28980ef4766215cf13f01e51
environments/surveys/summary/survey_results: b7d86f636beaee2b4d5746bdda058d07
environments/surveys/summary/this_month: 50845a38865204a97773c44dcd2ebb90
environments/surveys/summary/this_quarter: 9c77d94783dff2269c069389122cd7bd
environments/surveys/summary/this_year: 1e69651c2ac722f8ce138f43cf2e02f9
+18
View File
@@ -130,84 +130,102 @@ export const appLanguages = [
code: "de-DE",
label: {
"en-US": "German",
native: "Deutsch",
},
},
{
code: "en-US",
label: {
"en-US": "English (US)",
native: "English (US)",
},
},
{
code: "es-ES",
label: {
"en-US": "Spanish",
native: "Español",
},
},
{
code: "fr-FR",
label: {
"en-US": "French",
native: "Français",
},
},
{
code: "hu-HU",
label: {
"en-US": "Hungarian",
native: "Magyar",
},
},
{
code: "ja-JP",
label: {
"en-US": "Japanese",
native: "日本語",
},
},
{
code: "nl-NL",
label: {
"en-US": "Dutch",
native: "Nederlands",
},
},
{
code: "pt-BR",
label: {
"en-US": "Portuguese (Brazil)",
native: "Português (Brasil)",
},
},
{
code: "pt-PT",
label: {
"en-US": "Portuguese (Portugal)",
native: "Português (Portugal)",
},
},
{
code: "ro-RO",
label: {
"en-US": "Romanian",
native: "Română",
},
},
{
code: "ru-RU",
label: {
"en-US": "Russian",
native: "Русский",
},
},
{
code: "sv-SE",
label: {
"en-US": "Swedish",
native: "Svenska",
},
},
{
code: "zh-Hans-CN",
label: {
"en-US": "Chinese (Simplified)",
native: "简体中文",
},
},
{
code: "zh-Hant-TW",
label: {
"en-US": "Chinese (Traditional)",
native: "繁體中文",
},
},
];
export const sortedAppLanguages = [...appLanguages].sort((a, b) =>
a.label["en-US"].localeCompare(b.label["en-US"])
);
@@ -0,0 +1,297 @@
import dns from "node:dns";
import { afterEach, describe, expect, test, vi } from "vitest";
import { validateWebhookUrl } from "./validate-webhook-url";
vi.mock("node:dns", () => ({
default: {
resolve: vi.fn(),
resolve6: vi.fn(),
},
}));
const mockResolve = vi.mocked(dns.resolve);
const mockResolve6 = vi.mocked(dns.resolve6);
type DnsCallback = (err: NodeJS.ErrnoException | null, addresses: string[]) => void;
const setupDnsResolution = (ipv4: string[] | null, ipv6: string[] | null = null): void => {
// dns.resolve/resolve6 have overloaded signatures; we only mock the (hostname, callback) form
mockResolve.mockImplementation(((_hostname: string, callback: DnsCallback) => {
if (ipv4) {
callback(null, ipv4);
} else {
callback(new Error("ENOTFOUND"), []);
}
}) as never);
mockResolve6.mockImplementation(((_hostname: string, callback: DnsCallback) => {
if (ipv6) {
callback(null, ipv6);
} else {
callback(new Error("ENOTFOUND"), []);
}
}) as never);
};
afterEach(() => {
vi.clearAllMocks();
});
describe("validateWebhookUrl", () => {
describe("valid public URLs", () => {
test("accepts HTTPS URL resolving to a public IPv4 address", async () => {
setupDnsResolution(["93.184.216.34"]);
await expect(validateWebhookUrl("https://example.com/webhook")).resolves.toBeUndefined();
});
test("accepts HTTP URL resolving to a public IPv4 address", async () => {
setupDnsResolution(["93.184.216.34"]);
await expect(validateWebhookUrl("http://example.com/webhook")).resolves.toBeUndefined();
});
test("accepts URL with port and path segments", async () => {
setupDnsResolution(["93.184.216.34"]);
await expect(validateWebhookUrl("https://example.com:8443/api/v1/webhook")).resolves.toBeUndefined();
});
test("accepts URL resolving to a public IPv6 address", async () => {
setupDnsResolution(null, ["2606:2800:220:1:248:1893:25c8:1946"]);
await expect(validateWebhookUrl("https://example.com/webhook")).resolves.toBeUndefined();
});
test("accepts a public IPv4 address as hostname", async () => {
await expect(validateWebhookUrl("https://93.184.216.34/webhook")).resolves.toBeUndefined();
});
});
describe("URL format validation", () => {
test("rejects a completely malformed string", async () => {
await expect(validateWebhookUrl("not-a-url")).rejects.toThrow("Invalid webhook URL format");
});
test("rejects an empty string", async () => {
await expect(validateWebhookUrl("")).rejects.toThrow("Invalid webhook URL format");
});
});
describe("protocol validation", () => {
test("rejects FTP protocol", async () => {
await expect(validateWebhookUrl("ftp://example.com/file")).rejects.toThrow(
"Webhook URL must use HTTPS or HTTP protocol"
);
});
test("rejects file:// protocol", async () => {
await expect(validateWebhookUrl("file:///etc/passwd")).rejects.toThrow(
"Webhook URL must use HTTPS or HTTP protocol"
);
});
test("rejects javascript: protocol", async () => {
await expect(validateWebhookUrl("javascript:alert(1)")).rejects.toThrow(
"Webhook URL must use HTTPS or HTTP protocol"
);
});
});
describe("blocked hostname validation", () => {
test("rejects localhost", async () => {
await expect(validateWebhookUrl("http://localhost/admin")).rejects.toThrow(
"Webhook URL must not point to localhost or internal services"
);
});
test("rejects localhost.localdomain", async () => {
await expect(validateWebhookUrl("https://localhost.localdomain/path")).rejects.toThrow(
"Webhook URL must not point to localhost or internal services"
);
});
test("rejects metadata.google.internal", async () => {
await expect(validateWebhookUrl("http://metadata.google.internal/computeMetadata/v1/")).rejects.toThrow(
"Webhook URL must not point to localhost or internal services"
);
});
});
describe("private IPv4 literal blocking", () => {
test("rejects 127.0.0.1 (loopback)", async () => {
await expect(validateWebhookUrl("http://127.0.0.1/metadata")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 127.0.0.53 (loopback range)", async () => {
await expect(validateWebhookUrl("http://127.0.0.53/")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 10.0.0.1 (Class A private)", async () => {
await expect(validateWebhookUrl("http://10.0.0.1/internal")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 172.16.0.1 (Class B private)", async () => {
await expect(validateWebhookUrl("http://172.16.0.1/internal")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 172.31.255.255 (Class B private upper bound)", async () => {
await expect(validateWebhookUrl("http://172.31.255.255/internal")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 192.168.1.1 (Class C private)", async () => {
await expect(validateWebhookUrl("http://192.168.1.1/internal")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 169.254.169.254 (AWS/GCP/Azure metadata endpoint)", async () => {
await expect(validateWebhookUrl("http://169.254.169.254/latest/meta-data/")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 0.0.0.0 ('this' network)", async () => {
await expect(validateWebhookUrl("http://0.0.0.0/")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects 100.64.0.1 (CGNAT / shared address space)", async () => {
await expect(validateWebhookUrl("http://100.64.0.1/")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
});
describe("DNS resolution with private IP results", () => {
test("rejects hostname resolving to loopback address", async () => {
setupDnsResolution(["127.0.0.1"]);
await expect(validateWebhookUrl("https://evil.com/steal")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to cloud metadata endpoint IP", async () => {
setupDnsResolution(["169.254.169.254"]);
await expect(validateWebhookUrl("https://attacker.com/ssrf")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to Class A private network", async () => {
setupDnsResolution(["10.0.0.5"]);
await expect(validateWebhookUrl("https://internal.service/api")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to Class C private network", async () => {
setupDnsResolution(["192.168.0.1"]);
await expect(validateWebhookUrl("https://sneaky.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to IPv6 loopback", async () => {
setupDnsResolution(null, ["::1"]);
await expect(validateWebhookUrl("https://sneaky.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to IPv6 link-local", async () => {
setupDnsResolution(null, ["fe80::1"]);
await expect(validateWebhookUrl("https://link-local.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to IPv6 unique local address", async () => {
setupDnsResolution(null, ["fd12:3456:789a::1"]);
await expect(validateWebhookUrl("https://ula.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to IPv4-mapped IPv6 private address (dotted)", async () => {
setupDnsResolution(null, ["::ffff:192.168.1.1"]);
await expect(validateWebhookUrl("https://mapped.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hostname resolving to IPv4-mapped IPv6 private address (hex-encoded)", async () => {
setupDnsResolution(null, ["::ffff:c0a8:0101"]); // 192.168.1.1 in hex
await expect(validateWebhookUrl("https://hex-mapped.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hex-encoded IPv4-mapped loopback (::ffff:7f00:0001)", async () => {
setupDnsResolution(null, ["::ffff:7f00:0001"]); // 127.0.0.1 in hex
await expect(validateWebhookUrl("https://hex-loopback.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects hex-encoded IPv4-mapped metadata endpoint (::ffff:a9fe:a9fe)", async () => {
setupDnsResolution(null, ["::ffff:a9fe:a9fe"]); // 169.254.169.254 in hex
await expect(validateWebhookUrl("https://hex-metadata.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("accepts hex-encoded IPv4-mapped public address", async () => {
setupDnsResolution(null, ["::ffff:5db8:d822"]); // 93.184.216.34 in hex
await expect(validateWebhookUrl("https://hex-public.example.com/webhook")).resolves.toBeUndefined();
});
test("rejects when any resolved IP is private (mixed public + private)", async () => {
setupDnsResolution(["93.184.216.34", "192.168.1.1"]);
await expect(validateWebhookUrl("https://dual.example.com/webhook")).rejects.toThrow(
"Webhook URL must not point to private or internal IP addresses"
);
});
test("rejects unresolvable hostname", async () => {
setupDnsResolution(null, null);
await expect(validateWebhookUrl("https://nonexistent.invalid/path")).rejects.toThrow(
"Could not resolve webhook URL hostname"
);
});
test("rejects with timeout error when DNS resolution hangs", async () => {
vi.useFakeTimers();
mockResolve.mockImplementation((() => {
// never calls callback — simulates a hanging DNS server
}) as never);
const promise = validateWebhookUrl("https://slow-dns.example.com/webhook");
const assertion = expect(promise).rejects.toThrow(
"DNS resolution timed out for webhook URL hostname: slow-dns.example.com"
);
await vi.advanceTimersByTimeAsync(3000);
await assertion;
vi.useRealTimers();
});
});
describe("error type", () => {
test("throws InvalidInputError (not generic Error)", async () => {
await expect(validateWebhookUrl("http://127.0.0.1/")).rejects.toMatchObject({
name: "InvalidInputError",
});
});
});
});
+176
View File
@@ -0,0 +1,176 @@
import "server-only";
import dns from "node:dns";
import { InvalidInputError } from "@formbricks/types/errors";
const BLOCKED_HOSTNAMES = new Set([
"localhost",
"localhost.localdomain",
"ip6-localhost",
"ip6-loopback",
"metadata.google.internal",
]);
const PRIVATE_IPV4_PATTERNS: RegExp[] = [
/^127\./, // 127.0.0.0/8 Loopback
/^10\./, // 10.0.0.0/8 Class A private
/^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12 Class B private
/^192\.168\./, // 192.168.0.0/16 Class C private
/^169\.254\./, // 169.254.0.0/16 Link-local (AWS/GCP/Azure metadata)
/^0\./, // 0.0.0.0/8 "This" network
/^100\.(6[4-9]|[7-9]\d|1[0-2]\d)\./, // 100.64.0.0/10 Shared address space (RFC 6598)
/^192\.0\.0\./, // 192.0.0.0/24 IETF protocol assignments
/^192\.0\.2\./, // 192.0.2.0/24 TEST-NET-1 (documentation)
/^198\.51\.100\./, // 198.51.100.0/24 TEST-NET-2 (documentation)
/^203\.0\.113\./, // 203.0.113.0/24 TEST-NET-3 (documentation)
/^198\.1[89]\./, // 198.18.0.0/15 Benchmarking
/^224\./, // 224.0.0.0/4 Multicast
/^240\./, // 240.0.0.0/4 Reserved for future use
/^255\.255\.255\.255$/, // Limited broadcast
];
const PRIVATE_IPV6_PREFIXES = [
"::1", // Loopback
"fe80:", // Link-local
"fc", // Unique local address (ULA, fc00::/7 — covers fc00:: through fdff::)
"fd", // Unique local address (ULA, fc00::/7 — covers fc00:: through fdff::)
];
const isPrivateIPv4 = (ip: string): boolean => {
return PRIVATE_IPV4_PATTERNS.some((pattern) => pattern.test(ip));
};
const hexMappedToIPv4 = (hexPart: string): string | null => {
const groups = hexPart.split(":");
if (groups.length !== 2) return null;
const high = Number.parseInt(groups[0], 16);
const low = Number.parseInt(groups[1], 16);
if (Number.isNaN(high) || Number.isNaN(low) || high > 0xffff || low > 0xffff) return null;
return `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`;
};
const isIPv4Mapped = (normalized: string): boolean => {
if (!normalized.startsWith("::ffff:")) return false;
const suffix = normalized.slice(7); // strip "::ffff:"
if (suffix.includes(".")) {
return isPrivateIPv4(suffix);
}
const dotted = hexMappedToIPv4(suffix);
return dotted !== null && isPrivateIPv4(dotted);
};
const isPrivateIPv6 = (ip: string): boolean => {
const normalized = ip.toLowerCase();
if (normalized === "::") return true;
if (isIPv4Mapped(normalized)) return true;
return PRIVATE_IPV6_PREFIXES.some((prefix) => normalized.startsWith(prefix));
};
const isPrivateIP = (ip: string): boolean => {
return isPrivateIPv4(ip) || isPrivateIPv6(ip);
};
const DNS_TIMEOUT_MS = 3000;
const resolveHostnameToIPs = (hostname: string): Promise<string[]> => {
return new Promise((resolve, reject) => {
let settled = false;
const settle = <T>(fn: (value: T) => void, value: T): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
fn(value);
};
const timer = setTimeout(() => {
settle(reject, new Error(`DNS resolution timed out for hostname: ${hostname}`));
}, DNS_TIMEOUT_MS);
dns.resolve(hostname, (errV4, ipv4Addresses) => {
const ipv4 = errV4 ? [] : ipv4Addresses;
dns.resolve6(hostname, (errV6, ipv6Addresses) => {
const ipv6 = errV6 ? [] : ipv6Addresses;
const allAddresses = [...ipv4, ...ipv6];
if (allAddresses.length === 0) {
settle(reject, new Error(`DNS resolution failed for hostname: ${hostname}`));
} else {
settle(resolve, allAddresses);
}
});
});
});
};
const stripIPv6Brackets = (hostname: string): string => {
if (hostname.startsWith("[") && hostname.endsWith("]")) {
return hostname.slice(1, -1);
}
return hostname;
};
const IPV4_LITERAL = /^\d{1,3}(?:\.\d{1,3}){3}$/;
/**
* Validates a webhook URL to prevent Server-Side Request Forgery (SSRF).
*
* Checks performed:
* 1. URL must be well-formed
* 2. Protocol must be HTTPS or HTTP
* 3. Hostname must not be a known internal name (localhost, metadata endpoints)
* 4. IP literal hostnames are checked directly against private ranges
* 5. Domain hostnames are resolved via DNS; all resulting IPs must be public
*
* @throws {InvalidInputError} when the URL fails any validation check
*/
export const validateWebhookUrl = async (url: string): Promise<void> => {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new InvalidInputError("Invalid webhook URL format");
}
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
throw new InvalidInputError("Webhook URL must use HTTPS or HTTP protocol");
}
const hostname = parsed.hostname;
if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
}
// Direct IP literal — validate without DNS resolution
const isIPv4Literal = IPV4_LITERAL.test(hostname);
const isIPv6Literal = hostname.startsWith("[");
if (isIPv4Literal || isIPv6Literal) {
const ip = isIPv6Literal ? stripIPv6Brackets(hostname) : hostname;
if (isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
return;
}
// Domain name — resolve DNS and validate every resolved IP
let resolvedIPs: string[];
try {
resolvedIPs = await resolveHostnameToIPs(hostname);
} catch (error) {
const isTimeout = error instanceof Error && error.message.includes("timed out");
throw new InvalidInputError(
isTimeout
? `DNS resolution timed out for webhook URL hostname: ${hostname}`
: `Could not resolve webhook URL hostname: ${hostname}`
);
}
for (const ip of resolvedIPs) {
if (isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
}
};
+6 -6
View File
@@ -175,9 +175,11 @@
"copy": "Kopieren",
"copy_code": "Code kopieren",
"copy_link": "Link kopieren",
"count_attributes": "{value, plural, one {{value} Attribut} other {{value} Attribute}}",
"count_contacts": "{value, plural, one {{value} Kontakt} other {{value} Kontakte}}",
"count_responses": "{value, plural, one {{value} Antwort} other {{value} Antworten}}",
"count_attributes": "{count, plural, one {{count} Attribut} other {{count} Attribute}}",
"count_contacts": "{count, plural, one {{count} Kontakt} other {{count} Kontakte}}",
"count_members": "{count, plural, one {{count} Mitglied} other {{count} Mitglieder}}",
"count_responses": "{count, plural, one {{count} Antwort} other {{count} Antworten}}",
"count_selections": "{count, plural, one {{count} Auswahl} other {{count} Auswahlen}}",
"create_new_organization": "Neue Organisation erstellen",
"create_segment": "Segment erstellen",
"create_survey": "Umfrage erstellen",
@@ -276,7 +278,6 @@
"look_and_feel": "Darstellung",
"manage": "Verwalten",
"marketing": "Marketing",
"member": "Mitglied",
"members": "Mitglieder",
"members_and_teams": "Mitglieder & Teams",
"membership_not_found": "Mitgliedschaft nicht gefunden",
@@ -384,8 +385,6 @@
"select_teams": "Teams auswählen",
"selected": "Ausgewählt",
"selected_questions": "Ausgewählte Fragen",
"selection": "Auswahl",
"selections": "Auswahlen",
"send_test_email": "Test-E-Mail senden",
"session_not_found": "Sitzung nicht gefunden",
"settings": "Einstellungen",
@@ -2031,6 +2030,7 @@
"starts": "Startet",
"starts_tooltip": "So oft wurde die Umfrage gestartet.",
"survey_reset_successfully": "Umfrage erfolgreich zurückgesetzt! {responseCount} Antworten und {displayCount} Anzeigen wurden gelöscht.",
"survey_results": "{surveyName}-Ergebnisse",
"this_month": "Dieser Monat",
"this_quarter": "Dieses Quartal",
"this_year": "Dieses Jahr",
+6 -6
View File
@@ -175,9 +175,11 @@
"copy": "Copy",
"copy_code": "Copy code",
"copy_link": "Copy Link",
"count_attributes": "{value, plural, one {{value} attribute} other {{value} attributes}}",
"count_contacts": "{value, plural, one {{value} contact} other {{value} contacts}}",
"count_responses": "{value, plural, one {{value} response} other {{value} responses}}",
"count_attributes": "{count, plural, one {{count} attribute} other {{count} attributes}}",
"count_contacts": "{count, plural, one {{count} contact} other {{count} contacts}}",
"count_members": "{count, plural, one {{count} member} other {{count} members}}",
"count_responses": "{count, plural, one {{count} response} other {{count} responses}}",
"count_selections": "{count, plural, one {{count} selection} other {{count} selections}}",
"create_new_organization": "Create new organization",
"create_segment": "Create segment",
"create_survey": "Create survey",
@@ -276,7 +278,6 @@
"look_and_feel": "Look & Feel",
"manage": "Manage",
"marketing": "Marketing",
"member": "Member",
"members": "Members",
"members_and_teams": "Members & Teams",
"membership_not_found": "Membership not found",
@@ -384,8 +385,6 @@
"select_teams": "Select teams",
"selected": "Selected",
"selected_questions": "Selected questions",
"selection": "Selection",
"selections": "Selections",
"send_test_email": "Send test email",
"session_not_found": "Session not found",
"settings": "Settings",
@@ -2031,6 +2030,7 @@
"starts": "Starts",
"starts_tooltip": "Number of times the survey has been started.",
"survey_reset_successfully": "Survey reset successfully. {responseCount} responses and {displayCount} displays were deleted.",
"survey_results": "{surveyName} Results",
"this_month": "This month",
"this_quarter": "This quarter",
"this_year": "This year",
+6 -6
View File
@@ -175,9 +175,11 @@
"copy": "Copiar",
"copy_code": "Copiar código",
"copy_link": "Copiar enlace",
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
"count_contacts": "{value, plural, one {{value} contacto} other {{value} contactos}}",
"count_responses": "{value, plural, one {{value} respuesta} other {{value} respuestas}}",
"count_attributes": "{count, plural, one {{count} atributo} other {{count} atributos}}",
"count_contacts": "{count, plural, one {{count} contacto} other {{count} contactos}}",
"count_members": "{count, plural, one {{count} miembro} other {{count} miembros}}",
"count_responses": "{count, plural, one {{count} respuesta} other {{count} respuestas}}",
"count_selections": "{count, plural, one {{count} selección} other {{count} selecciones}}",
"create_new_organization": "Crear organización nueva",
"create_segment": "Crear segmento",
"create_survey": "Crear encuesta",
@@ -276,7 +278,6 @@
"look_and_feel": "Apariencia",
"manage": "Gestionar",
"marketing": "Marketing",
"member": "Miembro",
"members": "Miembros",
"members_and_teams": "Miembros y equipos",
"membership_not_found": "Membresía no encontrada",
@@ -384,8 +385,6 @@
"select_teams": "Seleccionar equipos",
"selected": "Seleccionado",
"selected_questions": "Preguntas seleccionadas",
"selection": "Selección",
"selections": "Selecciones",
"send_test_email": "Enviar correo electrónico de prueba",
"session_not_found": "Sesión no encontrada",
"settings": "Ajustes",
@@ -2031,6 +2030,7 @@
"starts": "Inicios",
"starts_tooltip": "Número de veces que se ha iniciado la encuesta.",
"survey_reset_successfully": "¡Encuesta restablecida correctamente! Se eliminaron {responseCount} respuestas y {displayCount} visualizaciones.",
"survey_results": "Resultados de {surveyName}",
"this_month": "Este mes",
"this_quarter": "Este trimestre",
"this_year": "Este año",
+6 -6
View File
@@ -175,9 +175,11 @@
"copy": "Copier",
"copy_code": "Copier le code",
"copy_link": "Copier le lien",
"count_attributes": "{value, plural, one {{value} attribut} other {{value} attributs}}",
"count_contacts": "{value, plural, one {# contact} other {# contacts} }",
"count_responses": "{value, plural, other {# réponses}}",
"count_attributes": "{count, plural, one {{count} attribut} other {{count} attributs}}",
"count_contacts": "{count, plural, one {# contact} other {# contacts} }",
"count_members": "{count, plural, one {{count} membre} other {{count} membres}}",
"count_responses": "{count, plural, other {# réponses}}",
"count_selections": "{count, plural, one {{count} sélection} other {{count} sélections}}",
"create_new_organization": "Créer une nouvelle organisation",
"create_segment": "Créer un segment",
"create_survey": "Créer un sondage",
@@ -276,7 +278,6 @@
"look_and_feel": "Apparence",
"manage": "Gérer",
"marketing": "Marketing",
"member": "Membre",
"members": "Membres",
"members_and_teams": "Membres & Équipes",
"membership_not_found": "Abonnement non trouvé",
@@ -384,8 +385,6 @@
"select_teams": "Sélectionner les équipes",
"selected": "Sélectionné",
"selected_questions": "Questions sélectionnées",
"selection": "Sélection",
"selections": "Sélections",
"send_test_email": "Envoyer un e-mail de test",
"session_not_found": "Session non trouvée",
"settings": "Paramètres",
@@ -2031,6 +2030,7 @@
"starts": "Commence",
"starts_tooltip": "Nombre de fois que l'enquête a été commencée.",
"survey_reset_successfully": "Réinitialisation du sondage réussie ! {responseCount} réponses et {displayCount} affichages ont été supprimés.",
"survey_results": "Résultats de {surveyName}",
"this_month": "Ce mois-ci",
"this_quarter": "Ce trimestre",
"this_year": "Cette année",
+6 -6
View File
@@ -175,9 +175,11 @@
"copy": "Másolás",
"copy_code": "Kód másolása",
"copy_link": "Hivatkozás másolása",
"count_attributes": "{value, plural, one {{value} attribútum} other {{value} attribútum}}",
"count_contacts": "{value, plural, one {{value} partner} other {{value} partner}}",
"count_responses": "{value, plural, one {{value} válasz} other {{value} válasz}}",
"count_attributes": "{count, plural, one {{count} attribútum} other {{count} attribútum}}",
"count_contacts": "{count, plural, one {{count} partner} other {{count} partner}}",
"count_members": "{count, plural, one {{count} tag} other {{count} tag}}",
"count_responses": "{count, plural, one {{count} válasz} other {{count} válasz}}",
"count_selections": "{count, plural, one {{count} kijelölés} other {{count} kijelölés}}",
"create_new_organization": "Új szervezet létrehozása",
"create_segment": "Szakasz létrehozása",
"create_survey": "Kérdőív létrehozása",
@@ -276,7 +278,6 @@
"look_and_feel": "Megjelenés",
"manage": "Kezelés",
"marketing": "Marketing",
"member": "Tag",
"members": "Tagok",
"members_and_teams": "Tagok és csapatok",
"membership_not_found": "A tagság nem található",
@@ -384,8 +385,6 @@
"select_teams": "Csapatok kiválasztása",
"selected": "Kiválasztva",
"selected_questions": "Kiválasztott kérdések",
"selection": "Kiválasztás",
"selections": "Kiválasztások",
"send_test_email": "Teszt e-mail küldése",
"session_not_found": "A munkamenet nem található",
"settings": "Beállítások",
@@ -2031,6 +2030,7 @@
"starts": "Elkezdések",
"starts_tooltip": "A kérdőív elkezdési alkalmainak száma.",
"survey_reset_successfully": "A kérdőív sikeresen visszaállítva. {responseCount} válasz és {displayCount} megjelenítés lett törölve.",
"survey_results": "{surveyName} eredményei",
"this_month": "Ez a hónap",
"this_quarter": "Ez a negyedév",
"this_year": "Ez az év",
+4 -4
View File
@@ -175,9 +175,11 @@
"copy": "コピー",
"copy_code": "コードをコピー",
"copy_link": "リンクをコピー",
"count_attributes": "{value, plural, other {{value}個の属性}}",
"count_attributes": "{count, plural, other {{count}個の属性}}",
"count_contacts": "{count, plural, other {# 件の連絡先}}",
"count_members": "{count, plural, other {{count}人のメンバー}}",
"count_responses": "{count, plural, other {# 件の回答}}",
"count_selections": "{count, plural, other {{count}件選択中}}",
"create_new_organization": "新しい組織を作成",
"create_segment": "セグメントを作成",
"create_survey": "フォームを作成",
@@ -276,7 +278,6 @@
"look_and_feel": "デザイン",
"manage": "管理",
"marketing": "マーケティング",
"member": "メンバー",
"members": "メンバー",
"members_and_teams": "メンバー&チーム",
"membership_not_found": "メンバーシップが見つかりません",
@@ -384,8 +385,6 @@
"select_teams": "チームを選択",
"selected": "選択済み",
"selected_questions": "選択した質問",
"selection": "選択",
"selections": "選択",
"send_test_email": "テストメールを送信",
"session_not_found": "セッションが見つかりません",
"settings": "設定",
@@ -2031,6 +2030,7 @@
"starts": "開始",
"starts_tooltip": "フォームが開始された回数。",
"survey_reset_successfully": "フォームを正常にリセットしました!{responseCount} 件の回答と {displayCount} 件の表示が削除されました。",
"survey_results": "{surveyName}の結果",
"this_month": "今月",
"this_quarter": "今四半期",
"this_year": "今年",
+6 -6
View File
@@ -175,9 +175,11 @@
"copy": "Kopiëren",
"copy_code": "Kopieer code",
"copy_link": "Kopieer link",
"count_attributes": "{value, plural, one {{value} attribuut} other {{value} attributen}}",
"count_contacts": "{value, plural, one {{value} contact} other {{value} contacten}}",
"count_responses": "{value, plural, one {{value} reactie} other {{value} reacties}}",
"count_attributes": "{count, plural, one {{count} attribuut} other {{count} attributen}}",
"count_contacts": "{count, plural, one {{count} contact} other {{count} contacten}}",
"count_members": "{count, plural, one {{count} lid} other {{count} leden}}",
"count_responses": "{count, plural, one {{count} reactie} other {{count} reacties}}",
"count_selections": "{count, plural, one {{count} selectie} other {{count} selecties}}",
"create_new_organization": "Creëer een nieuwe organisatie",
"create_segment": "Segment maken",
"create_survey": "Enquête maken",
@@ -276,7 +278,6 @@
"look_and_feel": "Kijk & voel",
"manage": "Beheren",
"marketing": "Marketing",
"member": "Lid",
"members": "Leden",
"members_and_teams": "Leden & teams",
"membership_not_found": "Lidmaatschap niet gevonden",
@@ -384,8 +385,6 @@
"select_teams": "Selecteer teams",
"selected": "Gekozen",
"selected_questions": "Geselecteerde vragen",
"selection": "Selectie",
"selections": "Selecties",
"send_test_email": "Test-e-mail verzenden",
"session_not_found": "Sessie niet gevonden",
"settings": "Instellingen",
@@ -2031,6 +2030,7 @@
"starts": "Begint",
"starts_tooltip": "Aantal keren dat de enquête is gestart.",
"survey_reset_successfully": "Enquête opnieuw ingesteld! {responseCount} reacties en {displayCount} displays zijn verwijderd.",
"survey_results": "Resultaten van {surveyName}",
"this_month": "Deze maand",
"this_quarter": "Dit kwartaal",
"this_year": "Dit jaar",
+6 -6
View File
@@ -175,9 +175,11 @@
"copy": "Copiar",
"copy_code": "Copiar código",
"copy_link": "Copiar Link",
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
"count_contacts": "{value, plural, one {# contato} other {# contatos} }",
"count_responses": "{value, plural, other {# respostas}}",
"count_attributes": "{count, plural, one {{count} atributo} other {{count} atributos}}",
"count_contacts": "{count, plural, one {# contato} other {# contatos} }",
"count_members": "{count, plural, one {{count} membro} other {{count} membros}}",
"count_responses": "{count, plural, other {# respostas}}",
"count_selections": "{count, plural, one {{count} seleção} other {{count} seleções}}",
"create_new_organization": "Criar nova organização",
"create_segment": "Criar segmento",
"create_survey": "Criar pesquisa",
@@ -276,7 +278,6 @@
"look_and_feel": "Aparência e Experiência",
"manage": "gerenciar",
"marketing": "marketing",
"member": "Membros",
"members": "Membros",
"members_and_teams": "Membros e equipes",
"membership_not_found": "Assinatura não encontrada",
@@ -384,8 +385,6 @@
"select_teams": "Selecionar times",
"selected": "Selecionado",
"selected_questions": "Perguntas selecionadas",
"selection": "seleção",
"selections": "seleções",
"send_test_email": "Enviar e-mail de teste",
"session_not_found": "Sessão não encontrada",
"settings": "Configurações",
@@ -2031,6 +2030,7 @@
"starts": "começa",
"starts_tooltip": "Número de vezes que a pesquisa foi iniciada.",
"survey_reset_successfully": "Pesquisa redefinida com sucesso! {responseCount} respostas e {displayCount} exibições foram deletadas.",
"survey_results": "Resultados de {surveyName}",
"this_month": "Este mês",
"this_quarter": "Este trimestre",
"this_year": "Este ano",
+6 -6
View File
@@ -175,9 +175,11 @@
"copy": "Copiar",
"copy_code": "Copiar código",
"copy_link": "Copiar Link",
"count_attributes": "{value, plural, one {{value} atributo} other {{value} atributos}}",
"count_contacts": "{value, plural, one {# contacto} other {# contactos} }",
"count_responses": "{value, plural, other {# respostas}}",
"count_attributes": "{count, plural, one {{count} atributo} other {{count} atributos}}",
"count_contacts": "{count, plural, one {# contacto} other {# contactos} }",
"count_members": "{count, plural, one {{count} membro} other {{count} membros}}",
"count_responses": "{count, plural, other {# respostas}}",
"count_selections": "{count, plural, one {{count} seleção} other {{count} seleções}}",
"create_new_organization": "Criar nova organização",
"create_segment": "Criar segmento",
"create_survey": "Criar inquérito",
@@ -276,7 +278,6 @@
"look_and_feel": "Aparência e Sensação",
"manage": "Gerir",
"marketing": "Marketing",
"member": "Membro",
"members": "Membros",
"members_and_teams": "Membros e equipas",
"membership_not_found": "Associação não encontrada",
@@ -384,8 +385,6 @@
"select_teams": "Selecionar equipas",
"selected": "Selecionado",
"selected_questions": "Perguntas selecionadas",
"selection": "Seleção",
"selections": "Seleções",
"send_test_email": "Enviar email de teste",
"session_not_found": "Sessão não encontrada",
"settings": "Configurações",
@@ -2031,6 +2030,7 @@
"starts": "Começa",
"starts_tooltip": "Número de vezes que o inquérito foi iniciado.",
"survey_reset_successfully": "Inquérito reiniciado com sucesso! {responseCount} respostas e {displayCount} exibições foram eliminadas.",
"survey_results": "Resultados de {surveyName}",
"this_month": "Este mês",
"this_quarter": "Este trimestre",
"this_year": "Este ano",
+6 -6
View File
@@ -175,9 +175,11 @@
"copy": "Copiază",
"copy_code": "Copiază codul",
"copy_link": "Copiază legătura",
"count_attributes": "{value, plural, one {{value} atribut} few {{value} atribute} other {{value} de atribute}}",
"count_contacts": "{value, plural, one {# contact} other {# contacte} }",
"count_responses": "{value, plural, one {# răspuns} other {# răspunsuri} }",
"count_attributes": "{count, plural, one {{count} atribut} few {{count} atribute} other {{count} de atribute}}",
"count_contacts": "{count, plural, one {# contact} other {# contacte} }",
"count_members": "{count, plural, one {{count} membru} few {{count} membri} other {{count} de membri}}",
"count_responses": "{count, plural, one {# răspuns} other {# răspunsuri} }",
"count_selections": "{count, plural, one {{count} selecție} few {{count} selecții} other {{count} de selecții}}",
"create_new_organization": "Creează organizație nouă",
"create_segment": "Creați segment",
"create_survey": "Creează sondaj",
@@ -276,7 +278,6 @@
"look_and_feel": "Aspect și Comportament",
"manage": "Gestionați",
"marketing": "Marketing",
"member": "Membru",
"members": "Membri",
"members_and_teams": "Membri și echipe",
"membership_not_found": "Apartenența nu a fost găsită",
@@ -384,8 +385,6 @@
"select_teams": "Selectați echipele",
"selected": "Selectat",
"selected_questions": "Întrebări selectate",
"selection": "Selecție",
"selections": "Selecții",
"send_test_email": "Trimite email de test",
"session_not_found": "Sesiune inexistentă",
"settings": "Setări",
@@ -2031,6 +2030,7 @@
"starts": "Începuturi",
"starts_tooltip": "Număr de ori când sondajul a fost început.",
"survey_reset_successfully": "Resetarea chestionarului realizată cu succes! Au fost șterse {responseCount} răspunsuri și {displayCount} afișări.",
"survey_results": "Rezultatele {surveyName}",
"this_month": "Luna aceasta",
"this_quarter": "Trimestrul acesta",
"this_year": "Anul acesta",
+6 -6
View File
@@ -175,9 +175,11 @@
"copy": "Копировать",
"copy_code": "Скопировать код",
"copy_link": "Скопировать ссылку",
"count_attributes": "{value, plural, one {{value} атрибут} few {{value} атрибута} many {{value} атрибутов} other {{value} атрибута}}",
"count_contacts": "{value, plural, one {{value} контакт} few {{value} контакта} many {{value} контактов} other {{value} контактов}}",
"count_responses": "{value, plural, one {{value} ответ} few {{value} ответа} many {{value} ответов} other {{value} ответов}}",
"count_attributes": "{count, plural, one {{count} атрибут} few {{count} атрибута} many {{count} атрибутов} other {{count} атрибута}}",
"count_contacts": "{count, plural, one {{count} контакт} few {{count} контакта} many {{count} контактов} other {{count} контактов}}",
"count_members": "{count, plural, one {{count} участник} few {{count} участника} many {{count} участников} other {{count} участника}}",
"count_responses": "{count, plural, one {{count} ответ} few {{count} ответа} many {{count} ответов} other {{count} ответов}}",
"count_selections": "{count, plural, one {{count} выбран} few {{count} выбрано} many {{count} выбрано} other {{count} выбрано}}",
"create_new_organization": "Создать новую организацию",
"create_segment": "Создать сегмент",
"create_survey": "Создать опрос",
@@ -276,7 +278,6 @@
"look_and_feel": "Внешний вид",
"manage": "Управление",
"marketing": "Маркетинг",
"member": "Участник",
"members": "Участники",
"members_and_teams": "Участники и команды",
"membership_not_found": "Участие не найдено",
@@ -384,8 +385,6 @@
"select_teams": "Выбрать команды",
"selected": "Выбрано",
"selected_questions": "Выбранные вопросы",
"selection": "Выбор",
"selections": "Выборы",
"send_test_email": "Отправить тестовое письмо",
"session_not_found": "Сессия не найдена",
"settings": "Настройки",
@@ -2031,6 +2030,7 @@
"starts": "Запуски",
"starts_tooltip": "Количество запусков опроса.",
"survey_reset_successfully": "Опрос успешно сброшен! {responseCount} ответов и {displayCount} показов были удалены.",
"survey_results": "Результаты {surveyName}",
"this_month": "В этом месяце",
"this_quarter": "В этом квартале",
"this_year": "В этом году",
+6 -6
View File
@@ -175,9 +175,11 @@
"copy": "Kopiera",
"copy_code": "Kopiera kod",
"copy_link": "Kopiera länk",
"count_attributes": "{value, plural, one {{value} attribut} other {{value} attribut}}",
"count_contacts": "{value, plural, one {{value} kontakt} other {{value} kontakter}}",
"count_responses": "{value, plural, one {{value} svar} other {{value} svar}}",
"count_attributes": "{count, plural, one {{count} attribut} other {{count} attribut}}",
"count_contacts": "{count, plural, one {{count} kontakt} other {{count} kontakter}}",
"count_members": "{count, plural, one {{count} medlem} other {{count} medlemmar}}",
"count_responses": "{count, plural, one {{count} svar} other {{count} svar}}",
"count_selections": "{count, plural, one {{count} val} other {{count} val}}",
"create_new_organization": "Skapa ny organisation",
"create_segment": "Skapa segment",
"create_survey": "Skapa enkät",
@@ -276,7 +278,6 @@
"look_and_feel": "Utseende",
"manage": "Hantera",
"marketing": "Marknadsföring",
"member": "Medlem",
"members": "Medlemmar",
"members_and_teams": "Medlemmar och team",
"membership_not_found": "Medlemskap hittades inte",
@@ -384,8 +385,6 @@
"select_teams": "Välj team",
"selected": "Vald",
"selected_questions": "Valda frågor",
"selection": "Urval",
"selections": "Urval",
"send_test_email": "Skicka testmeddelande",
"session_not_found": "Session hittades inte",
"settings": "Inställningar",
@@ -2031,6 +2030,7 @@
"starts": "Starter",
"starts_tooltip": "Antal gånger enkäten har startats.",
"survey_reset_successfully": "Enkät återställd! {responseCount} svar och {displayCount} visningar togs bort.",
"survey_results": "Resultat för {surveyName}",
"this_month": "Denna månad",
"this_quarter": "Detta kvartal",
"this_year": "Detta år",
+6 -6
View File
@@ -175,9 +175,11 @@
"copy": "复制",
"copy_code": "复制 代码",
"copy_link": "复制 链接",
"count_attributes": "{value, plural, one {{value} 个属性} other {{value} 个属性}}",
"count_contacts": "{value, plural, other {{value} 联系人} }",
"count_responses": "{value, plural, other {{value} 回复} }",
"count_attributes": "{count, plural, one {{count} 个属性} other {{count} 个属性}}",
"count_contacts": "{count, plural, other {{count} 联系人} }",
"count_members": "{count, plural, one {{count} 位成员} other {{count} 位成员}}",
"count_responses": "{count, plural, other {{count} 回复} }",
"count_selections": "{count, plural, other {已选择{count}项}}",
"create_new_organization": "创建 新的 组织",
"create_segment": "创建 细分",
"create_survey": "创建 调查",
@@ -276,7 +278,6 @@
"look_and_feel": "外观 & 感觉",
"manage": "管理",
"marketing": "市场营销",
"member": "成员",
"members": "成员",
"members_and_teams": "成员和团队",
"membership_not_found": "未找到会员资格",
@@ -384,8 +385,6 @@
"select_teams": "选择 团队",
"selected": "已选择",
"selected_questions": "选择的问题",
"selection": "选择",
"selections": "选择",
"send_test_email": "发送 测试 电子邮件",
"session_not_found": "会话 未找到",
"settings": "设置",
@@ -2031,6 +2030,7 @@
"starts": "开始",
"starts_tooltip": "调查 被 开始 的 次数",
"survey_reset_successfully": "调查已重置成功!{responseCount} 个 反馈 和 {displayCount} 个 显示 已删除。",
"survey_results": "{surveyName} 结果",
"this_month": "本月",
"this_quarter": "本季度",
"this_year": "今年",
+6 -6
View File
@@ -175,9 +175,11 @@
"copy": "複製",
"copy_code": "複製程式碼",
"copy_link": "複製連結",
"count_attributes": "{value, plural, one {{value} 個屬性} other {{value} 個屬性}}",
"count_contacts": "{value, plural, other {{value} 聯絡人} }",
"count_responses": "{value, plural, other {{value} 回應} }",
"count_attributes": "{count, plural, one {{count} 個屬性} other {{count} 個屬性}}",
"count_contacts": "{count, plural, other {{count} 聯絡人} }",
"count_members": "{count, plural, one {{count} 位成員} other {{count} 位成員}}",
"count_responses": "{count, plural, other {{count} 回應} }",
"count_selections": "{count, plural, one {{count} 個選項} other {{count} 個選項}}",
"create_new_organization": "建立新組織",
"create_segment": "建立區隔",
"create_survey": "建立問卷",
@@ -276,7 +278,6 @@
"look_and_feel": "外觀與風格",
"manage": "管理",
"marketing": "行銷",
"member": "成員",
"members": "成員",
"members_and_teams": "成員與團隊",
"membership_not_found": "找不到成員資格",
@@ -384,8 +385,6 @@
"select_teams": "選擇 團隊",
"selected": "已選取",
"selected_questions": "選取的問題",
"selection": "選取",
"selections": "選取",
"send_test_email": "發送測試電子郵件",
"session_not_found": "找不到工作階段",
"settings": "設定",
@@ -2031,6 +2030,7 @@
"starts": "開始次數",
"starts_tooltip": "問卷已開始的次數。",
"survey_reset_successfully": "調查 重置 成功!{responseCount} 條回應和 {displayCount} 個顯示被刪除。",
"survey_results": "{surveyName} 結果",
"this_month": "本月",
"this_quarter": "本季",
"this_year": "今年",
@@ -1,6 +1,8 @@
import { describe, expect, test, vi } from "vitest";
import { z } from "zod";
import { prisma } from "@formbricks/database";
import { InvalidInputError } from "@formbricks/types/errors";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import {
mockedPrismaWebhookUpdateReturn,
prismaNotFoundError,
@@ -18,6 +20,10 @@ vi.mock("@formbricks/database", () => ({
},
}));
vi.mock("@/lib/utils/validate-webhook-url", () => ({
validateWebhookUrl: vi.fn().mockResolvedValue(undefined),
}));
describe("getWebhook", () => {
test("returns ok if webhook is found", async () => {
vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce({ id: "123" });
@@ -63,6 +69,44 @@ describe("updateWebhook", () => {
}
});
test("calls validateWebhookUrl when URL is provided", async () => {
vi.mocked(prisma.webhook.update).mockResolvedValueOnce(mockedPrismaWebhookUpdateReturn);
await updateWebhook("123", mockedWebhookUpdateReturn);
expect(validateWebhookUrl).toHaveBeenCalledWith("https://example.com");
});
test("returns bad_request and skips Prisma update when URL fails SSRF validation", async () => {
vi.mocked(validateWebhookUrl).mockRejectedValueOnce(
new InvalidInputError("Webhook URL must not point to private or internal IP addresses")
);
const result = await updateWebhook("123", mockedWebhookUpdateReturn);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("bad_request");
expect(result.error.details[0].field).toBe("url");
}
expect(prisma.webhook.update).not.toHaveBeenCalled();
});
test("returns internal_server_error when validateWebhookUrl throws an unexpected error", async () => {
vi.mocked(validateWebhookUrl).mockRejectedValueOnce(new Error("unexpected DNS failure"));
const result = await updateWebhook("123", mockedWebhookUpdateReturn);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toBe("internal_server_error");
expect(result.error.details[0].field).toBe("url");
}
expect(prisma.webhook.update).not.toHaveBeenCalled();
});
test("returns not_found if record does not exist", async () => {
vi.mocked(prisma.webhook.update).mockRejectedValueOnce(prismaNotFoundError);
const result = await updateWebhook("999", mockedWebhookUpdateReturn);
@@ -3,6 +3,8 @@ import { z } from "zod";
import { prisma } from "@formbricks/database";
import { PrismaErrorType } from "@formbricks/database/types/error";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { InvalidInputError } from "@formbricks/types/errors";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { ZWebhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
@@ -34,6 +36,23 @@ export const updateWebhook = async (
webhookId: string,
webhookInput: z.infer<typeof ZWebhookUpdateSchema>
): Promise<Result<Webhook, ApiErrorResponseV2>> => {
if (webhookInput.url) {
try {
await validateWebhookUrl(webhookInput.url);
} catch (error) {
if (error instanceof InvalidInputError) {
return err({
type: "bad_request",
details: [{ field: "url", issue: error.message }],
});
}
return err({
type: "internal_server_error",
details: [{ field: "url", issue: "Webhook URL validation failed unexpectedly" }],
});
}
}
try {
const updatedWebhook = await prisma.webhook.update({
where: {
@@ -1,6 +1,8 @@
import { WebhookSource } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { InvalidInputError } from "@formbricks/types/errors";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
import { createWebhook, getWebhooks } from "../webhook";
@@ -15,6 +17,10 @@ vi.mock("@formbricks/database", () => ({
},
}));
vi.mock("@/lib/utils/validate-webhook-url", () => ({
validateWebhookUrl: vi.fn().mockResolvedValue(undefined),
}));
describe("getWebhooks", () => {
const environmentId = "env1";
const params = {
@@ -89,6 +95,44 @@ describe("createWebhook", () => {
}
});
test("calls validateWebhookUrl with the provided URL", async () => {
vi.mocked(prisma.webhook.create).mockResolvedValueOnce(createdWebhook);
await createWebhook(inputWebhook);
expect(validateWebhookUrl).toHaveBeenCalledWith("http://example.com");
});
test("returns bad_request and skips Prisma create when URL fails SSRF validation", async () => {
vi.mocked(validateWebhookUrl).mockRejectedValueOnce(
new InvalidInputError("Webhook URL must not point to private or internal IP addresses")
);
const result = await createWebhook(inputWebhook);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toEqual("bad_request");
expect(result.error.details[0].field).toEqual("url");
}
expect(prisma.webhook.create).not.toHaveBeenCalled();
});
test("returns internal_server_error when validateWebhookUrl throws an unexpected error", async () => {
vi.mocked(validateWebhookUrl).mockRejectedValueOnce(new Error("unexpected DNS failure"));
const result = await createWebhook(inputWebhook);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error.type).toEqual("internal_server_error");
expect(result.error.details[0].field).toEqual("url");
}
expect(prisma.webhook.create).not.toHaveBeenCalled();
});
test("returns error when creation fails", async () => {
vi.mocked(prisma.webhook.create).mockRejectedValueOnce(new Error("Creation failed"));
@@ -1,7 +1,9 @@
import { Prisma, Webhook } from "@prisma/client";
import { prisma } from "@formbricks/database";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { InvalidInputError } from "@formbricks/types/errors";
import { generateWebhookSecret } from "@/lib/crypto";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { getWebhooksQuery } from "@/modules/api/v2/management/webhooks/lib/utils";
import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks";
import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error";
@@ -49,6 +51,21 @@ export const getWebhooks = async (
export const createWebhook = async (webhook: TWebhookInput): Promise<Result<Webhook, ApiErrorResponseV2>> => {
const { environmentId, name, url, source, triggers, surveyIds } = webhook;
try {
await validateWebhookUrl(url);
} catch (error) {
if (error instanceof InvalidInputError) {
return err({
type: "bad_request",
details: [{ field: "url", issue: error.message }],
});
}
return err({
type: "internal_server_error",
details: [{ field: "url", issue: "Webhook URL validation failed unexpectedly" }],
});
}
try {
const secret = generateWebhookSecret();
@@ -39,9 +39,7 @@ export const AccessTable = ({ teams }: AccessTableProps) => {
{teams.map((team) => (
<TableRow key={team.id} className="border-slate-200 hover:bg-transparent">
<TableCell className="font-medium">{team.name}</TableCell>
<TableCell>
{team.memberCount} {team.memberCount === 1 ? t("common.member") : t("common.members")}
</TableCell>
<TableCell>{t("common.count_members", { count: team.memberCount })}</TableCell>
<TableCell>
<IdBadge id={team.id} />
</TableCell>
@@ -97,9 +97,7 @@ export const TeamsTable = ({
{userTeams.map((team) => (
<TableRow key={team.id} id={team.name} className="hover:bg-transparent">
<TableCell>{team.name}</TableCell>
<TableCell>
{team.memberCount} {team.memberCount === 1 ? t("common.member") : t("common.members")}
</TableCell>
<TableCell>{t("common.count_members", { count: team.memberCount })}</TableCell>
<TableCell>
<Badge
type="success"
@@ -120,9 +118,7 @@ export const TeamsTable = ({
{otherTeams.map((team) => (
<TableRow key={team.id} id={team.name} className="hover:bg-transparent">
<TableCell>{team.name}</TableCell>
<TableCell>
{team.memberCount} {team.memberCount === 1 ? t("common.member") : t("common.members")}
</TableCell>
<TableCell>{t("common.count_members", { count: team.memberCount })}</TableCell>
<TableCell></TableCell>
<TableCell className="flex justify-end">
<ManageTeamButton
@@ -11,6 +11,7 @@ import {
} from "@formbricks/types/errors";
import { generateStandardWebhookSignature, generateWebhookSecret } from "@/lib/crypto";
import { validateInputs } from "@/lib/utils/validate";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
import { TWebhookInput } from "../types/webhooks";
@@ -18,6 +19,10 @@ export const updateWebhook = async (
webhookId: string,
webhookInput: Partial<TWebhookInput>
): Promise<boolean> => {
if (webhookInput.url) {
await validateWebhookUrl(webhookInput.url);
}
try {
await prisma.webhook.update({
where: {
@@ -66,6 +71,8 @@ export const createWebhook = async (
webhookInput: TWebhookInput,
secret?: string
): Promise<Webhook> => {
await validateWebhookUrl(webhookInput.url);
try {
if (isDiscordWebhook(webhookInput.url)) {
throw new UnknownError("Discord webhooks are currently not supported.");
@@ -123,6 +130,8 @@ export const getWebhooks = async (environmentId: string): Promise<Webhook[]> =>
};
export const testEndpoint = async (url: string, secret?: string): Promise<boolean> => {
await validateWebhookUrl(url);
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
@@ -136,11 +136,11 @@ export const SelectedRowSettings = <T,>({
let deleteWhatText: string;
if (type === "response") {
deleteWhatText = t("common.count_responses", { value: selectedRowCount });
deleteWhatText = t("common.count_responses", { count: selectedRowCount });
} else if (type === "contact") {
deleteWhatText = t("common.count_contacts", { value: selectedRowCount });
deleteWhatText = t("common.count_contacts", { count: selectedRowCount });
} else {
deleteWhatText = t("common.count_attributes", { value: selectedRowCount });
deleteWhatText = t("common.count_attributes", { count: selectedRowCount });
}
return (
@@ -64,7 +64,7 @@ test.describe("API Tests for Webhooks", () => {
const webhookBody = {
environmentId,
name: "New Webhook",
url: "https://examplewebhook.com",
url: "https://example.com/webhook",
source: "user",
triggers: ["responseFinished"],
surveyIds: [surveyId],
@@ -104,7 +104,7 @@ test.describe("API Tests for Webhooks", () => {
const updatedBody = {
environmentId,
name: "Updated Webhook",
url: "https://updated-webhook-url.com",
url: "https://example.com/updated-webhook",
source: "zapier",
triggers: ["responseCreated"],
surveyIds: [surveyId],
+1 -1
View File
@@ -139,7 +139,7 @@ test.describe("JS Package Test", async () => {
await expect(page.getByRole("link", { name: "Responses" })).toBeVisible();
await expect(page.getByRole("button", { name: "Completed 100%" })).toBeVisible();
await expect(page.getByText("1 Responses", { exact: true }).first()).toBeVisible();
await expect(page.getByText("1 response", { exact: true }).first()).toBeVisible();
await expect(page.getByText("Somewhat disappointed")).toBeVisible();
await expect(page.getByText("Founder")).toBeVisible();
await expect(page.getByText("People who believe that PMF").first()).toBeVisible();