diff --git a/CHANGELOG.md b/CHANGELOG.md index f975533648a..eea11269354 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ The types of changes are: ### Changed - Increased max number of preferences allowed in privacy preference API calls [#4469](https://github.com/ethyca/fides/pull/4469) +- Reduce size of tcf_consent payload in fides_consent cookie [#4480](https://github.com/ethyca/fides/pull/4480) ### Fixed - Fix type errors when TCF vendors have no dataDeclaration [#4465](https://github.com/ethyca/fides/pull/4465) diff --git a/clients/fides-js/__tests__/lib/cookie.test.ts b/clients/fides-js/__tests__/lib/cookie.test.ts index ed0314a8c12..05403d19424 100644 --- a/clients/fides-js/__tests__/lib/cookie.test.ts +++ b/clients/fides-js/__tests__/lib/cookie.test.ts @@ -12,7 +12,7 @@ import { saveFidesCookie, transformTcfPreferencesToCookieKeys, updateCookieFromNoticePreferences, - updateExperienceFromCookieConsent, + updateExperienceFromCookieConsentNotices, } from "../../src/lib/cookie"; import type { ConsentContext } from "../../src/lib/consent-context"; import { @@ -23,13 +23,7 @@ import { SaveConsentPreference, UserConsentPreference, } from "../../src/lib/consent-types"; -import { - TCFPurposeConsentRecord, - TCFVendorConsentRecord, - TcfCookieConsent, - TcfExperienceRecords, - TcfSavePreferences, -} from "../../src/lib/tcf/types"; +import { TcfCookieConsent, TcfSavePreferences } from "../../src/lib/tcf/types"; // Setup mock date const MOCK_DATE = "2023-01-01T12:00:00.000Z"; @@ -346,11 +340,6 @@ describe("transformTcfPreferencesToCookieKeys", () => { it("can handle empty preferences", () => { const preferences: TcfSavePreferences = { purpose_consent_preferences: [] }; const expected: TcfCookieConsent = { - purpose_consent_preferences: {}, - purpose_legitimate_interests_preferences: {}, - special_feature_preferences: {}, - vendor_consent_preferences: {}, - vendor_legitimate_interests_preferences: {}, system_consent_preferences: {}, system_legitimate_interests_preferences: {}, }; @@ -383,11 +372,6 @@ describe("transformTcfPreferencesToCookieKeys", () => { ], }; const expected: TcfCookieConsent = { - purpose_consent_preferences: { 1: true }, - purpose_legitimate_interests_preferences: { 1: false }, - special_feature_preferences: { 1: true, 2: false }, - vendor_consent_preferences: { 1111: false }, - vendor_legitimate_interests_preferences: { 1111: true }, system_consent_preferences: { ctl_test_system: true }, system_legitimate_interests_preferences: { ctl_test_system: true }, }; @@ -408,37 +392,10 @@ describe("updateExperienceFromCookieConsent", () => { privacy_notices: notices, } as PrivacyExperience; - // TCF test data - const purposeRecords = [ - { id: 1 }, - { id: 2 }, - { id: 3 }, - ] as TCFPurposeConsentRecord[]; - const featureRecords = [ - { id: 4 }, - { id: 5 }, - { id: 6 }, - ] as TCFPurposeConsentRecord[]; - const vendorRecords = [ - { id: "1111" }, - { id: "ctl_test_system" }, - ] as TCFVendorConsentRecord[]; - const experienceWithTcf = { - tcf_purpose_consents: purposeRecords, - tcf_legitimate_interests_consent: purposeRecords, - tcf_special_purposes: purposeRecords, - tcf_features: featureRecords, - tcf_special_features: featureRecords, - tcf_vendor_consents: vendorRecords, - tcf_vendor_legitimate_interests: vendorRecords, - tcf_system_consents: vendorRecords, - tcf_system_legitimate_interests: vendorRecords, - } as unknown as PrivacyExperience; - describe("notices", () => { it("can handle an empty cookie", () => { const cookie = { ...baseCookie, consent: {} }; - const updatedExperience = updateExperienceFromCookieConsent({ + const updatedExperience = updateExperienceFromCookieConsentNotices({ experience: experienceWithNotices, cookie, }); @@ -451,7 +408,7 @@ describe("updateExperienceFromCookieConsent", () => { it("can handle updating preferences", () => { const cookie = { ...baseCookie, consent: { one: true, two: false } }; - const updatedExperience = updateExperienceFromCookieConsent({ + const updatedExperience = updateExperienceFromCookieConsentNotices({ experience: experienceWithNotices, cookie, }); @@ -470,7 +427,7 @@ describe("updateExperienceFromCookieConsent", () => { ...baseCookie, consent: { one: true, two: false, fake: true }, }; - const updatedExperience = updateExperienceFromCookieConsent({ + const updatedExperience = updateExperienceFromCookieConsentNotices({ experience: experienceWithNotices, cookie, }); @@ -484,165 +441,6 @@ describe("updateExperienceFromCookieConsent", () => { ]); }); }); - - describe("tcf", () => { - it("can handle an empty tcf cookie", () => { - const updatedExperience = updateExperienceFromCookieConsent({ - experience: experienceWithTcf, - cookie: baseCookie, - }); - expect(updatedExperience.tcf_purpose_consents).toEqual([ - { id: 1, current_preference: undefined }, - { - id: 2, - current_preference: undefined, - }, - { id: 3, current_preference: undefined }, - ]); - }); - - it("can handle updating preferences", () => { - const cookie = { - ...baseCookie, - tcf_consent: { - purpose_consent_preferences: { - 1: true, - 2: false, - }, - system_consent_preferences: { - 1111: true, - ctl_test_system: false, - }, - }, - }; - const updatedExperience = updateExperienceFromCookieConsent({ - experience: experienceWithTcf, - cookie, - }); - expect(updatedExperience.tcf_purpose_consents).toEqual([ - { id: 1, current_preference: UserConsentPreference.OPT_IN }, - { - id: 2, - current_preference: UserConsentPreference.OPT_OUT, - }, - { id: 3, current_preference: undefined }, - ]); - expect(updatedExperience.tcf_system_consents).toEqual([ - { id: "1111", current_preference: UserConsentPreference.OPT_IN }, - { - id: "ctl_test_system", - current_preference: UserConsentPreference.OPT_OUT, - }, - ]); - // The rest should be undefined - const keys: Array = [ - "tcf_purpose_legitimate_interests", - "tcf_special_purposes", - "tcf_features", - "tcf_special_features", - "tcf_vendor_consents", - "tcf_vendor_legitimate_interests", - "tcf_system_legitimate_interests", - ]; - keys.forEach((key) => { - updatedExperience[key]?.forEach((f) => { - expect(f.current_preference).toEqual(undefined); - }); - }); - }); - - it("can handle when cookie has values not in the experience", () => { - const cookie = { - ...baseCookie, - tcf_consent: { - purpose_consent_preferences: { - 1: true, - 2: false, - 555: false, - }, - }, - }; - const updatedExperience = updateExperienceFromCookieConsent({ - experience: experienceWithTcf, - cookie, - }); - expect(updatedExperience.tcf_purpose_consents).toEqual([ - { id: 1, current_preference: UserConsentPreference.OPT_IN }, - { - id: 2, - current_preference: UserConsentPreference.OPT_OUT, - }, - { id: 3, current_preference: undefined }, - ]); - - // The rest should be undefined - const keys: Array = [ - "tcf_purpose_legitimate_interests", - "tcf_special_purposes", - "tcf_features", - "tcf_special_features", - "tcf_vendor_consents", - "tcf_vendor_legitimate_interests", - "tcf_system_consents", - "tcf_system_legitimate_interests", - ]; - keys.forEach((key) => { - updatedExperience[key]?.forEach((f) => { - expect(f.current_preference).toEqual(undefined); - }); - }); - }); - }); - it("can handle both notices and tcf", () => { - const experience = { ...experienceWithNotices, ...experienceWithTcf }; - const cookie = { - ...baseCookie, - consent: { one: true, two: false }, - tcf_consent: { - purpose_consent_preferences: { - 1: true, - 2: false, - }, - }, - }; - const updatedExperience = updateExperienceFromCookieConsent({ - experience, - cookie, - }); - expect(updatedExperience.privacy_notices).toEqual([ - { notice_key: "one", current_preference: UserConsentPreference.OPT_IN }, - { - notice_key: "two", - current_preference: UserConsentPreference.OPT_OUT, - }, - { notice_key: "three", current_preference: undefined }, - ]); - expect(updatedExperience.tcf_purpose_consents).toEqual([ - { id: 1, current_preference: UserConsentPreference.OPT_IN }, - { - id: 2, - current_preference: UserConsentPreference.OPT_OUT, - }, - { id: 3, current_preference: undefined }, - ]); - - // The rest should be undefined - const keys: Array = [ - "tcf_purpose_legitimate_interests", - "tcf_special_purposes", - "tcf_features", - "tcf_special_features", - "tcf_vendor_consents", - "tcf_vendor_legitimate_interests", - "tcf_system_consents", - "tcf_system_legitimate_interests", - ]; - keys.forEach((key) => { - updatedExperience[key]?.forEach((f) => { - expect(f.current_preference).toEqual(undefined); - }); - }); - }); }); describe("updateCookieFromNoticePreferences", () => { diff --git a/clients/fides-js/__tests__/lib/tcf/utils.ts b/clients/fides-js/__tests__/lib/tcf/utils.ts new file mode 100644 index 00000000000..0e62a101612 --- /dev/null +++ b/clients/fides-js/__tests__/lib/tcf/utils.ts @@ -0,0 +1,175 @@ +import * as uuid from "uuid"; + +import { CookieAttributes } from "typescript-cookie/dist/types"; +import { makeFidesCookie } from "~/lib/cookie"; +import { + TcfExperienceRecords, + TCFPurposeConsentRecord, + TCFVendorConsentRecord, +} from "~/lib/tcf/types"; +import { updateExperienceFromCookieConsentTcf } from "~/lib/tcf/utils"; +import { PrivacyExperience, UserConsentPreference } from "~/lib/consent-types"; + +// Setup mock date +const MOCK_DATE = "2023-01-01T12:00:00.000Z"; +jest.useFakeTimers().setSystemTime(new Date(MOCK_DATE)); + +// Setup mock uuid +const MOCK_UUID = "fae7e16d-37fd-40ed-b2a8-a020ad90106d"; +jest.mock("uuid"); +const mockUuid = jest.mocked(uuid); +mockUuid.v4.mockReturnValue(MOCK_UUID); + +// Setup mock typescript-cookie +// NOTE: the default module mocking just *doesn't* work for typescript-cookie +// for some mysterious reason (see note in jest.config.js), so we define a +// minimal mock implementation here +const mockGetCookie = jest.fn((): string | undefined => "mockGetCookie return"); +const mockSetCookie = jest.fn( + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + (name: string, value: string, attributes: object, encoding: object) => + `mock setCookie return (value=${value})` +); +const mockRemoveCookie = jest.fn( + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + (name: string, attributes?: CookieAttributes) => undefined +); +jest.mock("typescript-cookie", () => ({ + getCookie: () => mockGetCookie(), + setCookie: ( + name: string, + value: string, + attributes: object, + encoding: object + ) => mockSetCookie(name, value, attributes, encoding), + removeCookie: (name: string, attributes?: CookieAttributes) => + mockRemoveCookie(name, attributes), +})); + +describe("updateExperienceFromCookieConsentTcf", () => { + const baseCookie = makeFidesCookie(); + + // TCF test data + const purposeRecords = [ + { id: 1 }, + { id: 2 }, + { id: 3 }, + ] as TCFPurposeConsentRecord[]; + const featureRecords = [ + { id: 4 }, + { id: 5 }, + { id: 6 }, + ] as TCFPurposeConsentRecord[]; + const vendorRecords = [ + { id: "1111" }, + { id: "ctl_test_system" }, + ] as TCFVendorConsentRecord[]; + const experienceWithTcf = { + tcf_purpose_consents: purposeRecords, + tcf_legitimate_interests_consent: purposeRecords, + tcf_special_purposes: purposeRecords, + tcf_features: featureRecords, + tcf_special_features: featureRecords, + tcf_vendor_consents: vendorRecords, + tcf_vendor_legitimate_interests: vendorRecords, + tcf_system_consents: vendorRecords, + tcf_system_legitimate_interests: vendorRecords, + } as unknown as PrivacyExperience; + + describe("tcf", () => { + it("can handle an empty tcf cookie", () => { + const updatedExperience = updateExperienceFromCookieConsentTcf({ + experience: experienceWithTcf, + cookie: baseCookie, + }); + expect(updatedExperience.tcf_purpose_consents).toEqual([ + { id: 1, current_preference: undefined }, + { + id: 2, + current_preference: undefined, + }, + { id: 3, current_preference: undefined }, + ]); + }); + + it("can handle updating preferences", () => { + const cookie = { + ...baseCookie, + tcf_consent: { + system_consent_preferences: { + 1111: true, + ctl_test_system: false, + }, + }, + }; + const updatedExperience = updateExperienceFromCookieConsentTcf({ + experience: experienceWithTcf, + cookie, + }); + expect(updatedExperience.tcf_system_consents).toEqual([ + { id: "1111", current_preference: UserConsentPreference.OPT_IN }, + { + id: "ctl_test_system", + current_preference: UserConsentPreference.OPT_OUT, + }, + ]); + // The rest should be undefined + const keys: Array = [ + "tcf_purpose_legitimate_interests", + "tcf_special_purposes", + "tcf_features", + "tcf_special_features", + "tcf_vendor_consents", + "tcf_purpose_consents", + "tcf_vendor_legitimate_interests", + "tcf_system_legitimate_interests", + ]; + keys.forEach((key) => { + updatedExperience[key]?.forEach((f) => { + expect(f.current_preference).toEqual(undefined); + }); + }); + }); + + it("can handle when cookie has values not in the experience", () => { + const cookie = { + ...baseCookie, + tcf_consent: { + system_consent_preferences: { + 1111: false, + 2: false, + 555: false, + }, + }, + }; + const updatedExperience = updateExperienceFromCookieConsentTcf({ + experience: experienceWithTcf, + cookie, + }); + expect(updatedExperience.tcf_system_consents).toEqual([ + { id: "1111", current_preference: UserConsentPreference.OPT_OUT }, + { + id: "ctl_test_system", + current_preference: undefined, + }, + ]); + + // The rest should be undefined + const keys: Array = [ + "tcf_purpose_legitimate_interests", + "tcf_special_purposes", + "tcf_features", + "tcf_special_features", + "tcf_vendor_consents", + "tcf_vendor_legitimate_interests", + "tcf_purpose_consents", + "tcf_system_legitimate_interests", + ]; + keys.forEach((key) => { + updatedExperience[key]?.forEach((f) => { + expect(f.current_preference).toEqual(undefined); + }); + }); + }); + }); +}); diff --git a/clients/fides-js/src/fides-tcf.ts b/clients/fides-js/src/fides-tcf.ts index ba2cf4f82ec..7518031ce30 100644 --- a/clients/fides-js/src/fides-tcf.ts +++ b/clients/fides-js/src/fides-tcf.ts @@ -46,6 +46,7 @@ * ``` */ import type { TCData } from "@iabtechlabtcf/cmpapi"; +import { TCString } from "@iabtechlabtcf/core"; import { gtm } from "./integrations/gtm"; import { meta } from "./integrations/meta"; import { shopify } from "./integrations/shopify"; @@ -70,13 +71,9 @@ import { import type { Fides } from "./lib/initialize"; import { dispatchFidesEvent } from "./lib/events"; import { - buildTcfEntitiesFromCookie, debugLog, - experienceIsValid, FidesCookie, hasSavedTcfPreferences, - isPrivacyExperience, - tcfConsentCookieObjHasSomeConsentSet, transformTcfPreferencesToCookieKeys, transformUserPreferenceToBoolean, } from "./fides"; @@ -86,14 +83,15 @@ import { TcfModelsRecord, TcfSavePreferences, } from "./lib/tcf/types"; -import { TCF_KEY_MAP } from "./lib/tcf/constants"; -import { - generateFidesStringFromCookieTcfConsent, - transformFidesStringToCookieKeys, -} from "./lib/tcf/utils"; +import { FIDES_SYSTEM_COOKIE_KEY_MAP, TCF_KEY_MAP } from "./lib/tcf/constants"; import type { GppFunction } from "./lib/gpp/types"; import { makeStub } from "./lib/tcf/stub"; import { customGetConsentPreferences } from "./services/external/preferences"; +import { decodeFidesString } from "./lib/tcf/fidesString"; +import { + buildTcfEntitiesFromCookieAndFidesString, + updateExperienceFromCookieConsentTcf, +} from "./lib/tcf/utils"; declare global { interface Window { @@ -152,7 +150,10 @@ const updateCookieAndExperience = async ({ "Overriding preferences from client-side fetched experience with cookie fides_string consent", cookie.fides_string ); - const tcfEntities = buildTcfEntitiesFromCookie(experience, cookie); + const tcfEntities = buildTcfEntitiesFromCookieAndFidesString( + experience, + cookie + ); return { cookie, experience: tcfEntities }; } @@ -162,8 +163,11 @@ const updateCookieAndExperience = async ({ } // If the user has prefs on a client-side fetched experience, but there is no fides_string, - // we need to use the prefs on the experience to generate a fidesString and cookie.tcf_consent - const tcSavePrefs: TcfSavePreferences = {}; + // we need to use the prefs on the experience to generate + // 1. a fidesString + // 2. a cookie.tcf_consent (which only has system preferences since those are not captured in the fidesString) + + // 1. Generate a fidesString from the experience const enabledIds: EnabledIds = { purposesConsent: [], purposesLegint: [], @@ -173,17 +177,9 @@ const updateCookieAndExperience = async ({ vendorsConsent: [], vendorsLegint: [], }; - - TCF_KEY_MAP.forEach(({ experienceKey, cookieKey, enabledIdsKey }) => { - tcSavePrefs[cookieKey] = []; + TCF_KEY_MAP.forEach(({ experienceKey, enabledIdsKey }) => { experience[experienceKey]?.forEach((record) => { const pref: UserConsentPreference = getInitialPreference(record); - // map experience to tcSavePrefs (same as cookie keys) - tcSavePrefs[cookieKey]?.push({ - // @ts-ignore - id: record.id, - preference: pref, - }); // add to enabledIds only if user consent is True if (transformUserPreferenceToBoolean(pref)) { if (enabledIdsKey) { @@ -192,52 +188,29 @@ const updateCookieAndExperience = async ({ } }); }); - const fidesString = await generateFidesString({ experience, tcStringPreferences: enabledIds, }); + + // 2. Generate a cookie object from the experience + const tcSavePrefs: TcfSavePreferences = {}; + FIDES_SYSTEM_COOKIE_KEY_MAP.forEach(({ cookieKey, experienceKey }) => { + tcSavePrefs[cookieKey] = []; + experience[experienceKey]?.forEach((record) => { + const preference = getInitialPreference(record); + tcSavePrefs[cookieKey]?.push({ id: `${record.id}`, preference }); + }); + }); const tcfConsent = transformTcfPreferencesToCookieKeys(tcSavePrefs); + + // Return the updated cookie return { cookie: { ...cookie, fides_string: fidesString, tcf_consent: tcfConsent }, experience, }; }; -/** - * If a fidesString is provided either explicitly or retrieved with a custom get preferences fn, - * we override the associated cookie props, which are then used to override associated props in the experience. - */ -const updateFidesCookieFromString = ( - cookie: FidesCookie, - fidesString: string, - debug: boolean, - fidesStringVersionHash: string | undefined -): { cookie: FidesCookie; success: boolean } => { - debugLog( - debug, - "Explicit fidesString detected. Proceeding to override all TCF preferences with given fidesString" - ); - try { - const cookieKeys = transformFidesStringToCookieKeys(fidesString, debug); - return { - cookie: { - ...cookie, - tcf_consent: cookieKeys, - fides_string: fidesString, - tcf_version_hash: fidesStringVersionHash ?? cookie.tcf_version_hash, - }, - success: true, - }; - } catch (error) { - debugLog( - debug, - `Could not decode tcString from ${fidesString}, it may be invalid. ${error}` - ); - return { cookie, success: false }; - } -}; - /** * Initialize the global Fides object with the given configuration values */ @@ -263,35 +236,32 @@ const init = async (config: FidesConfig) => { ...getInitialCookie(config), ...overrides.consentPrefsOverrides?.consent, }; - if (config.options.fidesString) { - const { cookie: updatedCookie, success } = updateFidesCookieFromString( - cookie, - config.options.fidesString, - config.options.debug, - overrides.consentPrefsOverrides?.version_hash - ); - if (success) { + // Update the fidesString if we have an override and the TC portion is valid + const { fidesString } = config.options; + if (fidesString) { + try { + // Make sure TC string is valid before we assign it + const { tc: tcString } = decodeFidesString(fidesString); + TCString.decode(tcString); + const updatedCookie: Partial = { + fides_string: fidesString, + tcf_version_hash: + overrides.consentPrefsOverrides?.version_hash ?? + cookie.tcf_version_hash, + }; Object.assign(cookie, updatedCookie); + } catch (error) { + debugLog( + config.options.debug, + `Could not decode tcString from ${fidesString}, it may be invalid. ${error}` + ); } - } else if ( - tcfConsentCookieObjHasSomeConsentSet(cookie.tcf_consent) && - !cookie.fides_string && - isPrivacyExperience(config.experience) && - experienceIsValid(config.experience, config.options) - ) { - // This state should not be hit, but just in case: if fidesString is missing on cookie but we have tcf consent, - // we should generate fidesString so that our CMP API accurately reflects user preference - cookie.fides_string = await generateFidesStringFromCookieTcfConsent( - config.experience, - cookie.tcf_consent - ); - debugLog( - config.options.debug, - "fides_string was missing from cookie, so it has been generated based on tcf_consent", - cookie.fides_string - ); } - const initialFides = getInitialFides({ ...config, cookie }); + const initialFides = getInitialFides({ + ...config, + cookie, + updateExperienceFromCookieConsent: updateExperienceFromCookieConsentTcf, + }); // Initialize the CMP API early so that listeners are established initializeTcfCmpApi(); if (initialFides) { diff --git a/clients/fides-js/src/fides.ts b/clients/fides-js/src/fides.ts index 4f6dcb60ffc..ccea66fbdcf 100644 --- a/clients/fides-js/src/fides.ts +++ b/clients/fides-js/src/fides.ts @@ -51,7 +51,7 @@ import { shopify } from "./integrations/shopify"; import { FidesCookie, buildCookieConsentForExperiences, - updateExperienceFromCookieConsent, + updateExperienceFromCookieConsentNotices, consentCookieObjHasSomeConsentSet, } from "./lib/cookie"; import { @@ -101,7 +101,7 @@ const updateCookie = async ( if (isExperienceClientSideFetched && preferencesExistOnCookie) { // If we have some preferences on the cookie, we update client-side experience with those preferences // if the name matches - updatedExperience = updateExperienceFromCookieConsent({ + updatedExperience = updateExperienceFromCookieConsentNotices({ experience, cookie: oldCookie, debug, @@ -138,7 +138,11 @@ const init = async (config: FidesConfig) => { ...getInitialCookie(config), ...overrides.consentPrefsOverrides?.consent, }; - const initialFides = getInitialFides({ ...config, cookie }); + const initialFides = getInitialFides({ + ...config, + cookie, + updateExperienceFromCookieConsent: updateExperienceFromCookieConsentNotices, + }); if (initialFides) { Object.assign(_Fides, initialFides); dispatchFidesEvent("FidesInitialized", cookie, config.options.debug); diff --git a/clients/fides-js/src/lib/cookie.ts b/clients/fides-js/src/lib/cookie.ts index f531e7a688a..793a610368d 100644 --- a/clients/fides-js/src/lib/cookie.ts +++ b/clients/fides-js/src/lib/cookie.ts @@ -7,7 +7,6 @@ import { resolveLegacyConsentValue, } from "./consent-value"; import { - ConsentMechanism, Cookies, ExperienceMeta, LegacyConsentConfig, @@ -20,8 +19,7 @@ import { transformUserPreferenceToBoolean, } from "./consent-utils"; import type { TcfCookieConsent, TcfSavePreferences } from "./tcf/types"; -import { TCF_KEY_MAP } from "./tcf/constants"; -import { TcfCookieKeyConsent } from "./tcf/types"; +import { FIDES_SYSTEM_COOKIE_KEY_MAP } from "./tcf/constants"; /** * Store the user's consent preferences on the cookie, as key -> boolean pairs, e.g. @@ -84,17 +82,6 @@ const CODEC: Types.CookieCodecConfig = { encodeValue: encodeURIComponent, }; -export const tcfConsentCookieObjHasSomeConsentSet = ( - tcf_consent: TcfCookieConsent | undefined -): boolean => { - if (!tcf_consent) { - return false; - } - return Object.values(tcf_consent).some( - (val: TcfCookieKeyConsent) => Object.keys(val).length >= 0 - ); -}; - export const consentCookieObjHasSomeConsentSet = ( consent: CookieKeyConsent | undefined ): boolean => { @@ -285,51 +272,6 @@ export const buildCookieConsentForExperiences = ( return cookieConsent; }; -/** - * Populates TCF entities with items from cookie.tcf_consent. - * Returns TCF entities to be assigned to an experience. - */ -export const buildTcfEntitiesFromCookie = ( - experience: PrivacyExperience, - cookie: FidesCookie -) => { - const tcfEntities = { - tcf_purpose_consents: experience.tcf_purpose_consents, - tcf_purpose_legitimate_interests: - experience.tcf_purpose_legitimate_interests, - tcf_special_purposes: experience.tcf_special_purposes, - tcf_features: experience.tcf_features, - tcf_special_features: experience.tcf_special_features, - tcf_vendor_consents: experience.tcf_vendor_consents, - tcf_vendor_legitimate_interests: experience.tcf_vendor_legitimate_interests, - tcf_system_consents: experience.tcf_system_consents, - tcf_system_legitimate_interests: experience.tcf_system_legitimate_interests, - }; - - if (cookie.tcf_consent) { - TCF_KEY_MAP.forEach(({ cookieKey, experienceKey }) => { - const cookieConsent = cookie.tcf_consent[cookieKey] ?? {}; - // @ts-ignore the array map should ensure we will get the right record type - tcfEntities[experienceKey] = experience[experienceKey]?.map((item) => { - const defaultPreference = cookie.fides_string - ? ConsentMechanism.OPT_OUT - : item.default_preference; - const preference = Object.hasOwn(cookieConsent, item.id) - ? transformConsentToFidesUserPreference( - Boolean(cookieConsent[item.id]), - ConsentMechanism.OPT_IN - ) - : // If experience contains a tcf entity not defined by tcfEntities, this means: - // A) If fides_string exists, user has probably opted out. Since opt-outs are not tracked by TC string, in this case we assume opt-out. - // B) There is a new tcf entity that requires consent. In this case we would use the default on the experience. - defaultPreference; - return { ...item, current_preference: preference }; - }); - }); - } - return tcfEntities; -}; - /** * Updates prefetched experience, based on: * 1) experience: pre-fetched or client-side experience-based consent configuration @@ -338,7 +280,7 @@ export const buildTcfEntitiesFromCookie = ( * * Returns updated experience with user preferences. */ -export const updateExperienceFromCookieConsent = ({ +export const updateExperienceFromCookieConsentNotices = ({ experience, cookie, debug, @@ -360,9 +302,6 @@ export const updateExperienceFromCookieConsent = ({ return { ...notice, current_preference: preference }; }); - // Handle the TCF case, which has many keys to query - const tcfEntities = buildTcfEntitiesFromCookie(experience, cookie); - if (debug) { debugLog( debug, @@ -370,14 +309,14 @@ export const updateExperienceFromCookieConsent = ({ experience ); } - return { ...experience, ...tcfEntities, privacy_notices: noticesWithConsent }; + return { ...experience, privacy_notices: noticesWithConsent }; }; export const transformTcfPreferencesToCookieKeys = ( tcfPreferences: TcfSavePreferences ): TcfCookieConsent => { const cookieKeys: TcfCookieConsent = {}; - TCF_KEY_MAP.forEach(({ cookieKey }) => { + FIDES_SYSTEM_COOKIE_KEY_MAP.forEach(({ cookieKey }) => { const preferences = tcfPreferences[cookieKey] ?? []; cookieKeys[cookieKey] = Object.fromEntries( preferences.map((pref) => [ diff --git a/clients/fides-js/src/lib/initialize.ts b/clients/fides-js/src/lib/initialize.ts index a5896776fdd..dfd7812f044 100644 --- a/clients/fides-js/src/lib/initialize.ts +++ b/clients/fides-js/src/lib/initialize.ts @@ -13,7 +13,6 @@ import { isNewFidesCookie, makeConsentDefaultsLegacy, updateCookieFromNoticePreferences, - updateExperienceFromCookieConsent, } from "./cookie"; import { ConsentMechanism, @@ -219,9 +218,16 @@ export const getInitialFides = ({ experience, geolocation, options, + updateExperienceFromCookieConsent, }: { cookie: FidesCookie; -} & FidesConfig): Partial | null => { +} & FidesConfig & { + updateExperienceFromCookieConsent: (props: { + experience: PrivacyExperience; + cookie: FidesCookie; + debug: boolean; + }) => PrivacyExperience; + }): Partial | null => { const hasExistingCookie = !isNewFidesCookie(cookie); if (!hasExistingCookie && !options.fidesString) { // A TC str can be injected and take effect even if the user has no previous Fides Cookie diff --git a/clients/fides-js/src/lib/tcf/constants.ts b/clients/fides-js/src/lib/tcf/constants.ts index 6c77f6a56f8..9cbdadace7e 100644 --- a/clients/fides-js/src/lib/tcf/constants.ts +++ b/clients/fides-js/src/lib/tcf/constants.ts @@ -1,4 +1,3 @@ -import { TCModel } from "@iabtechlabtcf/core"; import { EnabledIds, LegalBasisEnum, @@ -18,41 +17,48 @@ export const ETHYCA_CMP_ID = 407; export const FIDES_SEPARATOR = ","; export const TCF_KEY_MAP: { - cookieKey: TcfModelType; experienceKey: keyof TcfExperienceRecords; - tcfModelKey?: keyof TCModel; - enabledIdsKey?: keyof EnabledIds; + tcfModelKey: + | "purposeConsents" + | "purposeLegitimateInterests" + | "specialFeatureOptins" + | "vendorConsents" + | "vendorLegitimateInterests"; + enabledIdsKey: keyof EnabledIds; }[] = [ { - cookieKey: "purpose_consent_preferences", experienceKey: "tcf_purpose_consents", tcfModelKey: "purposeConsents", enabledIdsKey: "purposesConsent", }, { - cookieKey: "purpose_legitimate_interests_preferences", experienceKey: "tcf_purpose_legitimate_interests", tcfModelKey: "purposeLegitimateInterests", enabledIdsKey: "purposesLegint", }, { - cookieKey: "special_feature_preferences", experienceKey: "tcf_special_features", tcfModelKey: "specialFeatureOptins", enabledIdsKey: "specialFeatures", }, { - cookieKey: "vendor_consent_preferences", experienceKey: "tcf_vendor_consents", tcfModelKey: "vendorConsents", enabledIdsKey: "vendorsConsent", }, { - cookieKey: "vendor_legitimate_interests_preferences", experienceKey: "tcf_vendor_legitimate_interests", tcfModelKey: "vendorLegitimateInterests", enabledIdsKey: "vendorsLegint", }, +]; + +// These preferences are stored in the cooke on `tcf_consent` instead of `fides_string` because they +// pertain to Fides Systems instead of vendors on the FidesString. +export const FIDES_SYSTEM_COOKIE_KEY_MAP: { + cookieKey: TcfModelType; + experienceKey: keyof TcfExperienceRecords; +}[] = [ { cookieKey: "system_consent_preferences", experienceKey: "tcf_system_consents", diff --git a/clients/fides-js/src/lib/tcf/types.ts b/clients/fides-js/src/lib/tcf/types.ts index 3465f789099..a9b70134eaf 100644 --- a/clients/fides-js/src/lib/tcf/types.ts +++ b/clients/fides-js/src/lib/tcf/types.ts @@ -235,11 +235,6 @@ export type TcfCookieKeyConsent = { }; export interface TcfCookieConsent { - purpose_consent_preferences?: TcfCookieKeyConsent; - purpose_legitimate_interests_preferences?: TcfCookieKeyConsent; - special_feature_preferences?: TcfCookieKeyConsent; - vendor_consent_preferences?: TcfCookieKeyConsent; - vendor_legitimate_interests_preferences?: TcfCookieKeyConsent; system_consent_preferences?: TcfCookieKeyConsent; system_legitimate_interests_preferences?: TcfCookieKeyConsent; } diff --git a/clients/fides-js/src/lib/tcf/utils.ts b/clients/fides-js/src/lib/tcf/utils.ts index 54edf6dd9b8..0e5c8e6f0a1 100644 --- a/clients/fides-js/src/lib/tcf/utils.ts +++ b/clients/fides-js/src/lib/tcf/utils.ts @@ -1,79 +1,119 @@ -import { TCModel, TCString, Vector } from "@iabtechlabtcf/core"; -import { PrivacyExperience } from "../consent-types"; -import { EnabledIds, TcfCookieConsent, TcfCookieKeyConsent } from "./types"; -import { TCF_KEY_MAP } from "./constants"; -import { generateFidesString } from "../tcf"; -import { debugLog } from "../consent-utils"; +import { TCString } from "@iabtechlabtcf/core"; +import { ConsentMechanism, PrivacyExperience } from "../consent-types"; +import { FidesCookie } from "../cookie"; +import { + debugLog, + transformConsentToFidesUserPreference, +} from "../consent-utils"; +import { FIDES_SYSTEM_COOKIE_KEY_MAP, TCF_KEY_MAP } from "./constants"; import { decodeFidesString, idsFromAcString } from "./fidesString"; -export const transformFidesStringToCookieKeys = ( - fidesString: string, - debug: boolean -): TcfCookieConsent => { - const { tc: tcString, ac: acString } = decodeFidesString(fidesString); - const tcModel: TCModel = TCString.decode(tcString); +/** + * Populates TCF entities with items from both cookie.tcf_consent and cookie.fides_string. + * We must look at both because they contain non-overlapping info that is required for a complete TCFEntities object. + * Returns TCF entities to be assigned to an experience. + */ +export const buildTcfEntitiesFromCookieAndFidesString = ( + experience: PrivacyExperience, + cookie: FidesCookie +) => { + const tcfEntities = { + tcf_purpose_consents: experience.tcf_purpose_consents, + tcf_purpose_legitimate_interests: + experience.tcf_purpose_legitimate_interests, + tcf_special_purposes: experience.tcf_special_purposes, + tcf_features: experience.tcf_features, + tcf_special_features: experience.tcf_special_features, + tcf_vendor_consents: experience.tcf_vendor_consents, + tcf_vendor_legitimate_interests: experience.tcf_vendor_legitimate_interests, + tcf_system_consents: experience.tcf_system_consents, + tcf_system_legitimate_interests: experience.tcf_system_legitimate_interests, + }; + + // First update tcfEntities based on the `cookie.tcf_consent` obj + FIDES_SYSTEM_COOKIE_KEY_MAP.forEach(({ cookieKey, experienceKey }) => { + const cookieConsent = cookie.tcf_consent[cookieKey] ?? {}; + // @ts-ignore the array map should ensure we will get the right record type + tcfEntities[experienceKey] = experience[experienceKey]?.map((item) => { + const preference = Object.hasOwn(cookieConsent, item.id) + ? transformConsentToFidesUserPreference( + Boolean(cookieConsent[item.id]), + ConsentMechanism.OPT_IN + ) + : item.default_preference; + return { ...item, current_preference: preference }; + }); + }); - const cookieKeys: TcfCookieConsent = {}; + // Now update tcfEntities based on the fides string + if (cookie.fides_string) { + const { tc: tcString, ac: acString } = decodeFidesString( + cookie.fides_string + ); + const acStringIds = idsFromAcString(acString); - // map tc model key to cookie key - TCF_KEY_MAP.forEach(({ tcfModelKey, cookieKey }) => { - const isVendorKey = - tcfModelKey === "vendorConsents" || - tcfModelKey === "vendorLegitimateInterests"; - if (tcfModelKey) { - const items: TcfCookieKeyConsent = {}; - (tcModel[tcfModelKey] as Vector).forEach((consented, id) => { - const key = isVendorKey ? `gvl.${id}` : id; - items[key] = consented; + // Populate every field from tcModel + const tcModel = TCString.decode(tcString); + TCF_KEY_MAP.forEach(({ experienceKey, tcfModelKey }) => { + const isVendorKey = + tcfModelKey === "vendorConsents" || + tcfModelKey === "vendorLegitimateInterests"; + const tcIds = Array.from(tcModel[tcfModelKey]) + .filter(([, consented]) => consented) + .map(([id]) => (isVendorKey ? `gvl.${id}` : id)); + // @ts-ignore the array map should ensure we will get the right record type + tcfEntities[experienceKey] = experience[experienceKey]?.map((item) => { + let consented = !!tcIds.find((id) => id === item.id); + // Also check the AC string, which only applies to tcf_vendor_consents + if ( + experienceKey === "tcf_vendor_consents" && + acStringIds.find((id) => id === item.id) + ) { + consented = true; + } + return { + ...item, + current_preference: transformConsentToFidesUserPreference( + consented, + ConsentMechanism.OPT_IN + ), + }; }); - cookieKeys[cookieKey] = items; - } - }); + }); + } - // Set AC consents, which will only be on vendor_consents - const acIds = idsFromAcString(acString, debug); - acIds.forEach((acId) => { - if (!cookieKeys.vendor_consent_preferences) { - cookieKeys.vendor_consent_preferences = { [acId]: true }; - } else { - cookieKeys.vendor_consent_preferences[acId] = true; - } - }); - debugLog( - debug, - `Generated cookie.tcf_consent from explicit fidesString.`, - cookieKeys - ); - return cookieKeys; + return tcfEntities; }; -export const generateFidesStringFromCookieTcfConsent = async ( - experience: PrivacyExperience, - tcfConsent: TcfCookieConsent -): Promise => { - const enabledIds: EnabledIds = { - purposesConsent: [], - purposesLegint: [], - specialPurposes: [], - features: [], - specialFeatures: [], - vendorsConsent: [], - vendorsLegint: [], - }; +/** + * TCF version of updating prefetched experience, based on: + * 1) experience: pre-fetched or client-side experience-based consent configuration + * 2) cookie: cookie containing user preference. - TCF_KEY_MAP.forEach(({ cookieKey, enabledIdsKey }) => { - const cookieKeyConsent: TcfCookieKeyConsent | undefined = - tcfConsent[cookieKey]; - if (cookieKeyConsent) { - Object.keys(cookieKeyConsent).forEach((key: string | number) => { - if (cookieKeyConsent[key] && enabledIdsKey) { - enabledIds[enabledIdsKey].push(key.toString()); - } - }); - } - }); - return generateFidesString({ + * + * Returns updated experience with user preferences. We have a separate function for notices + * and for TCF so that the bundle trees do not overlap. + */ +export const updateExperienceFromCookieConsentTcf = ({ + experience, + cookie, + debug, +}: { + experience: PrivacyExperience; + cookie: FidesCookie; + debug?: boolean; +}): PrivacyExperience => { + const tcfEntities = buildTcfEntitiesFromCookieAndFidesString( experience, - tcStringPreferences: enabledIds, - }); + cookie + ); + + if (debug) { + debugLog( + debug, + `Returning updated pre-fetched experience with user consent.`, + experience + ); + } + return { ...experience, ...tcfEntities }; }; diff --git a/clients/package-lock.json b/clients/package-lock.json index 730605a8973..d0429426b2d 100644 --- a/clients/package-lock.json +++ b/clients/package-lock.json @@ -2473,9 +2473,9 @@ } }, "node_modules/@iabtechlabtcf/core": { - "version": "1.5.7", - "resolved": "https://registry.npmjs.org/@iabtechlabtcf/core/-/core-1.5.7.tgz", - "integrity": "sha512-9IBcr3pPdvH4kijHqs9kAVMoM7tLkRbyKMu377BYNMP1YPsJuSFK1Z1y9U1g1vk+wcnaEj/qpGHL1aUBrGaVPw==" + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/@iabtechlabtcf/core/-/core-1.5.10.tgz", + "integrity": "sha512-HWenM2wC5wloQoAto3l/lX3H2uAB7qnoIsGAEd4J+OS74j/GlW3j7RYfMZ0JS//HEgJbW0P1jlbJ7oHRrEJoaw==" }, "node_modules/@isaacs/cliui": { "version": "8.0.2", @@ -22053,6 +22053,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, "node_modules/unique-filename": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz", @@ -22984,6 +22990,7 @@ "@emotion/styled": "^11.10.6", "@fidesui/react": "^0.0.23", "@fontsource/inter": "^4.5.15", + "@iabtechlabtcf/core": "^1.5.10", "@reduxjs/toolkit": "^1.9.3", "cache-control-parser": "^2.0.4", "fides-js": "*", @@ -23015,7 +23022,7 @@ "@typescript-eslint/parser": "^5.57.0", "babel-jest": "^29.5.0", "cross-env": "^7.0.3", - "cypress": "^12.8.1", + "cypress": "^13.6.0", "cypress-wait-until": "^1.7.2", "eslint": "^8.36.0", "eslint-config-airbnb": "^19.0.4", @@ -23041,6 +23048,35 @@ "whatwg-fetch": "^3.6.2" } }, + "privacy-center/node_modules/@cypress/request": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@cypress/request/-/request-3.0.1.tgz", + "integrity": "sha512-TWivJlJi8ZDx2wGOw1dbLuHJKUYX7bWySw377nlnGOW3hP9/MUKIsEdXT/YngWxVdgNCHRBmFlBipE+5/2ZZlQ==", + "dev": true, + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "http-signature": "~1.3.6", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "performance-now": "^2.1.0", + "qs": "6.10.4", + "safe-buffer": "^5.1.2", + "tough-cookie": "^4.1.3", + "tunnel-agent": "^0.6.0", + "uuid": "^8.3.2" + }, + "engines": { + "node": ">= 6" + } + }, "privacy-center/node_modules/@next/bundle-analyzer": { "version": "12.3.4", "resolved": "https://registry.npmjs.org/@next/bundle-analyzer/-/bundle-analyzer-12.3.4.tgz", @@ -23307,6 +23343,88 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true }, + "privacy-center/node_modules/cypress": { + "version": "13.6.0", + "resolved": "https://registry.npmjs.org/cypress/-/cypress-13.6.0.tgz", + "integrity": "sha512-quIsnFmtj4dBUEJYU4OH0H12bABJpSujvWexC24Ju1gTlKMJbeT6tTO0vh7WNfiBPPjoIXLN+OUqVtiKFs6SGw==", + "dev": true, + "hasInstallScript": true, + "dependencies": { + "@cypress/request": "^3.0.0", + "@cypress/xvfb": "^1.2.4", + "@types/node": "^18.17.5", + "@types/sinonjs__fake-timers": "8.1.1", + "@types/sizzle": "^2.3.2", + "arch": "^2.2.0", + "blob-util": "^2.0.2", + "bluebird": "^3.7.2", + "buffer": "^5.6.0", + "cachedir": "^2.3.0", + "chalk": "^4.1.0", + "check-more-types": "^2.24.0", + "cli-cursor": "^3.1.0", + "cli-table3": "~0.6.1", + "commander": "^6.2.1", + "common-tags": "^1.8.0", + "dayjs": "^1.10.4", + "debug": "^4.3.4", + "enquirer": "^2.3.6", + "eventemitter2": "6.4.7", + "execa": "4.1.0", + "executable": "^4.1.1", + "extract-zip": "2.0.1", + "figures": "^3.2.0", + "fs-extra": "^9.1.0", + "getos": "^3.2.1", + "is-ci": "^3.0.0", + "is-installed-globally": "~0.4.0", + "lazy-ass": "^1.6.0", + "listr2": "^3.8.3", + "lodash": "^4.17.21", + "log-symbols": "^4.0.0", + "minimist": "^1.2.8", + "ospath": "^1.2.2", + "pretty-bytes": "^5.6.0", + "process": "^0.11.10", + "proxy-from-env": "1.0.0", + "request-progress": "^3.0.0", + "semver": "^7.5.3", + "supports-color": "^8.1.1", + "tmp": "~0.2.1", + "untildify": "^4.0.0", + "yauzl": "^2.10.0" + }, + "bin": { + "cypress": "bin/cypress" + }, + "engines": { + "node": "^16.0.0 || ^18.0.0 || >=20.0.0" + } + }, + "privacy-center/node_modules/cypress/node_modules/@types/node": { + "version": "18.19.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.2.tgz", + "integrity": "sha512-6wzfBdbWpe8QykUkXBjtmO3zITA0A3FIjoy+in0Y2K4KrCiRhNYJIdwAPDffZ3G6GnaKaSLSEa9ZuORLfEoiwg==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "privacy-center/node_modules/cypress/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "privacy-center/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -23316,6 +23434,18 @@ "node": ">=8" } }, + "privacy-center/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dev": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, "privacy-center/node_modules/next": { "version": "12.2.5", "resolved": "https://registry.npmjs.org/next/-/next-12.2.5.tgz", @@ -23410,6 +23540,21 @@ "node": "^10 || ^12 || >=14" } }, + "privacy-center/node_modules/semver": { + "version": "7.5.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", + "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "dev": true, + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "privacy-center/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -23422,6 +23567,39 @@ "node": ">=8" } }, + "privacy-center/node_modules/tough-cookie": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", + "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", + "dev": true, + "dependencies": { + "psl": "^1.1.33", + "punycode": "^2.1.1", + "universalify": "^0.2.0", + "url-parse": "^1.5.3" + }, + "engines": { + "node": ">=6" + } + }, + "privacy-center/node_modules/universalify": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz", + "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", + "dev": true, + "engines": { + "node": ">= 4.0.0" + } + }, + "privacy-center/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "dev": true, + "bin": { + "uuid": "dist/bin/uuid" + } + }, "privacy-center/node_modules/webpack-bundle-analyzer": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.3.0.tgz", @@ -23465,6 +23643,12 @@ "optional": true } } + }, + "privacy-center/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true } } } diff --git a/clients/privacy-center/cypress/e2e/consent-banner-tcf.cy.ts b/clients/privacy-center/cypress/e2e/consent-banner-tcf.cy.ts index ad0be071bcd..ab5cbcafad5 100644 --- a/clients/privacy-center/cypress/e2e/consent-banner-tcf.cy.ts +++ b/clients/privacy-center/cypress/e2e/consent-banner-tcf.cy.ts @@ -6,7 +6,9 @@ import { FidesEndpointPaths, PrivacyExperience, } from "fides-js"; +import { TCString } from "@iabtechlabtcf/core"; import { CookieKeyConsent } from "fides-js/src/lib/cookie"; +import { FIDES_SEPARATOR } from "fides-js/src/lib/tcf/constants"; import { API_URL, TCF_VERSION_HASH, @@ -113,6 +115,47 @@ const checkDefaultExperienceRender = () => { }); }; +const assertTcOptIns = ({ + cookie, + modelType, + ids, +}: { + cookie: FidesCookie; + modelType: + | "purposeConsents" + | "purposeLegitimateInterests" + | "specialFeatureOptins" + | "vendorConsents" + | "vendorLegitimateInterests"; + ids: number[]; +}) => { + const { fides_string: fidesString } = cookie; + const tcString = fidesString?.split(FIDES_SEPARATOR)[0]; + expect(tcString).to.be.a("string"); + const model = TCString.decode(tcString!); + const values = Array.from(model[modelType].values()).sort(); + expect(values).to.eql(ids.sort()); +}; + +const assertAcOptIns = ({ + cookie, + ids, +}: { + cookie: FidesCookie; + ids: number[]; +}) => { + const { fides_string: fidesString } = cookie; + const acString = fidesString?.split("1~")[1]; + expect(acString).to.be.a("string"); + const values = acString! + .split(".") + .map((id) => +id) + .sort(); + expect(values).to.eql(ids.sort()); +}; + +const fidesVendorIdToId = (fidesId: string) => +fidesId.split(".")[1]; + describe("Fides-js TCF", () => { describe("banner appears when it should", () => { beforeEach(() => { @@ -586,28 +629,31 @@ describe("Fides-js TCF", () => { const cookieKeyConsent: FidesCookie = JSON.parse( decodeURIComponent(cookie!.value) ); - [PURPOSE_9.id, PURPOSE_6.id, PURPOSE_7.id, PURPOSE_4.id].forEach( - (pid) => { - expect(cookieKeyConsent.tcf_consent.purpose_consent_preferences) - .property(`${pid}`) - .is.eql(true); - } - ); - expect( - cookieKeyConsent.tcf_consent - .purpose_legitimate_interests_preferences - ) - .property(`${PURPOSE_2.id}`) - .is.eql(true); - expect(cookieKeyConsent.tcf_consent.special_feature_preferences) - .property(`${SPECIAL_FEATURE_1.id}`) - .is.eql(true); - expect(cookieKeyConsent.tcf_consent.vendor_consent_preferences) - .property(`${VENDOR_1.id}`) - .is.eql(true); - expect( - cookieKeyConsent.tcf_consent.vendor_legitimate_interests_preferences - ).to.eql({}); + assertTcOptIns({ + cookie: cookieKeyConsent, + modelType: "purposeConsents", + ids: [PURPOSE_9.id, PURPOSE_6.id, PURPOSE_7.id, PURPOSE_4.id], + }); + assertTcOptIns({ + cookie: cookieKeyConsent, + modelType: "purposeLegitimateInterests", + ids: [PURPOSE_2.id], + }); + assertTcOptIns({ + cookie: cookieKeyConsent, + modelType: "specialFeatureOptins", + ids: [SPECIAL_FEATURE_1.id], + }); + assertTcOptIns({ + cookie: cookieKeyConsent, + modelType: "vendorConsents", + ids: [fidesVendorIdToId(VENDOR_1.id)], + }); + assertTcOptIns({ + cookie: cookieKeyConsent, + modelType: "vendorLegitimateInterests", + ids: [], + }); expect( cookieKeyConsent.tcf_consent.system_consent_preferences ).to.eql({}); @@ -696,29 +742,31 @@ describe("Fides-js TCF", () => { const cookieKeyConsent: FidesCookie = JSON.parse( decodeURIComponent(cookie!.value) ); - [PURPOSE_4.id, PURPOSE_9.id, PURPOSE_6.id, PURPOSE_7.id].forEach( - (pid) => { - expect(cookieKeyConsent.tcf_consent.purpose_consent_preferences) - .property(`${pid}`) - .is.eql(false); - } - ); - expect( - cookieKeyConsent.tcf_consent - .purpose_legitimate_interests_preferences - ) - .property(`${PURPOSE_2.id}`) - .is.eql(false); - expect(cookieKeyConsent.tcf_consent.special_feature_preferences) - .property(`${SPECIAL_FEATURE_1.id}`) - .is.eql(false); - expect(cookieKeyConsent.tcf_consent.vendor_consent_preferences) - .property(`${VENDOR_1.id}`) - .is.eql(false); - expect( - cookieKeyConsent.tcf_consent - .vendor_legitimate_interests_preferences - ).to.eql({}); + assertTcOptIns({ + cookie: cookieKeyConsent, + modelType: "purposeConsents", + ids: [], + }); + assertTcOptIns({ + cookie: cookieKeyConsent, + modelType: "purposeLegitimateInterests", + ids: [], + }); + assertTcOptIns({ + cookie: cookieKeyConsent, + modelType: "specialFeatureOptins", + ids: [], + }); + assertTcOptIns({ + cookie: cookieKeyConsent, + modelType: "vendorConsents", + ids: [], + }); + assertTcOptIns({ + cookie: cookieKeyConsent, + modelType: "vendorLegitimateInterests", + ids: [], + }); expect( cookieKeyConsent.tcf_consent.system_consent_preferences ).to.eql({}); @@ -814,29 +862,31 @@ describe("Fides-js TCF", () => { const cookieKeyConsent: FidesCookie = JSON.parse( decodeURIComponent(cookie!.value) ); - [PURPOSE_9.id, PURPOSE_6.id, PURPOSE_7.id].forEach((pid) => { - expect(cookieKeyConsent.tcf_consent.purpose_consent_preferences) - .property(`${pid}`) - .is.eql(true); + assertTcOptIns({ + cookie: cookieKeyConsent, + modelType: "purposeConsents", + ids: [PURPOSE_9.id, PURPOSE_6.id, PURPOSE_7.id], + }); + assertTcOptIns({ + cookie: cookieKeyConsent, + modelType: "purposeLegitimateInterests", + ids: [PURPOSE_2.id], + }); + assertTcOptIns({ + cookie: cookieKeyConsent, + modelType: "specialFeatureOptins", + ids: [SPECIAL_FEATURE_1.id], + }); + assertTcOptIns({ + cookie: cookieKeyConsent, + modelType: "vendorConsents", + ids: [], + }); + assertTcOptIns({ + cookie: cookieKeyConsent, + modelType: "vendorLegitimateInterests", + ids: [], }); - expect( - cookieKeyConsent.tcf_consent - .purpose_legitimate_interests_preferences - ) - .property(`${PURPOSE_2.id}`) - .is.eql(true); - expect(cookieKeyConsent.tcf_consent.purpose_consent_preferences) - .property(`${PURPOSE_4.id}`) - .is.eql(false); - expect(cookieKeyConsent.tcf_consent.special_feature_preferences) - .property(`${SPECIAL_FEATURE_1.id}`) - .is.eql(true); - expect(cookieKeyConsent.tcf_consent.vendor_consent_preferences) - .property(`${VENDOR_1.id}`) - .is.eql(false); - expect( - cookieKeyConsent.tcf_consent.vendor_legitimate_interests_preferences - ).to.eql({}); expect( cookieKeyConsent.tcf_consent.system_legitimate_interests_preferences ) @@ -947,29 +997,31 @@ describe("Fides-js TCF", () => { const cookieKeyConsent: FidesCookie = JSON.parse( decodeURIComponent(cookie!.value) ); - [PURPOSE_4.id, PURPOSE_9.id, PURPOSE_6.id, PURPOSE_7.id].forEach( - (pid) => { - expect(cookieKeyConsent.tcf_consent.purpose_consent_preferences) - .property(`${pid}`) - .is.eql(false); - } - ); - expect( - cookieKeyConsent.tcf_consent - .purpose_legitimate_interests_preferences - ) - .property(`${PURPOSE_2.id}`) - .is.eql(false); - expect(cookieKeyConsent.tcf_consent.special_feature_preferences) - .property(`${SPECIAL_FEATURE_1.id}`) - .is.eql(false); - expect(cookieKeyConsent.tcf_consent.vendor_consent_preferences) - .property(`${VENDOR_1.id}`) - .is.eql(false); - expect( - cookieKeyConsent.tcf_consent - .vendor_legitimate_interests_preferences - ).to.eql({}); + assertTcOptIns({ + cookie: cookieKeyConsent, + modelType: "purposeConsents", + ids: [], + }); + assertTcOptIns({ + cookie: cookieKeyConsent, + modelType: "purposeLegitimateInterests", + ids: [], + }); + assertTcOptIns({ + cookie: cookieKeyConsent, + modelType: "specialFeatureOptins", + ids: [], + }); + assertTcOptIns({ + cookie: cookieKeyConsent, + modelType: "vendorConsents", + ids: [], + }); + assertTcOptIns({ + cookie: cookieKeyConsent, + modelType: "vendorLegitimateInterests", + ids: [], + }); expect( cookieKeyConsent.tcf_consent.system_consent_preferences ).to.eql({}); @@ -1184,28 +1236,31 @@ describe("Fides-js TCF", () => { const cookieKeyConsent: FidesCookie = JSON.parse( decodeURIComponent(cookie!.value) ); - [PURPOSE_9.id, PURPOSE_6.id, PURPOSE_7.id].forEach((pid) => { - expect(cookieKeyConsent.tcf_consent.purpose_consent_preferences) - .property(`${pid}`) - .is.eql(true); + assertTcOptIns({ + cookie: cookieKeyConsent, + modelType: "purposeConsents", + ids: [PURPOSE_9.id, PURPOSE_6.id, PURPOSE_7.id], + }); + assertTcOptIns({ + cookie: cookieKeyConsent, + modelType: "purposeLegitimateInterests", + ids: [PURPOSE_2.id], + }); + assertTcOptIns({ + cookie: cookieKeyConsent, + modelType: "specialFeatureOptins", + ids: [SPECIAL_FEATURE_1.id], + }); + assertTcOptIns({ + cookie: cookieKeyConsent, + modelType: "vendorConsents", + ids: [], + }); + assertTcOptIns({ + cookie: cookieKeyConsent, + modelType: "vendorLegitimateInterests", + ids: [], }); - expect( - cookieKeyConsent.tcf_consent.purpose_legitimate_interests_preferences - ) - .property(`${PURPOSE_2.id}`) - .is.eql(true); - expect(cookieKeyConsent.tcf_consent.purpose_consent_preferences) - .property(`${PURPOSE_4.id}`) - .is.eql(false); - expect(cookieKeyConsent.tcf_consent.special_feature_preferences) - .property(`${SPECIAL_FEATURE_1.id}`) - .is.eql(true); - expect(cookieKeyConsent.tcf_consent.vendor_consent_preferences) - .property(`${VENDOR_1.id}`) - .is.eql(false); - expect( - cookieKeyConsent.tcf_consent.vendor_legitimate_interests_preferences - ).to.eql({}); expect( cookieKeyConsent.tcf_consent.system_legitimate_interests_preferences ) @@ -1471,14 +1526,9 @@ describe("Fides-js TCF", () => { const setFidesCookie = () => { const cookie = mockCookie({ tcf_consent: { - purpose_consent_preferences: { - [PURPOSE_4.id]: false, - [PURPOSE_9.id]: true, - }, - special_feature_preferences: { [SPECIAL_FEATURE_1.id]: true }, system_legitimate_interests_preferences: { [SYSTEM_1.id]: false }, - vendor_consent_preferences: { [VENDOR_1.id]: true }, }, + // Purpose 9, Special feature 1, Vendor consent 2 fides_string: "CPziCYAPziCYAGXABBENATEIAACAAAAAAAAAABEAAAAA.IABE", }); cy.setCookie(CONSENT_COOKIE_NAME, JSON.stringify(cookie)); @@ -1492,9 +1542,9 @@ describe("Fides-js TCF", () => { * ✅ 4) "prefetched" experience (via config.options.experience) * ❌ 5) experience API (via GET /privacy-experience) * - * EXPECTED RESULT: use preferences from local cookie + * EXPECTED RESULT: use preferences from local cookie's saved string */ - it("prefers preferences from a cookie when both cookie and experience exist", () => { + it("prefers preferences from a cookie's fides_string when both cookie and experience exist", () => { setFidesCookie(); cy.fixture("consent/experience_tcf.json").then((experience) => { stubConfig({ @@ -1641,6 +1691,7 @@ describe("Fides-js TCF", () => { */ it("prefers preferences from fides_string option when fides_string, experience, and cookie exist", () => { setFidesCookie(); + // Purpose 7, Special Feature 1 const fidesStringOverride = "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA.IABE,1~"; const expectedTCString = "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA"; // without disclosed vendors @@ -1792,8 +1843,9 @@ describe("Fides-js TCF", () => { }); cy.get("#fides-panel-Vendors").within(() => { cy.get("button").contains("Legitimate interest").click(); + // Should be checked because legitimate interest defaults to true and Systems aren't in the fides string cy.getByTestId(`toggle-${SYSTEM_1.name}`).within(() => { - cy.get("input").should("not.be.checked"); + cy.get("input").should("be.checked"); }); }); @@ -2266,7 +2318,7 @@ describe("Fides-js TCF", () => { * * EXPECTED RESULT: prefers preferences from local cookie instead of from client-side experience */ - it("prefers preferences from fides_string option when both fides_string option and cookie exist and experience is fetched from API", () => { + it("prefers preferences from cookie's fides_string when cookie exists and experience is fetched from API", () => { setFidesCookie(); cy.fixture("consent/experience_tcf.json").then((experience) => { cy.fixture("consent/geolocation_tcf.json").then((geo) => { @@ -2752,25 +2804,9 @@ describe("Fides-js TCF", () => { }); it("can initialize from an AC string", () => { - const uuid = "4fbb6edf-34f6-4717-a6f1-541fd1e5d585"; - const CREATED_DATE = "2022-12-24T12:00:00.000Z"; - const UPDATED_DATE = "2022-12-25T12:00:00.000Z"; - const cookie = { - identity: { fides_user_device_id: uuid }, - fides_meta: { - version: "0.9.0", - createdAt: CREATED_DATE, - updatedAt: UPDATED_DATE, - }, - consent: {}, - tcf_consent: { - purpose_consent_preferences: { 2: false, [PURPOSE_4.id]: true }, - special_feature_preferences: { [SPECIAL_FEATURE_1.id]: true }, - system_legitimate_interests_preferences: { [SYSTEM_1.id]: false }, - vendor_consent_preferences: { [VENDOR_1.id]: false }, - }, - tc_string: "CPzbcgAPzbcgAGXABBENATEIAACAAAAAAAAAABEAAAAA.IABE", - }; + const cookie = mockCookie({ + fides_string: "CPzbcgAPzbcgAGXABBENATEIAACAAAAAAAAAABEAAAAA.IABE", + }); cy.setCookie(CONSENT_COOKIE_NAME, JSON.stringify(cookie)); cy.fixture("consent/experience_tcf.json").then((experience) => { stubConfig({ @@ -2778,7 +2814,7 @@ describe("Fides-js TCF", () => { isOverlayEnabled: true, tcfEnabled: true, // this TC string sets purpose 4 to false and purpose 7 to true - // the appended AC string sets AC 42 to true + // the appended AC string sets AC 42,43,44 to true fidesString: "CPzevcAPzevcAGXABBENATEIAAIAAAAAAAAAAAAAAAAA.IABE,1~42.43.44", }, @@ -2787,24 +2823,16 @@ describe("Fides-js TCF", () => { }); cy.get("@FidesInitialized") - .its("lastCall.args.0.detail.tcf_consent") - .then((tcfConsent) => { + .its("lastCall.args.0.detail") + .then((updatedCookie: FidesCookie) => { // TC string setting worked - expect(tcfConsent.purpose_consent_preferences).to.eql({ - 1: false, - 2: false, - 3: false, - 4: false, - 5: false, - 6: false, - 7: true, + assertTcOptIns({ + cookie: updatedCookie, + modelType: "purposeConsents", + ids: [PURPOSE_7.id], }); // AC string setting worked - expect(tcfConsent.vendor_consent_preferences).to.eql({ - "gacp.42": true, - "gacp.43": true, - "gacp.44": true, - }); + assertAcOptIns({ cookie: updatedCookie, ids: [42, 43, 44] }); }); }); }); diff --git a/clients/privacy-center/package.json b/clients/privacy-center/package.json index f48be0f2fe0..6ca35b18f6b 100644 --- a/clients/privacy-center/package.json +++ b/clients/privacy-center/package.json @@ -28,6 +28,7 @@ "@emotion/styled": "^11.10.6", "@fidesui/react": "^0.0.23", "@fontsource/inter": "^4.5.15", + "@iabtechlabtcf/core": "^1.5.10", "@reduxjs/toolkit": "^1.9.3", "cache-control-parser": "^2.0.4", "fides-js": "*", @@ -59,7 +60,7 @@ "@typescript-eslint/parser": "^5.57.0", "babel-jest": "^29.5.0", "cross-env": "^7.0.3", - "cypress": "^12.8.1", + "cypress": "^13.6.0", "cypress-wait-until": "^1.7.2", "eslint": "^8.36.0", "eslint-config-airbnb": "^19.0.4",