mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-30 10:19:51 -06:00
Compare commits
3 Commits
v3.16.0
...
fix/allow-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
701539d862 | ||
|
|
a19b6dfeb4 | ||
|
|
46f38f5b67 |
@@ -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)
|
||||
|
||||
7
.github/workflows/e2e.yml
vendored
7
.github/workflows/e2e.yml
vendored
@@ -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-)
|
||||
|
||||
1
.github/workflows/sonarqube.yml
vendored
1
.github/workflows/sonarqube.yml
vendored
@@ -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: |
|
||||
|
||||
1
.github/workflows/test.yml
vendored
1
.github/workflows/test.yml
vendored
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
@@ -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) {
|
||||
|
||||
@@ -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." }],
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
381
apps/web/modules/cache/lib/cacheKeys.test.ts
vendored
381
apps/web/modules/cache/lib/cacheKeys.test.ts
vendored
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
3
apps/web/modules/cache/lib/cacheKeys.ts
vendored
3
apps/web/modules/cache/lib/cacheKeys.ts
vendored
@@ -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
|
||||
|
||||
261
apps/web/modules/cache/redis.test.ts
vendored
261
apps/web/modules/cache/redis.test.ts
vendored
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
70
apps/web/modules/cache/redis.ts
vendored
70
apps/web/modules/cache/redis.ts
vendored
@@ -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;
|
||||
|
||||
@@ -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."
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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>;
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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
1380
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user