From 2598d774302d74e9113b80c1f0c98dbe6c67c2ba Mon Sep 17 00:00:00 2001 From: Nick Phura Date: Wed, 21 Jun 2023 16:20:49 -0700 Subject: [PATCH 1/7] Feature Flag Guard --- api/.pipeline/config.js | 6 +- api/.pipeline/lib/api.deploy.js | 3 +- api/.pipeline/templates/api.dc.yaml | 11 ++- api/src/services/platform-service.test.ts | 9 +- api/src/services/platform-service.ts | 6 +- api/src/utils/feature-flag-utils.test.ts | 99 +++++++++++++++++++ api/src/utils/feature-flag-utils.ts | 34 +++++++ app/.pipeline/config.js | 6 +- app/.pipeline/lib/app.deploy.js | 3 +- app/.pipeline/templates/app.dc.yaml | 10 +- app/server/index.js | 42 +++++--- app/src/components/file-upload/DropZone.tsx | 10 +- app/src/components/security/Guards.tsx | 52 ++++++++-- .../components/security/RouteGuards.test.tsx | 79 +++++++++++++++ app/src/contexts/configContext.tsx | 50 +++++----- .../features/surveys/view/SurveyHeader.tsx | 10 +- app/src/hooks/useBioHubApi.ts | 8 +- app/src/hooks/useCritterbaseApi.ts | 7 +- app/src/hooks/useTelemetryApi.ts | 7 +- docker-compose.yml | 6 +- env_config/env.docker | 12 ++- 21 files changed, 372 insertions(+), 98 deletions(-) create mode 100644 api/src/utils/feature-flag-utils.test.ts create mode 100644 api/src/utils/feature-flag-utils.ts diff --git a/api/.pipeline/config.js b/api/.pipeline/config.js index 54c8f665d2..d746dae945 100644 --- a/api/.pipeline/config.js +++ b/api/.pipeline/config.js @@ -83,13 +83,13 @@ const phases = { backboneIntakePath: '/api/dwc/submission/queue', backboneArtifactIntakePath: '/api/artifact/intake', biohubTaxonTsnPath: '/api/taxonomy/taxon/tsn', - backboneIntakeEnabled: false, bctwApiHost: 'https://moe-bctw-api-dev.apps.silver.devops.gov.bc.ca', critterbaseApiHost: 'https://moe-critterbase-api-dev.apps.silver.devops.gov.bc.ca/api', nodeEnv: 'development', s3KeyPrefix: (isStaticDeployment && 'sims') || `local/${deployChangeId}/sims`, tz: config.timezone.api, sso: config.sso.dev, + featureFlags: 'API_FF_SUBMIT_BIOHUB', logLevel: 'info', nodeOptions: '--max_old_space_size=2250', // 75% of memoryLimit (bytes) cpuRequest: '50m', @@ -116,13 +116,13 @@ const phases = { backboneIntakePath: '/api/dwc/submission/queue', backboneArtifactIntakePath: '/api/artifact/intake', biohubTaxonTsnPath: '/api/taxonomy/taxon/tsn', - backboneIntakeEnabled: false, bctwApiHost: 'https://moe-bctw-api-test.apps.silver.devops.gov.bc.ca', critterbaseApiHost: 'https://moe-critterbase-api-test.apps.silver.devops.gov.bc.ca/api', nodeEnv: 'production', s3KeyPrefix: 'sims', tz: config.timezone.api, sso: config.sso.test, + featureFlags: 'API_FF_SUBMIT_BIOHUB', logLevel: 'info', nodeOptions: '--max_old_space_size=2250', // 75% of memoryLimit (bytes) cpuRequest: '50m', @@ -149,13 +149,13 @@ const phases = { backboneIntakePath: '/api/dwc/submission/queue', backboneArtifactIntakePath: '/api/artifact/intake', biohubTaxonTsnPath: '/api/taxonomy/taxon/tsn', - backboneIntakeEnabled: false, bctwApiHost: 'https://moe-bctw-api-prod.apps.silver.devops.gov.bc.ca', critterbaseApiHost: 'https://moe-critterbase-api-prod.apps.silver.devops.gov.bc.ca/api', nodeEnv: 'production', s3KeyPrefix: 'sims', tz: config.timezone.api, sso: config.sso.prod, + featureFlags: 'API_FF_SUBMIT_BIOHUB', logLevel: 'warn', nodeOptions: '--max_old_space_size=2250', // 75% of memoryLimit (bytes) cpuRequest: '50m', diff --git a/api/.pipeline/lib/api.deploy.js b/api/.pipeline/lib/api.deploy.js index 19a01027e0..838a660d02 100644 --- a/api/.pipeline/lib/api.deploy.js +++ b/api/.pipeline/lib/api.deploy.js @@ -38,7 +38,6 @@ const apiDeploy = async (settings) => { BACKBONE_INTERNAL_API_HOST: phases[phase].backboneInternalApiHost, BACKBONE_INTAKE_PATH: phases[phase].backboneIntakePath, BACKBONE_ARTIFACT_INTAKE_PATH: phases[phase].backboneArtifactIntakePath, - BACKBONE_INTAKE_ENABLED: phases[phase].backboneIntakeEnabled, BIOHUB_TAXON_PATH: phases[phase].biohubTaxonPath, BIOHUB_TAXON_TSN_PATH: phases[phase].biohubTaxonTsnPath, // BCTW / Critterbase @@ -66,6 +65,8 @@ const apiDeploy = async (settings) => { KEYCLOAK_API_ENVIRONMENT: phases[phase].sso.cssApi.cssApiEnvironment, // Log Level LOG_LEVEL: phases[phase].logLevel || 'info', + // Feature Flags + FEATURE_FLAGS: phases[phase].featureFlags, // Openshift Resources CPU_REQUEST: phases[phase].cpuRequest, CPU_LIMIT: phases[phase].cpuLimit, diff --git a/api/.pipeline/templates/api.dc.yaml b/api/.pipeline/templates/api.dc.yaml index 3a794677da..722efffc88 100644 --- a/api/.pipeline/templates/api.dc.yaml +++ b/api/.pipeline/templates/api.dc.yaml @@ -49,9 +49,6 @@ parameters: - name: BACKBONE_ARTIFACT_INTAKE_PATH required: true description: API path for BioHub Platform Backbone artifact submission intake endpoint. Example "/api/path/to/artifact/intake". - - name: BACKBONE_INTAKE_ENABLED - required: true - description: Controls whether or not SIMS will submit DwCA datasets to the BioHub Platform Backbone. Set to "true" to enable it, will be disabled by default otherwise. - name: BIOHUB_TAXON_TSN_PATH required: true description: API path for BioHub Platform Backbone taxon TSN endpoint. Example "/api/path/to/taxon/tsn". @@ -121,6 +118,10 @@ parameters: # Log level - name: LOG_LEVEL value: 'info' + # Feature Flags + - name: FEATURE_FLAGS + description: Used to identify features that should be temporarily disabled/hidden. Must be a comma delimited list of keywords. + value: '' # GCNotify - name: GCNOTIFY_API_SECRET description: Secret for gcnotify api key @@ -227,8 +228,6 @@ objects: value: ${BACKBONE_INTAKE_PATH} - name: BACKBONE_ARTIFACT_INTAKE_PATH value: ${BACKBONE_ARTIFACT_INTAKE_PATH} - - name: BACKBONE_INTAKE_ENABLED - value: ${BACKBONE_INTAKE_ENABLED} - name: BIOHUB_TAXON_TSN_PATH value: ${BIOHUB_TAXON_TSN_PATH} - name: BIOHUB_TAXON_PATH @@ -342,6 +341,8 @@ objects: value: ${GCNOTIFY_EMAIL_URL} - name: GCNOTIFY_SMS_URL value: ${GCNOTIFY_SMS_URL} + - name: FEATURE_FLAGS + value: ${FEATURE_FLAGS} image: ' ' imagePullPolicy: Always ports: diff --git a/api/src/services/platform-service.test.ts b/api/src/services/platform-service.test.ts index e4433fdc7d..025e300b79 100644 --- a/api/src/services/platform-service.test.ts +++ b/api/src/services/platform-service.test.ts @@ -4,6 +4,7 @@ import { describe } from 'mocha'; import sinon from 'sinon'; import sinonChai from 'sinon-chai'; import { ObservationRecord } from '../repositories/observation-repository'; +import * as featureFlagUtils from '../utils/feature-flag-utils'; import { getMockDBConnection } from '../__mocks__/db'; import { AttachmentService } from './attachment-service'; import { HistoryPublishService } from './history-publish-service'; @@ -24,8 +25,8 @@ describe('PlatformService', () => { sinon.restore(); }); - it('throws an error if BioHub intake is not enabled', async () => { - process.env.BACKBONE_INTAKE_ENABLED = 'false'; + it('throws an error if publishing to BioHub is not currently enabled.', async () => { + sinon.stub(featureFlagUtils, 'isFeatureFlagPresent').returns(true); const mockDBConnection = getMockDBConnection(); const platformService = new PlatformService(mockDBConnection); @@ -34,12 +35,11 @@ describe('PlatformService', () => { await platformService.submitSurveyToBioHub(1, { submissionComment: 'test' }); expect.fail(); } catch (error) { - expect((error as Error).message).to.equal('BioHub intake is not enabled'); + expect((error as Error).message).to.equal('Publishing to BioHub is not currently enabled.'); } }); it('throws error when axios request fails', async () => { - process.env.BACKBONE_INTAKE_ENABLED = 'true'; process.env.BACKBONE_INTERNAL_API_HOST = 'http://backbone-host.dev/'; const mockDBConnection = getMockDBConnection(); @@ -69,7 +69,6 @@ describe('PlatformService', () => { }); it('should submit survey to BioHub successfully', async () => { - process.env.BACKBONE_INTAKE_ENABLED = 'true'; process.env.BACKBONE_INTERNAL_API_HOST = 'http://backbone-host.dev/'; const mockDBConnection = getMockDBConnection(); diff --git a/api/src/services/platform-service.ts b/api/src/services/platform-service.ts index 84bd994ab5..72af783769 100644 --- a/api/src/services/platform-service.ts +++ b/api/src/services/platform-service.ts @@ -8,6 +8,7 @@ import { IDBConnection } from '../database/db'; import { ApiError, ApiErrorType, ApiGeneralError } from '../errors/api-error'; import { PostSurveySubmissionToBioHubObject } from '../models/biohub-create'; import { ISurveyAttachment, ISurveyReportAttachment } from '../repositories/attachment-repository'; +import { isFeatureFlagPresent } from '../utils/feature-flag-utils'; import { getFileFromS3 } from '../utils/file-utils'; import { getLogger } from '../utils/logger'; import { AttachmentService } from './attachment-service'; @@ -54,7 +55,6 @@ export interface ITaxonomy { scientificName: string; } -const getBackboneIntakeEnabled = () => process.env.BACKBONE_INTAKE_ENABLED === 'true' || false; const getBackboneInternalApiHost = () => process.env.BACKBONE_INTERNAL_API_HOST || ''; const getBackboneArtifactIntakePath = () => process.env.BACKBONE_ARTIFACT_INTAKE_PATH || ''; const getBackboneSurveyIntakePath = () => process.env.BACKBONE_INTAKE_PATH || ''; @@ -120,8 +120,8 @@ export class PlatformService extends DBService { ): Promise<{ submission_uuid: string }> { defaultLog.debug({ label: 'submitSurveyToBioHub', message: 'params', surveyId }); - if (!getBackboneIntakeEnabled()) { - throw new ApiGeneralError('BioHub intake is not enabled'); + if (isFeatureFlagPresent(['API_FF_SUBMIT_BIOHUB'])) { + throw new ApiGeneralError('Publishing to BioHub is not currently enabled.'); } const keycloakService = new KeycloakService(); diff --git a/api/src/utils/feature-flag-utils.test.ts b/api/src/utils/feature-flag-utils.test.ts new file mode 100644 index 0000000000..97ff69009a --- /dev/null +++ b/api/src/utils/feature-flag-utils.test.ts @@ -0,0 +1,99 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import { getFeatureFlags, getFeatureFlagsString, isFeatureFlagPresent } from './feature-flag-utils'; + +describe('getFeatureFlagsString', () => { + describe('returns the env var `FEATURE_FLAGS`', () => { + const env = Object.assign({}, process.env); + + afterEach(() => { + sinon.restore(); + process.env = env; + }); + + it('when the FEATURE_FLAGS env var is undefined', () => { + process.env.FEATURE_FLAGS = undefined; + + expect(getFeatureFlagsString()).to.equal(undefined); + }); + + it('when the FEATURE_FLAGS env var is defined', () => { + process.env.FEATURE_FLAGS = 'flag1,flag2,flag3'; + + expect(getFeatureFlagsString()).to.equal('flag1,flag2,flag3'); + }); + }); +}); + +describe('getFeatureFlags', () => { + describe('returns an array of flags', () => { + const env = Object.assign({}, process.env); + + afterEach(() => { + sinon.restore(); + process.env = env; + }); + + it('when the FEATURE_FLAGS env var is undefined', () => { + process.env.FEATURE_FLAGS = undefined; + + expect(getFeatureFlags()).to.eql([]); + }); + + it('when the FEATURE_FLAGS env var is defined', () => { + process.env.FEATURE_FLAGS = 'flag1,flag2,flag3'; + + expect(getFeatureFlags()).to.eql(['flag1', 'flag2', 'flag3']); + }); + }); +}); + +describe('isFeatureFlagPresent', () => { + describe('returns true', () => { + const env = Object.assign({}, process.env); + + afterEach(() => { + sinon.restore(); + process.env = env; + }); + + it('when one flag matches', () => { + process.env.FEATURE_FLAGS = 'flag1,flag2,flag3'; + + expect(isFeatureFlagPresent(['flag2'])).to.equal(true); + }); + + it('when two flags match', () => { + process.env.FEATURE_FLAGS = 'flag1,flag2,flag3'; + + expect(isFeatureFlagPresent(['flag2', 'flag1'])).to.equal(true); + }); + + it('when some, but not all, flags match', () => { + process.env.FEATURE_FLAGS = 'flag1,flag2,flag3'; + + expect(isFeatureFlagPresent(['flag4', 'flag3'])).to.equal(true); + }); + }); + + describe('returns false', () => { + const env = Object.assign({}, process.env); + + afterEach(() => { + sinon.restore(); + process.env = env; + }); + + it('when no flags match', () => { + process.env.FEATURE_FLAGS = 'flag1,flag2,flag3'; + + expect(isFeatureFlagPresent(['flag4'])).to.equal(false); + }); + + it('when no flags specified', () => { + process.env.FEATURE_FLAGS = 'flag1,flag2,flag3'; + + expect(isFeatureFlagPresent([])).to.equal(false); + }); + }); +}); diff --git a/api/src/utils/feature-flag-utils.ts b/api/src/utils/feature-flag-utils.ts new file mode 100644 index 0000000000..5559f5ebb8 --- /dev/null +++ b/api/src/utils/feature-flag-utils.ts @@ -0,0 +1,34 @@ +/** + * Returns the raw feature flag string from the environment variable `REACT_APP_FEATURE_FLAGS`. + * + * @return {*} {(string | undefined)} + */ +export const getFeatureFlagsString = (): string | undefined => { + return process.env.FEATURE_FLAGS; +}; + +/** + * Returns a parsed array of feature flag strings from the environment variable `REACT_APP_FEATURE_FLAGS`. + * + * @return {*} {string[]} + */ +export const getFeatureFlags = (): string[] => { + const featureFlagsString = getFeatureFlagsString(); + + if (!featureFlagsString) { + return []; + } + + return featureFlagsString.split(','); +}; + +/** + * Returns `true` if at least one of the provided `featureFlags` is present in the environment variable + * `REACT_APP_FEATURE_FLAGS`. + * + * @param {string[]} featureFlags + * @return {*} {boolean} + */ +export const isFeatureFlagPresent = (featureFlags: string[]): boolean => { + return getFeatureFlags().some((flag) => featureFlags.includes(flag)); +}; diff --git a/app/.pipeline/config.js b/app/.pipeline/config.js index e9b73cc508..850a0edfad 100644 --- a/app/.pipeline/config.js +++ b/app/.pipeline/config.js @@ -83,13 +83,13 @@ const phases = { maxUploadFileSize, nodeEnv: 'development', sso: config.sso.dev, + featureFlags: '', cpuRequest: '50m', cpuLimit: '300m', memoryRequest: '100Mi', memoryLimit: '500Mi', replicas: (isStaticDeployment && '1') || '1', replicasMax: (isStaticDeployment && '2') || '1', - biohubFeatureFlag: 'true', backbonePublicApiHost: 'https://api-dev-biohub-platform.apps.silver.devops.gov.bc.ca', biohubTaxonPath: '/api/taxonomy/taxon', biohubTaxonTsnPath: '/api/taxonomy/taxon/tsn' @@ -110,13 +110,13 @@ const phases = { maxUploadFileSize, nodeEnv: 'production', sso: config.sso.test, + featureFlags: 'APP_FF_PUBLISH_BIOHUB', cpuRequest: '50m', cpuLimit: '500m', memoryRequest: '100Mi', memoryLimit: '500Mi', replicas: '2', replicasMax: '3', - biohubFeatureFlag: 'false', backbonePublicApiHost: 'https://api-test-biohub-platform.apps.silver.devops.gov.bc.ca', biohubTaxonPath: '/api/taxonomy/taxon', biohubTaxonTsnPath: '/api/taxonomy/taxon/tsn' @@ -137,13 +137,13 @@ const phases = { maxUploadFileSize, nodeEnv: 'production', sso: config.sso.prod, + featureFlags: 'APP_FF_PUBLISH_BIOHUB', cpuRequest: '50m', cpuLimit: '500m', memoryRequest: '100Mi', memoryLimit: '500Mi', replicas: '2', replicasMax: '3', - biohubFeatureFlag: 'false', backbonePublicApiHost: 'https://api-biohub-platform.apps.silver.devops.gov.bc.ca', biohubTaxonPath: '/api/taxonomy/taxon', biohubTaxonTsnPath: '/api/taxonomy/taxon/tsn' diff --git a/app/.pipeline/lib/app.deploy.js b/app/.pipeline/lib/app.deploy.js index 3a1d7f873a..8f8fe171e4 100644 --- a/app/.pipeline/lib/app.deploy.js +++ b/app/.pipeline/lib/app.deploy.js @@ -36,6 +36,8 @@ const appDeploy = async (settings) => { REACT_APP_KEYCLOAK_HOST: phases[phase].sso.host, REACT_APP_KEYCLOAK_REALM: phases[phase].sso.realm, REACT_APP_KEYCLOAK_CLIENT_ID: phases[phase].sso.clientId, + // Feature Flags + REACT_APP_FEATURE_FLAGS: phases[phase].featureFlags, // Openshift Resources CPU_REQUEST: phases[phase].cpuRequest, CPU_LIMIT: phases[phase].cpuLimit, @@ -43,7 +45,6 @@ const appDeploy = async (settings) => { MEMORY_LIMIT: phases[phase].memoryLimit, REPLICAS: phases[phase].replicas, REPLICAS_MAX: phases[phase].replicasMax, - REACT_APP_BIOHUB_FEATURE_FLAG: phases[phase].biohubFeatureFlag, REACT_APP_BACKBONE_PUBLIC_API_HOST: phases[phase].backbonePublicApiHost, REACT_APP_BIOHUB_TAXON_PATH: phases[phase].biohubTaxonPath, REACT_APP_BIOHUB_TAXON_TSN_PATH: phases[phase].biohubTaxonTsnPath diff --git a/app/.pipeline/templates/app.dc.yaml b/app/.pipeline/templates/app.dc.yaml index 6bc31cf023..415dfaa31a 100644 --- a/app/.pipeline/templates/app.dc.yaml +++ b/app/.pipeline/templates/app.dc.yaml @@ -14,9 +14,6 @@ parameters: - name: HOST - name: CHANGE_ID value: '0' - - name: REACT_APP_BIOHUB_FEATURE_FLAG - description: Flag to indicate if the application has biohub specific features enabled - value: 'false' - name: REACT_APP_API_HOST description: API host for application backend value: '' @@ -59,6 +56,9 @@ parameters: description: Taxon path for biohub - name: REACT_APP_BIOHUB_TAXON_TSN_PATH description: Taxon TSN path for biohub + - name: REACT_APP_FEATURE_FLAGS + description: Used to identify features that should be temporarily disabled/hidden. Must be a comma delimited list of keywords. + value: '' - name: CPU_REQUEST value: '50m' - name: CPU_LIMIT @@ -145,8 +145,6 @@ objects: name: ${OBJECT_STORE_SECRETS} - name: NODE_ENV value: ${NODE_ENV} - - name: REACT_APP_BIOHUB_FEATURE_FLAG - value: ${REACT_APP_BIOHUB_FEATURE_FLAG} - name: REACT_APP_NODE_ENV value: ${REACT_APP_NODE_ENV} - name: VERSION @@ -163,6 +161,8 @@ objects: value: ${REACT_APP_BIOHUB_TAXON_PATH} - name: REACT_APP_BIOHUB_TAXON_TSN_PATH value: ${REACT_APP_BIOHUB_TAXON_TSN_PATH} + - name: REACT_APP_FEATURE_FLAGS + value: ${REACT_APP_FEATURE_FLAGS} image: ' ' imagePullPolicy: Always ports: diff --git a/app/server/index.js b/app/server/index.js index 6614ddccf0..f02385061b 100644 --- a/app/server/index.js +++ b/app/server/index.js @@ -30,41 +30,59 @@ const request = require('request'); // Express APP const app = express(); // Getting Port - const port = process.env.APP_PORT || 7100; + const port = process.env.APP_PORT; // Resource path const resourcePath = path.resolve(__dirname, '../build'); // Setting express static app.use(express.static(resourcePath)); + /** + * Parses a valid feature flag string into an array of feature flag strings. + * + * @param {string} featureFlagsString + * @return {*} {string[]} + */ + const parseFeatureFlagsString = (featureFlagsString) => { + if (!featureFlagsString) { + return []; + } + + return featureFlagsString.split(','); + }; + // App config app.use('/config', (_, resp) => { - const OBJECT_STORE_URL = process.env.OBJECT_STORE_URL || 'nrs.objectstore.gov.bc.ca'; - const OBJECT_STORE_BUCKET_NAME = process.env.OBJECT_STORE_BUCKET_NAME || 'gblhvt'; + const OBJECT_STORE_URL = process.env.OBJECT_STORE_URL; + const OBJECT_STORE_BUCKET_NAME = process.env.OBJECT_STORE_BUCKET_NAME; const config = { - API_HOST: process.env.REACT_APP_API_HOST || 'localhost', - CHANGE_VERSION: process.env.CHANGE_VERSION || 'NA', - NODE_ENV: process.env.NODE_ENV || 'development', - REACT_APP_NODE_ENV: process.env.REACT_APP_NODE_ENV || 'dev', - VERSION: `${process.env.VERSION || 'NA'}(build #${process.env.CHANGE_VERSION || 'NA'})`, + API_HOST: process.env.REACT_APP_API_HOST, + CHANGE_VERSION: process.env.CHANGE_VERSION, + NODE_ENV: process.env.NODE_ENV, + REACT_APP_NODE_ENV: process.env.REACT_APP_NODE_ENV, + VERSION: `${process.env.VERSION}(build #${process.env.CHANGE_VERSION})`, KEYCLOAK_CONFIG: { authority: process.env.REACT_APP_KEYCLOAK_HOST, realm: process.env.REACT_APP_KEYCLOAK_REALM, clientId: process.env.REACT_APP_KEYCLOAK_CLIENT_ID }, SITEMINDER_LOGOUT_URL: process.env.REACT_APP_SITEMINDER_LOGOUT_URL, - MAX_UPLOAD_NUM_FILES: Number(process.env.REACT_APP_MAX_UPLOAD_NUM_FILES) || 10, - MAX_UPLOAD_FILE_SIZE: Number(process.env.REACT_APP_MAX_UPLOAD_FILE_SIZE) || 52428800, + MAX_UPLOAD_NUM_FILES: Number(process.env.REACT_APP_MAX_UPLOAD_NUM_FILES), + MAX_UPLOAD_FILE_SIZE: Number(process.env.REACT_APP_MAX_UPLOAD_FILE_SIZE), S3_PUBLIC_HOST_URL: `https://${OBJECT_STORE_URL}/${OBJECT_STORE_BUCKET_NAME}`, - BIOHUB_FEATURE_FLAG: process.env.REACT_APP_BIOHUB_FEATURE_FLAG === 'true' + BACKBONE_PUBLIC_API_HOST: process.env.REACT_APP_BACKBONE_PUBLIC_API_HOST, + BIOHUB_TAXON_PATH: process.env.REACT_APP_BIOHUB_TAXON_PATH, + BIOHUB_TAXON_TSN_PATH: process.env.REACT_APP_BIOHUB_TAXON_TSN_PATH, + FEATURE_FLAGS: parseFeatureFlagsString(process.env.REACT_APP_FEATURE_FLAGS) }; + resp.status(200).json(config); }); // Health check app.use('/healthcheck', (_, resp) => { // Request server api - const host = process.env.REACT_APP_API_HOST || process.env.LOCAL_API_HOST || 'localhost'; + const host = process.env.REACT_APP_API_HOST; request(`https://${host}/`, (err, res) => { if (err) { console.log(`Error: ${err}, host: ${host}`); diff --git a/app/src/components/file-upload/DropZone.tsx b/app/src/components/file-upload/DropZone.tsx index dd6f5a612b..953b49c418 100644 --- a/app/src/components/file-upload/DropZone.tsx +++ b/app/src/components/file-upload/DropZone.tsx @@ -3,8 +3,8 @@ import Icon from '@mdi/react'; import Box from '@mui/material/Box'; import Link from '@mui/material/Link'; import Typography from '@mui/material/Typography'; -import { ConfigContext } from 'contexts/configContext'; -import React, { useContext } from 'react'; +import { useConfigContext } from 'hooks/useContext'; +import React from 'react'; import Dropzone, { FileRejection } from 'react-dropzone'; const BYTES_PER_MEGABYTE = 1048576; @@ -57,10 +57,10 @@ export interface IDropZoneConfigProps { } export const DropZone: React.FC = (props) => { - const config = useContext(ConfigContext); + const config = useConfigContext(); - const maxNumFiles = props.maxNumFiles || config?.MAX_UPLOAD_NUM_FILES; - const maxFileSize = props.maxFileSize || config?.MAX_UPLOAD_FILE_SIZE; + const maxNumFiles = props.maxNumFiles || config.MAX_UPLOAD_NUM_FILES; + const maxFileSize = props.maxFileSize || config.MAX_UPLOAD_FILE_SIZE; const multiple = props.multiple ?? true; const acceptedFileExtensions = props.acceptedFileExtensions; diff --git a/app/src/components/security/Guards.tsx b/app/src/components/security/Guards.tsx index 2b73e356a8..1034d829f7 100644 --- a/app/src/components/security/Guards.tsx +++ b/app/src/components/security/Guards.tsx @@ -1,6 +1,7 @@ import { PROJECT_PERMISSION, PROJECT_ROLE, SYSTEM_ROLE } from 'constants/roles'; import { ProjectAuthStateContext } from 'contexts/projectAuthStateContext'; import { useAuthStateContext } from 'hooks/useAuthStateContext'; +import { useConfigContext } from 'hooks/useContext'; import { PropsWithChildren, ReactElement, useContext } from 'react'; import { hasAtLeastOneValidValue } from 'utils/authUtils'; @@ -48,6 +49,16 @@ export interface IProjectRoleGuardProps extends IGuardProps { validProjectPermissions: PROJECT_PERMISSION[]; } +export interface IFeatureFlagGuardProps extends IGuardProps { + /** + * An array of feature flag names. + * + * @type {string[]} + * @memberof IFeatureFlagGuardProps + */ + featureFlags: string[]; +} + /** * Renders `props.children` only if the user is authenticated and has at least 1 of the specified valid system roles. * @@ -63,9 +74,9 @@ export const SystemRoleGuard = (props: PropsWithChildren) if (!hasSystemRole) { if (props.fallback) { return <>{props.fallback}; - } else { - return <>; } + + return <>; } return <>{props.children}; @@ -125,9 +136,9 @@ export const AuthGuard = (props: PropsWithChildren) => { if (!authStateContext.auth.isAuthenticated || authStateContext.simsUserWrapper.isLoading) { if (props.fallback) { return <>{props.fallback}; - } else { - return <>; } + + return <>; } return <>{props.children}; @@ -145,10 +156,39 @@ export const UnAuthGuard = (props: PropsWithChildren) => { if (authStateContext.auth.isAuthenticated) { if (props.fallback) { return <>{props.fallback}; - } else { - return <>; } + + return <>; + } + + return <>{props.children}; +}; + +/** + * Renders `props.children` only if all specified feature flags do not exist. + * + * If at least one feature flag exists, the fallback will be rendered, or nothing if no fallback is provided. + * + * Feature flags are used to disable child components. to enabled a child component, simply remove all associated + * feature flags from the config (via the `REACT_APP_FEATURE_FLAGS` env var). + * + * @param {*} props + * @return {*} + */ +export const FeatureFlagGuard = (props: PropsWithChildren) => { + const config = useConfigContext(); + + const matchingFeatureFlags = config.FEATURE_FLAGS.filter((featureFlag) => props.featureFlags.includes(featureFlag)); + + if (matchingFeatureFlags.length) { + // Found at least one matching feature flag, render the fallback or nothing if no fallback is provided. + if (props.fallback) { + return <>{props.fallback}; + } + + return <>; } + // No matching feature flags found, render the children. return <>{props.children}; }; diff --git a/app/src/components/security/RouteGuards.test.tsx b/app/src/components/security/RouteGuards.test.tsx index 4e345c9b17..b636fa3c3d 100644 --- a/app/src/components/security/RouteGuards.test.tsx +++ b/app/src/components/security/RouteGuards.test.tsx @@ -1,3 +1,4 @@ +import { FeatureFlagGuard } from 'components/security/Guards'; import { AuthenticatedRouteGuard, SystemRoleRouteGuard, @@ -5,6 +6,7 @@ import { } from 'components/security/RouteGuards'; import { SYSTEM_ROLE } from 'constants/roles'; import { AuthStateContext } from 'contexts/authStateContext'; +import { ConfigContext, IConfig } from 'contexts/configContext'; import { createMemoryHistory } from 'history'; import { AuthContextProps } from 'react-oidc-context'; import { Router } from 'react-router'; @@ -682,4 +684,81 @@ describe('RouteGuards', () => { }); }); }); + + describe('FeatureFlagGuard', () => { + describe('feature flag guard specifies no flags', () => { + afterAll(() => { + cleanup(); + }); + + it('renders the child component', async () => { + const { getByTestId } = render( + + }> + + + + ); + + await waitFor(() => { + expect(getByTestId('success-component')).toBeVisible(); + }); + }); + }); + + describe('feature flag guard specifies no matching flags', () => { + afterAll(() => { + cleanup(); + }); + + it('renders the child component', async () => { + const { getByTestId } = render( + + }> + + + + ); + + await waitFor(() => { + expect(getByTestId('success-component')).toBeVisible(); + }); + }); + }); + + describe('feature flag guard specifies a matching flag', () => { + afterAll(() => { + cleanup(); + }); + + it('renders the fallback component', async () => { + const { getByTestId } = render( + + }> + + + + ); + + await waitFor(() => { + expect(getByTestId('success-component')).toBeVisible(); + }); + }); + }); + }); }); diff --git a/app/src/contexts/configContext.tsx b/app/src/contexts/configContext.tsx index 64f4f56ad3..f4e0f003fe 100644 --- a/app/src/contexts/configContext.tsx +++ b/app/src/contexts/configContext.tsx @@ -17,36 +17,40 @@ export interface IConfig { MAX_UPLOAD_NUM_FILES: number; MAX_UPLOAD_FILE_SIZE: number; S3_PUBLIC_HOST_URL: string; - BIOHUB_FEATURE_FLAG: boolean; BACKBONE_PUBLIC_API_HOST: string; BIOHUB_TAXON_PATH: string; BIOHUB_TAXON_TSN_PATH: string; + /** + * Used in conjunction with the feature flag guard (FeatureFlagGuard) to disable components. + * + * @type {string[]} + * @memberof IConfig + */ + FEATURE_FLAGS: string[]; } -export const ConfigContext = React.createContext({ - API_HOST: '', - CHANGE_VERSION: '', - NODE_ENV: '', - REACT_APP_NODE_ENV: '', - VERSION: '', - KEYCLOAK_CONFIG: { - authority: '', - realm: '', - clientId: '' - }, - SITEMINDER_LOGOUT_URL: '', - MAX_UPLOAD_NUM_FILES: 10, - MAX_UPLOAD_FILE_SIZE: 52428800, - S3_PUBLIC_HOST_URL: '', - BIOHUB_FEATURE_FLAG: false, - BACKBONE_PUBLIC_API_HOST: '', - BIOHUB_TAXON_PATH: '', - BIOHUB_TAXON_TSN_PATH: '' -}); +export const ConfigContext = React.createContext(undefined); + +/** + * Parses a valid feature flag string into an array of feature flag strings. + * + * @param {string} featureFlagsString + * @return {*} {string[]} + */ +const parseFeatureFlagsString = (featureFlagsString: string): string[] => { + if (!featureFlagsString) { + return []; + } + + return featureFlagsString.split(','); +}; /** * Return the app config based on locally set environment variables. * + * IMPORTANT: Any changes made to this file must also be made in `app/server/index.js` to ensure the same config is + * provided to the client when running locally and in OpenShift. + * * @return {*} {IConfig} */ const getLocalConfig = (): IConfig => { @@ -72,10 +76,10 @@ const getLocalConfig = (): IConfig => { MAX_UPLOAD_NUM_FILES: Number(process.env.REACT_APP_MAX_UPLOAD_NUM_FILES) || 10, MAX_UPLOAD_FILE_SIZE: Number(process.env.REACT_APP_MAX_UPLOAD_FILE_SIZE) || 52428800, S3_PUBLIC_HOST_URL: ensureProtocol(`${OBJECT_STORE_URL}/${OBJECT_STORE_BUCKET_NAME}`, 'https://'), - BIOHUB_FEATURE_FLAG: process.env.REACT_APP_BIOHUB_FEATURE_FLAG === 'true', BACKBONE_PUBLIC_API_HOST: process.env.REACT_APP_BACKBONE_PUBLIC_API_HOST || '', BIOHUB_TAXON_PATH: process.env.REACT_APP_BIOHUB_TAXON_PATH || '', - BIOHUB_TAXON_TSN_PATH: process.env.REACT_APP_BIOHUB_TAXON_TSN_PATH || '' + BIOHUB_TAXON_TSN_PATH: process.env.REACT_APP_BIOHUB_TAXON_TSN_PATH || '', + FEATURE_FLAGS: parseFeatureFlagsString(process.env.REACT_APP_FEATURE_FLAGS || '') }; }; diff --git a/app/src/features/surveys/view/SurveyHeader.tsx b/app/src/features/surveys/view/SurveyHeader.tsx index 84c396d7ef..cfd404166c 100644 --- a/app/src/features/surveys/view/SurveyHeader.tsx +++ b/app/src/features/surveys/view/SurveyHeader.tsx @@ -12,11 +12,10 @@ import Typography from '@mui/material/Typography'; import { IErrorDialogProps } from 'components/dialog/ErrorDialog'; import PageHeader from 'components/layout/PageHeader'; import PublishSurveyIdDialog from 'components/publish/PublishSurveyDialog'; -import { ProjectRoleGuard } from 'components/security/Guards'; +import { FeatureFlagGuard, ProjectRoleGuard } from 'components/security/Guards'; import { DATE_FORMAT } from 'constants/dateTimeFormats'; import { DeleteSurveyI18N } from 'constants/i18n'; import { PROJECT_PERMISSION, SYSTEM_ROLE } from 'constants/roles'; -import { ConfigContext } from 'contexts/configContext'; import { DialogContext } from 'contexts/dialogContext'; import { ProjectContext } from 'contexts/projectContext'; import { SurveyContext } from 'contexts/surveyContext'; @@ -35,7 +34,6 @@ import { getFormattedDateRangeString } from 'utils/Utils'; const SurveyHeader = () => { const surveyContext = useContext(SurveyContext); const projectContext = useContext(ProjectContext); - const configContext = useContext(ConfigContext); const surveyWithDetails = surveyContext.surveyDataLoader.data; const projectWithDetails = projectContext.projectDataLoader.data; @@ -121,8 +119,6 @@ const SurveyHeader = () => { const publishDate = surveyWithDetails.surveySupplementaryData.survey_metadata_publish?.event_timestamp.split(' ')[0]; - const BIOHUB_FEATURE_FLAG = configContext?.BIOHUB_FEATURE_FLAG; - return ( <> { validProjectPermissions={[PROJECT_PERMISSION.COORDINATOR, PROJECT_PERMISSION.COLLABORATOR]} validSystemRoles={[SYSTEM_ROLE.SYSTEM_ADMIN, SYSTEM_ROLE.DATA_ADMINISTRATOR]}> - {BIOHUB_FEATURE_FLAG && ( + @@ -195,7 +191,7 @@ const SurveyHeader = () => { Publish - )} +