fix: recaptcha feature bugs (#5599)

This commit is contained in:
Piyush Gupta
2025-05-02 12:41:51 +05:30
committed by GitHub
parent aad5a59e82
commit 3e6f558b08
16 changed files with 248 additions and 49 deletions

View File

@@ -125,7 +125,7 @@ export const updateSurveyAction = authenticatedActionClient
const { followUps } = parsedInput;
if (parsedInput.recaptcha?.enabled) {
await checkSpamProtectionPermission();
await checkSpamProtectionPermission(organizationId);
}
if (followUps?.length) {

View File

@@ -8,7 +8,6 @@ import {
capturePosthogEnvironmentEvent,
sendPlanLimitsReachedEventToPosthogWeekly,
} from "@/lib/posthogServer";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
@@ -48,6 +47,8 @@ vi.mock("./survey");
vi.mock("@/lib/constants", () => ({
IS_FORMBRICKS_CLOUD: true, // Default to false, override in specific tests
RECAPTCHA_SITE_KEY: "mock_recaptcha_site_key",
RECAPTCHA_SECRET_KEY: "mock_recaptcha_secret_key",
IS_RECAPTCHA_CONFIGURED: true,
IS_PRODUCTION: true,
IS_POSTHOG_CONFIGURED: false,
ENTERPRISE_LICENSE_KEY: "mock_enterprise_license_key",
@@ -255,7 +256,6 @@ describe("getEnvironmentState", () => {
vi.mocked(getProjectForEnvironmentState).mockResolvedValue(mockProject);
vi.mocked(getSurveysForEnvironmentState).mockResolvedValue(mockSurveys);
vi.mocked(getActionClassesForEnvironmentState).mockResolvedValue(mockActionClasses);
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(false); // Default to false
vi.mocked(getMonthlyOrganizationResponseCount).mockResolvedValue(50); // Default below limit
});
@@ -267,6 +267,7 @@ describe("getEnvironmentState", () => {
const result = await getEnvironmentState(environmentId);
const expectedData: TJsEnvironmentState["data"] = {
recaptchaSiteKey: "mock_recaptcha_site_key",
surveys: [mockSurveys[0]], // Only app, inProgress survey
actionClasses: mockActionClasses,
project: mockProject,
@@ -279,7 +280,6 @@ describe("getEnvironmentState", () => {
expect(getProjectForEnvironmentState).toHaveBeenCalledWith(environmentId);
expect(getSurveysForEnvironmentState).toHaveBeenCalledWith(environmentId);
expect(getActionClassesForEnvironmentState).toHaveBeenCalledWith(environmentId);
expect(getIsSpamProtectionEnabled).toHaveBeenCalled();
expect(prisma.environment.update).not.toHaveBeenCalled();
expect(capturePosthogEnvironmentEvent).not.toHaveBeenCalled();
expect(getMonthlyOrganizationResponseCount).toHaveBeenCalled(); // Not cloud
@@ -358,22 +358,12 @@ describe("getEnvironmentState", () => {
);
});
test("should include recaptchaSiteKey if spam protection is enabled", async () => {
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true);
test("should include recaptchaSiteKey if recaptcha variables are set", async () => {
const result = await getEnvironmentState(environmentId);
expect(result.data.recaptchaSiteKey).toBe("mock_recaptcha_site_key");
});
test("should not include recaptchaSiteKey if spam protection is disabled", async () => {
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(false);
const result = await getEnvironmentState(environmentId);
expect(result.data.recaptchaSiteKey).toBeUndefined();
});
test("should filter surveys correctly (only app type and inProgress status)", async () => {
const result = await getEnvironmentState(environmentId);
expect(result.data.surveys).toHaveLength(1);

View File

@@ -1,6 +1,6 @@
import { actionClassCache } from "@/lib/actionClass/cache";
import { cache } from "@/lib/cache";
import { IS_FORMBRICKS_CLOUD, RECAPTCHA_SITE_KEY } from "@/lib/constants";
import { IS_FORMBRICKS_CLOUD, IS_RECAPTCHA_CONFIGURED, RECAPTCHA_SITE_KEY } from "@/lib/constants";
import { environmentCache } from "@/lib/environment/cache";
import { getEnvironment } from "@/lib/environment/service";
import { organizationCache } from "@/lib/organization/cache";
@@ -14,7 +14,6 @@ import {
} from "@/lib/posthogServer";
import { projectCache } from "@/lib/project/cache";
import { surveyCache } from "@/lib/survey/cache";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { ResourceNotFoundError } from "@formbricks/types/errors";
@@ -99,7 +98,6 @@ export const getEnvironmentState = async (
getSurveysForEnvironmentState(environmentId),
getActionClassesForEnvironmentState(environmentId),
]);
const isSpamProtectionEnabled = await getIsSpamProtectionEnabled();
const filteredSurveys = surveys.filter(
(survey) => survey.type === "app" && survey.status === "inProgress"
@@ -109,7 +107,7 @@ export const getEnvironmentState = async (
surveys: !isMonthlyResponsesLimitReached ? filteredSurveys : [],
actionClasses,
project: project,
...(isSpamProtectionEnabled ? { recaptchaSiteKey: RECAPTCHA_SITE_KEY } : {}),
...(IS_RECAPTCHA_CONFIGURED ? { recaptchaSiteKey: RECAPTCHA_SITE_KEY } : {}),
};
return {

View File

@@ -9,7 +9,7 @@ export const checkFeaturePermissions = async (
organization: TOrganization
): Promise<Response | null> => {
if (surveyData.recaptcha?.enabled) {
const isSpamProtectionEnabled = await getIsSpamProtectionEnabled();
const isSpamProtectionEnabled = await getIsSpamProtectionEnabled(organization.billing.plan);
if (!isSpamProtectionEnabled) {
return responses.forbiddenResponse("Spam protection is not enabled for this organization");
}

View File

@@ -0,0 +1,81 @@
import { Organization } from "@prisma/client";
import { describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
import { getOrganizationBillingByEnvironmentId } from "./organization";
vi.mock("@formbricks/database", () => ({
prisma: {
organization: {
findFirst: vi.fn(),
},
},
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
},
}));
vi.mock("@/lib/cache", () => ({
cache: (fn: any) => fn,
}));
vi.mock("@/lib/organization/cache", () => ({
organizationCache: {
tag: {
byEnvironmentId: (id: string) => `tag-${id}`,
},
},
}));
vi.mock("react", () => ({
cache: (fn: any) => fn,
}));
describe("getOrganizationBillingByEnvironmentId", () => {
const environmentId = "env-123";
const mockBillingData: Organization["billing"] = {
limits: {
monthly: { miu: 0, responses: 0 },
projects: 3,
},
period: "monthly",
periodStart: new Date(),
plan: "scale",
stripeCustomerId: "mock-stripe-customer-id",
};
test("returns billing when organization is found", async () => {
vi.mocked(prisma.organization.findFirst).mockResolvedValue({ billing: mockBillingData });
const result = await getOrganizationBillingByEnvironmentId(environmentId);
expect(result).toEqual(mockBillingData);
expect(prisma.organization.findFirst).toHaveBeenCalledWith({
where: {
projects: {
some: {
environments: {
some: {
id: environmentId,
},
},
},
},
},
select: {
billing: true,
},
});
});
test("returns null when organization is not found", async () => {
vi.mocked(prisma.organization.findFirst).mockResolvedValueOnce(null);
const result = await getOrganizationBillingByEnvironmentId(environmentId);
expect(result).toBeNull();
});
test("logs error and returns null on exception", async () => {
const error = new Error("db error");
vi.mocked(prisma.organization.findFirst).mockRejectedValueOnce(error);
const result = await getOrganizationBillingByEnvironmentId(environmentId);
expect(result).toBeNull();
expect(logger.error).toHaveBeenCalledWith(error, "Failed to get organization billing by environment ID");
});
});

View File

@@ -0,0 +1,45 @@
import { cache } from "@/lib/cache";
import { organizationCache } from "@/lib/organization/cache";
import { Organization } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
export const getOrganizationBillingByEnvironmentId = reactCache(
async (environmentId: string): Promise<Organization["billing"] | null> =>
cache(
async () => {
try {
const organization = await prisma.organization.findFirst({
where: {
projects: {
some: {
environments: {
some: {
id: environmentId,
},
},
},
},
},
select: {
billing: true,
},
});
if (!organization) {
return null;
}
return organization.billing;
} catch (error) {
logger.error(error, "Failed to get organization billing by environment ID");
return null;
}
},
[`api-v2-client-getOrganizationBillingByEnvironmentId-${environmentId}`],
{
tags: [organizationCache.tag.byEnvironmentId(environmentId)],
}
)()
);

View File

@@ -1,9 +1,11 @@
import { getOrganizationBillingByEnvironmentId } from "@/app/api/v2/client/[environmentId]/responses/lib/organization";
import { verifyRecaptchaToken } from "@/app/api/v2/client/[environmentId]/responses/lib/recaptcha";
import { checkSurveyValidity } from "@/app/api/v2/client/[environmentId]/responses/lib/utils";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { responses } from "@/app/lib/api/response";
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { describe, expect, test, vi } from "vitest";
import { Organization } from "@prisma/client";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { logger } from "@formbricks/logger";
import { TSurvey } from "@formbricks/types/surveys/types";
@@ -14,6 +16,7 @@ vi.mock("@/app/api/v2/client/[environmentId]/responses/lib/recaptcha", () => ({
vi.mock("@/app/lib/api/response", () => ({
responses: {
badRequestResponse: vi.fn((message) => new Response(message, { status: 400 })),
notFoundResponse: vi.fn((message) => new Response(message, { status: 404 })),
},
}));
@@ -21,6 +24,10 @@ vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsSpamProtectionEnabled: vi.fn(),
}));
vi.mock("@/app/api/v2/client/[environmentId]/responses/lib/organization", () => ({
getOrganizationBillingByEnvironmentId: vi.fn(),
}));
vi.mock("@formbricks/logger", () => ({
logger: {
error: vi.fn(),
@@ -76,7 +83,22 @@ const mockResponseInput: TResponseInputV2 = {
meta: {},
};
const mockBillingData: Organization["billing"] = {
limits: {
monthly: { miu: 0, responses: 0 },
projects: 3,
},
period: "monthly",
periodStart: new Date(),
plan: "scale",
stripeCustomerId: "mock-stripe-customer-id",
};
describe("checkSurveyValidity", () => {
beforeEach(() => {
vi.resetAllMocks();
});
test("should return badRequestResponse if survey environmentId does not match", async () => {
const survey = { ...mockSurvey, environmentId: "env-2" };
const result = await checkSurveyValidity(survey, "env-1", mockResponseInput);
@@ -98,18 +120,6 @@ describe("checkSurveyValidity", () => {
expect(result).toBeNull();
});
test("should return null if recaptcha is enabled but spam protection is disabled", async () => {
const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 } };
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(false);
vi.mocked(verifyRecaptchaToken).mockResolvedValue(true);
const result = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
recaptchaToken: "test-token",
});
expect(result).toBeNull();
expect(logger.error).toHaveBeenCalledWith("Spam protection is not enabled for this organization");
});
test("should return badRequestResponse if recaptcha enabled, spam protection enabled, but token is missing", async () => {
const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 } };
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true);
@@ -127,11 +137,40 @@ describe("checkSurveyValidity", () => {
);
});
test("should return not found response if billing data is not found", async () => {
const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 } };
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true);
vi.mocked(getOrganizationBillingByEnvironmentId).mockResolvedValue(null);
const result = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
recaptchaToken: "test-token",
});
expect(result).toBeInstanceOf(Response);
expect(result?.status).toBe(404);
expect(responses.notFoundResponse).toHaveBeenCalledWith("Organization", null);
expect(getOrganizationBillingByEnvironmentId).toHaveBeenCalledWith("env-1");
});
test("should return null if recaptcha is enabled but spam protection is disabled", async () => {
const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 } };
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(false);
vi.mocked(verifyRecaptchaToken).mockResolvedValue(true);
vi.mocked(getOrganizationBillingByEnvironmentId).mockResolvedValue(mockBillingData);
const result = await checkSurveyValidity(survey, "env-1", {
...mockResponseInput,
recaptchaToken: "test-token",
});
expect(result).toBeNull();
expect(logger.error).toHaveBeenCalledWith("Spam protection is not enabled for this organization");
});
test("should return badRequestResponse if recaptcha verification fails", async () => {
const survey = { ...mockSurvey, recaptcha: { enabled: true, threshold: 0.5 } };
const responseInputWithToken = { ...mockResponseInput, recaptchaToken: "test-token" };
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true);
vi.mocked(verifyRecaptchaToken).mockResolvedValue(false);
vi.mocked(getOrganizationBillingByEnvironmentId).mockResolvedValue(mockBillingData);
const result = await checkSurveyValidity(survey, "env-1", responseInputWithToken);
expect(result).toBeInstanceOf(Response);
@@ -149,6 +188,7 @@ describe("checkSurveyValidity", () => {
const responseInputWithToken = { ...mockResponseInput, recaptchaToken: "test-token" };
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true);
vi.mocked(verifyRecaptchaToken).mockResolvedValue(true);
vi.mocked(getOrganizationBillingByEnvironmentId).mockResolvedValue(mockBillingData);
const result = await checkSurveyValidity(survey, "env-1", responseInputWithToken);
expect(result).toBeNull();

View File

@@ -1,3 +1,4 @@
import { getOrganizationBillingByEnvironmentId } from "@/app/api/v2/client/[environmentId]/responses/lib/organization";
import { verifyRecaptchaToken } from "@/app/api/v2/client/[environmentId]/responses/lib/recaptcha";
import { TResponseInputV2 } from "@/app/api/v2/client/[environmentId]/responses/types/response";
import { responses } from "@/app/lib/api/response";
@@ -34,7 +35,13 @@ export const checkSurveyValidity = async (
true
);
}
const isSpamProtectionEnabled = await getIsSpamProtectionEnabled();
const billing = await getOrganizationBillingByEnvironmentId(environmentId);
if (!billing) {
return responses.notFoundResponse("Organization", null);
}
const isSpamProtectionEnabled = await getIsSpamProtectionEnabled(billing.plan);
if (!isSpamProtectionEnabled) {
logger.error("Spam protection is not enabled for this organization");

View File

@@ -391,13 +391,18 @@ export const getIsSamlSsoEnabled = async (): Promise<boolean> => {
return licenseFeatures.sso && licenseFeatures.saml;
};
export const getIsSpamProtectionEnabled = async (): Promise<boolean> => {
export const getIsSpamProtectionEnabled = async (
billingPlan: Organization["billing"]["plan"]
): Promise<boolean> => {
if (!IS_RECAPTCHA_CONFIGURED) return false;
if (E2E_TESTING) {
const previousResult = await fetchLicenseForE2ETesting();
return previousResult?.features ? previousResult.features.spamProtection : false;
}
if (IS_FORMBRICKS_CLOUD)
return billingPlan === PROJECT_FEATURE_KEYS.SCALE || billingPlan === PROJECT_FEATURE_KEYS.ENTERPRISE;
const licenseFeatures = await getLicenseFeatures();
if (!licenseFeatures) return false;
return licenseFeatures.spamProtection;

View File

@@ -58,7 +58,7 @@ export const createSurveyAction = authenticatedActionClient
});
if (parsedInput.surveyBody.recaptcha?.enabled) {
await checkSpamProtectionPermission();
await checkSpamProtectionPermission(organizationId);
}
if (parsedInput.surveyBody.followUps?.length) {

View File

@@ -63,7 +63,7 @@ export const updateSurveyAction = authenticatedActionClient
});
if (parsedInput.recaptcha?.enabled) {
await checkSpamProtectionPermission();
await checkSpamProtectionPermission(organizationId);
}
if (parsedInput.followUps?.length) {

View File

@@ -67,7 +67,7 @@ export const SurveyEditorPage = async (props) => {
const isUserTargetingAllowed = await getIsContactsEnabled();
const isMultiLanguageAllowed = await getMultiLanguagePermission(organizationBilling.plan);
const isSurveyFollowUpsAllowed = await getSurveyFollowUpsPermission(organizationBilling.plan);
const isSpamProtectionAllowed = await getIsSpamProtectionEnabled();
const isSpamProtectionAllowed = await getIsSpamProtectionEnabled(organizationBilling.plan);
const userEmail = await getUserEmail(session.user.id);
const projectLanguages = await getProjectLanguages(projectWithTeamIds.id);

View File

@@ -1,27 +1,52 @@
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { Organization } from "@prisma/client";
import { cleanup } from "@testing-library/react";
import { afterEach, describe, expect, test, vi } from "vitest";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
import { checkSpamProtectionPermission } from "./permission";
vi.mock("@/modules/ee/license-check/lib/utils", () => ({
getIsSpamProtectionEnabled: vi.fn(),
}));
vi.mock("@/modules/survey/lib/survey", () => ({
getOrganizationBilling: vi.fn(),
}));
describe("checkSpamProtectionPermission", () => {
const mockOrganizationId = "mock-organization-id";
const mockBillingData: Organization["billing"] = {
limits: {
monthly: { miu: 0, responses: 0 },
projects: 3,
},
period: "monthly",
periodStart: new Date(),
plan: "scale",
stripeCustomerId: "mock-stripe-customer-id",
};
afterEach(() => {
cleanup();
});
test("throws ResourceNotFoundError if organization is not found", async () => {
vi.mocked(getOrganizationBilling).mockResolvedValue(null);
await expect(checkSpamProtectionPermission(mockOrganizationId)).rejects.toThrow(ResourceNotFoundError);
});
test("resolves if spam protection is enabled", async () => {
vi.mocked(getOrganizationBilling).mockResolvedValue(mockBillingData);
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(true);
await expect(checkSpamProtectionPermission()).resolves.toBeUndefined();
await expect(checkSpamProtectionPermission(mockOrganizationId)).resolves.toBeUndefined();
});
test("throws OperationNotAllowedError if spam protection is not enabled", async () => {
vi.mocked(getOrganizationBilling).mockResolvedValue(mockBillingData);
vi.mocked(getIsSpamProtectionEnabled).mockResolvedValue(false);
await expect(checkSpamProtectionPermission()).rejects.toThrow(OperationNotAllowedError);
await expect(checkSpamProtectionPermission()).rejects.toThrow(
await expect(checkSpamProtectionPermission(mockOrganizationId)).rejects.toThrow(OperationNotAllowedError);
await expect(checkSpamProtectionPermission(mockOrganizationId)).rejects.toThrow(
"Spam protection is not enabled for this organization"
);
});

View File

@@ -1,14 +1,21 @@
import { getIsSpamProtectionEnabled } from "@/modules/ee/license-check/lib/utils";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
import { OperationNotAllowedError, ResourceNotFoundError } from "@formbricks/types/errors";
/**
* Checks if the organization has spam protection enabled.
*
* @param {string} organizationId - The ID of the organization to check.
* @returns {Promise<void>} A promise that resolves if spam protection is enabled.
* @throws {ResourceNotFoundError} If the organization is not found.
* @throws {OperationNotAllowedError} If spam protection is not enabled for the organization.
*/
export const checkSpamProtectionPermission = async (): Promise<void> => {
const isSpamProtectionEnabled = await getIsSpamProtectionEnabled();
export const checkSpamProtectionPermission = async (organizationId: string): Promise<void> => {
const organizationBilling = await getOrganizationBilling(organizationId);
if (!organizationBilling) {
throw new ResourceNotFoundError("Organization", organizationId);
}
const isSpamProtectionEnabled = await getIsSpamProtectionEnabled(organizationBilling.plan);
if (!isSpamProtectionEnabled) {
throw new OperationNotAllowedError("Spam protection is not enabled for this organization");
}

View File

@@ -1,13 +1,14 @@
import {
IMPRINT_URL,
IS_FORMBRICKS_CLOUD,
IS_RECAPTCHA_CONFIGURED,
PRIVACY_URL,
RECAPTCHA_SITE_KEY,
WEBAPP_URL,
} from "@/lib/constants";
import { getSurveyDomain } from "@/lib/getSurveyUrl";
import { findMatchingLocale } from "@/lib/utils/locale";
import { getIsSpamProtectionEnabled, getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { getMultiLanguagePermission } from "@/modules/ee/license-check/lib/utils";
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
import { getResponseCountBySurveyId } from "@/modules/survey/lib/response";
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
@@ -57,8 +58,7 @@ export const renderSurvey = async ({
}
const isMultiLanguageAllowed = await getMultiLanguagePermission(organizationBilling.plan);
const isSpamProtectionAllowed = await getIsSpamProtectionEnabled();
const isSpamProtectionEnabled = Boolean(isSpamProtectionAllowed && survey.recaptcha?.enabled);
const isSpamProtectionEnabled = Boolean(IS_RECAPTCHA_CONFIGURED && survey.recaptcha?.enabled);
if (survey.status !== "inProgress" && !isPreview) {
return (

View File

@@ -38,6 +38,7 @@ export default defineConfig({
"modules/ui/components/environmentId-base-layout/*.tsx",
"modules/ui/components/survey/recaptcha.ts",
"app/api/v2/client/**/responses/lib/recaptcha.ts",
"app/api/v2/client/**/responses/lib/organization.ts",
"app/api/v2/client/**/responses/lib/utils.ts",
"modules/ui/components/progress-bar/index.tsx",
"app/(app)/environments/**/layout.tsx",