From 6035252f4da2ea6fc7cbfce186cd72297d9c4621 Mon Sep 17 00:00:00 2001 From: Catherine Smith Date: Mon, 25 Sep 2023 16:04:56 +0200 Subject: [PATCH] Update to not fetch experience if it is empty (#4149) Co-authored-by: Neville Samuell --- CHANGELOG.md | 1 + .../__tests__/lib/consent-utils.test.ts | 104 ++++++++++++++++++ clients/fides-js/src/lib/consent-types.ts | 4 +- clients/fides-js/src/lib/consent-utils.ts | 16 ++- clients/fides-js/src/lib/initialize.ts | 4 +- .../cypress/e2e/consent-banner.cy.ts | 103 ++++++++++++++--- .../privacy-center/cypress/support/stubs.ts | 31 +++--- clients/privacy-center/pages/api/fides-js.ts | 2 + 8 files changed, 231 insertions(+), 34 deletions(-) create mode 100644 clients/fides-js/__tests__/lib/consent-utils.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e406e4d3e8..b24070c754 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ The types of changes are: ### Fixed - Allows CDN to cache empty experience responses from fides.js API [#4113](https://github.com/ethyca/fides/pull/4113) - added version_added, version_deprecated, and replaced_by to data use, data subject, and data category APIs [#4135](https://github.com/ethyca/fides/pull/4135) +- Update fides.js to not fetch experience client-side if pre-fetched experience is empty [#4149](https://github.com/ethyca/fides/pull/4149) ## [2.20.1](https://github.com/ethyca/fides/compare/2.20.0...2.20.1) diff --git a/clients/fides-js/__tests__/lib/consent-utils.test.ts b/clients/fides-js/__tests__/lib/consent-utils.test.ts new file mode 100644 index 0000000000..9a314f73db --- /dev/null +++ b/clients/fides-js/__tests__/lib/consent-utils.test.ts @@ -0,0 +1,104 @@ +import { isPrivacyExperience } from "../../src/lib/consent-utils"; + +const MOCK_EXPERIENCE = { + id: "132345243", + region: "us_ca", + show_banner: true, + component: "overlay", + created_at: "2023-04-24T21:29:08.870351+00:00", + updated_at: "2023-04-24T21:29:08.870351+00:00", + experience_config: { + accept_button_label: "Accept Test", + acknowledge_button_label: "OK", + banner_enabled: "enabled_where_required", + disabled: false, + description: + "We use cookies and similar methods to recognize visitors and remember their preferences. We also use them to measure ad campaign effectiveness, target ads and analyze site traffic. Learn more about these methods, including how to manage them, by clicking ‘Manage Preferences.’ By clicking ‘accept’ you consent to the of these methods by us and our third parties. By clicking ‘reject’ you decline the use of these methods.", + reject_button_label: "Reject Test", + is_default: false, + save_button_label: "Save test", + title: "Manage your consent", + component: "overlay", + version: 2.0, + privacy_policy_link_label: "Privacy policy", + privacy_policy_url: "https://privacy.ethyca.com/", + privacy_preferences_link_label: "Manage preferences", + created_at: "2023-04-24T21:29:08.870351+00:00", + updated_at: "2023-04-24T21:29:08.870351+00:00", + id: "2348571y34", + regions: ["us_ca"], + }, + privacy_notices: [ + { + name: "Test privacy notice", + disabled: false, + origin: "12435134", + description: "a test sample privacy notice configuration", + internal_description: + "a test sample privacy notice configuration for internal use", + regions: ["us_ca"], + consent_mechanism: "opt_in", + default_preference: "opt_out", + current_preference: null, + outdated_preference: null, + has_gpc_flag: true, + data_uses: ["advertising", "third_party_sharing"], + enforcement_level: "system_wide", + displayed_in_overlay: true, + displayed_in_api: true, + displayed_in_privacy_center: false, + id: "pri_4bed96d0-b9e3-4596-a807-26b783836374", + created_at: "2023-04-24T21:29:08.870351+00:00", + updated_at: "2023-04-24T21:29:08.870351+00:00", + version: 1.0, + privacy_notice_history_id: "pri_b09058a7-9f54-4360-8da5-4521e8975d4f", + notice_key: "advertising", + cookies: [{ name: "testCookie", path: "/", domain: null }], + }, + { + name: "Essential", + description: + "Notify the user about data processing activities that are essential to your services functionality. Typically consent is not required for this.", + regions: ["us_ca"], + consent_mechanism: "notice_only", + default_preference: "opt_in", + current_preference: null, + outdated_preference: null, + has_gpc_flag: true, + data_uses: ["provide.service"], + enforcement_level: "system_wide", + displayed_in_overlay: true, + displayed_in_api: true, + displayed_in_privacy_center: false, + id: "pri_4bed96d0-b9e3-4596-a807-26b783836375", + created_at: "2023-04-24T21:29:08.870351+00:00", + updated_at: "2023-04-24T21:29:08.870351+00:00", + version: 1.0, + privacy_notice_history_id: "pri_b09058a7-9f54-4360-8da5-4521e8975d4e", + notice_key: "essential", + cookies: [], + }, + ], +}; + +describe("isPrivacyExperience", () => { + it.each([ + { label: "undefined", obj: undefined, expected: false }, + { label: "a number", obj: 7, expected: false }, + { label: "an object", obj: { foo: "bar" }, expected: false }, + { label: "a string", obj: "foo", expected: false }, + { label: "an empty object", obj: {}, expected: true }, + { + label: "an object with 'id'", + obj: { id: "123456", foo: "bar" }, + expected: true, + }, + { + label: "a full 'experience' object", + obj: MOCK_EXPERIENCE, + expected: true, + }, + ])("returns $expected when input is $label", ({ obj, expected }) => { + expect(isPrivacyExperience(obj as any)).toBe(expected); + }); +}); diff --git a/clients/fides-js/src/lib/consent-types.ts b/clients/fides-js/src/lib/consent-types.ts index c841678c3b..b69fa54364 100644 --- a/clients/fides-js/src/lib/consent-types.ts +++ b/clients/fides-js/src/lib/consent-types.ts @@ -15,8 +15,8 @@ export interface FidesConfig { // Set the consent defaults from a "legacy" Privacy Center config.json. consent?: LegacyConsentConfig; // Set the "experience" to be used for this Fides.js instance -- overrides the "legacy" config. - // If set, Fides.js will fetch neither experience config nor user geolocation. - // If not set or is empty, Fides.js will attempt to fetch its own experience config. + // If defined or is empty, Fides.js will not fetch experience config. + // If undefined, Fides.js will attempt to fetch its own experience config. experience?: PrivacyExperience | EmptyExperience; // Set the geolocation for this Fides.js instance. If *not* set, Fides.js will fetch its own geolocation. geolocation?: UserGeolocation; diff --git a/clients/fides-js/src/lib/consent-utils.ts b/clients/fides-js/src/lib/consent-utils.ts index d86ae93e3d..a3c625c97b 100644 --- a/clients/fides-js/src/lib/consent-utils.ts +++ b/clients/fides-js/src/lib/consent-utils.ts @@ -29,14 +29,26 @@ export const debugLog = ( }; /** - * Returns true if privacy experience is null or empty + * Returns true if the provided input is a valid PrivacyExperience object. + * + * This includes the special case where the input is an empty object ({}), which + * is a valid response when the API does not find a PrivacyExperience configured + * for the given geolocation. */ export const isPrivacyExperience = ( obj: PrivacyExperience | undefined | EmptyExperience ): obj is PrivacyExperience => { - if (!obj) { + // Return false for all non-object types + if (!obj || typeof obj !== "object") { return false; } + + // Treat an empty object ({}) as a valid experience + if (Object.keys(obj).length === 0) { + return true; + } + + // Require at least an "id" field to be considered an experience if ("id" in obj) { return true; } diff --git a/clients/fides-js/src/lib/initialize.ts b/clients/fides-js/src/lib/initialize.ts index 1f98e08bc0..15698288c6 100644 --- a/clients/fides-js/src/lib/initialize.ts +++ b/clients/fides-js/src/lib/initialize.ts @@ -235,7 +235,9 @@ export const initialize = async ({ `User location could not be obtained. Skipping overlay initialization.` ); shouldInitOverlay = false; - } else if (!isPrivacyExperience(experience)) { + } else if (!isPrivacyExperience(effectiveExperience)) { + // If no effective PrivacyExperience was pre-fetched, fetch one now from + // the Fides API using the current region string effectiveExperience = await fetchExperience( fidesRegionString, options.fidesApiUrl, diff --git a/clients/privacy-center/cypress/e2e/consent-banner.cy.ts b/clients/privacy-center/cypress/e2e/consent-banner.cy.ts index ae43bc0d23..d2f2f84a29 100644 --- a/clients/privacy-center/cypress/e2e/consent-banner.cy.ts +++ b/clients/privacy-center/cypress/e2e/consent-banner.cy.ts @@ -1,12 +1,12 @@ import { ComponentType, CONSENT_COOKIE_NAME, - ConsentMethod, ConsentMechanism, - UserConsentPreference, + ConsentMethod, + ConsentOptionCreate, FidesCookie, LastServedNoticeSchema, - ConsentOptionCreate, + UserConsentPreference, } from "fides-js"; import { mockPrivacyNotice } from "../support/mocks"; @@ -26,17 +26,20 @@ describe("Consent banner", () => { }, }); }); + it("sets Fides.consent object with default consent based on legacy consent", () => { cy.window().its("Fides").its("consent").should("eql", { data_sales: true, tracking: false, }); }); + it("does not render banner", () => { cy.waitUntilFidesInitialized().then(() => { cy.get("div#fides-banner").should("not.exist"); }); }); + it("does not render modal link", () => { cy.get("#fides-modal-link").should("not.be.visible"); }); @@ -49,22 +52,25 @@ describe("Consent banner", () => { options: { isOverlayEnabled: false, }, - experience: OVERRIDE.EMPTY, + experience: OVERRIDE.UNDEFINED, }, {}, {} ); }); + it("sets Fides.consent object with default consent based on legacy consent", () => { cy.window().its("Fides").its("consent").should("eql", { data_sales: true, tracking: false, }); }); + it("does not render banner", () => { cy.get("div#fides-banner").should("not.exist"); cy.contains("button", "Accept Test").should("not.exist"); }); + it("does not render modal link", () => { cy.get("#fides-modal-link").should("not.be.visible"); }); @@ -81,6 +87,7 @@ describe("Consent banner", () => { }, }); }); + it("should render the expected HTML banner", () => { cy.get("div#fides-banner").within(() => { cy.get( @@ -111,6 +118,7 @@ describe("Consent banner", () => { }); }); }); + it("renders modal link", () => { cy.get("#fides-modal-link").should("be.visible"); }); @@ -429,6 +437,7 @@ describe("Consent banner", () => { preference: "acknowledge", }, ]; + beforeEach(() => { cy.getCookie(CONSENT_COOKIE_NAME).should("not.exist"); stubConfig({ @@ -495,6 +504,7 @@ describe("Consent banner", () => { }, }); }); + it("sends GPC consent override downstream to Fides API", () => { // check that consent was sent to Fides API let generatedUserDeviceId: string; @@ -584,6 +594,7 @@ describe("Consent banner", () => { }, }); }); + it("does not set user consent preference automatically", () => { // timeout means API call not made, which is expected cy.on("fail", (error) => { @@ -673,6 +684,7 @@ describe("Consent banner", () => { cy.get("div#fides-banner").should("not.exist"); cy.contains("button", "Accept Test").should("not.exist"); }); + it("does not render modal link", () => { cy.get("#fides-modal-link").should("not.be.visible"); }); @@ -681,7 +693,7 @@ describe("Consent banner", () => { describe("when experience is not provided, and valid geolocation is provided", () => { beforeEach(() => { stubConfig({ - experience: OVERRIDE.EMPTY, + experience: OVERRIDE.UNDEFINED, geolocation: { country: "US", location: "US-CA", @@ -704,6 +716,7 @@ describe("Consent banner", () => { ); }); }); + it("renders modal link", () => { cy.get("#fides-modal-link").should("be.visible"); }); @@ -712,7 +725,7 @@ describe("Consent banner", () => { describe("when experience is provided, and geolocation is not provided", () => { beforeEach(() => { stubConfig({ - geolocation: OVERRIDE.EMPTY, + geolocation: OVERRIDE.UNDEFINED, options: { isGeolocationEnabled: true, geolocationApiUrl: "https://some-geolocation-url.com", @@ -733,18 +746,69 @@ describe("Consent banner", () => { ); }); }); + it("renders modal link", () => { cy.get("#fides-modal-link").should("be.visible"); }); }); + describe("when experience is empty, and geolocation is not provided", () => { + beforeEach(() => { + stubConfig({ + geolocation: OVERRIDE.UNDEFINED, + experience: OVERRIDE.EMPTY, + options: { + isGeolocationEnabled: true, + geolocationApiUrl: "https://some-geolocation-url.com", + }, + }); + }); + + it("does fetches geolocation and does not render the banner", () => { + // we still need geolocation because it is needed to save consent preference + cy.wait("@getGeolocation"); + cy.get("div#fides-banner").should("not.exist"); + cy.contains("button", "Accept Test").should("not.exist"); + }); + + it("does not render modal link", () => { + cy.get("#fides-modal-link").should("not.be.visible"); + }); + }); + + describe("when experience is empty, and geolocation is provided", () => { + beforeEach(() => { + stubConfig({ + geolocation: { + country: "US", + location: "US-CA", + region: "CA", + }, + experience: OVERRIDE.EMPTY, + options: { + isGeolocationEnabled: true, + geolocationApiUrl: "https://some-geolocation-url.com", + }, + }); + }); + + it("does not geolocate and does not render the banner", () => { + cy.get("div#fides-banner").should("not.exist"); + cy.contains("button", "Accept Test").should("not.exist"); + }); + + it("does not render modal link", () => { + cy.get("#fides-modal-link").should("not.be.visible"); + }); + }); + describe("when neither experience nor geolocation is provided, but geolocationApiUrl is defined", () => { describe("when geolocation is successful", () => { beforeEach(() => { const geoLocationUrl = "https://some-geolocation-api.com"; stubConfig({ - experience: OVERRIDE.EMPTY, - geolocation: OVERRIDE.EMPTY, + experience: OVERRIDE.UNDEFINED, + geolocation: OVERRIDE.UNDEFINED, options: { isGeolocationEnabled: true, geolocationApiUrl: geoLocationUrl, @@ -767,6 +831,7 @@ describe("Consent banner", () => { ); }); }); + it("shows the modal link", () => { cy.get("#fides-modal-link").should("be.visible"); }); @@ -780,8 +845,8 @@ describe("Consent banner", () => { }; stubConfig( { - experience: OVERRIDE.EMPTY, - geolocation: OVERRIDE.EMPTY, + experience: OVERRIDE.UNDEFINED, + geolocation: OVERRIDE.UNDEFINED, options: { isGeolocationEnabled: true, geolocationApiUrl: "https://some-geolocation-api.com", @@ -790,11 +855,13 @@ describe("Consent banner", () => { mockFailedGeolocationCall ); }); + it("does not render banner", () => { cy.wait("@getGeolocation"); cy.get("div#fides-banner").should("not.exist"); cy.contains("button", "Accept Test").should("not.exist"); }); + it("hides the modal link", () => { cy.get("#fides-modal-link").should("not.be.visible"); }); @@ -805,7 +872,7 @@ describe("Consent banner", () => { describe("when experience is not provided, and geolocation is invalid", () => { beforeEach(() => { stubConfig({ - experience: OVERRIDE.EMPTY, + experience: OVERRIDE.UNDEFINED, geolocation: { country: "US", location: "", @@ -842,8 +909,8 @@ describe("Consent banner", () => { describe("when experience is not provided, and geolocation is not provided, but geolocation is disabled", () => { beforeEach(() => { stubConfig({ - experience: OVERRIDE.EMPTY, - geolocation: OVERRIDE.EMPTY, + experience: OVERRIDE.UNDEFINED, + geolocation: OVERRIDE.UNDEFINED, options: { isGeolocationEnabled: false, }, @@ -1017,6 +1084,7 @@ describe("Consent banner", () => { }, }); }); + // NOTE: See definition of cy.visitConsentDemo in commands.ts for where we // register listeners for these window events it("emits both a FidesInitialized and FidesUpdated event when initialized", () => { @@ -1135,6 +1203,7 @@ describe("Consent banner", () => { }); }); }); + describe("when listening for fides.js events with existing cookie", () => { describe("when overlay is enabled and legacy notices exist", () => { beforeEach(() => { @@ -1165,6 +1234,7 @@ describe("Consent banner", () => { }, }); }); + // NOTE: See definition of cy.visitConsentDemo in commands.ts for where we // register listeners for these window events it("first event reflects legacy consent from cookie, second event reflects new experiences consent", () => { @@ -1200,6 +1270,7 @@ describe("Consent banner", () => { }); }); }); + describe("when overlay is enabled and legacy notices do not exist", () => { beforeEach(() => { const uuid = "4fbb6edf-34f6-4717-a6f1-541fd1e5d585"; @@ -1228,6 +1299,7 @@ describe("Consent banner", () => { consent: { options: [] }, }); }); + it("first event reflects legacy cookie consent, second event reflects new experiences consent", () => { cy.window() .its("Fides") @@ -1259,6 +1331,7 @@ describe("Consent banner", () => { }); }); }); + describe("when overlay is disabled and legacy notices do not exist", () => { beforeEach(() => { const uuid = "4fbb6edf-34f6-4717-a6f1-541fd1e5d585"; @@ -1287,6 +1360,7 @@ describe("Consent banner", () => { consent: { options: [] }, }); }); + // NOTE: See definition of cy.visitConsentDemo in commands.ts for where we // register listeners for these window events it("all events should reflect existing legacy cookie values", () => { @@ -1319,6 +1393,7 @@ describe("Consent banner", () => { }); }); }); + describe("when overlay is disabled and legacy notices exist", () => { beforeEach(() => { const uuid = "4fbb6edf-34f6-4717-a6f1-541fd1e5d585"; @@ -1346,6 +1421,7 @@ describe("Consent banner", () => { }, }); }); + // NOTE: See definition of cy.visitConsentDemo in commands.ts for where we // register listeners for these window events it("all events should reflect legacy consent from cookie", () => { @@ -1387,6 +1463,7 @@ describe("Consent banner", () => { win.navigator.globalPrivacyControl = true; }); }); + it("renders the proper gpc indicator", () => { stubConfig({ experience: { diff --git a/clients/privacy-center/cypress/support/stubs.ts b/clients/privacy-center/cypress/support/stubs.ts index 2c62d1451a..50e2d18b60 100644 --- a/clients/privacy-center/cypress/support/stubs.ts +++ b/clients/privacy-center/cypress/support/stubs.ts @@ -18,8 +18,19 @@ export const stubIdVerification = () => { export enum OVERRIDE { // signals that we should override entire prop with undefined EMPTY = "Empty", + UNDEFINED = "Undefined", } +const setNewConfig = (baseConfigObj: any, newConfig: any): any => { + if (newConfig === OVERRIDE.EMPTY) { + return {}; + } + if (newConfig === OVERRIDE.UNDEFINED) { + return undefined; + } + return Object.assign(baseConfigObj, newConfig); +}; + interface FidesConfigTesting { // We don't need all required props to override the default config consent?: Partial | OVERRIDE; @@ -39,22 +50,10 @@ export const stubConfig = ( ) => { cy.fixture("consent/test_banner_options.json").then((config) => { const updatedConfig = { - consent: - consent === OVERRIDE.EMPTY - ? undefined - : Object.assign(config.consent, consent), - experience: - experience === OVERRIDE.EMPTY - ? {} - : Object.assign(config.experience, experience), - geolocation: - geolocation === OVERRIDE.EMPTY - ? undefined - : Object.assign(config.geolocation, geolocation), - options: - options === OVERRIDE.EMPTY - ? undefined - : Object.assign(config.options, options), + consent: setNewConfig(config.consent, consent), + experience: setNewConfig(config.experience, experience), + geolocation: setNewConfig(config.geolocation, geolocation), + options: setNewConfig(config.options, options), }; // We conditionally stub these APIs because we need the exact API urls, which can change or not even exist // depending on the specific test case. diff --git a/clients/privacy-center/pages/api/fides-js.ts b/clients/privacy-center/pages/api/fides-js.ts index 2c0ae780bb..6100416458 100644 --- a/clients/privacy-center/pages/api/fides-js.ts +++ b/clients/privacy-center/pages/api/fides-js.ts @@ -86,6 +86,7 @@ export default async function handler( environment.settings.IS_PREFETCH_ENABLED ) { if (tcfEnabled) { + // eslint-disable-next-line no-console console.warn( "TCF mode is not currently compatible with prefetching, skipping prefetching..." ); @@ -94,6 +95,7 @@ export default async function handler( if (fidesRegionString) { if (environment.settings.DEBUG) { + // eslint-disable-next-line no-console console.log("Fetching relevant experiences from server-side..."); } experience = await fetchExperience(