Skip to content

Commit

Permalink
add feature flag to enable tenants individually
Browse files Browse the repository at this point in the history
  • Loading branch information
macfarlandian committed Apr 14, 2021
1 parent 75c4a0f commit 7791789
Show file tree
Hide file tree
Showing 10 changed files with 175 additions and 10 deletions.
2 changes: 2 additions & 0 deletions .github/workflows/spotlight-staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ jobs:
REACT_APP_AUTH_ENABLED: true
REACT_APP_AUTH_ENV: development
REACT_APP_API_URL: ${{ secrets.REACT_APP_API_URL }}
REACT_APP_ENABLED_TENANTS: US_ND,US_PA

run: yarn build
- name: Store build artifact
uses: actions/upload-artifact@v2
Expand Down
1 change: 1 addition & 0 deletions spotlight-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Expected environment variables include:
- `REACT_APP_AUTH_ENABLED` - set to `true` or `false` to toggle Auth0 protection per environment. Currently only used in staging to make the entire site private. No need to enable this locally unless you are developing or testing something auth-related. If set to `true` then `REACT_APP_AUTH_ENV` **must** be set to a supported value.
- `REACT_APP_AUTH_ENV` - a string indicating the "auth environment" used to point to the correct Auth0 tenant. `development` (which also covers staging) is the only supported value, which **must** be set if `REACT_APP_AUTH_ENABLED` is `true`.
- `REACT_APP_API_URL` - the base URL of the backend API server. This should be set to http://localhost:3001 when running the server locally, and to http://localhost:3002 in the test environment (because some tests will make requests to this URL).
- `REACT_APP_ENABLED_TENANTS` - a feature flag for activating individual tenants, in the form of a comma-separated list of tenant IDs (e.g., "US_ND,US_PA") that should be available. Tenants that are configured but not enumerated here will not be accessible to users.

(Note that variables must be prefixed with `REACT_APP_` to be available inside the client application.)

Expand Down
28 changes: 28 additions & 0 deletions spotlight-client/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,23 @@ import {
fireEvent,
waitFor,
} from "@testing-library/react";
import { isTenantEnabled } from "./contentApi/isTenantEnabled";
import testContent from "./contentApi/sources/us_nd";
import { NarrativesSlug } from "./routerUtils/types";
import { renderNavigableApp, segmentMock } from "./testUtils";

jest.mock("./contentApi/isTenantEnabled", () => ({
isTenantEnabled: jest.fn(),
}));

const isTenantEnabledMock = isTenantEnabled as jest.MockedFunction<
typeof isTenantEnabled
>;

beforeEach(() => {
isTenantEnabledMock.mockReturnValue(true);
});

