Compare commits

..

3 Commits

Author SHA1 Message Date
pandeymangg
701539d862 fix 2025-07-16 13:02:51 +05:30
pandeymangg
a19b6dfeb4 Merge remote-tracking branch 'origin/main' into fix/allow-read-write-permission-v1-management-me 2025-07-16 13:02:34 +05:30
SaurabhJain708
46f38f5b67 fix: allow read and write API key permissions for /v1/management/me (#6031) 2025-07-07 17:00:09 +05:30
84 changed files with 1238 additions and 4468 deletions

View File

@@ -189,6 +189,7 @@ ENTERPRISE_LICENSE_KEY=
UNSPLASH_ACCESS_KEY=
# The below is used for Next Caching (uses In-Memory from Next Cache if not provided)
# You can also add more configuration to Redis using the redis.conf file in the root directory
REDIS_URL=redis://localhost:6379
# The below is used for Rate Limiting (uses In-Memory LRU Cache if not provided) (You can use a service like Webdis for this)

View File

@@ -89,7 +89,6 @@ jobs:
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s/ENTERPRISE_LICENSE_KEY=.*/ENTERPRISE_LICENSE_KEY=${{ secrets.ENTERPRISE_LICENSE_KEY }}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=redis://localhost:6379|" .env
echo "" >> .env
echo "E2E_TESTING=1" >> .env
shell: bash
@@ -103,12 +102,6 @@ jobs:
# pnpm prisma migrate deploy
pnpm db:migrate:dev
- name: Run Rate Limiter Load Tests
run: |
echo "Running rate limiter load tests with Redis/Valkey..."
cd apps/web && pnpm vitest run modules/core/rate-limit/rate-limit-load.test.ts
shell: bash
- name: Check for Enterprise License
run: |
LICENSE_KEY=$(grep '^ENTERPRISE_LICENSE_KEY=' .env | cut -d'=' -f2-)

View File

@@ -43,7 +43,6 @@ jobs:
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
- name: Run tests with coverage
run: |

View File

@@ -41,7 +41,6 @@ jobs:
sed -i "s/ENCRYPTION_KEY=.*/ENCRYPTION_KEY=${RANDOM_KEY}/" .env
sed -i "s/CRON_SECRET=.*/CRON_SECRET=${RANDOM_KEY}/" .env
sed -i "s/NEXTAUTH_SECRET=.*/NEXTAUTH_SECRET=${RANDOM_KEY}/" .env
sed -i "s|REDIS_URL=.*|REDIS_URL=|" .env
- name: Test
run: pnpm test

View File

@@ -86,7 +86,7 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -89,7 +89,7 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -97,7 +97,7 @@ vi.mock("@/lib/constants", () => ({
OIDC_ISSUER: "https://mock-oidc-issuer.com",
OIDC_SIGNING_ALGORITHM: "RS256",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -35,7 +35,7 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -34,7 +34,7 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url",
IS_PRODUCTION: false,
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -27,7 +27,7 @@ vi.mock("@/lib/constants", () => ({
IS_POSTHOG_CONFIGURED: true,
SESSION_MAX_AGE: 1000,
AUDIT_LOG_ENABLED: 1,
REDIS_URL: undefined,
REDIS_URL: "redis://localhost:6379",
}));
vi.mock("@/lib/env", () => ({

View File

@@ -49,7 +49,7 @@ vi.mock("@/lib/constants", () => ({
OIDC_SIGNING_ALGORITHM: "test-oidc-signing-algorithm",
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -32,7 +32,7 @@ vi.mock("@/lib/constants", () => ({
GOOGLE_SHEETS_CLIENT_SECRET: "test-client-secret",
GOOGLE_SHEETS_REDIRECT_URL: "test-redirect-url",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "mock-redis-url",
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));

View File

@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));

View File

@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));

View File

@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));

View File

@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -41,7 +41,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: false,
SENTRY_DSN: "mock-sentry-dsn",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -30,7 +30,7 @@ vi.mock("@/lib/constants", () => ({
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "redis://localhost:6379",
AUDIT_LOG_ENABLED: 1,
}));

View File

@@ -45,7 +45,7 @@ vi.mock("@/lib/constants", () => ({
SMTP_USER: "mock-smtp-user",
SMTP_PASSWORD: "mock-smtp-password",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -5,7 +5,6 @@ import { ShareSurveyModal } from "@/app/(app)/environments/[environmentId]/surve
import { SurveyStatusDropdown } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/SurveyStatusDropdown";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { EditPublicSurveyAlertDialog } from "@/modules/survey/components/edit-public-survey-alert-dialog";
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
import { copySurveyToOtherEnvironmentAction } from "@/modules/survey/list/actions";
import { Badge } from "@/modules/ui/components/badge";
import { Button } from "@/modules/ui/components/button";
@@ -13,7 +12,7 @@ import { IconBar } from "@/modules/ui/components/iconbar";
import { useTranslate } from "@tolgee/react";
import { BellRing, Eye, SquarePenIcon } from "lucide-react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast";
import { TEnvironment } from "@formbricks/types/environment";
import { TSegment } from "@formbricks/types/segment";
@@ -49,16 +48,17 @@ export const SurveyAnalysisCTA = ({
isFormbricksCloud,
}: SurveyAnalysisCTAProps) => {
const { t } = useTranslate();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const [loading, setLoading] = useState(false);
const [modalState, setModalState] = useState<ModalState>({
start: searchParams.get("share") === "true",
share: false,
});
const { refreshSingleUseId } = useSingleUseId(survey);
const surveyUrl = useMemo(() => `${publicDomain}/s/${survey.id}`, [survey.id, publicDomain]);
const widgetSetupCompleted = survey.type === "app" && environment.appSetupCompleted;
@@ -102,18 +102,9 @@ export const SurveyAnalysisCTA = ({
setLoading(false);
};
const getPreviewUrl = async () => {
const surveyUrl = new URL(`${publicDomain}/s/${survey.id}`);
if (survey.singleUse?.enabled) {
const newId = await refreshSingleUseId();
if (newId) {
surveyUrl.searchParams.set("suId", newId);
}
}
surveyUrl.searchParams.set("preview", "true");
return surveyUrl.toString();
const getPreviewUrl = () => {
const separator = surveyUrl.includes("?") ? "&" : "?";
return `${surveyUrl}${separator}preview=true`;
};
const [isCautionDialogOpen, setIsCautionDialogOpen] = useState(false);
@@ -128,10 +119,7 @@ export const SurveyAnalysisCTA = ({
{
icon: Eye,
tooltip: t("common.preview"),
onClick: async () => {
const previewUrl = await getPreviewUrl();
window.open(previewUrl, "_blank");
},
onClick: () => window.open(getPreviewUrl(), "_blank"),
isVisible: survey.type === "link",
},
{

View File

@@ -1,5 +1,6 @@
"use client";
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
import { AnonymousLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/anonymous-links-tab";
import { AppTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/app-tab";
import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/dynamic-popup-tab";
@@ -7,14 +8,21 @@ import { EmailTab } from "@/app/(app)/environments/[environmentId]/surveys/[surv
import { PersonalLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab";
import { QRCodeTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab";
import { SocialMediaTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/social-media-tab";
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
import { WebsiteEmbedTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/website-embed-tab";
import { ShareViewType } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/types/share";
import { getSurveyUrl } from "@/modules/analysis/utils";
import { Dialog, DialogContent, DialogTitle } from "@/modules/ui/components/dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { useTranslate } from "@tolgee/react";
import { Code2Icon, LinkIcon, MailIcon, QrCodeIcon, Share2Icon, SquareStack, UserIcon } from "lucide-react";
import {
Code2Icon,
LinkIcon,
MailIcon,
QrCodeIcon,
Share2Icon,
SquareStack,
UserIcon
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { TSegment } from "@formbricks/types/segment";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -197,11 +205,17 @@ export const ShareSurveyModal = ({
}
if (survey.type === "link") {
return <ShareView tabs={linkTabs} activeId={activeId} setActiveId={setActiveId} />;
return (
<ShareView
tabs={linkTabs}
activeId={activeId}
setActiveId={setActiveId}
/>
);
}
return (
<div className={`h-full w-full rounded-lg bg-slate-50 p-6`}>
<div className={`h-full w-full bg-slate-50 p-6 rounded-lg`}>
<TabContainer
title={t("environments.surveys.summary.in_app.title")}
description={t("environments.surveys.summary.in_app.description")}>
@@ -221,7 +235,7 @@ export const ShareSurveyModal = ({
width={survey.type === "link" ? "wide" : "default"}
aria-describedby={undefined}
unconstrained>
{renderContent()}
{renderContent()}
</DialogContent>
</Dialog>
);

View File

@@ -4,13 +4,14 @@ import { updateSingleUseLinksAction } from "@/app/(app)/environments/[environmen
import { DisableLinkModal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/disable-link-modal";
import { DocumentationLinks } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/documentation-links";
import { ShareSurveyLink } from "@/modules/analysis/components/ShareSurveyLink";
import { getSurveyUrl } from "@/modules/analysis/utils";
import { generateSingleUseIdsAction } from "@/modules/survey/list/actions";
import { AdvancedOptionToggle } from "@/modules/ui/components/advanced-option-toggle";
import { Alert, AlertDescription, AlertTitle } from "@/modules/ui/components/alert";
import { Button } from "@/modules/ui/components/button";
import { Input } from "@/modules/ui/components/input";
import { useTranslate } from "@tolgee/react";
import { CirclePlayIcon, CopyIcon } from "lucide-react";
import { CirclePlayIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
@@ -32,7 +33,6 @@ export const AnonymousLinksTab = ({
setSurveyUrl,
locale,
}: AnonymousLinksTabProps) => {
const surveyUrlWithCustomSuid = `${surveyUrl}?suId=CUSTOM-ID`;
const router = useRouter();
const { t } = useTranslate();
@@ -173,9 +173,11 @@ export const AnonymousLinksTab = ({
count,
});
const baseSurveyUrl = getSurveyUrl(survey, publicDomain, "default");
if (!!response?.data?.length) {
const singleUseIds = response.data;
const surveyLinks = singleUseIds.map((singleUseId) => `${surveyUrl}?suId=${singleUseId}`);
const surveyLinks = singleUseIds.map((singleUseId) => `${baseSurveyUrl}?suId=${singleUseId}`);
// Create content with just the links
const csvContent = surveyLinks.join("\n");
@@ -256,36 +258,14 @@ export const AnonymousLinksTab = ({
/>
{!singleUseEncryption ? (
<div className="flex w-full flex-col gap-4">
<Alert variant="info" size="default">
<AlertTitle>
{t("environments.surveys.share.anonymous_links.custom_single_use_id_title")}
</AlertTitle>
<AlertDescription>
{t("environments.surveys.share.anonymous_links.custom_single_use_id_description")}
</AlertDescription>
</Alert>
<div className="grid w-full grid-cols-6 items-center gap-2">
<div className="col-span-5 truncate rounded-md border border-slate-200 px-2 py-1">
<span className="truncate text-sm text-slate-900">{surveyUrlWithCustomSuid}</span>
</div>
<Button
variant="secondary"
onClick={() => {
navigator.clipboard.writeText(surveyUrlWithCustomSuid);
toast.success(t("common.copied_to_clipboard"));
}}
className="col-span-1 gap-1 text-sm">
{t("common.copy")}
<CopyIcon />
</Button>
</div>
</div>
<Alert variant="info" size="default">
<AlertTitle>
{t("environments.surveys.share.anonymous_links.custom_single_use_id_title")}
</AlertTitle>
<AlertDescription>
{t("environments.surveys.share.anonymous_links.custom_single_use_id_description")}
</AlertDescription>
</Alert>
) : null}
{singleUseEncryption && (

View File

@@ -39,7 +39,7 @@ vi.mock("@/lib/constants", () => ({
FORMBRICKS_ENVIRONMENT_ID: "mock-formbricks-environment-id",
IS_FORMBRICKS_ENABLED: true,
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -16,18 +16,9 @@ vi.mock("@sentry/nextjs", () => ({
captureException: vi.fn(),
}));
vi.mock("@formbricks/types/errors", async (importOriginal) => {
const actual = await importOriginal<typeof import("@formbricks/types/errors")>();
return {
...actual,
getClientErrorData: vi.fn(),
};
});
describe("ErrorBoundary", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
const dummyError = new Error("Test error");
@@ -38,13 +29,6 @@ describe("ErrorBoundary", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any);
const { getClientErrorData } = await import("@formbricks/types/errors");
vi.mocked(getClientErrorData).mockReturnValue({
title: "Something went wrong",
description: "An unexpected error occurred. Please try again.",
showButtons: true,
});
render(<ErrorBoundary error={dummyError} reset={resetMock} />);
await waitFor(() => {
@@ -58,14 +42,7 @@ describe("ErrorBoundary", () => {
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
vi.mocked(Sentry.captureException).mockImplementation((() => {}) as any);
const { getClientErrorData } = await import("@formbricks/types/errors");
vi.mocked(getClientErrorData).mockReturnValue({
title: "Something went wrong",
description: "An unexpected error occurred. Please try again.",
showButtons: true,
});
render(<ErrorBoundary error={dummyError} reset={resetMock} />);
render(<ErrorBoundary error={{ ...dummyError }} reset={resetMock} />);
await waitFor(() => {
expect(Sentry.captureException).toHaveBeenCalled();
@@ -73,28 +50,14 @@ describe("ErrorBoundary", () => {
expect(consoleErrorSpy).not.toHaveBeenCalled();
});
test("calls reset when try again button is clicked for general errors", async () => {
const { getClientErrorData } = await import("@formbricks/types/errors");
vi.mocked(getClientErrorData).mockReturnValue({
title: "Something went wrong",
description: "An unexpected error occurred. Please try again.",
showButtons: true,
});
test("calls reset when try again button is clicked", async () => {
render(<ErrorBoundary error={{ ...dummyError }} reset={resetMock} />);
const tryAgainBtn = screen.getByRole("button", { name: "common.try_again" });
userEvent.click(tryAgainBtn);
await waitFor(() => expect(resetMock).toHaveBeenCalled());
});
test("sets window.location.href to '/' when dashboard button is clicked for general errors", async () => {
const { getClientErrorData } = await import("@formbricks/types/errors");
vi.mocked(getClientErrorData).mockReturnValue({
title: "Something went wrong",
description: "An unexpected error occurred. Please try again.",
showButtons: true,
});
test("sets window.location.href to '/' when dashboard button is clicked", async () => {
const originalLocation = window.location;
delete (window as any).location;
(window as any).location = { href: "" };
@@ -104,34 +67,6 @@ describe("ErrorBoundary", () => {
await waitFor(() => {
expect(window.location.href).toBe("/");
});
(window as any).location = originalLocation;
});
test("does not show buttons for rate limit errors", async () => {
const { getClientErrorData } = await import("@formbricks/types/errors");
vi.mocked(getClientErrorData).mockReturnValue({
title: "common.error_rate_limit_title",
description: "common.error_rate_limit_description",
showButtons: false,
});
render(<ErrorBoundary error={{ ...dummyError }} reset={resetMock} />);
expect(screen.queryByRole("button", { name: "common.try_again" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "common.go_to_dashboard" })).not.toBeInTheDocument();
});
test("shows error component with custom title and description for rate limit errors", async () => {
const { getClientErrorData } = await import("@formbricks/types/errors");
vi.mocked(getClientErrorData).mockReturnValue({
title: "common.error_rate_limit_title",
description: "common.error_rate_limit_description",
showButtons: false,
});
render(<ErrorBoundary error={dummyError} reset={resetMock} />);
expect(screen.getByTestId("ErrorComponent")).toBeInTheDocument();
expect(getClientErrorData).toHaveBeenCalledWith(dummyError);
window.location = originalLocation;
});
});

View File

@@ -5,12 +5,9 @@ import { Button } from "@/modules/ui/components/button";
import { ErrorComponent } from "@/modules/ui/components/error-component";
import * as Sentry from "@sentry/nextjs";
import { useTranslate } from "@tolgee/react";
import { getClientErrorData } from "@formbricks/types/errors";
const ErrorBoundary = ({ error, reset }: { error: Error; reset: () => void }) => {
const { t } = useTranslate();
const errorData = getClientErrorData(error);
if (process.env.NODE_ENV === "development") {
console.error(error.message);
} else {
@@ -19,15 +16,13 @@ const ErrorBoundary = ({ error, reset }: { error: Error; reset: () => void }) =>
return (
<div className="flex h-full w-full flex-col items-center justify-center">
<ErrorComponent title={errorData.title} description={errorData.description} />
{errorData.showButtons && (
<div className="mt-2">
<Button variant="secondary" onClick={() => reset()} className="mr-2">
{t("common.try_again")}
</Button>
<Button onClick={() => (window.location.href = "/")}>{t("common.go_to_dashboard")}</Button>
</div>
)}
<ErrorComponent />
<div className="mt-2">
<Button variant="secondary" onClick={() => reset()} className="mr-2">
{t("common.try_again")}
</Button>
<Button onClick={() => (window.location.href = "/")}>{t("common.go_to_dashboard")}</Button>
</div>
</div>
);
};

View File

@@ -13,6 +13,54 @@ describe("bucket middleware rate limiters", () => {
mockedRateLimit.mockImplementation((config) => config);
});
test("loginLimiter uses LOGIN_RATE_LIMIT settings", async () => {
const { loginLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.LOGIN_RATE_LIMIT.interval,
allowedPerInterval: constants.LOGIN_RATE_LIMIT.allowedPerInterval,
});
expect(loginLimiter).toEqual({
interval: constants.LOGIN_RATE_LIMIT.interval,
allowedPerInterval: constants.LOGIN_RATE_LIMIT.allowedPerInterval,
});
});
test("signupLimiter uses SIGNUP_RATE_LIMIT settings", async () => {
const { signupLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.SIGNUP_RATE_LIMIT.interval,
allowedPerInterval: constants.SIGNUP_RATE_LIMIT.allowedPerInterval,
});
expect(signupLimiter).toEqual({
interval: constants.SIGNUP_RATE_LIMIT.interval,
allowedPerInterval: constants.SIGNUP_RATE_LIMIT.allowedPerInterval,
});
});
test("verifyEmailLimiter uses VERIFY_EMAIL_RATE_LIMIT settings", async () => {
const { verifyEmailLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.VERIFY_EMAIL_RATE_LIMIT.interval,
allowedPerInterval: constants.VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval,
});
expect(verifyEmailLimiter).toEqual({
interval: constants.VERIFY_EMAIL_RATE_LIMIT.interval,
allowedPerInterval: constants.VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval,
});
});
test("forgotPasswordLimiter uses FORGET_PASSWORD_RATE_LIMIT settings", async () => {
const { forgotPasswordLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.FORGET_PASSWORD_RATE_LIMIT.interval,
allowedPerInterval: constants.FORGET_PASSWORD_RATE_LIMIT.allowedPerInterval,
});
expect(forgotPasswordLimiter).toEqual({
interval: constants.FORGET_PASSWORD_RATE_LIMIT.interval,
allowedPerInterval: constants.FORGET_PASSWORD_RATE_LIMIT.allowedPerInterval,
});
});
test("clientSideApiEndpointsLimiter uses CLIENT_SIDE_API_RATE_LIMIT settings", async () => {
const { clientSideApiEndpointsLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
@@ -25,6 +73,18 @@ describe("bucket middleware rate limiters", () => {
});
});
test("shareUrlLimiter uses SHARE_RATE_LIMIT settings", async () => {
const { shareUrlLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({
interval: constants.SHARE_RATE_LIMIT.interval,
allowedPerInterval: constants.SHARE_RATE_LIMIT.allowedPerInterval,
});
expect(shareUrlLimiter).toEqual({
interval: constants.SHARE_RATE_LIMIT.interval,
allowedPerInterval: constants.SHARE_RATE_LIMIT.allowedPerInterval,
});
});
test("syncUserIdentificationLimiter uses SYNC_USER_IDENTIFICATION_RATE_LIMIT settings", async () => {
const { syncUserIdentificationLimiter } = await import("./bucket");
expect(rateLimit).toHaveBeenCalledWith({

View File

@@ -1,11 +1,40 @@
import { CLIENT_SIDE_API_RATE_LIMIT, SYNC_USER_IDENTIFICATION_RATE_LIMIT } from "@/lib/constants";
import {
CLIENT_SIDE_API_RATE_LIMIT,
FORGET_PASSWORD_RATE_LIMIT,
LOGIN_RATE_LIMIT,
SHARE_RATE_LIMIT,
SIGNUP_RATE_LIMIT,
SYNC_USER_IDENTIFICATION_RATE_LIMIT,
VERIFY_EMAIL_RATE_LIMIT,
} from "@/lib/constants";
import { rateLimit } from "@/lib/utils/rate-limit";
export const loginLimiter = rateLimit({
interval: LOGIN_RATE_LIMIT.interval,
allowedPerInterval: LOGIN_RATE_LIMIT.allowedPerInterval,
});
export const signupLimiter = rateLimit({
interval: SIGNUP_RATE_LIMIT.interval,
allowedPerInterval: SIGNUP_RATE_LIMIT.allowedPerInterval,
});
export const verifyEmailLimiter = rateLimit({
interval: VERIFY_EMAIL_RATE_LIMIT.interval,
allowedPerInterval: VERIFY_EMAIL_RATE_LIMIT.allowedPerInterval,
});
export const forgotPasswordLimiter = rateLimit({
interval: FORGET_PASSWORD_RATE_LIMIT.interval,
allowedPerInterval: FORGET_PASSWORD_RATE_LIMIT.allowedPerInterval,
});
export const clientSideApiEndpointsLimiter = rateLimit({
interval: CLIENT_SIDE_API_RATE_LIMIT.interval,
allowedPerInterval: CLIENT_SIDE_API_RATE_LIMIT.allowedPerInterval,
});
export const shareUrlLimiter = rateLimit({
interval: SHARE_RATE_LIMIT.interval,
allowedPerInterval: SHARE_RATE_LIMIT.allowedPerInterval,
});
export const syncUserIdentificationLimiter = rateLimit({
interval: SYNC_USER_IDENTIFICATION_RATE_LIMIT.interval,
allowedPerInterval: SYNC_USER_IDENTIFICATION_RATE_LIMIT.allowedPerInterval,

View File

@@ -3,13 +3,63 @@ import {
isAdminDomainRoute,
isAuthProtectedRoute,
isClientSideApiRoute,
isForgotPasswordRoute,
isLoginRoute,
isManagementApiRoute,
isPublicDomainRoute,
isRouteAllowedForDomain,
isShareUrlRoute,
isSignupRoute,
isSyncWithUserIdentificationEndpoint,
isVerifyEmailRoute,
} from "./endpoint-validator";
describe("endpoint-validator", () => {
describe("isLoginRoute", () => {
test("should return true for login routes", () => {
expect(isLoginRoute("/api/auth/callback/credentials")).toBe(true);
expect(isLoginRoute("/auth/login")).toBe(true);
});
test("should return false for non-login routes", () => {
expect(isLoginRoute("/auth/signup")).toBe(false);
expect(isLoginRoute("/api/something")).toBe(false);
});
});
describe("isSignupRoute", () => {
test("should return true for signup route", () => {
expect(isSignupRoute("/auth/signup")).toBe(true);
});
test("should return false for non-signup routes", () => {
expect(isSignupRoute("/auth/login")).toBe(false);
expect(isSignupRoute("/api/something")).toBe(false);
});
});
describe("isVerifyEmailRoute", () => {
test("should return true for verify email route", () => {
expect(isVerifyEmailRoute("/auth/verify-email")).toBe(true);
});
test("should return false for non-verify email routes", () => {
expect(isVerifyEmailRoute("/auth/login")).toBe(false);
expect(isVerifyEmailRoute("/api/something")).toBe(false);
});
});
describe("isForgotPasswordRoute", () => {
test("should return true for forgot password route", () => {
expect(isForgotPasswordRoute("/auth/forgot-password")).toBe(true);
});
test("should return false for non-forgot password routes", () => {
expect(isForgotPasswordRoute("/auth/login")).toBe(false);
expect(isForgotPasswordRoute("/api/something")).toBe(false);
});
});
describe("isClientSideApiRoute", () => {
test("should return true for client-side API routes", () => {
expect(isClientSideApiRoute("/api/v1/js/actions")).toBe(true);
@@ -41,6 +91,20 @@ describe("endpoint-validator", () => {
});
});
describe("isShareUrlRoute", () => {
test("should return true for share URL routes", () => {
expect(isShareUrlRoute("/share/abc123/summary")).toBe(true);
expect(isShareUrlRoute("/share/abc123/responses")).toBe(true);
expect(isShareUrlRoute("/share/abc123def456/summary")).toBe(true);
});
test("should return false for non-share URL routes", () => {
expect(isShareUrlRoute("/share/abc123")).toBe(false);
expect(isShareUrlRoute("/share/abc123/other")).toBe(false);
expect(isShareUrlRoute("/api/something")).toBe(false);
});
});
describe("isAuthProtectedRoute", () => {
test("should return true for protected routes", () => {
expect(isAuthProtectedRoute("/environments")).toBe(true);

View File

@@ -4,6 +4,15 @@ import {
matchesAnyPattern,
} from "./route-config";
export const isLoginRoute = (url: string) =>
url === "/api/auth/callback/credentials" || url === "/auth/login";
export const isSignupRoute = (url: string) => url === "/auth/signup";
export const isVerifyEmailRoute = (url: string) => url === "/auth/verify-email";
export const isForgotPasswordRoute = (url: string) => url === "/auth/forgot-password";
export const isClientSideApiRoute = (url: string): boolean => {
// Open Graph image generation route is a client side API route but it should not be rate limited
if (url.includes("/api/v1/client/og")) return false;
@@ -19,6 +28,11 @@ export const isManagementApiRoute = (url: string): boolean => {
return regex.test(url);
};
export const isShareUrlRoute = (url: string): boolean => {
const regex = /\/share\/[A-Za-z0-9]+\/(?:summary|responses)/;
return regex.test(url);
};
export const isAuthProtectedRoute = (url: string): boolean => {
// List of routes that require authentication
const protectedRoutes = ["/environments", "/setup/organization", "/organizations"];

View File

@@ -7,8 +7,6 @@ import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurvey, getSurveyIdByResultShareKey } from "@/lib/survey/service";
import { getTagsByEnvironmentId } from "@/lib/tag/service";
import { findMatchingLocale } from "@/lib/utils/locale";
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
@@ -23,8 +21,6 @@ interface ResponsesPageProps {
}
const Page = async (props: ResponsesPageProps) => {
await applyIPRateLimit(rateLimitConfigs.share.url);
const t = await getTranslate();
const params = await props.params;
const surveyId = await getSurveyIdByResultShareKey(params.sharingKey);

View File

@@ -1,144 +0,0 @@
import { getSurveyIdByResultShareKey } from "@/lib/survey/service";
// Import mocked functions
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { cleanup } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
// Mock all dependencies to avoid server-side environment issues
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: false,
IS_PRODUCTION: false,
WEBAPP_URL: "http://localhost:3000",
SHORT_URL_BASE: "http://localhost:3000",
ENCRYPTION_KEY: "test-key",
RATE_LIMITING_DISABLED: false,
}));
vi.mock("@/lib/env", () => ({
env: {
IS_FORMBRICKS_CLOUD: "0",
NODE_ENV: "test",
WEBAPP_URL: "http://localhost:3000",
SHORT_URL_BASE: "http://localhost:3000",
ENCRYPTION_KEY: "test-key",
RATE_LIMITING_DISABLED: "false",
},
}));
// Mock rate limiting dependencies
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
share: {
url: { interval: 60, allowedPerInterval: 30, namespace: "share:url" },
},
},
}));
// Mock other dependencies
vi.mock("@/lib/survey/service", () => ({
getSurveyIdByResultShareKey: vi.fn(),
}));
describe("Share Summary Page Rate Limiting", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
cleanup();
});
describe("Rate Limiting Configuration", () => {
test("should have correct rate limit config for share URLs", () => {
expect(rateLimitConfigs.share.url).toEqual({
interval: 60,
allowedPerInterval: 30,
namespace: "share:url",
});
});
test("should apply rate limiting function correctly", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
await applyIPRateLimit(rateLimitConfigs.share.url);
expect(applyIPRateLimit).toHaveBeenCalledWith({
interval: 60,
allowedPerInterval: 30,
namespace: "share:url",
});
});
test("should throw rate limit error when limit exceeded", async () => {
vi.mocked(applyIPRateLimit).mockRejectedValue(
new Error("Maximum number of requests reached. Please try again later.")
);
await expect(applyIPRateLimit(rateLimitConfigs.share.url)).rejects.toThrow(
"Maximum number of requests reached. Please try again later."
);
});
});
describe("Share Key Validation Flow", () => {
test("should validate sharing key after rate limiting", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getSurveyIdByResultShareKey).mockResolvedValue("survey123");
// Simulate the flow: rate limit first, then validate sharing key
await applyIPRateLimit(rateLimitConfigs.share.url);
const surveyId = await getSurveyIdByResultShareKey("test-sharing-key-123");
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.share.url);
expect(getSurveyIdByResultShareKey).toHaveBeenCalledWith("test-sharing-key-123");
expect(surveyId).toBe("survey123");
});
test("should handle invalid sharing keys after rate limiting", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getSurveyIdByResultShareKey).mockResolvedValue(null);
await applyIPRateLimit(rateLimitConfigs.share.url);
const surveyId = await getSurveyIdByResultShareKey("invalid-key");
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.share.url);
expect(getSurveyIdByResultShareKey).toHaveBeenCalledWith("invalid-key");
expect(surveyId).toBeNull();
});
});
describe("Security Considerations", () => {
test("should rate limit all requests regardless of sharing key validity", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
// Test with valid sharing key
vi.mocked(getSurveyIdByResultShareKey).mockResolvedValue("survey123");
await applyIPRateLimit(rateLimitConfigs.share.url);
await getSurveyIdByResultShareKey("valid-key");
// Test with invalid sharing key
vi.mocked(getSurveyIdByResultShareKey).mockResolvedValue(null);
await applyIPRateLimit(rateLimitConfigs.share.url);
await getSurveyIdByResultShareKey("invalid-key");
expect(applyIPRateLimit).toHaveBeenCalledTimes(2);
});
test("should not expose internal errors when rate limited", async () => {
const rateLimitError = new Error("Maximum number of requests reached. Please try again later.");
vi.mocked(applyIPRateLimit).mockRejectedValue(rateLimitError);
await expect(applyIPRateLimit(rateLimitConfigs.share.url)).rejects.toThrow(
"Maximum number of requests reached. Please try again later."
);
// Ensure no other operations are performed
expect(getSurveyIdByResultShareKey).not.toHaveBeenCalled();
});
});
});

View File

@@ -6,8 +6,6 @@ import { getEnvironment } from "@/lib/environment/service";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { getProjectByEnvironmentId } from "@/lib/project/service";
import { getSurvey, getSurveyIdByResultShareKey } from "@/lib/survey/service";
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { getTranslate } from "@/tolgee/server";
@@ -22,8 +20,6 @@ interface SummaryPageProps {
}
const Page = async (props: SummaryPageProps) => {
await applyIPRateLimit(rateLimitConfigs.share.url);
const t = await getTranslate();
const params = await props.params;
const surveyId = await getSurveyIdByResultShareKey(params.sharingKey);

View File

@@ -1,47 +0,0 @@
import { redirect } from "next/navigation";
import { describe, expect, test, vi } from "vitest";
// Mock the redirect function
vi.mock("next/navigation", () => ({
redirect: vi.fn(),
}));
// Import the page component
const PageComponent = (await import("./page")).default;
describe("Share Redirect Page", () => {
test("should redirect to summary page without rate limiting", async () => {
const mockParams = Promise.resolve({ sharingKey: "test-sharing-key-123" });
await PageComponent({ params: mockParams });
expect(redirect).toHaveBeenCalledWith("/share/test-sharing-key-123/summary");
});
test("should handle different sharing keys", async () => {
const testCases = ["abc123", "survey-key-456", "long-sharing-key-with-dashes-789"];
for (const sharingKey of testCases) {
vi.clearAllMocks();
const mockParams = Promise.resolve({ sharingKey });
await PageComponent({ params: mockParams });
expect(redirect).toHaveBeenCalledWith(`/share/${sharingKey}/summary`);
}
});
test("should be lightweight and not perform any rate limiting", async () => {
// This test ensures the page doesn't import or use rate limiting
const mockParams = Promise.resolve({ sharingKey: "test-key" });
// Measure execution time to ensure it's very fast (< 10ms)
const startTime = performance.now();
await PageComponent({ params: mockParams });
const endTime = performance.now();
const executionTime = endTime - startTime;
expect(executionTime).toBeLessThan(10); // Should be very fast since it's just a redirect
expect(redirect).toHaveBeenCalled();
});
});

View File

@@ -154,6 +154,15 @@ export const SURVEY_BG_COLORS = [
];
// Rate Limiting
export const SIGNUP_RATE_LIMIT = {
interval: 60 * 60, // 60 minutes
allowedPerInterval: 30,
};
export const LOGIN_RATE_LIMIT = {
interval: 15 * 60, // 15 minutes
allowedPerInterval: 30,
};
export const CLIENT_SIDE_API_RATE_LIMIT = {
interval: 60, // 1 minute
allowedPerInterval: 100,
@@ -162,6 +171,23 @@ export const MANAGEMENT_API_RATE_LIMIT = {
interval: 60, // 1 minute
allowedPerInterval: 100,
};
export const SHARE_RATE_LIMIT = {
interval: 60 * 1, // 1 minutes
allowedPerInterval: 30,
};
export const FORGET_PASSWORD_RATE_LIMIT = {
interval: 60 * 60, // 60 minutes
allowedPerInterval: 5, // Limit to 5 requests per hour
};
export const RESET_PASSWORD_RATE_LIMIT = {
interval: 60 * 60, // 60 minutes
allowedPerInterval: 5, // Limit to 5 requests per hour
};
export const VERIFY_EMAIL_RATE_LIMIT = {
interval: 60 * 60, // 60 minutes
allowedPerInterval: 10, // Limit to 10 requests per hour
};
export const SYNC_USER_IDENTIFICATION_RATE_LIMIT = {
interval: 60, // 1 minute
allowedPerInterval: 5,

View File

@@ -1,5 +1,5 @@
import { describe, expect, test } from "vitest";
import { hashString } from "./hash-string";
import { hashString } from "./hashString";
describe("hashString", () => {
test("should return a string", () => {

View File

@@ -1,7 +1,6 @@
import "server-only";
import { isValidImageFile } from "@/lib/fileValidation";
import { deleteOrganization, getOrganizationsWhereUserIsSingleOwner } from "@/lib/organization/service";
import { deleteBrevoCustomerByEmail } from "@/modules/auth/lib/brevo";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { z } from "zod";
@@ -134,7 +133,6 @@ export const deleteUser = async (id: string): Promise<TUser> => {
}
const deletedUser = await deleteUserById(id);
await deleteBrevoCustomerByEmail({ email: deletedUser.email });
return deletedUser;
} catch (error) {

View File

@@ -1,10 +1,23 @@
import { clientSideApiEndpointsLimiter, syncUserIdentificationLimiter } from "@/app/middleware/bucket";
import {
clientSideApiEndpointsLimiter,
forgotPasswordLimiter,
loginLimiter,
shareUrlLimiter,
signupLimiter,
syncUserIdentificationLimiter,
verifyEmailLimiter,
} from "@/app/middleware/bucket";
import { isPublicDomainConfigured, isRequestFromPublicDomain } from "@/app/middleware/domain-utils";
import {
isAuthProtectedRoute,
isClientSideApiRoute,
isForgotPasswordRoute,
isLoginRoute,
isRouteAllowedForDomain,
isShareUrlRoute,
isSignupRoute,
isSyncWithUserIdentificationEndpoint,
isVerifyEmailRoute,
} from "@/app/middleware/endpoint-validator";
import { IS_PRODUCTION, RATE_LIMITING_DISABLED, WEBAPP_URL } from "@/lib/constants";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
@@ -38,13 +51,23 @@ const handleAuth = async (request: NextRequest): Promise<Response | null> => {
};
const applyRateLimiting = async (request: NextRequest, ip: string) => {
if (isClientSideApiRoute(request.nextUrl.pathname)) {
if (isLoginRoute(request.nextUrl.pathname)) {
await loginLimiter(`login-${ip}`);
} else if (isSignupRoute(request.nextUrl.pathname)) {
await signupLimiter(`signup-${ip}`);
} else if (isVerifyEmailRoute(request.nextUrl.pathname)) {
await verifyEmailLimiter(`verify-email-${ip}`);
} else if (isForgotPasswordRoute(request.nextUrl.pathname)) {
await forgotPasswordLimiter(`forgot-password-${ip}`);
} else if (isClientSideApiRoute(request.nextUrl.pathname)) {
await clientSideApiEndpointsLimiter(`client-side-api-${ip}`);
const envIdAndUserId = isSyncWithUserIdentificationEndpoint(request.nextUrl.pathname);
if (envIdAndUserId) {
const { environmentId, userId } = envIdAndUserId;
await syncUserIdentificationLimiter(`sync-${environmentId}-${userId}`);
}
} else if (isShareUrlRoute(request.nextUrl.pathname)) {
await shareUrlLimiter(`share-${ip}`);
}
};
@@ -111,7 +134,7 @@ export const middleware = async (originalRequest: NextRequest) => {
await applyRateLimiting(request, ip);
return nextResponseWithCustomHeader;
} catch (e) {
logger.error(e, "Error applying rate limiting");
// NOSONAR - This is a catch all for rate limiting errors
const apiError: ApiErrorResponseV2 = {
type: "too_many_requests",
details: [{ field: "", issue: "Too many requests. Please try again later." }],

View File

@@ -4,7 +4,6 @@ import { toast } from "react-hot-toast";
import { afterEach, describe, expect, test, vi } from "vitest";
import { TSurvey, TSurveyQuestionTypeEnum } from "@formbricks/types/surveys/types";
import { TUserLocale } from "@formbricks/types/user";
import { getSurveyUrl } from "../../utils";
vi.mock("react-hot-toast", () => ({
toast: {
@@ -12,24 +11,6 @@ vi.mock("react-hot-toast", () => ({
},
}));
// Mock the useSingleUseId hook
vi.mock("@/modules/survey/hooks/useSingleUseId", () => ({
useSingleUseId: vi.fn(() => ({
singleUseId: "test-single-use-id",
refreshSingleUseId: vi.fn().mockResolvedValue("test-single-use-id"),
})),
}));
// Mock the survey utils
vi.mock("../../utils", () => ({
getSurveyUrl: vi.fn((survey, publicDomain, language) => {
if (language && language !== "en") {
return `${publicDomain}/s/${survey.id}?lang=${language}`;
}
return `${publicDomain}/s/${survey.id}`;
}),
}));
const survey: TSurvey = {
id: "survey-id",
name: "Test Survey",
@@ -180,7 +161,7 @@ describe("ShareSurveyLink", () => {
expect(toast.success).toHaveBeenCalledWith("common.copied_to_clipboard");
});
test("opens the preview link in a new tab when preview button is clicked (no query params)", async () => {
test("opens the preview link in a new tab when preview button is clicked (no query params)", () => {
render(
<ShareSurveyLink
survey={survey}
@@ -194,13 +175,10 @@ describe("ShareSurveyLink", () => {
const previewButton = screen.getByLabelText("environments.surveys.preview_survey_in_a_new_tab");
fireEvent.click(previewButton);
// Wait for the async function to complete
await new Promise((resolve) => setTimeout(resolve, 0));
expect(global.open).toHaveBeenCalledWith(`${surveyUrl}?preview=true`, "_blank");
});
test("opens the preview link in a new tab when preview button is clicked (with query params)", async () => {
test("opens the preview link in a new tab when preview button is clicked (with query params)", () => {
const surveyWithParamsUrl = `${publicDomain}/s/survey-id?foo=bar`;
render(
<ShareSurveyLink
@@ -215,9 +193,6 @@ describe("ShareSurveyLink", () => {
const previewButton = screen.getByLabelText("environments.surveys.preview_survey_in_a_new_tab");
fireEvent.click(previewButton);
// Wait for the async function to complete
await new Promise((resolve) => setTimeout(resolve, 0));
expect(global.open).toHaveBeenCalledWith(`${surveyWithParamsUrl}&preview=true`, "_blank");
});
@@ -240,9 +215,7 @@ describe("ShareSurveyLink", () => {
});
test("updates the survey URL when the language is changed", () => {
const mockGetSurveyUrl = vi.mocked(getSurveyUrl);
render(
const { rerender } = render(
<ShareSurveyLink
survey={survey}
publicDomain={publicDomain}
@@ -258,7 +231,16 @@ describe("ShareSurveyLink", () => {
const germanOption = screen.getByText("German");
fireEvent.click(germanOption);
expect(mockGetSurveyUrl).toHaveBeenCalledWith(survey, publicDomain, "de");
expect(setSurveyUrl).toHaveBeenCalledWith(`${publicDomain}/s/${survey.id}?lang=de`);
rerender(
<ShareSurveyLink
survey={survey}
publicDomain={publicDomain}
surveyUrl={surveyUrl}
setSurveyUrl={setSurveyUrl}
locale={locale}
/>
);
expect(setSurveyUrl).toHaveBeenCalled();
expect(surveyUrl).toContain("lang=de");
});
});

View File

@@ -1,6 +1,5 @@
"use client";
import { useSingleUseId } from "@/modules/survey/hooks/useSingleUseId";
import { Button } from "@/modules/ui/components/button";
import { useTranslate } from "@tolgee/react";
import { Copy, SquareArrowOutUpRight } from "lucide-react";
@@ -33,22 +32,6 @@ export const ShareSurveyLink = ({
setSurveyUrl(url);
};
const { refreshSingleUseId } = useSingleUseId(survey);
const getPreviewUrl = async () => {
const previewUrl = new URL(surveyUrl);
if (survey.singleUse?.enabled) {
const newId = await refreshSingleUseId();
if (newId) {
previewUrl.searchParams.set("suId", newId);
}
}
previewUrl.searchParams.set("preview", "true");
return previewUrl.toString();
};
return (
<div className={"flex max-w-full flex-col items-center justify-center gap-2 md:flex-row"}>
<SurveyLinkDisplay surveyUrl={surveyUrl} key={surveyUrl} />
@@ -70,9 +53,14 @@ export const ShareSurveyLink = ({
title={t("environments.surveys.preview_survey_in_a_new_tab")}
aria-label={t("environments.surveys.preview_survey_in_a_new_tab")}
disabled={!surveyUrl}
onClick={async () => {
const url = await getPreviewUrl();
window.open(url, "_blank");
onClick={() => {
let previewUrl = surveyUrl;
if (previewUrl.includes("?")) {
previewUrl += "&preview=true";
} else {
previewUrl += "?preview=true";
}
window.open(previewUrl, "_blank");
}}>
{t("common.preview")}
<SquareArrowOutUpRight />

View File

@@ -20,7 +20,7 @@ vi.mock("@/lib/constants", () => ({
IS_PRODUCTION: true,
AUDIT_LOG_ENABLED: true,
ENCRYPTION_KEY: "mocked-encryption-key",
REDIS_URL: undefined,
REDIS_URL: "mock-url",
}));
describe("utils", () => {

View File

@@ -1,230 +0,0 @@
import { getUserByEmail } from "@/modules/auth/lib/user";
// Import mocked functions
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { sendForgotPasswordEmail } from "@/modules/email";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { forgotPasswordAction } from "./actions";
// Mock dependencies
vi.mock("@/lib/constants", () => ({
PASSWORD_RESET_DISABLED: false,
}));
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
auth: {
forgotPassword: { interval: 3600, allowedPerInterval: 5, namespace: "auth:forgot" },
},
},
}));
vi.mock("@/modules/auth/lib/user", () => ({
getUserByEmail: vi.fn(),
}));
vi.mock("@/modules/email", () => ({
sendForgotPasswordEmail: vi.fn(),
}));
vi.mock("@/lib/utils/action-client", () => ({
actionClient: {
schema: vi.fn().mockReturnThis(),
action: vi.fn((fn) => fn),
},
}));
describe("forgotPasswordAction", () => {
const validInput = {
email: "test@example.com",
};
const mockUser = {
id: "user123",
email: "test@example.com",
identityProvider: "email",
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("Rate Limiting", () => {
test("should apply rate limiting before processing forgot password request", async () => {
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
await forgotPasswordAction({ parsedInput: validInput } as any);
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.forgotPassword);
expect(applyIPRateLimit).toHaveBeenCalledBefore(getUserByEmail as any);
});
test("should throw rate limit error when limit exceeded", async () => {
vi.mocked(applyIPRateLimit).mockRejectedValue(
new Error("Maximum number of requests reached. Please try again later.")
);
await expect(forgotPasswordAction({ parsedInput: validInput } as any)).rejects.toThrow(
"Maximum number of requests reached. Please try again later."
);
expect(getUserByEmail).not.toHaveBeenCalled();
expect(sendForgotPasswordEmail).not.toHaveBeenCalled();
});
test("should use correct rate limit configuration", async () => {
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
await forgotPasswordAction({ parsedInput: validInput } as any);
expect(applyIPRateLimit).toHaveBeenCalledWith({
interval: 3600,
allowedPerInterval: 5,
namespace: "auth:forgot",
});
});
test("should apply rate limiting even when user doesn't exist", async () => {
vi.mocked(getUserByEmail).mockResolvedValue(null);
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.forgotPassword);
expect(result).toEqual({ success: true });
});
});
describe("Password Reset Flow", () => {
test("should send password reset email when user exists with email identity provider", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
expect(applyIPRateLimit).toHaveBeenCalled();
expect(getUserByEmail).toHaveBeenCalledWith(validInput.email);
expect(sendForgotPasswordEmail).toHaveBeenCalledWith(mockUser);
expect(result).toEqual({ success: true });
});
test("should not send email when user doesn't exist", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(null);
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
expect(applyIPRateLimit).toHaveBeenCalled();
expect(getUserByEmail).toHaveBeenCalledWith(validInput.email);
expect(sendForgotPasswordEmail).not.toHaveBeenCalled();
expect(result).toEqual({ success: true });
});
test("should not send email when user has non-email identity provider", async () => {
const ssoUser = { ...mockUser, identityProvider: "google" };
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(ssoUser as any);
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
expect(applyIPRateLimit).toHaveBeenCalled();
expect(getUserByEmail).toHaveBeenCalledWith(validInput.email);
expect(sendForgotPasswordEmail).not.toHaveBeenCalled();
expect(result).toEqual({ success: true });
});
});
describe("Password Reset Disabled", () => {
test("should check password reset is enabled in our implementation", async () => {
// This test verifies that password reset is enabled by default
// The actual PASSWORD_RESET_DISABLED check is part of the implementation
// and we've mocked it as false, so rate limiting should work normally
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
expect(applyIPRateLimit).toHaveBeenCalled();
expect(getUserByEmail).toHaveBeenCalled();
expect(result).toEqual({ success: true });
});
});
describe("Error Handling", () => {
test("should propagate rate limiting errors", async () => {
const rateLimitError = new Error("Maximum number of requests reached. Please try again later.");
vi.mocked(applyIPRateLimit).mockRejectedValue(rateLimitError);
await expect(forgotPasswordAction({ parsedInput: validInput } as any)).rejects.toThrow(
"Maximum number of requests reached. Please try again later."
);
});
test("should handle user lookup errors after rate limiting", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockRejectedValue(new Error("Database error"));
await expect(forgotPasswordAction({ parsedInput: validInput } as any)).rejects.toThrow(
"Database error"
);
expect(applyIPRateLimit).toHaveBeenCalled();
});
test("should handle email sending errors after rate limiting", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
vi.mocked(sendForgotPasswordEmail).mockRejectedValue(new Error("Email service error"));
await expect(forgotPasswordAction({ parsedInput: validInput } as any)).rejects.toThrow(
"Email service error"
);
expect(applyIPRateLimit).toHaveBeenCalled();
expect(getUserByEmail).toHaveBeenCalled();
});
});
describe("Security Considerations", () => {
test("should always return success even for non-existent users to prevent email enumeration", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(null);
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
expect(result).toEqual({ success: true });
});
test("should always return success even for SSO users to prevent identity provider enumeration", async () => {
const ssoUser = { ...mockUser, identityProvider: "github" };
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(ssoUser as any);
const result = await forgotPasswordAction({ parsedInput: validInput } as any);
expect(result).toEqual({ success: true });
expect(sendForgotPasswordEmail).not.toHaveBeenCalled();
});
test("should rate limit all requests regardless of user existence", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
// Test with existing user
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
await forgotPasswordAction({ parsedInput: validInput } as any);
// Test with non-existing user
vi.mocked(getUserByEmail).mockResolvedValue(null);
await forgotPasswordAction({ parsedInput: { email: "nonexistent@example.com" } } as any);
expect(applyIPRateLimit).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -3,8 +3,6 @@
import { PASSWORD_RESET_DISABLED } from "@/lib/constants";
import { actionClient } from "@/lib/utils/action-client";
import { getUserByEmail } from "@/modules/auth/lib/user";
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { sendForgotPasswordEmail } from "@/modules/email";
import { z } from "zod";
import { OperationNotAllowedError } from "@formbricks/types/errors";
@@ -17,8 +15,6 @@ const ZForgotPasswordAction = z.object({
export const forgotPasswordAction = actionClient
.schema(ZForgotPasswordAction)
.action(async ({ parsedInput }) => {
await applyIPRateLimit(rateLimitConfigs.auth.forgotPassword);
if (PASSWORD_RESET_DISABLED) {
throw new OperationNotAllowedError("Password reset is disabled");
}

View File

@@ -25,7 +25,7 @@ vi.mock("@/lib/constants", () => ({
SMTP_HOST: "smtp.example.com",
SMTP_PORT: "587",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -1,8 +1,5 @@
import { EMAIL_VERIFICATION_DISABLED } from "@/lib/constants";
import { createToken } from "@/lib/jwt";
// Import mocked rate limiting functions
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { randomBytes } from "crypto";
import { Provider } from "next-auth/providers/index";
import { afterEach, describe, expect, test, vi } from "vitest";
@@ -11,20 +8,6 @@ import { authOptions } from "./authOptions";
import { mockUser } from "./mock-data";
import { hashPassword } from "./utils";
// Mock rate limiting dependencies
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
auth: {
login: { interval: 900, allowedPerInterval: 30, namespace: "auth:login" },
verifyEmail: { interval: 3600, allowedPerInterval: 10, namespace: "auth:verify" },
},
},
}));
// Mock constants that this test needs
vi.mock("@/lib/constants", () => ({
EMAIL_VERIFICATION_DISABLED: false,
@@ -38,7 +21,6 @@ vi.mock("@/lib/constants", () => ({
ENTERPRISE_LICENSE_KEY: undefined,
SENTRY_DSN: undefined,
BREVO_API_KEY: undefined,
RATE_LIMITING_DISABLED: false,
}));
// Mock next/headers
@@ -87,7 +69,6 @@ function getProviderById(id: string): Provider {
describe("authOptions", () => {
afterEach(() => {
vi.clearAllMocks();
vi.restoreAllMocks();
});
@@ -101,7 +82,6 @@ describe("authOptions", () => {
});
test("should throw error if user not found", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(null);
const credentials = { email: mockUser.email, password: mockPassword };
@@ -112,7 +92,6 @@ describe("authOptions", () => {
});
test("should throw error if user has no password stored", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUser.id,
email: mockUser.email,
@@ -127,7 +106,6 @@ describe("authOptions", () => {
});
test("should throw error if password verification fails", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUserId,
email: mockUser.email,
@@ -142,7 +120,6 @@ describe("authOptions", () => {
});
test("should successfully login when credentials are valid", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
const fakeUser = {
id: mockUserId,
email: mockUser.email,
@@ -165,64 +142,8 @@ describe("authOptions", () => {
});
});
describe("Rate Limiting", () => {
test("should apply rate limiting before credential validation", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUserId,
email: mockUser.email,
password: mockHashedPassword,
emailVerified: new Date(),
twoFactorEnabled: false,
} as any);
const credentials = { email: mockUser.email, password: mockPassword };
await credentialsProvider.options.authorize(credentials, {});
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.login);
expect(applyIPRateLimit).toHaveBeenCalledBefore(prisma.user.findUnique as any);
});
test("should block login when rate limit exceeded", async () => {
vi.mocked(applyIPRateLimit).mockRejectedValue(
new Error("Maximum number of requests reached. Please try again later.")
);
const credentials = { email: mockUser.email, password: mockPassword };
await expect(credentialsProvider.options.authorize(credentials, {})).rejects.toThrow(
"Maximum number of requests reached. Please try again later."
);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
test("should use correct rate limit configuration", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUserId,
email: mockUser.email,
password: mockHashedPassword,
emailVerified: new Date(),
twoFactorEnabled: false,
} as any);
const credentials = { email: mockUser.email, password: mockPassword };
await credentialsProvider.options.authorize(credentials, {});
expect(applyIPRateLimit).toHaveBeenCalledWith({
interval: 900,
allowedPerInterval: 30,
namespace: "auth:login",
});
});
});
describe("Two-Factor Backup Code login", () => {
test("should throw error if backup codes are missing", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
const mockUser = {
id: mockUserId,
email: "2fa@example.com",
@@ -251,7 +172,6 @@ describe("authOptions", () => {
});
test("should throw error if token is invalid or user not found", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
const credentials = { token: "badtoken" };
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
@@ -260,7 +180,6 @@ describe("authOptions", () => {
});
test("should throw error if email is already verified", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.spyOn(prisma.user, "findUnique").mockResolvedValue(mockUser as any);
const credentials = { token: createToken(mockUser.id, mockUser.email) };
@@ -271,7 +190,6 @@ describe("authOptions", () => {
});
test("should update user and verify email when token is valid", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({ id: mockUser.id, emailVerified: null } as any);
vi.spyOn(prisma.user, "update").mockResolvedValue({
...mockUser,
@@ -288,70 +206,6 @@ describe("authOptions", () => {
expect(result.email).toBe(mockUser.email);
expect(result.emailVerified).toBeInstanceOf(Date);
});
describe("Rate Limiting", () => {
test("should apply rate limiting before token verification", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUser.id,
emailVerified: null,
} as any);
vi.spyOn(prisma.user, "update").mockResolvedValue({
...mockUser,
password: mockHashedPassword,
backupCodes: null,
twoFactorSecret: null,
identityProviderAccountId: null,
groupId: null,
} as any);
const credentials = { token: createToken(mockUserId, mockUser.email) };
await tokenProvider.options.authorize(credentials, {});
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.verifyEmail);
});
test("should block verification when rate limit exceeded", async () => {
vi.mocked(applyIPRateLimit).mockRejectedValue(
new Error("Maximum number of requests reached. Please try again later.")
);
const credentials = { token: createToken(mockUserId, mockUser.email) };
await expect(tokenProvider.options.authorize(credentials, {})).rejects.toThrow(
"Maximum number of requests reached. Please try again later."
);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
test("should use correct rate limit configuration", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.spyOn(prisma.user, "findUnique").mockResolvedValue({
id: mockUser.id,
emailVerified: null,
} as any);
vi.spyOn(prisma.user, "update").mockResolvedValue({
...mockUser,
password: mockHashedPassword,
backupCodes: null,
twoFactorSecret: null,
identityProviderAccountId: null,
groupId: null,
} as any);
const credentials = { token: createToken(mockUserId, mockUser.email) };
await tokenProvider.options.authorize(credentials, {});
expect(applyIPRateLimit).toHaveBeenCalledWith({
interval: 3600,
allowedPerInterval: 10,
namespace: "auth:verify",
});
});
});
});
describe("Callbacks", () => {
@@ -421,7 +275,6 @@ describe("authOptions", () => {
const credentialsProvider = getProviderById("credentials");
test("should throw error if TOTP code is missing when 2FA is enabled", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
const mockUser = {
id: mockUserId,
email: "2fa@example.com",
@@ -439,7 +292,6 @@ describe("authOptions", () => {
});
test("should throw error if two factor secret is missing", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue(); // Rate limiting passes
const mockUser = {
id: mockUserId,
email: "2fa@example.com",

View File

@@ -16,8 +16,6 @@ import {
shouldLogAuthFailure,
verifyPassword,
} from "@/modules/auth/lib/utils";
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import { getSSOProviders } from "@/modules/ee/sso/lib/providers";
import { handleSsoCallback } from "@/modules/ee/sso/lib/sso-handlers";
@@ -54,8 +52,6 @@ export const authOptions: NextAuthOptions = {
backupCode: { label: "Backup Code", type: "input", placeholder: "Two-factor backup code" },
},
async authorize(credentials, _req) {
await applyIPRateLimit(rateLimitConfigs.auth.login);
// Use email for rate limiting when available, fall back to "unknown_user" for credential validation
const identifier = credentials?.email || "unknown_user"; // NOSONAR // We want to check for empty strings
@@ -225,8 +221,6 @@ export const authOptions: NextAuthOptions = {
},
},
async authorize(credentials, _req) {
await applyIPRateLimit(rateLimitConfigs.auth.verifyEmail);
// For token verification, we can't rate limit effectively by token (single-use)
// So we use a generic identifier for token abuse attempts
const identifier = "email_verification_attempts";

View File

@@ -1,7 +1,7 @@
import { validateInputs } from "@/lib/utils/validate";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { createBrevoCustomer, deleteBrevoCustomerByEmail, updateBrevoCustomer } from "./brevo";
import { createBrevoCustomer, updateBrevoCustomer } from "./brevo";
vi.mock("@/lib/constants", () => ({
BREVO_API_KEY: "mock_api_key",
@@ -125,63 +125,3 @@ describe("updateBrevoCustomer", () => {
expect(validateInputs).toHaveBeenCalled();
});
});
describe("deleteBrevoCustomerByEmail", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("should return early if BREVO_API_KEY is not defined", async () => {
vi.doMock("@/lib/constants", () => ({
BREVO_API_KEY: undefined,
BREVO_LIST_ID: "123",
}));
const { deleteBrevoCustomerByEmail } = await import("./brevo"); // Re-import to get the mocked version
const result = await deleteBrevoCustomerByEmail({ email: "test@example.com" });
expect(result).toBeUndefined();
expect(global.fetch).not.toHaveBeenCalled();
expect(validateInputs).not.toHaveBeenCalled();
});
test("should log an error if fetch fails", async () => {
const loggerSpy = vi.spyOn(logger, "error");
vi.mocked(global.fetch).mockRejectedValueOnce(new Error("Fetch failed"));
await deleteBrevoCustomerByEmail({ email: "test@example.com" });
expect(loggerSpy).toHaveBeenCalledWith(expect.any(Error), "Error deleting user from Brevo");
});
test("should log the error response if fetch status is not 204", async () => {
const loggerSpy = vi.spyOn(logger, "error");
vi.mocked(global.fetch).mockResolvedValueOnce(
new global.Response("Bad Request", { status: 400, statusText: "Bad Request" })
);
await deleteBrevoCustomerByEmail({ email: "test@example.com" });
expect(loggerSpy).toHaveBeenCalledWith({ errorText: "Bad Request" }, "Error deleting user from Brevo");
});
test("should successfully delete a Brevo customer", async () => {
vi.mocked(global.fetch).mockResolvedValueOnce(new global.Response(null, { status: 204 }));
await deleteBrevoCustomerByEmail({ email: "test@example.com" });
expect(global.fetch).toHaveBeenCalledWith(
"https://api.brevo.com/v3/contacts/test%40example.com?identifierType=email_id",
expect.objectContaining({
method: "DELETE",
headers: {
Accept: "application/json",
"api-key": "mock_api_key",
},
})
);
});
});

View File

@@ -95,28 +95,3 @@ export const updateBrevoCustomer = async ({ id, email }: { id: string; email: TU
logger.error(error, "Error updating user in Brevo");
}
};
export const deleteBrevoCustomerByEmail = async ({ email }: { email: TUserEmail }) => {
if (!BREVO_API_KEY) {
return;
}
const encodedEmail = encodeURIComponent(email.toLowerCase());
try {
const res = await fetch(`https://api.brevo.com/v3/contacts/${encodedEmail}?identifierType=email_id`, {
method: "DELETE",
headers: {
Accept: "application/json",
"api-key": BREVO_API_KEY,
},
});
if (res.status !== 204) {
const errorText = await res.text();
logger.error({ errorText }, "Error deleting user from Brevo");
}
} catch (error) {
logger.error(error, "Error deleting user from Brevo");
}
};

View File

@@ -27,7 +27,6 @@ vi.mock("crypto", () => ({
digest: vi.fn(() => "a".repeat(32)), // Mock 64-char hex string
})),
})),
randomUUID: vi.fn(() => "test-uuid-123"),
}));
// Mock Sentry
@@ -43,12 +42,8 @@ vi.mock("@/lib/constants", () => ({
}));
// Mock Redis client
const { mockGetRedisClient } = vi.hoisted(() => ({
mockGetRedisClient: vi.fn(),
}));
vi.mock("@/modules/cache/redis", () => ({
getRedisClient: mockGetRedisClient,
default: null, // Intentionally simulate Redis unavailability to test fail-closed security behavior
}));
describe("Auth Utils", () => {
@@ -114,17 +109,11 @@ describe("Auth Utils", () => {
describe("Rate Limiting", () => {
test("should always allow successful authentication logging", async () => {
// This test doesn't need Redis to be available as it short-circuits for success
mockGetRedisClient.mockResolvedValue(null);
expect(await shouldLogAuthFailure("user@example.com", true)).toBe(true);
expect(await shouldLogAuthFailure("user@example.com", true)).toBe(true);
});
test("should implement fail-closed behavior when Redis is unavailable", async () => {
// Set Redis unavailable for this test
mockGetRedisClient.mockResolvedValue(null);
const email = "rate-limit-test@example.com";
// When Redis is unavailable (mocked as null), the system fails closed for security.
@@ -142,254 +131,6 @@ describe("Auth Utils", () => {
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 9th failure - blocked
expect(await shouldLogAuthFailure(email, false)).toBe(false); // 10th failure - blocked
});
describe("Redis Available - All Branch Coverage", () => {
let mockRedis: any;
let mockMulti: any;
beforeEach(() => {
// Clear mocks first
vi.clearAllMocks();
// Create comprehensive Redis mock
mockMulti = {
zRemRangeByScore: vi.fn().mockReturnThis(),
zCard: vi.fn().mockReturnThis(),
zAdd: vi.fn().mockReturnThis(),
expire: vi.fn().mockReturnThis(),
exec: vi.fn(),
};
mockRedis = {
multi: vi.fn().mockReturnValue(mockMulti),
zRange: vi.fn(),
isReady: true, // Add isReady property
};
// Reset the Redis mock for these specific tests
mockGetRedisClient.mockReset();
mockGetRedisClient.mockReturnValue(mockRedis); // Use mockReturnValue instead of mockResolvedValue
});
test("should handle Redis transaction failure - !results branch", async () => {
// Create fresh mock objects for this test
const testMockMulti = {
zRemRangeByScore: vi.fn().mockReturnThis(),
zCard: vi.fn().mockReturnThis(),
zAdd: vi.fn().mockReturnThis(),
expire: vi.fn().mockReturnThis(),
exec: vi.fn().mockResolvedValue(null), // Mock transaction returning null
};
const testMockRedis = {
multi: vi.fn().mockReturnValue(testMockMulti),
zRange: vi.fn(),
isReady: true,
};
// Reset and setup mock for this specific test
mockGetRedisClient.mockReset();
mockGetRedisClient.mockReturnValue(testMockRedis);
const email = "transaction-failure@example.com";
const result = await shouldLogAuthFailure(email, false);
// Function should return false when Redis transaction fails (fail-closed behavior)
expect(result).toBe(false);
expect(mockGetRedisClient).toHaveBeenCalled();
expect(testMockRedis.multi).toHaveBeenCalled();
expect(testMockMulti.zRemRangeByScore).toHaveBeenCalled();
expect(testMockMulti.zCard).toHaveBeenCalled();
expect(testMockMulti.zAdd).toHaveBeenCalled();
expect(testMockMulti.expire).toHaveBeenCalled();
expect(testMockMulti.exec).toHaveBeenCalled();
});
test("should allow logging when currentCount <= AGGREGATION_THRESHOLD", async () => {
// Mock Redis transaction returning count <= threshold (assuming threshold is 3)
mockMulti.exec.mockResolvedValue([
null, // zRemRangeByScore result
2, // zCard result - below threshold
null, // zAdd result
null, // expire result
]);
const email = "below-threshold@example.com";
const result = await shouldLogAuthFailure(email, false);
expect(result).toBe(true);
expect(mockMulti.exec).toHaveBeenCalled();
});
test("should allow logging when recentEntries.length === 0", async () => {
// Mock Redis transaction returning count above threshold
mockMulti.exec.mockResolvedValue([
null, // zRemRangeByScore result
5, // zCard result - above threshold
null, // zAdd result
null, // expire result
]);
// Mock zRange returning empty array
mockRedis.zRange.mockResolvedValue([]);
const email = "no-recent-entries@example.com";
const result = await shouldLogAuthFailure(email, false);
expect(result).toBe(true);
expect(mockRedis.zRange).toHaveBeenCalledWith(expect.stringContaining("rate_limit:auth:"), -10, -1);
});
test("should allow logging on every 10th attempt - currentCount % 10 === 0", async () => {
const now = Date.now();
// Mock Redis transaction returning count that is divisible by 10
mockMulti.exec.mockResolvedValue([
null, // zRemRangeByScore result
10, // zCard result - 10th attempt
null, // zAdd result
null, // expire result
]);
// Mock zRange returning recent entries
mockRedis.zRange.mockResolvedValue([
`${now - 30000}:uuid1`, // 30 seconds ago
]);
const email = "tenth-attempt@example.com";
const result = await shouldLogAuthFailure(email, false);
expect(result).toBe(true);
expect(mockRedis.zRange).toHaveBeenCalled();
});
test("should allow logging after 1 minute gap - timeSinceLastLog > 60000", async () => {
const now = Date.now();
// Mock Redis transaction returning count not divisible by 10
mockMulti.exec.mockResolvedValue([
null, // zRemRangeByScore result
7, // zCard result - 7th attempt (not divisible by 10)
null, // zAdd result
null, // expire result
]);
// Mock zRange returning entry older than 1 minute
mockRedis.zRange.mockResolvedValue([
`${now - 120000}:uuid1`, // 2 minutes ago
]);
const email = "one-minute-gap@example.com";
const result = await shouldLogAuthFailure(email, false);
expect(result).toBe(true);
expect(mockRedis.zRange).toHaveBeenCalled();
});
test("should block logging when neither condition is met", async () => {
const now = Date.now();
// Mock Redis transaction returning count not divisible by 10
mockMulti.exec.mockResolvedValue([
null, // zRemRangeByScore result
7, // zCard result - 7th attempt (not divisible by 10)
null, // zAdd result
null, // expire result
]);
// Mock zRange returning recent entry (less than 1 minute)
mockRedis.zRange.mockResolvedValue([
`${now - 30000}:uuid1`, // 30 seconds ago
]);
const email = "blocked-logging@example.com";
const result = await shouldLogAuthFailure(email, false);
expect(result).toBe(false);
expect(mockRedis.zRange).toHaveBeenCalled();
});
test("should handle Redis operation errors gracefully", async () => {
// Mock Redis multi throwing an error
mockMulti.exec.mockRejectedValue(new Error("Redis operation failed"));
const email = "redis-error@example.com";
const result = await shouldLogAuthFailure(email, false);
expect(result).toBe(false);
expect(mockMulti.exec).toHaveBeenCalled();
});
test("should handle zRange errors gracefully", async () => {
// Mock successful transaction but zRange failing
mockMulti.exec.mockResolvedValue([
null, // zRemRangeByScore result
5, // zCard result - above threshold
null, // zAdd result
null, // expire result
]);
mockRedis.zRange.mockRejectedValue(new Error("zRange failed"));
const email = "zrange-error@example.com";
const result = await shouldLogAuthFailure(email, false);
expect(result).toBe(false);
expect(mockRedis.zRange).toHaveBeenCalled();
});
test("should handle malformed timestamp in recent entries", async () => {
// Mock Redis transaction returning count not divisible by 10
mockMulti.exec.mockResolvedValue([
null, // zRemRangeByScore result
7, // zCard result - 7th attempt
null, // zAdd result
null, // expire result
]);
// Mock zRange returning entry with malformed timestamp
mockRedis.zRange.mockResolvedValue(["invalid-timestamp:uuid1"]);
const email = "malformed-timestamp@example.com";
const result = await shouldLogAuthFailure(email, false);
// Should handle parseInt(NaN) gracefully and still make a decision
expect(typeof result).toBe("boolean");
expect(mockRedis.zRange).toHaveBeenCalled();
});
test("should verify correct Redis key generation and operations", async () => {
mockMulti.exec.mockResolvedValue([
null, // zRemRangeByScore result
2, // zCard result - below threshold
null, // zAdd result
null, // expire result
]);
const email = "key-generation@example.com";
await shouldLogAuthFailure(email, false);
// Verify correct Redis operations were called
expect(mockRedis.multi).toHaveBeenCalled();
expect(mockMulti.zRemRangeByScore).toHaveBeenCalledWith(
expect.stringContaining("rate_limit:auth:"),
0,
expect.any(Number)
);
expect(mockMulti.zCard).toHaveBeenCalledWith(expect.stringContaining("rate_limit:auth:"));
expect(mockMulti.zAdd).toHaveBeenCalledWith(
expect.stringContaining("rate_limit:auth:"),
expect.objectContaining({
score: expect.any(Number),
value: expect.stringMatching(/^\d+:.+$/),
})
);
expect(mockMulti.expire).toHaveBeenCalledWith(
expect.stringContaining("rate_limit:auth:"),
expect.any(Number)
);
});
});
});
describe("Audit Logging Functions", () => {

View File

@@ -1,5 +1,5 @@
import { IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
import { getRedisClient } from "@/modules/cache/redis";
import redis from "@/modules/cache/redis";
import { queueAuditEventBackground } from "@/modules/ee/audit-logs/lib/handler";
import { TAuditAction, TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
import * as Sentry from "@sentry/nextjs";
@@ -228,47 +228,46 @@ export const shouldLogAuthFailure = async (
const rateLimitKey = `rate_limit:auth:${createAuditIdentifier(identifier, "ratelimit")}`;
const now = Date.now();
try {
// Get Redis client
const redis = getRedisClient();
if (!redis) {
logger.warn("Redis not available for rate limiting, not logging due to Redis requirement");
if (redis) {
try {
// Use Redis for distributed rate limiting
const multi = redis.multi();
const windowStart = now - RATE_LIMIT_WINDOW;
// Remove expired entries and count recent failures
multi.zremrangebyscore(rateLimitKey, 0, windowStart);
multi.zcard(rateLimitKey);
multi.zadd(rateLimitKey, now, `${now}:${randomUUID()}`);
multi.expire(rateLimitKey, Math.ceil(RATE_LIMIT_WINDOW / 1000));
const results = await multi.exec();
if (!results) {
throw new Error("Redis transaction failed");
}
const currentCount = results[1][1] as number;
// Apply throttling logic
if (currentCount <= AGGREGATION_THRESHOLD) {
return true;
}
// Check if we should log (every 10th or after 1 minute gap)
const recentEntries = await redis.zrange(rateLimitKey, -10, -1);
if (recentEntries.length === 0) return true;
const lastLogTime = parseInt(recentEntries[recentEntries.length - 1].split(":")[0]);
const timeSinceLastLog = now - lastLogTime;
return currentCount % 10 === 0 || timeSinceLastLog > 60000;
} catch (error) {
logger.warn("Redis rate limiting failed, not logging due to Redis requirement", { error });
// If Redis fails, do not log as Redis is required for audit logs
return false;
}
// Use Redis for distributed rate limiting
const multi = redis.multi();
const windowStart = now - RATE_LIMIT_WINDOW;
// Remove expired entries and count recent failures
multi.zRemRangeByScore(rateLimitKey, 0, windowStart);
multi.zCard(rateLimitKey);
multi.zAdd(rateLimitKey, { score: now, value: `${now}:${randomUUID()}` });
multi.expire(rateLimitKey, Math.ceil(RATE_LIMIT_WINDOW / 1000));
const results = await multi.exec();
if (!results) {
throw new Error("Redis transaction failed");
}
const currentCount = results[1] as number;
// Apply throttling logic
if (currentCount <= AGGREGATION_THRESHOLD) {
return true;
}
// Check if we should log (every 10th or after 1 minute gap)
const recentEntries = await redis.zRange(rateLimitKey, -10, -1);
if (recentEntries.length === 0) return true;
const lastLogTime = Number.parseInt(recentEntries[recentEntries.length - 1].split(":")[0]);
const timeSinceLastLog = now - lastLogTime;
return currentCount % 10 === 0 || timeSinceLastLog > 60000;
} catch (error) {
logger.warn("Redis rate limiting failed, not logging due to Redis requirement", { error });
// If Redis fails, do not log as Redis is required for audit logs
} else {
logger.warn("Redis not available for rate limiting, not logging due to Redis requirement");
// If Redis not configured, do not log as Redis is required for audit logs
return false;
}
};

View File

@@ -11,8 +11,6 @@ import { createUser, updateUser } from "@/modules/auth/lib/user";
import { deleteInvite, getInvite } from "@/modules/auth/signup/lib/invite";
import { createTeamMembership } from "@/modules/auth/signup/lib/team";
import { captureFailedSignup, verifyTurnstileToken } from "@/modules/auth/signup/lib/utils";
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { getIsMultiOrgEnabled } from "@/modules/ee/license-check/lib/utils";
import { sendInviteAcceptedEmail, sendVerificationEmail } from "@/modules/email";
@@ -179,7 +177,6 @@ export const createUserAction = actionClient.schema(ZCreateUserAction).action(
"created",
"user",
async ({ ctx, parsedInput }: { ctx: ActionClientCtx; parsedInput: Record<string, any> }) => {
await applyIPRateLimit(rateLimitConfigs.auth.signup);
await verifyTurnstileIfConfigured(parsedInput.turnstileToken, parsedInput.email, parsedInput.name);
const hashedPassword = await hashPassword(parsedInput.password);

View File

@@ -1,344 +0,0 @@
import { getUserByEmail } from "@/modules/auth/lib/user";
// Import mocked functions
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendVerificationEmail } from "@/modules/email";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { resendVerificationEmailAction } from "./actions";
// Mock dependencies
vi.mock("@/modules/core/rate-limit/helpers", () => ({
applyIPRateLimit: vi.fn(),
}));
vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
rateLimitConfigs: {
auth: {
verifyEmail: { interval: 3600, allowedPerInterval: 10, namespace: "auth:verify" },
},
},
}));
vi.mock("@/modules/auth/lib/user", () => ({
getUserByEmail: vi.fn(),
}));
vi.mock("@/modules/email", () => ({
sendVerificationEmail: vi.fn(),
}));
vi.mock("@/modules/ee/audit-logs/lib/handler", () => ({
withAuditLogging: vi.fn((type, object, fn) => fn),
}));
vi.mock("@/lib/utils/action-client", () => ({
actionClient: {
schema: vi.fn().mockReturnThis(),
action: vi.fn((fn) => fn),
},
}));
describe("resendVerificationEmailAction", () => {
const validInput = {
email: "test@example.com",
};
const mockUser = {
id: "user123",
email: "test@example.com",
emailVerified: null, // Not verified
name: "Test User",
};
const mockVerifiedUser = {
id: "user123",
email: "test@example.com",
emailVerified: new Date(),
name: "Test User",
};
const mockCtx = {
auditLoggingCtx: {
organizationId: "",
userId: "",
},
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe("Rate Limiting", () => {
test("should apply rate limiting before processing verification email resend", async () => {
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
await resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: validInput,
} as any);
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.verifyEmail);
expect(applyIPRateLimit).toHaveBeenCalledBefore(getUserByEmail as any);
});
test("should throw rate limit error when limit exceeded", async () => {
vi.mocked(applyIPRateLimit).mockRejectedValue(
new Error("Maximum number of requests reached. Please try again later.")
);
await expect(
resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: validInput,
} as any)
).rejects.toThrow("Maximum number of requests reached. Please try again later.");
expect(getUserByEmail).not.toHaveBeenCalled();
expect(sendVerificationEmail).not.toHaveBeenCalled();
});
test("should use correct rate limit configuration", async () => {
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
await resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: validInput,
} as any);
expect(applyIPRateLimit).toHaveBeenCalledWith({
interval: 3600,
allowedPerInterval: 10,
namespace: "auth:verify",
});
});
test("should apply rate limiting even when user doesn't exist", async () => {
vi.mocked(getUserByEmail).mockResolvedValue(null);
await expect(
resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: validInput,
} as any)
).rejects.toThrow(ResourceNotFoundError);
expect(applyIPRateLimit).toHaveBeenCalledWith(rateLimitConfigs.auth.verifyEmail);
});
});
describe("Verification Email Resend Flow", () => {
test("should send verification email when user exists and email is not verified", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
const result = await resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: validInput,
} as any);
expect(applyIPRateLimit).toHaveBeenCalled();
expect(getUserByEmail).toHaveBeenCalledWith(validInput.email);
expect(sendVerificationEmail).toHaveBeenCalledWith(mockUser);
expect(result).toEqual({ success: true });
});
test("should return success without sending email when user email is already verified", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(mockVerifiedUser as any);
const result = await resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: validInput,
} as any);
expect(applyIPRateLimit).toHaveBeenCalled();
expect(getUserByEmail).toHaveBeenCalledWith(validInput.email);
expect(sendVerificationEmail).not.toHaveBeenCalled();
expect(result).toEqual({ success: true });
});
test("should throw ResourceNotFoundError when user doesn't exist", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(null);
await expect(
resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: validInput,
} as any)
).rejects.toThrow(ResourceNotFoundError);
expect(applyIPRateLimit).toHaveBeenCalled();
expect(getUserByEmail).toHaveBeenCalledWith(validInput.email);
expect(sendVerificationEmail).not.toHaveBeenCalled();
});
});
describe("Audit Logging", () => {
test("should be wrapped with audit logging decorator", () => {
// withAuditLogging is called at module load time to wrap the action
// We just verify the mock was set up correctly
expect(withAuditLogging).toBeDefined();
});
test("should set audit context userId when sending verification email", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
const testCtx = {
auditLoggingCtx: {
organizationId: "",
userId: "",
},
};
await resendVerificationEmailAction({
ctx: testCtx,
parsedInput: validInput,
} as any);
// The userId should be set in the audit context
expect(testCtx.auditLoggingCtx.userId).toBe(mockUser.id);
});
test("should not set audit context userId when email is already verified", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(mockVerifiedUser as any);
const testCtx = {
auditLoggingCtx: {
organizationId: "",
userId: "",
},
};
await resendVerificationEmailAction({
ctx: testCtx,
parsedInput: validInput,
} as any);
// The userId should not be set since no email was sent
expect(testCtx.auditLoggingCtx.userId).toBe("");
});
});
describe("Error Handling", () => {
test("should propagate rate limiting errors", async () => {
const rateLimitError = new Error("Maximum number of requests reached. Please try again later.");
vi.mocked(applyIPRateLimit).mockRejectedValue(rateLimitError);
await expect(
resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: validInput,
} as any)
).rejects.toThrow("Maximum number of requests reached. Please try again later.");
});
test("should handle user lookup errors after rate limiting", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockRejectedValue(new Error("Database error"));
await expect(
resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: validInput,
} as any)
).rejects.toThrow("Database error");
expect(applyIPRateLimit).toHaveBeenCalled();
});
test("should handle email sending errors after rate limiting", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(mockUser as any);
vi.mocked(sendVerificationEmail).mockRejectedValue(new Error("Email service error"));
await expect(
resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: validInput,
} as any)
).rejects.toThrow("Email service error");
expect(applyIPRateLimit).toHaveBeenCalled();
expect(getUserByEmail).toHaveBeenCalled();
});
});
describe("Input Validation", () => {
test("should handle empty email input", async () => {
const invalidInput = { email: "" };
// This would be caught by the Zod schema validation in the actual action
// but we test the behavior if it somehow gets through
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(null);
await expect(
resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: invalidInput,
} as any)
).rejects.toThrow(ResourceNotFoundError);
});
test("should handle malformed email input", async () => {
const invalidInput = { email: "invalid-email" };
// This would be caught by the Zod schema validation in the actual action
// but we test the behavior if it somehow gets through
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(null);
await expect(
resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: invalidInput,
} as any)
).rejects.toThrow(ResourceNotFoundError);
});
});
describe("Security Considerations", () => {
test("should always apply rate limiting regardless of user existence", async () => {
vi.mocked(getUserByEmail).mockResolvedValue(null);
await expect(
resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: validInput,
} as any)
).rejects.toThrow(ResourceNotFoundError);
expect(applyIPRateLimit).toHaveBeenCalled();
});
test("should not leak information about user existence through different error messages", async () => {
vi.mocked(applyIPRateLimit).mockResolvedValue();
vi.mocked(getUserByEmail).mockResolvedValue(null);
// Both non-existent users should throw the same ResourceNotFoundError
await expect(
resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: validInput,
} as any)
).rejects.toThrow(ResourceNotFoundError);
const anotherEmail = { email: "another@example.com" };
await expect(
resendVerificationEmailAction({
ctx: mockCtx,
parsedInput: anotherEmail,
} as any)
).rejects.toThrow(ResourceNotFoundError);
});
});
});

View File

@@ -3,8 +3,6 @@
import { actionClient } from "@/lib/utils/action-client";
import { ActionClientCtx } from "@/lib/utils/action-client/types/context";
import { getUserByEmail } from "@/modules/auth/lib/user";
import { applyIPRateLimit } from "@/modules/core/rate-limit/helpers";
import { rateLimitConfigs } from "@/modules/core/rate-limit/rate-limit-configs";
import { withAuditLogging } from "@/modules/ee/audit-logs/lib/handler";
import { sendVerificationEmail } from "@/modules/email";
import { z } from "zod";
@@ -20,8 +18,6 @@ export const resendVerificationEmailAction = actionClient.schema(ZResendVerifica
"verificationEmailSent",
"user",
async ({ ctx, parsedInput }: { ctx: ActionClientCtx; parsedInput: Record<string, any> }) => {
await applyIPRateLimit(rateLimitConfigs.auth.verifyEmail);
const user = await getUserByEmail(parsedInput.email);
if (!user) {
throw new ResourceNotFoundError("user", parsedInput.email);

View File

@@ -1,381 +0,0 @@
import { describe, expect, test } from "vitest";
import { createCacheKey, parseCacheKey, validateCacheKey } from "./cacheKeys";
describe("cacheKeys", () => {
describe("createCacheKey", () => {
describe("environment keys", () => {
test("should create environment state key", () => {
const key = createCacheKey.environment.state("env123");
expect(key).toBe("fb:env:env123:state");
});
test("should create environment surveys key", () => {
const key = createCacheKey.environment.surveys("env456");
expect(key).toBe("fb:env:env456:surveys");
});
test("should create environment actionClasses key", () => {
const key = createCacheKey.environment.actionClasses("env789");
expect(key).toBe("fb:env:env789:action_classes");
});
test("should create environment config key", () => {
const key = createCacheKey.environment.config("env101");
expect(key).toBe("fb:env:env101:config");
});
test("should create environment segments key", () => {
const key = createCacheKey.environment.segments("env202");
expect(key).toBe("fb:env:env202:segments");
});
});
describe("organization keys", () => {
test("should create organization billing key", () => {
const key = createCacheKey.organization.billing("org123");
expect(key).toBe("fb:org:org123:billing");
});
test("should create organization environments key", () => {
const key = createCacheKey.organization.environments("org456");
expect(key).toBe("fb:org:org456:environments");
});
test("should create organization config key", () => {
const key = createCacheKey.organization.config("org789");
expect(key).toBe("fb:org:org789:config");
});
test("should create organization limits key", () => {
const key = createCacheKey.organization.limits("org101");
expect(key).toBe("fb:org:org101:limits");
});
});
describe("license keys", () => {
test("should create license status key", () => {
const key = createCacheKey.license.status("org123");
expect(key).toBe("fb:license:org123:status");
});
test("should create license features key", () => {
const key = createCacheKey.license.features("org456");
expect(key).toBe("fb:license:org456:features");
});
test("should create license usage key", () => {
const key = createCacheKey.license.usage("org789");
expect(key).toBe("fb:license:org789:usage");
});
test("should create license check key", () => {
const key = createCacheKey.license.check("org123", "feature-x");
expect(key).toBe("fb:license:org123:check:feature-x");
});
test("should create license previous_result key", () => {
const key = createCacheKey.license.previous_result("org456");
expect(key).toBe("fb:license:org456:previous_result");
});
});
describe("user keys", () => {
test("should create user profile key", () => {
const key = createCacheKey.user.profile("user123");
expect(key).toBe("fb:user:user123:profile");
});
test("should create user preferences key", () => {
const key = createCacheKey.user.preferences("user456");
expect(key).toBe("fb:user:user456:preferences");
});
test("should create user organizations key", () => {
const key = createCacheKey.user.organizations("user789");
expect(key).toBe("fb:user:user789:organizations");
});
test("should create user permissions key", () => {
const key = createCacheKey.user.permissions("user123", "org456");
expect(key).toBe("fb:user:user123:org:org456:permissions");
});
});
describe("project keys", () => {
test("should create project config key", () => {
const key = createCacheKey.project.config("proj123");
expect(key).toBe("fb:project:proj123:config");
});
test("should create project environments key", () => {
const key = createCacheKey.project.environments("proj456");
expect(key).toBe("fb:project:proj456:environments");
});
test("should create project surveys key", () => {
const key = createCacheKey.project.surveys("proj789");
expect(key).toBe("fb:project:proj789:surveys");
});
});
describe("survey keys", () => {
test("should create survey metadata key", () => {
const key = createCacheKey.survey.metadata("survey123");
expect(key).toBe("fb:survey:survey123:metadata");
});
test("should create survey responses key", () => {
const key = createCacheKey.survey.responses("survey456");
expect(key).toBe("fb:survey:survey456:responses");
});
test("should create survey stats key", () => {
const key = createCacheKey.survey.stats("survey789");
expect(key).toBe("fb:survey:survey789:stats");
});
});
describe("session keys", () => {
test("should create session data key", () => {
const key = createCacheKey.session.data("session123");
expect(key).toBe("fb:session:session123:data");
});
test("should create session permissions key", () => {
const key = createCacheKey.session.permissions("session456");
expect(key).toBe("fb:session:session456:permissions");
});
});
describe("rate limit keys", () => {
test("should create rate limit api key", () => {
const key = createCacheKey.rateLimit.api("api-key-123", "endpoint-v1");
expect(key).toBe("fb:rate_limit:api:api-key-123:endpoint-v1");
});
test("should create rate limit login key", () => {
const key = createCacheKey.rateLimit.login("user-ip-hash");
expect(key).toBe("fb:rate_limit:login:user-ip-hash");
});
test("should create rate limit core key", () => {
const key = createCacheKey.rateLimit.core("auth:login", "user123", 1703174400);
expect(key).toBe("fb:rate_limit:auth:login:user123:1703174400");
});
});
describe("custom keys", () => {
test("should create custom key without subResource", () => {
const key = createCacheKey.custom("temp", "identifier123");
expect(key).toBe("fb:temp:identifier123");
});
test("should create custom key with subResource", () => {
const key = createCacheKey.custom("analytics", "user456", "daily-stats");
expect(key).toBe("fb:analytics:user456:daily-stats");
});
test("should work with all valid namespaces", () => {
const validNamespaces = ["temp", "analytics", "webhook", "integration", "backup"];
validNamespaces.forEach((namespace) => {
const key = createCacheKey.custom(namespace, "test-id");
expect(key).toBe(`fb:${namespace}:test-id`);
});
});
test("should throw error for invalid namespace", () => {
expect(() => createCacheKey.custom("invalid", "identifier")).toThrow(
"Invalid cache namespace: invalid. Use: temp, analytics, webhook, integration, backup"
);
});
test("should throw error for empty namespace", () => {
expect(() => createCacheKey.custom("", "identifier")).toThrow(
"Invalid cache namespace: . Use: temp, analytics, webhook, integration, backup"
);
});
});
});
describe("validateCacheKey", () => {
test("should validate correct cache keys", () => {
const validKeys = [
"fb:env:env123:state",
"fb:user:user456:profile",
"fb:org:org789:billing",
"fb:rate_limit:api:key123:endpoint",
"fb:custom:namespace:identifier:sub:resource",
];
validKeys.forEach((key) => {
expect(validateCacheKey(key)).toBe(true);
});
});
test("should reject keys without fb prefix", () => {
const invalidKeys = ["env:env123:state", "user:user456:profile", "redis:key:value", "cache:item:data"];
invalidKeys.forEach((key) => {
expect(validateCacheKey(key)).toBe(false);
});
});
test("should reject keys with insufficient parts", () => {
const invalidKeys = ["fb:", "fb:env", "fb:env:", "fb:user:user123:"];
invalidKeys.forEach((key) => {
expect(validateCacheKey(key)).toBe(false);
});
});
test("should reject keys with empty parts", () => {
const invalidKeys = ["fb::env123:state", "fb:env::state", "fb:env:env123:", "fb:user::profile"];
invalidKeys.forEach((key) => {
expect(validateCacheKey(key)).toBe(false);
});
});
test("should validate minimum valid key", () => {
expect(validateCacheKey("fb:a:b")).toBe(true);
});
});
describe("parseCacheKey", () => {
test("should parse basic cache key", () => {
const result = parseCacheKey("fb:env:env123:state");
expect(result).toEqual({
prefix: "fb",
resource: "env",
identifier: "env123",
subResource: "state",
full: "fb:env:env123:state",
});
});
test("should parse key without subResource", () => {
const result = parseCacheKey("fb:user:user123");
expect(result).toEqual({
prefix: "fb",
resource: "user",
identifier: "user123",
subResource: undefined,
full: "fb:user:user123",
});
});
test("should parse key with multiple subResource parts", () => {
const result = parseCacheKey("fb:user:user123:org:org456:permissions");
expect(result).toEqual({
prefix: "fb",
resource: "user",
identifier: "user123",
subResource: "org:org456:permissions",
full: "fb:user:user123:org:org456:permissions",
});
});
test("should parse rate limit key with timestamp", () => {
const result = parseCacheKey("fb:rate_limit:auth:login:user123:1703174400");
expect(result).toEqual({
prefix: "fb",
resource: "rate_limit",
identifier: "auth",
subResource: "login:user123:1703174400",
full: "fb:rate_limit:auth:login:user123:1703174400",
});
});
test("should throw error for invalid cache key", () => {
const invalidKeys = ["invalid:key:format", "fb:env", "fb::env123:state", "redis:user:profile"];
invalidKeys.forEach((key) => {
expect(() => parseCacheKey(key)).toThrow(`Invalid cache key format: ${key}`);
});
});
});
describe("cache key patterns and consistency", () => {
test("all environment keys should follow same pattern", () => {
const envId = "test-env-123";
const envKeys = [
createCacheKey.environment.state(envId),
createCacheKey.environment.surveys(envId),
createCacheKey.environment.actionClasses(envId),
createCacheKey.environment.config(envId),
createCacheKey.environment.segments(envId),
];
envKeys.forEach((key) => {
expect(key).toMatch(/^fb:env:test-env-123:.+$/);
expect(validateCacheKey(key)).toBe(true);
});
});
test("all organization keys should follow same pattern", () => {
const orgId = "test-org-456";
const orgKeys = [
createCacheKey.organization.billing(orgId),
createCacheKey.organization.environments(orgId),
createCacheKey.organization.config(orgId),
createCacheKey.organization.limits(orgId),
];
orgKeys.forEach((key) => {
expect(key).toMatch(/^fb:org:test-org-456:.+$/);
expect(validateCacheKey(key)).toBe(true);
});
});
test("all generated keys should be parseable", () => {
const testKeys = [
createCacheKey.environment.state("env123"),
createCacheKey.user.profile("user456"),
createCacheKey.organization.billing("org789"),
createCacheKey.survey.metadata("survey101"),
createCacheKey.session.data("session202"),
createCacheKey.rateLimit.core("auth:login", "user303", 1703174400),
createCacheKey.custom("temp", "temp404", "cleanup"),
];
testKeys.forEach((key) => {
expect(() => parseCacheKey(key)).not.toThrow();
const parsed = parseCacheKey(key);
expect(parsed.prefix).toBe("fb");
expect(parsed.full).toBe(key);
expect(parsed.resource).toBeTruthy();
expect(parsed.identifier).toBeTruthy();
});
});
test("keys should be unique across different resources", () => {
const keys = [
createCacheKey.environment.state("same-id"),
createCacheKey.user.profile("same-id"),
createCacheKey.organization.billing("same-id"),
createCacheKey.project.config("same-id"),
createCacheKey.survey.metadata("same-id"),
];
const uniqueKeys = new Set(keys);
expect(uniqueKeys.size).toBe(keys.length);
});
test("namespace validation should prevent collisions", () => {
// These should not throw (valid namespaces)
expect(() => createCacheKey.custom("temp", "id")).not.toThrow();
expect(() => createCacheKey.custom("analytics", "id")).not.toThrow();
// These should throw (reserved/invalid namespaces)
expect(() => createCacheKey.custom("env", "id")).toThrow();
expect(() => createCacheKey.custom("user", "id")).toThrow();
expect(() => createCacheKey.custom("org", "id")).toThrow();
});
});
});

View File

@@ -11,7 +11,6 @@ import "server-only";
* - Predictable invalidation patterns
* - Multi-tenant safe
*/
export const createCacheKey = {
// Environment-related keys
environment: {
@@ -72,8 +71,6 @@ export const createCacheKey = {
rateLimit: {
api: (identifier: string, endpoint: string) => `fb:rate_limit:api:${identifier}:${endpoint}`,
login: (identifier: string) => `fb:rate_limit:login:${identifier}`,
core: (namespace: string, identifier: string, windowStart: number) =>
`fb:rate_limit:${namespace}:${identifier}:${windowStart}`,
},
// Custom keys with validation

View File

@@ -1,261 +0,0 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
// Mock the logger
vi.mock("@formbricks/logger", () => ({
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
// Mock the redis client
const mockRedisClient = {
connect: vi.fn(),
disconnect: vi.fn(),
on: vi.fn(),
isReady: true,
get: vi.fn(),
set: vi.fn(),
del: vi.fn(),
exists: vi.fn(),
expire: vi.fn(),
ttl: vi.fn(),
keys: vi.fn(),
flushall: vi.fn(),
};
vi.mock("redis", () => ({
createClient: vi.fn(() => mockRedisClient),
}));
// Mock crypto for UUID generation
vi.mock("crypto", () => ({
randomUUID: vi.fn(() => "test-uuid-123"),
}));
describe("Redis module", () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset environment variable
process.env.REDIS_URL = "redis://localhost:6379";
// Reset isReady state
mockRedisClient.isReady = true;
// Make connect resolve successfully
mockRedisClient.connect.mockResolvedValue(undefined);
});
afterEach(() => {
vi.resetModules();
process.env.REDIS_URL = undefined;
});
describe("Module initialization", () => {
test("should create Redis client when REDIS_URL is set", async () => {
const { createClient } = await import("redis");
// Re-import the module to trigger initialization
await import("./redis");
expect(createClient).toHaveBeenCalledWith({
url: "redis://localhost:6379",
socket: {
reconnectStrategy: expect.any(Function),
},
});
});
test("should not create Redis client when REDIS_URL is not set", async () => {
delete process.env.REDIS_URL;
const { createClient } = await import("redis");
// Clear the module cache and re-import
vi.resetModules();
await import("./redis");
expect(createClient).not.toHaveBeenCalled();
});
test("should set up event listeners", async () => {
// Re-import the module to trigger initialization
await import("./redis");
expect(mockRedisClient.on).toHaveBeenCalledWith("error", expect.any(Function));
expect(mockRedisClient.on).toHaveBeenCalledWith("connect", expect.any(Function));
expect(mockRedisClient.on).toHaveBeenCalledWith("reconnecting", expect.any(Function));
expect(mockRedisClient.on).toHaveBeenCalledWith("ready", expect.any(Function));
});
test("should attempt initial connection", async () => {
// Re-import the module to trigger initialization
await import("./redis");
expect(mockRedisClient.connect).toHaveBeenCalled();
});
});
describe("getRedisClient", () => {
test("should return client when ready", async () => {
mockRedisClient.isReady = true;
const { getRedisClient } = await import("./redis");
const client = getRedisClient();
expect(client).toBe(mockRedisClient);
});
test("should return null when client is not ready", async () => {
mockRedisClient.isReady = false;
const { getRedisClient } = await import("./redis");
const client = getRedisClient();
expect(client).toBeNull();
});
test("should return null when no REDIS_URL is set", async () => {
delete process.env.REDIS_URL;
vi.resetModules();
const { getRedisClient } = await import("./redis");
const client = getRedisClient();
expect(client).toBeNull();
});
});
describe("disconnectRedis", () => {
test("should disconnect the client", async () => {
const { disconnectRedis } = await import("./redis");
await disconnectRedis();
expect(mockRedisClient.disconnect).toHaveBeenCalled();
});
test("should handle case when client is null", async () => {
delete process.env.REDIS_URL;
vi.resetModules();
const { disconnectRedis } = await import("./redis");
await expect(disconnectRedis()).resolves.toBeUndefined();
});
});
describe("Reconnection strategy", () => {
test("should configure reconnection strategy properly", async () => {
const { createClient } = await import("redis");
// Re-import the module to trigger initialization
await import("./redis");
const createClientCall = vi.mocked(createClient).mock.calls[0];
const config = createClientCall[0] as any;
expect(config.socket.reconnectStrategy).toBeDefined();
expect(typeof config.socket.reconnectStrategy).toBe("function");
});
});
describe("Event handlers", () => {
test("should log error events", async () => {
const { logger } = await import("@formbricks/logger");
// Re-import the module to trigger initialization
await import("./redis");
// Find the error event handler
const errorCall = vi.mocked(mockRedisClient.on).mock.calls.find((call) => call[0] === "error");
const errorHandler = errorCall?.[1];
const testError = new Error("Test error");
errorHandler?.(testError);
expect(logger.error).toHaveBeenCalledWith("Redis client error:", testError);
});
test("should log connect events", async () => {
const { logger } = await import("@formbricks/logger");
// Re-import the module to trigger initialization
await import("./redis");
// Find the connect event handler
const connectCall = vi.mocked(mockRedisClient.on).mock.calls.find((call) => call[0] === "connect");
const connectHandler = connectCall?.[1];
connectHandler?.();
expect(logger.info).toHaveBeenCalledWith("Redis client connected");
});
test("should log reconnecting events", async () => {
const { logger } = await import("@formbricks/logger");
// Re-import the module to trigger initialization
await import("./redis");
// Find the reconnecting event handler
const reconnectingCall = vi
.mocked(mockRedisClient.on)
.mock.calls.find((call) => call[0] === "reconnecting");
const reconnectingHandler = reconnectingCall?.[1];
reconnectingHandler?.();
expect(logger.info).toHaveBeenCalledWith("Redis client reconnecting");
});
test("should log ready events", async () => {
const { logger } = await import("@formbricks/logger");
// Re-import the module to trigger initialization
await import("./redis");
// Find the ready event handler
const readyCall = vi.mocked(mockRedisClient.on).mock.calls.find((call) => call[0] === "ready");
const readyHandler = readyCall?.[1];
readyHandler?.();
expect(logger.info).toHaveBeenCalledWith("Redis client ready");
});
test("should log end events", async () => {
const { logger } = await import("@formbricks/logger");
// Re-import the module to trigger initialization
await import("./redis");
// Find the end event handler
const endCall = vi.mocked(mockRedisClient.on).mock.calls.find((call) => call[0] === "end");
const endHandler = endCall?.[1];
endHandler?.();
expect(logger.info).toHaveBeenCalledWith("Redis client disconnected");
});
});
describe("Connection failure handling", () => {
test("should handle initial connection failure", async () => {
const { logger } = await import("@formbricks/logger");
const connectionError = new Error("Connection failed");
mockRedisClient.connect.mockRejectedValue(connectionError);
vi.resetModules();
await import("./redis");
// Wait for the connection promise to resolve
await new Promise((resolve) => setTimeout(resolve, 0));
expect(logger.error).toHaveBeenCalledWith("Initial Redis connection failed:", connectionError);
});
});
});

View File

@@ -1,69 +1,11 @@
import { createClient } from "redis";
import { REDIS_URL } from "@/lib/constants";
import Redis from "ioredis";
import { logger } from "@formbricks/logger";
type RedisClient = ReturnType<typeof createClient>;
const redis = REDIS_URL ? new Redis(REDIS_URL) : null;
const REDIS_URL = process.env.REDIS_URL;
let client: RedisClient | null = null;
if (REDIS_URL) {
client = createClient({
url: REDIS_URL,
socket: {
reconnectStrategy: (retries) => {
logger.info(`Redis reconnection attempt ${retries}`);
// For the first 5 attempts, use exponential backoff with max 5 second delay
if (retries <= 5) {
return Math.min(retries * 1000, 5000);
}
// After 5 attempts, use a longer delay but never give up
// This ensures the client keeps trying to reconnect when Redis comes back online
logger.info("Redis reconnection using extended delay (30 seconds)");
return 30000; // 30 second delay for persistent reconnection attempts
},
},
});
client.on("error", (err) => {
logger.error("Redis client error:", err);
});
client.on("connect", () => {
logger.info("Redis client connected");
});
client.on("reconnecting", () => {
logger.info("Redis client reconnecting");
});
client.on("ready", () => {
logger.info("Redis client ready");
});
client.on("end", () => {
logger.info("Redis client disconnected");
});
// Connect immediately
client.connect().catch((err) => {
logger.error("Initial Redis connection failed:", err);
});
if (!redis) {
logger.info("REDIS_URL is not set");
}
export const getRedisClient = (): RedisClient | null => {
if (!client?.isReady) {
logger.warn("Redis client not ready, operations will be skipped");
return null;
}
return client;
};
export const disconnectRedis = async (): Promise<void> => {
if (client) {
await client.disconnect();
client = null;
}
};
export default redis;

View File

@@ -1,199 +0,0 @@
import { hashString } from "@/lib/hash-string";
// Import modules after mocking
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { err, ok } from "@formbricks/types/error-handlers";
import { applyIPRateLimit, applyRateLimit, getClientIdentifier } from "./helpers";
import { checkRateLimit } from "./rate-limit";
// Mock all dependencies
vi.mock("@/lib/utils/client-ip", () => ({
getClientIpFromHeaders: vi.fn(),
}));
vi.mock("@/lib/hash-string", () => ({
hashString: vi.fn(),
}));
vi.mock("./rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
describe("helpers", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("getClientIdentifier", () => {
test("should get client IP and return hashed identifier", async () => {
const mockIp = "192.168.1.1";
const mockHashedIp = "abc123hashedip";
(getClientIpFromHeaders as any).mockResolvedValue(mockIp);
(hashString as any).mockReturnValue(mockHashedIp);
const result = await getClientIdentifier();
expect(getClientIpFromHeaders).toHaveBeenCalledOnce();
expect(hashString).toHaveBeenCalledWith(mockIp);
expect(result).toBe(mockHashedIp);
// Verify no error was logged on successful hashing
expect(logger.error).not.toHaveBeenCalled();
});
test("should handle IP retrieval errors", async () => {
const mockError = new Error("Failed to get IP");
(getClientIpFromHeaders as any).mockRejectedValue(mockError);
await expect(getClientIdentifier()).rejects.toThrow("Failed to get IP");
});
test("should handle hashing errors with proper logging", async () => {
const mockIp = "192.168.1.1";
const originalError = new Error("Hashing failed");
(getClientIpFromHeaders as any).mockResolvedValue(mockIp);
(hashString as any).mockImplementation(() => {
throw originalError;
});
await expect(getClientIdentifier()).rejects.toThrow("Failed to hash IP");
// Verify that the error was logged with proper context
expect(logger.error).toHaveBeenCalledWith("Failed to hash IP", { error: originalError });
});
});
describe("applyRateLimit", () => {
const mockConfig = {
interval: 300,
allowedPerInterval: 5,
namespace: "test",
};
const mockIdentifier = "test-identifier";
test("should allow request when rate limit check passes", async () => {
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
await expect(applyRateLimit(mockConfig, mockIdentifier)).resolves.toBeUndefined();
expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, mockIdentifier);
});
test("should throw error when rate limit is exceeded", async () => {
(checkRateLimit as any).mockResolvedValue(ok({ allowed: false }));
await expect(applyRateLimit(mockConfig, mockIdentifier)).rejects.toThrow(
"Maximum number of requests reached. Please try again later."
);
expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, mockIdentifier);
});
test("should throw error when rate limit check fails", async () => {
(checkRateLimit as any).mockResolvedValue(err("Redis connection failed"));
await expect(applyRateLimit(mockConfig, mockIdentifier)).rejects.toThrow(
"Maximum number of requests reached. Please try again later."
);
expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, mockIdentifier);
});
test("should throw error when rate limit check throws exception", async () => {
const mockError = new Error("Unexpected error");
(checkRateLimit as any).mockRejectedValue(mockError);
await expect(applyRateLimit(mockConfig, mockIdentifier)).rejects.toThrow("Unexpected error");
expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, mockIdentifier);
});
test("should work with different configurations", async () => {
const customConfig = {
interval: 3600,
allowedPerInterval: 100,
namespace: "api:v1",
};
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
await expect(applyRateLimit(customConfig, "api-key-identifier")).resolves.toBeUndefined();
expect(checkRateLimit).toHaveBeenCalledWith(customConfig, "api-key-identifier");
});
test("should work with different identifiers", async () => {
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
const identifiers = ["user-123", "ip-192.168.1.1", "auth-login-hashedip", "api-key-abc123"];
for (const identifier of identifiers) {
await expect(applyRateLimit(mockConfig, identifier)).resolves.toBeUndefined();
expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, identifier);
}
expect(checkRateLimit).toHaveBeenCalledTimes(identifiers.length);
});
});
describe("applyIPRateLimit", () => {
test("should be a convenience function that gets IP and applies rate limit", async () => {
// This is an integration test - the function calls getClientIdentifier internally
// and then calls applyRateLimit, which we've already tested extensively
const mockConfig = {
interval: 3600,
allowedPerInterval: 100,
namespace: "test:page",
};
// Mock the IP getting functions
(getClientIpFromHeaders as any).mockResolvedValue("192.168.1.1");
(hashString as any).mockReturnValue("hashed-ip-123");
(checkRateLimit as any).mockResolvedValue(ok({ allowed: true }));
await expect(applyIPRateLimit(mockConfig)).resolves.toBeUndefined();
expect(getClientIpFromHeaders).toHaveBeenCalledTimes(1);
expect(hashString).toHaveBeenCalledWith("192.168.1.1");
expect(checkRateLimit).toHaveBeenCalledWith(mockConfig, "hashed-ip-123");
});
test("should propagate errors from getClientIdentifier", async () => {
const mockConfig = {
interval: 3600,
allowedPerInterval: 100,
namespace: "test:page",
};
(getClientIpFromHeaders as any).mockRejectedValue(new Error("IP fetch failed"));
await expect(applyIPRateLimit(mockConfig)).rejects.toThrow("IP fetch failed");
});
test("should propagate rate limit exceeded errors", async () => {
const mockConfig = {
interval: 3600,
allowedPerInterval: 100,
namespace: "test:page",
};
(getClientIpFromHeaders as any).mockResolvedValue("192.168.1.1");
(hashString as any).mockReturnValue("hashed-ip-123");
(checkRateLimit as any).mockResolvedValue(ok({ allowed: false }));
await expect(applyIPRateLimit(mockConfig)).rejects.toThrow(
"Maximum number of requests reached. Please try again later."
);
});
});
});

View File

@@ -1,52 +0,0 @@
import { hashString } from "@/lib/hash-string";
import { getClientIpFromHeaders } from "@/lib/utils/client-ip";
import { logger } from "@formbricks/logger";
import { TooManyRequestsError } from "@formbricks/types/errors";
import { checkRateLimit } from "./rate-limit";
import { type TRateLimitConfig } from "./types/rate-limit";
/**
* Get client identifier for rate limiting with IP hashing
* Used when the user is not authenticated or the api is called from the client
*
* @returns {Promise<string>} Hashed IP address for rate limiting
* @throws {Error} When IP hashing fails due to invalid IP format or hashing algorithm issues
*/
export const getClientIdentifier = async (): Promise<string> => {
const ip = await getClientIpFromHeaders();
try {
return hashString(ip);
} catch (error) {
const errorMessage = "Failed to hash IP";
logger.error(errorMessage, { error });
throw new Error(errorMessage);
}
};
/**
* Generic rate limit application function
*
* @param config - Rate limit configuration
* @param identifier - Unique identifier for rate limiting (IP hash, user ID, API key, etc.)
* @throws {Error} When rate limit is exceeded or rate limiting system fails
*/
export const applyRateLimit = async (config: TRateLimitConfig, identifier: string): Promise<void> => {
const result = await checkRateLimit(config, identifier);
if (!result.ok || !result.data.allowed) {
throw new TooManyRequestsError("Maximum number of requests reached. Please try again later.");
}
};
/**
* Apply IP-based rate limiting for unauthenticated requests
* Generic function for IP-based rate limiting in authentication flows and public pages
*
* @param config - Rate limit configuration to apply
* @throws {Error} When rate limit is exceeded or IP hashing fails
*/
export const applyIPRateLimit = async (config: TRateLimitConfig): Promise<void> => {
const identifier = await getClientIdentifier();
await applyRateLimit(config, identifier);
};

View File

@@ -1,167 +0,0 @@
import { ZRateLimitConfig } from "@/modules/core/rate-limit/types/rate-limit";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { applyRateLimit } from "./helpers";
import { checkRateLimit } from "./rate-limit";
import { rateLimitConfigs } from "./rate-limit-configs";
const { mockEval, mockRedisClient, mockGetRedisClient } = vi.hoisted(() => {
const _mockEval = vi.fn();
const _mockRedisClient = { eval: _mockEval } as any;
const _mockGetRedisClient = vi.fn().mockReturnValue(_mockRedisClient);
return { mockEval: _mockEval, mockRedisClient: _mockRedisClient, mockGetRedisClient: _mockGetRedisClient };
});
// Mock dependencies for integration tests
vi.mock("@/lib/constants", () => ({
REDIS_URL: "redis://localhost:6379",
RATE_LIMITING_DISABLED: false,
SENTRY_DSN: "https://test@sentry.io/test",
}));
vi.mock("@/modules/cache/redis", () => ({
getRedisClient: mockGetRedisClient,
}));
vi.mock("@formbricks/logger", () => ({
logger: {
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@sentry/nextjs", () => ({
addBreadcrumb: vi.fn(),
captureException: vi.fn(),
}));
vi.mock("@/modules/cache/lib/cacheKeys", () => ({
createCacheKey: {
rateLimit: {
core: vi.fn(
(namespace, identifier, windowStart) => `fb:rate_limit:${namespace}:${identifier}:${windowStart}`
),
},
},
}));
describe("rateLimitConfigs", () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset the mock to return our mock client
mockGetRedisClient.mockReturnValue(mockRedisClient);
});
describe("Configuration Structure", () => {
test("should have all required config groups", () => {
expect(rateLimitConfigs).toHaveProperty("auth");
expect(rateLimitConfigs).toHaveProperty("api");
expect(rateLimitConfigs).toHaveProperty("actions");
expect(rateLimitConfigs).toHaveProperty("share");
});
test("should have all auth configurations", () => {
const authConfigs = Object.keys(rateLimitConfigs.auth);
expect(authConfigs).toEqual(["login", "signup", "forgotPassword", "verifyEmail"]);
});
test("should have all API configurations", () => {
const apiConfigs = Object.keys(rateLimitConfigs.api);
expect(apiConfigs).toEqual(["v1", "v2", "client"]);
});
test("should have all action configurations", () => {
const actionConfigs = Object.keys(rateLimitConfigs.actions);
expect(actionConfigs).toEqual(["profileUpdate", "surveyFollowUp"]);
});
test("should have all share configurations", () => {
const shareConfigs = Object.keys(rateLimitConfigs.share);
expect(shareConfigs).toEqual(["url"]);
});
});
describe("Zod Validation", () => {
test("all configurations should pass Zod validation", () => {
const allConfigs = [
...Object.values(rateLimitConfigs.auth),
...Object.values(rateLimitConfigs.api),
...Object.values(rateLimitConfigs.actions),
...Object.values(rateLimitConfigs.share),
];
allConfigs.forEach((config) => {
expect(() => ZRateLimitConfig.parse(config)).not.toThrow();
});
});
});
describe("Configuration Logic", () => {
test("all namespaces should be unique", () => {
const allNamespaces: string[] = [];
// Collect all namespaces
Object.values(rateLimitConfigs.auth).forEach((config) => allNamespaces.push(config.namespace));
Object.values(rateLimitConfigs.api).forEach((config) => allNamespaces.push(config.namespace));
Object.values(rateLimitConfigs.actions).forEach((config) => allNamespaces.push(config.namespace));
Object.values(rateLimitConfigs.share).forEach((config) => allNamespaces.push(config.namespace));
const uniqueNamespaces = new Set(allNamespaces);
expect(uniqueNamespaces.size).toBe(allNamespaces.length);
});
});
describe("Integration with Rate Limiting", () => {
test("should work with checkRateLimit function", async () => {
mockEval.mockResolvedValue([1, 1]);
const config = rateLimitConfigs.auth.login;
const result = await checkRateLimit(config, "test-identifier");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.allowed).toBe(true);
}
});
test("should work with applyRateLimit helper", async () => {
mockEval.mockResolvedValue([1, 1]);
const config = rateLimitConfigs.api.v1;
await expect(applyRateLimit(config, "api-key-123")).resolves.toBeUndefined();
});
test("should enforce limits correctly for each config type", async () => {
const testCases = [
{ config: rateLimitConfigs.auth.login, identifier: "user-login" },
{ config: rateLimitConfigs.auth.signup, identifier: "user-signup" },
{ config: rateLimitConfigs.api.v1, identifier: "api-v1-key" },
{ config: rateLimitConfigs.api.v2, identifier: "api-v2-key" },
{ config: rateLimitConfigs.actions.profileUpdate, identifier: "user-profile" },
{ config: rateLimitConfigs.share.url, identifier: "share-url" },
];
const testAllowedRequest = async (config: any, identifier: string) => {
mockEval.mockClear();
mockEval.mockResolvedValue([1, 1]);
const result = await checkRateLimit(config, identifier);
expect(result.ok).toBe(true);
expect((result as any).data.allowed).toBe(true);
};
const testExceededLimit = async (config: any, identifier: string) => {
// When limit is exceeded, remaining should be 0
mockEval.mockClear();
mockEval.mockResolvedValue([config.allowedPerInterval + 1, 0]);
const result = await checkRateLimit(config, identifier);
expect(result.ok).toBe(true);
expect((result as any).data.allowed).toBe(false);
};
for (const { config, identifier } of testCases) {
await testAllowedRequest(config, identifier);
await testExceededLimit(config, identifier);
}
});
});
});

View File

@@ -1,27 +0,0 @@
export const rateLimitConfigs = {
// Authentication endpoints - stricter limits for security
auth: {
login: { interval: 900, allowedPerInterval: 30, namespace: "auth:login" }, // 30 per 15 minutes
signup: { interval: 3600, allowedPerInterval: 30, namespace: "auth:signup" }, // 30 per hour
forgotPassword: { interval: 3600, allowedPerInterval: 5, namespace: "auth:forgot" }, // 5 per hour
verifyEmail: { interval: 3600, allowedPerInterval: 10, namespace: "auth:verify" }, // 10 per hour
},
// API endpoints - higher limits for legitimate usage
api: {
v1: { interval: 60, allowedPerInterval: 100, namespace: "api:v1" }, // 100 per minute
v2: { interval: 60, allowedPerInterval: 100, namespace: "api:v2" }, // 100 per minute
client: { interval: 60, allowedPerInterval: 100, namespace: "api:client" }, // 100 per minute
},
// Server actions - varies by action type
actions: {
profileUpdate: { interval: 3600, allowedPerInterval: 3, namespace: "action:profile" }, // 3 per hour
surveyFollowUp: { interval: 3600, allowedPerInterval: 50, namespace: "action:followup" }, // 50 per hour
},
// Share pages - moderate limits for public access
share: {
url: { interval: 60, allowedPerInterval: 30, namespace: "share:url" }, // 30 per minute
},
};

View File

@@ -1,543 +0,0 @@
import { getRedisClient } from "@/modules/cache/redis";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { applyRateLimit } from "./helpers";
import { checkRateLimit } from "./rate-limit";
import { TRateLimitConfig } from "./types/rate-limit";
// Check if Redis is available (basic requirements)
let isRedisAvailable = false;
// Test Redis availability
async function checkRedisAvailability() {
try {
const redis = getRedisClient();
if (redis === null) {
console.log("Redis client is null - Redis not available");
return false;
}
// Test basic Redis operation
await redis.ping();
console.log("Redis ping successful - Redis is available");
return true;
} catch (error) {
console.error("Error checking Redis availability:", error);
return false;
}
}
/**
* Rate Limiter Load Tests - Race Condition Detection
*
* This test suite verifies that the rate limiter implementation is free from race conditions
* and handles high concurrency correctly. The rate limiter uses Redis with Lua scripts for
* atomic operations to prevent race conditions in multi-pod Kubernetes environments.
*
* Prerequisites:
* - Redis server must be running and accessible
* - REDIS_URL environment variable must be set to a valid Redis connection string
* - Tests will be automatically skipped if REDIS_URL is empty or Redis client is not available
*
* Running the tests:
* Local development: cd apps/web && npx vitest run modules/core/rate-limit/rate-limit-load.test.ts
* CI Environment: Tests run automatically in E2E workflow with Redis/Valkey service
*
* Test Scenarios:
*
* 1. Basic Race Condition Test
* - Purpose: Verify atomic operations under high concurrency
* - Method: Send 20 concurrent requests to the same identifier (limit: 3)
* - Expected: Exactly 3 requests allowed, 17 denied
* - Failure Indicates: Race conditions in the Redis Lua script
*
* 2. Multiple Waves Test
* - Purpose: Test consistency across multiple request waves
* - Method: Send 3 waves of 15 concurrent requests each (limit: 10)
* - Expected: Exactly 10 requests allowed total across all waves
* - Failure Indicates: Window boundary issues or counter corruption
*
* 3. Different Identifiers Test
* - Purpose: Ensure identifiers don't interfere with each other
* - Method: 5 different identifiers, 10 requests each (limit: 3 per identifier)
* - Expected: Each identifier gets exactly 3 allowed requests
* - Failure Indicates: Key collision or identifier mixing
*
* 4. Window Boundary Test
* - Purpose: Verify correct window expiration and reset
* - Method: Send requests, wait for window expiry, send more requests
* - Expected: Fresh limits after window expiry
* - Failure Indicates: TTL or window calculation issues
*
* 5. High Throughput Stress Test
* - Purpose: Test performance under sustained load
* - Method: 200 requests in batches (limit: 50)
* - Expected: Exactly 50 requests allowed, consistent performance
* - Failure Indicates: Performance degradation or counter corruption
*
* 6. applyRateLimit Function Test
* - Purpose: Test the higher-level wrapper function
* - Method: Concurrent requests using applyRateLimit instead of checkRateLimit
* - Expected: Exact limit compliance with proper error handling
* - Failure Indicates: Issues in the wrapper function logic
*
* 7. Mixed Identifier Patterns Test
* - Purpose: Test real-world identifier patterns under load
* - Method: Different identifier formats running concurrently
* - Expected: Each pattern respects its individual limits
* - Failure Indicates: Pattern-specific issues
*
* 8. TTL Expiration Test
* - Purpose: Verify that rate limit keys expire correctly and unblock requests
* - Method: Hit rate limit, wait for TTL expiration, verify unblocking
* - Expected: Keys expire automatically, fresh limits after expiration
* - Failure Indicates: TTL not working, keys not expiring, memory leaks
*
* Success Indicators:
* ✅ Exact limit compliance (no more, no less than configured limit)
* ✅ Consistent behavior across multiple runs
* ✅ No interference between different identifiers
* ✅ Proper window reset behavior
*
* Failure Indicators:
* ❌ More requests allowed than limit: Race condition in increment
* ❌ Fewer requests allowed than limit: Lock contention or failed operations
* ❌ Identifier interference: Key collision or namespace issues
* ❌ Window boundary failures: TTL or timestamp calculation errors
*/
// The availability check and logging is now handled in the beforeAll hook
// Test configurations
const TEST_CONFIGS = {
// Very restrictive for race condition testing
strict: {
interval: 5, // 5 seconds
allowedPerInterval: 3,
namespace: "test:strict",
} as TRateLimitConfig,
// Medium restrictive
medium: {
interval: 10,
allowedPerInterval: 10,
namespace: "test:medium",
} as TRateLimitConfig,
// High throughput
high: {
interval: 5,
allowedPerInterval: 50,
namespace: "test:high",
} as TRateLimitConfig,
} as const;
describe("Rate Limiter Load Tests - Race Conditions", () => {
beforeAll(async () => {
// Check Redis availability first
isRedisAvailable = await checkRedisAvailability();
if (!isRedisAvailable) {
console.log("🟡 Rate Limiter Load Tests: Redis not available - tests will be skipped");
console.log(" To run these tests locally, ensure Redis is running and REDIS_URL is set");
return;
}
console.log("🟢 Rate Limiter Load Tests: Redis available - tests will run");
// Clear any existing test keys
const redis = getRedisClient();
if (redis) {
const testKeys = await redis.keys("fb:rate_limit:test:*");
if (testKeys.length > 0) {
await redis.del(testKeys);
}
}
});
afterAll(async () => {
// Clean up test keys
const redis = getRedisClient();
if (redis) {
const testKeys = await redis.keys("fb:rate_limit:test:*");
if (testKeys.length > 0) {
await redis.del(testKeys);
}
}
});
test("Race condition test: concurrent requests to same identifier", async () => {
if (!isRedisAvailable) {
console.log("Skipping test: Redis not available");
return;
}
const config = TEST_CONFIGS.strict;
const identifier = "race-test-same-id";
const concurrentRequests = 20; // More than allowed (3)
// Create array of concurrent promises
const promises = Array.from({ length: concurrentRequests }, () => checkRateLimit(config, identifier));
// Execute all requests concurrently
const results = await Promise.all(promises);
// Count allowed vs denied requests
const allowed = results.filter((r) => r.ok && r.data.allowed).length;
const denied = results.filter((r) => r.ok && !r.data.allowed).length;
console.log(`Race condition test results: ${allowed} allowed, ${denied} denied`);
// Should allow exactly the configured limit
expect(allowed).toBe(config.allowedPerInterval);
expect(denied).toBe(concurrentRequests - config.allowedPerInterval);
expect(allowed + denied).toBe(concurrentRequests);
}, 15000);
test("Race condition test: multiple waves of concurrent requests", async () => {
if (!isRedisAvailable) {
console.log("Skipping test: Redis not available");
return;
}
const config = TEST_CONFIGS.medium;
const identifier = "race-test-waves";
const wavesCount = 3;
const requestsPerWave = 15; // More than allowed (10)
const allResults: Array<Awaited<ReturnType<typeof checkRateLimit>>> = [];
// Send waves of concurrent requests (no delay to ensure same window)
for (let wave = 0; wave < wavesCount; wave++) {
const promises = Array.from({ length: requestsPerWave }, () => checkRateLimit(config, identifier));
const waveResults = await Promise.all(promises);
allResults.push(...waveResults);
// No delay - we want all waves in the same window for this test
}
const totalAllowed = allResults.filter((r) => r.ok && r.data.allowed).length;
const totalDenied = allResults.filter((r) => r.ok && !r.data.allowed).length;
console.log(`Multi-wave test: ${totalAllowed} allowed, ${totalDenied} denied`);
// Should still only allow the configured limit across all waves
expect(totalAllowed).toBe(config.allowedPerInterval);
expect(totalDenied).toBe(wavesCount * requestsPerWave - config.allowedPerInterval);
}, 20000);
test("Race condition test: different identifiers should not interfere", async () => {
if (!isRedisAvailable) {
console.log("Skipping test: Redis not available");
return;
}
const config = TEST_CONFIGS.strict;
const identifiersCount = 5;
const requestsPerIdentifier = 10;
// Create promises for multiple identifiers concurrently
const allPromises: Promise<{ identifier: string; result: Awaited<ReturnType<typeof checkRateLimit>> }>[] =
[];
for (let i = 0; i < identifiersCount; i++) {
const identifier = `race-test-different-${i}`;
for (let j = 0; j < requestsPerIdentifier; j++) {
allPromises.push(checkRateLimit(config, identifier).then((result) => ({ identifier, result })));
}
}
// Execute all requests concurrently
const results = await Promise.all(allPromises);
// Group results by identifier
const resultsByIdentifier = results.reduce(
(acc, { identifier, result }) => {
if (!acc[identifier]) acc[identifier] = [];
acc[identifier].push(result);
return acc;
},
{} as Record<string, any[]>
);
// Each identifier should have exactly the allowed limit
Object.entries(resultsByIdentifier).forEach(([identifier, identifierResults]) => {
const allowed = identifierResults.filter((r) => r.ok && r.data.allowed).length;
const denied = identifierResults.filter((r) => r.ok && !r.data.allowed).length;
console.log(`Identifier ${identifier}: ${allowed} allowed, ${denied} denied`);
expect(allowed).toBe(config.allowedPerInterval);
expect(denied).toBe(requestsPerIdentifier - config.allowedPerInterval);
});
}, 20000);
test("Window boundary race condition test", async () => {
if (!isRedisAvailable) {
console.log("Skipping test: Redis not available");
return;
}
const config = {
interval: 2, // Very short window for testing
allowedPerInterval: 5,
namespace: "test:boundary",
} as TRateLimitConfig;
const identifier = "boundary-test";
// First batch of requests
const firstBatch = Array.from({ length: 8 }, () => checkRateLimit(config, identifier));
const firstResults = await Promise.all(firstBatch);
const firstAllowed = firstResults.filter((r) => r.ok && r.data.allowed).length;
console.log(`First batch: ${firstAllowed} allowed`);
expect(firstAllowed).toBe(config.allowedPerInterval);
// Wait for window to expire
await new Promise((resolve) => setTimeout(resolve, config.interval * 1000 + 100));
// Second batch should get fresh limits
const secondBatch = Array.from({ length: 8 }, () => checkRateLimit(config, identifier));
const secondResults = await Promise.all(secondBatch);
const secondAllowed = secondResults.filter((r) => r.ok && r.data.allowed).length;
console.log(`Second batch: ${secondAllowed} allowed`);
expect(secondAllowed).toBe(config.allowedPerInterval);
}, 15000);
test("High throughput stress test", async () => {
if (!isRedisAvailable) {
console.log("Skipping test: Redis not available");
return;
}
const config = TEST_CONFIGS.high;
const totalRequests = 200;
const batchSize = 50;
const identifier = "stress-test";
let totalAllowed = 0;
let totalDenied = 0;
// Send requests in batches to simulate real load
for (let i = 0; i < totalRequests; i += batchSize) {
const batchEnd = Math.min(i + batchSize, totalRequests);
const batchPromises = Array.from({ length: batchEnd - i }, () => checkRateLimit(config, identifier));
const batchResults = await Promise.all(batchPromises);
const batchAllowed = batchResults.filter((r) => r.ok && r.data.allowed).length;
const batchDenied = batchResults.filter((r) => r.ok && !r.data.allowed).length;
totalAllowed += batchAllowed;
totalDenied += batchDenied;
// Small delay between batches
await new Promise((resolve) => setTimeout(resolve, 10));
}
console.log(`Stress test: ${totalAllowed} allowed, ${totalDenied} denied`);
// Should respect the rate limit even under high load
expect(totalAllowed).toBe(config.allowedPerInterval);
expect(totalDenied).toBe(totalRequests - config.allowedPerInterval);
expect(totalAllowed + totalDenied).toBe(totalRequests);
}, 30000);
test("applyRateLimit function race condition test", async () => {
if (!isRedisAvailable) {
console.log("Skipping test: Redis not available");
return;
}
const config = TEST_CONFIGS.strict;
const identifier = "apply-rate-limit-test";
const concurrentRequests = 15;
// Test the higher-level applyRateLimit function
const promises = Array.from({ length: concurrentRequests }, async () => {
try {
await applyRateLimit(config, identifier);
return { success: true, error: null };
} catch (error) {
return { success: false, error: error.message };
}
});
const results = await Promise.all(promises);
const successes = results.filter((r) => r.success).length;
const failures = results.filter((r) => !r.success).length;
console.log(`applyRateLimit test: ${successes} successes, ${failures} failures`);
// Should allow exactly the configured limit
expect(successes).toBe(config.allowedPerInterval);
expect(failures).toBe(concurrentRequests - config.allowedPerInterval);
// All failures should be "Maximum number of requests reached. Please try again later."
const rateLimitErrors = results.filter(
(r) => r.error === "Maximum number of requests reached. Please try again later."
).length;
expect(rateLimitErrors).toBe(failures);
}, 15000);
test("Mixed identifier patterns under load", async () => {
if (!isRedisAvailable) {
console.log("Skipping test: Redis not available");
return;
}
const config = TEST_CONFIGS.medium;
const patterns = ["user-123", "ip-192.168.1.1", "api-key-abc", "session-xyz"];
const requestsPerPattern = 15;
// Create mixed concurrent requests
const allPromises: Promise<{ pattern: string; result: any }>[] = [];
for (const pattern of patterns) {
for (let i = 0; i < requestsPerPattern; i++) {
allPromises.push(checkRateLimit(config, pattern).then((result) => ({ pattern, result })));
}
}
// Shuffle the array to simulate random request order
for (let i = allPromises.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[allPromises[i], allPromises[j]] = [allPromises[j], allPromises[i]];
}
const results = await Promise.all(allPromises);
// Group and verify results
const resultsByPattern = results.reduce(
(acc, { pattern, result }) => {
if (!acc[pattern]) acc[pattern] = [];
acc[pattern].push(result);
return acc;
},
{} as Record<string, any[]>
);
Object.entries(resultsByPattern).forEach(([pattern, patternResults]) => {
const allowed = patternResults.filter((r) => r.ok && r.data.allowed).length;
const denied = patternResults.filter((r) => r.ok && !r.data.allowed).length;
console.log(`Pattern ${pattern}: ${allowed} allowed, ${denied} denied`);
expect(allowed).toBe(config.allowedPerInterval);
expect(denied).toBe(requestsPerPattern - config.allowedPerInterval);
});
}, 25000);
test("TTL expiration test: rate limit key should expire and unblock requests", async () => {
if (!isRedisAvailable) {
console.log("Skipping test: Redis not available");
return;
}
// Use a very short interval for faster testing
const config: TRateLimitConfig = {
interval: 3, // 3 seconds
allowedPerInterval: 2,
namespace: "test:ttl",
};
const identifier = "ttl-test-user";
// Clear any existing keys first
const redis = getRedisClient();
if (redis) {
const existingKeys = await redis.keys(`fb:rate_limit:${config.namespace}:*`);
if (existingKeys.length > 0) {
await redis.del(existingKeys);
}
}
console.log("Phase 1: Hitting rate limit...");
// Phase 1: Make requests until rate limit is hit
const phase1Promises = Array.from({ length: 5 }, () => checkRateLimit(config, identifier));
const phase1Results = await Promise.all(phase1Promises);
const phase1Allowed = phase1Results.filter((r) => r.ok && r.data.allowed).length;
const phase1Denied = phase1Results.filter((r) => r.ok && !r.data.allowed).length;
console.log(`Phase 1 results: ${phase1Allowed} allowed, ${phase1Denied} denied`);
// Verify rate limit is working
expect(phase1Allowed).toBe(config.allowedPerInterval);
expect(phase1Denied).toBe(5 - config.allowedPerInterval);
// Check that the key exists in Redis
if (redis) {
const now = Date.now();
const windowStart = Math.floor(now / (config.interval * 1000)) * config.interval;
const expectedKey = `fb:rate_limit:${config.namespace}:${identifier}:${windowStart}`;
const keyExists = await redis.exists(expectedKey);
expect(keyExists).toBe(1);
console.log(`Redis key exists: ${expectedKey}`);
// Check the TTL
const ttl = await redis.ttl(expectedKey);
expect(ttl).toBeGreaterThan(0);
expect(ttl).toBeLessThanOrEqual(config.interval);
console.log(`Key TTL: ${ttl} seconds`);
// Phase 2: Wait for TTL to expire
console.log(`Phase 2: Waiting for TTL expiration (${config.interval + 1} seconds)...`);
await new Promise((resolve) => setTimeout(resolve, (config.interval + 1) * 1000));
// Verify key has been automatically deleted by Redis
const keyExistsAfterTTL = await redis.exists(expectedKey);
expect(keyExistsAfterTTL).toBe(0);
console.log("Key automatically deleted by Redis TTL ✅");
}
// Phase 3: Make new requests after TTL expiration
console.log("Phase 3: Making requests after TTL expiration...");
const phase3Promises = Array.from({ length: 5 }, () => checkRateLimit(config, identifier));
const phase3Results = await Promise.all(phase3Promises);
const phase3Allowed = phase3Results.filter((r) => r.ok && r.data.allowed).length;
const phase3Denied = phase3Results.filter((r) => r.ok && !r.data.allowed).length;
console.log(`Phase 3 results: ${phase3Allowed} allowed, ${phase3Denied} denied`);
// Should get fresh limits after TTL expiration
expect(phase3Allowed).toBe(config.allowedPerInterval);
expect(phase3Denied).toBe(5 - config.allowedPerInterval);
// Verify new key was created for the new window
if (redis) {
const newNow = Date.now();
const newWindowStart = Math.floor(newNow / (config.interval * 1000)) * config.interval;
const newKey = `fb:rate_limit:${config.namespace}:${identifier}:${newWindowStart}`;
const newKeyExists = await redis.exists(newKey);
expect(newKeyExists).toBe(1);
console.log(`New Redis key created: ${newKey}`);
}
// Phase 4: Test that we're blocked again within the new window
console.log("Phase 4: Verifying rate limit is active in new window...");
const phase4Promises = Array.from({ length: 3 }, () => checkRateLimit(config, identifier));
const phase4Results = await Promise.all(phase4Promises);
const phase4Allowed = phase4Results.filter((r) => r.ok && r.data.allowed).length;
const phase4Denied = phase4Results.filter((r) => r.ok && !r.data.allowed).length;
console.log(`Phase 4 results: ${phase4Allowed} allowed, ${phase4Denied} denied`);
// Should be blocked since we already used up the limit in phase 3
expect(phase4Allowed).toBe(0);
expect(phase4Denied).toBe(3);
console.log("✅ TTL expiration working correctly - rate limits properly reset after expiration");
}, 20000);
});

View File

@@ -1,344 +0,0 @@
// Import modules after mocking
import { afterAll, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
// Import after mocking
import { checkRateLimit } from "./rate-limit";
import { TRateLimitConfig } from "./types/rate-limit";
const { mockEval, mockRedisClient, mockGetRedisClient } = vi.hoisted(() => {
const _mockEval = vi.fn();
const _mockRedisClient = {
eval: _mockEval,
} as any;
const _mockGetRedisClient = vi.fn().mockReturnValue(_mockRedisClient);
return {
mockEval: _mockEval,
mockRedisClient: _mockRedisClient,
mockGetRedisClient: _mockGetRedisClient,
};
});
// Mock all dependencies (will use the hoisted mocks above)
vi.mock("@/modules/cache/redis", () => ({
getRedisClient: mockGetRedisClient,
}));
vi.mock("@/lib/constants", () => ({
REDIS_URL: "redis://localhost:6379",
RATE_LIMITING_DISABLED: false,
SENTRY_DSN: "https://test@sentry.io/test",
}));
vi.mock("@formbricks/logger", () => ({
logger: {
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("@sentry/nextjs", () => ({
addBreadcrumb: vi.fn(),
captureException: vi.fn(),
}));
describe("checkRateLimit", () => {
const testConfig: TRateLimitConfig = {
interval: 300, // 5 minutes
allowedPerInterval: 5,
namespace: "test",
};
beforeEach(() => {
vi.clearAllMocks();
// Reset the mock to return our mock client
mockGetRedisClient.mockReturnValue(mockRedisClient);
});
afterEach(() => {
vi.restoreAllMocks();
});
// Ensure mocks don't leak to other test suites (e.g. load tests)
afterAll(() => {
vi.resetModules();
vi.resetAllMocks();
});
test("should allow request when under limit", async () => {
// Mock Redis returning count of 2, which is under limit of 5
mockEval.mockResolvedValue([2, 1]);
const result = await checkRateLimit(testConfig, "test-user");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.allowed).toBe(true);
}
});
test("should deny request when over limit", async () => {
// Mock Redis returning count of 6, which is over limit of 5
mockEval.mockResolvedValue([6, 0]);
const result = await checkRateLimit(testConfig, "test-user");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.allowed).toBe(false);
}
});
test("should fail open when Redis is unavailable", async () => {
// Mock Redis throwing an error
mockEval.mockRejectedValue(new Error("Redis connection failed"));
const result = await checkRateLimit(testConfig, "test-user");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.allowed).toBe(true);
}
});
test("should fail open when rate limiting is disabled", async () => {
vi.resetModules();
vi.doMock("@/lib/constants", () => ({
REDIS_URL: "redis://localhost:6379",
RATE_LIMITING_DISABLED: true,
SENTRY_DSN: "https://test@sentry.io/test",
}));
// Dynamic import after mocking
const { checkRateLimit: checkRateLimitMocked } = await import("./rate-limit");
const result = await checkRateLimitMocked(testConfig, "test-user");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.allowed).toBe(true);
}
});
test("should fail open when Redis is not configured", async () => {
vi.resetModules();
vi.doMock("@/modules/cache/redis", () => ({
getRedisClient: vi.fn().mockReturnValue(null),
}));
// Dynamic import after mocking
const { checkRateLimit: checkRateLimitMocked } = await import("./rate-limit");
const result = await checkRateLimitMocked(testConfig, "test-user");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.allowed).toBe(true);
}
});
test("should generate correct Redis key with window alignment", async () => {
mockEval.mockResolvedValue([1, 1]);
await checkRateLimit(testConfig, "test-user");
expect(mockEval).toHaveBeenCalledWith(
expect.stringContaining("redis.call('INCR', key)"),
expect.objectContaining({
keys: [expect.stringMatching(/^fb:rate_limit:test:test-user:\d+$/)],
arguments: ["5", expect.any(String)],
})
);
});
test("should use provided namespace", async () => {
const configWithCustomNamespace: TRateLimitConfig = {
interval: 300,
allowedPerInterval: 5,
namespace: "custom",
};
mockEval.mockResolvedValue([1, 1]);
await checkRateLimit(configWithCustomNamespace, "test-user");
expect(mockEval).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
keys: [expect.stringMatching(/^fb:rate_limit:custom:test-user:\d+$/)],
arguments: ["5", expect.any(String)],
})
);
});
test("should calculate correct TTL for window expiration", async () => {
mockEval.mockResolvedValue([1, 1]);
await checkRateLimit(testConfig, "test-user");
// TTL should be between 0 and 300 seconds (window interval)
const ttlUsed = Number.parseInt(mockEval.mock.calls[0][1].arguments[1]);
expect(ttlUsed).toBeGreaterThan(0);
expect(ttlUsed).toBeLessThanOrEqual(300);
});
test("should set TTL only on first increment", async () => {
mockEval.mockResolvedValue([1, 1]);
await checkRateLimit(testConfig, "test-user");
// Verify the Lua script contains the conditional TTL logic
const luaScript = mockEval.mock.calls[0][0];
expect(luaScript).toContain("if current == 1 then");
expect(luaScript).toContain("redis.call('EXPIRE', key, ttl)");
expect(luaScript).toContain("end");
// Verify script structure for atomic increment and conditional expire
expect(luaScript).toContain("redis.call('INCR', key)");
expect(luaScript).toContain("return {current, current <= limit and 1 or 0}");
});
test("should not call Sentry when SENTRY_DSN is not configured", async () => {
vi.resetModules();
// Re-mock all dependencies after resetModules
vi.doMock("@/lib/constants", () => ({
REDIS_URL: "redis://localhost:6379",
RATE_LIMITING_DISABLED: false,
SENTRY_DSN: undefined,
}));
const mockAddBreadcrumb = vi.fn();
const mockCaptureException = vi.fn();
vi.doMock("@sentry/nextjs", () => ({
addBreadcrumb: mockAddBreadcrumb,
captureException: mockCaptureException,
}));
vi.doMock("@formbricks/logger", () => ({
logger: {
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.doMock("@/modules/cache/redis", () => ({
getRedisClient: vi.fn().mockReturnValue({
eval: vi.fn().mockResolvedValue([6, 0]),
}),
}));
// Dynamic import after mocking
const { checkRateLimit: checkRateLimitMocked } = await import("./rate-limit");
await checkRateLimitMocked(testConfig, "test-user");
// Verify Sentry functions were not called
expect(mockAddBreadcrumb).not.toHaveBeenCalled();
});
test("should call Sentry when SENTRY_DSN is configured and rate limit exceeded", async () => {
vi.resetModules();
// Re-mock all dependencies after resetModules
vi.doMock("@/lib/constants", () => ({
REDIS_URL: "redis://localhost:6379",
RATE_LIMITING_DISABLED: false,
SENTRY_DSN: "https://test@sentry.io/test",
}));
const mockAddBreadcrumb = vi.fn();
const mockCaptureException = vi.fn();
vi.doMock("@sentry/nextjs", () => ({
addBreadcrumb: mockAddBreadcrumb,
captureException: mockCaptureException,
}));
vi.doMock("@formbricks/logger", () => ({
logger: {
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.doMock("@/modules/cache/redis", () => ({
getRedisClient: vi.fn().mockReturnValue({
eval: vi.fn().mockResolvedValue([6, 0]),
}),
}));
// Dynamic import after mocking
const { checkRateLimit: checkRateLimitMocked } = await import("./rate-limit");
await checkRateLimitMocked(testConfig, "test-user");
// Verify Sentry breadcrumb was added
expect(mockAddBreadcrumb).toHaveBeenCalledWith({
message: "Rate limit exceeded",
level: "warning",
data: expect.objectContaining({
identifier: "test-user",
currentCount: 6,
limit: 5,
namespace: "test",
}),
});
});
test("should call Sentry when SENTRY_DSN is configured and Redis error occurs", async () => {
vi.resetModules();
// Re-mock all dependencies after resetModules
vi.doMock("@/lib/constants", () => ({
REDIS_URL: "redis://localhost:6379",
RATE_LIMITING_DISABLED: false,
SENTRY_DSN: "https://test@sentry.io/test",
}));
const mockAddBreadcrumb = vi.fn();
const mockCaptureException = vi.fn();
vi.doMock("@sentry/nextjs", () => ({
addBreadcrumb: mockAddBreadcrumb,
captureException: mockCaptureException,
}));
vi.doMock("@formbricks/logger", () => ({
logger: {
info: vi.fn(),
debug: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
const redisError = new Error("Redis connection failed");
vi.doMock("@/modules/cache/redis", () => ({
getRedisClient: vi.fn().mockReturnValue({
eval: vi.fn().mockRejectedValue(redisError),
}),
}));
// Dynamic import after mocking
const { checkRateLimit: checkRateLimitMocked } = await import("./rate-limit");
await checkRateLimitMocked(testConfig, "test-user");
// Verify Sentry exception was captured
expect(mockCaptureException).toHaveBeenCalledWith(
redisError,
expect.objectContaining({
tags: {
component: "rate-limiter",
namespace: "test",
},
extra: expect.objectContaining({
error: redisError,
identifier: "test-user",
namespace: "test",
}),
})
);
});
});

View File

@@ -1,131 +0,0 @@
import { RATE_LIMITING_DISABLED, SENTRY_DSN } from "@/lib/constants";
import { createCacheKey } from "@/modules/cache/lib/cacheKeys";
import { getRedisClient } from "@/modules/cache/redis";
import * as Sentry from "@sentry/nextjs";
import { logger } from "@formbricks/logger";
import { Result, ok } from "@formbricks/types/error-handlers";
import { TRateLimitConfig, type TRateLimitResponse } from "./types/rate-limit";
/**
* Atomic Redis-based rate limiter using Lua scripts
* Prevents race conditions in multi-pod Kubernetes environments
*/
export const checkRateLimit = async (
config: TRateLimitConfig,
identifier: string
): Promise<Result<TRateLimitResponse, string>> => {
// Skip rate limiting if disabled
if (RATE_LIMITING_DISABLED) {
logger.debug(`Rate limiting disabled`);
return ok({
allowed: true,
});
}
try {
// Get Redis client
const redis = getRedisClient();
if (!redis) {
logger.debug(`Redis unavailable`);
return ok({
allowed: true,
});
}
const now = Date.now();
const windowStart = Math.floor(now / (config.interval * 1000)) * config.interval;
const key = createCacheKey.rateLimit.core(config.namespace, identifier, windowStart);
// Calculate TTL to expire exactly at window end, value in seconds
const windowEnd = windowStart + config.interval;
// Convert window end from seconds to milliseconds, subtract current time, then convert back to seconds for Redis EXPIRE
const ttlSeconds = Math.ceil((windowEnd * 1000 - now) / 1000);
// Lua script for atomic increment and conditional expire
// This prevents race conditions between INCR and EXPIRE operations
const luaScript = `
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])
-- Atomically increment and get current count
local current = redis.call('INCR', key)
-- Set TTL only if this is the first increment (avoids extending windows)
if current == 1 then
redis.call('EXPIRE', key, ttl)
end
-- Return current count and whether it's within limit
return {current, current <= limit and 1 or 0}
`;
const result = (await redis.eval(luaScript, {
keys: [key],
arguments: [config.allowedPerInterval.toString(), ttlSeconds.toString()],
})) as [number, number];
const [currentCount, isAllowed] = result;
// Log debug information for every Redis count increase
logger.debug(`Rate limit check`, {
identifier,
currentCount,
limit: config.allowedPerInterval,
window: config.interval,
key,
allowed: isAllowed === 1,
windowEnd,
});
const response: TRateLimitResponse = {
allowed: isAllowed === 1,
};
// Log rate limit violations for security monitoring
if (!response.allowed) {
const violationContext = {
identifier,
currentCount,
limit: config.allowedPerInterval,
window: config.interval,
key,
namespace: config.namespace,
};
logger.error(`Rate limit exceeded`, violationContext);
if (SENTRY_DSN) {
// Breadcrumb because the exception will be captured in the error handler
Sentry.addBreadcrumb({
message: `Rate limit exceeded`,
level: "warning",
data: violationContext,
});
}
}
return ok(response);
} catch (error) {
const errorMessage = `Rate limit check failed`;
const errorContext = { error, identifier, namespace: config.namespace };
logger.error(errorMessage, errorContext);
if (SENTRY_DSN) {
// Log error to Sentry
Sentry.captureException(error, {
tags: {
component: "rate-limiter",
namespace: config.namespace,
},
extra: errorContext,
});
}
// Fail open - allow request if rate limiting fails
// This ensures system availability over perfect rate limiting
return ok({
allowed: true,
});
}
};

View File

@@ -1,18 +0,0 @@
import { z } from "zod";
export const ZRateLimitConfig = z.object({
/** Rate limit window in seconds */
interval: z.number().int().positive().describe("Rate limit window in seconds"),
/** Maximum allowed requests per interval */
allowedPerInterval: z.number().int().positive().describe("Maximum allowed requests per interval"),
/** Namespace for grouping rate limit per feature */
namespace: z.string().min(1).describe("Namespace for grouping rate limit per feature"),
});
export type TRateLimitConfig = z.infer<typeof ZRateLimitConfig>;
const ZRateLimitResponse = z.object({
allowed: z.boolean().describe("Whether the request is allowed"),
});
export type TRateLimitResponse = z.infer<typeof ZRateLimitResponse>;

View File

@@ -364,7 +364,7 @@ describe("License Core Logic", () => {
},
}));
// Import hashString to compute the expected cache key
const { hashString } = await import("@/lib/hash-string");
const { hashString } = await import("@/lib/hashString");
const hashedKey = hashString("test-license-key");
const detailsKey = `fb:license:${hashedKey}:status`;
// Patch the cache mock to match the actual key logic
@@ -476,7 +476,7 @@ describe("License Core Logic", () => {
HTTP_PROXY: undefined,
},
}));
const { hashString } = await import("@/lib/hash-string");
const { hashString } = await import("@/lib/hashString");
const expectedHash = hashString(testLicenseKey);
const { getEnterpriseLicense } = await import("./license");
await getEnterpriseLicense();

View File

@@ -1,6 +1,5 @@
import "server-only";
import { env } from "@/lib/env";
import { hashString } from "@/lib/hash-string";
import { hashString } from "@/lib/hashString";
import { createCacheKey } from "@/modules/cache/lib/cacheKeys";
import { getCache } from "@/modules/cache/lib/service";
import {

View File

@@ -192,7 +192,7 @@ export const handleSsoCallback = async ({
});
// send new user to brevo
createBrevoCustomer({ id: userProfile.id, email: userProfile.email });
createBrevoCustomer({ id: user.id, email: user.email });
if (isMultiOrgEnabled) return true;

View File

@@ -32,7 +32,7 @@ vi.mock("@/lib/constants", () => ({
WEBAPP_URL: "test-webapp-url",
SESSION_MAX_AGE: 1000,
AUDIT_LOG_ENABLED: 1,
REDIS_URL: undefined,
REDIS_URL: "redis://localhost:6379",
}));
// Mock @/lib/env

View File

@@ -55,7 +55,7 @@ vi.mock("@/lib/constants", () => ({
SAML_OAUTH_ENABLED: true,
SMTP_PASSWORD: "smtp-password",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -57,7 +57,7 @@ vi.mock("@/lib/constants", () => ({
SAML_OAUTH_ENABLED: true,
SMTP_PASSWORD: "smtp-password",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -50,7 +50,7 @@ vi.mock("@/lib/constants", () => ({
SMTP_PORT: "mock-smtp-port",
IS_POSTHOG_CONFIGURED: true,
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -7,10 +7,12 @@ import { DatePicker } from "@/modules/ui/components/date-picker";
import { Input } from "@/modules/ui/components/input";
import { Label } from "@/modules/ui/components/label";
import { Slider } from "@/modules/ui/components/slider";
import { Switch } from "@/modules/ui/components/switch";
import { useAutoAnimate } from "@formkit/auto-animate/react";
import * as Collapsible from "@radix-ui/react-collapsible";
import { useTranslate } from "@tolgee/react";
import { CheckIcon } from "lucide-react";
import { ArrowUpRight, CheckIcon } from "lucide-react";
import Link from "next/link";
import { KeyboardEventHandler, useEffect, useState } from "react";
import toast from "react-hot-toast";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -45,6 +47,12 @@ export const ResponseOptionsCard = ({
subheading: t("environments.surveys.edit.survey_completed_subheading"),
});
const [singleUseMessage, setSingleUseMessage] = useState({
heading: t("environments.surveys.edit.survey_already_answered_heading"),
subheading: t("environments.surveys.edit.survey_already_answered_subheading"),
});
const [singleUseEncryption, setSingleUseEncryption] = useState(true);
const [runOnDate, setRunOnDate] = useState<Date | null>(null);
const [closeOnDate, setCloseOnDate] = useState<Date | null>(null);
const [recaptchaThreshold, setRecaptchaThreshold] = useState<number>(localSurvey.recaptcha?.threshold ?? 0);
@@ -155,6 +163,53 @@ export const ResponseOptionsCard = ({
setLocalSurvey({ ...localSurvey, surveyClosedMessage: message });
};
const handleSingleUseSurveyToggle = () => {
if (!localSurvey.singleUse?.enabled) {
setLocalSurvey({
...localSurvey,
singleUse: { enabled: true, ...singleUseMessage, isEncrypted: singleUseEncryption },
});
} else {
setLocalSurvey({ ...localSurvey, singleUse: { enabled: false, isEncrypted: false } });
}
};
const handleSingleUseSurveyMessageChange = ({
heading,
subheading,
}: {
heading?: string;
subheading?: string;
}) => {
const message = {
heading: heading ?? singleUseMessage.heading,
subheading: subheading ?? singleUseMessage.subheading,
};
const localSurveySingleUseEnabled = localSurvey.singleUse?.enabled ?? false;
setSingleUseMessage(message);
setLocalSurvey({
...localSurvey,
singleUse: { enabled: localSurveySingleUseEnabled, ...message, isEncrypted: singleUseEncryption },
});
};
const hangleSingleUseEncryptionToggle = () => {
if (!singleUseEncryption) {
setSingleUseEncryption(true);
setLocalSurvey({
...localSurvey,
singleUse: { enabled: true, ...singleUseMessage, isEncrypted: true },
});
} else {
setSingleUseEncryption(false);
setLocalSurvey({
...localSurvey,
singleUse: { enabled: true, ...singleUseMessage, isEncrypted: false },
});
}
};
const handleHideBackButtonToggle = () => {
setLocalSurvey({ ...localSurvey, isBackButtonHidden: !localSurvey.isBackButtonHidden });
};
@@ -168,6 +223,14 @@ export const ResponseOptionsCard = ({
setSurveyClosedMessageToggle(true);
}
if (localSurvey.singleUse?.enabled) {
setSingleUseMessage({
heading: localSurvey.singleUse.heading ?? singleUseMessage.heading,
subheading: localSurvey.singleUse.subheading ?? singleUseMessage.subheading,
});
setSingleUseEncryption(localSurvey.singleUse.isEncrypted);
}
if (localSurvey.runOnDate) {
setRunOnDate(localSurvey.runOnDate);
setRunOnDateToggle(true);
@@ -177,7 +240,13 @@ export const ResponseOptionsCard = ({
setCloseOnDate(localSurvey.closeOnDate);
setCloseOnDateToggle(true);
}
}, [localSurvey, surveyClosedMessage.heading, surveyClosedMessage.subheading]);
}, [
localSurvey,
singleUseMessage.heading,
singleUseMessage.subheading,
surveyClosedMessage.heading,
surveyClosedMessage.subheading,
]);
const toggleAutocomplete = () => {
if (autoComplete) {
@@ -402,6 +471,80 @@ export const ResponseOptionsCard = ({
</div>
</AdvancedOptionToggle>
{/* Single User Survey Options */}
<AdvancedOptionToggle
htmlId="singleUserSurveyOptions"
isChecked={!!localSurvey.singleUse?.enabled}
onToggle={handleSingleUseSurveyToggle}
title={t("environments.surveys.edit.single_use_survey_links")}
description={t("environments.surveys.edit.single_use_survey_links_description")}
childBorder={true}>
<div className="flex w-full items-center space-x-1 p-4 pb-4">
<div className="w-full cursor-pointer items-center bg-slate-50">
<div className="row mb-2 flex cursor-default items-center space-x-2">
<Label htmlFor="howItWorks">{t("environments.surveys.edit.how_it_works")}</Label>
</div>
<ul className="mb-3 ml-4 cursor-default list-inside list-disc space-y-1">
<li className="text-sm text-slate-600">
{t(
"environments.surveys.edit.blocks_survey_if_the_survey_url_has_no_single_use_id_suid"
)}
</li>
<li className="text-sm text-slate-600">
{t(
"environments.surveys.edit.blocks_survey_if_a_submission_with_the_single_use_id_suid_exists_already"
)}
</li>
<li className="text-sm text-slate-600">
<Link
href="https://formbricks.com/docs/link-surveys/single-use-links"
target="_blank"
className="underline">
{t("common.read_docs")} <ArrowUpRight className="inline" size={16} />
</Link>
</li>
</ul>
<Label htmlFor="headline">{t("environments.surveys.edit.link_used_message")}</Label>
<Input
autoFocus
id="heading"
className="mb-4 mt-2 bg-white"
name="heading"
value={singleUseMessage.heading}
onChange={(e) => handleSingleUseSurveyMessageChange({ heading: e.target.value })}
/>
<Label htmlFor="headline">{t("environments.surveys.edit.subheading")}</Label>
<Input
className="mb-4 mt-2 bg-white"
id="subheading"
name="subheading"
value={singleUseMessage.subheading}
onChange={(e) => handleSingleUseSurveyMessageChange({ subheading: e.target.value })}
/>
<Label htmlFor="headline">{t("environments.surveys.edit.url_encryption")}</Label>
<div>
<div className="mt-2 flex items-center space-x-1">
<Switch
id="encryption-switch"
checked={singleUseEncryption}
onCheckedChange={hangleSingleUseEncryptionToggle}
/>
<Label htmlFor="encryption-label">
<div className="ml-2">
<p className="text-sm font-normal text-slate-600">
{t(
"environments.surveys.edit.enable_encryption_of_single_use_id_suid_in_survey_url"
)}
</p>
</div>
</Label>
</div>
</div>
</div>
</div>
</AdvancedOptionToggle>
{/* Verify Email Section */}
<AdvancedOptionToggle
htmlId="verifyEmailBeforeSubmission"

View File

@@ -14,7 +14,7 @@ export const useSingleUseId = (survey: TSurvey | TSurveyList) => {
if (survey.singleUse?.enabled) {
const response = await generateSingleUseIdsAction({
surveyId: survey.id,
isEncrypted: Boolean(survey.singleUse?.isEncrypted),
isEncrypted: !!survey.singleUse?.isEncrypted,
count: 1,
});

View File

@@ -38,7 +38,7 @@ vi.mock("@/lib/constants", () => ({
SMTP_USERNAME: "user@example.com",
SMTP_PASSWORD: "password",
SESSION_MAX_AGE: 1000,
REDIS_URL: undefined,
REDIS_URL: "test-redis-url",
AUDIT_LOG_ENABLED: true,
}));

View File

@@ -38,7 +38,7 @@ vi.mock("@/lib/constants", () => ({
ENTERPRISE_LICENSE_KEY: "mock-license-key",
SESSION_MAX_AGE: 1000,
AUDIT_LOG_ENABLED: 1,
REDIS_URL: undefined,
REDIS_URL: "redis://localhost:6379",
}));
// Track the callback for useDebounce to better control when it fires

View File

@@ -3,18 +3,8 @@
import { useTranslate } from "@tolgee/react";
import { XCircleIcon } from "lucide-react";
interface ErrorComponentProps {
title?: string;
description?: string;
}
export const ErrorComponent: React.FC<ErrorComponentProps> = ({ title, description }) => {
export const ErrorComponent: React.FC = () => {
const { t } = useTranslate();
// Use custom title/description if provided, otherwise fallback to translations
const errorTitle = title || "common.error_component_title";
const errorDescription = description || "common.error_component_description";
return (
<div className="rounded-lg bg-red-50 p-4">
<div className="flex">
@@ -23,10 +13,10 @@ export const ErrorComponent: React.FC<ErrorComponentProps> = ({ title, descripti
</div>
<div className="ml-3">
<h3 className="text-sm font-medium text-red-800" data-testid="error-title">
{t(errorTitle)}
{t("common.error_component_title")}
</h3>
<div className="mt-2 text-sm text-red-700" data-testid="error-description">
<p>{t(errorDescription)}</p>
<p>{t("common.error_component_description")}</p>
</div>
</div>
</div>

View File

@@ -95,6 +95,7 @@
"googleapis": "148.0.0",
"heic-convert": "2.1.0",
"https-proxy-agent": "7.0.6",
"ioredis": "5.6.1",
"jiti": "2.4.2",
"jsonwebtoken": "9.0.2",
"keyv": "5.3.3",
@@ -105,7 +106,7 @@
"markdown-it": "14.1.0",
"mime-types": "3.0.1",
"nanoid": "5.1.5",
"next": "15.3.3",
"next": "15.3.1",
"next-auth": "4.24.11",
"next-safe-action": "7.10.8",
"node-fetch": "3.3.2",

View File

@@ -62,7 +62,3 @@ For more information on SSO setup, see:
- [Azure AD OAuth](./configuration/auth-sso/azure-ad-oauth)
- [Open ID Connect](./configuration/auth-sso/open-id-connect)
- [SAML SSO](./configuration/auth-sso/saml-sso)
<Note>
Formbricks does not support special characters, such as Cyrillic, in account email addresses to avoid technical, compatibility, and security issues. Additionally, universal support for such addresses is still limited.
</Note>

View File

@@ -133,7 +133,6 @@ ingress:
alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]'
alb.ingress.kubernetes.io/scheme: internet-facing
alb.ingress.kubernetes.io/ssl-policy: ELBSecurityPolicy-TLS13-1-2-2021-06
alb.ingress.kubernetes.io/load-balancer-attributes: idle_timeout.timeout_seconds=600,client_keep_alive.seconds=590
alb.ingress.kubernetes.io/ssl-redirect: "443"
alb.ingress.kubernetes.io/target-type: ip
enabled: true

View File

@@ -146,30 +146,3 @@ export interface ApiErrorResponse {
details?: Record<string, string | string[] | number | number[] | boolean | boolean[]>;
responseMessage?: string;
}
export interface ClientErrorData {
title: string;
description: string;
showButtons?: boolean;
}
/**
* Helper function to get error data from any error for UI display
*/
export const getClientErrorData = (error: Error): ClientErrorData => {
// Check by error name as fallback (in case instanceof fails due to module loading issues)
if (error.name === "TooManyRequestsError") {
return {
title: "common.error_rate_limit_title",
description: "common.error_rate_limit_description",
showButtons: false,
};
}
// Default to general error for any other error
return {
title: "common.error_component_title",
description: "common.error_component_description",
showButtons: true,
};
};

1380
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff