diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ab7ea6dae..4b2f433113 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ The types of changes are: - Refactor Fides.js embedded modal to not use A11y dialog [#4355](https://github.com/ethyca/fides/pull/4355) ### Fixed +- Handle invalid `fides_string` when passed in as an override [#4350](https://github.com/ethyca/fides/pull/4350) - Bug where vendor opt-ins would not initialize properly based on a `fides_string` in the TCF overlay [#4368](https://github.com/ethyca/fides/pull/4368) ## [2.23.0](https://github.com/ethyca/fides/compare/2.22.1...2.23.0) diff --git a/clients/fides-js/src/fides-tcf.ts b/clients/fides-js/src/fides-tcf.ts index 5df7d740e7..9c88a08769 100644 --- a/clients/fides-js/src/fides-tcf.ts +++ b/clients/fides-js/src/fides-tcf.ts @@ -68,6 +68,7 @@ import { import type { Fides } from "./lib/initialize"; import { dispatchFidesEvent } from "./lib/events"; import { + buildTcfEntitiesFromCookie, debugLog, experienceIsValid, FidesCookie, @@ -121,13 +122,43 @@ const getInitialPreference = ( return tcfObject.default_preference ?? UserConsentPreference.OPT_OUT; }; -const updateCookie = async ( - oldCookie: FidesCookie, - experience: PrivacyExperience -): Promise => { - // ignore server-side prefs if either user has no prefs to override, or TC str override is set +const updateCookieAndExperience = async ({ + cookie, + experience, + debug = false, + hasValidFidesStringOverride, + isExperienceClientSideFetched, +}: { + cookie: FidesCookie; + experience: PrivacyExperience; + debug?: boolean; + hasValidFidesStringOverride: boolean; + isExperienceClientSideFetched: boolean; +}): Promise<{ + cookie: FidesCookie; + experience: Partial; +}> => { + // If string override exists and is valid, the cookie has already been overridden + if (hasValidFidesStringOverride) { + // However, if we didn't get the experience until the client side fetch, we need to update the fetched + // experience based on the cookie here. + if (isExperienceClientSideFetched) { + debugLog( + debug, + "Overriding preferences from client-side fetched experience with cookie fides_string consent", + cookie.fides_string + ); + const tcfEntities = buildTcfEntitiesFromCookie(experience, cookie); + return { cookie, experience: tcfEntities }; + } + // If it's not client side fetched, we don't update anything since the cookie has already + // been updated earlier. + return { cookie, experience }; + } + + // If user has no prefs saved, we don't need to override the prefs on the cookie if (!hasSavedTcfPreferences(experience)) { - return { ...oldCookie, fides_string: "" }; + return { cookie: { ...cookie, fides_string: "" }, experience }; } const tcSavePrefs: TcfSavePreferences = {}; @@ -165,7 +196,38 @@ const updateCookie = async ( tcStringPreferences: enabledIds, }); const tcfConsent = transformTcfPreferencesToCookieKeys(tcSavePrefs); - return { ...oldCookie, fides_string: fidesString, tcf_consent: tcfConsent }; + return { + cookie: { ...cookie, fides_string: fidesString, tcf_consent: tcfConsent }, + experience, + }; +}; + +/** + * If a fidesString is explicitly passed in, 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 +): { 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 }, + success: true, + }; + } catch (error) { + debugLog( + debug, + `Could not decode tcString from ${fidesString}, it may be invalid. ${error}` + ); + return { cookie, success: false }; + } }; /** @@ -177,18 +239,18 @@ const init = async (config: FidesConfig) => { // eslint-disable-next-line no-param-reassign config.options = { ...config.options, ...overrideOptions }; const cookie = getInitialCookie(config); + let hasValidFidesStringOverride = !!config.options.fidesString; if (config.options.fidesString) { - // If a fidesString is explicitly passed in, we override the associated cookie props, which are then used to - // override associated props in experience - debugLog( - config.options.debug, - "Explicit fidesString detected. Proceeding to override all TCF preferences with given fidesString" - ); - cookie.fides_string = config.options.fidesString; - cookie.tcf_consent = transformFidesStringToCookieKeys( + const { cookie: updatedCookie, success } = updateFidesCookieFromString( + cookie, config.options.fidesString, config.options.debug ); + if (success) { + Object.assign(cookie, updatedCookie); + } else { + hasValidFidesStringOverride = false; + } } else if ( tcfConsentCookieObjHasSomeConsentSet(cookie.tcf_consent) && !cookie.fides_string && @@ -221,7 +283,8 @@ const init = async (config: FidesConfig) => { cookie, experience, renderOverlay, - updateCookie, + updateCookieAndExperience: (props) => + updateCookieAndExperience({ ...props, hasValidFidesStringOverride }), }); Object.assign(_Fides, updatedFides); diff --git a/clients/fides-js/src/fides.ts b/clients/fides-js/src/fides.ts index 279d542399..554f0024d1 100644 --- a/clients/fides-js/src/fides.ts +++ b/clients/fides-js/src/fides.ts @@ -91,14 +91,14 @@ const updateCookie = async ( oldCookie: FidesCookie, experience: PrivacyExperience, debug?: boolean -): Promise => { +): Promise<{ cookie: FidesCookie; experience: PrivacyExperience }> => { const context = getConsentContext(); const consent = buildCookieConsentForExperiences( experience, context, !!debug ); - return { ...oldCookie, consent }; + return { cookie: { ...oldCookie, consent }, experience }; }; /** @@ -122,7 +122,11 @@ const init = async (config: FidesConfig) => { cookie, experience, renderOverlay, - updateCookie, + updateCookieAndExperience: ({ + cookie: oldCookie, + experience: effectiveExperience, + debug, + }) => updateCookie(oldCookie, effectiveExperience, debug), }); Object.assign(_Fides, updatedFides); diff --git a/clients/fides-js/src/lib/initialize.ts b/clients/fides-js/src/lib/initialize.ts index 72a75f61ff..9aeff5f2bd 100644 --- a/clients/fides-js/src/lib/initialize.ts +++ b/clients/fides-js/src/lib/initialize.ts @@ -4,7 +4,6 @@ import { meta } from "../integrations/meta"; import { shopify } from "../integrations/shopify"; import { getConsentContext } from "./consent-context"; import { - buildTcfEntitiesFromCookie, CookieIdentity, CookieKeyConsent, CookieMeta, @@ -259,15 +258,28 @@ export const initialize = async ({ experience, geolocation, renderOverlay, - updateCookie, + updateCookieAndExperience, }: { cookie: FidesCookie; renderOverlay: (props: OverlayProps, parent: ContainerNode) => void; - updateCookie: ( - oldCookie: FidesCookie, - experience: PrivacyExperience, - debug?: boolean - ) => Promise; + /** + * Once we for sure have a valid experience, this is another chance to update values + * before the overlay renders. + */ + updateCookieAndExperience: ({ + cookie, + experience, + debug, + isExperienceClientSideFetched, + }: { + cookie: FidesCookie; + experience: PrivacyExperience; + debug?: boolean; + isExperienceClientSideFetched: boolean; + }) => Promise<{ + cookie: FidesCookie; + experience: Partial; + }>; } & FidesConfig): Promise> => { let shouldInitOverlay: boolean = options.isOverlayEnabled; let effectiveExperience = experience; @@ -312,34 +324,15 @@ export const initialize = async ({ isPrivacyExperience(effectiveExperience) && experienceIsValid(effectiveExperience, options) ) { - if (options.fidesString) { - if (fetchedClientSideExperience) { - // if tc str was explicitly passed in, we need to override the client-side-fetched experience with consent from the cookie - // we don't update cookie because it already has been overridden by the injected fidesString - debugLog( - options.debug, - "Overriding preferences from client-side fetched experience with cookie fides_string consent", - cookie.fides_string - ); - const tcfEntities = buildTcfEntitiesFromCookie( - effectiveExperience, - cookie - ); - Object.assign(effectiveExperience, tcfEntities); - } - } else { - const updatedCookie = await updateCookie( - cookie, - effectiveExperience, - options.debug - ); - debugLog( - options.debug, - "Updated cookie based on experience", - updatedCookie - ); - Object.assign(cookie, updatedCookie); - } + const updated = await updateCookieAndExperience({ + cookie, + experience: effectiveExperience, + debug: options.debug, + isExperienceClientSideFetched: fetchedClientSideExperience, + }); + debugLog(options.debug, "Updated cookie and experience", updated); + Object.assign(cookie, updated.cookie); + Object.assign(effectiveExperience, updated.experience); if (shouldInitOverlay) { await initOverlay({ experience: effectiveExperience, 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 3a45f5fe62..f8be35fb36 100644 --- a/clients/privacy-center/cypress/e2e/consent-banner-tcf.cy.ts +++ b/clients/privacy-center/cypress/e2e/consent-banner-tcf.cy.ts @@ -1876,6 +1876,106 @@ describe("Fides-js TCF", () => { expect(tcData.vendor.legitimateInterests).to.eql({}); }); }); + + /** + * TEST CASE #8: + * 😬 1) fides_string override option exists but is invalid (via config.options.fidesString) + * ❌ 2) DEFER: preferences API (via a custom function) + * ❌ 3) local cookie (via fides_consent cookie) + * ❌ 4) "prefetched" experience (via config.options.experience) + * ✅ 5) experience API (via GET /privacy-experience) + * + * EXPECTED RESULT: ignore invalid fides_string option and render experience as-is + */ + it("can handle an invalid fides_string option and continue rendering the experience", () => { + const fidesStringOverride = "invalid-string,1~"; + cy.fixture("consent/experience_tcf.json").then((experience) => { + cy.fixture("consent/geolocation_tcf.json").then((geo) => { + stubConfig( + { + options: { + isOverlayEnabled: true, + tcfEnabled: true, + fidesString: fidesStringOverride, + }, + experience: OVERRIDE.UNDEFINED, + }, + geo, + experience + ); + }); + }); + + cy.waitUntilFidesInitialized().then(() => { + cy.window().then((win) => { + win.__tcfapi("addEventListener", 2, cy.stub().as("TCFEvent")); + }); + }); + + cy.get("#fides-modal-link").click(); + + // Verify that all these are equal to the default experience, as if + // there had been no overrides. + // Purposes + cy.getByTestId(`toggle-${PURPOSE_2.name}`).within(() => { + cy.get("input").should("be.checked"); + }); + cy.getByTestId(`toggle-${PURPOSE_4.name}-consent`).within(() => { + cy.get("input").should("be.checked"); + }); + cy.getByTestId(`toggle-${PURPOSE_6.name}-consent`).within(() => { + cy.get("input").should("be.checked"); + }); + cy.getByTestId(`toggle-${PURPOSE_7.name}-consent`).within(() => { + cy.get("input").should("be.checked"); + }); + cy.getByTestId(`toggle-${PURPOSE_9.name}-consent`).within(() => { + cy.get("input").should("be.checked"); + }); + // Features + cy.get("#fides-tab-Features").click(); + cy.getByTestId(`toggle-${SPECIAL_FEATURE_1.name}`).within(() => { + cy.get("input").should("not.be.checked"); + }); + // Vendors + cy.get("#fides-tab-Vendors").click(); + cy.getByTestId(`toggle-${SYSTEM_1.name}`).within(() => { + cy.get("input").should("be.checked"); + }); + cy.getByTestId(`toggle-${VENDOR_1.name}-consent`).within(() => { + cy.get("input").should("not.be.checked"); + }); + + // verify CMP API + cy.get("@TCFEvent") + .its("lastCall.args") + .then(([tcData, success]) => { + expect(success).to.eql(true); + + // Make sure our invalid fides string does not make it into tcData + expect(tcData.tcString).to.be.a("string"); + expect(tcData.tcString).to.not.contain("invalid"); + expect(tcData.eventStatus).to.eql("cmpuishown"); + expect(tcData.purpose.consents).to.eql({ + [PURPOSE_2.id]: true, + [PURPOSE_4.id]: true, + [PURPOSE_6.id]: true, + [PURPOSE_7.id]: true, + [PURPOSE_9.id]: true, + 1: false, + 2: false, + 3: false, + 5: false, + 8: false, + }); + expect(tcData.purpose.legitimateInterests).to.eql({ + [PURPOSE_2.id]: true, + 1: false, + }); + expect(tcData.vendor.consents).to.eql({}); + expect(tcData.vendor.legitimateInterests).to.eql({}); + }); + }); it("can use a fides_string to override a vendor consent", () => { // Opts in to all const fidesStringOverride = @@ -2125,13 +2225,15 @@ describe("Fides-js TCF", () => { expect(body.vendor_consent_preferences).to.eql(expected); // Check the cookie - cy.getCookie(CONSENT_COOKIE_NAME).then((cookie) => { - const cookieKeyConsent: FidesCookie = JSON.parse( - decodeURIComponent(cookie!.value) - ); - const { fides_string: tcString } = cookieKeyConsent; - const acString = tcString?.split(",")[1]; - expect(acString).to.eql(acceptAllAcString); + cy.waitUntilCookieExists(CONSENT_COOKIE_NAME).then(() => { + cy.getCookie(CONSENT_COOKIE_NAME).then((cookie) => { + const cookieKeyConsent: FidesCookie = JSON.parse( + decodeURIComponent(cookie!.value) + ); + const { fides_string: tcString } = cookieKeyConsent; + const acString = tcString?.split(",")[1]; + expect(acString).to.eql(acceptAllAcString); + }); }); }); }); @@ -2151,13 +2253,15 @@ describe("Fides-js TCF", () => { expect(body.vendor_consent_preferences).to.eql(expected); // Check the cookie - cy.getCookie(CONSENT_COOKIE_NAME).then((cookie) => { - const cookieKeyConsent: FidesCookie = JSON.parse( - decodeURIComponent(cookie!.value) - ); - const { fides_string: tcString } = cookieKeyConsent; - const acString = tcString?.split(",")[1]; - expect(acString).to.eql(rejectAllAcString); + cy.waitUntilCookieExists(CONSENT_COOKIE_NAME).then(() => { + cy.getCookie(CONSENT_COOKIE_NAME).then((cookie) => { + const cookieKeyConsent: FidesCookie = JSON.parse( + decodeURIComponent(cookie!.value) + ); + const { fides_string: tcString } = cookieKeyConsent; + const acString = tcString?.split(",")[1]; + expect(acString).to.eql(rejectAllAcString); + }); }); }); });