describe("navigation", () => {
/**
* Convenience method that verifies page contents when loading a url directly,
Expand Down Expand Up @@ -231,5 +244,20 @@ describe("navigation", () => {
expect(screen.queryByRole(...notFoundRoleArgs)).not.toBeInTheDocument()
);
});

test("disabled tenant", async () => {
isTenantEnabledMock.mockReturnValue(false);

renderNavigableApp({ route: "/us-pa" });

expect(screen.getByRole(...notFoundRoleArgs)).toBeVisible();
expect(document.title).toBe("Page not found — Spotlight by Recidiviz");

fireEvent.click(screen.getByRole("link", { name: "Spotlight" }));

await waitFor(() =>
expect(screen.queryByRole(...notFoundRoleArgs)).not.toBeInTheDocument()
);
});
});
});
17 changes: 13 additions & 4 deletions spotlight-client/src/DataStore/TenantStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
// =============================================================================

import { intercept, makeAutoObservable } from "mobx";
import { ERROR_MESSAGES } from "../constants";
import {
isSystemNarrativeTypeId,
NarrativeTypeId,
Expand Down Expand Up @@ -63,10 +64,18 @@ export default class TenantStore {
get currentTenant(): Tenant | undefined {
if (!this.currentTenantId) return undefined;
if (!this.tenants.has(this.currentTenantId)) {
this.tenants.set(
this.currentTenantId,
createTenant({ tenantId: this.currentTenantId })
);
// if the tenant is not enabled, the caller will get undefined,
// so they need to be prepared to handle that
try {
this.tenants.set(
this.currentTenantId,
createTenant({ tenantId: this.currentTenantId })
);
} catch (error) {
if (!error.message.includes(ERROR_MESSAGES.disabledTenant)) {
throw error;
}
}
}
return this.tenants.get(this.currentTenantId);
}
Expand Down
1 change: 1 addition & 0 deletions spotlight-client/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const ERROR_MESSAGES = {
noMetricData: "Unable to retrieve valid data for this metric.",
missingRequiredContent:
"Unable to create Metric because required content is missing.",
disabledTenant: "This tenant has not been enabled.",
};

export const NAV_BAR_HEIGHT = 80;
Expand Down
18 changes: 13 additions & 5 deletions spotlight-client/src/contentApi/getTenantList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,17 @@
import retrieveContent from "./retrieveContent";
import { TenantId, TenantIdList } from "./types";

export default function getTenantList(): { id: TenantId; name: string }[] {
return TenantIdList.map((id) => ({
id,
name: retrieveContent({ tenantId: id }).name,
}));
type TenantListItem = { id: TenantId; name: string };

export default function getTenantList(): TenantListItem[] {
return TenantIdList.map((id) => {
try {
return {
id,
name: retrieveContent({ tenantId: id }).name,
};
} catch {
return null;
}
}).filter((t): t is TenantListItem => t !== null);
}
74 changes: 74 additions & 0 deletions spotlight-client/src/contentApi/isTenantEnabled.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Recidiviz - a data platform for criminal justice reform
// Copyright (C) 2021 Recidiviz, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// =============================================================================

// we have to import everything dynamically to manipulate process.env,
// which is weird and Typescript doesn't like it, so silence these warnings
/* eslint-disable @typescript-eslint/no-explicit-any */
let cleanup: any;
let TenantIdList: any;
/* eslint-enable @typescript-eslint/no-explicit-any */

// this doesn't do anything but convince TypeScript this file is a module,
// since we don't have any top-level imports
export {};

// mocking the node env is esoteric, see https://stackoverflow.com/a/48042799
const ORIGINAL_ENV = process.env;

/**
* Convenience method for importing test module after updating environment
*/
async function getTestFn() {
return (await import("./isTenantEnabled")).isTenantEnabled;
}

beforeEach(async () => {
// make a copy that we can modify
process.env = { ...ORIGINAL_ENV };

jest.resetModules();
// reimport all modules except the test module
const reactTestingLibrary = await import("@testing-library/react");
cleanup = reactTestingLibrary.cleanup;
TenantIdList = (await import("./types")).TenantIdList;
});

afterEach(() => {
process.env = ORIGINAL_ENV;
// dynamic imports break auto cleanup so we have to do it manually
cleanup();
});

test("all tenants enabled", async () => {
process.env.REACT_APP_ENABLED_TENANTS = TenantIdList.join(",");

const isTenantEnabled = await getTestFn();

expect(TenantIdList.map(isTenantEnabled)).toEqual(
TenantIdList.map(() => true)
);
});

test("disabled tenant", async () => {
process.env.REACT_APP_ENABLED_TENANTS = "US_ND";

const isTenantEnabled = await getTestFn();

expect(TenantIdList.map(isTenantEnabled)).toEqual(
TenantIdList.map((id: string) => id === "US_ND")
);
});
30 changes: 30 additions & 0 deletions spotlight-client/src/contentApi/isTenantEnabled.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Recidiviz - a data platform for criminal justice reform
// Copyright (C) 2021 Recidiviz, Inc.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// =============================================================================

import { TenantId } from "./types";

const enabledTenants = process.env.REACT_APP_ENABLED_TENANTS?.split(
","
).map((id) => id.trim());

/**
* Tenants have to be explicitly enabled per environment; this function
* checks against that configuration for a given tenant.
*/
export function isTenantEnabled(id: TenantId): boolean {
return Array.isArray(enabledTenants) && enabledTenants.includes(id);
}
7 changes: 6 additions & 1 deletion spotlight-client/src/contentApi/retrieveContent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// =============================================================================

import { ERROR_MESSAGES } from "../constants";
import { isTenantEnabled } from "./isTenantEnabled";
import US_ND from "./sources/us_nd";
import US_PA from "./sources/us_pa";
import { TenantContent, TenantId } from "./types";
Expand All @@ -31,5 +33,8 @@ type RetrieveContentParams = {
export default function retrieveContent({
tenantId,
}: RetrieveContentParams): TenantContent {
return CONTENT_SOURCES[tenantId];
if (isTenantEnabled(tenantId)) {
return CONTENT_SOURCES[tenantId];
}
throw new Error(`${ERROR_MESSAGES.disabledTenant} (${tenantId})`);
}
7 changes: 7 additions & 0 deletions spotlight-client/src/withRouteSync/withRouteSync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { RouteComponentProps } from "@reach/router";
import { action } from "mobx";
import { observer } from "mobx-react-lite";
import React, { ComponentType, useEffect } from "react";
import { isTenantEnabled } from "../contentApi/isTenantEnabled";
import NotFound from "../NotFound";
import normalizeRouteParams from "../routerUtils/normalizeRouteParams";
import { RouteParams } from "../routerUtils/types";
Expand Down Expand Up @@ -47,8 +48,14 @@ const withRouteSync = <Props extends RouteComponentProps & RouteParams>(
normalizedProps.tenantId &&
tenantStore.currentTenantId !== normalizedProps.tenantId;

const isTenantDisabled = Boolean(
tenantStore.currentTenantId &&
!isTenantEnabled(tenantStore.currentTenantId)
);

const isRouteInvalid =
isTenantForbidden ||
isTenantDisabled ||
Object.values(normalizedProps).includes(null) ||
// catchall path for partially valid URLs; e.g. :tenantId/something-invalid
path === "/*";
Expand Down

0 comments on commit 7791789

Please sign in to comment.