From edce12274cf7552a9a0d5e3449bdb67e79f0bf87 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 | 10 +- .../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 | 105 ------------- packages/shared/src/contracts/system/index.ts | 3 + packages/shared/src/contracts/system/misc.ts | 27 ++++ .../shared/src/contracts/system/plugins.ts | 37 +++++ .../shared/src/contracts/system/settings.ts | 34 +++++ packages/shared/src/schemas/index.ts | 2 +- packages/shared/src/schemas/system.ts | 23 --- packages/shared/src/schemas/system/index.ts | 3 + packages/shared/src/schemas/system/misc.ts | 9 ++ packages/shared/src/schemas/system/plugins.ts | 14 ++ .../shared/src/schemas/system/settings.ts | 17 +++ packages/test-utils/src/imports/data.ts | 2 +- 38 files changed, 521 insertions(+), 440 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 56e0f86e2..72d739acf 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 02fa047bc..ea5487fb4 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 313af4029..825d4bfe1 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 ef28e203f..87e2a2209 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 b79946b03..6fda1346c 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 6fb9e3439..8a9c97a60 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 a8042c109..90ec92f9e 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 bb9796e55..dd019650f 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 eb05fc8b4..98134ce77 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 075578c96..000000000 --- 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 3e9556625..000000000 --- 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 0ef492f78..f9ba7b5bf 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 000000000..243518f3b --- /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 2efc8d6d6..6fc67cd34 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 c64cb3b74..9864f501a 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 c4c6b56b6..49c73f3ef 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 { systemSettingsContract, systemSettingsDefaultSchema } 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 7b5bf3c56..9623c4edd 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 000000000..3f8260e71 --- /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 484e08262..4d45a73a1 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 000000000..e2dd67ba4 --- /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 000000000..e2d13989d --- /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 000000000..ce032fd28 --- /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 deb1bfe64..858e2494c 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 0d986867f..b78ca323b 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 bb7cb9f79..000000000 --- a/packages/shared/src/contracts/system.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { ClientInferRequest, ClientInferResponseBody } from '@ts-rest/core' -import { z } from 'zod' -import { apiPrefix, contractInstance } from '../api-client.js' -import { - SystemSettingSchema, - pluginSchema, - pluginUpdateBody, -} from '../schemas/index.js' -import { ErrorSchema, baseHeaders } from './_utils.js' - -export const systemContract = contractInstance.router({ - getVersion: { - method: 'GET', - path: `${apiPrefix}/version`, - summary: 'Get version', - description: 'Retrieve api version.', - responses: { - 200: z.object({ - version: z.string(), - }), - 500: ErrorSchema, - }, - }, - - getHealth: { - method: 'GET', - path: `${apiPrefix}/healthz`, - summary: 'Get health', - description: 'Retrieve api health infos.', - responses: { - 200: z.object({ - status: z.enum(['OK', 'KO']), - }), - 500: ErrorSchema, - }, - }, -}) - -export const systemPluginContract = contractInstance.router({ - getPluginsConfig: { - method: 'GET', - path: `${apiPrefix}/system/plugins`, - summary: 'Get plugins configuration', - description: 'Get plugins configuration', - responses: { - 200: pluginSchema.array(), - 400: ErrorSchema, - 401: ErrorSchema, - 500: ErrorSchema, - }, - }, - - updatePluginsConfig: { - method: 'POST', - path: `${apiPrefix}/system/plugins`, - summary: 'Update project service configuration', - description: 'Update project service configuration', - body: pluginUpdateBody, - responses: { - 204: null, - 400: ErrorSchema, - 401: ErrorSchema, - 403: ErrorSchema, - 500: ErrorSchema, - }, - }, -}, { - baseHeaders, -}) - -export const systemSettingsContract = contractInstance.router({ - listSystemSettings: { - method: 'GET', - path: `${apiPrefix}/system/settings`, - summary: 'Get System Settings state', - description: 'Get System Settings state', - query: SystemSettingSchema.pick({ key: true }) - .partial(), - responses: { - 200: SystemSettingSchema.array(), - 500: ErrorSchema, - }, - }, - - upsertSystemSetting: { - method: 'POST', - path: `${apiPrefix}/system/settings`, - contentType: 'application/json', - summary: 'Update System Settings state', - description: 'Update System Settings state', - body: SystemSettingSchema, - responses: { - 201: SystemSettingSchema, - 400: ErrorSchema, - 401: ErrorSchema, - 500: ErrorSchema, - }, - }, -}, { - baseHeaders, -}) - -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 000000000..dea458403 --- /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 000000000..55127db6a --- /dev/null +++ b/packages/shared/src/contracts/system/misc.ts @@ -0,0 +1,27 @@ +import { apiPrefix, contractInstance } from '../../api-client.js' +import { ErrorSchema } from '../_utils.js' +import { healthzSchema, versionSchema } from '../../schemas/index.js' + +export const systemContract = contractInstance.router({ + getVersion: { + method: 'GET', + path: `${apiPrefix}/version`, + summary: 'Get version', + description: 'Retrieve api version.', + responses: { + 200: versionSchema, + 500: ErrorSchema, + }, + }, + + getHealth: { + method: 'GET', + path: `${apiPrefix}/healthz`, + summary: 'Get health', + description: 'Retrieve api health infos.', + responses: { + 200: healthzSchema, + 500: ErrorSchema, + }, + }, +}) diff --git a/packages/shared/src/contracts/system/plugins.ts b/packages/shared/src/contracts/system/plugins.ts new file mode 100644 index 000000000..f2d8ad6a0 --- /dev/null +++ b/packages/shared/src/contracts/system/plugins.ts @@ -0,0 +1,37 @@ +import { apiPrefix, contractInstance } from '../../api-client.js' +import { + pluginUpdateBody, +} from '../../schemas/index.js' +import { ErrorSchema } from '../_utils.js' +import { pluginSchema } from '../../schemas/system/plugins.js' + +export const systemPluginContract = contractInstance.router({ + getPluginsConfig: { + method: 'GET', + path: `${apiPrefix}/system/plugins`, + summary: 'Get plugins configuration', + description: 'Get plugins configuration', + responses: { + 200: pluginSchema.array(), + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, + + updatePluginsConfig: { + method: 'POST', + path: `${apiPrefix}/system/plugins`, + summary: 'Update project service configuration', + description: 'Update project service configuration', + body: pluginUpdateBody, + responses: { + 204: null, + 400: ErrorSchema, + 401: ErrorSchema, + 403: ErrorSchema, + 500: ErrorSchema, + }, + }, + +}) diff --git a/packages/shared/src/contracts/system/settings.ts b/packages/shared/src/contracts/system/settings.ts new file mode 100644 index 000000000..5a4635d3e --- /dev/null +++ b/packages/shared/src/contracts/system/settings.ts @@ -0,0 +1,34 @@ +import type { ClientInferRequest } from '@ts-rest/core' +import { apiPrefix, contractInstance } from '../../api-client.js' +import { systemSettingsSchema } from '../../schemas/index.js' +import { ErrorSchema } from '../_utils.js' + +export const systemSettingsContract = contractInstance.router({ + listSystemSettings: { + method: 'GET', + path: `${apiPrefix}/system/settings`, + summary: 'Get System Settings state', + description: 'Get System Settings state', + responses: { + 200: systemSettingsSchema, + 500: ErrorSchema, + }, + }, + + upsertSystemSettings: { + method: 'POST', + path: `${apiPrefix}/system/settings`, + contentType: 'application/json', + summary: 'Update System Settings state', + description: 'Update System Settings state', + body: systemSettingsSchema.partial(), + responses: { + 201: systemSettingsSchema, + 400: ErrorSchema, + 401: ErrorSchema, + 500: ErrorSchema, + }, + }, +}) + +export type UpsertSystemSettingsBody = ClientInferRequest['body'] diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index dade3bf9e..0c17fbf5c 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 0d1de3ad7..000000000 --- a/packages/shared/src/schemas/system.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { z } from 'zod' -import { configProjectItemDeclaration } 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 Plugin = Zod.infer - -export const SettingsSchema = 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 type Settings = Zod.infer diff --git a/packages/shared/src/schemas/system/index.ts b/packages/shared/src/schemas/system/index.ts new file mode 100644 index 000000000..dea458403 --- /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 000000000..abb5ba437 --- /dev/null +++ b/packages/shared/src/schemas/system/misc.ts @@ -0,0 +1,9 @@ +import { z } from 'zod' + +export const versionSchema = z.object({ + version: z.string(), +}) + +export const healthzSchema = z.object({ + status: z.enum(['OK', 'KO']), +}) diff --git a/packages/shared/src/schemas/system/plugins.ts b/packages/shared/src/schemas/system/plugins.ts new file mode 100644 index 000000000..0afcfb37b --- /dev/null +++ b/packages/shared/src/schemas/system/plugins.ts @@ -0,0 +1,14 @@ +import { z } from 'zod' +import { configProjectItemDeclaration } 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 Plugin = Zod.infer diff --git a/packages/shared/src/schemas/system/settings.ts b/packages/shared/src/schemas/system/settings.ts new file mode 100644 index 000000000..34a507d0a --- /dev/null +++ b/packages/shared/src/schemas/system/settings.ts @@ -0,0 +1,17 @@ +import { z } from 'zod' + +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 π 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'), +}) diff --git a/packages/test-utils/src/imports/data.ts b/packages/test-utils/src/imports/data.ts index 3634ca4f7..5e42bd4c6 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: [