diff --git a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/route.ts b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/route.ts index 200d319c06..8bf733ecfa 100644 --- a/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/route.ts +++ b/apps/web/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/route.ts @@ -92,7 +92,7 @@ export const GET = async (request: Request, props: { params: Promise { + const contactLinks = await Promise.all( + contacts.map(async (contact) => { const { contactId, attributes } = contact; - const surveyUrlResult = getContactSurveyLink( + const surveyUrlResult = await getContactSurveyLink( contactId, params.surveyId, query?.expirationDays || undefined @@ -107,10 +107,11 @@ export const GET = async ( expiresAt, }; }) - .filter(Boolean); + ); + const filteredContactLinks = contactLinks.filter(Boolean); return responses.successResponse({ - data: contactLinks, + data: filteredContactLinks, meta, }); }, diff --git a/apps/web/modules/ee/contacts/lib/contact-survey-link.test.ts b/apps/web/modules/ee/contacts/lib/contact-survey-link.test.ts index bbba2228ce..0699095e2c 100644 --- a/apps/web/modules/ee/contacts/lib/contact-survey-link.test.ts +++ b/apps/web/modules/ee/contacts/lib/contact-survey-link.test.ts @@ -1,8 +1,11 @@ import { ENCRYPTION_KEY } from "@/lib/constants"; import * as crypto from "@/lib/crypto"; import { getPublicDomain } from "@/lib/getPublicUrl"; +import { generateSurveySingleUseId } from "@/lib/utils/single-use-surveys"; +import { getSurvey } from "@/modules/survey/lib/survey"; import jwt from "jsonwebtoken"; import { beforeEach, describe, expect, test, vi } from "vitest"; +import { TSurvey } from "@formbricks/types/surveys/types"; import * as contactSurveyLink from "./contact-survey-link"; // Mock all modules needed (this gets hoisted to the top of the file) @@ -33,12 +36,22 @@ vi.mock("@/lib/crypto", () => ({ symmetricDecrypt: vi.fn(), })); +vi.mock("@/modules/survey/lib/survey", () => ({ + getSurvey: vi.fn(), +})); + +vi.mock("@/lib/utils/single-use-surveys", () => ({ + generateSurveySingleUseId: vi.fn(), +})); + describe("Contact Survey Link", () => { const mockContactId = "contact-123"; const mockSurveyId = "survey-456"; const mockToken = "mock.jwt.token"; const mockEncryptedContactId = "encrypted-contact-id"; const mockEncryptedSurveyId = "encrypted-survey-id"; + const mockedGetSurvey = vi.mocked(getSurvey); + const mockedGenerateSurveySingleUseId = vi.mocked(generateSurveySingleUseId); beforeEach(() => { vi.clearAllMocks(); @@ -60,11 +73,17 @@ describe("Contact Survey Link", () => { contactId: mockEncryptedContactId, surveyId: mockEncryptedSurveyId, } as any); + + mockedGetSurvey.mockResolvedValue({ + id: mockSurveyId, + singleUse: { enabled: false, isEncrypted: false }, + } as TSurvey); + mockedGenerateSurveySingleUseId.mockReturnValue("single-use-id"); }); describe("getContactSurveyLink", () => { - test("creates a survey link with encrypted contact and survey IDs", () => { - const result = contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId); + test("creates a survey link with encrypted contact and survey IDs", async () => { + const result = await contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId); // Verify encryption was called for both IDs expect(crypto.symmetricEncrypt).toHaveBeenCalledWith(mockContactId, ENCRYPTION_KEY); @@ -85,11 +104,13 @@ describe("Contact Survey Link", () => { ok: true, data: `${getPublicDomain()}/c/${mockToken}`, }); + + expect(mockedGenerateSurveySingleUseId).not.toHaveBeenCalled(); }); - test("adds expiration to the token when expirationDays is provided", () => { + test("adds expiration to the token when expirationDays is provided", async () => { const expirationDays = 7; - contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId, expirationDays); + await contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId, expirationDays); // Verify JWT sign was called with expiration expect(jwt.sign).toHaveBeenCalledWith( @@ -102,7 +123,55 @@ describe("Contact Survey Link", () => { ); }); - test("throws an error when ENCRYPTION_KEY is not available", async () => { + test("returns a not_found error when survey does not exist", async () => { + mockedGetSurvey.mockResolvedValue(null as unknown as TSurvey); + + const result = await contactSurveyLink.getContactSurveyLink(mockContactId, "unfound-survey-id"); + + expect(result).toEqual({ + ok: false, + error: { + type: "not_found", + message: "Survey not found", + details: [{ field: "surveyId", issue: "not_found" }], + }, + }); + expect(mockedGetSurvey).toHaveBeenCalledWith("unfound-survey-id"); + }); + + test("creates a link with unencrypted single use ID when enabled", async () => { + mockedGetSurvey.mockResolvedValue({ + id: mockSurveyId, + singleUse: { enabled: true, isEncrypted: false }, + } as TSurvey); + mockedGenerateSurveySingleUseId.mockReturnValue("suId-unencrypted"); + + const result = await contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId); + + expect(mockedGenerateSurveySingleUseId).toHaveBeenCalledWith(false); + expect(result).toEqual({ + ok: true, + data: `${getPublicDomain()}/c/${mockToken}?suId=suId-unencrypted`, + }); + }); + + test("creates a link with encrypted single use ID when enabled and encrypted", async () => { + mockedGetSurvey.mockResolvedValue({ + id: mockSurveyId, + singleUse: { enabled: true, isEncrypted: true }, + } as TSurvey); + mockedGenerateSurveySingleUseId.mockReturnValue("suId-encrypted"); + + const result = await contactSurveyLink.getContactSurveyLink(mockContactId, mockSurveyId); + + expect(mockedGenerateSurveySingleUseId).toHaveBeenCalledWith(true); + expect(result).toEqual({ + ok: true, + data: `${getPublicDomain()}/c/${mockToken}?suId=suId-encrypted`, + }); + }); + + test("returns an error when ENCRYPTION_KEY is not available", async () => { // Reset modules so the new mock is used by the module under test vi.resetModules(); // Re‑mock constants to simulate missing ENCRYPTION_KEY @@ -113,7 +182,7 @@ describe("Contact Survey Link", () => { // Re‑import the modules so they pick up the new mock const { getContactSurveyLink } = await import("./contact-survey-link"); - const result = getContactSurveyLink(mockContactId, mockSurveyId); + const result = await getContactSurveyLink(mockContactId, mockSurveyId); expect(result).toEqual({ ok: false, error: { @@ -141,7 +210,7 @@ describe("Contact Survey Link", () => { }); }); - test("throws an error when token verification fails", () => { + test("returns an error when token verification fails", () => { vi.mocked(jwt.verify).mockImplementation(() => { throw new Error("Token verification failed"); }); @@ -157,7 +226,7 @@ describe("Contact Survey Link", () => { } }); - test("throws an error when token has invalid format", () => { + test("returns an error when token has invalid format", () => { // Mock JWT.verify to return an incomplete payload vi.mocked(jwt.verify).mockReturnValue({ // Missing surveyId @@ -178,7 +247,7 @@ describe("Contact Survey Link", () => { } }); - test("throws an error when ENCRYPTION_KEY is not available", async () => { + test("returns an error when ENCRYPTION_KEY is not available", async () => { vi.resetModules(); vi.doMock("@/lib/constants", () => ({ ENCRYPTION_KEY: undefined, diff --git a/apps/web/modules/ee/contacts/lib/contact-survey-link.ts b/apps/web/modules/ee/contacts/lib/contact-survey-link.ts index 30cd4204df..8ec376096d 100644 --- a/apps/web/modules/ee/contacts/lib/contact-survey-link.ts +++ b/apps/web/modules/ee/contacts/lib/contact-survey-link.ts @@ -1,17 +1,19 @@ import { ENCRYPTION_KEY } from "@/lib/constants"; import { symmetricDecrypt, symmetricEncrypt } from "@/lib/crypto"; import { getPublicDomain } from "@/lib/getPublicUrl"; +import { generateSurveySingleUseId } from "@/lib/utils/single-use-surveys"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; +import { getSurvey } from "@/modules/survey/lib/survey"; import jwt from "jsonwebtoken"; import { logger } from "@formbricks/logger"; import { Result, err, ok } from "@formbricks/types/error-handlers"; // Creates an encrypted personalized survey link for a contact -export const getContactSurveyLink = ( +export const getContactSurveyLink = async ( contactId: string, surveyId: string, expirationDays?: number -): Result => { +): Promise> => { if (!ENCRYPTION_KEY) { return err({ type: "internal_server_error", @@ -19,10 +21,27 @@ export const getContactSurveyLink = ( }); } + const survey = await getSurvey(surveyId); + if (!survey) { + return err({ + type: "not_found", + message: "Survey not found", + details: [{ field: "surveyId", issue: "not_found" }], + }); + } + + const { enabled: isSingleUseEnabled, isEncrypted: isSingleUseEncrypted } = survey.singleUse ?? {}; + // Encrypt the contact and survey IDs const encryptedContactId = symmetricEncrypt(contactId, ENCRYPTION_KEY); const encryptedSurveyId = symmetricEncrypt(surveyId, ENCRYPTION_KEY); + let singleUseId: string | undefined; + + if (isSingleUseEnabled) { + singleUseId = generateSurveySingleUseId(isSingleUseEncrypted ?? false); + } + // Create JWT payload with encrypted IDs const payload = { contactId: encryptedContactId, @@ -43,7 +62,9 @@ export const getContactSurveyLink = ( const token = jwt.sign(payload, ENCRYPTION_KEY, tokenOptions); // Return the personalized URL - return ok(`${getPublicDomain()}/c/${token}`); + return singleUseId + ? ok(`${getPublicDomain()}/c/${token}?suId=${singleUseId}`) + : ok(`${getPublicDomain()}/c/${token}`); }; // Validates and decrypts a contact survey JWT token @@ -59,7 +80,10 @@ export const verifyContactSurveyToken = ( try { // Verify the token - const decoded = jwt.verify(token, ENCRYPTION_KEY) as { contactId: string; surveyId: string }; + const decoded = jwt.verify(token, ENCRYPTION_KEY) as { + contactId: string; + surveyId: string; + }; if (!decoded || !decoded.contactId || !decoded.surveyId) { throw err("Invalid token format"); diff --git a/apps/web/modules/ee/contacts/lib/contacts.ts b/apps/web/modules/ee/contacts/lib/contacts.ts index 499a44b151..a6a2a6bf0b 100644 --- a/apps/web/modules/ee/contacts/lib/contacts.ts +++ b/apps/web/modules/ee/contacts/lib/contacts.ts @@ -501,11 +501,11 @@ export const generatePersonalLinks = async (surveyId: string, segmentId: string, } // Generate survey links for each contact - const contactLinks = contactsResult - .map((contact) => { + const contactLinks = await Promise.all( + contactsResult.map(async (contact) => { const { contactId, attributes } = contact; - const surveyUrlResult = getContactSurveyLink(contactId, surveyId, expirationDays); + const surveyUrlResult = await getContactSurveyLink(contactId, surveyId, expirationDays); if (!surveyUrlResult.ok) { logger.error( @@ -522,7 +522,8 @@ export const generatePersonalLinks = async (surveyId: string, segmentId: string, expirationDays, }; }) - .filter(Boolean); + ); - return contactLinks; + const filteredContactLinks = contactLinks.filter(Boolean); + return filteredContactLinks; }; diff --git a/apps/web/modules/survey/link/contact-survey/page.tsx b/apps/web/modules/survey/link/contact-survey/page.tsx index aeeac245a6..063b48db04 100644 --- a/apps/web/modules/survey/link/contact-survey/page.tsx +++ b/apps/web/modules/survey/link/contact-survey/page.tsx @@ -3,6 +3,7 @@ import { getSurvey } from "@/modules/survey/lib/survey"; import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive"; import { renderSurvey } from "@/modules/survey/link/components/survey-renderer"; import { getExistingContactResponse } from "@/modules/survey/link/lib/data"; +import { checkAndValidateSingleUseId } from "@/modules/survey/link/lib/helper"; import { getBasicSurveyMetadata } from "@/modules/survey/link/lib/metadata-utils"; import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project"; import { getTranslate } from "@/tolgee/server"; @@ -14,6 +15,7 @@ interface ContactSurveyPageProps { jwt: string; }>; searchParams: Promise<{ + suId?: string; verify?: string; lang?: string; embed?: string; @@ -46,9 +48,10 @@ export const generateMetadata = async (props: ContactSurveyPageProps): Promise { const searchParams = await props.searchParams; const params = await props.params; + const t = await getTranslate(); const { jwt } = params; - const { preview } = searchParams; + const { preview, suId } = searchParams; const result = verifyContactSurveyToken(jwt); if (!result.ok) { @@ -62,6 +65,7 @@ export const ContactSurveyPage = async (props: ContactSurveyPageProps) => { // So we show SurveyInactive without project data (shows branding by default for backward compatibility) return ; } + const { surveyId, contactId } = result.data; const existingResponse = await getExistingContactResponse(surveyId, contactId)(); @@ -81,10 +85,26 @@ export const ContactSurveyPage = async (props: ContactSurveyPageProps) => { notFound(); } + const isSingleUseSurvey = survey?.singleUse?.enabled; + const isSingleUseSurveyEncrypted = survey?.singleUse?.isEncrypted; + + let singleUseId: string | undefined = undefined; + + if (isSingleUseSurvey) { + const validatedSingleUseId = checkAndValidateSingleUseId(suId, isSingleUseSurveyEncrypted); + if (!validatedSingleUseId) { + const project = await getProjectByEnvironmentId(survey.environmentId); + return ; + } + + singleUseId = validatedSingleUseId; + } + return renderSurvey({ survey, searchParams, contactId, isPreview, + singleUseId, }); }; diff --git a/apps/web/modules/survey/link/lib/helper.test.ts b/apps/web/modules/survey/link/lib/helper.test.ts index 59beb802bf..2f69b2ea94 100644 --- a/apps/web/modules/survey/link/lib/helper.test.ts +++ b/apps/web/modules/survey/link/lib/helper.test.ts @@ -1,11 +1,16 @@ +import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys"; import { verifyTokenForLinkSurvey } from "@/lib/jwt"; import { beforeEach, describe, expect, test, vi } from "vitest"; -import { getEmailVerificationDetails } from "./helper"; +import { checkAndValidateSingleUseId, getEmailVerificationDetails } from "./helper"; vi.mock("@/lib/jwt", () => ({ verifyTokenForLinkSurvey: vi.fn(), })); +vi.mock("@/app/lib/singleUseSurveys", () => ({ + validateSurveySingleUseId: vi.fn(), +})); + describe("getEmailVerificationDetails", () => { const mockedVerifyTokenForLinkSurvey = vi.mocked(verifyTokenForLinkSurvey); const testSurveyId = "survey-123"; @@ -54,3 +59,82 @@ describe("getEmailVerificationDetails", () => { expect(mockedVerifyTokenForLinkSurvey).toHaveBeenCalledWith(testToken, testSurveyId); }); }); + +describe("checkAndValidateSingleUseId", () => { + const mockedValidateSurveySingleUseId = vi.mocked(validateSurveySingleUseId); + + beforeEach(() => { + vi.resetAllMocks(); + }); + + test("returns null when no suid is provided", () => { + const result = checkAndValidateSingleUseId(); + + expect(result).toBeNull(); + expect(mockedValidateSurveySingleUseId).not.toHaveBeenCalled(); + }); + + test("returns null when suid is empty string", () => { + const result = checkAndValidateSingleUseId(""); + + expect(result).toBeNull(); + expect(mockedValidateSurveySingleUseId).not.toHaveBeenCalled(); + }); + + test("returns suid as-is when isEncrypted is false", () => { + const testSuid = "plain-suid-123"; + const result = checkAndValidateSingleUseId(testSuid, false); + + expect(result).toBe(testSuid); + expect(mockedValidateSurveySingleUseId).not.toHaveBeenCalled(); + }); + + test("returns suid as-is when isEncrypted is not provided (defaults to false)", () => { + const testSuid = "plain-suid-123"; + const result = checkAndValidateSingleUseId(testSuid); + + expect(result).toBe(testSuid); + expect(mockedValidateSurveySingleUseId).not.toHaveBeenCalled(); + }); + + test("returns validated suid when isEncrypted is true and validation succeeds", () => { + const encryptedSuid = "encrypted-suid-123"; + const validatedSuid = "validated-suid-456"; + mockedValidateSurveySingleUseId.mockReturnValueOnce(validatedSuid); + + const result = checkAndValidateSingleUseId(encryptedSuid, true); + + expect(result).toBe(validatedSuid); + expect(mockedValidateSurveySingleUseId).toHaveBeenCalledWith(encryptedSuid); + }); + + test("returns null when isEncrypted is true and validation returns undefined", () => { + const encryptedSuid = "invalid-encrypted-suid"; + mockedValidateSurveySingleUseId.mockReturnValueOnce(undefined); + + const result = checkAndValidateSingleUseId(encryptedSuid, true); + + expect(result).toBeNull(); + expect(mockedValidateSurveySingleUseId).toHaveBeenCalledWith(encryptedSuid); + }); + + test("returns null when isEncrypted is true and validation returns empty string", () => { + const encryptedSuid = "invalid-encrypted-suid"; + mockedValidateSurveySingleUseId.mockReturnValueOnce(""); + + const result = checkAndValidateSingleUseId(encryptedSuid, true); + + expect(result).toBeNull(); + expect(mockedValidateSurveySingleUseId).toHaveBeenCalledWith(encryptedSuid); + }); + + test("returns null when isEncrypted is true and validation returns null", () => { + const encryptedSuid = "invalid-encrypted-suid"; + mockedValidateSurveySingleUseId.mockReturnValueOnce(null as any); + + const result = checkAndValidateSingleUseId(encryptedSuid, true); + + expect(result).toBeNull(); + expect(mockedValidateSurveySingleUseId).toHaveBeenCalledWith(encryptedSuid); + }); +}); diff --git a/apps/web/modules/survey/link/lib/helper.ts b/apps/web/modules/survey/link/lib/helper.ts index 902b2d5491..85809001cd 100644 --- a/apps/web/modules/survey/link/lib/helper.ts +++ b/apps/web/modules/survey/link/lib/helper.ts @@ -1,4 +1,5 @@ import "server-only"; +import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys"; import { verifyTokenForLinkSurvey } from "@/lib/jwt"; interface emailVerificationDetails { @@ -25,3 +26,15 @@ export const getEmailVerificationDetails = async ( } } }; + +export const checkAndValidateSingleUseId = (suid?: string, isEncrypted = false): string | null => { + if (!suid?.trim()) return null; + + if (isEncrypted) { + const validatedSingleUseId = validateSurveySingleUseId(suid); + if (!validatedSingleUseId) return null; + return validatedSingleUseId; + } + + return suid; +}; diff --git a/apps/web/modules/survey/link/page.test.tsx b/apps/web/modules/survey/link/page.test.tsx index ca14b569f2..88c6f78e7e 100644 --- a/apps/web/modules/survey/link/page.test.tsx +++ b/apps/web/modules/survey/link/page.test.tsx @@ -13,6 +13,16 @@ import { logger } from "@formbricks/logger"; import { TSurvey } from "@formbricks/types/surveys/types"; import { LinkSurveyPage, generateMetadata } from "./page"; +// Mock server-side constants to prevent client-side access +vi.mock("@/lib/constants", () => ({ + IS_FORMBRICKS_CLOUD: false, + IS_RECAPTCHA_CONFIGURED: false, + RECAPTCHA_SITE_KEY: "test-key", + IMPRINT_URL: "https://example.com/imprint", + PRIVACY_URL: "https://example.com/privacy", + ENCRYPTION_KEY: "0".repeat(32), +})); + // Mock dependencies vi.mock("next/navigation", () => ({ notFound: vi.fn(), diff --git a/apps/web/modules/survey/link/page.tsx b/apps/web/modules/survey/link/page.tsx index ed060bbd0e..587d6c6766 100644 --- a/apps/web/modules/survey/link/page.tsx +++ b/apps/web/modules/survey/link/page.tsx @@ -1,7 +1,7 @@ -import { validateSurveySingleUseId } from "@/app/lib/singleUseSurveys"; import { SurveyInactive } from "@/modules/survey/link/components/survey-inactive"; import { renderSurvey } from "@/modules/survey/link/components/survey-renderer"; import { getResponseBySingleUseId, getSurveyWithMetadata } from "@/modules/survey/link/lib/data"; +import { checkAndValidateSingleUseId } from "@/modules/survey/link/lib/helper"; import { getProjectByEnvironmentId } from "@/modules/survey/link/lib/project"; import { getMetadataForLinkSurvey } from "@/modules/survey/link/metadata"; import type { Metadata } from "next"; @@ -60,23 +60,13 @@ export const LinkSurveyPage = async (props: LinkSurveyPageProps) => { let singleUseId: string | undefined = undefined; if (isSingleUseSurvey) { - // check if the single use id is present for single use surveys - if (!suId) { + const validatedSingleUseId = checkAndValidateSingleUseId(suId, isSingleUseSurveyEncrypted); + if (!validatedSingleUseId) { const project = await getProjectByEnvironmentId(survey.environmentId); return ; } - // if encryption is enabled, validate the single use id - let validatedSingleUseId: string | undefined = undefined; - if (isSingleUseSurveyEncrypted) { - validatedSingleUseId = validateSurveySingleUseId(suId); - if (!validatedSingleUseId) { - const project = await getProjectByEnvironmentId(survey.environmentId); - return ; - } - } - // if encryption is disabled, use the suId as is - singleUseId = validatedSingleUseId ?? suId; + singleUseId = validatedSingleUseId; } let singleUseResponse;