From f73dffea7cdcb52bcc7f0a931037a80d78c521ae Mon Sep 17 00:00:00 2001 From: doug-s-nava <92806979+doug-s-nava@users.noreply.github.com> Date: Mon, 6 Jan 2025 15:55:51 -0500 Subject: [PATCH] [Issue #3249] Add auth ui feature flag and refactor feature flag system for client side use (#3374) * rewrites the FeatureFlagManager and useFeatureFlag hook to allow syncing flags between server and client using cookies * adds the `authOn` feature flag in frontend code and terraform * refactors the `environments` setup a bit to more easily expose feature flags * splits functionality that does not benefit from being held in the FeatureFlagsManager class into a helper file * moves feature flag manager file into a nested directory --- .../dev/feature-flags/FeatureFlagsTable.tsx | 64 +-- frontend/src/components/Header.tsx | 14 +- frontend/src/components/Layout.tsx | 16 +- frontend/src/constants/defaultFeatureFlags.ts | 9 + frontend/src/constants/environments.ts | 33 +- frontend/src/constants/featureFlags.ts | 8 - frontend/src/hoc/withFeatureFlag.tsx | 5 +- frontend/src/hooks/useFeatureFlags.ts | 94 ++-- frontend/src/middleware.ts | 13 +- frontend/src/services/FeatureFlagManager.ts | 308 ----------- .../featureFlags/FeatureFlagManager.ts | 148 ++++++ .../featureFlags/featureFlagHelpers.ts | 115 ++++ frontend/src/utils/generalUtils.ts | 9 +- .../src/utils/testing/FeatureFlagTestUtils.ts | 12 +- frontend/tests/components/Header.test.tsx | 6 + frontend/tests/hooks/useFeatureFlags.test.ts | 81 ++- .../page.test.tsx} | 46 +- .../tests/services/FeatureFlagManager.test.ts | 502 ------------------ .../featureFlags/FeatureFlagManager.test.ts | 299 +++++++++++ .../featureFlags/featureFlagHelpers.test.ts | 202 +++++++ .../tests/services/withFeatureFlag.test.tsx | 4 +- .../env-config/environment-variables.tf | 8 +- 22 files changed, 1019 insertions(+), 977 deletions(-) create mode 100644 frontend/src/constants/defaultFeatureFlags.ts delete mode 100644 frontend/src/constants/featureFlags.ts delete mode 100644 frontend/src/services/FeatureFlagManager.ts create mode 100644 frontend/src/services/featureFlags/FeatureFlagManager.ts create mode 100644 frontend/src/services/featureFlags/featureFlagHelpers.ts rename frontend/tests/pages/dev/{feature-flags.test.tsx => feature-flags/page.test.tsx} (60%) delete mode 100644 frontend/tests/services/FeatureFlagManager.test.ts create mode 100644 frontend/tests/services/featureFlags/FeatureFlagManager.test.ts create mode 100644 frontend/tests/services/featureFlags/featureFlagHelpers.test.ts diff --git a/frontend/src/app/[locale]/dev/feature-flags/FeatureFlagsTable.tsx b/frontend/src/app/[locale]/dev/feature-flags/FeatureFlagsTable.tsx index b0c2fe1ae..c800fe9f1 100644 --- a/frontend/src/app/[locale]/dev/feature-flags/FeatureFlagsTable.tsx +++ b/frontend/src/app/[locale]/dev/feature-flags/FeatureFlagsTable.tsx @@ -12,13 +12,9 @@ import Loading from "src/components/Loading"; * View for managing feature flags */ export default function FeatureFlagsTable() { - const { featureFlagsManager, mounted, setFeatureFlag } = useFeatureFlags(); + const { setFeatureFlag, featureFlags } = useFeatureFlags(); const { user, isLoading, error } = useUser(); - if (!mounted) { - return null; - } - if (isLoading) { return ; } @@ -47,37 +43,35 @@ export default function FeatureFlagsTable() { - {Object.entries(featureFlagsManager.featureFlags).map( - ([featureName, enabled]) => ( - - ( + + + {enabled ? "Enabled" : "Disabled"} + + {featureName} + + + - - - - ), - )} + Disable + + + + ))} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 696027112..00899ef3a 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,6 +1,7 @@ "use client"; import clsx from "clsx"; +import { useFeatureFlags } from "src/hooks/useFeatureFlags"; import { assetPath } from "src/utils/assetPath"; import { useTranslations } from "next-intl"; @@ -170,6 +171,9 @@ const Header = ({ logoPath, locale }: Props) => { }; }, [isMobileNavExpanded, closeMenuOnEscape]); + const { checkFeatureFlag } = useFeatureFlags(); + const showLoginLink = checkFeatureFlag("authOn"); + const language = locale && locale.match("/^es/") ? "spanish" : "english"; const handleMobileNavToggle = () => { @@ -218,11 +222,13 @@ const Header = ({ logoPath, locale }: Props) => { className="usa-menu-btn" /> -
-
- + {!!showLoginLink && ( +
+
+ +
-
+ )} {t("Layout.skip_to_main")} - -
- +
{children}
diff --git a/frontend/src/constants/defaultFeatureFlags.ts b/frontend/src/constants/defaultFeatureFlags.ts new file mode 100644 index 000000000..cc870be42 --- /dev/null +++ b/frontend/src/constants/defaultFeatureFlags.ts @@ -0,0 +1,9 @@ +export type FeatureFlags = { [name: string]: boolean }; + +export const defaultFeatureFlags: FeatureFlags = { + // Kill switches for search and opportunity pages, will show maintenance page when turned on + searchOff: false, + opportunityOff: false, + // should we show a sign in button in the header? + authOn: false, +}; diff --git a/frontend/src/constants/environments.ts b/frontend/src/constants/environments.ts index 4f3f5b795..a005297a6 100644 --- a/frontend/src/constants/environments.ts +++ b/frontend/src/constants/environments.ts @@ -1,37 +1,44 @@ +import { stringToBoolean } from "src/utils/generalUtils"; + const { NEXT_PUBLIC_BASE_PATH, - USE_SEARCH_MOCK_DATA = "", + USE_SEARCH_MOCK_DATA, SENDY_API_URL, SENDY_API_KEY, SENDY_LIST_ID, API_URL, - API_AUTH_TOKEN = "", + API_AUTH_TOKEN, + NEXT_BUILD, + SESSION_SECRET, NEXT_PUBLIC_BASE_URL, - FEATURE_SEARCH_OFF = "false", - FEATURE_OPPORTUNITY_OFF = "false", - NEXT_BUILD = "false", ENVIRONMENT = "dev", - SESSION_SECRET = "", + FEATURE_SEARCH_OFF, + FEATURE_OPPORTUNITY_OFF, + FEATURE_AUTH_ON, AUTH_LOGIN_URL, } = process.env; +export const featureFlags = { + opportunityOff: stringToBoolean(FEATURE_OPPORTUNITY_OFF), + searchOff: stringToBoolean(FEATURE_SEARCH_OFF), + authOn: stringToBoolean(FEATURE_AUTH_ON), +}; + // home for all interpreted server side environment variables export const environment: { [key: string]: string } = { LEGACY_HOST: ENVIRONMENT === "prod" ? "https://grants.gov" : "https://test.grants.gov", NEXT_PUBLIC_BASE_PATH: NEXT_PUBLIC_BASE_PATH ?? "", - USE_SEARCH_MOCK_DATA, + USE_SEARCH_MOCK_DATA: USE_SEARCH_MOCK_DATA || "", SENDY_API_URL: SENDY_API_URL || "", SENDY_API_KEY: SENDY_API_KEY || "", SENDY_LIST_ID: SENDY_LIST_ID || "", API_URL: API_URL || "", - API_AUTH_TOKEN, AUTH_LOGIN_URL: AUTH_LOGIN_URL || "", - NEXT_PUBLIC_BASE_URL: NEXT_PUBLIC_BASE_URL || "http://localhost:3000", + API_AUTH_TOKEN: API_AUTH_TOKEN || "", GOOGLE_TAG_MANAGER_ID: "GTM-MV57HMHS", - FEATURE_OPPORTUNITY_OFF, - FEATURE_SEARCH_OFF, - NEXT_BUILD, ENVIRONMENT, - SESSION_SECRET, + NEXT_BUILD: NEXT_BUILD || "false", + SESSION_SECRET: SESSION_SECRET || "", + NEXT_PUBLIC_BASE_URL: NEXT_PUBLIC_BASE_URL || "http://localhost:3000", }; diff --git a/frontend/src/constants/featureFlags.ts b/frontend/src/constants/featureFlags.ts deleted file mode 100644 index 7a5223bd4..000000000 --- a/frontend/src/constants/featureFlags.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { FeatureFlags } from "src/services/FeatureFlagManager"; - -// Feature flags should default to false -export const featureFlags: FeatureFlags = { - // Kill switches for search and opportunity pages, will show maintenance page when turned on - searchOff: false, - opportunityOff: false, -}; diff --git a/frontend/src/hoc/withFeatureFlag.tsx b/frontend/src/hoc/withFeatureFlag.tsx index 26ba94fda..d0e0cbf09 100644 --- a/frontend/src/hoc/withFeatureFlag.tsx +++ b/frontend/src/hoc/withFeatureFlag.tsx @@ -1,5 +1,5 @@ import { environment } from "src/constants/environments"; -import { FeatureFlagsManager } from "src/services/FeatureFlagManager"; +import { featureFlagsManager } from "src/services/featureFlags/FeatureFlagManager"; import { WithFeatureFlagProps } from "src/types/uiTypes"; import { cookies } from "next/headers"; @@ -25,11 +25,10 @@ const withFeatureFlag = ( ) => { const searchParams = props.searchParams || {}; const ComponentWithFeatureFlag = (props: P & WithFeatureFlagProps) => { - const featureFlagsManager = new FeatureFlagsManager(cookies()); - if ( featureFlagsManager.isFeatureEnabled( featureFlagName, + cookies(), props.searchParams, ) ) { diff --git a/frontend/src/hooks/useFeatureFlags.ts b/frontend/src/hooks/useFeatureFlags.ts index 9432d2848..b02bd8635 100644 --- a/frontend/src/hooks/useFeatureFlags.ts +++ b/frontend/src/hooks/useFeatureFlags.ts @@ -1,48 +1,70 @@ +"use client"; + import Cookies from "js-cookie"; -import { FeatureFlagsManager } from "src/services/FeatureFlagManager"; +import { isBoolean } from "lodash"; +import { + defaultFeatureFlags, + FeatureFlags, +} from "src/constants/defaultFeatureFlags"; +import { + FEATURE_FLAGS_KEY, + getCookieExpiration, +} from "src/services/featureFlags/featureFlagHelpers"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; /** - * React hook for reading and managing feature flags in client-side code. - * - * ``` - * function MyComponent() { - * const { - * featureFlagsManager, // An instance of FeatureFlagsManager - * mounted, // Useful for hydration - * setFeatureFlag, // Proxy for featureFlagsManager.setFeatureFlagCookie that handles updating state - * } = useFeatureFlags() - * - * if (featureFlagsManager.isFeatureEnabled("someFeatureFlag")) { - * // Do something - * } - * - * if (!mounted) { - * // To allow hydration - * return null - * } + * Allows client components to access feature flags by + * - setting the cookie + * - reading the cookie * - * return ( - * ... - * ) - * } - * ``` */ -export function useFeatureFlags() { - const [featureFlagsManager, setFeatureFlagsManager] = useState( - new FeatureFlagsManager(Cookies), - ); - const [mounted, setMounted] = useState(false); +export function useFeatureFlags(): { + setFeatureFlag: (flagName: string, value: boolean) => void; + checkFeatureFlag: (flagName: string) => boolean; + featureFlags: FeatureFlags; +} { + const [featureFlags, setFeatureFlags] = + useState(defaultFeatureFlags); + // a workaround, as setting this in default state value results in hydration error useEffect(() => { - setMounted(true); + const flagsFromCookie = JSON.parse( + Cookies.get(FEATURE_FLAGS_KEY) || "{}", + ) as FeatureFlags; + setFeatureFlags(flagsFromCookie); }, []); - function setFeatureFlag(name: string, value: boolean) { - featureFlagsManager.setFeatureFlagCookie(name, value); - setFeatureFlagsManager(new FeatureFlagsManager(Cookies)); - } + // Note that values set in cookies will be persistent per browser session unless explicitly overwritten + const setFeatureFlag = useCallback( + (flagName: string, value: boolean) => { + const newFlags = { + ...featureFlags, + [flagName]: value, + }; + setFeatureFlags(newFlags); + Cookies.set(FEATURE_FLAGS_KEY, JSON.stringify(newFlags), { + expires: getCookieExpiration(), + }); + }, + [featureFlags, setFeatureFlags], + ); + + const checkFeatureFlag = useCallback( + (flagName: string): boolean => { + const value = featureFlags[flagName]; + if (!isBoolean(value)) { + console.error("Unknown or misconfigured feature flag: ", flagName); + return false; + } + return value; + }, + [featureFlags], + ); - return { featureFlagsManager, mounted, setFeatureFlag }; + return { + setFeatureFlag, + checkFeatureFlag, + featureFlags, + }; } diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index 7e3ae258c..b14cb2b53 100644 --- a/frontend/src/middleware.ts +++ b/frontend/src/middleware.ts @@ -4,12 +4,12 @@ * modifying the request or response headers, or responding directly. * @see https://nextjs.org/docs/app/building-your-application/routing/middleware */ +import { defaultLocale, locales } from "src/i18n/config"; +import { featureFlagsManager } from "src/services/featureFlags/FeatureFlagManager"; + import createIntlMiddleware from "next-intl/middleware"; import { NextRequest, NextResponse } from "next/server"; -import { defaultLocale, locales } from "./i18n/config"; -import { FeatureFlagsManager } from "./services/FeatureFlagManager"; - export const config = { matcher: [ /* @@ -40,10 +40,5 @@ const i18nMiddleware = createIntlMiddleware({ }); export default function middleware(request: NextRequest): NextResponse { - let response = i18nMiddleware(request); - - const featureFlagsManager = new FeatureFlagsManager(request.cookies); - response = featureFlagsManager.middleware(request, response); - - return response; + return featureFlagsManager.middleware(request, i18nMiddleware(request)); } diff --git a/frontend/src/services/FeatureFlagManager.ts b/frontend/src/services/FeatureFlagManager.ts deleted file mode 100644 index b5ff25dd8..000000000 --- a/frontend/src/services/FeatureFlagManager.ts +++ /dev/null @@ -1,308 +0,0 @@ -/** - * @file Service for checking and managing feature flags - */ - -import { CookiesStatic } from "js-cookie"; -import { environment } from "src/constants/environments"; -import { featureFlags } from "src/constants/featureFlags"; -import { ServerSideSearchParams } from "src/types/searchRequestURLTypes"; -import { camelToSnake } from "src/utils/generalUtils"; - -import { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies"; -import { NextRequest, NextResponse } from "next/server"; - -export type FeatureFlags = { [name: string]: boolean }; -// Parity with unexported getServerSideProps context cookie type -export type NextServerSideCookies = Partial<{ - [key: string]: string; -}>; - -/** - * Class for reading and managing feature flags. - * - * Server-side: - * - * You can use this manager to gate features on the backend. - * - * ``` - * export default async function handler(request, response) { - * const featureFlagsManager = new FeatureFlagsManager(request.cookies) - * if (featureFlagsManager.isFeatureEnabled("someFeatureFlag")) { - * // Do something - * } - * ... - * } - * ``` - * - * Client-side: - * - * While this manager can be used client-side, it is recommended that you use - * the `useFeatureFlags` hook to simplify instantiation and updating of react - * state. Here's how you can use it directly. - * - * ``` - * const featureFlagsManager = new FeatureFlagsManager(Cookies) - * if (featureFlagsManager.isFeatureEnabled("someFeatureflag")) { - * // Do something - * } - * ``` - */ -export class FeatureFlagsManager { - static FEATURE_FLAGS_KEY = "_ff"; - - // Define all feature flags here! You can override these in your - // browser, once a default is defined here. - private _defaultFeatureFlags = featureFlags; - - private _cookies; - - constructor( - cookies?: - | NextRequest["cookies"] - | CookiesStatic - | NextServerSideCookies - | ReadonlyRequestCookies, - ) { - this._cookies = cookies; - } - - get defaultFeatureFlags(): FeatureFlags { - return { ...this._defaultFeatureFlags }; - } - - // The SSR function getServerSideProps provides a Record type for cookie, which excludes - // cookie methods like set or get. - // likely should be moved out of this class - private isCookieARecord( - cookies?: typeof this._cookies | NextResponse["cookies"], - ): cookies is NextServerSideCookies { - return !(cookies && "get" in cookies && typeof cookies.get === "function"); - } - - /** - * Return the value of the parsed feature flag cookie. - * - * This returns {} if the cookie value is not parsable. - * Invalid flag names and flag values are removed. - */ - get featureFlagsCookie(): FeatureFlags { - if (!this._cookies) { - return {}; - } - let cookieValue; - let parsedCookie: FeatureFlags; - - cookieValue = this.isCookieARecord(this._cookies) - ? this._cookies[FeatureFlagsManager.FEATURE_FLAGS_KEY] - : this._cookies.get(FeatureFlagsManager.FEATURE_FLAGS_KEY); - - if (typeof cookieValue === "object") { - cookieValue = cookieValue.value; - } - try { - if (cookieValue) { - parsedCookie = JSON.parse(cookieValue) as FeatureFlags; - if (typeof parsedCookie !== "object" || Array.isArray(parsedCookie)) { - parsedCookie = {}; - } - } else { - parsedCookie = {}; - } - } catch (error) { - // Something went wrong with getting this value, so we assume the cookie is blank - // eslint-disable-next-line no-console - console.error(error); - parsedCookie = {}; - } - - return Object.fromEntries( - Object.entries(parsedCookie).filter(([name, enabled]) => { - return this.isValidFeatureFlag(name) && typeof enabled === "boolean"; - }), - ); - } - - /* - - - Flags set by environment variables are the first override to default values - - Flags set in the /dev/feature-flags admin view will be set in the cookie - - Flags set in query params will result in the flag value being stored in the cookie - - As query param values are set in middleware on each request, query params have the highest precedence - - Values set in cookies will be persistent per browser session unless explicitly overwritten - - This means that simply removing a query param from a url will not revert the feature flag value to - the value specified in environment variable or default, you'll need to clear cookies or open a new private browser - - */ - get featureFlags(): FeatureFlags { - return { - ...this.defaultFeatureFlags, - ...this.featureFlagsFromEnvironment, - ...this.featureFlagsCookie, - }; - } - - /** - * Check whether a feature flag is enabled - * @param name - Feature flag name - * @example isFeatureEnabled("featureFlagName") - */ - isFeatureEnabled( - name: string, - searchParams?: ServerSideSearchParams, - ): boolean { - if (!this.isValidFeatureFlag(name)) { - throw new Error(`\`${name}\` is not a valid feature flag`); - } - - // Start with the default feature flag setting - const currentFeatureFlags = this.featureFlags; - let featureFlagBoolean = currentFeatureFlags[name]; - - // Query params take precedent. Override the returned value if we see them - if (searchParams && searchParams._ff) { - const featureFlagsObject = this.parseFeatureFlagsFromString( - searchParams._ff, - ); - featureFlagBoolean = featureFlagsObject[name]; - } - - return featureFlagBoolean; - } - - /** - * Check whether it is a valid feature flag. - */ - isValidFeatureFlag(name: string): boolean { - return Object.keys(this.defaultFeatureFlags).includes(name); - } - - /** - * Load feature flags from query params and set them on the cookie. This allows for - * feature flags to be set via url query params as well. - * - * Expects a query string with a param of `FeatureFlagsManager.FEATURE_FLAGS_KEY`. - * - * For example, `example.com?_ff=showSite:true;enableClaimFlow:false` - * would enable `showSite` and disable `enableClaimFlow`. - */ - middleware(request: NextRequest, response: NextResponse): NextResponse { - const paramValue = request.nextUrl.searchParams.get( - FeatureFlagsManager.FEATURE_FLAGS_KEY, - ); - - const featureFlags = - paramValue === "reset" - ? this.defaultFeatureFlags - : this.parseFeatureFlagsFromString(paramValue); - if (Object.keys(featureFlags).length === 0) { - // No valid feature flags specified - return response; - } - Object.entries(featureFlags).forEach(([flagName, flagValue]) => { - this.setFeatureFlagCookie(flagName, flagValue); - }); - this.setCookie(JSON.stringify(this.featureFlagsCookie), response.cookies); - return response; - } - - get featureFlagsFromEnvironment() { - return Object.keys(this.defaultFeatureFlags).reduce( - (featureFlagsFromEnvironment, flagName) => { - // by convention all feature flag env var names start with "FEATURE" - // and all app side feature flag names should be in the camel case version of the env var names (minus FEATURE) - // ex "FEATURE_SEARCH_OFF" -> "searchOff" - const envVarName = `FEATURE_${camelToSnake(flagName).toUpperCase()}`; - const envVarValue = environment[envVarName]; - if (envVarValue) - // by convention, any feature flag environment variables should use the exact string "true" - // when activating the flag. Negative values are more forgiving, but should be non empty strings - featureFlagsFromEnvironment[flagName] = envVarValue === "true"; - - return featureFlagsFromEnvironment; - }, - {} as FeatureFlags, - ); - } - - /** - * Parses feature flags from a query param string - * * should be removed from this class - */ - parseFeatureFlagsFromString(queryParamString: string | null): FeatureFlags { - if (!queryParamString) { - return {}; - } - const entries = queryParamString - .split(";") - // Remove any non-standard formatted strings; must be in the format 'key:value' - .filter((value) => { - const splitValue = value.split(":"); - if (splitValue.length < 2 || splitValue.length > 2) { - return false; - } - return true; - }) - // Convert 'key:value' to ['key', 'value'] - .map((value) => { - const [paramName, paramValue] = value.split(":"); - return [paramName.trim(), paramValue.trim()]; - }) - // Remove any invalid feature flags or feature flag values - .filter(([paramName, paramValue]) => { - // Prevent someone from setting arbitrary feature flags, which is usually an accident - if (!this.isValidFeatureFlag(paramName)) { - return false; - } - if (!["true", "false"].includes(paramValue)) { - return false; - } - return true; - }) - // Convert string to bool - .map(([paramName, paramValue]) => [paramName, paramValue === "true"]); - return Object.fromEntries(entries) as FeatureFlags; - } - - /** - * Toggle feature flag on cookie - */ - setFeatureFlagCookie(featureName: string, enabled: boolean): void { - if (!this.isValidFeatureFlag(featureName)) { - throw new Error(`\`${featureName}\` is not a valid feature flag`); - } - const featureFlags = { - ...this.featureFlagsCookie, - [featureName]: enabled, - }; - - this.setCookie(JSON.stringify(featureFlags)); - } - - /** - * Set a cookie using the NextRequest['cookies'] interface - * or the CookiesStatic interface. - */ - private setCookie( - value: string, - cookies?: CookiesStatic | NextRequest["cookies"] | NextResponse["cookies"], - ) { - // 3 months - const expires = new Date(Date.now() + 1000 * 60 * 60 * 24 * 90); - - if (!cookies && !this.isCookieARecord(this._cookies)) { - cookies = this._cookies; - } - - if (cookies && "remove" in cookies) { - // CookiesStatic - cookies.set(FeatureFlagsManager.FEATURE_FLAGS_KEY, value, { expires }); - } else { - // Next.js cookies API - cookies?.set({ - name: FeatureFlagsManager.FEATURE_FLAGS_KEY, - value, - expires, - }); - } - } -} diff --git a/frontend/src/services/featureFlags/FeatureFlagManager.ts b/frontend/src/services/featureFlags/FeatureFlagManager.ts new file mode 100644 index 000000000..11c9baa7b --- /dev/null +++ b/frontend/src/services/featureFlags/FeatureFlagManager.ts @@ -0,0 +1,148 @@ +/** + * @file Service for checking and managing feature flags + */ + +import { + defaultFeatureFlags, + FeatureFlags, +} from "src/constants/defaultFeatureFlags"; +import { featureFlags } from "src/constants/environments"; +import { + FEATURE_FLAGS_KEY, + getFeatureFlagsFromCookie, + isValidFeatureFlag, + parseFeatureFlagsFromString, + setCookie, +} from "src/services/featureFlags/featureFlagHelpers"; +import { ServerSideSearchParams } from "src/types/searchRequestURLTypes"; + +import { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies"; +import { NextRequest, NextResponse } from "next/server"; + +/** + * Class for reading and managing feature flags on the server. + * + * Holds default and env var based values in class, and exposes two functions: + * + * - middleware: for use in setting feature flags on cookies in next middleware + * - isFeatureEnabled: for checking if a feature is currently enabled, requires + * passing in existing request cookies + * + */ +export class FeatureFlagsManager { + // Define all feature flags here! You can override these in your + // browser, once a default is defined here. + private _defaultFeatureFlags = defaultFeatureFlags; + + // pass in env var values at instantiation, as these won't change during runtime + // this supports easier integration of the class on the client side, as server side flags can be passed down + private _envVarFlags; + + constructor(envVarFlags: FeatureFlags) { + this._envVarFlags = envVarFlags; + } + + get defaultFeatureFlags(): FeatureFlags { + return { ...this._defaultFeatureFlags }; + } + + private get featureFlagsFromEnvironment(): FeatureFlags { + return { ...this._envVarFlags }; + } + + /* + + * - Flags set by environment variables are the first override to default values + - Flags set in the /dev/feature-flags admin view will be set in the cookie + - Flags set in query params will result in the flag value being stored in the cookie + - As query param values are set in middleware on each request, query params have the highest precedence + + + */ + get featureFlags(): FeatureFlags { + return { + ...this.defaultFeatureFlags, + ...this.featureFlagsFromEnvironment, + }; + } + + /** + * Check whether a feature flag is enabled + * + * @description Server side flag values are interpreted as follows: + * + * - Default values and any environment variable overrides are grabbed from the class + * - Any flags from the passed in request cookie will override the class's stored flags + * - Any flags set in the query param are a final override, though these likely will have already been set on the cookie + * by the middleware + * + * @param name - Feature flag name + * @param cookies - server side cookies to check for enabled flags + * @param searchParams - search params to check for enabled flags + * @example isFeatureEnabled("featureFlagName") + */ + isFeatureEnabled( + name: string, + cookies: NextRequest["cookies"] | ReadonlyRequestCookies, + searchParams?: ServerSideSearchParams, + ): boolean { + if (!isValidFeatureFlag(name)) { + throw new Error(`\`${name}\` is not a valid feature flag`); + } + + // Start with the default feature flag setting + const currentFeatureFlags = { + ...this.featureFlags, + ...getFeatureFlagsFromCookie(cookies), + }; + let featureFlagBoolean = currentFeatureFlags[name]; + + // Query params take precedent. Override the returned value if we see them + if (searchParams && searchParams[FEATURE_FLAGS_KEY]) { + const featureFlagsObject = parseFeatureFlagsFromString(searchParams._ff); + featureFlagBoolean = featureFlagsObject[name]; + } + + return featureFlagBoolean; + } + + /** + * Load feature flags from class, existing cookie, and query params and set them on the response cookie. This allows for + * feature flags to be set via url query params as well. + * + * Expects a query string with a param of `_ff`. For example, `example.com?_ff=showSite:true;enableClaimFlow:false` + * would enable `showSite` and disable `enableClaimFlow`. + * + * Note that since flags set in a query param are persisted into the cookie, simply removing a query param from a url + * will not revert the feature flag value to the value specified in environment variable or default. + * To reset a flag value you'll need to clear cookies or open a new private browser + * + * This functionality allows environment variable based feature flag values, which otherwise cannot be made + * available to the client in our current setup, to be exposed to the client via the cookie. + * + */ + middleware(request: NextRequest, response: NextResponse): NextResponse { + const paramValue = request.nextUrl.searchParams.get(FEATURE_FLAGS_KEY); + + const featureFlagsFromQuery = + paramValue === "reset" + ? this.featureFlags + : parseFeatureFlagsFromString(paramValue); + + // previously there was logic here to return early if there were no feature flags active + // beyond default values. Unfortunately, this breaks the implementation of the feature + // flag admin view, which depends on reading all flags from cookies, so the logic has beeen removed + + const featureFlags = { + ...this.featureFlags, + ...getFeatureFlagsFromCookie(request.cookies), + ...featureFlagsFromQuery, + }; + + setCookie(JSON.stringify(featureFlags), response.cookies); + + return response; + } +} + +export const featureFlagsManager = new FeatureFlagsManager(featureFlags); diff --git a/frontend/src/services/featureFlags/featureFlagHelpers.ts b/frontend/src/services/featureFlags/featureFlagHelpers.ts new file mode 100644 index 000000000..8a40072bd --- /dev/null +++ b/frontend/src/services/featureFlags/featureFlagHelpers.ts @@ -0,0 +1,115 @@ +import { + defaultFeatureFlags, + FeatureFlags, +} from "src/constants/defaultFeatureFlags"; + +import { ReadonlyRequestCookies } from "next/dist/server/web/spec-extension/adapters/request-cookies"; +import { NextRequest, NextResponse } from "next/server"; + +export const FEATURE_FLAGS_KEY = "_ff"; + +// 3 months +export const getCookieExpiration = () => + new Date(Date.now() + 1000 * 60 * 60 * 24 * 90); + +/** + * Check whether it is a valid feature flag. + */ +export function isValidFeatureFlag(name: string): boolean { + return Object.keys(defaultFeatureFlags).includes(name); +} + +/** + * Parses feature flags from a query param string + */ +export function parseFeatureFlagsFromString( + queryParamString: string | null, +): FeatureFlags { + if (!queryParamString) { + return {}; + } + const entries = queryParamString + .split(";") + // Remove any non-standard formatted strings; must be in the format 'key:value' + .filter((value) => { + const splitValue = value.split(":"); + if (splitValue.length < 2 || splitValue.length > 2) { + return false; + } + return true; + }) + // Convert 'key:value' to ['key', 'value'] + .map((value) => { + const [paramName, paramValue] = value.split(":"); + return [paramName.trim(), paramValue.trim()]; + }) + // Remove any invalid feature flags or feature flag values + .filter(([paramName, paramValue]) => { + // Prevent someone from setting arbitrary feature flags, which is usually an accident + if (!isValidFeatureFlag(paramName)) { + return false; + } + if (!["true", "false"].includes(paramValue)) { + return false; + } + return true; + }) + // Convert string to bool + .map(([paramName, paramValue]) => [paramName, paramValue === "true"]); + return Object.fromEntries(entries) as FeatureFlags; +} + +/** + * Set a cookie using the NextResponse['cookies'] interface + */ +export function setCookie(value: string, cookies: NextResponse["cookies"]) { + const expires = getCookieExpiration(); + cookies?.set({ + name: FEATURE_FLAGS_KEY, + value, + expires, + }); +} + +/** + * Return the value of the parsed feature flag cookie. + * + * This returns {} if the cookie value is not parsable. + * Invalid flag names and flag values are removed. + */ +export function getFeatureFlagsFromCookie( + cookies: NextRequest["cookies"] | ReadonlyRequestCookies, +): FeatureFlags { + if (!cookies) { + return {}; + } + let cookieValue; + let parsedCookie: FeatureFlags; + + cookieValue = cookies.get(FEATURE_FLAGS_KEY); + + if (typeof cookieValue === "object") { + cookieValue = cookieValue.value; + } + try { + if (cookieValue) { + parsedCookie = JSON.parse(cookieValue) as FeatureFlags; + if (typeof parsedCookie !== "object" || Array.isArray(parsedCookie)) { + parsedCookie = {}; + } + } else { + parsedCookie = {}; + } + } catch (error) { + // Something went wrong with getting this value, so we assume the cookie is blank + // eslint-disable-next-line no-console + console.error(error); + parsedCookie = {}; + } + + return Object.fromEntries( + Object.entries(parsedCookie).filter(([name, enabled]) => { + return isValidFeatureFlag(name) && typeof enabled === "boolean"; + }), + ); +} diff --git a/frontend/src/utils/generalUtils.ts b/frontend/src/utils/generalUtils.ts index 05e2d2003..ceb394a83 100644 --- a/frontend/src/utils/generalUtils.ts +++ b/frontend/src/utils/generalUtils.ts @@ -90,10 +90,9 @@ export const splitMarkup = ( export const findFirstWhitespace = (content: string, startAt: number): number => content.substring(startAt).search(/\s/) + startAt; -// "snakeCase" functionality is available in lodash, but importing lodash anywhere that -// is used by Next middleware throws a compilation error, so let's roll our own -export const camelToSnake = (camel: string) => - camel.replace(/[A-Z]/g, (letter) => `_${letter}`); - export const encodeText = (valueToEncode: string) => new TextEncoder().encode(valueToEncode); + +export const stringToBoolean = ( + mightRepresentABoolean: string | undefined, +): boolean => mightRepresentABoolean === "true"; diff --git a/frontend/src/utils/testing/FeatureFlagTestUtils.ts b/frontend/src/utils/testing/FeatureFlagTestUtils.ts index 626ccfad4..afaa1ac5d 100644 --- a/frontend/src/utils/testing/FeatureFlagTestUtils.ts +++ b/frontend/src/utils/testing/FeatureFlagTestUtils.ts @@ -8,11 +8,9 @@ * default `jsdom` test environment or specify the `node` environments. Otherwise, they would need * to manually specify the custom `./src/utils/testing/jsdomNodeEnvironment.ts` environment. */ - -import { - FeatureFlags, - FeatureFlagsManager, -} from "src/services/FeatureFlagManager"; +import { FeatureFlags } from "src/constants/defaultFeatureFlags"; +import { FEATURE_FLAGS_KEY } from "src/services/featureFlags/featureFlagHelpers"; +import { FeatureFlagsManager } from "src/services/featureFlags/FeatureFlagManager"; /** * Mock feature flags cookie in `window.document` so that we don't need to mock @@ -21,9 +19,7 @@ import { export function mockFeatureFlagsCookie(cookieValue: FeatureFlags) { Object.defineProperty(window.document, "cookie", { writable: true, - value: `${FeatureFlagsManager.FEATURE_FLAGS_KEY}=${JSON.stringify( - cookieValue, - )}`, + value: `${FEATURE_FLAGS_KEY}=${JSON.stringify(cookieValue)}`, }); } diff --git a/frontend/tests/components/Header.test.tsx b/frontend/tests/components/Header.test.tsx index 178f25339..2eba64766 100644 --- a/frontend/tests/components/Header.test.tsx +++ b/frontend/tests/components/Header.test.tsx @@ -23,6 +23,12 @@ jest.mock("next/navigation", () => ({ usePathname: () => usePathnameMock() as string, })); +jest.mock("src/hooks/useFeatureFlags", () => ({ + useFeatureFlags: () => ({ + checkFeatureFlag: () => true, + }), +})); + describe("Header", () => { const mockResponse = { auth_login_url: "/login-url", diff --git a/frontend/tests/hooks/useFeatureFlags.test.ts b/frontend/tests/hooks/useFeatureFlags.test.ts index d5d256d8f..cfa52cfe8 100644 --- a/frontend/tests/hooks/useFeatureFlags.test.ts +++ b/frontend/tests/hooks/useFeatureFlags.test.ts @@ -1,30 +1,79 @@ import { act, renderHook } from "@testing-library/react"; import { useFeatureFlags } from "src/hooks/useFeatureFlags"; -import { mockDefaultFeatureFlags } from "src/utils/testing/FeatureFlagTestUtils"; +import { FEATURE_FLAGS_KEY } from "src/services/featureFlags/featureFlagHelpers"; -describe("useFeatureFlags", () => { - const MOCK_FEATURE_FLAG_NAME = "mockFeatureName"; - const MOCK_FEATURE_FLAG_INITIAL_VALUE = true; +const MOCK_DEFAULT_FEATURE_FLAGS = { + someFakeFeature1: true, + someFakeFeature2: true, + someFakeFeature3: true, +}; - beforeEach(() => { - mockDefaultFeatureFlags({ - [MOCK_FEATURE_FLAG_NAME]: MOCK_FEATURE_FLAG_INITIAL_VALUE, - }); - }); +const mockDefaultFeatureFlagsString = JSON.stringify( + MOCK_DEFAULT_FEATURE_FLAGS, +); +const MOCK_FEATURE_FLAG_NAME = "mockFeatureName"; +const MOCK_FEATURE_FLAG_INITIAL_VALUE = true; - test("should allow you to update feature flags using FeatureFlagManager", () => { +const mockSetCookie = jest.fn(); +const mockGetCookie = jest.fn(); + +jest.mock("js-cookie", () => ({ + get: () => mockGetCookie() as unknown, + set: (...args: unknown[]) => mockSetCookie(...args) as unknown, +})); + +describe("useFeatureFlags", () => { + afterEach(() => { + jest.resetAllMocks(); + }); + test("should allow you to update feature flags on client cookie", () => { const { result } = renderHook(() => useFeatureFlags()); - const { featureFlagsManager, setFeatureFlag } = result.current; + const { setFeatureFlag } = result.current; - expect(featureFlagsManager.isFeatureEnabled(MOCK_FEATURE_FLAG_NAME)).toBe( - MOCK_FEATURE_FLAG_INITIAL_VALUE, - ); act(() => { setFeatureFlag(MOCK_FEATURE_FLAG_NAME, !MOCK_FEATURE_FLAG_INITIAL_VALUE); }); - expect(featureFlagsManager.isFeatureEnabled(MOCK_FEATURE_FLAG_NAME)).toBe( - !MOCK_FEATURE_FLAG_INITIAL_VALUE, + + expect(mockSetCookie).toHaveBeenCalledWith( + FEATURE_FLAGS_KEY, + JSON.stringify({ + [MOCK_FEATURE_FLAG_NAME]: !MOCK_FEATURE_FLAG_INITIAL_VALUE, + }), + { + expires: expect.any(Date) as Date, + }, ); }); + + test("returns list feature flags from cookie", () => { + mockGetCookie.mockReturnValue(mockDefaultFeatureFlagsString); + const { result } = renderHook(() => useFeatureFlags()); + + const { featureFlags } = result.current; + + expect(featureFlags).toEqual(MOCK_DEFAULT_FEATURE_FLAGS); + }); + + describe("checkFeatureFlag", () => { + test("allows checking value of individual flag", () => { + mockGetCookie.mockReturnValue(mockDefaultFeatureFlagsString); + const { result } = renderHook(() => useFeatureFlags()); + + const { checkFeatureFlag } = result.current; + + const value = checkFeatureFlag("someFakeFeature1"); + expect(value).toEqual(true); + }); + + test("returns false if specified flag is not present", () => { + mockGetCookie.mockReturnValue(mockDefaultFeatureFlagsString); + const { result } = renderHook(() => useFeatureFlags()); + + const { checkFeatureFlag } = result.current; + + const value = checkFeatureFlag("someFakeFeature4"); + expect(value).toEqual(false); + }); + }); }); diff --git a/frontend/tests/pages/dev/feature-flags.test.tsx b/frontend/tests/pages/dev/feature-flags/page.test.tsx similarity index 60% rename from frontend/tests/pages/dev/feature-flags.test.tsx rename to frontend/tests/pages/dev/feature-flags/page.test.tsx index 3094beeb0..3f3dbddd4 100644 --- a/frontend/tests/pages/dev/feature-flags.test.tsx +++ b/frontend/tests/pages/dev/feature-flags/page.test.tsx @@ -4,21 +4,35 @@ import { fireEvent, render, screen } from "@testing-library/react"; import FeatureFlags from "src/app/[locale]/dev/feature-flags/page"; -import { mockDefaultFeatureFlags } from "src/utils/testing/FeatureFlagTestUtils"; -describe("Feature flags page", () => { - const MOCK_DEFAULT_FEATURE_FLAGS = { - someFakeFeature1: true, - someFakeFeature2: true, - someFakeFeature3: true, - }; - - beforeEach(() => { - mockDefaultFeatureFlags(MOCK_DEFAULT_FEATURE_FLAGS); - }); +const MOCK_DEFAULT_FEATURE_FLAGS = { + someFakeFeature1: true, + someFakeFeature2: true, + someFakeFeature3: true, +}; + +type MockFeatureFlagKeys = keyof typeof MOCK_DEFAULT_FEATURE_FLAGS; + +const mockUseFeatureFlags = jest.fn(() => ({ + featureFlags: MOCK_DEFAULT_FEATURE_FLAGS, + setFeatureFlag: (flagName: MockFeatureFlagKeys, value: boolean) => + mockSetFeatureFlag(flagName, value), +})); + +const mockSetFeatureFlag = jest.fn( + (flagName: MockFeatureFlagKeys, value: boolean) => { + MOCK_DEFAULT_FEATURE_FLAGS[flagName] = value; + }, +); + +jest.mock("src/hooks/useFeatureFlags", () => ({ + useFeatureFlags: () => mockUseFeatureFlags(), +})); +describe("Feature flags page", () => { it("should render all feature flags", () => { render(); + expect(mockUseFeatureFlags).toHaveBeenCalled(); Object.keys(MOCK_DEFAULT_FEATURE_FLAGS).forEach((name) => { expect(screen.getByText(name)).toBeInTheDocument(); expect(screen.getByTestId(`${name}-status`)).toHaveTextContent("Enabled"); @@ -26,19 +40,27 @@ describe("Feature flags page", () => { }); it("clicking on a feature flag enable and disable buttons should update state", () => { - render(); + const { rerender } = render(); Object.keys(MOCK_DEFAULT_FEATURE_FLAGS).forEach((name) => { const enableButton = screen.getByTestId(`enable-${name}`); const disableButton = screen.getByTestId(`disable-${name}`); const statusElement = screen.getByTestId(`${name}-status`); + expect(statusElement).toHaveTextContent("Enabled"); fireEvent.click(enableButton); + rerender(); expect(statusElement).toHaveTextContent("Enabled"); + fireEvent.click(disableButton); + rerender(); expect(statusElement).toHaveTextContent("Disabled"); + fireEvent.click(disableButton); + rerender(); expect(statusElement).toHaveTextContent("Disabled"); + fireEvent.click(enableButton); + rerender(); expect(statusElement).toHaveTextContent("Enabled"); }); }); diff --git a/frontend/tests/services/FeatureFlagManager.test.ts b/frontend/tests/services/FeatureFlagManager.test.ts deleted file mode 100644 index b50560530..000000000 --- a/frontend/tests/services/FeatureFlagManager.test.ts +++ /dev/null @@ -1,502 +0,0 @@ -/** - * @jest-environment ./src/utils/testing/jsdomNodeEnvironment.ts - */ - -import Cookies from "js-cookie"; -import { FeatureFlagsManager } from "src/services/FeatureFlagManager"; -import { - mockDefaultFeatureFlags, - mockFeatureFlagsCookie, -} from "src/utils/testing/FeatureFlagTestUtils"; - -import { NextRequest, NextResponse } from "next/server"; - -jest.mock("src/constants/environments", () => ({ - environment: { - FEATURE_FAKE_ONE: "true", - FEATURE_FAKE_TWO: "false", - NOT_A_FEATURE_FLAG: "true", - FEATURE_NON_BOOL: "sure", - }, -})); - -const DEFAULT_FEATURE_FLAGS = { - feature1: true, - feature2: false, - feature3: true, -}; - -const COOKIE_VALUE: { [key: string]: boolean } = { feature1: true }; - -function MockServerCookiesModule(cookieValue = COOKIE_VALUE) { - return { - get: (name: string) => { - if (name === FeatureFlagsManager.FEATURE_FLAGS_KEY) { - return { - value: JSON.stringify(cookieValue), - }; - } - }, - set: jest.fn(), - } as object as NextRequest["cookies"]; -} - -describe("FeatureFlagsManager", () => { - let featureFlagsManager: FeatureFlagsManager; - - beforeEach(() => { - mockFeatureFlagsCookie(COOKIE_VALUE); - - // Mock default feature flags to allow for tests to be independent of actual default values - mockDefaultFeatureFlags(DEFAULT_FEATURE_FLAGS); - - featureFlagsManager = new FeatureFlagsManager(Cookies); - }); - - test("`.featureFlagsCookie` getter loads feature flags with client-side js-cookies", () => { - expect(featureFlagsManager.featureFlagsCookie).toEqual(COOKIE_VALUE); - }); - - test('`.featureFlagsCookie` getter loads feature flags with server-side NextRequest["cookies"]', () => { - const serverFeatureFlagsManager = new FeatureFlagsManager( - MockServerCookiesModule(), - ); - expect(serverFeatureFlagsManager.featureFlagsCookie).toEqual(COOKIE_VALUE); - }); - - test("`.featureFlagsCookie` getter loads feature flags with server-side getServerSideProps cookies", () => { - const cookieRecord = { - // Was unable to override flag keys. Use feature flag class invocation default for now. - _ff: JSON.stringify(COOKIE_VALUE), - }; - const serverFeatureFlagsManager = new FeatureFlagsManager(cookieRecord); - expect(serverFeatureFlagsManager.featureFlagsCookie).toEqual(COOKIE_VALUE); - }); - - test("`.featureFlagsCookie` does not error if the cookie value is empty", () => { - Object.defineProperty(window.document, "cookie", { - writable: true, - value: "", - }); - expect(Cookies.get(FeatureFlagsManager.FEATURE_FLAGS_KEY)).toEqual( - undefined, - ); - expect(featureFlagsManager.featureFlagsCookie).toEqual({}); - }); - - test("`.featureFlagsCookie` does not error if the cookie value is invalid", () => { - const invalidCookieValue = "----------------"; - Object.defineProperty(window.document, "cookie", { - writable: true, - value: `${FeatureFlagsManager.FEATURE_FLAGS_KEY}=${invalidCookieValue}`, - }); - expect(() => JSON.parse(invalidCookieValue) as string).toThrow(); - expect(Cookies.get(FeatureFlagsManager.FEATURE_FLAGS_KEY)).toEqual( - invalidCookieValue, - ); - expect(featureFlagsManager.featureFlagsCookie).toEqual({}); - }); - - test("`.featureFlagsCookie` does not include invalid feature flags even if cookie value includes it", () => { - const invalidFeatureFlag = "someInvalidFeatureFlagName"; - expect(featureFlagsManager.isValidFeatureFlag(invalidFeatureFlag)).toBe( - false, - ); - Object.defineProperty(window.document, "cookie", { - writable: true, - value: JSON.stringify({ [invalidFeatureFlag]: true }), - }); - expect(featureFlagsManager.featureFlagsCookie).toEqual({}); - }); - - test("`.featureFlagsCookie` does not include feature flags if the value is not a boolean", () => { - const validFeatureFlag = "feature1"; - expect(featureFlagsManager.isValidFeatureFlag(validFeatureFlag)).toBe(true); - Object.defineProperty(window.document, "cookie", { - writable: true, - value: JSON.stringify({ - [validFeatureFlag]: "someInvalidFeatureFlagValue", - }), - }); - expect(featureFlagsManager.featureFlagsCookie).toEqual({}); - }); - - test.each([ - "string", - '"string"', - {}, - [], - 1, - 1.1, - "true", - null, - undefined, - "", - ])( - "`.featureFlagsCookie` does not include feature flags if the value is not a boolean like %p", - (featureFlagValue) => { - const featureName = "feature1"; - expect(featureFlagsManager.isValidFeatureFlag(featureName)).toBe(true); - mockFeatureFlagsCookie({ [featureName]: featureFlagValue as boolean }); - expect(featureFlagsManager.featureFlagsCookie).toEqual({}); - }, - ); - - test("`.featureFlags` getter loads cookie value and combines with default feature flags", () => { - const expectedFeatureFlags = { - ...featureFlagsManager.defaultFeatureFlags, - ...COOKIE_VALUE, - }; - expect(featureFlagsManager.featureFlags).toEqual(expectedFeatureFlags); - }); - - test("`.featureFlags` uses default feature flags if no cookie is present", () => { - Cookies.set(FeatureFlagsManager.FEATURE_FLAGS_KEY, JSON.stringify({})); - expect(featureFlagsManager.featureFlagsCookie).toEqual({}); - expect(featureFlagsManager.featureFlags).toEqual( - featureFlagsManager.defaultFeatureFlags, - ); - }); - - test("`.featureFlags` uses cookie values over default feature flags", () => { - // Flip the value of all cookie values - const defaultFeatureFlags = Object.fromEntries( - Object.entries(COOKIE_VALUE).map(([key, value]) => [key, !value]), - ); - jest - .spyOn(FeatureFlagsManager.prototype, "defaultFeatureFlags", "get") - .mockReturnValue(defaultFeatureFlags); - expect(featureFlagsManager.featureFlags).toEqual(COOKIE_VALUE); - }); - - test("`.isFeatureEnabled` correctly interprets values from `.featureFlags`", () => { - expect( - Object.keys(featureFlagsManager.featureFlags).length, - ).toBeGreaterThanOrEqual(1); - Object.entries(featureFlagsManager.featureFlags).forEach( - ([name, enabled]) => { - expect(featureFlagsManager.isFeatureEnabled(name)).toEqual(enabled); - }, - ); - }); - - test("`.isFeatureEnabled` is resilient against cookie value updating from what is cached", () => { - const currentFeatureFlags = featureFlagsManager.featureFlags; - - const featureFlagToSneakilyUpdateName = Object.keys( - featureFlagsManager.featureFlags, - )[0]; - const currentValue = currentFeatureFlags[featureFlagToSneakilyUpdateName]; - expect( - featureFlagsManager.isFeatureEnabled(featureFlagToSneakilyUpdateName), - ).toEqual(currentValue); - - // At this point, since the `FeatureFlagsManager` has already been instantiated, we can change the cookie - // (without going through the manager) and check if it resiliently uses the updated values - Cookies.set( - FeatureFlagsManager.FEATURE_FLAGS_KEY, - JSON.stringify({ - ...currentFeatureFlags, - [featureFlagToSneakilyUpdateName]: !currentValue, - }), - ); - expect( - featureFlagsManager.isFeatureEnabled(featureFlagToSneakilyUpdateName), - ).toEqual(!currentValue); - }); - - test("`.isFeatureEnabled` throws an error if accessing an invalid feature flag", () => { - const fakeFeatureFlag = "someFakeFeatureFlag-------------------"; - expect( - Object.keys(featureFlagsManager.featureFlags).includes(fakeFeatureFlag), - ).toEqual(false); - expect(() => - featureFlagsManager.isFeatureEnabled(fakeFeatureFlag), - ).toThrow(); - }); - - test("`.isValidFeatureFlag` correctly identifies valid feature flag names", () => { - Object.keys(featureFlagsManager.defaultFeatureFlags).forEach((name) => { - expect(featureFlagsManager.isValidFeatureFlag(name)).toEqual(true); - }); - const invalidFeatureFlagName = "someInvalidFeatureFlag--------------"; - expect( - featureFlagsManager.isValidFeatureFlag(invalidFeatureFlagName), - ).toEqual(false); - }); - - test("`.middleware` processes feature flags from query params", () => { - const featureFlagName = "feature1"; - const newValue = false; - expect(DEFAULT_FEATURE_FLAGS[featureFlagName]).not.toEqual(newValue); - const expectedFeatureFlagsCookie = { - [featureFlagName]: newValue, - }; - const queryParamString = `${featureFlagName}:${newValue.toString()}`; - const url = `http://localhost/feature-flags?${FeatureFlagsManager.FEATURE_FLAGS_KEY}=${queryParamString}`; - const request = new NextRequest(new Request(url), {}); - const mockSet = jest.fn(); - jest - .spyOn(NextResponse.prototype, "cookies", "get") - .mockReturnValue({ set: mockSet } as object as NextResponse["cookies"]); - featureFlagsManager.middleware(request, NextResponse.next()); - expect(mockSet).toHaveBeenCalledWith({ - expires: expect.any(Date) as jest.Expect, - name: FeatureFlagsManager.FEATURE_FLAGS_KEY, - value: JSON.stringify(expectedFeatureFlagsCookie), - }); - }); - - /** - * This test is important because there is a race condition between middleware vs frontend cookie - * setting. - */ - test("`.middleware` does not set cookies if no valid feature flags are specified in params", () => { - const paramValue = "fakeFeature:true;anotherFakeFeature:false"; - const parsedFeatureFlags = - featureFlagsManager.parseFeatureFlagsFromString(paramValue); - expect(Object.keys(parsedFeatureFlags).length).toEqual(0); - const url = `http://localhost/feature-flags?${paramValue}`; - const request = new NextRequest(new Request(url), {}); - const mockSet = jest.fn(); - jest - .spyOn(NextResponse.prototype, "cookies", "get") - .mockReturnValue({ set: mockSet } as object as NextResponse["cookies"]); - featureFlagsManager.middleware(request, NextResponse.next()); - expect(mockSet).not.toHaveBeenCalled(); - }); - - test("`.parseFeatureFlagsFromString` correctly parses a valid query param string", () => { - const expectedFeatureFlags = { - feature1: false, - feature2: true, - }; - const validQueryParamString = "feature1:false;feature2:true"; - expect( - featureFlagsManager.parseFeatureFlagsFromString(validQueryParamString), - ).toEqual(expectedFeatureFlags); - const validQueryParamStringWithExtraCharacters = - ";feature1: false; feature2 : true ;"; - expect( - featureFlagsManager.parseFeatureFlagsFromString( - validQueryParamStringWithExtraCharacters, - ), - ).toEqual(expectedFeatureFlags); - }); - - test("`.parseFeatureFlagsFromString` returns {} if null param", () => { - expect(featureFlagsManager.parseFeatureFlagsFromString(null)).toEqual({}); - }); - - test("`.parseFeatureFlagsFromString` returns {} if empty string param", () => { - expect(featureFlagsManager.parseFeatureFlagsFromString("")).toEqual({}); - }); - - test.each([ - "sadfkdfj", - ";;;;;;;;;;;;;", - "truetruetrue=false", - "true=false", - "!@#$%^&*(){}[]:\";|'<.,./?\\`~", - ])( - "`.parseFeatureFlagsFromString` gracefully handles garbled values like %s", - (queryParamString) => { - const featureFlags = - featureFlagsManager.parseFeatureFlagsFromString(queryParamString); - expect(featureFlags).toEqual({}); - }, - ); - - test.each([ - ["feature1", "true", true], - ["invalidFeatureFlag", "true", false], - ["feature1", "invalidFlagValue", false], - ])( - "`.parseFeatureFlagsFromString` omits invalid flag names and values (case %#)", - (flagName, flagValue, isValid) => { - const queryParamString = `${flagName}:${flagValue}`; - const featureFlags = - featureFlagsManager.parseFeatureFlagsFromString(queryParamString); - expect(Object.keys(featureFlags).includes(flagName)).toBe(isValid); - }, - ); - - test("`.setFeatureflagCookie` updates the feature flags", () => { - const currentFeatureFlags = featureFlagsManager.featureFlags; - - const featureFlagToChangeName = Object.keys(currentFeatureFlags)[0]; - const newFeatureFlagValue = !currentFeatureFlags[featureFlagToChangeName]; - featureFlagsManager.setFeatureFlagCookie( - featureFlagToChangeName, - newFeatureFlagValue, - ); - - const expectedNewFeatureFlags = { - ...currentFeatureFlags, - [featureFlagToChangeName]: newFeatureFlagValue, - }; - - const expectedFeatureFlagsCookie = { - [featureFlagToChangeName]: newFeatureFlagValue, - }; - expect(featureFlagsManager.featureFlagsCookie).toEqual( - expectedFeatureFlagsCookie, - ); - expect(featureFlagsManager.featureFlags).toEqual(expectedNewFeatureFlags); - }); - - test("`.setFeatureflagCookie` throws an error if the feature flag name is invalid", () => { - const someInvalidFeatureFlag = "someFakeFeatureFlag-------------------"; - expect( - Object.keys(featureFlagsManager.featureFlags).includes( - someInvalidFeatureFlag, - ), - ).toEqual(false); - expect(() => - featureFlagsManager.setFeatureFlagCookie(someInvalidFeatureFlag, true), - ).toThrow(); - }); - - test("`.setFeatureflagCookie` is resilient against cookie value updating from what is cached", () => { - const currentFeatureFlags = featureFlagsManager.featureFlags; - - const featureFlagToSneakilyUpdateName = Object.keys(currentFeatureFlags)[0]; - const newFeatureFlagToSneakilyUpdateValue = - !currentFeatureFlags[featureFlagToSneakilyUpdateName]; - // At this point, since the `FeatureFlagsManager` has already been instantiated, we can change the cookie - // (without going through the manager) and check if it resiliently uses the updated values - expect( - featureFlagsManager.isFeatureEnabled(featureFlagToSneakilyUpdateName), - ).not.toEqual(newFeatureFlagToSneakilyUpdateValue); - Cookies.set( - FeatureFlagsManager.FEATURE_FLAGS_KEY, - JSON.stringify({ - ...currentFeatureFlags, - [featureFlagToSneakilyUpdateName]: newFeatureFlagToSneakilyUpdateValue, - }), - ); - - const featureFlagToChangeName = Object.keys(currentFeatureFlags)[1]; - const newFeatureFlagToChangeValue = - !currentFeatureFlags[featureFlagToChangeName]; - expect( - featureFlagsManager.isFeatureEnabled(featureFlagToChangeName), - ).not.toEqual(newFeatureFlagToChangeValue); - expect(featureFlagToChangeName).not.toEqual( - featureFlagToSneakilyUpdateName, - ); - featureFlagsManager.setFeatureFlagCookie( - featureFlagToChangeName, - newFeatureFlagToChangeValue, - ); - - const expectedNewFeatureFlags = { - ...currentFeatureFlags, - [featureFlagToSneakilyUpdateName]: newFeatureFlagToSneakilyUpdateValue, - [featureFlagToChangeName]: newFeatureFlagToChangeValue, - }; - expect(featureFlagsManager.featureFlagsCookie).toEqual( - expectedNewFeatureFlags, - ); - expect(featureFlagsManager.featureFlags).toEqual(expectedNewFeatureFlags); - }); - - describe("Calls feature flag from server component", () => { - const readonlyCookiesExample = { - _ff: '{"feature1": true, "feature2": false}', - }; - - test("correctly initializes from ReadonlyRequestCookies", () => { - const readonlyCookies = readonlyCookiesExample; - const featureFlagsManager = new FeatureFlagsManager(readonlyCookies); - - expect(featureFlagsManager.isFeatureEnabled("feature1")).toBe(true); - expect(featureFlagsManager.isFeatureEnabled("feature2")).toBe(false); - }); - - test("throws error for invalid feature flag in ReadonlyRequestCookies", () => { - const invalidFlagCookies = { - _ff: '{"invalidFeature": true}', - }; - - const featureFlagsManager = new FeatureFlagsManager(invalidFlagCookies); - - // Accessing an invalid feature flag throws an error - expect(() => - featureFlagsManager.isFeatureEnabled("invalidFeature"), - ).toThrow(); - }); - describe("featureFlagsFromEnvironment", () => { - // notice the interaction between the values in the test and the values in the - // mocked environment at the top of the file - it("sets any env vars fitting `FEATURE_` pattern into snake case", () => { - mockDefaultFeatureFlags({ - fakeOne: false, - fakeTwo: true, - nonBool: true, - }); - - const featureFlagsManager = new FeatureFlagsManager(Cookies); - expect(featureFlagsManager.featureFlagsFromEnvironment).toEqual({ - fakeOne: true, - fakeTwo: false, - nonBool: false, - }); - }); - }); - }); - describe("feature flag precedence", () => { - it("overrides defaults with env vars", () => { - mockDefaultFeatureFlags({ - fakeOne: false, - fakeTwo: true, - }); - // Set a different state in cookies to test precedence - const modifiedCookieValue = {}; - mockFeatureFlagsCookie(modifiedCookieValue); - const serverFeatureFlagsManager = new FeatureFlagsManager( - MockServerCookiesModule(), - ); - - expect(serverFeatureFlagsManager.isFeatureEnabled("fakeOne")).toBe(true); - expect(serverFeatureFlagsManager.isFeatureEnabled("fakeTwo")).toBe(false); - }); - - it("overrides env vars with cookies", () => { - mockDefaultFeatureFlags({ - fakeOne: false, - fakeTwo: true, - }); - // Set a different state in cookies to test precedence - const modifiedCookieValue = { - fakeOne: false, - fakeTwo: true, - }; - mockFeatureFlagsCookie(modifiedCookieValue); - const serverFeatureFlagsManager = new FeatureFlagsManager( - MockServerCookiesModule(modifiedCookieValue), - ); - - expect(serverFeatureFlagsManager.isFeatureEnabled("fakeOne")).toBe(false); - expect(serverFeatureFlagsManager.isFeatureEnabled("fakeTwo")).toBe(true); - }); - - test("`searchParams` override takes precedence over default and cookie-based feature flags", () => { - // Set a different state in cookies to test precedence - const modifiedCookieValue = { feature1: true }; - mockFeatureFlagsCookie(modifiedCookieValue); - const serverFeatureFlagsManager = new FeatureFlagsManager( - MockServerCookiesModule(), - ); - - // Now provide searchParams with a conflicting setup - const searchParams = { - _ff: "feature1:false", - }; - - expect( - serverFeatureFlagsManager.isFeatureEnabled("feature1", searchParams), - ).toBe(false); - }); - }); -}); diff --git a/frontend/tests/services/featureFlags/FeatureFlagManager.test.ts b/frontend/tests/services/featureFlags/FeatureFlagManager.test.ts new file mode 100644 index 000000000..ad689b93a --- /dev/null +++ b/frontend/tests/services/featureFlags/FeatureFlagManager.test.ts @@ -0,0 +1,299 @@ +/** + * @jest-environment ./src/utils/testing/jsdomNodeEnvironment.ts + */ + +import { FEATURE_FLAGS_KEY } from "src/services/featureFlags/featureFlagHelpers"; +import { FeatureFlagsManager } from "src/services/featureFlags/FeatureFlagManager"; +import { + mockDefaultFeatureFlags, + mockFeatureFlagsCookie, +} from "src/utils/testing/FeatureFlagTestUtils"; + +import { RequestCookies } from "next/dist/compiled/@edge-runtime/cookies"; +import { NextRequest, NextResponse } from "next/server"; + +const mockParseFeatureFlagsFromString = jest.fn(); +const mockIsValidFeatureFlag = jest.fn(); +const mockGetFeatureFlagsFromCookie = jest.fn(); + +jest.mock("src/constants/environments", () => ({ + featureFlags: { + fakeOne: true, + fakeTwo: false, + nonBool: "sure", + }, +})); + +jest.mock("src/services/featureFlags/featureFlagHelpers", () => ({ + ...jest.requireActual< + typeof import("src/services/featureFlags/featureFlagHelpers") + >("src/services/featureFlags/featureFlagHelpers"), + parseFeatureFlagsFromString: (params: string): unknown => + mockParseFeatureFlagsFromString(params), + isValidFeatureFlag: (name: string): unknown => mockIsValidFeatureFlag(name), + getFeatureFlagsFromCookie: (): unknown => mockGetFeatureFlagsFromCookie(), +})); + +const DEFAULT_FEATURE_FLAGS = { + feature1: true, + feature2: false, + feature3: true, +}; + +const COOKIE_VALUE: { [key: string]: boolean } = { feature1: true }; + +function MockServerCookiesModule(cookieValue = COOKIE_VALUE) { + return { + get: (name: string) => { + if (name === FEATURE_FLAGS_KEY) { + return { + value: JSON.stringify(cookieValue), + }; + } + }, + set: jest.fn(), + } as object as NextRequest["cookies"]; +} + +describe("FeatureFlagsManager", () => { + let featureFlagsManager: FeatureFlagsManager; + + beforeEach(() => { + mockFeatureFlagsCookie(COOKIE_VALUE); + + // Mock default feature flags to allow for tests to be independent of actual default values + mockDefaultFeatureFlags(DEFAULT_FEATURE_FLAGS); + + featureFlagsManager = new FeatureFlagsManager({}); + mockIsValidFeatureFlag.mockReturnValue(true); + }); + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("middleware", () => { + test("processes feature flags from query params", () => { + const expectedFeatureFlags = { + feature1: false, + }; + mockParseFeatureFlagsFromString.mockImplementation( + () => expectedFeatureFlags, + ); + const mockSet = jest.fn(); + jest + .spyOn(NextResponse.prototype, "cookies", "get") + .mockReturnValue({ set: mockSet } as object as NextResponse["cookies"]); + + const request = new NextRequest(new Request("fakeUrl://not.real"), {}); + + featureFlagsManager.middleware(request, NextResponse.next()); + expect(mockSet).toHaveBeenCalledWith({ + expires: expect.any(Date) as jest.Expect, + name: FEATURE_FLAGS_KEY, + value: JSON.stringify({ + ...DEFAULT_FEATURE_FLAGS, + ...expectedFeatureFlags, + }), + }); + }); + + test("sets cookie with correct combination of default, env var and query param based flags", () => { + mockParseFeatureFlagsFromString.mockImplementation(() => ({ + feature2: true, + })); + const request = new NextRequest(new Request("fakeUrl://not.real"), {}); + const mockSet = jest.fn(); + jest + .spyOn(NextResponse.prototype, "cookies", "get") + .mockReturnValue({ set: mockSet } as object as NextResponse["cookies"]); + + const featureFlagsManager = new FeatureFlagsManager({ feature3: false }); + featureFlagsManager.middleware(request, NextResponse.next()); + expect(mockSet).toHaveBeenCalledWith({ + expires: expect.any(Date) as jest.Expect, + name: FEATURE_FLAGS_KEY, + value: JSON.stringify({ + feature1: true, // from default + feature2: true, // from query param + feature3: false, // from env var + }), + }); + }); + + test("resets cookie to server side settings on `reset` value", () => { + const request = new NextRequest( + new Request("fakeUrl://not.real?_ff=reset"), + {}, + ); + const mockSet = jest.fn(); + jest + .spyOn(NextResponse.prototype, "cookies", "get") + .mockReturnValue({ set: mockSet } as object as NextResponse["cookies"]); + + const featureFlagsManager = new FeatureFlagsManager({ feature3: false }); + featureFlagsManager.middleware(request, NextResponse.next()); + expect(mockSet).toHaveBeenCalledWith({ + expires: expect.any(Date) as jest.Expect, + name: FEATURE_FLAGS_KEY, + value: JSON.stringify({ + feature1: true, // from default + feature2: false, // from default + feature3: false, // from env var + }), + }); + }); + }); + + describe("isFeatureEnabled", () => { + test("correctly interprets values from basic internal feature flags with no passed cookies", () => { + mockIsValidFeatureFlag.mockReturnValue(true); + Object.entries(featureFlagsManager.featureFlags).forEach( + ([name, enabled]) => { + expect( + featureFlagsManager.isFeatureEnabled( + name, + new RequestCookies(new Headers()), + ), + ).toEqual(enabled); + }, + ); + }); + + test("reads values from passed cookies", () => { + mockGetFeatureFlagsFromCookie.mockReturnValue({}); + const currentFeatureFlags = featureFlagsManager.featureFlags; + + const featureFlagToUpdateName = Object.keys( + featureFlagsManager.featureFlags, + )[0]; + const currentValue = currentFeatureFlags[featureFlagToUpdateName]; + expect( + featureFlagsManager.isFeatureEnabled( + featureFlagToUpdateName, + new RequestCookies(new Headers()), + ), + ).toEqual(currentValue); + + mockGetFeatureFlagsFromCookie.mockReturnValue({ + [featureFlagToUpdateName]: !currentValue, + }); + expect( + featureFlagsManager.isFeatureEnabled( + featureFlagToUpdateName, + new RequestCookies(new Headers()), + ), + ).toEqual(!currentValue); + }); + + test("reads values from query params", () => { + mockGetFeatureFlagsFromCookie.mockReturnValue({}); + const currentFeatureFlags = featureFlagsManager.featureFlags; + + const featureFlagToUpdateName = Object.keys( + featureFlagsManager.featureFlags, + )[0]; + const currentValue = currentFeatureFlags[featureFlagToUpdateName]; + + mockParseFeatureFlagsFromString.mockReturnValue({ + [featureFlagToUpdateName]: !currentValue, + }); + + expect( + featureFlagsManager.isFeatureEnabled( + featureFlagToUpdateName, + new RequestCookies(new Headers()), + { [FEATURE_FLAGS_KEY]: "some: junk" }, + ), + ).toEqual(!currentValue); + }); + + test("throws an error if accessing an invalid feature flag", () => { + mockIsValidFeatureFlag.mockReturnValue(false); + expect(() => + featureFlagsManager.isFeatureEnabled( + "any", + new RequestCookies(new Headers()), + ), + ).toThrow(); + }); + describe("feature flag precedence", () => { + it("overrides defaults with env vars", () => { + mockDefaultFeatureFlags({ + fakeOne: false, + fakeTwo: true, + }); + // Set a different state in cookies to test precedence + const modifiedCookieValue = {}; + mockFeatureFlagsCookie(modifiedCookieValue); + const serverFeatureFlagsManager = new FeatureFlagsManager({ + fakeOne: true, + fakeTwo: false, + }); + + expect( + serverFeatureFlagsManager.isFeatureEnabled( + "fakeOne", + MockServerCookiesModule(), + ), + ).toBe(true); + expect( + serverFeatureFlagsManager.isFeatureEnabled( + "fakeTwo", + MockServerCookiesModule(), + ), + ).toBe(false); + }); + + it("overrides env vars with cookies", () => { + mockDefaultFeatureFlags({ + fakeOne: false, + fakeTwo: true, + }); + // Set a different state in cookies to test precedence + const modifiedCookieValue = { + fakeOne: false, + fakeTwo: true, + }; + mockFeatureFlagsCookie(modifiedCookieValue); + const serverFeatureFlagsManager = new FeatureFlagsManager({}); + + expect( + serverFeatureFlagsManager.isFeatureEnabled( + "fakeOne", + MockServerCookiesModule(), + ), + ).toBe(false); + expect( + serverFeatureFlagsManager.isFeatureEnabled( + "fakeTwo", + MockServerCookiesModule(), + ), + ).toBe(true); + }); + + test("`searchParams` override takes precedence over default and cookie-based feature flags", () => { + // Set a different state in cookies to test precedence + const modifiedCookieValue = { feature1: true }; + mockFeatureFlagsCookie(modifiedCookieValue); + const serverFeatureFlagsManager = new FeatureFlagsManager({}); + + // Now provide searchParams with a conflicting setup + const searchParams = { + _ff: "feature1:false", + }; + + mockParseFeatureFlagsFromString.mockReturnValue({ + feature1: false, + }); + + expect( + serverFeatureFlagsManager.isFeatureEnabled( + "feature1", + MockServerCookiesModule(), + searchParams, + ), + ).toBe(false); + }); + }); + }); +}); diff --git a/frontend/tests/services/featureFlags/featureFlagHelpers.test.ts b/frontend/tests/services/featureFlags/featureFlagHelpers.test.ts new file mode 100644 index 000000000..c17be671d --- /dev/null +++ b/frontend/tests/services/featureFlags/featureFlagHelpers.test.ts @@ -0,0 +1,202 @@ +import { + FEATURE_FLAGS_KEY, + getFeatureFlagsFromCookie, + isValidFeatureFlag, + parseFeatureFlagsFromString, + setCookie, +} from "src/services/featureFlags/featureFlagHelpers"; + +import { + RequestCookies, + ResponseCookies, +} from "next/dist/compiled/@edge-runtime/cookies"; +import { NextRequest } from "next/server"; + +jest.mock("src/constants/defaultFeatureFlags", () => ({ + defaultFeatureFlags: { + feature1: true, + feature2: false, + feature3: true, + }, +})); + +const COOKIE_VALUE: { [key: string]: boolean } = { feature1: true }; + +const mockSetCookies = jest.fn(); + +function MockRequestCookiesModule(cookieValue = COOKIE_VALUE) { + return { + get: (name: string) => { + if (name === FEATURE_FLAGS_KEY) { + return { + value: JSON.stringify(cookieValue), + }; + } + }, + set: mockSetCookies, + } as object as NextRequest["cookies"]; +} + +function MockResponseCookiesModule(cookieValue = COOKIE_VALUE) { + return { + get: (name: string) => { + if (name === FEATURE_FLAGS_KEY) { + return { + value: JSON.stringify(cookieValue), + }; + } + }, + set: mockSetCookies, + } as object as ResponseCookies; +} + +describe("getFeatureFlagsFromCookie", () => { + test('getter loads feature flags with server-side NextRequest["cookies"]', () => { + expect(getFeatureFlagsFromCookie(MockRequestCookiesModule())).toEqual( + COOKIE_VALUE, + ); + }); + + // // do we still need to support this? + // test("getter loads feature flags with server-side getServerSideProps cookies", () => { + // const cookieRecord = { + // // Was unable to override flag keys. Use feature flag class invocation default for now. + // _ff: JSON.stringify(COOKIE_VALUE), + // }; + // expect(getFeatureFlagsFromCookie(cookieRecord)).toEqual(COOKIE_VALUE); + // }); + + test("does not error if the cookie value is empty", () => { + expect( + getFeatureFlagsFromCookie(new RequestCookies(new Headers())), + ).toEqual({}); + }); + + test("does not error if the cookie value is invalid", () => { + const invalidCookieValue = "----------------"; + expect( + getFeatureFlagsFromCookie( + new RequestCookies( + new Headers({ Cookie: `${FEATURE_FLAGS_KEY}=${invalidCookieValue}` }), + ), + ), + ).toEqual({}); + }); + + test("does not include invalid feature flags even if included in cookie value", () => { + const invalidFeatureFlag = "someInvalidFeatureFlagName"; + expect( + getFeatureFlagsFromCookie( + new RequestCookies( + new Headers({ + Cookie: `${FEATURE_FLAGS_KEY}=${invalidFeatureFlag}:true`, + }), + ), + ), + ).toEqual({}); + }); + + test("does not include feature flags if the value is not a boolean", () => { + const validFeatureFlag = "feature1"; + expect( + getFeatureFlagsFromCookie( + new RequestCookies( + new Headers({ + Cookie: `${FEATURE_FLAGS_KEY}=${validFeatureFlag}:someInvalidFeatureFlagValue"`, + }), + ), + ), + ).toEqual({}); + }); + + test.each(["string", '"string"', 1, 1.1, "true", ""])( + "does not include feature flags if the value is not a boolean like %p", + (featureFlagValue) => { + const featureName = "feature1"; + expect( + getFeatureFlagsFromCookie( + new RequestCookies( + new Headers({ + Cookie: `${FEATURE_FLAGS_KEY}=${featureName}:${featureFlagValue}`, + }), + ), + ), + ).toEqual({}); + }, + ); +}); + +describe("isValidFeatureFlag", () => { + test("correctly identifies valid feature flag names", () => { + Object.keys({ + feature1: true, + feature2: false, + feature3: true, + }).forEach((name) => { + expect(isValidFeatureFlag(name)).toEqual(true); + }); + const invalidFeatureFlagName = "someInvalidFeatureFlag--------------"; + expect(isValidFeatureFlag(invalidFeatureFlagName)).toEqual(false); + }); +}); + +describe("parseFeatureFlagsFromString", () => { + test("correctly parses a valid query param string", () => { + const expectedFeatureFlags = { + feature1: false, + feature2: true, + }; + const validQueryParamString = "feature1:false;feature2:true"; + expect(parseFeatureFlagsFromString(validQueryParamString)).toEqual( + expectedFeatureFlags, + ); + const validQueryParamStringWithExtraCharacters = + ";feature1: false; feature2 : true ;"; + expect( + parseFeatureFlagsFromString(validQueryParamStringWithExtraCharacters), + ).toEqual(expectedFeatureFlags); + }); + + test("returns {} if null param", () => { + expect(parseFeatureFlagsFromString(null)).toEqual({}); + }); + + test("returns {} if empty string param", () => { + expect(parseFeatureFlagsFromString("")).toEqual({}); + }); + + test.each([ + "sadfkdfj", + ";;;;;;;;;;;;;", + "truetruetrue=false", + "true=false", + "!@#$%^&*(){}[]:\";|'<.,./?\\`~", + ])("gracefully handles garbled values like %s", (queryParamString) => { + const featureFlags = parseFeatureFlagsFromString(queryParamString); + expect(featureFlags).toEqual({}); + }); + + test.each([ + ["feature1", "true", true], + ["invalidFeatureFlag", "true", false], + ["feature1", "invalidFlagValue", false], + ])( + "omits invalid flag names and values (case %#)", + (flagName, flagValue, isValid) => { + const queryParamString = `${flagName}:${flagValue}`; + const featureFlags = parseFeatureFlagsFromString(queryParamString); + expect(Object.keys(featureFlags).includes(flagName)).toBe(isValid); + }, + ); +}); + +describe("setCookie", () => { + test("calls cookie set method with proper arguments", () => { + setCookie('{"anyFeatureFlagName":"anyValue"}', MockResponseCookiesModule()); + expect(mockSetCookies).toHaveBeenCalledWith({ + name: FEATURE_FLAGS_KEY, + value: '{"anyFeatureFlagName":"anyValue"}', + expires: expect.any(Date) as Date, + }); + }); +}); diff --git a/frontend/tests/services/withFeatureFlag.test.tsx b/frontend/tests/services/withFeatureFlag.test.tsx index 54ba9e7d5..32eecb29a 100644 --- a/frontend/tests/services/withFeatureFlag.test.tsx +++ b/frontend/tests/services/withFeatureFlag.test.tsx @@ -6,7 +6,7 @@ import { render } from "tests/react-utils"; let enableFeature = false; -jest.mock("src/services/FeatureFlagManager", () => { +jest.mock("src/services/featureFlags/FeatureFlagManager", () => { class FakeFeatureFlagManager { isFeatureEnabled() { return enableFeature; @@ -14,7 +14,7 @@ jest.mock("src/services/FeatureFlagManager", () => { } return { - FeatureFlagsManager: FakeFeatureFlagManager, + featureFlagsManager: new FakeFeatureFlagManager(), }; }); diff --git a/infra/frontend/app-config/env-config/environment-variables.tf b/infra/frontend/app-config/env-config/environment-variables.tf index 42d7b8f3f..2551d89dc 100644 --- a/infra/frontend/app-config/env-config/environment-variables.tf +++ b/infra/frontend/app-config/env-config/environment-variables.tf @@ -46,7 +46,7 @@ locals { manage_method = "manual" secret_store_name = "/${var.app_name}/${var.environment}/api-auth-token" }, - # URL for the API login route. + # URL for the API login route. AUTH_LOGIN_URL = { manage_method = "manual" secret_store_name = "/${var.app_name}/${var.environment}/auth-login-url" @@ -70,6 +70,10 @@ locals { SESSION_SECRET = { manage_method = "generated" secret_store_name = "/${var.app_name}/${var.environment}/session-secret" - } + }, + FEATURE_AUTH_ON = { + manage_method = "manual" + secret_store_name = "/${var.app_name}/${var.environment}/feature-auth-on" + }, } }