diff --git a/documentation/infra/environment-variables-and-secrets.md b/documentation/infra/environment-variables-and-secrets.md index f05d39df6..8b3156898 100644 --- a/documentation/infra/environment-variables-and-secrets.md +++ b/documentation/infra/environment-variables-and-secrets.md @@ -8,10 +8,10 @@ Applications may need application specific configuration as environment variable > ⚠️ Note: Do not put sensitive information such as credentials as regular environment variables. The method described in this section will embed the environment variables and their values in the ECS task definition's container definitions, so anyone with access to view the task definition will be able to see the values of the environment variables. For configuring secrets, see the section below on [Secrets](#secrets) -Environment variables are defined in the `app-config` module in the [environment-variables.tf file](/infra/app/app-config/env-config/environment-variables.tf). Modify the `default_extra_environment_variables` map to define extra environment variables specific to the application. Map keys define the environment variable name, and values define the default value for the variable across application environments. For example: +Environment variables are defined in the `app-config` module in the [environment_variables.tf file](/infra/app/app-config/env-config/environment_variables.tf). Modify the `default_extra_environment_variables` map to define extra environment variables specific to the application. Map keys define the environment variable name, and values define the default value for the variable across application environments. For example: ```terraform -# environment-variables.tf +# environment_variables.tf locals { default_extra_environment_variables = { @@ -40,19 +40,23 @@ module "dev_config" { Secrets are a specific category of environment variables that need to be handled sensitively. Examples of secrets are authentication credentials such as API keys for external services. Secrets first need to be stored in AWS SSM Parameter Store as a `SecureString`. This section then describes how to make those secrets accessible to the ECS task as environment variables through the `secrets` configuration in the container definition (see AWS documentation on [retrieving Secrets Manager secrets through environment variables](https://docs.aws.amazon.com/AmazonECS/latest/developerguide/secrets-envvar-secrets-manager.html)). -Secrets are defined in the same file that non-sensitive environment variables are defined, in the `app-config` module in the [environment-variables.tf file](/infra/app/app-config/env-config/environment-variables.tf). Modify the `secrets` list to define the secrets that the application will have access to. For each secret, `name` defines the environment variable name, and `ssm_param_name` defines the SSM parameter name that stores the secret value. For example: +Secrets are defined in the same file that non-sensitive environment variables are defined, in the `app-config` module in the [environment_variables.tf file](/infra/app/app-config/env-config/environment_variables.tf). Modify the `secrets` map to define the secrets that the application will have access to. For each secret, the map key defines the environment variable name. The `manage_method` property, which can be set to `"generated"` or `"manual"`, defines whether or not to generate a random secret or to reference an existing secret that was manually created and stored into AWS SSM. The `secret_store_name` property defines the SSM parameter name that stores the secret value. If `manage_method = "generated"`, then `secret_store_name` is where terraform will store the secret. If `manage_method = "manual"`, then `secret_store_name` is where terraform will look for the existing secret. For example: ```terraform -# environment-variables.tf +# environment_variables.tf locals { - secrets = [ - { - name = "SOME_API_KEY" - ssm_param_name = "/${var.app_name}-${var.environment}/secret-sauce" + secrets = { + GENERATED_SECRET = { + manage_method = "generated" + secret_store_name = "/${var.app_name}-${var.environment}/generated-secret" } - ] + MANUALLY_CREATED_SECRET = { + manage_method = "manual" + secret_store_name = "/${var.app_name}-${var.environment}/manually-created-secret" + } + } } ``` -> ⚠️ Make sure you store the secret in SSM Parameter Store before you try to add secrets to your application service, or else the service won't be able to start since the ECS Task Executor won't be able to fetch the configured secret. +> ⚠️ For secrets with `manage_method = "manual"`, make sure you store the secret in SSM Parameter Store _before_ you try to add configure your application service with the secrets, or else the service won't be able to start since the ECS Task Executor won't be able to fetch the configured secret. diff --git a/frontend/.env.development b/frontend/.env.development index 7226f0a7a..a9ec42c2e 100644 --- a/frontend/.env.development +++ b/frontend/.env.development @@ -20,6 +20,8 @@ API_URL=http://localhost:8080 # This is also hardcoded and checked-in on the API API_AUTH_TOKEN=LOCAL_AUTH_12345678 +AUTH_LOGIN_URL=http://localhost:8080/v1/users/login + # Boolean to switch between mock data and live API call data # Default is a live API call USE_SEARCH_MOCK_DATA=false @@ -29,3 +31,5 @@ USE_SEARCH_MOCK_DATA=false # DO NOT COMMIT THESE VALUES TO GITHUB NEW_RELIC_APP_NAME= NEW_RELIC_LICENSE_KEY= + +SESSION_SECRET=extraSecretSessionSecretValueSssh diff --git a/frontend/.env.production b/frontend/.env.production index 6aeea7571..5c9d9633e 100644 --- a/frontend/.env.production +++ b/frontend/.env.production @@ -2,13 +2,3 @@ # This file is checked into your git repo and provides defaults for non-sensitive # env vars when running `next dev` only. # Learn more: https://nextjs.org/docs/app/building-your-application/configuring/environment-variables - -# If you deploy to a subpath, change this to the subpath so relative paths work correctly. -NEXT_PUBLIC_BASE_PATH= - -# Put the following secrets into a .env.local file for local development -SENDY_API_KEY= -SENDY_API_URL= -SENDY_LIST_ID= - -API_URL=http://api.simpler.grants.gov diff --git a/frontend/next.config.js b/frontend/next.config.js index a2dc01e3e..0b0994455 100644 --- a/frontend/next.config.js +++ b/frontend/next.config.js @@ -66,6 +66,16 @@ const nextConfig = { }, ], }, + // don't cache the api + { + source: "/api/:path*", + headers: [ + { + key: "Cache-Control", + value: "no-store, must-revalidate", + }, + ], + }, ]; }, basePath, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5a0ac9d19..968c9932f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -18,6 +18,7 @@ "dayjs": "^1.11.13", "focus-trap-react": "^10.2.3", "isomorphic-dompurify": "^2.15.0", + "jose": "^5.9.6", "js-cookie": "^3.0.5", "lodash": "^4.17.21", "newrelic": "^12.7.0", @@ -16260,6 +16261,14 @@ "jiti": "bin/jiti.js" } }, + "node_modules/jose": { + "version": "5.9.6", + "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", + "integrity": "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ==", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-cookie": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index fee56a75b..d588fb266 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -39,6 +39,7 @@ "dayjs": "^1.11.13", "focus-trap-react": "^10.2.3", "isomorphic-dompurify": "^2.15.0", + "jose": "^5.9.6", "js-cookie": "^3.0.5", "lodash": "^4.17.21", "newrelic": "^12.7.0", diff --git a/frontend/src/app/[locale]/dev/feature-flags/FeatureFlagsTable.tsx b/frontend/src/app/[locale]/dev/feature-flags/FeatureFlagsTable.tsx index 934b6b952..eef88cbdc 100644 --- a/frontend/src/app/[locale]/dev/feature-flags/FeatureFlagsTable.tsx +++ b/frontend/src/app/[locale]/dev/feature-flags/FeatureFlagsTable.tsx @@ -9,24 +9,20 @@ import { Button, Table } from "@trussworks/react-uswds"; * View for managing feature flags */ export default function FeatureFlagsTable() { - const { featureFlagsManager, mounted, setFeatureFlag } = useFeatureFlags(); - - if (!mounted) { - return null; - } + const { setFeatureFlag, featureFlags } = useFeatureFlags(); return ( - - - - - - - - - - {Object.entries(featureFlagsManager.featureFlags).map( - ([featureName, enabled]) => ( + <> +
StatusFeature FlagActions
+ + + + + + + + + {Object.entries(featureFlags).map(([featureName, enabled]) => ( - ), - )} - -
StatusFeature FlagActions
+ ))} + + + ); } diff --git a/frontend/src/app/[locale]/dev/feature-flags/page.tsx b/frontend/src/app/[locale]/dev/feature-flags/page.tsx index 88986ad97..18ed8e909 100644 --- a/frontend/src/app/[locale]/dev/feature-flags/page.tsx +++ b/frontend/src/app/[locale]/dev/feature-flags/page.tsx @@ -1,10 +1,9 @@ import { Metadata } from "next"; +import FeatureFlagsTable from "src/app/[locale]/dev/feature-flags/FeatureFlagsTable"; import Head from "next/head"; import React from "react"; -import FeatureFlagsTable from "./FeatureFlagsTable"; - export function generateMetadata() { const meta: Metadata = { title: "Feature flag manager", diff --git a/frontend/src/app/[locale]/error/page.tsx b/frontend/src/app/[locale]/error/page.tsx new file mode 100644 index 000000000..925b16626 --- /dev/null +++ b/frontend/src/app/[locale]/error/page.tsx @@ -0,0 +1,29 @@ +import { Metadata } from "next"; + +import { useTranslations } from "next-intl"; +import { getTranslations } from "next-intl/server"; +import { GridContainer } from "@trussworks/react-uswds"; + +import ServerErrorAlert from "src/components/ServerErrorAlert"; + +export async function generateMetadata() { + const t = await getTranslations(); + const meta: Metadata = { + title: t("ErrorPages.generic_error.page_title"), + description: t("Index.meta_description"), + }; + return meta; +} + +// not a NextJS error page - this is here to be redirected to manually in cases +// where Next's error handling situation doesn't quite do what we need. +const TopLevelError = () => { + const t = useTranslations("Errors"); + return ( + + + + ); +}; + +export default TopLevelError; diff --git a/frontend/src/app/[locale]/not-found.tsx b/frontend/src/app/[locale]/not-found.tsx index 7831c241e..d280024aa 100644 --- a/frontend/src/app/[locale]/not-found.tsx +++ b/frontend/src/app/[locale]/not-found.tsx @@ -10,7 +10,7 @@ import BetaAlert from "src/components/BetaAlert"; export async function generateMetadata() { const t = await getTranslations(); const meta: Metadata = { - title: t("ErrorPages.page_not_found.title"), + title: t("ErrorPages.page_not_found.page_title"), description: t("Index.meta_description"), }; return meta; @@ -24,7 +24,7 @@ export default function NotFound() { <> -

{t("title")}

+

{t("title")}

{t("message_content_1")}

{t("visit_homepage_button")} diff --git a/frontend/src/app/[locale]/opportunity/[id]/page.tsx b/frontend/src/app/[locale]/opportunity/[id]/page.tsx index 4ed492676..5c2e7c88e 100644 --- a/frontend/src/app/[locale]/opportunity/[id]/page.tsx +++ b/frontend/src/app/[locale]/opportunity/[id]/page.tsx @@ -1,9 +1,9 @@ import { Metadata } from "next"; import NotFound from "src/app/[locale]/not-found"; -import { fetchOpportunity } from "src/app/api/fetchers"; import { OPPORTUNITY_CRUMBS } from "src/constants/breadcrumbs"; import { ApiRequestError, parseErrorStatus } from "src/errors"; -import withFeatureFlag from "src/hoc/search/withFeatureFlag"; +import withFeatureFlag from "src/hoc/withFeatureFlag"; +import { fetchOpportunity } from "src/services/fetch/fetchers/fetchers"; import { Opportunity } from "src/types/opportunity/opportunityResponseTypes"; import { WithFeatureFlagProps } from "src/types/uiTypes"; diff --git a/frontend/src/app/[locale]/process/ProcessNext.tsx b/frontend/src/app/[locale]/process/ProcessNext.tsx index d24a09ea5..68f6b6a17 100644 --- a/frontend/src/app/[locale]/process/ProcessNext.tsx +++ b/frontend/src/app/[locale]/process/ProcessNext.tsx @@ -80,7 +80,7 @@ const ProcessNext = () => { > diff --git a/frontend/src/app/[locale]/search/error.tsx b/frontend/src/app/[locale]/search/error.tsx index 003667ad6..33fc600d2 100644 --- a/frontend/src/app/[locale]/search/error.tsx +++ b/frontend/src/app/[locale]/search/error.tsx @@ -2,7 +2,7 @@ import QueryProvider from "src/app/[locale]/search/QueryProvider"; import { ServerSideSearchParams } from "src/types/searchRequestURLTypes"; -import { Breakpoints } from "src/types/uiTypes"; +import { Breakpoints, ErrorProps } from "src/types/uiTypes"; import { convertSearchParamsToProperTypes } from "src/utils/search/convertSearchParamsToProperTypes"; import { useTranslations } from "next-intl"; @@ -13,12 +13,6 @@ import SearchBar from "src/components/search/SearchBar"; import SearchFilters from "src/components/search/SearchFilters"; import ServerErrorAlert from "src/components/ServerErrorAlert"; -interface ErrorProps { - // Next's error boundary also includes a reset function as a prop for retries, - // but it was not needed as users can retry with new inputs in the normal page flow. - error: Error & { digest?: string }; -} - export interface ParsedError { message: string; searchInputs: ServerSideSearchParams; @@ -54,7 +48,7 @@ function createBlankParsedError(): ParsedError { }; } -export default function Error({ error }: ErrorProps) { +export default function SearchError({ error }: ErrorProps) { const t = useTranslations("Search"); // The error message is passed as an object that's been stringified. diff --git a/frontend/src/app/[locale]/search/page.tsx b/frontend/src/app/[locale]/search/page.tsx index d006a905b..05314e83a 100644 --- a/frontend/src/app/[locale]/search/page.tsx +++ b/frontend/src/app/[locale]/search/page.tsx @@ -1,6 +1,6 @@ import { Metadata } from "next"; import QueryProvider from "src/app/[locale]/search/QueryProvider"; -import withFeatureFlag from "src/hoc/search/withFeatureFlag"; +import withFeatureFlag from "src/hoc/withFeatureFlag"; import { LocalizedPageProps } from "src/types/intl"; import { SearchParamsTypes } from "src/types/search/searchRequestTypes"; import { Breakpoints } from "src/types/uiTypes"; diff --git a/frontend/src/app/[locale]/unauthorized/page.tsx b/frontend/src/app/[locale]/unauthorized/page.tsx new file mode 100644 index 000000000..e0813b2c4 --- /dev/null +++ b/frontend/src/app/[locale]/unauthorized/page.tsx @@ -0,0 +1,27 @@ +import { Metadata } from "next"; + +import { useTranslations } from "next-intl"; +import { getTranslations } from "next-intl/server"; +import { Alert, GridContainer } from "@trussworks/react-uswds"; + +export async function generateMetadata() { + const t = await getTranslations(); + const meta: Metadata = { + title: t("ErrorPages.unauthorized.page_title"), + description: t("Index.meta_description"), + }; + return meta; +} + +const Unauthorized = () => { + const t = useTranslations("Errors"); + return ( + + + {t("authorization_fail")} + + + ); +}; + +export default Unauthorized; diff --git a/frontend/src/app/[locale]/user/LogoutButton.tsx b/frontend/src/app/[locale]/user/LogoutButton.tsx new file mode 100644 index 000000000..c82a6387e --- /dev/null +++ b/frontend/src/app/[locale]/user/LogoutButton.tsx @@ -0,0 +1,28 @@ +"use client"; + +/* + +Delete this file when we build an actual Users page + +*/ +import { useRouter } from "next/navigation"; +import { Button } from "@trussworks/react-uswds"; + +const makeLogoutRequest = async (push: (location: string) => void) => { + const response = await fetch("/api/auth/logout", { method: "POST" }); + if (response.status === 200) { + push("/user?message=logged out"); + return; + } + push("/user?message=log out error"); +}; + +export const LogoutButton = () => { + const router = useRouter(); + return ( + // eslint-disable-next-line + + ); +}; diff --git a/frontend/src/app/[locale]/user/page.tsx b/frontend/src/app/[locale]/user/page.tsx new file mode 100644 index 000000000..31064ef63 --- /dev/null +++ b/frontend/src/app/[locale]/user/page.tsx @@ -0,0 +1,40 @@ +import { Metadata } from "next"; +import { LocalizedPageProps } from "src/types/intl"; + +import { getTranslations } from "next-intl/server"; +import { GridContainer } from "@trussworks/react-uswds"; + +import { LogoutButton } from "./LogoutButton"; + +export async function generateMetadata({ + params: { locale }, +}: LocalizedPageProps) { + const t = await getTranslations({ locale }); + const meta: Metadata = { + title: t("User.pageTitle"), + description: t("Index.meta_description"), + }; + return meta; +} + +// this is a placeholder page used as temporary landing page for login redirects. +// Note that this page only functions to display the message passed down in query params from +// the /api/auth/callback route, and it does not handle errors. +// How to handle errors or failures from the callback route in the UI will need to be revisited +// later on, but note that throwing to an error page won't be an option, as that produces a 500 +// response in the client. +export default async function UserDisplay({ + searchParams, + params: { locale }, +}: LocalizedPageProps & { searchParams: { message?: string } }) { + const { message } = searchParams; + + const t = await getTranslations({ locale, namespace: "User" }); + return ( + +

{t("heading")}

+ {message &&
{message}
} + +
+ ); +} diff --git a/frontend/src/app/api/auth/callback/route.ts b/frontend/src/app/api/auth/callback/route.ts new file mode 100644 index 000000000..f8b4c1f2b --- /dev/null +++ b/frontend/src/app/api/auth/callback/route.ts @@ -0,0 +1,17 @@ +import { createSession } from "src/services/auth/session"; + +import { redirect } from "next/navigation"; +import { NextRequest } from "next/server"; + +export async function GET(request: NextRequest) { + const token = request.nextUrl.searchParams.get("token"); + if (!token) { + return redirect("/unauthorized"); + } + try { + await createSession(token); + } catch (_e) { + return redirect("/error"); + } + return redirect("/"); +} diff --git a/frontend/src/app/api/auth/login/route.ts b/frontend/src/app/api/auth/login/route.ts new file mode 100644 index 000000000..66fa2ba4d --- /dev/null +++ b/frontend/src/app/api/auth/login/route.ts @@ -0,0 +1,16 @@ +import { environment } from "src/constants/environments"; + +import { NextResponse } from "next/server"; + +export const dynamic = "force-dynamic"; + +export function GET() { + try { + if (!environment.AUTH_LOGIN_URL) { + throw new Error("AUTH_LOGIN_URL not defined"); + } + return NextResponse.redirect(environment.AUTH_LOGIN_URL); + } catch (error) { + return new NextResponse(error as string, { status: 500 }); + } +} diff --git a/frontend/src/app/api/auth/logout/route.ts b/frontend/src/app/api/auth/logout/route.ts new file mode 100644 index 000000000..ec6247aa3 --- /dev/null +++ b/frontend/src/app/api/auth/logout/route.ts @@ -0,0 +1,26 @@ +import { getSession } from "src/services/auth/session"; +import { deleteSession } from "src/services/auth/sessionUtils"; +import { postLogout } from "src/services/fetch/fetchers/userFetcher"; + +export async function POST() { + try { + const session = await getSession(); + if (!session || !session.token) { + throw new Error("No active session to logout"); + } + // logout on API via /v1/users/token/logout + const response = await postLogout(session.token); + if (!response) { + throw new Error("No logout response from API"); + } + // delete session from current cookies + deleteSession(); + return Response.json({ message: "logout success" }); + } catch (e) { + const error = e as Error; + return Response.json( + { message: `Error logging out: ${error.message}` }, + { status: 500 }, + ); + } +} diff --git a/frontend/src/app/api/auth/session/route.ts b/frontend/src/app/api/auth/session/route.ts new file mode 100644 index 000000000..85235315c --- /dev/null +++ b/frontend/src/app/api/auth/session/route.ts @@ -0,0 +1,14 @@ +import { getSession } from "src/services/auth/session"; + +import { NextResponse } from "next/server"; + +export const revalidate = 0; + +export async function GET() { + const currentSession = await getSession(); + if (currentSession) { + return NextResponse.json(currentSession); + } else { + return NextResponse.json({ token: "" }); + } +} diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index 3d78d2cd7..8087aff20 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -24,7 +24,7 @@ type SocialLinkProps = { const SocialLink = ({ href, name, icon }: SocialLinkProps) => ( { - logoPath = "./img/grants-logo.svg"; +const Header = ({ locale }: Props) => { const t = useTranslations("Header"); const [isMobileNavExpanded, setIsMobileNavExpanded] = useState(false); @@ -138,6 +141,8 @@ const Header = ({ logoPath, locale }: Props) => { }; }, [isMobileNavExpanded, closeMenuOnEscape]); + const { checkFeatureFlag } = useFeatureFlags(); + const showLoginLink = checkFeatureFlag("authOn"); const language = locale && locale.match("/^es/") ? "spanish" : "english"; const handleMobileNavToggle = () => { @@ -163,7 +168,7 @@ const Header = ({ logoPath, locale }: Props) => { basic={true} className="desktop:position-sticky top-0 desktop:z-500 bg-white border-bottom-2px border-primary-vivid" > -
+
<div className="display-flex flex-align-center"> @@ -178,11 +183,19 @@ const Header = ({ logoPath, locale }: Props) => { </Link> </div> +
+
+ {!!showLoginLink && ( +
+ +
+ )} -
- {t("Layout.skip_to_main")} - - + +
+ + {t("Layout.skip_to_main")} +
- -
- {children} -
-
- -
+
+ {children} +
+
+ +
+ ); } diff --git a/frontend/src/app/[locale]/search/loading.tsx b/frontend/src/components/Loading.tsx similarity index 100% rename from frontend/src/app/[locale]/search/loading.tsx rename to frontend/src/components/Loading.tsx diff --git a/frontend/src/components/LoginModal.tsx b/frontend/src/components/LoginModal.tsx new file mode 100644 index 000000000..7d3649575 --- /dev/null +++ b/frontend/src/components/LoginModal.tsx @@ -0,0 +1,77 @@ +import { useTranslations } from "next-intl"; +import { useRef } from "react"; +import { + ButtonGroup, + Modal, + ModalFooter, + ModalHeading, + ModalRef, + ModalToggleButton, +} from "@trussworks/react-uswds"; + +import { USWDSIcon } from "src/components/USWDSIcon"; + +const LOGIN_URL = "/api/auth/login"; + +export const LoginModal = ({ + navLoginLinkText, +}: { + navLoginLinkText: string; +}) => { + const t = useTranslations("LoginModal"); + const modalRef = useRef(null); + + return ( + <> +
+
+ + + {navLoginLinkText} + +
+
+ + {t("title")} +
+

{t("help")}

+

{t("description")}

+
+ + + + {t("button")} + + + + {t("close")} + + + +
+ + ); +}; diff --git a/frontend/src/components/USWDSIcon.tsx b/frontend/src/components/USWDSIcon.tsx index 7414d8978..3785e112a 100644 --- a/frontend/src/components/USWDSIcon.tsx +++ b/frontend/src/components/USWDSIcon.tsx @@ -1,8 +1,10 @@ +import clsx from "clsx"; + import SpriteSVG from "public/img/uswds-sprite.svg"; interface IconProps { name: string; - className: string; + className?: string; height?: string; } @@ -12,7 +14,7 @@ const sprite_uri = SpriteSVG.src as string; export function USWDSIcon(props: IconProps) { return ( {t("goal.cta")} diff --git a/frontend/src/components/content/ProcessAndResearchContent.tsx b/frontend/src/components/content/ProcessAndResearchContent.tsx index ef59ee7a2..0f467f1de 100644 --- a/frontend/src/components/content/ProcessAndResearchContent.tsx +++ b/frontend/src/components/content/ProcessAndResearchContent.tsx @@ -27,7 +27,7 @@ const ProcessAndResearchContent = () => { @@ -47,7 +47,7 @@ const ProcessAndResearchContent = () => { diff --git a/frontend/src/components/opportunity/OpportunityCTA.tsx b/frontend/src/components/opportunity/OpportunityCTA.tsx index ad4486493..d7a2173b5 100644 --- a/frontend/src/components/opportunity/OpportunityCTA.tsx +++ b/frontend/src/components/opportunity/OpportunityCTA.tsx @@ -36,10 +36,7 @@ const OpportunityCTA = ({ id }: { id: number }) => { > diff --git a/frontend/src/components/opportunity/OpportunityDownload.tsx b/frontend/src/components/opportunity/OpportunityDownload.tsx index b9dbb82b4..3984c9569 100644 --- a/frontend/src/components/opportunity/OpportunityDownload.tsx +++ b/frontend/src/components/opportunity/OpportunityDownload.tsx @@ -22,10 +22,7 @@ const OpportunityDownload = ({ nofoPath }: Props) => {
{ switch (status) { case "archived": return ( -
+

{t("archived")} {formatDate(archiveDate) || "--"} @@ -71,7 +71,7 @@ const OpportunityStatusWidget = ({ opportunityData }: Props) => { ); case "closed": return ( -

+

{t("closed")} {formatDate(closeDate) || "--"} @@ -81,7 +81,7 @@ const OpportunityStatusWidget = ({ opportunityData }: Props) => { case "posted": return ( <> -

+

{t("closing")} {formatDate(closeDate) || "--"} @@ -96,7 +96,7 @@ const OpportunityStatusWidget = ({ opportunityData }: Props) => { ); case "forecasted": return ( -

+

{t("forecasted")}

diff --git a/frontend/src/components/search/SearchResults.tsx b/frontend/src/components/search/SearchResults.tsx index c98da36b7..bb688c9a3 100644 --- a/frontend/src/components/search/SearchResults.tsx +++ b/frontend/src/components/search/SearchResults.tsx @@ -1,9 +1,9 @@ -import Loading from "src/app/[locale]/search/loading"; -import { searchForOpportunities } from "src/app/api/searchFetcher"; +import { searchForOpportunities } from "src/services/fetch/fetchers/searchFetcher"; import { QueryParamData } from "src/types/search/searchRequestTypes"; import { Suspense } from "react"; +import Loading from "src/components/Loading"; import SearchPagination from "src/components/search/SearchPagination"; import SearchPaginationFetch from "src/components/search/SearchPaginationFetch"; import SearchResultsHeader from "src/components/search/SearchResultsHeader"; diff --git a/frontend/src/components/user/UserControl.tsx b/frontend/src/components/user/UserControl.tsx new file mode 100644 index 000000000..55f674cef --- /dev/null +++ b/frontend/src/components/user/UserControl.tsx @@ -0,0 +1,129 @@ +import clsx from "clsx"; +import { UserProfile } from "src/services/auth/types"; +import { useUser } from "src/services/auth/useUser"; + +import { useTranslations } from "next-intl"; +import { useCallback, useState } from "react"; +import { + IconListContent, + Menu, + NavDropDownButton, +} from "@trussworks/react-uswds"; + +import { LoginModal } from "src/components/LoginModal"; +import { USWDSIcon } from "src/components/USWDSIcon"; + +// used in three different places +// 1. on desktop - nav item drop down button content +// 2. on mobile - nav item drop down button content, without email text +// 3. on mobile - nav sub item content +const UserEmailItem = ({ + email, + isSubnav, +}: { + email?: string; + isSubnav: boolean; +}) => { + return ( + + +
+ {email} +
+
+ ); +}; + +const UserDropdown = ({ + user, + navLogoutLinkText, + logout, +}: { + user: UserProfile; + navLogoutLinkText: string; + logout: () => Promise; +}) => { + const [userProfileMenuOpen, setUserProfileMenuOpen] = useState(false); + + const logoutNavItem = ( + logout()} + > + + + {navLogoutLinkText} + + + ); + + return ( +
+ } + isOpen={userProfileMenuOpen} + onToggle={() => setUserProfileMenuOpen(!userProfileMenuOpen)} + isCurrent={false} + menuId="user-control" + /> + , + logoutNavItem, + ]} + type="subnav" + isOpen={userProfileMenuOpen} + /> +
+ ); +}; + +export const UserControl = () => { + const t = useTranslations("Header"); + + const { user, refreshUser } = useUser(); + + const logout = useCallback(async (): Promise => { + await fetch("/api/auth/logout", { + method: "POST", + }); + await refreshUser(); + }, [refreshUser]); + + return ( + <> + {!user?.token && } + {!!user?.token && ( + + )} + + ); +}; 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 4bd63598b..99e62de54 100644 --- a/frontend/src/constants/environments.ts +++ b/frontend/src/constants/environments.ts @@ -1,33 +1,46 @@ +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", + FEATURE_SEARCH_OFF, + FEATURE_OPPORTUNITY_OFF, + FEATURE_AUTH_ON, + AUTH_LOGIN_URL, + API_JWT_PUBLIC_KEY, } = 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, - NEXT_PUBLIC_BASE_URL: NEXT_PUBLIC_BASE_URL || "http://localhost:3000", + AUTH_LOGIN_URL: AUTH_LOGIN_URL || "", + API_AUTH_TOKEN: API_AUTH_TOKEN || "", GOOGLE_TAG_MANAGER_ID: "GTM-MV57HMHS", - FEATURE_OPPORTUNITY_OFF, - FEATURE_SEARCH_OFF, - NEXT_BUILD, ENVIRONMENT, + NEXT_BUILD: NEXT_BUILD || "false", + SESSION_SECRET: SESSION_SECRET || "", + NEXT_PUBLIC_BASE_URL: NEXT_PUBLIC_BASE_URL || "http://localhost:3000", + API_JWT_PUBLIC_KEY: API_JWT_PUBLIC_KEY || "", }; 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/search/withFeatureFlag.tsx b/frontend/src/hoc/withFeatureFlag.tsx similarity index 92% rename from frontend/src/hoc/search/withFeatureFlag.tsx rename to frontend/src/hoc/withFeatureFlag.tsx index 26ba94fda..d0e0cbf09 100644 --- a/frontend/src/hoc/search/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/i18n/messages/en/index.ts b/frontend/src/i18n/messages/en/index.ts index 65b7b2bed..b98fac57c 100644 --- a/frontend/src/i18n/messages/en/index.ts +++ b/frontend/src/i18n/messages/en/index.ts @@ -463,8 +463,14 @@ export const messages = { "The Simpler.Grants.gov email subscriptions are powered by the Sendy data service. Personal information is not stored within Simpler.Grants.gov.", }, ErrorPages: { - page_title: "Page Not Found | Simpler.Grants.gov", + generic_error: { + page_title: "Error | Simpler.Grants.gov", + }, + unauthorized: { + page_title: "Unauthorized | Simpler.Grants.gov", + }, page_not_found: { + page_title: "Page Not Found | Simpler.Grants.gov", title: "Oops! Page Not Found", message_content_1: "The page you have requested cannot be displayed because it does not exist, has been moved, or the server has been instructed not to let you view it. There is nothing to see here.", @@ -478,8 +484,18 @@ export const messages = { nav_link_subscribe: "Subscribe", nav_menu_toggle: "Menu", nav_link_search: "Search", + nav_link_login: "Sign in", + nav_link_logout: "Sign out", title: "Simpler.Grants.gov", }, + LoginModal: { + title: "Sign in to Simpler.Grants.gov", + help: "Simpler.Grants.gov uses Login.gov to verify your identity and manage your account securely. You don't need a separate username or password for this site.", + description: + "You’ll be redirected to Login.gov to sign in or create an account. Then, you’ll return to Simpler.Grants.gov as a signed-in user.", + button: "Sign in with Login.gov", + close: "Cancel", + }, Hero: { title: "We're building a simpler Grants.gov!", content: @@ -520,6 +536,9 @@ export const messages = { Errors: { heading: "We're sorry.", generic_message: "There seems to have been an error.", + try_again: "Please try again.", + unauthorized: "Unauthorized", + authorization_fail: "Login or user authorization failed. Please try again.", }, Search: { title: "Search Funding Opportunities | Simpler.Grants.gov", @@ -608,4 +627,9 @@ export const messages = { signOff: "Thank you for your patience.", pageTitle: "Simpler.Grants.gov - Maintenance", }, + User: { + heading: "User", + pageTitle: "User | Simpler.Grants.Gov", + errorHeading: "Error", + }, }; diff --git a/frontend/src/middleware.ts b/frontend/src/middleware.ts index 7e3ae258c..039e8cac6 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,25 @@ 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); - + const response = featureFlagsManager.middleware( + request, + i18nMiddleware(request), + ); + // in Next 15 there is an experimental `unauthorized` function that will send a 401 + // code to the client and display an unauthorized page + // see https://nextjs.org/docs/app/api-reference/functions/unauthorized + // For now we can set status codes on auth redirect errors here + if (request.url.includes("/error")) { + return new NextResponse(response.body, { + status: 500, + headers: response.headers, + }); + } + if (request.url.includes("/unauthorized")) { + return new NextResponse(response.body, { + status: 401, + headers: response.headers, + }); + } return response; } 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/auth/README.md b/frontend/src/services/auth/README.md new file mode 100644 index 000000000..858d79902 --- /dev/null +++ b/frontend/src/services/auth/README.md @@ -0,0 +1,54 @@ +# User Auth + +### Notes + +- Server components can't write cookies, but middleware, route handlers and server actions can. + +### Login flow + +- user clicks "login" + - client side component directs users to /api link +- user comes back with a simpler JWT to /auth/callback + - verifies JWT + - sets cookie +- useUser / UserProvider + - checks cookie / API (see diagram) + +```mermaid +flowchart TD + checkCookie[Check cookie] + cookieExists{Cookie Exists?} + useUser/UserProvider --> checkCookie + cookieValid{Cookie is Valid} + redirectToLogin[redirect to login] + + checkCookie --> cookieExists + cookieExists --> |Yes| cookieValid + cookieExists --> |No| redirectToLogin + cookieValid --> |Yes| d[Return User Data] + cookieValid --> |No| redirectToLogin + +``` + +## Next step + +```mermaid +flowchart TD + checkCookie[Check cookie] + cookieExists{Cookie Exists?} + useUser/UserProvider --> checkCookie + cookieValid{Cookie is Valid} + cookieIsCurrent{Cookie is Current} + redirectToLogin[redirect to login] + + checkCookie --> cookieExists + cookieExists --> |Yes| cookieValid + cookieExists --> |No| redirectToLogin + cookieValid --> |Yes| cookieIsCurrent + cookieValid --> |No | redirectToLogin + cookieIsCurrent --> |Yes| d[Return User Data] + cookieIsCurrent --> |No| e{User exists with session from /api/user} + e --> |Yes| f[set cookie] + e --> |No| redirectToLogin + +``` diff --git a/frontend/src/services/auth/UserProvider.tsx b/frontend/src/services/auth/UserProvider.tsx new file mode 100644 index 000000000..c812f3784 --- /dev/null +++ b/frontend/src/services/auth/UserProvider.tsx @@ -0,0 +1,65 @@ +"use client"; + +// note that importing these individually allows us to mock them, otherwise mocks don't work :shrug: +import debounce from "lodash/debounce"; +import noop from "lodash/noop"; +import { UserProfile } from "src/services/auth/types"; +import { UserContext } from "src/services/auth/useUser"; +import { userFetcher } from "src/services/fetch/fetchers/clientUserFetcher"; +import { isSessionExpired } from "src/utils/authUtil"; + +import React, { useCallback, useEffect, useMemo, useState } from "react"; + +// if we don't debounce this call we get multiple requests going out on page load +const debouncedUserFetcher = debounce( + () => userFetcher("/api/auth/session"), + 500, + { + leading: true, + trailing: false, + }, +); + +export default function UserProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [localUser, setLocalUser] = useState(); + const [isLoading, setIsLoading] = useState(false); + const [userFetchError, setUserFetchError] = useState(); + + const getUserSession = useCallback(async (): Promise => { + try { + setIsLoading(true); + const fetchedUser = await debouncedUserFetcher(); + if (fetchedUser) { + setLocalUser(fetchedUser); + setUserFetchError(undefined); + setIsLoading(false); + return; + } + throw new Error("received empty user session"); + } catch (error) { + setIsLoading(false); + setUserFetchError(error as Error); + } + }, []); + + useEffect(() => { + if (localUser && !isSessionExpired(localUser)) return; + getUserSession().then(noop).catch(noop); + }, [localUser, getUserSession]); + + const value = useMemo( + () => ({ + user: localUser, + error: userFetchError, + isLoading, + refreshUser: getUserSession, + }), + [localUser, userFetchError, isLoading, getUserSession], + ); + + return {children}; +} diff --git a/frontend/src/services/auth/session.ts b/frontend/src/services/auth/session.ts new file mode 100644 index 000000000..8ad47be57 --- /dev/null +++ b/frontend/src/services/auth/session.ts @@ -0,0 +1,94 @@ +import "server-only"; + +import { createPublicKey, KeyObject } from "crypto"; +import { environment } from "src/constants/environments"; +import { + API_JWT_ENCRYPTION_ALGORITHM, + CLIENT_JWT_ENCRYPTION_ALGORITHM, + decrypt, + encrypt, + newExpirationDate, +} from "src/services/auth/sessionUtils"; +import { SimplerJwtPayload, UserSession } from "src/services/auth/types"; +import { encodeText } from "src/utils/generalUtils"; + +// note that cookies will be async in Next 15 +import { cookies } from "next/headers"; + +let clientJwtKey: Uint8Array; +let loginGovJwtKey: KeyObject; + +// isolate encoding behavior from file execution +const initializeSessionSecrets = () => { + if (!environment.SESSION_SECRET || !environment.API_JWT_PUBLIC_KEY) { + // eslint-disable-next-line + console.debug("Session keys not present"); + return; + } + // eslint-disable-next-line + console.debug("Initializing Session Secrets"); + clientJwtKey = encodeText(environment.SESSION_SECRET); + loginGovJwtKey = createPublicKey(environment.API_JWT_PUBLIC_KEY); +}; + +const decryptClientToken = async ( + jwt: string, +): Promise => { + const payload = await decrypt( + jwt, + clientJwtKey, + CLIENT_JWT_ENCRYPTION_ALGORITHM, + ); + if (!payload || !payload.token) return null; + return payload as SimplerJwtPayload; +}; + +const decryptLoginGovToken = async ( + jwt: string, +): Promise => { + const payload = await decrypt( + jwt, + loginGovJwtKey, + API_JWT_ENCRYPTION_ALGORITHM, + ); + return (payload as UserSession) ?? null; +}; + +// sets client token on cookie +export const createSession = async (token: string) => { + if (!clientJwtKey) { + initializeSessionSecrets(); + } + const expiresAt = newExpirationDate(); + const session = await encrypt(token, expiresAt, clientJwtKey); + cookies().set("session", session, { + httpOnly: true, + secure: true, + expires: expiresAt, + sameSite: "lax", + path: "/", + }); +}; + +// returns the necessary user info from decrypted login gov token +// plus client token and expiration +export const getSession = async (): Promise => { + if (!clientJwtKey || !loginGovJwtKey) { + initializeSessionSecrets(); + } + const cookie = cookies().get("session")?.value; + if (!cookie) return null; + const payload = await decryptClientToken(cookie); + if (!payload) { + return null; + } + const { token, exp } = payload; + const session = await decryptLoginGovToken(token); + return session + ? { + ...session, + token, + exp, + } + : null; +}; diff --git a/frontend/src/services/auth/sessionUtils.ts b/frontend/src/services/auth/sessionUtils.ts new file mode 100644 index 000000000..d075dd55c --- /dev/null +++ b/frontend/src/services/auth/sessionUtils.ts @@ -0,0 +1,46 @@ +import { KeyObject } from "crypto"; +import { JWTPayload, jwtVerify, SignJWT } from "jose"; + +import { cookies } from "next/headers"; + +export const CLIENT_JWT_ENCRYPTION_ALGORITHM = "HS256"; +export const API_JWT_ENCRYPTION_ALGORITHM = "RS256"; + +// returns a new date 1 week from time of function call +export const newExpirationDate = () => + new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + +// extracts payload object from jwt string using passed encrytion key and algo +export const decrypt = async ( + jwt = "", + encryptionKey: KeyObject | Uint8Array, + algorithm: string, +): Promise => { + try { + const { payload } = await jwtVerify(jwt, encryptionKey, { + algorithms: [algorithm], + }); + return payload; + } catch (error) { + console.error("Failed to decrypt session cookie", error); + return null; + } +}; + +// we only encrypt using the client key +export const encrypt = async ( + token: string, + expiresAt: Date, + clientJwtKey: Uint8Array, +): Promise => { + const jwt = await new SignJWT({ token }) + .setProtectedHeader({ alg: CLIENT_JWT_ENCRYPTION_ALGORITHM }) + .setIssuedAt() + .setExpirationTime(expiresAt || "") + .sign(clientJwtKey); + return jwt; +}; + +export function deleteSession() { + cookies().delete("session"); +} diff --git a/frontend/src/services/auth/types.tsx b/frontend/src/services/auth/types.tsx new file mode 100644 index 000000000..3e5666740 --- /dev/null +++ b/frontend/src/services/auth/types.tsx @@ -0,0 +1,66 @@ +import { JWTPayload } from "jose"; + +/** + * Configure the UserProvider component. + * + * If you have any server-side rendered pages (using `getServerSideProps` or Server Components), you should get the + * user from the server-side session and pass it to the `` component via the `user` + * prop. This will prefill the useUser hook with the UserProfile object. + * For example: + * + * import { UserProvider } from 'src/services/auth/UserProvider'; + * + * export default async function RootLayout({ children }) { + * // this will emit a warning because Server Components cannot write to cookies + * // see https://github.com/auth0/nextjs-auth0#using-this-sdk-with-react-server-components + * const session = await getSession(); + * + * return ( + * + * + * + * {children} + * + * + * + * ); + * } + * ``` + * + * In client-side rendered pages, the useUser hook uses a UserFetcher to fetch the + * user from the profile API route. If needed, you can specify a custom fetcher here in the + * `fetcher` option. + * + * + * @category Client + */ + +// represents relevant client side data from API JWT +export interface UserProfile { + email?: string; + token: string; + expiresAt: Date; +} + +// represents client JWT payload +export interface SimplerJwtPayload extends JWTPayload { + token: string; +} +// represents API JWT payload +export type UserSession = UserProfile & SimplerJwtPayload; + +/** + * Fetches the user from the profile API route to fill the useUser hook with the + * UserProfile object. + */ +export type UserFetcher = (url: string) => Promise; + +/** + * @ignore + */ +export type UserProviderState = { + user?: UserProfile; + error?: Error; + isLoading: boolean; + refreshUser: () => Promise; +}; diff --git a/frontend/src/services/auth/useUser.tsx b/frontend/src/services/auth/useUser.tsx new file mode 100644 index 000000000..e99bb7bd3 --- /dev/null +++ b/frontend/src/services/auth/useUser.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { UserProviderState } from "src/services/auth/types"; + +import { createContext, useContext } from "react"; + +export const UserContext = createContext({} as UserProviderState); + +/** + * @ignore + */ +export type UserContextHook = () => UserProviderState; + +/** + * The `useUser` hook, which will get you the {@link UserProfile} object from the server-side session by fetching it + * from the {@link HandleProfile} API route. + * + * ```js + * import Link from 'next/link'; + * import { useUser } from 'src/services/auth/useUser'; + * + * export default function Profile() { + * const { user, error, isLoading } = useUser(); + * + * if (isLoading) return
Loading...
; + * if (error) return
{error.message}
; + * if (!user) return Login; + * return
Hello {user.name}, Logout
; + * } + * ``` + * + * @category Client + */ +export const useUser: UserContextHook = () => + useContext(UserContext); 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/app/api/endpointConfigs.ts b/frontend/src/services/fetch/endpointConfigs.ts similarity index 69% rename from frontend/src/app/api/endpointConfigs.ts rename to frontend/src/services/fetch/endpointConfigs.ts index 630d3a815..f107f8fd6 100644 --- a/frontend/src/app/api/endpointConfigs.ts +++ b/frontend/src/services/fetch/endpointConfigs.ts @@ -1,5 +1,5 @@ -import { ApiMethod } from "src/app/api/fetcherHelpers"; import { environment } from "src/constants/environments"; +import { ApiMethod } from "src/services/fetch/fetcherHelpers"; export interface EndpointConfig { basePath: string; @@ -21,3 +21,10 @@ export const fetchOpportunityEndpoint = { namespace: "opportunities", method: "GET" as ApiMethod, }; + +export const userLogoutEndpoint = { + basePath: environment.API_URL, + version: "v1", + namespace: "users/token/logout", + method: "POST" as ApiMethod, +}; diff --git a/frontend/src/app/api/fetcherHelpers.ts b/frontend/src/services/fetch/fetcherHelpers.ts similarity index 100% rename from frontend/src/app/api/fetcherHelpers.ts rename to frontend/src/services/fetch/fetcherHelpers.ts diff --git a/frontend/src/services/fetch/fetchers/clientUserFetcher.ts b/frontend/src/services/fetch/fetchers/clientUserFetcher.ts new file mode 100644 index 000000000..649ba7d99 --- /dev/null +++ b/frontend/src/services/fetch/fetchers/clientUserFetcher.ts @@ -0,0 +1,19 @@ +"use client"; + +import { ApiRequestError } from "src/errors"; +import { UserFetcher, UserSession } from "src/services/auth/types"; + +// this fetcher is a one off for now, since the request is made from the client to the +// NextJS Node server. We will need to build out a fetcher pattern to accomodate this usage in the future +export const userFetcher: UserFetcher = async (url) => { + let response; + try { + response = await fetch(url, { cache: "no-store" }); + } catch (e) { + console.error("User session fetch network error", e); + throw new ApiRequestError(0); // Network error + } + if (response.status === 204) return undefined; + if (response.ok) return (await response.json()) as UserSession; + throw new ApiRequestError(response.status); +}; diff --git a/frontend/src/app/api/fetchers.ts b/frontend/src/services/fetch/fetchers/fetchers.ts similarity index 90% rename from frontend/src/app/api/fetchers.ts rename to frontend/src/services/fetch/fetchers/fetchers.ts index 7c9841814..731adb32e 100644 --- a/frontend/src/app/api/fetchers.ts +++ b/frontend/src/services/fetch/fetchers/fetchers.ts @@ -4,7 +4,8 @@ import { EndpointConfig, fetchOpportunityEndpoint, opportunitySearchEndpoint, -} from "src/app/api/endpointConfigs"; + userLogoutEndpoint, +} from "src/services/fetch/endpointConfigs"; import { createRequestBody, createRequestUrl, @@ -12,7 +13,7 @@ import { HeadersDict, JSONRequestBody, sendRequest, -} from "src/app/api/fetcherHelpers"; +} from "src/services/fetch/fetcherHelpers"; import { APIResponse } from "src/types/apiResponseTypes"; import { OpportunityApiResponse } from "src/types/opportunity/opportunityResponseTypes"; import { QueryParamData } from "src/types/search/searchRequestTypes"; @@ -72,3 +73,6 @@ export const fetchOpportunity = cache( export const fetchOpportunitySearch = requesterForEndpoint( opportunitySearchEndpoint, ); + +export const postUserLogout = + requesterForEndpoint(userLogoutEndpoint); diff --git a/frontend/src/app/api/searchFetcher.ts b/frontend/src/services/fetch/fetchers/searchFetcher.ts similarity index 97% rename from frontend/src/app/api/searchFetcher.ts rename to frontend/src/services/fetch/fetchers/searchFetcher.ts index 8d0c8dc34..480cf97d6 100644 --- a/frontend/src/app/api/searchFetcher.ts +++ b/frontend/src/services/fetch/fetchers/searchFetcher.ts @@ -1,6 +1,6 @@ import "server-only"; -import { fetchOpportunitySearch } from "src/app/api/fetchers"; +import { fetchOpportunitySearch } from "src/services/fetch/fetchers/fetchers"; import { PaginationOrderBy, PaginationRequestBody, diff --git a/frontend/src/services/fetch/fetchers/userFetcher.ts b/frontend/src/services/fetch/fetchers/userFetcher.ts new file mode 100644 index 000000000..7e8b6ff4a --- /dev/null +++ b/frontend/src/services/fetch/fetchers/userFetcher.ts @@ -0,0 +1,6 @@ +import { postUserLogout } from "src/services/fetch/fetchers/fetchers"; + +export const postLogout = async (token: string) => { + const jwtAuthHeader = { "X-SGG-Token": token }; + return postUserLogout({ additionalHeaders: jwtAuthHeader }); +}; diff --git a/frontend/src/styles/_uswds-theme-custom-styles.scss b/frontend/src/styles/_uswds-theme-custom-styles.scss index b01ad73a4..0d582eaca 100644 --- a/frontend/src/styles/_uswds-theme-custom-styles.scss +++ b/frontend/src/styles/_uswds-theme-custom-styles.scss @@ -378,3 +378,50 @@ button.usa-pagination__button.usa-button { color: white; } } + +@include at-media("desktop") { + .usa-nav__submenu { + right: 0; + } +} + +// we are implementing the uswds nav drop down at mobile widths, which is not ordinarily supported +// these styles are taken from the desktop imlementation of the dropdown and applied at all breakpoints +.usa-nav__primary { + .mobile-nav-dropdown-uncollapsed-override { + button[aria-expanded="true"] { + background-color: color("mint-60"); + a { + color: white; + } + span:after { + mask-image: url("/uswds/img/usa-icons/expand_less.svg"), + linear-gradient(transparent, transparent); + } + } + button[aria-expanded="false"] { + span:after { + mask-image: url("/uswds/img/usa-icons/expand_more.svg"), + linear-gradient(transparent, transparent); + } + } + button[aria-expanded] { + span:after { + right: 0.75rem; + } + } + .usa-nav__submenu-item { + background-color: color("mint-60"); + a { + padding-left: 1rem; + padding-right: 1rem; + color: white; + line-height: 1.4; + display: block; + } + } + .usa-nav__submenu { + right: 3.7em; + } + } +} diff --git a/frontend/src/types/uiTypes.ts b/frontend/src/types/uiTypes.ts index 145152b82..c8ce95a4f 100644 --- a/frontend/src/types/uiTypes.ts +++ b/frontend/src/types/uiTypes.ts @@ -15,3 +15,9 @@ export enum Breakpoints { export type WithFeatureFlagProps = { searchParams: ServerSideSearchParams; }; + +export interface ErrorProps { + // Next's error boundary also includes a reset function as a prop for retries, + // but it was not needed as users can retry with new inputs in the normal page flow. + error: Error & { digest?: string }; +} diff --git a/frontend/src/utils/authUtil.ts b/frontend/src/utils/authUtil.ts new file mode 100644 index 000000000..9bc91811b --- /dev/null +++ b/frontend/src/utils/authUtil.ts @@ -0,0 +1,10 @@ +import { UserProfile } from "src/services/auth/types"; + +export const isSessionExpired = (userSession: UserProfile): boolean => { + // if we haven't implemented expiration yet + // TODO: remove this once expiration is implemented in the token + if (!userSession?.expiresAt) { + return false; + } + return userSession.expiresAt > new Date(Date.now()); +}; diff --git a/frontend/src/utils/generalUtils.ts b/frontend/src/utils/generalUtils.ts index 227169d5d..ceb394a83 100644 --- a/frontend/src/utils/generalUtils.ts +++ b/frontend/src/utils/generalUtils.ts @@ -90,7 +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/src/utils/testing/commonTestUtils.ts b/frontend/src/utils/testing/commonTestUtils.ts index 891dfea4a..c238d11f7 100644 --- a/frontend/src/utils/testing/commonTestUtils.ts +++ b/frontend/src/utils/testing/commonTestUtils.ts @@ -16,3 +16,25 @@ export async function mockProcessEnv( await callback(); process.env = oldEnv; } + +class NoErrorThrownError extends Error {} + +/* + Jest doesn't like it when you put expect calls in catch blocks + This is unavoidable, though, when testing route handlers that use Next's redirect functionality, + as that implementation throws errors on redirect by design. + When testing those sorts of functions, wrap the call to the route handler in an anonymous function and + pass it into this wrapper, which will spit out the error as a return value + see https://github.com/jest-community/eslint-plugin-jest/blob/main/docs/rules/no-conditional-expect.md +*/ +export const wrapForExpectedError = async ( + originalFunction: () => unknown, +): Promise => { + try { + await originalFunction(); + // since the original function should throw, should never hit this next line + throw new NoErrorThrownError(); + } catch (error: unknown) { + return error as TError; + } +}; diff --git a/frontend/stories/pages/loading.stories.tsx b/frontend/stories/pages/loading.stories.tsx index 1ee7e1995..adcd65e3e 100644 --- a/frontend/stories/pages/loading.stories.tsx +++ b/frontend/stories/pages/loading.stories.tsx @@ -1,5 +1,6 @@ import { Meta } from "@storybook/react"; -import Loading from "src/app/[locale]/search/loading"; + +import Loading from "src/components/Loading"; const meta: Meta = { component: Loading, diff --git a/frontend/tests/__mocks__/focus-trap-react.tsx b/frontend/tests/__mocks__/focus-trap-react.tsx new file mode 100644 index 000000000..c656e75cc --- /dev/null +++ b/frontend/tests/__mocks__/focus-trap-react.tsx @@ -0,0 +1,23 @@ +import type * as FocusTrapType from "focus-trap-react"; + +import { ComponentType } from "react"; + +const FocusTrap = + jest.requireActual>("focus-trap-react"); + +/** + * Override displayCheck for testing. See: https://github.com/focus-trap/tabbable#testing-in-jsdom + */ +const FixedComponent = ({ + focusTrapOptions, + ...props +}: FocusTrapType.Props) => { + const fixedOptions = { ...focusTrapOptions }; + fixedOptions.tabbableOptions = { + ...fixedOptions.tabbableOptions, + displayCheck: "none", + }; + return ; +}; + +module.exports = FixedComponent; diff --git a/frontend/tests/api/auth/callback/route.test.ts b/frontend/tests/api/auth/callback/route.test.ts new file mode 100644 index 000000000..68d1da171 --- /dev/null +++ b/frontend/tests/api/auth/callback/route.test.ts @@ -0,0 +1,37 @@ +/** + * @jest-environment node + */ + +import { GET } from "src/app/api/auth/callback/route"; +import { wrapForExpectedError } from "src/utils/testing/commonTestUtils"; + +import { NextRequest } from "next/server"; + +const createSessionMock = jest.fn(); + +jest.mock("src/services/auth/session", () => ({ + createSession: (token: string): unknown => createSessionMock(token), +})); + +// note that all calls to the GET endpoint need to be caught here since the behavior of the Next redirect +// is to throw an error +describe("/api/auth/callback GET handler", () => { + afterEach(() => jest.clearAllMocks()); + it("calls createSession on request with token in query params", async () => { + const redirectError = await wrapForExpectedError<{ digest: string }>(() => + GET(new NextRequest("https://simpler.grants.gov/?token=fakeToken")), + ); + + expect(createSessionMock).toHaveBeenCalledTimes(1); + expect(createSessionMock).toHaveBeenCalledWith("fakeToken"); + expect(redirectError.digest).toContain(";/;"); + }); + + it("if no token exists on query param, does not call createSession and redirects to unauthorized page", async () => { + const redirectError = await wrapForExpectedError<{ digest: string }>(() => + GET(new NextRequest("https://simpler.grants.gov")), + ); + expect(createSessionMock).toHaveBeenCalledTimes(0); + expect(redirectError.digest).toContain(";/unauthorized;"); + }); +}); diff --git a/frontend/tests/api/auth/login/route.test.ts b/frontend/tests/api/auth/login/route.test.ts new file mode 100644 index 000000000..1f278e1a5 --- /dev/null +++ b/frontend/tests/api/auth/login/route.test.ts @@ -0,0 +1,29 @@ +/** + * @jest-environment node + */ +import { GET } from "src/app/api/auth/login/route"; +import { environment } from "src/constants/environments"; + +jest.mock("src/constants/environments", () => ({ + environment: { AUTH_LOGIN_URL: "http://simpler.grants.gov/login" }, +})); + +describe("/api/auth/login GET handler", () => { + afterEach(() => jest.clearAllMocks()); + it("redirects correctly", () => { + const response = GET(); + + expect(response.headers.get("location")).toBe( + "http://simpler.grants.gov/login", + ); + expect(response.status).toBe(307); + }); + it("errors correctly", () => { + jest.replaceProperty(environment, "AUTH_LOGIN_URL", ""); + + const response = GET(); + + expect(response.headers.get("location")).toBe(null); + expect(response.status).toBe(500); + }); +}); diff --git a/frontend/tests/api/auth/logout/route.test.ts b/frontend/tests/api/auth/logout/route.test.ts new file mode 100644 index 000000000..4e3893cba --- /dev/null +++ b/frontend/tests/api/auth/logout/route.test.ts @@ -0,0 +1,100 @@ +/** + * @jest-environment node + */ + +import { POST } from "src/app/api/auth/logout/route"; + +const getSessionMock = jest.fn(); +const deleteSessionMock = jest.fn(); +const postLogoutMock = jest.fn(); + +jest.mock("src/services/auth/session", () => ({ + getSession: (): unknown => getSessionMock(), +})); + +jest.mock("src/services/auth/sessionUtils", () => ({ + deleteSession: (): unknown => deleteSessionMock(), +})); + +jest.mock("src/services/fetch/fetchers/userFetcher", () => ({ + postLogout: (token: string): unknown => postLogoutMock(token), +})); + +// note that all calls to the GET endpoint need to be caught here since the behavior of the Next redirect +// is to throw an error +describe("/api/auth/logout POST handler", () => { + afterEach(() => jest.clearAllMocks()); + it("errors if there is no current session token", async () => { + getSessionMock.mockImplementation(() => ({ + token: "", + })); + const response = await POST(); + + expect(postLogoutMock).toHaveBeenCalledTimes(0); + expect(response.status).toEqual(500); + const json = (await response.json()) as { message: string }; + expect(json.message).toEqual( + "Error logging out: No active session to logout", + ); + }); + it("calls postLogout with token from session", async () => { + getSessionMock.mockImplementation(() => ({ + token: "fakeToken", + })); + await POST(); + + expect(postLogoutMock).toHaveBeenCalledTimes(1); + expect(postLogoutMock).toHaveBeenCalledWith("fakeToken"); + }); + it("errors if API logout call errors", async () => { + getSessionMock.mockImplementation(() => ({ + token: "fakeToken", + })); + postLogoutMock.mockImplementation(() => { + throw new Error("the API threw this error"); + }); + const response = await POST(); + + expect(postLogoutMock).toHaveBeenCalledTimes(1); + expect(postLogoutMock).toHaveBeenCalledWith("fakeToken"); + expect(response.status).toEqual(500); + const json = (await response.json()) as { message: string }; + expect(json.message).toEqual("Error logging out: the API threw this error"); + }); + + it("errors if API logout call returns nothing", async () => { + getSessionMock.mockImplementation(() => ({ + token: "fakeToken", + })); + postLogoutMock.mockImplementation(() => null); + const response = await POST(); + + expect(postLogoutMock).toHaveBeenCalledTimes(1); + expect(postLogoutMock).toHaveBeenCalledWith("fakeToken"); + expect(response.status).toEqual(500); + const json = (await response.json()) as { message: string }; + expect(json.message).toEqual( + "Error logging out: No logout response from API", + ); + }); + it("calls deleteSession", async () => { + getSessionMock.mockImplementation(() => ({ + token: "fakeToken", + })); + postLogoutMock.mockImplementation(() => "success"); + await POST(); + + expect(deleteSessionMock).toHaveBeenCalledTimes(1); + }); + it("returns sucess message on success", async () => { + getSessionMock.mockImplementation(() => ({ + token: "fakeToken", + })); + postLogoutMock.mockImplementation(() => "success"); + const response = await POST(); + + expect(response.status).toEqual(200); + const json = (await response.json()) as { message: string }; + expect(json.message).toEqual("logout success"); + }); +}); diff --git a/frontend/tests/api/auth/session/route.test.ts b/frontend/tests/api/auth/session/route.test.ts new file mode 100644 index 000000000..1db694619 --- /dev/null +++ b/frontend/tests/api/auth/session/route.test.ts @@ -0,0 +1,44 @@ +/** + * @jest-environment node + */ + +import { GET } from "src/app/api/auth/session/route"; + +const getSessionMock = jest.fn(); +const responseJsonMock = jest.fn((something: unknown) => something); + +jest.mock("src/services/auth/session", () => ({ + getSession: (): unknown => getSessionMock(), +})); + +jest.mock("next/server", () => ({ + NextResponse: { + json: (any: object) => responseJsonMock(any), + }, +})); + +// note that all calls to the GET endpoint need to be caught here since the behavior of the Next redirect +// is to throw an error +describe("GET request", () => { + afterEach(() => jest.clearAllMocks()); + it("returns the current session token when one exists", async () => { + getSessionMock.mockImplementation(() => ({ + token: "fakeToken", + })); + + await GET(); + + expect(getSessionMock).toHaveBeenCalledTimes(1); + expect(responseJsonMock).toHaveBeenCalledTimes(1); + expect(responseJsonMock).toHaveBeenCalledWith({ token: "fakeToken" }); + }); + + it("returns a resopnse with an empty token if no session token exists", async () => { + getSessionMock.mockImplementation(() => null); + await GET(); + + expect(getSessionMock).toHaveBeenCalledTimes(1); + expect(responseJsonMock).toHaveBeenCalledTimes(1); + expect(responseJsonMock).toHaveBeenCalledWith({ token: "" }); + }); +}); diff --git a/frontend/tests/components/Header.test.tsx b/frontend/tests/components/Header.test.tsx index 670d524b7..a9db65913 100644 --- a/frontend/tests/components/Header.test.tsx +++ b/frontend/tests/components/Header.test.tsx @@ -1,4 +1,5 @@ import userEvent from "@testing-library/user-event"; +import { Response } from "node-fetch"; import { render, screen } from "tests/react-utils"; import { ReadonlyURLSearchParams } from "next/navigation"; @@ -6,7 +7,6 @@ import { ReadonlyURLSearchParams } from "next/navigation"; import Header from "src/components/Header"; const props = { - logoPath: "/img/logo.svg", locale: "en", }; @@ -22,8 +22,32 @@ 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", + } as unknown as Response; + + let originalFetch: typeof global.fetch; + beforeAll(() => { + originalFetch = global.fetch; + }); + afterAll(() => { + global.fetch = originalFetch; + }); + it("toggles the mobile nav menu", async () => { + global.fetch = jest.fn(() => + Promise.resolve({ + json: () => Promise.resolve(mockResponse), + }), + ) as jest.Mock; + render(
); const menuButton = screen.getByTestId("navMenuButton"); diff --git a/frontend/tests/components/LoginModal.test.tsx b/frontend/tests/components/LoginModal.test.tsx new file mode 100644 index 000000000..591298767 --- /dev/null +++ b/frontend/tests/components/LoginModal.test.tsx @@ -0,0 +1,46 @@ +import userEvent from "@testing-library/user-event"; +import { render, screen } from "tests/react-utils"; + +import Header from "src/components/Header"; +import { LoginModal } from "src/components/LoginModal"; + +const usePathnameMock = jest.fn().mockReturnValue("/fakepath"); + +jest.mock("next/navigation", () => ({ + usePathname: () => usePathnameMock() as string, +})); + +jest.mock("src/hooks/useFeatureFlags", () => ({ + useFeatureFlags: () => ({ + checkFeatureFlag: () => true, + }), +})); + +describe("LoginModal", () => { + it("renders", () => { + render(); + const loginGovLink = screen.getByRole("link", { + name: /Sign in with Login.gov/i, + }); + expect(loginGovLink).toBeInTheDocument(); + const modalTitle = screen.getByRole("heading", { level: 2 }); + expect(modalTitle).toHaveTextContent("Sign in to Simpler.Grants.gov"); + }); + + it("displays modal when clicked", async () => { + render(
); + + const loginButton = screen.getByRole("button", { name: /Sign in/i }); + expect(loginButton).toBeInTheDocument(); + + const modal = screen.getByRole("dialog"); + expect(modal).toHaveClass("is-hidden"); + + await userEvent.click(loginButton); + expect(modal).toHaveClass("is-visible"); + + const cancelButton = screen.getByRole("button", { name: /Cancel/i }); + await userEvent.click(cancelButton); + expect(modal).toHaveClass("is-hidden"); + }); +}); diff --git a/frontend/tests/components/search/SearchResults.test.tsx b/frontend/tests/components/search/SearchResults.test.tsx index 675d6fec7..10da0a8f1 100644 --- a/frontend/tests/components/search/SearchResults.test.tsx +++ b/frontend/tests/components/search/SearchResults.test.tsx @@ -34,7 +34,7 @@ jest.mock("react", () => ({ cache: (fn: unknown) => fn, })); -jest.mock("src/app/api/searchFetcher", () => ({ +jest.mock("src/services/fetch/fetchers/searchFetcher", () => ({ searchForOpportunities: jest.fn(() => Promise.resolve()), })); diff --git a/frontend/tests/e2e/index.spec.ts b/frontend/tests/e2e/index.spec.ts index de39ab9c4..4122312da 100644 --- a/frontend/tests/e2e/index.spec.ts +++ b/frontend/tests/e2e/index.spec.ts @@ -56,9 +56,13 @@ test("skips to main content when navigating via keyboard", async ({ test("displays mobile nav at mobile width", async ({ page }, { project }) => { if (project.name.match(/[Mm]obile/)) { // confirm that nav items are not visible by default with menu closed - const primaryNavItems = page.locator(".usa-nav__primary-item"); + const primaryNavItems = page.locator( + ".usa-accordion > .usa-nav__primary-item", + ); await expect(primaryNavItems).toHaveCount(5); - const allNavItems = await page.locator(".usa-nav__primary-item").all(); + const allNavItems = await page + .locator(".usa-accordion > .usa-nav__primary-item") + .all(); await Promise.all( allNavItems.map((item) => { return expect(item).not.toBeVisible(); @@ -86,7 +90,9 @@ test("hides mobile nav at expected times", async ({ page }, { project }) => { await menuOpener.click(); // mobile menu closes when a navigation link is clicked - const firstNavItem = page.locator(".usa-nav__primary-item > a").first(); + const firstNavItem = page + .locator(".usa-accordion > .usa-nav__primary-item > a") + .first(); await expect(firstNavItem).toBeVisible(); await firstNavItem.click(); await expect(firstNavItem).not.toBeVisible(); diff --git a/frontend/tests/e2e/opportunity.spec.ts b/frontend/tests/e2e/opportunity.spec.ts index 9a4a001ee..2c37ea5ca 100644 --- a/frontend/tests/e2e/opportunity.spec.ts +++ b/frontend/tests/e2e/opportunity.spec.ts @@ -10,7 +10,7 @@ test.afterEach(async ({ context }) => { }); test("has title", async ({ page }) => { - await expect(page).toHaveTitle(/^Opportunity Listing - */); + await expect(page).toHaveTitle(/^Opportunity Listing - */); }); test("has page attributes", async ({ page }) => { 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/pages/search/page.test.tsx b/frontend/tests/pages/search/page.test.tsx index 62cd9d00e..7d23a1d4b 100644 --- a/frontend/tests/pages/search/page.test.tsx +++ b/frontend/tests/pages/search/page.test.tsx @@ -7,7 +7,7 @@ import { useTranslationsMock } from "src/utils/testing/intlMocks"; import { ReadonlyURLSearchParams } from "next/navigation"; // test without feature flag functionality -jest.mock("src/hoc/search/withFeatureFlag", () => +jest.mock("src/hoc/withFeatureFlag", () => jest.fn((Component: React.Component) => Component), ); @@ -29,7 +29,7 @@ jest.mock("src/hooks/useSearchParamUpdater", () => ({ // // currently, with Suspense mocked out below to always show fallback content, // // the components making the fetch calls are never being rendered so we do not need to mock them out // // uncomment this if we figure out a way to properly test the underlying async components -// jest.mock("src/app/api/fetchers", () => ({ +// jest.mock("src/services/fetch/fetchers", () => ({ // get searchOpportunityFetcher() { // return new MockSearchOpportunityAPI(); // }, 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/auth/session.test.ts b/frontend/tests/services/auth/session.test.ts new file mode 100644 index 000000000..f5f585dba --- /dev/null +++ b/frontend/tests/services/auth/session.test.ts @@ -0,0 +1,125 @@ +import { createSession, getSession } from "src/services/auth/session"; + +const getCookiesMock = jest.fn(() => ({ + value: "some cookie value", +})); +const setCookiesMock = jest.fn(); +const deleteCookiesMock = jest.fn(); + +const encodeTextMock = jest.fn((arg: string): string => arg); +const createPublicKeyMock = jest.fn((arg: string): string => arg); + +const decryptMock = jest.fn(); +const encryptMock = jest.fn(); + +const cookiesMock = () => { + return { + get: getCookiesMock, + set: setCookiesMock, + delete: deleteCookiesMock, + }; +}; + +jest.mock("src/services/auth/sessionUtils", () => ({ + decrypt: (...args: unknown[]) => decryptMock(args) as unknown, + encrypt: (...args: unknown[]) => encryptMock(args) as unknown, + CLIENT_JWT_ENCRYPTION_ALGORITHM: "algo one", + API_JWT_ENCRYPTION_ALGORITHM: "algo two", + newExpirationDate: () => new Date(0), +})); + +jest.mock("next/headers", () => ({ + cookies: () => cookiesMock(), +})); + +jest.mock("src/constants/environments", () => ({ + environment: { + SESSION_SECRET: "session secret", + API_JWT_PUBLIC_KEY: "api secret", + }, +})); + +jest.mock("src/utils/generalUtils", () => ({ + encodeText: (arg: string): string => encodeTextMock(arg), +})); + +jest.mock("crypto", () => ({ + createPublicKey: (arg: string): string => createPublicKeyMock(arg), +})); + +describe("getSession", () => { + afterEach(() => jest.clearAllMocks()); + it("initializes session secrets if necessary", async () => { + await getSession(); + expect(encodeTextMock).toHaveBeenCalledWith("session secret"); + expect(createPublicKeyMock).toHaveBeenCalledWith("api secret"); + }); + it("calls decrypt with the correct arguments and returns successfully", async () => { + decryptMock.mockReturnValue({ + token: "some decrypted token", + exp: 123, + }); + const session = await getSession(); + expect(decryptMock).toHaveBeenCalledTimes(2); + expect(decryptMock).toHaveBeenCalledWith([ + "some cookie value", + "session secret", + "algo one", + ]); + expect(decryptMock).toHaveBeenCalledWith([ + "some decrypted token", + "api secret", + "algo two", + ]); + expect(session).toEqual({ + token: "some decrypted token", + exp: 123, + }); + }); + it("returns null if client token decrypt does not return a payload and token", async () => { + decryptMock.mockReturnValue(null); + const session = await getSession(); + expect(session).toEqual(null); + }); + it("returns null if api token decrypt does not return a payload", async () => { + decryptMock + .mockReturnValueOnce({ + token: "some decrypted token", + exp: 123, + }) + .mockReturnValueOnce(null); + const session = await getSession(); + expect(session).toEqual(null); + }); +}); + +describe("createSession", () => { + afterEach(() => jest.clearAllMocks()); + // to get this to work we'd need to manage resetting all modules before the test, which is a bit of a pain + it.skip("initializes session secrets if necessary", async () => { + await createSession("nothingSpecial"); + expect(encodeTextMock).toHaveBeenCalledWith("session secret"); + expect(createPublicKeyMock).toHaveBeenCalledWith("api secret"); + }); + it("calls cookie.set with expected values", async () => { + encryptMock.mockReturnValue("encrypted session"); + await createSession("nothingSpecial"); + expect(encryptMock).toHaveBeenCalledWith([ + "nothingSpecial", + new Date(0), + "session secret", + ]); + expect(setCookiesMock).toHaveBeenCalledTimes(1); + expect(setCookiesMock).toHaveBeenCalledWith( + "session", + "encrypted session", + { + httpOnly: true, + secure: true, + expires: new Date(0), + sameSite: "lax", + path: "/", + }, + ); + }); +}); diff --git a/frontend/tests/services/auth/sessionUtils.test.ts b/frontend/tests/services/auth/sessionUtils.test.ts new file mode 100644 index 000000000..a43884875 --- /dev/null +++ b/frontend/tests/services/auth/sessionUtils.test.ts @@ -0,0 +1,125 @@ +import { + decrypt, + deleteSession, + encrypt, +} from "src/services/auth/sessionUtils"; + +type RecursiveObject = { + [key: string]: () => RecursiveObject | string; +}; + +const getCookiesMock = jest.fn(); +const setCookiesMock = jest.fn(); +const deleteCookiesMock = jest.fn(); +const reallyFakeMockJWTConstructor = jest.fn(); +const setProtectedHeaderMock = jest.fn(() => fakeJWTInstance()); +const setIssuedAtMock = jest.fn(() => fakeJWTInstance()); +const setExpirationTimeMock = jest.fn(() => fakeJWTInstance()); +const signMock = jest.fn(); +const jwtVerifyMock = jest.fn(); + +const fakeKey = new Uint8Array([1, 2, 3]); + +// close over the token +// all of this rigmarole means that the mocked signing functionality will output the token passed into it +const setJWTMocksWithToken = (token: string) => { + signMock.mockImplementation(() => token); +}; + +const fakeJWTInstance = (): RecursiveObject => ({ + setProtectedHeader: setProtectedHeaderMock, + setIssuedAt: setIssuedAtMock, + setExpirationTime: setExpirationTimeMock, + sign: signMock, +}); + +const cookiesMock = () => { + return { + get: getCookiesMock, + set: setCookiesMock, + delete: deleteCookiesMock, + }; +}; + +jest.mock("next/headers", () => ({ + cookies: () => cookiesMock(), +})); + +jest.mock("jose", () => ({ + jwtVerify: (...args: unknown[]): unknown => jwtVerifyMock(...args), + SignJWT: function SignJWTMock( + this: { + setProtectedHeader: typeof jest.fn; + setIssuedAt: typeof jest.fn; + setExpirationTime: typeof jest.fn; + sign: typeof jest.fn; + token: string; + }, + { token = "" } = {}, + ) { + reallyFakeMockJWTConstructor(); + setJWTMocksWithToken(token); + return { + ...fakeJWTInstance(), + }; + }, +})); + +describe("deleteSession", () => { + afterEach(() => jest.clearAllMocks()); + it("calls cookie.delete with expected values", () => { + deleteSession(); + expect(deleteCookiesMock).toHaveBeenCalledTimes(1); + expect(deleteCookiesMock).toHaveBeenCalledWith("session"); + }); +}); + +describe("encrypt", () => { + afterEach(() => jest.clearAllMocks()); + it("calls all the JWT functions with expected values and returns expected value", async () => { + const token = "fakeToken"; + const expiresAt = new Date(); + + const encrypted = await encrypt(token, expiresAt, fakeKey); + + expect(reallyFakeMockJWTConstructor).toHaveBeenCalledTimes(1); + + expect(setProtectedHeaderMock).toHaveBeenCalledTimes(1); + expect(setProtectedHeaderMock).toHaveBeenCalledWith({ alg: "HS256" }); + + expect(setIssuedAtMock).toHaveBeenCalledTimes(1); + + expect(setExpirationTimeMock).toHaveBeenCalledTimes(1); + expect(setExpirationTimeMock).toHaveBeenCalledWith(expiresAt); + + expect(signMock).toHaveBeenCalledTimes(1); + expect(signMock).toHaveBeenCalledWith(fakeKey); + + // this is synthetic but generally proves things are working + expect(encrypted).toEqual(token); + }); +}); + +describe("decrypt", () => { + const cookie = "fakeCookie"; + afterEach(() => jest.clearAllMocks()); + it("calls JWT verification with expected values and returns payload", async () => { + jwtVerifyMock.mockImplementation((...args) => ({ payload: args })); + const decrypted = await decrypt(cookie, fakeKey, "HS256"); + + expect(jwtVerifyMock).toHaveBeenCalledTimes(1); + expect(jwtVerifyMock).toHaveBeenCalledWith(cookie, fakeKey, { + algorithms: ["HS256"], + }); + + expect(decrypted).toEqual([cookie, fakeKey, { algorithms: ["HS256"] }]); + }); + + it("returns null on error", async () => { + jwtVerifyMock.mockImplementation(() => { + throw new Error(); + }); + const decrypted = await decrypt(cookie, fakeKey, "HS256"); + expect(decrypted).toEqual(null); + }); +}); diff --git a/frontend/tests/services/auth/useUser.test.tsx b/frontend/tests/services/auth/useUser.test.tsx new file mode 100644 index 000000000..d8d4c5aeb --- /dev/null +++ b/frontend/tests/services/auth/useUser.test.tsx @@ -0,0 +1,65 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import identity from "lodash/identity"; +import UserProvider from "src/services/auth/UserProvider"; +import { useUser } from "src/services/auth/useUser"; + +const userFetcherMock = jest.fn(); + +jest.mock("src/services/fetch/fetchers/clientUserFetcher", () => ({ + userFetcher: () => userFetcherMock() as unknown, +})); + +jest.mock("lodash/debounce", () => identity); + +const UseUserConsumer = () => { + const { error, isLoading, user } = useUser(); + return ( + <> +
{error?.toString() || ""}
+
{isLoading.toString()}
+
{user?.toString() || ""}
+ + ); +}; + +describe("useUser", () => { + afterEach(() => jest.clearAllMocks()); + it("renders with the expected state on successful fetch", async () => { + userFetcherMock.mockResolvedValue("this is where a user would be"); + + render( + + + , + ); + + const errorDisplay = await screen.findByTestId("error"); + const userDisplay = await screen.findByTestId("user"); + + await waitFor(() => { + expect(userDisplay).toHaveTextContent("this is where a user would be"); + }); + + expect(errorDisplay).toBeEmptyDOMElement(); + expect(userFetcherMock).toHaveBeenCalledTimes(1); + }); + + it("renders with the expected state on error", async () => { + userFetcherMock.mockResolvedValue(null); + render( + + + , + ); + + const errorDisplay = await screen.findByTestId("error"); + const userDisplay = await screen.findByTestId("user"); + + await waitFor(() => { + expect(errorDisplay).not.toBeEmptyDOMElement(); + }); + + expect(userDisplay).toBeEmptyDOMElement(); + expect(userFetcherMock).toHaveBeenCalledTimes(1); + }); +}); 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/api/FetchHelpers.test.ts b/frontend/tests/services/fetch/FetchHelpers.test.ts similarity index 97% rename from frontend/tests/api/FetchHelpers.test.ts rename to frontend/tests/services/fetch/FetchHelpers.test.ts index 51a0bab61..d1d6da06d 100644 --- a/frontend/tests/api/FetchHelpers.test.ts +++ b/frontend/tests/services/fetch/FetchHelpers.test.ts @@ -1,7 +1,10 @@ import "server-only"; -import { createRequestUrl, sendRequest } from "src/app/api/fetcherHelpers"; import { ApiRequestError, NetworkError } from "src/errors"; +import { + createRequestUrl, + sendRequest, +} from "src/services/fetch/fetcherHelpers"; import { QueryParamData } from "src/types/search/searchRequestTypes"; const searchInputs: QueryParamData = { diff --git a/frontend/tests/api/Fetchers.test.ts b/frontend/tests/services/fetch/Fetchers.test.ts similarity index 92% rename from frontend/tests/api/Fetchers.test.ts rename to frontend/tests/services/fetch/Fetchers.test.ts index a0270231f..e651d6b58 100644 --- a/frontend/tests/api/Fetchers.test.ts +++ b/frontend/tests/services/fetch/Fetchers.test.ts @@ -1,5 +1,5 @@ -import { EndpointConfig } from "src/app/api/endpointConfigs"; -import { requesterForEndpoint } from "src/app/api/fetchers"; +import { EndpointConfig } from "src/services/fetch/endpointConfigs"; +import { requesterForEndpoint } from "src/services/fetch/fetchers/fetchers"; const createRequestUrlMock = jest.fn( (_method, _basePath, _version, _namespace, subPath: string, _body) => { @@ -15,7 +15,7 @@ const getDefaultHeadersMock = jest.fn(() => ({ "Content-Type": "application/json", })); -jest.mock("src/app/api/fetcherHelpers", () => ({ +jest.mock("src/services/fetch/fetcherHelpers", () => ({ createRequestUrl: ( _method: unknown, _basePath: unknown, diff --git a/frontend/tests/api/SearchFetcher.test.ts b/frontend/tests/services/fetch/fetchers/SearchFetcher.test.ts similarity index 98% rename from frontend/tests/api/SearchFetcher.test.ts rename to frontend/tests/services/fetch/fetchers/SearchFetcher.test.ts index 418b9aa8e..2aa314ca5 100644 --- a/frontend/tests/api/SearchFetcher.test.ts +++ b/frontend/tests/services/fetch/fetchers/SearchFetcher.test.ts @@ -2,7 +2,7 @@ import { buildFilters, buildPagination, searchForOpportunities, -} from "src/app/api/searchFetcher"; +} from "src/services/fetch/fetchers/searchFetcher"; import { QueryParamData, SearchFetcherActionType, @@ -29,7 +29,7 @@ jest.mock("react", () => ({ cache: (fn: unknown) => fn, })); -jest.mock("src/app/api/fetchers", () => ({ +jest.mock("src/services/fetch/fetchers/fetchers", () => ({ fetchOpportunitySearch: (params: QueryParamData) => { return mockfetchOpportunitySearch(params) as SearchAPIResponse; }, diff --git a/frontend/tests/services/withFeatureFlag.test.tsx b/frontend/tests/services/withFeatureFlag.test.tsx index ead6d50b7..32eecb29a 100644 --- a/frontend/tests/services/withFeatureFlag.test.tsx +++ b/frontend/tests/services/withFeatureFlag.test.tsx @@ -1,12 +1,12 @@ // import Cookies from "js-cookie"; import Cookies from "js-cookie"; import { identity } from "lodash"; -import withFeatureFlag from "src/hoc/search/withFeatureFlag"; +import withFeatureFlag from "src/hoc/withFeatureFlag"; 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/frontend/tests/utils/getRoutes.test.ts b/frontend/tests/utils/getRoutes.test.ts index 023d3a955..71fd2b6c1 100644 --- a/frontend/tests/utils/getRoutes.test.ts +++ b/frontend/tests/utils/getRoutes.test.ts @@ -27,6 +27,7 @@ describe("getNextRoutes", () => { expect(result).toEqual([ "/dev/feature-flags", + "/error", "/health", "/maintenance", "/opportunity/1", @@ -37,6 +38,8 @@ describe("getNextRoutes", () => { "/subscribe/confirmation", "/subscribe", "/subscribe/unsubscribe", + "/unauthorized", + "/user", ]); }); }); @@ -60,7 +63,6 @@ function getPaths() { "src/app/[locale]/search/SearchForm.tsx", "src/app/[locale]/search/actions.ts", "src/app/[locale]/search/error.tsx", - "src/app/[locale]/search/loading.tsx", "src/app/[locale]/search/page.tsx", "src/app/[locale]/subscribe/SubscriptionForm.tsx", "src/app/[locale]/subscribe/confirmation/page.tsx", diff --git a/infra/frontend/app-config/env-config/environment-variables.tf b/infra/frontend/app-config/env-config/environment-variables.tf index bb08013ea..857afce37 100644 --- a/infra/frontend/app-config/env-config/environment-variables.tf +++ b/infra/frontend/app-config/env-config/environment-variables.tf @@ -46,6 +46,11 @@ locals { manage_method = "manual" secret_store_name = "/${var.app_name}/${var.environment}/api-auth-token" }, + # URL for the API login route. + AUTH_LOGIN_URL = { + manage_method = "manual" + secret_store_name = "/${var.app_name}/${var.environment}/auth-login-url" + }, NEW_RELIC_APP_NAME = { manage_method = "manual" secret_store_name = "/${var.app_name}/${var.environment}/new-relic-app-name" @@ -61,6 +66,18 @@ locals { FEATURE_OPPORTUNITY_OFF = { manage_method = "manual" secret_store_name = "/${var.app_name}/${var.environment}/feature-opportunity-off" - } + }, + 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" + }, + API_JWT_PUBLIC_KEY = { + manage_method = "manual" + secret_store_name = "/api/${var.environment}/api-jwt-public-key" + }, } }