fix: fixes personalized links when single use id is enabled (#6270)

This commit is contained in:
Anshuman Pandey
2025-07-22 17:38:45 +05:30
committed by GitHub
parent 30fdcff737
commit 3803111b19
10 changed files with 252 additions and 40 deletions

View File

@@ -92,7 +92,7 @@ export const GET = async (request: Request, props: { params: Promise<TContactLin
});
}
const surveyUrlResult = getContactSurveyLink(params.contactId, params.surveyId, 7);
const surveyUrlResult = await getContactSurveyLink(params.contactId, params.surveyId, 7);
if (!surveyUrlResult.ok) {
return handleApiError(request, surveyUrlResult.error);

View File

@@ -82,11 +82,11 @@ export const GET = async (
}
// Generate survey links for each contact
const contactLinks = contacts
.map((contact) => {
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,
});
},

View File

@@ -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();
// Remock constants to simulate missing ENCRYPTION_KEY
@@ -113,7 +182,7 @@ describe("Contact Survey Link", () => {
// Reimport 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,

View File

@@ -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<string, ApiErrorResponseV2> => {
): Promise<Result<string, ApiErrorResponseV2>> => {
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");

View File

@@ -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;
};

View File

@@ -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<M
export const ContactSurveyPage = async (props: ContactSurveyPageProps) => {
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 <SurveyInactive status="link invalid" />;
}
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 <SurveyInactive status="link invalid" project={project ?? undefined} />;
}
singleUseId = validatedSingleUseId;
}
return renderSurvey({
survey,
searchParams,
contactId,
isPreview,
singleUseId,
});
};

View File

@@ -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);
});
});

View File

@@ -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;
};

View File

@@ -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(),

View File

@@ -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 <SurveyInactive status="link invalid" project={project ?? undefined} />;
}
// 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 <SurveyInactive status="link invalid" project={project ?? undefined} />;
}
}
// if encryption is disabled, use the suId as is
singleUseId = validatedSingleUseId ?? suId;
singleUseId = validatedSingleUseId;
}
let singleUseResponse;