From 9474fd9794b105c2347810a54cc41c6548e94b20 Mon Sep 17 00:00:00 2001 From: Claire Nollet Date: Mon, 2 Sep 2024 22:54:47 +0200 Subject: [PATCH] feat: :sparkles: upsert settings in db --- .../e2e/specs/admin/system-settings.e2e.ts | 30 ++-- apps/client/src/App.vue | 17 +-- apps/client/src/components/ConfigParam.vue | 30 +++- apps/client/src/components/ServicesConfig.vue | 9 +- apps/client/src/router/index.ts | 4 +- .../client/src/stores/system-settings.spec.ts | 40 ++--- apps/client/src/stores/system-settings.ts | 21 ++- apps/client/src/views/DsoHome.vue | 3 +- apps/client/src/views/admin/ListPlugins.vue | 8 +- .../client/src/views/admin/SystemSettings.vue | 138 ++++++++++-------- apps/server/src/connect.spec.ts | 1 + apps/server/src/mocks/prisma.ts | 14 -- apps/server/src/mocks/utils.ts | 24 --- apps/server/src/resources/queries-index.ts | 1 - .../system/settings/business.spec.ts | 72 +++++++++ .../src/resources/system/settings/business.ts | 15 +- .../src/resources/system/settings/queries.ts | 14 +- .../resources/system/settings/router.spec.ts | 21 +-- .../src/resources/system/settings/router.ts | 9 +- apps/server/src/utils/config.spec.ts | 69 +++++++++ apps/server/src/utils/config.ts | 121 +++------------ .../utils/configs/config.invalid.spec.json | 6 + apps/server/src/utils/configs/config.json | 6 + .../src/utils/configs/config.valid.spec.json | 6 + apps/server/vitest.config.ts | 2 +- packages/shared/src/contracts/index.ts | 2 +- packages/shared/src/contracts/system.ts | 79 ---------- packages/shared/src/contracts/system/index.ts | 3 + packages/shared/src/contracts/system/misc.ts | 29 ++++ .../shared/src/contracts/system/plugins.ts | 25 ++++ .../shared/src/contracts/system/settings.ts | 28 ++++ packages/shared/src/schemas/index.ts | 2 +- packages/shared/src/schemas/system.ts | 81 ---------- packages/shared/src/schemas/system/index.ts | 3 + packages/shared/src/schemas/system/misc.ts | 20 +++ packages/shared/src/schemas/system/plugins.ts | 35 +++++ .../shared/src/schemas/system/settings.ts | 35 +++++ packages/test-utils/src/imports/data.ts | 2 +- 38 files changed, 554 insertions(+), 471 deletions(-) delete mode 100644 apps/server/src/mocks/prisma.ts delete mode 100644 apps/server/src/mocks/utils.ts create mode 100644 apps/server/src/resources/system/settings/business.spec.ts create mode 100644 apps/server/src/utils/config.spec.ts create mode 100644 apps/server/src/utils/configs/config.invalid.spec.json create mode 100644 apps/server/src/utils/configs/config.json create mode 100644 apps/server/src/utils/configs/config.valid.spec.json delete mode 100644 packages/shared/src/contracts/system.ts create mode 100644 packages/shared/src/contracts/system/index.ts create mode 100644 packages/shared/src/contracts/system/misc.ts create mode 100644 packages/shared/src/contracts/system/plugins.ts create mode 100644 packages/shared/src/contracts/system/settings.ts delete mode 100644 packages/shared/src/schemas/system.ts create mode 100644 packages/shared/src/schemas/system/index.ts create mode 100644 packages/shared/src/schemas/system/misc.ts create mode 100644 packages/shared/src/schemas/system/plugins.ts create mode 100644 packages/shared/src/schemas/system/settings.ts diff --git a/apps/client/cypress/e2e/specs/admin/system-settings.e2e.ts b/apps/client/cypress/e2e/specs/admin/system-settings.e2e.ts index 56e0f86e2a..442fc686e6 100644 --- a/apps/client/cypress/e2e/specs/admin/system-settings.e2e.ts +++ b/apps/client/cypress/e2e/specs/admin/system-settings.e2e.ts @@ -1,4 +1,4 @@ -import type { SystemSettings } from '@cpn-console/shared' +import { type SystemSettings, SystemSettingsSchema } from '@cpn-console/shared' import { getModel } from '../../support/func.js' describe('Administration system settings', () => { @@ -7,7 +7,7 @@ describe('Administration system settings', () => { beforeEach(() => { cy.intercept('GET', 'api/v1/system/settings?key=maintenance').as('listMaintenanceSetting') cy.intercept('GET', 'api/v1/system/settings').as('listSystemSettings') - cy.intercept('POST', 'api/v1/system/settings').as('upsertSystemSetting') + cy.intercept('POST', 'api/v1/system/settings').as('upsertSystemSettings') cy.kcLogin('tcolin') cy.visit('/admin/system-settings') @@ -29,12 +29,13 @@ describe('Administration system settings', () => { cy.getByDataTestid(`toggle-maintenance`) .find('input') .check({ force: true }) - cy.wait('@upsertSystemSetting').its('response').then(($response) => { + + cy.getByDataTestid('button-submit') + .click() + + cy.wait('@upsertSystemSettings').its('response').then(($response) => { expect($response?.statusCode).to.match(/^20\d$/) - expect(JSON.stringify($response?.body)).to.equal(JSON.stringify({ - key: 'maintenance', - value: 'on', - })) + expect(JSON.stringify($response?.body)).to.equal(JSON.stringify(SystemSettingsSchema.parse({}))) }) cy.visit('/projects') @@ -61,7 +62,7 @@ describe('Administration system settings', () => { expect($response?.statusCode).to.match(/^20\d$/) expect(JSON.stringify($response?.body)).to.equal(JSON.stringify([{ key: 'maintenance', - value: 'on', + value: 'true', }])) }) cy.wait('@listRoles') @@ -83,12 +84,13 @@ describe('Administration system settings', () => { cy.getByDataTestid(`toggle-maintenance`) .find('input') .uncheck({ force: true }) - cy.wait('@upsertSystemSetting').its('response').then(($response) => { + + cy.getByDataTestid('button-submit') + .click() + + cy.wait('@upsertSystemSettings').its('response').then(($response) => { expect($response?.statusCode).to.match(/^20\d$/) - expect(JSON.stringify($response?.body)).to.equal(JSON.stringify({ - key: 'maintenance', - value: 'off', - })) + expect(JSON.stringify($response?.body)).to.equal(JSON.stringify(SystemSettingsSchema.parse({}))) }) cy.visit('/projects') @@ -96,7 +98,7 @@ describe('Administration system settings', () => { expect($response?.statusCode).to.match(/^20\d$/) expect(JSON.stringify($response?.body)).to.equal(JSON.stringify([{ key: 'maintenance', - value: 'on', + value: 'true', }])) }) cy.getByDataTestid('maintenance-notice') diff --git a/apps/client/src/App.vue b/apps/client/src/App.vue index 02fa047bc0..5bed527976 100644 --- a/apps/client/src/App.vue +++ b/apps/client/src/App.vue @@ -1,5 +1,4 @@ diff --git a/apps/client/src/components/ServicesConfig.vue b/apps/client/src/components/ServicesConfig.vue index 313af40290..825d4bfe1d 100644 --- a/apps/client/src/components/ServicesConfig.vue +++ b/apps/client/src/components/ServicesConfig.vue @@ -30,7 +30,8 @@ function refTheValues(services: ProjectService[]) { const updated = ref({}) -function update(data: { value: string, key: string, plugin: string }) { +function update(data: { value: string | boolean, key: string, plugin: string }) { + if (typeof data.value === 'boolean') return if (!updated.value[data.plugin]) updated.value[data.plugin] = {} updated.value[data.plugin][data.key] = data.value } @@ -175,11 +176,12 @@ function reload() { kind: item.kind, description: item.description, name: item.title, + label: undefined, // @ts-ignore Sisi il y a potentiellement un placeholder placeholder: item.placeholder || '', disabled: !item.permissions[permissionTarget].write, }" - @update="(value: string) => update({ key: item.key, value, plugin: service.name })" + @update="(value: string | boolean) => update({ key: item.key, value, plugin: service.name })" />
diff --git a/apps/client/src/router/index.ts b/apps/client/src/router/index.ts index ef28e203f8..87e2a2209e 100644 --- a/apps/client/src/router/index.ts +++ b/apps/client/src/router/index.ts @@ -259,8 +259,8 @@ router.beforeEach(async (to, _from, next) => { !validPath.includes(to.name) && userStore.isLoggedIn ) { - await systemStore.listSystemSettings('maintenance') - if (systemStore.systemSettings?.maintenance === 'on' && userStore.adminPerms === 0n) return next('/maintenance') + await systemStore.listSystemSettings() + if (systemStore.systemSettings?.maintenance === 'true' && userStore.adminPerms === 0n) return next('/maintenance') } next() diff --git a/apps/client/src/stores/system-settings.spec.ts b/apps/client/src/stores/system-settings.spec.ts index b79946b03e..6fda1346c6 100644 --- a/apps/client/src/stores/system-settings.spec.ts +++ b/apps/client/src/stores/system-settings.spec.ts @@ -4,7 +4,7 @@ import { apiClient } from '../api/xhr-client.js' import { useSystemSettingsStore } from './system-settings.js' const listSystemSettings = vi.spyOn(apiClient.SystemSettings, 'listSystemSettings') -const upsertSystemSetting = vi.spyOn(apiClient.SystemSettings, 'upsertSystemSetting') +const upsertSystemSettings = vi.spyOn(apiClient.SystemSettings, 'upsertSystemSettings') describe('system Settings Store', () => { beforeEach(() => { @@ -15,41 +15,29 @@ describe('system Settings Store', () => { }) it('should get system settings list by api call', async () => { - const data = [ - { key: 'maintenace', value: 'on' }, - { key: 'theme', value: 'dsfr' }, - { key: 'organisation', value: 'miom' }, - ] - listSystemSettings.mockReturnValueOnce(Promise.resolve({ status: 200, body: data })) + const sytemSettings = { maintenace: 'true', theme: 'dsfr', organisation: 'miom' } + // @ts-expect-error TS2345 + listSystemSettings.mockReturnValueOnce(Promise.resolve({ status: 200, body: sytemSettings })) const systemSettingsStore = useSystemSettingsStore() await systemSettingsStore.listSystemSettings() - expect(systemSettingsStore.systemSettings).toEqual(data) + expect(systemSettingsStore.systemSettings).toEqual(sytemSettings) expect(listSystemSettings).toHaveBeenCalledTimes(1) }) it('should upsert a system setting by api call', async () => { - const data = [ - { key: 'maintenace', value: 'on' }, - { key: 'theme', value: 'dsfr' }, - { key: 'organisation', value: 'miom' }, - ] - const newSystemSetting = { key: 'organisation', value: 'mj' } - const newData = [ - { key: 'maintenace', value: 'on' }, - { key: 'theme', value: 'dsfr' }, - { key: 'organisation', value: 'mj' }, - ] - - upsertSystemSetting.mockReturnValueOnce(Promise.resolve({ status: 201, body: newSystemSetting })) + const sytemSettings = { maintenace: 'true', theme: 'dsfr', organisation: 'miom' } + const newSystemSettings = { maintenace: 'true', theme: 'dsfr', organisation: 'mj' } + + // @ts-expect-error TS2345 + upsertSystemSettings.mockReturnValueOnce(Promise.resolve({ status: 201, body: newSystemSettings })) const systemSettingsStore = useSystemSettingsStore() - systemSettingsStore.systemSettings = data + systemSettingsStore.systemSettings = sytemSettings - const res = await systemSettingsStore.upsertSystemSetting(data) + await systemSettingsStore.upsertSystemSettings(sytemSettings) - expect(res).toEqual(newSystemSetting) - expect(systemSettingsStore.systemSettings).toEqual(newData) - expect(upsertSystemSetting).toHaveBeenCalledTimes(1) + expect(systemSettingsStore.systemSettings).toEqual(newSystemSettings) + expect(upsertSystemSettings).toHaveBeenCalledTimes(1) }) }) diff --git a/apps/client/src/stores/system-settings.ts b/apps/client/src/stores/system-settings.ts index 6fb9e34394..8a9c97a607 100644 --- a/apps/client/src/stores/system-settings.ts +++ b/apps/client/src/stores/system-settings.ts @@ -1,29 +1,26 @@ -import { defineStore } from 'pinia' import type { SystemSettings, + UpsertSystemSettingsBody, } from '@cpn-console/shared' +import { defineStore } from 'pinia' import { apiClient, extractData } from '@/api/xhr-client.js' export const useSystemSettingsStore = defineStore('systemSettings', () => { const systemSettings = ref() - const listSystemSettings = async (key?: keyof SystemSettings) => { - systemSettings.value = await apiClient.SystemSettings.listSystemSettings({ query: { key } }) + const listSystemSettings = async () => { + systemSettings.value = await apiClient.SystemSettings.listSystemSettings() .then(response => extractData(response, 200)) } - // const upsertSystemSetting = async (newSystemSetting: UpsertSystemSettingBody) => { - // const res = await apiClient.SystemSettings.upsertSystemSetting({ body: newSystemSetting }) - // .then(response => extractData(response, 201)) - // systemSettings.value = systemSettings.value - // .toSpliced(systemSettings.value - // .findIndex(systemSetting => systemSetting.key === res.key), 1, res) - // return res - // } + const upsertSystemSettings = async (newSystemSetting: UpsertSystemSettingsBody) => { + systemSettings.value = await apiClient.SystemSettings.upsertSystemSettings({ body: newSystemSetting }) + .then(response => extractData(response, 201)) + } return { systemSettings, listSystemSettings, - // upsertSystemSetting, + upsertSystemSettings, } }) diff --git a/apps/client/src/views/DsoHome.vue b/apps/client/src/views/DsoHome.vue index a8042c1095..90ec92f9e3 100644 --- a/apps/client/src/views/DsoHome.vue +++ b/apps/client/src/views/DsoHome.vue @@ -53,8 +53,7 @@ const knowMoreBtn: Ref = ref({ }) function setWindowLocation(to: string) { - // TODO - // @ts-ignore + // @ts-expect-error 2322 window.location = to } diff --git a/apps/client/src/views/admin/ListPlugins.vue b/apps/client/src/views/admin/ListPlugins.vue index bb9796e552..1617506ed3 100644 --- a/apps/client/src/views/admin/ListPlugins.vue +++ b/apps/client/src/views/admin/ListPlugins.vue @@ -1,6 +1,6 @@ - - - - - diff --git a/apps/server/src/connect.spec.ts b/apps/server/src/connect.spec.ts index eb05fc8b41..98134ce776 100644 --- a/apps/server/src/connect.spec.ts +++ b/apps/server/src/connect.spec.ts @@ -15,6 +15,7 @@ vi.mock('./models/user.js', () => getModel('getUserModel')) vi.mock('./models/users-projects.js', () => getModel('getRolesModel')) vi.mock('./models/organization.js', () => getModel('getOrganizationModel')) vi.mock('./models/zone.js', () => getModel('getZoneModel')) +vi.mock('./models/system-setting.js', () => getModel('getSystemSetting')) vi.mock('./prisma.js') vi.spyOn(app, 'listen') diff --git a/apps/server/src/mocks/prisma.ts b/apps/server/src/mocks/prisma.ts deleted file mode 100644 index 075578c96c..0000000000 --- a/apps/server/src/mocks/prisma.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { PrismaClient } from '@prisma/client' -import { beforeEach, vi } from 'vitest' -import { mockDeep, mockReset } from 'vitest-mock-extended' - -vi.mock('../prisma.js') - -const prisma = mockDeep() - -beforeEach(() => { - // reset les mocks - mockReset(prisma) -}) - -export default prisma diff --git a/apps/server/src/mocks/utils.ts b/apps/server/src/mocks/utils.ts deleted file mode 100644 index 3e9556625a..0000000000 --- a/apps/server/src/mocks/utils.ts +++ /dev/null @@ -1,24 +0,0 @@ -import fp from 'fastify-plugin' -import type { User } from '@cpn-console/test-utils' - -let requestor: User - -export function setRequestor(user: User) { - requestor = user -} - -export function getRequestor() { - return requestor -} - -export async function mockSessionPlugin() { - const sessionPlugin = (app, opt, next) => { - app.addHook('onRequest', (req, res, next) => { - req.session = { user: getRequestor() } - next() - }) - next() - } - - return { default: fp(sessionPlugin) } -} diff --git a/apps/server/src/resources/queries-index.ts b/apps/server/src/resources/queries-index.ts index 0ef492f784..f9ba7b5bf5 100644 --- a/apps/server/src/resources/queries-index.ts +++ b/apps/server/src/resources/queries-index.ts @@ -3,7 +3,6 @@ export * from '@/resources/cluster/queries.js' export * from '@/resources/environment/queries.js' export * from '@/resources/log/queries.js' export * from '@/resources/organization/queries.js' -// export * from '@/resources/permission/queries.js' export * from '@/resources/project/queries.js' export * from '@/resources/project-member/queries.js' export * from '@/resources/project-role/queries.js' diff --git a/apps/server/src/resources/system/settings/business.spec.ts b/apps/server/src/resources/system/settings/business.spec.ts new file mode 100644 index 0000000000..243518f3b9 --- /dev/null +++ b/apps/server/src/resources/system/settings/business.spec.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from 'vitest' +import prisma from '../../../__mocks__/prisma.js' +import { getFileConfig } from '../../../utils/config.ts' +import { getSystemSettings, upsertSystemSettings } from './business.ts' + +describe('test system settings business logic', () => { + const systemSettings = [ + { + key: 'maintenance', + value: 'true', + }, + { + key: 'appName', + value: 'candilib', + }, + { + key: 'appSubTitle', + value: 'Direction, de la Sécurité, Routière', + }, + ] + describe('getSystemSettings', () => { + it('should get one setting', async () => { + prisma.systemSetting.findMany.mockResolvedValue(systemSettings) + const response = await getSystemSettings('maintenance') + expect(prisma.systemSetting.findMany).toHaveBeenCalledTimes(1) + expect(response).toMatchObject({ maintenance: 'true' }) + }) + it('should get all settings', async () => { + prisma.systemSetting.findMany.mockResolvedValue(systemSettings) + const response = await getSystemSettings() + expect(prisma.systemSetting.findMany).toHaveBeenCalledTimes(1) + expect(response).toMatchObject({ + ...await getFileConfig(), + maintenance: 'true', + appName: 'candilib', + appSubTitle: 'Direction, de la Sécurité, Routière', + }) + }) + }) + + describe('upsertSystemSettings', () => { + it('should upsert 2 settings', async () => { + prisma.systemSetting.findMany.mockResolvedValue([ + { + key: 'maintenance', + value: 'true', + }, + { + key: 'appName', + value: 'PSIJ', + }, + { + key: 'appSubTitle', + value: 'SNPS', + }, + ]) + prisma.systemSetting.upsert.mockResolvedValue({ key: '', value: '' }) + const response = await upsertSystemSettings({ + appName: 'PSIJ', + appSubTitle: 'SNPS', + }) + expect(prisma.systemSetting.upsert).toHaveBeenCalledTimes(2) + expect(prisma.systemSetting.findMany).toHaveBeenCalledTimes(1) + expect(response).toMatchObject({ + ...await getFileConfig(), + maintenance: 'true', + appName: 'PSIJ', + appSubTitle: 'SNPS', + }) + }) + }) +}) diff --git a/apps/server/src/resources/system/settings/business.ts b/apps/server/src/resources/system/settings/business.ts index 2efc8d6d63..6fc67cd345 100644 --- a/apps/server/src/resources/system/settings/business.ts +++ b/apps/server/src/resources/system/settings/business.ts @@ -1,14 +1,13 @@ import type { UpsertSystemSettingsBody } from '@cpn-console/shared' import { upsertSystemSetting as upsertSystemSettingQuery } from './queries.js' +import { getConfig } from '@/utils/config.js' -import { config } from '@/utils/config.js' +export const getSystemSettings = async () => getConfig() -export function getSystemSettings(key?: keyof typeof config) { - if (key) { - return { [key]: config[key] } - } else { - return config +export async function upsertSystemSettings(newSystemSettings: UpsertSystemSettingsBody) { + if (!newSystemSettings) return getSystemSettings() + for (const [key, value] of Object.entries(newSystemSettings)) { + await upsertSystemSettingQuery({ key, value }) } + return await getSystemSettings() } - -export const upsertSystemSettings = (newSystemSetting: UpsertSystemSettingsBody) => upsertSystemSettingQuery(newSystemSetting) diff --git a/apps/server/src/resources/system/settings/queries.ts b/apps/server/src/resources/system/settings/queries.ts index c64cb3b74c..9864f501a5 100644 --- a/apps/server/src/resources/system/settings/queries.ts +++ b/apps/server/src/resources/system/settings/queries.ts @@ -15,4 +15,16 @@ export function upsertSystemSetting(newSystemSetting: SystemSetting) { }) } -export const getSystemSettings = (where?: Prisma.SystemSettingWhereInput) => prisma.systemSetting.findMany({ where }) +export async function getSystemSettings(where?: Prisma.SystemSettingWhereInput) { + const res = await prisma.systemSetting.findMany({ where }) + if (res === undefined || typeof res != 'object') { + return {} + } else { + return res.reduce((acc, curr) => { + return { + ...acc, + [curr.key]: curr.value, + } + }, {}) + } +} diff --git a/apps/server/src/resources/system/settings/router.spec.ts b/apps/server/src/resources/system/settings/router.spec.ts index c4c6b56b63..2bd384c54c 100644 --- a/apps/server/src/resources/system/settings/router.spec.ts +++ b/apps/server/src/resources/system/settings/router.spec.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { systemSettingsContract } from '@cpn-console/shared' +import { SystemSettingsDefaultSchema, systemSettingsContract } from '@cpn-console/shared' import app from '../../../app.js' import * as utilsController from '../../../utils/controller.js' import { getUserMockInfos } from '../../../utils/mocks.js' @@ -8,7 +8,7 @@ import * as business from './business.js' vi.mock('fastify-keycloak-adapter', (await import('../../../utils/mocks.js')).mockSessionPlugin) const authUserMock = vi.spyOn(utilsController, 'authUser') const businessGetSystemSettingsMock = vi.spyOn(business, 'getSystemSettings') -const businessUpsertSystemSettingMock = vi.spyOn(business, 'upsertSystemSetting') +const businessUpsertSystemSettingMock = vi.spyOn(business, 'upsertSystemSettings') describe('test systemSettingsContract', () => { beforeEach(() => { @@ -16,9 +16,9 @@ describe('test systemSettingsContract', () => { }) describe('listSystemSettings', () => { - it('should return plugin configurations for authorized users', async () => { + it('should return system settings for authorized users', async () => { const user = getUserMockInfos(true) - const systemSettings = [] + const systemSettings = SystemSettingsDefaultSchema.parse({}) authUserMock.mockResolvedValueOnce(user) businessGetSystemSettingsMock.mockResolvedValueOnce(systemSettings) @@ -33,20 +33,21 @@ describe('test systemSettingsContract', () => { }) }) - describe('upsertSystemSetting', () => { - const newConfig = { key: 'key1', value: 'value1' } + describe('upsertSystemSettings', () => { + const newConfig = { appName: 'key1' } it('should update system setting, authorized users', async () => { + const defaultSystemSettings = SystemSettingsDefaultSchema.parse({}) const user = getUserMockInfos(true) authUserMock.mockResolvedValueOnce(user) - businessUpsertSystemSettingMock.mockResolvedValueOnce(newConfig) - + businessUpsertSystemSettingMock.mockResolvedValueOnce({ ...defaultSystemSettings, ...newConfig }) const response = await app.inject() - .post(systemSettingsContract.upsertSystemSetting.path) + .post(systemSettingsContract.upsertSystemSettings.path) .body(newConfig) .end() expect(businessUpsertSystemSettingMock).toHaveBeenCalledWith(newConfig) + expect(response.json()).toEqual({ ...defaultSystemSettings, ...newConfig }) expect(response.statusCode).toEqual(201) }) @@ -56,7 +57,7 @@ describe('test systemSettingsContract', () => { authUserMock.mockResolvedValueOnce(user) const response = await app.inject() - .post(systemSettingsContract.upsertSystemSetting.path) + .post(systemSettingsContract.upsertSystemSettings.path) .body(newConfig) .end() diff --git a/apps/server/src/resources/system/settings/router.ts b/apps/server/src/resources/system/settings/router.ts index 7b5bf3c565..9623c4edde 100644 --- a/apps/server/src/resources/system/settings/router.ts +++ b/apps/server/src/resources/system/settings/router.ts @@ -1,3 +1,4 @@ +import type { SystemSettings } from '@cpn-console/shared' import { AdminAuthorized, systemSettingsContract } from '@cpn-console/shared' import { getSystemSettings, upsertSystemSettings } from './business.js' import { serverInstance } from '@/app.js' @@ -6,8 +7,8 @@ import { Forbidden403 } from '@/utils/errors.js' export function systemSettingsRouter() { return serverInstance.router(systemSettingsContract, { - listSystemSettings: async ({ query }) => { - const systemSettings = await getSystemSettings(query.key) + listSystemSettings: async () => { + const systemSettings = await getSystemSettings() if (!systemSettings) { return { @@ -21,11 +22,11 @@ export function systemSettingsRouter() { } }, - upsertSystemSetting: async ({ request: req, body: data }) => { + upsertSystemSettings: async ({ request: req, body: data }) => { const perms = await authUser(req) if (!AdminAuthorized.isAdmin(perms.adminPermissions)) return new Forbidden403() - const systemSetting = await upsertSystemSettings(data) + const systemSetting = await upsertSystemSettings(data) as SystemSettings return { status: 201, diff --git a/apps/server/src/utils/config.spec.ts b/apps/server/src/utils/config.spec.ts new file mode 100644 index 0000000000..508193accf --- /dev/null +++ b/apps/server/src/utils/config.spec.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { SystemSettingsDefaultSchema as ConfigDefaultSchema } from '@cpn-console/shared' +import prisma from '../__mocks__/prisma.js' +import { getConfig } from './config.js' + +const originalEnv = process.env +const testEnv = { + maintenance: 'true', + appName: 'Console Cloud Pi Native du test unitaire ENV', + contactMail: 'cloudpinative-test-unitaire@testENV.unitaire.gouv.fr', + appSubTitle: 'Ministère, du test unitaire, et des tests ENV', +} + +describe('utils - config', () => { + beforeEach(() => { + vi.resetModules() + globalThis.process.env = originalEnv + }) + + // describe('getEnv', () => { + // it('should retieve environment variables', () => { + // globalThis.process.env = { ...originalEnv, ...testEnv } + + // const env = getEnvConfig() + + // expect(env).toEqual(testEnv) + // }) + // }) + + describe('getConfig', () => { + it('should retieve config', async () => { + prisma.systemSetting.findMany.mockResolvedValue([]) + globalThis.process.env = {} + + const testConfig = await import('./configs/config.valid.spec.json', { assert: { type: 'json' } }) + const env = await getConfig() + + expect(env).toEqual(testConfig.default) + }) + + it('should retieve config override by environment variables', async () => { + prisma.systemSetting.findMany.mockResolvedValue([]) + globalThis.process.env = { ...originalEnv, ...testEnv } + const testConfig = await import('./configs/config.valid.spec.json', { assert: { type: 'json' } }) + + const env = await getConfig() + const expected = { + ...ConfigDefaultSchema.parse({}), + ...testConfig.default, + ...testEnv, + } + + expect(env).toEqual(expected) + }) + + it('should throw an error if config file have an invalid schema', async () => { + prisma.systemSetting.findMany.mockResolvedValue([]) + globalThis.process.env = {} + + try { + await getConfig({ fileConfigPath: './configs/config.invalid.spec.json' }) + } catch (error) { + expect(error).toBeInstanceOf(Error) + expect(JSON.parse(error?.message).description).toEqual('invalid config file "./configs/config.invalid.spec.json"') + expect(JSON.parse(error?.message).error.issues[0].message).toEqual('Unrecognized key(s) in object: \'config\'') + } + }) + }) +}) diff --git a/apps/server/src/utils/config.ts b/apps/server/src/utils/config.ts index 484e082621..2609ccc8df 100644 --- a/apps/server/src/utils/config.ts +++ b/apps/server/src/utils/config.ts @@ -1,5 +1,7 @@ import path from 'node:path' -import { SystemSettingSchema as ConfigSchema } from '@cpn-console/shared' +import type { SystemSettingsSchema as ConfigSchema } from '@cpn-console/shared' +import { SystemSettingsDefaultSchema as ConfigSchemaDefault } from '@cpn-console/shared' +import { getSystemSettings } from '@/resources/queries-index.js' const getNodeEnv: () => 'development' | 'test' | 'production' = () => { if (process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'test') { @@ -8,126 +10,37 @@ const getNodeEnv: () => 'development' | 'test' | 'production' = () => { return 'production' } -function snakeCaseToCamelCase(input: string) { - return input - .split('_') - .reduce((acc, cur, i) => { - if (!i) { - return cur.toLowerCase() - } - return acc + cur.charAt(0).toUpperCase() + cur.substring(1).toLowerCase() - }, '') -} - -function deepMerge(target: any, source: any) { - const result = { ...target, ...source } - for (const key of Object.keys(result)) { - if (Array.isArray(target[key]) && Array.isArray(source[key])) { - result[key] = result[key].map((value: unknown, idx: number) => { - return typeof value === 'object' - ? deepMerge(target[key][idx], source[key][idx]) - : structuredClone(result[key][idx]) - }) - } else if (typeof target[key] === 'object' && typeof source[key] === 'object') { - result[key] = deepMerge(target[key], source[key]) - } else { - result[key] = structuredClone(result[key]) - } - } - return result -} - const configPaths = { - development: path.resolve(__dirname, '../../config-example.json'), - production: '/app/config.json', - test: path.resolve(__dirname, './configs/config.valid.spec.json'), + development: path.resolve(import.meta.dirname, './configs/config.json'), + production: './config.json', + test: path.resolve(import.meta.dirname, './configs/config.valid.spec.json'), } const CONFIG_PATH = configPaths[getNodeEnv()] -const ENV_PREFIX = ['API__', 'DOC__'] - -// export const ConfigSchema = z.object({ -// maintenance: z.string().default('off'), -// appName: z.string().default('Console Cloud Pi Native TEST DE FOU'), -// contactMail: z.string().default('cloudpinative-relations@interieur.gouv.fr'), -// appSubTitle: z.array(z.string()).default(['Ministère 2', 'de l’intérieur 3', 'et des outre-mer 4']), -// // appLogoUrl: z.string().default(''), // pas sur de la faisabilité -// }).strict() export type Config = Zod.infer -// maybe a modifié ? ? -export function parseEnv(obj: Record): Config | Record { - return Object - .entries(obj) - .map(([key, value]) => key - .split('__') - .toReversed() - .reduce((acc, val, idx) => { - if (!idx) { - try { - return { [snakeCaseToCamelCase(val)]: JSON.parse(value) } - } catch (_e) { - return { [snakeCaseToCamelCase(val)]: value } - } - } else { - return { [snakeCaseToCamelCase(val)]: acc } - } - }, {})) - .reduce((acc, val) => deepMerge(acc, val), {}) -} - -// pour recup l'env -export function getEnv(prefix: string | string[] = ENV_PREFIX): Record { - return Object - .entries(process.env) - .filter(([key, _value]) => Array.isArray(prefix) ? prefix.some(p => key.startsWith(p)) : key.startsWith(prefix)) - .reduce((acc, [key, value]) => ({ ...acc, [key]: value }), {}) -} - -export async function getConfig(opts?: { fileConfigPath?: string, envPrefix?: string | string[] }) { - const fileConfigPath = opts?.fileConfigPath ?? CONFIG_PATH - const envPrefix = opts?.envPrefix ?? ENV_PREFIX - - const defaultConfig = ConfigSchema.parse({}) - let envConfig: Config | Record = {} - let fileConfig: Config | Record = {} - // const dbConfig: Config | Record = {} - - try { - envConfig = parseEnv(getEnv(envPrefix)) - ConfigSchema.partial().parse(envConfig) - } catch (error) { - const errorMessage = { description: 'invalid config environment variables', error } - throw new Error(JSON.stringify(errorMessage)) - } - +export async function getFileConfig(fileConfigPath = CONFIG_PATH): Promise> { try { const file = await import(fileConfigPath, { assert: { type: 'json' } }) - .catch(_e => console.log(`no config file detected "${fileConfigPath}"`)) + if (file) { - fileConfig = file.default - ConfigSchema.partial().parse(fileConfig) + return file.default } + return {} } catch (error) { const errorMessage = { description: `invalid config file "${fileConfigPath}"`, error } throw new Error(JSON.stringify(errorMessage)) } +} - // try { - // dbConfig = JSON.parse(await getSystemSettings()) - // } catch (error) { - // const errorMessage = { description: `invalid config env`, error } - // throw new Error(JSON.stringify(errorMessage)) - // } - - return { - ...defaultConfig, - ...fileConfig, - ...envConfig, - // ...dbConfig, - } +export async function getConfig(opts?: { fileConfigPath?: string, envPrefix?: string | string[] }) { + return ConfigSchemaDefault.parse({ + ...await getFileConfig(opts?.fileConfigPath), + ...process.env, + ...(await getSystemSettings()), + }) } export const config = await getConfig() diff --git a/apps/server/src/utils/configs/config.invalid.spec.json b/apps/server/src/utils/configs/config.invalid.spec.json new file mode 100644 index 0000000000..e2dd67ba4e --- /dev/null +++ b/apps/server/src/utils/configs/config.invalid.spec.json @@ -0,0 +1,6 @@ +{ + "config": { + "var1": "cfg1", + "var2": "cfg2" + } +} diff --git a/apps/server/src/utils/configs/config.json b/apps/server/src/utils/configs/config.json new file mode 100644 index 0000000000..e2d13989d8 --- /dev/null +++ b/apps/server/src/utils/configs/config.json @@ -0,0 +1,6 @@ +{ + "maintenance": "false", + "appName": "Console Cloud Pi Native", + "contactMail": "cloudpinative-dev@dev.gouv.fr", + "appSubTitle": "Ministère, de l’intérieur, et des outre-mer" +} diff --git a/apps/server/src/utils/configs/config.valid.spec.json b/apps/server/src/utils/configs/config.valid.spec.json new file mode 100644 index 0000000000..ce032fd281 --- /dev/null +++ b/apps/server/src/utils/configs/config.valid.spec.json @@ -0,0 +1,6 @@ +{ + "maintenance": "false", + "appName": "Console Cloud Pi Native du test unitaire", + "contactMail": "cloudpinative-test-unitaire@test.unitaire.gouv.fr", + "appSubTitle": "Ministère, du test unitaire, et des tests" +} diff --git a/apps/server/vitest.config.ts b/apps/server/vitest.config.ts index deb1bfe645..858e2494c4 100644 --- a/apps/server/vitest.config.ts +++ b/apps/server/vitest.config.ts @@ -26,7 +26,7 @@ export default mergeConfig( }, include: ['src/**/*.spec.{ts,js}'], exclude: [...configDefaults.exclude, 'e2e/*'], - setupFiles: ['./vitest-init.ts'], + setupFiles: ['./vitest-init.ts', './src/__mocks__/prisma.ts'], root: fileURLToPath(new URL('./', import.meta.url)), pool: 'forks', }, diff --git a/packages/shared/src/contracts/index.ts b/packages/shared/src/contracts/index.ts index 0d986867fa..b78ca323b2 100644 --- a/packages/shared/src/contracts/index.ts +++ b/packages/shared/src/contracts/index.ts @@ -12,6 +12,6 @@ export * from './repository.js' export * from './project-role.js' export * from './service-monitor.js' export * from './stage.js' -export * from './system.js' +export * from './system/index.js' export * from './user.js' export * from './zone.js' diff --git a/packages/shared/src/contracts/system.ts b/packages/shared/src/contracts/system.ts deleted file mode 100644 index b5b9591d88..0000000000 --- a/packages/shared/src/contracts/system.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { ClientInferRequest, ClientInferResponseBody } from '@ts-rest/core' -import { apiPrefix, contractInstance } from '../api-client.js' -import { - GetHealthzSchema, - GetSystemPluginSchema, - GetVersionSchema, - ListSystemSettingsSchema, - UpdateSystemPluginSchema, - UpsertSystemSettingsSchema, -} from '../schemas/index.js' - -export const systemContract = contractInstance.router({ - getVersion: { - method: 'GET', - path: `${apiPrefix}/version`, - summary: 'Get version', - description: 'Retrieve api version.', - responses: { - 200: GetVersionSchema.responses['200'], - 500: GetVersionSchema.responses['500'], - }, - }, - - getHealth: { - method: 'GET', - path: `${apiPrefix}/healthz`, - summary: 'Get health', - description: 'Retrieve api health infos.', - responses: { - 200: GetHealthzSchema.responses['200'], - 500: GetHealthzSchema.responses['500'], - }, - }, -}) - -export const systemPluginContract = contractInstance.router({ - getPluginsConfig: { - method: 'GET', - path: `${apiPrefix}/system/plugins`, - summary: 'Get plugins configuration', - description: 'Get plugins configuration', - responses: GetSystemPluginSchema.responses, - }, - - updatePluginsConfig: { - method: 'POST', - path: `${apiPrefix}/system/plugins`, - summary: 'Update project service configuration', - description: 'Update project service configuration', - body: UpdateSystemPluginSchema.body, - responses: UpdateSystemPluginSchema.responses, - }, - -}) - -export const systemSettingsContract = contractInstance.router({ - listSystemSettings: { - method: 'GET', - path: `${apiPrefix}/system/settings`, - query: ListSystemSettingsSchema.query, - summary: 'Get System Settings state', - description: 'Get System Settings state', - responses: ListSystemSettingsSchema.responses, - }, - - upsertSystemSetting: { - method: 'POST', - path: `${apiPrefix}/system/settings`, - contentType: 'application/json', - summary: 'Update System Settings state', - description: 'Update System Settings state', - body: UpsertSystemSettingsSchema.body, - responses: UpsertSystemSettingsSchema.responses, - }, -}) - -export type SystemSettings = ClientInferResponseBody - -export type UpsertSystemSettingBody = ClientInferRequest['body'] diff --git a/packages/shared/src/contracts/system/index.ts b/packages/shared/src/contracts/system/index.ts new file mode 100644 index 0000000000..dea4584039 --- /dev/null +++ b/packages/shared/src/contracts/system/index.ts @@ -0,0 +1,3 @@ +export * from './misc.js' +export * from './plugins.js' +export * from './settings.js' diff --git a/packages/shared/src/contracts/system/misc.ts b/packages/shared/src/contracts/system/misc.ts new file mode 100644 index 0000000000..26f9c36e1f --- /dev/null +++ b/packages/shared/src/contracts/system/misc.ts @@ -0,0 +1,29 @@ +import { apiPrefix, contractInstance } from '../../api-client.js' +import { + GetHealthzSchema, + GetVersionSchema, +} from '../../schemas/index.js' + +export const systemContract = contractInstance.router({ + getVersion: { + method: 'GET', + path: `${apiPrefix}/version`, + summary: 'Get version', + description: 'Retrieve api version.', + responses: { + 200: GetVersionSchema.responses['200'], + 500: GetVersionSchema.responses['500'], + }, + }, + + getHealth: { + method: 'GET', + path: `${apiPrefix}/healthz`, + summary: 'Get health', + description: 'Retrieve api health infos.', + responses: { + 200: GetHealthzSchema.responses['200'], + 500: GetHealthzSchema.responses['500'], + }, + }, +}) diff --git a/packages/shared/src/contracts/system/plugins.ts b/packages/shared/src/contracts/system/plugins.ts new file mode 100644 index 0000000000..5b88543183 --- /dev/null +++ b/packages/shared/src/contracts/system/plugins.ts @@ -0,0 +1,25 @@ +import { apiPrefix, contractInstance } from '../../api-client.js' +import { + GetSystemPluginSchema, + UpdateSystemPluginSchema, +} from '../../schemas/index.js' + +export const systemPluginContract = contractInstance.router({ + getPluginsConfig: { + method: 'GET', + path: `${apiPrefix}/system/plugins`, + summary: 'Get plugins configuration', + description: 'Get plugins configuration', + responses: GetSystemPluginSchema.responses, + }, + + updatePluginsConfig: { + method: 'POST', + path: `${apiPrefix}/system/plugins`, + summary: 'Update project service configuration', + description: 'Update project service configuration', + body: UpdateSystemPluginSchema.body, + responses: UpdateSystemPluginSchema.responses, + }, + +}) diff --git a/packages/shared/src/contracts/system/settings.ts b/packages/shared/src/contracts/system/settings.ts new file mode 100644 index 0000000000..f3fefacd22 --- /dev/null +++ b/packages/shared/src/contracts/system/settings.ts @@ -0,0 +1,28 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import { apiPrefix, contractInstance } from '../../api-client.js' +import { + ListSystemSettingsSchema, + UpsertSystemSettingsSchema, +} from '../../schemas/index.js' + +export const systemSettingsContract = contractInstance.router({ + listSystemSettings: { + method: 'GET', + path: `${apiPrefix}/system/settings`, + summary: 'Get System Settings state', + description: 'Get System Settings state', + responses: ListSystemSettingsSchema.responses, + }, + + upsertSystemSettings: { + method: 'POST', + path: `${apiPrefix}/system/settings`, + contentType: 'application/json', + summary: 'Update System Settings state', + description: 'Update System Settings state', + body: UpsertSystemSettingsSchema.body, + responses: UpsertSystemSettingsSchema.responses, + }, +}) + +export type UpsertSystemSettingsBody = ClientInferRequest['body'] diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index dade3bf9ef..0c17fbf5cc 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -8,7 +8,7 @@ export * from './role.js' export * from './quota.js' export * from './repository.js' export * from './stage.js' -export * from './system.js' export * from './services.js' +export * from './system/index.js' export * from './user.js' export * from './zone.js' diff --git a/packages/shared/src/schemas/system.ts b/packages/shared/src/schemas/system.ts deleted file mode 100644 index 147bdc4ef9..0000000000 --- a/packages/shared/src/schemas/system.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { z } from 'zod' -import { ErrorSchema } from './utils.js' -import { configProjectItemDeclaration, pluginUpdateBody } from './config.js' - -export const pluginSchema = z.object({ - description: z.string() - .optional(), - title: z.string(), - name: z.string(), - imgSrc: z.string() - .optional(), - manifest: configProjectItemDeclaration.array(), -}) - -export type PluginSchema = Zod.infer - -export const SystemSettingSchema = z.object({ - maintenance: z.string().default('off'), - appName: z.string().default('Console Cloud Pi Native TEST DE FOU'), - contactMail: z.string().default('cloudpinative-relations@interieur.gouv.fr'), - appSubTitle: z.array(z.string()).default(['Ministère 1', 'de l’intérieur 2', 'et des outre-mer 3']), -}).strict() - -export const GetVersionSchema = { - responses: { - 200: z.object({ - version: z.string(), - }), - 500: ErrorSchema, - }, -} - -export const GetHealthzSchema = { - responses: { - 200: z.object({ - status: z.enum(['OK', 'KO']), - }), - 500: ErrorSchema, - }, -} - -export const GetSystemPluginSchema = { - responses: { - 200: pluginSchema.array(), - 400: ErrorSchema, - 401: ErrorSchema, - 500: ErrorSchema, - }, -} - -export const UpdateSystemPluginSchema = { - body: pluginUpdateBody, - responses: { - 204: null, - 400: ErrorSchema, - 401: ErrorSchema, - 403: ErrorSchema, - 500: ErrorSchema, - }, -} - -export const ListSystemSettingsSchema = { - query: z.object({ - key: SystemSettingSchema.keyof(), - }) - .partial().strict(), - responses: { - 200: SystemSettingSchema, - 500: ErrorSchema, - }, -} - -export const UpsertSystemSettingsSchema = { - body: SystemSettingSchema, - responses: { - 201: SystemSettingSchema, - 400: ErrorSchema, - 401: ErrorSchema, - 500: ErrorSchema, - }, -} diff --git a/packages/shared/src/schemas/system/index.ts b/packages/shared/src/schemas/system/index.ts new file mode 100644 index 0000000000..dea4584039 --- /dev/null +++ b/packages/shared/src/schemas/system/index.ts @@ -0,0 +1,3 @@ +export * from './misc.js' +export * from './plugins.js' +export * from './settings.js' diff --git a/packages/shared/src/schemas/system/misc.ts b/packages/shared/src/schemas/system/misc.ts new file mode 100644 index 0000000000..a29836a454 --- /dev/null +++ b/packages/shared/src/schemas/system/misc.ts @@ -0,0 +1,20 @@ +import { z } from 'zod' +import { ErrorSchema } from '../utils.js' + +export const GetVersionSchema = { + responses: { + 200: z.object({ + version: z.string(), + }), + 500: ErrorSchema, + }, +} + +export const GetHealthzSchema = { + responses: { + 200: z.object({ + status: z.enum(['OK', 'KO']), + }), + 500: ErrorSchema, + }, +} diff --git a/packages/shared/src/schemas/system/plugins.ts b/packages/shared/src/schemas/system/plugins.ts new file mode 100644 index 0000000000..2c462698fb --- /dev/null +++ b/packages/shared/src/schemas/system/plugins.ts @@ -0,0 +1,35 @@ +import { z } from 'zod' +import { ErrorSchema } from '../utils.js' +import { configProjectItemDeclaration, pluginUpdateBody } from '../config.js' + +export const pluginSchema = z.object({ + description: z.string() + .optional(), + title: z.string(), + name: z.string(), + imgSrc: z.string() + .optional(), + manifest: configProjectItemDeclaration.array(), +}) + +export type PluginSchema = Zod.infer + +export const GetSystemPluginSchema = { + responses: { + 200: pluginSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, +} + +export const UpdateSystemPluginSchema = { + body: pluginUpdateBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, +} diff --git a/packages/shared/src/schemas/system/settings.ts b/packages/shared/src/schemas/system/settings.ts new file mode 100644 index 0000000000..7cd3bf4780 --- /dev/null +++ b/packages/shared/src/schemas/system/settings.ts @@ -0,0 +1,35 @@ +import { z } from 'zod' +import { ErrorSchema } from '../utils.js' + +export const SystemSettingsSchema = z.object({ + maintenance: z.string(), + appName: z.string(), + contactMail: z.string(), + appSubTitle: z.string(), +}) + +export type SystemSettings = z.infer + +export const SystemSettingsDefaultSchema = z.object({ + maintenance: SystemSettingsSchema._def.shape().maintenance.default('false'), + appName: SystemSettingsSchema._def.shape().appName.default('Console Cloud Pi Native'), + contactMail: SystemSettingsSchema._def.shape().contactMail.default('cloudpinative-relations@interieur.gouv.fr'), + appSubTitle: SystemSettingsSchema._def.shape().appSubTitle.default('Ministère, de l’intérieur, et des outre-mer'), +}) + +export const ListSystemSettingsSchema = { + responses: { + 200: SystemSettingsSchema, + 500: ErrorSchema, + }, +} + +export const UpsertSystemSettingsSchema = { + body: SystemSettingsSchema.partial(), + responses: { + 201: SystemSettingsSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, +} diff --git a/packages/test-utils/src/imports/data.ts b/packages/test-utils/src/imports/data.ts index 3634ca4f73..5e42bd4c60 100644 --- a/packages/test-utils/src/imports/data.ts +++ b/packages/test-utils/src/imports/data.ts @@ -2077,7 +2077,7 @@ export const data = { systemSetting: [ { key: 'maintenance', - value: 'off', + value: 'false', }, ], associations: [