From 1064dfa40c9c2bb62e79395a77c5475d37ff30e4 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Thu, 16 Mar 2023 15:29:52 +0100 Subject: [PATCH] feat: project mode (#3334) --- .../Project/CreateProject/CreateProject.tsx | 4 ++ .../Project/EditProject/EditProject.tsx | 7 +- .../Project/ProjectForm/ProjectForm.tsx | 39 ++++++++-- .../project/Project/hooks/useProjectForm.ts | 11 ++- .../api/getters/useProject/useProject.ts | 1 + frontend/src/interfaces/project.ts | 1 + frontend/src/interfaces/uiConfig.ts | 1 + src/lib/db/project-store.ts | 38 ++++++++-- .../export-import-permissions.e2e.test.ts | 1 + .../export-import.e2e.test.ts | 1 + .../openapi/spec/health-overview-schema.ts | 8 +++ .../openapi/spec/project-overview-schema.ts | 7 ++ src/lib/openapi/spec/project-schema.ts | 3 +- src/lib/services/project-schema.ts | 1 + src/lib/services/project-service.ts | 3 +- src/lib/services/state-service.test.ts | 8 ++- src/lib/types/model.ts | 4 +- src/lib/types/stores/project-store.ts | 8 ++- src/test/e2e/api/admin/archive.test.ts | 2 + .../e2e/api/admin/instance-admin.e2e.test.ts | 1 + .../api/admin/project/features.e2e.test.ts | 12 +++- .../api/admin/project/projects.e2e.test.ts | 7 +- src/test/e2e/api/admin/state.e2e.test.ts | 4 ++ src/test/e2e/api/client/feature.e2e.test.ts | 1 + .../client/feature.token.access.e2e.test.ts | 1 + .../__snapshots__/openapi.e2e.test.ts.snap | 32 ++++++++- src/test/e2e/api/proxy/proxy.e2e.test.ts | 13 ++-- .../services/api-token-service.e2e.test.ts | 5 +- .../e2e/services/project-service.e2e.test.ts | 72 +++++++++++++++++-- src/test/e2e/stores/project-store.e2e.test.ts | 17 ++++- 30 files changed, 272 insertions(+), 41 deletions(-) diff --git a/frontend/src/component/project/Project/CreateProject/CreateProject.tsx b/frontend/src/component/project/Project/CreateProject/CreateProject.tsx index 97e62250db..9ade2f889b 100644 --- a/frontend/src/component/project/Project/CreateProject/CreateProject.tsx +++ b/frontend/src/component/project/Project/CreateProject/CreateProject.tsx @@ -19,6 +19,7 @@ const CreateProject = () => { const { projectId, projectName, + projectMode, projectDesc, setProjectId, setProjectName, @@ -28,6 +29,7 @@ const CreateProject = () => { validateProjectId, validateName, setProjectStickiness, + setProjectMode, projectStickiness, errors, } = useProjectForm(); @@ -92,8 +94,10 @@ const CreateProject = () => { projectId={projectId} setProjectId={setProjectId} projectName={projectName} + projectMode={projectMode} projectStickiness={projectStickiness} setProjectStickiness={setProjectStickiness} + setProjectMode={setProjectMode} setProjectName={setProjectName} projectDesc={projectDesc} setProjectDesc={setProjectDesc} diff --git a/frontend/src/component/project/Project/EditProject/EditProject.tsx b/frontend/src/component/project/Project/EditProject/EditProject.tsx index 7326707a87..dcbc652c56 100644 --- a/frontend/src/component/project/Project/EditProject/EditProject.tsx +++ b/frontend/src/component/project/Project/EditProject/EditProject.tsx @@ -31,10 +31,12 @@ const EditProject = () => { projectName, projectDesc, projectStickiness, + projectMode, setProjectId, setProjectName, setProjectDesc, setProjectStickiness, + setProjectMode, getProjectPayload, clearErrors, validateProjectId, @@ -44,7 +46,8 @@ const EditProject = () => { id, project.name, project.description, - defaultStickiness + defaultStickiness, + project.mode ); const formatApiCode = () => { @@ -111,9 +114,11 @@ const EditProject = () => { projectId={projectId} setProjectId={setProjectId} projectName={projectName} + projectMode={projectMode} setProjectName={setProjectName} projectStickiness={projectStickiness} setProjectStickiness={setProjectStickiness} + setProjectMode={setProjectMode} projectDesc={projectDesc} setProjectDesc={setProjectDesc} mode="Edit" diff --git a/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx b/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx index aeb766d6ed..855ab074b0 100644 --- a/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx +++ b/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx @@ -1,23 +1,27 @@ import React from 'react'; import { trim } from 'component/common/util'; import { - StyledForm, + StyledButton, + StyledButtonContainer, StyledContainer, StyledDescription, + StyledForm, StyledInput, StyledTextField, - StyledButtonContainer, - StyledButton, } from './ProjectForm.styles'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { StickinessSelect } from 'component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import Select from 'component/common/select'; + interface IProjectForm { projectId: string; projectName: string; projectDesc: string; projectStickiness?: string; + projectMode?: string; setProjectStickiness?: React.Dispatch>; + setProjectMode?: React.Dispatch>; setProjectId: React.Dispatch>; setProjectName: React.Dispatch>; setProjectDesc: React.Dispatch>; @@ -37,17 +41,20 @@ const ProjectForm: React.FC = ({ projectName, projectDesc, projectStickiness, + projectMode, setProjectId, setProjectName, setProjectDesc, setProjectStickiness, + setProjectMode, errors, mode, validateProjectId, clearErrors, }) => { const { uiConfig } = useUiConfig(); - const { projectScopedStickiness } = uiConfig.flags; + const { projectScopedStickiness, projectMode: projectModeFlag } = + uiConfig.flags; return ( @@ -113,6 +120,30 @@ const ProjectForm: React.FC = ({ } /> + + + What is your project mode? + + + + } + /> diff --git a/frontend/src/component/project/Project/hooks/useProjectForm.ts b/frontend/src/component/project/Project/hooks/useProjectForm.ts index 6a5d7262aa..0b0428d62f 100644 --- a/frontend/src/component/project/Project/hooks/useProjectForm.ts +++ b/frontend/src/component/project/Project/hooks/useProjectForm.ts @@ -7,7 +7,8 @@ const useProjectForm = ( initialProjectId = '', initialProjectName = '', initialProjectDesc = '', - initialProjectStickiness = 'default' + initialProjectStickiness = 'default', + initialProjectMode = 'open' ) => { const [projectId, setProjectId] = useState(initialProjectId); const { defaultStickiness } = useDefaultProjectSettings(projectId); @@ -17,6 +18,7 @@ const useProjectForm = ( const [projectStickiness, setProjectStickiness] = useState( defaultStickiness || initialProjectStickiness ); + const [projectMode, setProjectMode] = useState(initialProjectMode); const [errors, setErrors] = useState({}); const { validateId } = useProjectApi(); @@ -33,12 +35,17 @@ const useProjectForm = ( setProjectDesc(initialProjectDesc); }, [initialProjectDesc]); + useEffect(() => { + setProjectMode(initialProjectMode); + }, [initialProjectMode]); + const getProjectPayload = () => { return { id: projectId, name: projectName, description: projectDesc, projectStickiness, + mode: projectMode, }; }; @@ -74,10 +81,12 @@ const useProjectForm = ( projectName, projectDesc, projectStickiness, + projectMode, setProjectId, setProjectName, setProjectDesc, setProjectStickiness, + setProjectMode, getProjectPayload, validateName, validateProjectId, diff --git a/frontend/src/hooks/api/getters/useProject/useProject.ts b/frontend/src/hooks/api/getters/useProject/useProject.ts index c188752f49..e02b9852d1 100644 --- a/frontend/src/hooks/api/getters/useProject/useProject.ts +++ b/frontend/src/hooks/api/getters/useProject/useProject.ts @@ -12,6 +12,7 @@ const fallbackProject: IProject = { version: '1', description: 'Default', favorite: false, + mode: 'open', stats: { archivedCurrentWindow: 0, archivedPastWindow: 0, diff --git a/frontend/src/interfaces/project.ts b/frontend/src/interfaces/project.ts index c126a6c9d2..e0442fa8fa 100644 --- a/frontend/src/interfaces/project.ts +++ b/frontend/src/interfaces/project.ts @@ -23,6 +23,7 @@ export interface IProject { stats: ProjectStatsSchema; favorite: boolean; features: IFeatureToggleListItem[]; + mode: 'open' | 'protected'; } export interface IProjectHealthReport extends IProject { diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index 1ba9a11bf5..946c87ebb0 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -51,6 +51,7 @@ export interface IFlags { bulkOperations?: boolean; projectScopedSegments?: boolean; projectScopedStickiness?: boolean; + projectMode?: boolean; } export interface IVersionInfo { diff --git a/src/lib/db/project-store.ts b/src/lib/db/project-store.ts index 962bc91386..a85bacccae 100644 --- a/src/lib/db/project-store.ts +++ b/src/lib/db/project-store.ts @@ -2,7 +2,12 @@ import { Knex } from 'knex'; import { Logger, LogProvider } from '../logger'; import NotFoundError from '../error/notfound-error'; -import { IEnvironment, IProject, IProjectWithCount } from '../types/model'; +import { + IEnvironment, + IProject, + IProjectWithCount, + ProjectMode, +} from '../types/model'; import { IProjectHealthUpdate, IProjectInsert, @@ -26,6 +31,8 @@ const COLUMNS = [ 'updated_at', ]; const TABLE = 'projects'; +const SETTINGS_COLUMNS = ['project_mode']; +const SETTINGS_TABLE = 'project_settings'; export interface IEnvironmentProjectLink { environmentName: string; @@ -63,7 +70,7 @@ class ProjectStore implements IProjectStore { } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types - fieldToRow(data): IProjectInsert { + fieldToRow(data): Omit { return { id: data.id, name: data.name, @@ -168,8 +175,13 @@ class ProjectStore implements IProjectStore { async get(id: string): Promise { return this.db - .first(COLUMNS) + .first([...COLUMNS, ...SETTINGS_COLUMNS]) .from(TABLE) + .leftJoin( + SETTINGS_TABLE, + `${SETTINGS_TABLE}.project`, + `${TABLE}.id`, + ) .where({ id }) .then(this.mapRow); } @@ -189,11 +201,19 @@ class ProjectStore implements IProjectStore { .update({ health: healthUpdate.health, updated_at: new Date() }); } - async create(project: IProjectInsert): Promise { + async create( + project: IProjectInsert & { mode: ProjectMode }, + ): Promise { const row = await this.db(TABLE) .insert(this.fieldToRow(project)) .returning('*'); - return this.mapRow(row[0]); + const settingsRow = await this.db(SETTINGS_TABLE) + .insert({ + project: project.id, + project_mode: project.mode, + }) + .returning('*'); + return this.mapRow({ ...row[0], ...settingsRow[0] }); } // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types @@ -202,6 +222,12 @@ class ProjectStore implements IProjectStore { await this.db(TABLE) .where({ id: data.id }) .update(this.fieldToRow(data)); + await this.db(SETTINGS_TABLE) + .where({ project: data.id }) + .update({ + project_mode: data.mode, + }) + .returning('*'); } catch (err) { this.logger.error('Could not update project, error: ', err); } @@ -460,7 +486,7 @@ class ProjectStore implements IProjectStore { createdAt: row.created_at, health: row.health ?? 100, updatedAt: row.updated_at || new Date(), - mode: 'open', + mode: row.project_mode || 'open', }; } } diff --git a/src/lib/features/export-import-toggles/export-import-permissions.e2e.test.ts b/src/lib/features/export-import-toggles/export-import-permissions.e2e.test.ts index 6cf1386b90..cbd114f258 100644 --- a/src/lib/features/export-import-toggles/export-import-permissions.e2e.test.ts +++ b/src/lib/features/export-import-toggles/export-import-permissions.e2e.test.ts @@ -87,6 +87,7 @@ const createProject = async () => { name: DEFAULT_PROJECT, description: '', id: DEFAULT_PROJECT, + mode: 'open' as const, }); }; diff --git a/src/lib/features/export-import-toggles/export-import.e2e.test.ts b/src/lib/features/export-import-toggles/export-import.e2e.test.ts index bef7fe74dd..324aece5d0 100644 --- a/src/lib/features/export-import-toggles/export-import.e2e.test.ts +++ b/src/lib/features/export-import-toggles/export-import.e2e.test.ts @@ -109,6 +109,7 @@ const createProject = async () => { name: DEFAULT_PROJECT, description: '', id: DEFAULT_PROJECT, + mode: 'open' as const, }); await app.request .post(`/api/admin/projects/${DEFAULT_PROJECT}/environments`) diff --git a/src/lib/openapi/spec/health-overview-schema.ts b/src/lib/openapi/spec/health-overview-schema.ts index 460e8fc743..18493549cd 100644 --- a/src/lib/openapi/spec/health-overview-schema.ts +++ b/src/lib/openapi/spec/health-overview-schema.ts @@ -25,6 +25,14 @@ export const healthOverviewSchema = { type: 'string', nullable: true, }, + mode: { + type: 'string', + enum: ['open', 'protected'], + example: 'open', + nullable: true, + description: + 'A mode of the project affecting what actions are possible in this project. During a rollout of project modes this feature can be optional or `null`', + }, members: { type: 'number', }, diff --git a/src/lib/openapi/spec/project-overview-schema.ts b/src/lib/openapi/spec/project-overview-schema.ts index 787dbe9a5d..9422107972 100644 --- a/src/lib/openapi/spec/project-overview-schema.ts +++ b/src/lib/openapi/spec/project-overview-schema.ts @@ -36,6 +36,13 @@ export const projectOverviewSchema = { example: 'DX squad feature release', description: 'Additional information about the project', }, + mode: { + type: 'string', + enum: ['open', 'protected'], + example: 'open', + description: + 'A mode of the project affecting what actions are possible in this project', + }, members: { type: 'number', example: 4, diff --git a/src/lib/openapi/spec/project-schema.ts b/src/lib/openapi/spec/project-schema.ts index 2e85f018d4..c847dba378 100644 --- a/src/lib/openapi/spec/project-schema.ts +++ b/src/lib/openapi/spec/project-schema.ts @@ -59,9 +59,8 @@ export const projectSchema = { type: 'string', enum: ['open', 'protected'], example: 'open', - nullable: true, description: - 'A mode of the project affecting what actions are possible in this project. During a rollout of project modes this feature can be optional or `null`', + 'A mode of the project affecting what actions are possible in this project', }, }, components: {}, diff --git a/src/lib/services/project-schema.ts b/src/lib/services/project-schema.ts index d7e5f7faec..01e779328c 100644 --- a/src/lib/services/project-schema.ts +++ b/src/lib/services/project-schema.ts @@ -7,5 +7,6 @@ export const projectSchema = joi id: nameType, name: joi.string().required(), description: joi.string().allow(null).allow('').optional(), + mode: joi.string().valid('open', 'protected').default('open'), }) .options({ allowUnknown: false, stripUnknown: true }); diff --git a/src/lib/services/project-service.ts b/src/lib/services/project-service.ts index 7596d4be22..7ddd9ebb31 100644 --- a/src/lib/services/project-service.ts +++ b/src/lib/services/project-service.ts @@ -166,7 +166,7 @@ export default class ProjectService { } async createProject( - newProject: Pick, + newProject: Pick, user: IUser, ): Promise { const data = await projectSchema.validateAsync(newProject); @@ -853,6 +853,7 @@ export default class ProjectService { stats: projectStats, name: project.name, description: project.description, + mode: project.mode, health: project.health || 0, favorite: favorite, updatedAt: project.updatedAt, diff --git a/src/lib/services/state-service.test.ts b/src/lib/services/state-service.test.ts index ff122c65eb..f0369696f0 100644 --- a/src/lib/services/state-service.test.ts +++ b/src/lib/services/state-service.test.ts @@ -518,6 +518,7 @@ test('Should not import an existing project', async () => { id: 'default', name: 'default', description: 'Some fancy description for project', + mode: 'open' as const, }, ], }; @@ -546,6 +547,7 @@ test('Should drop projects before import if specified', async () => { id: 'fancy', name: 'extra', description: 'Not expected to be seen after import', + mode: 'open' as const, }); await stateService.import({ data, dropBeforeImport: true }); const hasProject = await stores.projectStore.hasProject('fancy'); @@ -558,6 +560,7 @@ test('Should export projects', async () => { id: 'fancy', name: 'extra', description: 'No surprises here', + mode: 'open' as const, }); const exported = await stateService.export({ includeFeatureToggles: false, @@ -579,6 +582,7 @@ test('exporting to new format works', async () => { id: 'fancy', name: 'extra', description: 'No surprises here', + mode: 'open' as const, }); await stores.environmentStore.create({ name: 'dev', @@ -622,7 +626,7 @@ test('exporting variants to v4 format should not include variants in features', exported.featureEnvironments.forEach((fe) => { expect(fe.variants).toHaveLength(1); - expect(fe.variants[0].name).toBe(`${fe.environment}-variant`); + expect(fe.variants?.[0].name).toBe(`${fe.environment}-variant`); }); expect(exported.environments).toHaveLength(3); }); @@ -633,6 +637,7 @@ test('featureStrategies can keep existing', async () => { id: 'fancy', name: 'extra', description: 'No surprises here', + mode: 'open' as const, }); await stores.environmentStore.create({ name: 'dev', @@ -679,6 +684,7 @@ test('featureStrategies should not keep existing if dropBeforeImport', async () id: 'fancy', name: 'extra', description: 'No surprises here', + mode: 'open' as const, }); await stores.environmentStore.create({ name: 'dev', diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts index 8cb90abe81..0fca231e12 100644 --- a/src/lib/types/model.ts +++ b/src/lib/types/model.ts @@ -173,6 +173,8 @@ export interface IFeatureOverview { environments: IEnvironmentOverview[]; } +export type ProjectMode = 'open' | 'protected'; + export interface IProjectOverview { name: string; description: string; @@ -184,6 +186,7 @@ export interface IProjectOverview { favorite?: boolean; updatedAt?: Date; stats?: IProjectStats; + mode: ProjectMode; } export interface IProjectHealthReport extends IProjectOverview { @@ -368,7 +371,6 @@ export interface IProject { changeRequestsEnabled?: boolean; mode: ProjectMode; } -export type ProjectMode = 'open' | 'protected'; export interface ICustomRole { id: number; diff --git a/src/lib/types/stores/project-store.ts b/src/lib/types/stores/project-store.ts index 09f3fac258..958b05d97b 100644 --- a/src/lib/types/stores/project-store.ts +++ b/src/lib/types/stores/project-store.ts @@ -2,7 +2,12 @@ import { IEnvironmentProjectLink, IProjectMembersCount, } from '../../db/project-store'; -import { IEnvironment, IProject, IProjectWithCount } from '../model'; +import { + IEnvironment, + IProject, + IProjectWithCount, + ProjectMode, +} from '../model'; import { Store } from './store'; export interface IProjectInsert { @@ -11,6 +16,7 @@ export interface IProjectInsert { description: string; updatedAt?: Date; changeRequestsEnabled?: boolean; + mode: ProjectMode; } export interface IProjectArchived { diff --git a/src/test/e2e/api/admin/archive.test.ts b/src/test/e2e/api/admin/archive.test.ts index 80f20a8504..653ecfa40a 100644 --- a/src/test/e2e/api/admin/archive.test.ts +++ b/src/test/e2e/api/admin/archive.test.ts @@ -75,11 +75,13 @@ test('Should get archived toggles via project', async () => { id: 'proj-1', name: 'proj-1', description: '', + mode: 'open' as const, }); await db.stores.projectStore.create({ id: 'proj-2', name: 'proj-2', description: '', + mode: 'open' as const, }); await db.stores.featureToggleStore.create('proj-1', { diff --git a/src/test/e2e/api/admin/instance-admin.e2e.test.ts b/src/test/e2e/api/admin/instance-admin.e2e.test.ts index a7ec5ff65d..1efa36469a 100644 --- a/src/test/e2e/api/admin/instance-admin.e2e.test.ts +++ b/src/test/e2e/api/admin/instance-admin.e2e.test.ts @@ -36,6 +36,7 @@ test('should return instance statistics with correct number of projects', async id: 'test', name: 'Test', description: 'lorem', + mode: 'open' as const, }); return app.request diff --git a/src/test/e2e/api/admin/project/features.e2e.test.ts b/src/test/e2e/api/admin/project/features.e2e.test.ts index eb8dfb03bd..1ad98fa28c 100644 --- a/src/test/e2e/api/admin/project/features.e2e.test.ts +++ b/src/test/e2e/api/admin/project/features.e2e.test.ts @@ -544,7 +544,7 @@ describe('Interacting with features using project IDs that belong to other proje rootRole: RoleName.ADMIN, }); await app.services.projectService.createProject( - { name: otherProject, id: otherProject }, + { name: otherProject, id: otherProject, mode: 'open' }, dummyAdmin, ); @@ -701,8 +701,8 @@ test('Should patch feature toggle', async () => { }); const updateForOurToggle = events.find((e) => e.data.name === name); expect(updateForOurToggle).toBeTruthy(); - expect(updateForOurToggle.data.description).toBe('New desc'); - expect(updateForOurToggle.data.type).toBe('kill-switch'); + expect(updateForOurToggle?.data.description).toBe('New desc'); + expect(updateForOurToggle?.data.type).toBe('kill-switch'); }); test('Should patch feature toggle and not remove variants', async () => { @@ -1956,6 +1956,7 @@ test('Should not allow changing project to target project without the same enabl name: 'Project to be moved to', id: targetProject, description: '', + mode: 'open', }); await db.stores.environmentStore.create({ @@ -2042,6 +2043,7 @@ test('Should allow changing project to target project with the same enabled envi name: 'Project to be moved to', id: targetProject, description: '', + mode: 'open', }); await db.stores.environmentStore.create({ @@ -2284,6 +2286,7 @@ test('Can create toggle with impression data on different project', async () => id: 'impression-data', name: 'ImpressionData', description: '', + mode: 'open', }); const toggle = { @@ -2313,6 +2316,7 @@ test('should reject invalid constraint values for multi-valued constraints', asy id: uuidv4(), name: uuidv4(), description: '', + mode: 'open', }); const toggle = await db.stores.featureToggleStore.create(project.id, { @@ -2359,6 +2363,7 @@ test('should add default constraint values for single-valued constraints', async id: uuidv4(), name: uuidv4(), description: '', + mode: 'open', }); const toggle = await db.stores.featureToggleStore.create(project.id, { @@ -2418,6 +2423,7 @@ test('should allow long parameter values', async () => { id: uuidv4(), name: uuidv4(), description: uuidv4(), + mode: 'open', }); const toggle = await db.stores.featureToggleStore.create(project.id, { diff --git a/src/test/e2e/api/admin/project/projects.e2e.test.ts b/src/test/e2e/api/admin/project/projects.e2e.test.ts index 3d17505065..f41353ffb4 100644 --- a/src/test/e2e/api/admin/project/projects.e2e.test.ts +++ b/src/test/e2e/api/admin/project/projects.e2e.test.ts @@ -26,7 +26,12 @@ afterAll(async () => { }); test('Should ONLY return default project', async () => { - projectStore.create({ id: 'test2', name: 'test', description: '' }); + projectStore.create({ + id: 'test2', + name: 'test', + description: '', + mode: 'open', + }); const { body } = await app.request .get('/api/admin/projects') diff --git a/src/test/e2e/api/admin/state.e2e.test.ts b/src/test/e2e/api/admin/state.e2e.test.ts index 5e93de7f3e..d51a39b615 100644 --- a/src/test/e2e/api/admin/state.e2e.test.ts +++ b/src/test/e2e/api/admin/state.e2e.test.ts @@ -154,6 +154,7 @@ test('Can roundtrip. I.e. export and then import', async () => { name: projectId, id: projectId, description: 'Project for export', + mode: 'open' as const, }); await app.services.environmentService.addEnvironmentToProject( environment, @@ -201,6 +202,7 @@ test('Roundtrip with tags works', async () => { name: projectId, id: projectId, description: 'Project for export', + mode: 'open' as const, }); await app.services.environmentService.addEnvironmentToProject( environment, @@ -265,6 +267,7 @@ test('Roundtrip with strategies in multiple environments works', async () => { name: projectId, id: projectId, description: 'Project for export', + mode: 'open' as const, }); await app.services.featureToggleServiceV2.createFeatureToggle( @@ -364,6 +367,7 @@ test(`should not delete api_tokens on import when drop-flag is set`, async () => name: projectId, id: projectId, description: 'Project for export', + mode: 'open' as const, }); await app.services.environmentService.addEnvironmentToProject( environment, diff --git a/src/test/e2e/api/client/feature.e2e.test.ts b/src/test/e2e/api/client/feature.e2e.test.ts index 5999aed418..8cc3fd11cc 100644 --- a/src/test/e2e/api/client/feature.e2e.test.ts +++ b/src/test/e2e/api/client/feature.e2e.test.ts @@ -289,6 +289,7 @@ test('returns a feature toggles impression data for a different project', async id: 'impression-data-client', name: 'ImpressionData', description: '', + mode: 'open' as const, }; await db.stores.projectStore.create(project); diff --git a/src/test/e2e/api/client/feature.token.access.e2e.test.ts b/src/test/e2e/api/client/feature.token.access.e2e.test.ts index 29097cc92c..c879847d22 100644 --- a/src/test/e2e/api/client/feature.token.access.e2e.test.ts +++ b/src/test/e2e/api/client/feature.token.access.e2e.test.ts @@ -35,6 +35,7 @@ beforeAll(async () => { id: project2, name: 'Test Project 2', description: '', + mode: 'open' as const, }); await environmentService.addEnvironmentToProject(environment, project); diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index 26b71750b5..17c6ccb394 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -1859,6 +1859,16 @@ exports[`should serve the OpenAPI spec 1`] = ` "members": { "type": "number", }, + "mode": { + "description": "A mode of the project affecting what actions are possible in this project. During a rollout of project modes this feature can be optional or \`null\`", + "enum": [ + "open", + "protected", + ], + "example": "open", + "nullable": true, + "type": "string", + }, "name": { "type": "string", }, @@ -1912,6 +1922,16 @@ exports[`should serve the OpenAPI spec 1`] = ` "members": { "type": "number", }, + "mode": { + "description": "A mode of the project affecting what actions are possible in this project. During a rollout of project modes this feature can be optional or \`null\`", + "enum": [ + "open", + "protected", + ], + "example": "open", + "nullable": true, + "type": "string", + }, "name": { "type": "string", }, @@ -2771,6 +2791,15 @@ exports[`should serve the OpenAPI spec 1`] = ` "example": 4, "type": "number", }, + "mode": { + "description": "A mode of the project affecting what actions are possible in this project", + "enum": [ + "open", + "protected", + ], + "example": "open", + "type": "string", + }, "name": { "description": "The name of this project", "example": "dx-squad", @@ -2837,13 +2866,12 @@ exports[`should serve the OpenAPI spec 1`] = ` "type": "number", }, "mode": { - "description": "A mode of the project affecting what actions are possible in this project. During a rollout of project modes this feature can be optional or \`null\`", + "description": "A mode of the project affecting what actions are possible in this project", "enum": [ "open", "protected", ], "example": "open", - "nullable": true, "type": "string", }, "name": { diff --git a/src/test/e2e/api/proxy/proxy.e2e.test.ts b/src/test/e2e/api/proxy/proxy.e2e.test.ts index 6cae5d1f65..e3e7cf7f4d 100644 --- a/src/test/e2e/api/proxy/proxy.e2e.test.ts +++ b/src/test/e2e/api/proxy/proxy.e2e.test.ts @@ -100,7 +100,10 @@ const createProject = async (id: string, name: string): Promise => { name: randomId(), email: `${randomId()}@example.com`, }); - await app.services.projectService.createProject({ id, name }, user); + await app.services.projectService.createProject( + { id, name, mode: 'open' }, + user, + ); }; test('should require a frontend token or an admin token', async () => { @@ -188,14 +191,14 @@ test('should allow requests with a token secret alias', async () => { .expect((res) => expect(res.body.toggles[0].name).toEqual(featureB)); await app.request .get('/api/frontend') - .set('Authorization', tokenA.alias) + .set('Authorization', tokenA.alias!) .expect('Content-Type', /json/) .expect(200) .expect((res) => expect(res.body.toggles).toHaveLength(1)) .expect((res) => expect(res.body.toggles[0].name).toEqual(featureA)); await app.request .get('/api/frontend') - .set('Authorization', tokenB.alias) + .set('Authorization', tokenB.alias!) .expect('Content-Type', /json/) .expect(200) .expect((res) => expect(res.body.toggles).toHaveLength(1)) @@ -952,7 +955,7 @@ test('should terminate data polling when stop is called', async () => { frontendToken.secret, ); - const logTrap = []; + const logTrap: any[] = []; const getDebugLogger = (): Logger => { return { /* eslint-disable-next-line */ @@ -978,7 +981,7 @@ test('should terminate data polling when stop is called', async () => { }, db.stores, app.services, - user, + user!, ); await proxyRepository.start(); diff --git a/src/test/e2e/services/api-token-service.e2e.test.ts b/src/test/e2e/services/api-token-service.e2e.test.ts index e7702f60ff..edfcfb96b7 100644 --- a/src/test/e2e/services/api-token-service.e2e.test.ts +++ b/src/test/e2e/services/api-token-service.e2e.test.ts @@ -36,6 +36,7 @@ beforeAll(async () => { id: 'test-project', name: 'Test Project', description: 'Fancy', + mode: 'open' as const, }; const user = await stores.userStore.insert({ name: 'Some Name', @@ -219,11 +220,11 @@ test('should return user with multiple projects', async () => { tokens[1].secret, ); - expect(multiProjectUser.projects).toStrictEqual([ + expect(multiProjectUser!.projects).toStrictEqual([ 'test-project', 'default', ]); - expect(singleProjectUser.projects).toStrictEqual(['test-project']); + expect(singleProjectUser!.projects).toStrictEqual(['test-project']); }); test('should not partially create token if projects are invalid', async () => { diff --git a/src/test/e2e/services/project-service.e2e.test.ts b/src/test/e2e/services/project-service.e2e.test.ts index eb5cb04d0a..93ca9db6cf 100644 --- a/src/test/e2e/services/project-service.e2e.test.ts +++ b/src/test/e2e/services/project-service.e2e.test.ts @@ -100,6 +100,7 @@ test('should list all projects', async () => { id: 'test-list', name: 'New project', description: 'Blah', + mode: 'open' as const, }; await projectService.createProject(project, user); @@ -113,6 +114,7 @@ test('should create new project', async () => { id: 'test', name: 'New project', description: 'Blah', + mode: 'protected' as const, }; await projectService.createProject(project, user); @@ -121,6 +123,7 @@ test('should create new project', async () => { expect(project.name).toEqual(ret.name); expect(project.description).toEqual(ret.description); expect(ret.createdAt).toBeTruthy(); + expect(ret.mode).toEqual('protected'); }); test('should delete project', async () => { @@ -128,6 +131,7 @@ test('should delete project', async () => { id: 'test-delete', name: 'New project', description: 'Blah', + mode: 'open' as const, }; await projectService.createProject(project, user); @@ -145,6 +149,7 @@ test('should not be able to delete project with toggles', async () => { id: 'test-delete-with-toggles', name: 'New project', description: 'Blah', + mode: 'open' as const, }; await projectService.createProject(project, user); await stores.featureToggleStore.create(project.id, { @@ -180,6 +185,7 @@ test('should not be able to create existing project', async () => { id: 'test-delete', name: 'New project', description: 'Blah', + mode: 'open' as const, }; try { await projectService.createProject(project, user); @@ -210,13 +216,14 @@ test('should update project', async () => { id: 'test-update', name: 'New project', description: 'Blah', + mode: 'open' as const, }; const updatedProject = { id: 'test-update', name: 'New name', description: 'Blah longer desc', - mode: 'open' as const, + mode: 'protected' as const, }; await projectService.createProject(project, user); @@ -226,6 +233,7 @@ test('should update project', async () => { expect(updatedProject.name).toBe(readProject.name); expect(updatedProject.description).toBe(readProject.description); + expect(updatedProject.mode).toBe('protected'); }); test('should give error when getting unknown project', async () => { @@ -241,6 +249,7 @@ test('should get list of users with access to project', async () => { id: 'test-roles-access', name: 'New project', description: 'Blah', + mode: 'open' as const, }; await projectService.createProject(project, user); const { users } = await projectService.getAccessToProject(project.id); @@ -262,6 +271,7 @@ test('should add a member user to the project', async () => { id: 'add-users', name: 'New project', description: 'Blah', + mode: 'open' as const, }; await projectService.createProject(project, user); @@ -317,6 +327,7 @@ test('should add admin users to the project', async () => { id: 'add-admin-users', name: 'New project', description: 'Blah', + mode: 'open' as const, }; await projectService.createProject(project, user); @@ -363,6 +374,7 @@ test('add user should fail if user already have access', async () => { id: 'add-users-twice', name: 'New project', description: 'Blah', + mode: 'open' as const, }; await projectService.createProject(project, user); @@ -397,6 +409,7 @@ test('should remove user from the project', async () => { id: 'remove-users', name: 'New project', description: 'Blah', + mode: 'open' as const, }; await projectService.createProject(project, user); @@ -431,6 +444,7 @@ test('should not remove user from the project', async () => { id: 'remove-users-not-allowed', name: 'New project', description: 'Blah', + mode: 'open' as const, }; await projectService.createProject(project, user); @@ -454,6 +468,7 @@ test('should not change project if feature toggle project does not match current id: 'test-change-project', name: 'New project', description: 'Blah', + mode: 'open' as const, }; const toggle = { name: 'test-toggle' }; @@ -480,6 +495,7 @@ test('should return 404 if no project is found with the project id', async () => id: 'test-change-project-2', name: 'New project', description: 'Blah', + mode: 'open' as const, }; const toggle = { name: 'test-toggle-2' }; @@ -504,12 +520,14 @@ test('should fail if user is not authorized', async () => { id: 'test-change-project-3', name: 'New project', description: 'Blah', + mode: 'open' as const, }; const projectDestination = { id: 'test-change-project-dest', name: 'New project 2', description: 'Blah', + mode: 'open' as const, }; const toggle = { name: 'test-toggle-3' }; @@ -537,8 +555,16 @@ test('should fail if user is not authorized', async () => { }); test('should change project when checks pass', async () => { - const projectA = { id: randomId(), name: randomId() }; - const projectB = { id: randomId(), name: randomId() }; + const projectA = { + id: randomId(), + name: randomId(), + mode: 'open' as const, + }; + const projectB = { + id: randomId(), + name: randomId(), + mode: 'open' as const, + }; const toggle = { name: randomId() }; await projectService.createProject(projectA, user); @@ -558,8 +584,16 @@ test('should change project when checks pass', async () => { }); test('changing project should emit event even if user does not have a username set', async () => { - const projectA = { id: randomId(), name: randomId() }; - const projectB = { id: randomId(), name: randomId() }; + const projectA = { + id: randomId(), + name: randomId(), + mode: 'open' as const, + }; + const projectB = { + id: randomId(), + name: randomId(), + mode: 'open' as const, + }; const toggle = { name: randomId() }; await projectService.createProject(projectA, user); await projectService.createProject(projectB, user); @@ -576,8 +610,16 @@ test('changing project should emit event even if user does not have a username s }, 10000); test('should require equal project environments to move features', async () => { - const projectA = { id: randomId(), name: randomId() }; - const projectB = { id: randomId(), name: randomId() }; + const projectA = { + id: randomId(), + name: randomId(), + mode: 'open' as const, + }; + const projectB = { + id: randomId(), + name: randomId(), + mode: 'open' as const, + }; const environment = { name: randomId(), type: 'production' }; const toggle = { name: randomId() }; @@ -605,6 +647,7 @@ test('A newly created project only gets connected to enabled environments', asyn id: 'environment-test', name: 'New environment project', description: 'Blah', + mode: 'open' as const, }; const enabledEnv = 'connection_test'; await db.stores.environmentStore.create({ @@ -631,6 +674,7 @@ test('should have environments sorted in order', async () => { id: 'environment-order-test', name: 'Environment testing project', description: '', + mode: 'open' as const, }; const first = 'test'; const second = 'abc'; @@ -669,6 +713,7 @@ test('should add a user to the project with a custom role', async () => { id: 'add-users-custom-role', name: 'New project', description: 'Blah', + mode: 'open' as const, }; await projectService.createProject(project, user); @@ -719,6 +764,7 @@ test('should delete role entries when deleting project', async () => { id: 'test-delete-users-1', name: 'New project', description: 'Blah', + mode: 'open' as const, }; await projectService.createProject(project, user); @@ -770,6 +816,7 @@ test('should change a users role in the project', async () => { id: 'test-change-user-role', name: 'New project', description: 'Blah', + mode: 'open' as const, }; await projectService.createProject(project, user); @@ -836,6 +883,7 @@ test('should update role for user on project', async () => { id: 'update-users', name: 'New project', description: 'Blah', + mode: 'open' as const, }; await projectService.createProject(project, user); @@ -873,6 +921,7 @@ test('should able to assign role without existing members', async () => { id: 'update-users-test', name: 'New project', description: 'Blah', + mode: 'open' as const, }; await projectService.createProject(project, user); @@ -915,6 +964,7 @@ test('should not update role for user on project when she is the owner', async ( id: 'update-users-not-allowed', name: 'New project', description: 'Blah', + mode: 'open' as const, }; await projectService.createProject(project, user); @@ -948,6 +998,7 @@ test('Should allow bulk update of group permissions', async () => { const project = { id: 'bulk-update-project', name: 'bulk-update-project', + mode: 'open' as const, }; await projectService.createProject(project, user.id); const groupStore = stores.groupStore; @@ -1024,6 +1075,7 @@ test('Should allow bulk update of only groups', async () => { const project = { id: 'bulk-update-project-only', name: 'bulk-update-project-only', + mode: 'open' as const, }; const groupStore = stores.groupStore; @@ -1064,6 +1116,7 @@ test('should only count active feature toggles for project', async () => { id: 'only-active', name: 'New project', description: 'Blah', + mode: 'open' as const, }; await projectService.createProject(project, user); @@ -1091,6 +1144,7 @@ test('should list projects with all features archived', async () => { id: 'only-archived', name: 'Listed project', description: 'Blah', + mode: 'open' as const, }; await projectService.createProject(project, user); @@ -1126,6 +1180,7 @@ test('should calculate average time to production', async () => { const project = { id: 'average-time-to-prod', name: 'average-time-to-prod', + mode: 'open' as const, }; await projectService.createProject(project, user.id); @@ -1184,6 +1239,7 @@ test('should get correct amount of features created in current and past window', const project = { id: 'features-created', name: 'features-created', + mode: 'open' as const, }; await projectService.createProject(project, user.id); @@ -1219,6 +1275,7 @@ test('should get correct amount of features archived in current and past window' const project = { id: 'features-archived', name: 'features-archived', + mode: 'open' as const, }; await projectService.createProject(project, user.id); @@ -1268,6 +1325,7 @@ test('should get correct amount of project members for current and past window', const project = { id: 'features-members', name: 'features-members', + mode: 'open' as const, }; await projectService.createProject(project, user.id); diff --git a/src/test/e2e/stores/project-store.e2e.test.ts b/src/test/e2e/stores/project-store.e2e.test.ts index fa665769e4..71a0a2e7f9 100644 --- a/src/test/e2e/stores/project-store.e2e.test.ts +++ b/src/test/e2e/stores/project-store.e2e.test.ts @@ -31,6 +31,7 @@ test('should create new project', async () => { id: 'test', name: 'New project', description: 'Blah', + mode: 'open' as const, }; await projectStore.create(project); const ret = await projectStore.get('test'); @@ -48,6 +49,7 @@ test('should delete project', async () => { id: 'test-delete', name: 'New project', description: 'Blah', + mode: 'open' as const, }; await projectStore.create(project); await projectStore.delete(project.id); @@ -64,12 +66,14 @@ test('should update project', async () => { id: 'test-update', name: 'New project', description: 'Blah', + mode: 'open' as const, }; const updatedProject = { id: 'test-update', name: 'New name', description: 'Blah longer desc', + mode: 'open' as const, }; await projectStore.create(project); @@ -93,11 +97,17 @@ test('should import projects', async () => { const projectsCount = (await projectStore.getAll()).length; const projectsToImport: IProjectInsert[] = [ - { description: 'some project desc', name: 'some name', id: 'someId' }, + { + description: 'some project desc', + name: 'some name', + id: 'someId', + mode: 'open' as const, + }, { description: 'another project', name: 'another name', id: 'anotherId', + mode: 'open' as const, }, ]; @@ -110,8 +120,8 @@ test('should import projects', async () => { expect(projects.length - projectsCount).toBe(2); expect(someId).toBeDefined(); - expect(someId.name).toBe('some name'); - expect(someId.description).toBe('some project desc'); + expect(someId?.name).toBe('some name'); + expect(someId?.description).toBe('some project desc'); expect(anotherId).toBeDefined(); }); @@ -120,6 +130,7 @@ test('should add environment to project', async () => { id: 'test-env', name: 'New project with env', description: 'Blah', + mode: 'open' as const, }; await environmentStore.create({