From ac6409721a1fd4e6699cb5df72ea82aae3bcbc23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Bitard?= <bitard.michael@gmail.com> Date: Tue, 19 Mar 2024 17:40:22 +0100 Subject: [PATCH] =?UTF-8?q?feat(mail):=20les=20emails=20envoy=C3=A9s=20n'u?= =?UTF-8?q?tilisent=20plus=20camino@beta.gouv.fr=20(#1080)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env-example | 15 +++++ .github/workflows/api.yml | 5 +- infra/roles/camino/templates/daily | 2 +- infra/roles/camino/templates/env | 3 +- infra/roles/camino/templates/monthly | 2 +- packages/api/package.json | 2 +- packages/api/src/@types/global.d.ts | 11 ---- .../_titre-etape-email.test.ts.snap | 4 +- .../api/src/api/graphql/resolvers/metas.ts | 3 +- .../src/api/rest/keycloak.test.integration.ts | 3 + packages/api/src/api/rest/keycloak.ts | 16 ++--- .../api/rest/utilisateurs.test.integration.ts | 3 + packages/api/src/api/rest/utilisateurs.ts | 23 +++---- .../api/src/business/utils/urls-get.test.ts | 8 +-- packages/api/src/business/utils/urls-get.ts | 5 +- packages/api/src/config/index.ts | 61 ++++++++++++++++++- packages/api/src/database/init.ts | 4 +- packages/api/src/index.ts | 34 +++++------ packages/api/src/init.ts | 4 -- packages/api/src/knex/config.ts | 11 ++-- packages/api/src/scripts/daily.ts | 22 ++++--- .../api/src/scripts/keycloak-migration.ts | 3 +- packages/api/src/scripts/matrices.ts | 13 ++-- packages/api/src/scripts/monthly.ts | 20 +++--- packages/api/src/server/auth-jwt.ts | 12 ++-- .../api/src/server/config.test.integration.ts | 4 +- packages/api/src/server/rest.ts | 24 +++++--- packages/api/src/server/user-loader.ts | 5 +- .../src/tools/api-administrations/index.ts | 18 +++--- packages/api/src/tools/api-insee/fetch.ts | 22 ++++--- packages/api/src/tools/api-mailjet/emails.ts | 12 ++-- packages/api/src/tools/api-mailjet/index.ts | 5 +- .../api/src/tools/api-mailjet/newsletter.ts | 20 +++--- packages/api/src/tools/api-openfisca/index.ts | 7 +-- packages/api/test-env.ts | 32 ++++++++++ packages/api/tests/_utils/index.ts | 6 +- packages/api/tests/db-manager.ts | 5 +- packages/api/vitest.integration.config.ts | 2 + packages/api/vitest.unit.config.ts | 2 + packages/common/src/static/config.ts | 9 ++- packages/ui/src/index.ts | 17 +++--- 41 files changed, 300 insertions(+), 179 deletions(-) create mode 100644 packages/api/test-env.ts diff --git a/.env-example b/.env-example index 439c5b01f..9274155ee 100644 --- a/.env-example +++ b/.env-example @@ -53,6 +53,7 @@ ADMIN_EMAIL=email@domaine.com # email API_MAILJET_EMAIL=email@domaine.com +API_MAILJET_REPLY_TO_EMAIL=email@domaine.com API_MAILJET_KEY= API_MAILJET_SECRET= API_MAILJET_SERVER=in-v3.mailjet.com @@ -64,3 +65,17 @@ API_OPENFISCA_URL="http://localhost:8000" # certificat ssh (docker compose) LETSENCRYPT_EMAIL="" +APPLICATION_VERSION=version + + +API_INSEE_URL=something +API_INSEE_KEY=something +API_INSEE_SECRET=something + +SENTRY_DSN=something +API_MATOMO_URL=something +API_MATOMO_ID=something + +API_ADMINISTRATION_URL="http://something" + +OAUTH_URL="http://localhost:4180" \ No newline at end of file diff --git a/.github/workflows/api.yml b/.github/workflows/api.yml index 62169cb4f..af473c4ff 100644 --- a/.github/workflows/api.yml +++ b/.github/workflows/api.yml @@ -127,7 +127,10 @@ jobs: cache: 'npm' - name: Npm install run: make install - + - uses: cardinalby/export-env-action@v2 + with: + envFile: '.env-example' + expand: 'true' - name: Migrate database and check queries run: | make db/migrate diff --git a/infra/roles/camino/templates/daily b/infra/roles/camino/templates/daily index fa7ffdc20..330c6d3a1 100644 --- a/infra/roles/camino/templates/daily +++ b/infra/roles/camino/templates/daily @@ -1,3 +1,3 @@ #!/bin/bash -docker exec -t -e CAMINO_STAGE=true camino_api_app make daily \ No newline at end of file +docker exec -t -e CAMINO_STAGE={{ env }} camino_api_app make daily \ No newline at end of file diff --git a/infra/roles/camino/templates/env b/infra/roles/camino/templates/env index 4d710e0b6..2c2dca064 100644 --- a/infra/roles/camino/templates/env +++ b/infra/roles/camino/templates/env @@ -55,7 +55,8 @@ ADMIN_EMAIL=camino@beta.gouv.fr # API Mailjet -API_MAILJET_EMAIL=camino@beta.gouv.fr +API_MAILJET_REPLY_TO_EMAIL=camino@beta.gouv.fr +API_MAILJET_EMAIL=contact@camino.beta.gouv.fr API_MAILJET_KEY={{ mailjet_key }} API_MAILJET_SECRET={{ mailjet_secret }} API_MAILJET_SERVER=in-v3.mailjet.com diff --git a/infra/roles/camino/templates/monthly b/infra/roles/camino/templates/monthly index e416375cb..c33aadc23 100644 --- a/infra/roles/camino/templates/monthly +++ b/infra/roles/camino/templates/monthly @@ -1,3 +1,3 @@ #!/bin/bash -docker exec -t -e CAMINO_STAGE=true camino_api_app make monthly \ No newline at end of file +docker exec -t -e CAMINO_STAGE={{ env }} camino_api_app make monthly \ No newline at end of file diff --git a/packages/api/package.json b/packages/api/package.json index 0cd4623af..965b4f211 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -28,7 +28,7 @@ "start": "node --loader ts-node/esm/transpile-only ./src/index.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest --environment node --root src/ --config ../vitest.unit.config.ts", - "test:integration": "NODE_OPTIONS='--loader ts-node/esm/transpile-only' JWT_SECRET=secret-tests vitest --environment node --root src/ --config ../vitest.integration.config.ts", + "test:integration": "NODE_OPTIONS='--loader ts-node/esm/transpile-only' vitest --environment node --root src/ --config ../vitest.integration.config.ts", "test:generate-data": "node --loader ts-node/esm/transpile-only src/tools/demarches/tests-creation.ts", "test:generate-sections-data": "node --loader ts-node/esm/transpile-only src/tools/activites/tests-creation.ts", "ci:lint": "prettier --check . && eslint .", diff --git a/packages/api/src/@types/global.d.ts b/packages/api/src/@types/global.d.ts index f4ef5db26..f4cd56cec 100644 --- a/packages/api/src/@types/global.d.ts +++ b/packages/api/src/@types/global.d.ts @@ -8,15 +8,4 @@ declare global { interface Array<T> { includes<U>(_searchElement: U & (T & U extends never ? never : unknown), _fromIndex?: number): boolean } - namespace NodeJS { - interface ProcessEnv { - CAMINO_STAGE?: 'dev' | 'preprod' | 'prod' - SENTRY_DSN?: string - ENV: string - UI_HOST: string - API_MATOMO_URL: string - API_MATOMO_ID: string - APPLICATION_VERSION: string - } - } } diff --git a/packages/api/src/api/graphql/resolvers/__snapshots__/_titre-etape-email.test.ts.snap b/packages/api/src/api/graphql/resolvers/__snapshots__/_titre-etape-email.test.ts.snap index 99c7fcfbd..c4a9d1377 100644 --- a/packages/api/src/api/graphql/resolvers/__snapshots__/_titre-etape-email.test.ts.snap +++ b/packages/api/src/api/graphql/resolvers/__snapshots__/_titre-etape-email.test.ts.snap @@ -7,7 +7,7 @@ exports[`envoie un email sur un octroi d'AEX 1`] = ` <hr> - <b>Lien</b> : <a href=\\"undefined/titres/titreId\\">undefined/titres/titreId</a> <br> + <b>Lien</b> : <a href=\\"http://plop.plop/titres/titreId\\">http://plop.plop/titres/titreId</a> <br> <b>Effectué par</b> : Camino (camino@beta.gouv.fr)<br> ", @@ -25,7 +25,7 @@ exports[`envoie un email sur un octroi d'ARM 1`] = ` <hr> - <b>Lien</b> : <a href=\\"undefined/titres/titreId\\">undefined/titres/titreId</a> <br> + <b>Lien</b> : <a href=\\"http://plop.plop/titres/titreId\\">http://plop.plop/titres/titreId</a> <br> <b>Effectué par</b> : Camino (camino@beta.gouv.fr)<br> ", diff --git a/packages/api/src/api/graphql/resolvers/metas.ts b/packages/api/src/api/graphql/resolvers/metas.ts index e5ebcda13..36d4fc44b 100644 --- a/packages/api/src/api/graphql/resolvers/metas.ts +++ b/packages/api/src/api/graphql/resolvers/metas.ts @@ -13,6 +13,7 @@ import { sortedAdministrationTypes } from 'camino-common/src/static/administrati import { sortedDomaines } from 'camino-common/src/static/domaines.js' import { sortedTitreTypesTypes } from 'camino-common/src/static/titresTypesTypes.js' import { sortedDocumentTypes } from 'camino-common/src/static/documentsTypes.js' +import { config } from '../../../config/index.js' export const devises = async () => devisesGet() @@ -44,7 +45,7 @@ export const demarchesStatuts = async () => { export const etapesStatuts = () => Object.values(EtapesStatuts) -export const version = () => process.env.APPLICATION_VERSION +export const version = () => config().APPLICATION_VERSION /** * Retourne les types d'administrations diff --git a/packages/api/src/api/rest/keycloak.test.integration.ts b/packages/api/src/api/rest/keycloak.test.integration.ts index 3d42ca3c6..7fe70af11 100644 --- a/packages/api/src/api/rest/keycloak.test.integration.ts +++ b/packages/api/src/api/rest/keycloak.test.integration.ts @@ -4,6 +4,7 @@ import { restCall } from '../../../tests/_utils/index.js' import { test, expect, vi, beforeAll, afterAll } from 'vitest' import type { Pool } from 'pg' import { HTTP_STATUS } from 'camino-common/src/http.js' +import { renewConfig } from '../../config/index.js' console.info = vi.fn() console.error = vi.fn() @@ -22,6 +23,7 @@ afterAll(async () => { test('deconnecter', async () => { process.env.KEYCLOAK_LOGOUT_URL = 'https://notexisting.url' process.env.OAUTH_URL = 'https://another.notexisting.url' + renewConfig() const tested = await restCall(dbPool, '/deconnecter', {}, userSuper) expect(tested.statusCode).toBe(HTTP_STATUS.HTTP_STATUS_FOUND) @@ -29,6 +31,7 @@ test('deconnecter', async () => { test('resetPassword', async () => { process.env.KEYCLOAK_RESET_PASSWORD_URL = 'https://notexisting.url' + renewConfig() const tested = await restCall(dbPool, '/changerMotDePasse', {}, userSuper) expect(tested.statusCode).toBe(HTTP_STATUS.HTTP_STATUS_FOUND) diff --git a/packages/api/src/api/rest/keycloak.ts b/packages/api/src/api/rest/keycloak.ts index a2b5dc723..3b266d378 100644 --- a/packages/api/src/api/rest/keycloak.ts +++ b/packages/api/src/api/rest/keycloak.ts @@ -1,15 +1,17 @@ import { Pool } from 'pg' import { CaminoRequest, CustomResponse } from './express-type' +import { config } from '../../config/index.js' +import { isNullOrUndefined } from 'camino-common/src/typescript-tools.js' export const logout = (_pool: Pool) => async (req: CaminoRequest, res: CustomResponse<string>) => { const authorizationToken = req.header('authorization') - if (!authorizationToken) { + if (isNullOrUndefined(authorizationToken)) { res.sendStatus(403) } else { const token = authorizationToken.substring(7) - const uiUrl = process.env.OAUTH_URL ?? '' + const uiUrl = config().OAUTH_URL - const keycloakLogoutUrl = new URL(process.env.KEYCLOAK_LOGOUT_URL ?? '') + const keycloakLogoutUrl = new URL(config().KEYCLOAK_LOGOUT_URL) keycloakLogoutUrl.searchParams.append('post_logout_redirect_uri', uiUrl) keycloakLogoutUrl.searchParams.append('id_token_hint', token) @@ -21,14 +23,14 @@ export const logout = (_pool: Pool) => async (req: CaminoRequest, res: CustomRes } export const resetPassword = (_pool: Pool) => async (req: CaminoRequest, res: CustomResponse<string>) => { const authorizationToken = req.header('authorization') - if (!authorizationToken) { + if (isNullOrUndefined(authorizationToken)) { res.sendStatus(403) } else { - const uiUrl = process.env.OAUTH_URL ?? '' + const uiUrl = config().OAUTH_URL - const resetPasswordUrl = new URL(process.env.KEYCLOAK_RESET_PASSWORD_URL ?? '') + const resetPasswordUrl = new URL(config().KEYCLOAK_RESET_PASSWORD_URL) resetPasswordUrl.searchParams.append('response_type', 'code') - resetPasswordUrl.searchParams.append('client_id', process.env.KEYCLOAK_CLIENT_ID ?? '') + resetPasswordUrl.searchParams.append('client_id', config().KEYCLOAK_CLIENT_ID) resetPasswordUrl.searchParams.append('kc_action', 'UPDATE_PASSWORD') resetPasswordUrl.searchParams.append('redirect_uri', uiUrl) diff --git a/packages/api/src/api/rest/utilisateurs.test.integration.ts b/packages/api/src/api/rest/utilisateurs.test.integration.ts index 2de7f8325..1ddb07d84 100644 --- a/packages/api/src/api/rest/utilisateurs.test.integration.ts +++ b/packages/api/src/api/rest/utilisateurs.test.integration.ts @@ -8,6 +8,7 @@ import { HTTP_STATUS } from 'camino-common/src/http.js' import { userSuper } from '../../database/user-super.js' import { newUtilisateurId } from '../../database/models/_format/id-create.js' import { KeycloakFakeServer, idUserKeycloakRecognised, setupKeycloak, teardownKeycloak } from '../../../tests/keycloak.js' +import { renewConfig } from '../../config/index.js' console.info = vi.fn() console.error = vi.fn() @@ -19,6 +20,7 @@ beforeAll(async () => { dbPool = pool knex = knexInstance keycloak = await setupKeycloak() + renewConfig() }) afterAll(async () => { @@ -97,6 +99,7 @@ describe('utilisateurSupprimer', () => { test('peut supprimer son compte utilisateur', async () => { const OAUTH_URL = 'http://unused' process.env.OAUTH_URL = OAUTH_URL + renewConfig() const user = await userGenerate({ role: 'defaut' }) const tested = await restCall(dbPool, '/rest/utilisateurs/:id/delete', { id: user.id }, { role: 'defaut' }) diff --git a/packages/api/src/api/rest/utilisateurs.ts b/packages/api/src/api/rest/utilisateurs.ts index 9b9fc3105..8ccb52da8 100644 --- a/packages/api/src/api/rest/utilisateurs.ts +++ b/packages/api/src/api/rest/utilisateurs.ts @@ -16,6 +16,7 @@ import { canDeleteUtilisateur } from 'camino-common/src/permissions/utilisateurs import { DownloadFormat } from 'camino-common/src/rest.js' import { Pool } from 'pg' import { isNotNullNorUndefined, isNullOrUndefined } from 'camino-common/src/typescript-tools.js' +import { config } from '../../config/index.js' export const isSubscribedToNewsletter = (_pool: Pool) => async (req: CaminoRequest, res: CustomResponse<boolean>) => { const user = req.auth @@ -73,9 +74,9 @@ export const updateUtilisateurPermission = (_pool: Pool) => async (req: CaminoRe export type KeycloakAccessTokenResponse = { access_token: string } export const getKeycloakApiToken = async (): Promise<string> => { - const client_id = process.env.KEYCLOAK_API_CLIENT_ID - const client_secret = process.env.KEYCLOAK_API_CLIENT_SECRET - const url = process.env.KEYCLOAK_URL + const client_id = config().KEYCLOAK_API_CLIENT_ID + const client_secret = config().KEYCLOAK_API_CLIENT_SECRET + const url = config().KEYCLOAK_URL if (!client_id || !client_secret || !url) { throw new Error('variables KEYCLOAK_API_CLIENT_ID and KEYCLOAK_API_CLIENT_SECRET and KEYCLOAK_URL must be set') @@ -124,7 +125,7 @@ export const deleteUtilisateur = (_pool: Pool) => async (req: CaminoRequest, res const authorizationToken = await getKeycloakApiToken() - const deleteFromKeycloak = await fetch(`${process.env.KEYCLOAK_URL}/admin/realms/Camino/users/${utilisateurKeycloakId}`, { + const deleteFromKeycloak = await fetch(`${config().KEYCLOAK_URL}/admin/realms/Camino/users/${utilisateurKeycloakId}`, { method: 'DELETE', headers: { authorization: `Bearer ${authorizationToken}`, @@ -146,7 +147,7 @@ export const deleteUtilisateur = (_pool: Pool) => async (req: CaminoRequest, res await utilisateurUpsert(utilisateur) if (isNotNullNorUndefined(user) && user.id === req.params.id) { - const uiUrl = process.env.OAUTH_URL + const uiUrl = config().OAUTH_URL const oauthLogoutUrl = new URL(`${uiUrl}/oauth2/sign_out`) res.redirect(oauthLogoutUrl.href) } else { @@ -211,7 +212,7 @@ export const generateQgisToken = (_pool: Pool) => async (req: CaminoRequest, res await knex('utilisateurs') .update({ qgis_token: bcrypt.hashSync(token, 10) }) .where('email', user.email) - res.send({ token, url: `https://${user.email.replace('@', '%40')}:${token}@${process.env.API_HOST ?? 'api.camino.beta.gouv.fr'}/titres_qgis` }) + res.send({ token, url: `https://${user.email.replace('@', '%40')}:${token}@${config().API_HOST}/titres_qgis` }) } } @@ -220,9 +221,9 @@ interface IUtilisateursQueryInput { colonne?: IUtilisateursColonneId | null ordre?: 'asc' | 'desc' | null entrepriseIds?: string | string[] - administrationIds?: string + administrationIds?: string | string[] // TODO 2022-06-14: utiliser un tableau de string plutôt qu'une chaine séparée par des ',' - roles?: string + roles?: string | string[] noms?: string | null nomsUtilisateurs?: string | null emails?: string | null @@ -235,9 +236,9 @@ export const utilisateurs = { colonne, ordre, - entreprisesIds: entrepriseIds ? (Array.isArray(entrepriseIds) ? entrepriseIds : entrepriseIds.split(',')) : undefined, - administrationIds: administrationIds ? (Array.isArray(administrationIds) ? administrationIds : administrationIds.split(',')) : undefined, - roles: roles ? (Array.isArray(roles) ? roles.filter(isRole) : roles.split(',').filter(isRole)) : undefined, + entreprisesIds: isNotNullNorUndefined(entrepriseIds) ? (Array.isArray(entrepriseIds) ? entrepriseIds : entrepriseIds.split(',')) : undefined, + administrationIds: isNotNullNorUndefined(administrationIds) ? (Array.isArray(administrationIds) ? administrationIds : administrationIds.split(',')) : undefined, + roles: isNotNullNorUndefined(roles) ? (Array.isArray(roles) ? roles.filter(isRole) : roles.split(',').filter(isRole)) : undefined, noms: noms ?? nomsUtilisateurs, emails, }, diff --git a/packages/api/src/business/utils/urls-get.test.ts b/packages/api/src/business/utils/urls-get.test.ts index c1b55bcce..29cc026c5 100644 --- a/packages/api/src/business/utils/urls-get.test.ts +++ b/packages/api/src/business/utils/urls-get.test.ts @@ -4,10 +4,10 @@ import { CaminoAnnee, toCaminoAnnee } from 'camino-common/src/date.js' describe('activitesUrlGet', () => { test.each<[{ typesIds?: string[]; statutsIds?: string[]; annees?: CaminoAnnee[] } | undefined, string]>([ - [undefined, 'https://camino.beta.gouv.fr/activites?page=1&intervalle=200&ordre=asc'], - [{ typesIds: ['toto'] }, 'https://camino.beta.gouv.fr/activites?page=1&intervalle=200&ordre=asc&typesIds=toto'], - [{ typesIds: ['toto', 'tata'] }, 'https://camino.beta.gouv.fr/activites?page=1&intervalle=200&ordre=asc&typesIds=toto%2Ctata'], - [{ typesIds: ['toto'], annees: [toCaminoAnnee(2010)] }, 'https://camino.beta.gouv.fr/activites?page=1&intervalle=200&ordre=asc&typesIds=toto&annees=2010'], + [undefined, 'http://plop.plop/activites?page=1&intervalle=200&ordre=asc'], + [{ typesIds: ['toto'] }, 'http://plop.plop/activites?page=1&intervalle=200&ordre=asc&typesIds=toto'], + [{ typesIds: ['toto', 'tata'] }, 'http://plop.plop/activites?page=1&intervalle=200&ordre=asc&typesIds=toto%2Ctata'], + [{ typesIds: ['toto'], annees: [toCaminoAnnee(2010)] }, 'http://plop.plop/activites?page=1&intervalle=200&ordre=asc&typesIds=toto&annees=2010'], ])('test la construction de l url des activités', (params, url) => { expect(activitesUrlGet(params)).toEqual(url) }) diff --git a/packages/api/src/business/utils/urls-get.ts b/packages/api/src/business/utils/urls-get.ts index 2d31b905d..f84941f44 100644 --- a/packages/api/src/business/utils/urls-get.ts +++ b/packages/api/src/business/utils/urls-get.ts @@ -1,9 +1,10 @@ import { CaminoAnnee } from 'camino-common/src/date.js' +import { config } from '../../config/index.js' -export const titreUrlGet = (titreId: string) => `${process.env.OAUTH_URL}/titres/${titreId}` +export const titreUrlGet = (titreId: string) => `${config().OAUTH_URL}/titres/${titreId}` export const activitesUrlGet = (params?: { typesIds?: string[]; statutsIds?: string[]; annees?: CaminoAnnee[] }): string => { - const url = new URL(`${process.env.OAUTH_URL ?? 'https://camino.beta.gouv.fr'}/activites`) + const url = new URL(`${config().OAUTH_URL}/activites`) url.searchParams.append('page', '1') url.searchParams.append('intervalle', '200') diff --git a/packages/api/src/config/index.ts b/packages/api/src/config/index.ts index f75391c46..dfe90b47a 100644 --- a/packages/api/src/config/index.ts +++ b/packages/api/src/config/index.ts @@ -1,4 +1,59 @@ -const port = Number(process.env.API_PORT) -const url = `http://localhost:${port}/` +import dotenv from 'dotenv' +import { resolve } from 'node:path' +import { caminoAnneeValidator, getCurrentAnnee } from 'camino-common/src/date.js' +import { caminoConfigValidator } from 'camino-common/src/static/config.js' +import { isNullOrUndefined } from 'camino-common/src/typescript-tools.js' +import { z } from 'zod' -export { port, url } +dotenv.config({ path: resolve(process.cwd(), '../../.env') }) + +const JWT_ALGORITHMS = ['HS256', 'HS384', 'HS512', 'RS256', 'RS384', 'RS512', 'ES256', 'ES384', 'ES512', 'PS256', 'PS384', 'PS512', 'none'] as const + +const configValidator = caminoConfigValidator.extend({ + API_HOST: z.string(), + KEYCLOAK_API_CLIENT_ID: z.string(), + KEYCLOAK_API_CLIENT_SECRET: z.string(), + KEYCLOAK_URL: z.string().url(), + KEYCLOAK_RESET_PASSWORD_URL: z.string(), + KEYCLOAK_CLIENT_ID: z.string(), + KEYCLOAK_LOGOUT_URL: z.string(), + OAUTH_URL: z.string().url(), + NODE_ENV: z.string(), + APPLICATION_VERSION: z.string(), + API_SENTRY_URL: z.string().optional(), + PGHOST: z.string(), + PGPORT: z.coerce.number().default(5432), + PGUSER: z.string(), + PGPASSWORD: z.string(), + PGDATABASE: z.string(), + API_PORT: z.coerce.number(), + ENV: z.enum(['prod', 'preprod', 'dev']).default('dev'), + ADMIN_EMAIL: z.string().email(), + ANNEE: caminoAnneeValidator.default(getCurrentAnnee()), + JWT_SECRET: z.string(), + JWT_SECRET_ALGORITHM: z.enum(JWT_ALGORITHMS).default('HS256'), + API_INSEE_URL: z.string(), + API_INSEE_KEY: z.string(), + API_INSEE_SECRET: z.string(), + API_MAILJET_EMAIL: z.string().email(), + API_MAILJET_REPLY_TO_EMAIL: z.string().email(), + API_ADMINISTRATION_URL: z.string().url(), + API_MAILJET_KEY: z.string(), + API_MAILJET_SECRET: z.string(), + API_MAILJET_CONTACTS_LIST_ID: z.coerce.number(), + API_MAILJET_EXPLOITANTS_GUYANE_LIST_ID: z.coerce.number(), + API_OPENFISCA_URL: z.string().url(), +}) + +let cacheConfig: z.infer<typeof configValidator> +export const renewConfig = () => { + cacheConfig = configValidator.parse(process.env) +} + +export const config = (): z.infer<typeof configValidator> => { + if (isNullOrUndefined(cacheConfig)) { + renewConfig() + } + + return cacheConfig +} diff --git a/packages/api/src/database/init.ts b/packages/api/src/database/init.ts index 057dc5a81..a29a8080c 100644 --- a/packages/api/src/database/init.ts +++ b/packages/api/src/database/init.ts @@ -1,10 +1,12 @@ import { knex } from '../knex.js' import { daily } from '../business/daily.js' import type { Pool } from 'pg' +import { config } from '../config/index.js' +import { isNotNullNorUndefined } from 'camino-common/src/typescript-tools.js' export const databaseInit = async (pool: Pool) => { await knex.migrate.latest() - if (process.env.CAMINO_STAGE) { + if (isNotNullNorUndefined(config().CAMINO_STAGE)) { // pas de await pour ne pas bloquer le démarrage de l’appli daily(pool) } diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 87c23783e..8d2b2501f 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -15,7 +15,7 @@ import express from 'express' import rateLimit from 'express-rate-limit' import * as Sentry from '@sentry/node' -import { port, url } from './config/index.js' +import { config } from './config/index.js' import { restWithPool } from './server/rest.js' import { graphql } from './server/graphql.js' import { authJwt } from './server/auth-jwt.js' @@ -30,12 +30,13 @@ import { connectedCatcher } from './server/connected-catcher.js' import cookieParser from 'cookie-parser' import pg from 'pg' import qs from 'qs' +import { isNotNullNorUndefined } from 'camino-common/src/typescript-tools.js' // Le pool ne doit être qu'aux entrypoints : le daily, le monthly, et l'application. const pool = new pg.Pool({ - host: process.env.PGHOST, - user: process.env.PGUSER, - password: process.env.PGPASSWORD, - database: process.env.PGDATABASE, + host: config().PGHOST, + user: config().PGUSER, + password: config().PGPASSWORD, + database: config().PGDATABASE, idleTimeoutMillis: 60000, }) @@ -47,10 +48,10 @@ databaseInit(pool).then(() => { app.set('query parser', function (str: string) { return qs.parse(str, { comma: true }) }) - if (process.env.API_SENTRY_URL) { + if (isNotNullNorUndefined(config().API_SENTRY_URL)) { Sentry.init({ - dsn: process.env.API_SENTRY_URL, - environment: process.env.ENV === 'prod' ? 'production' : process.env.ENV, + dsn: config().API_SENTRY_URL, + environment: config().ENV === 'prod' ? 'production' : config().ENV, }) app.use(Sentry.Handlers.requestHandler()) } @@ -80,7 +81,7 @@ databaseInit(pool).then(() => { res.writeHead(200, headers) res.write(`id: ${Date.now()}\n`) res.write(`event: version\n`) - res.write(`data: ${process.env.APPLICATION_VERSION}\n\n`) + res.write(`data: ${config().APPLICATION_VERSION}\n\n`) res.flush() let counter = 0 const interValID = setInterval(() => { @@ -93,7 +94,7 @@ databaseInit(pool).then(() => { } res.write(`id: ${Date.now()}\n`) res.write(`event: version\n`) - res.write(`data: ${process.env.APPLICATION_VERSION}\n\n`) + res.write(`data: ${config().APPLICATION_VERSION}\n\n`) res.flush() }, ssePingDelayInSeconds * 1000) @@ -109,19 +110,14 @@ databaseInit(pool).then(() => { app.use('/', graphqlUpload, graphql(pool)) - if (process.env.API_SENTRY_URL) { + if (isNotNullNorUndefined(config().API_SENTRY_URL)) { app.use(Sentry.Handlers.errorHandler()) } - app.listen(port, () => { + app.listen(config().API_PORT, () => { console.info('') - console.info('URL:', url) - console.info('ENV:', process.env.ENV) - console.info('NODE_ENV:', process.env.NODE_ENV) - - if (process.env.NODE_DEBUG === 'true') { - console.warn('NODE_DEBUG:', process.env.NODE_DEBUG) - } + console.info('ENV:', config().ENV) + console.info('NODE_ENV:', config().NODE_ENV) console.info('') }) }) diff --git a/packages/api/src/init.ts b/packages/api/src/init.ts index 96445e1d4..4cc3abb1f 100644 --- a/packages/api/src/init.ts +++ b/packages/api/src/init.ts @@ -1,8 +1,4 @@ -import dotenv from 'dotenv' -import { resolve } from 'path' import { knexInit } from './knex.js' import { knexConfig } from './knex/config.js' -dotenv.config({ path: resolve(process.cwd(), '../../.env') }) - knexInit(knexConfig) diff --git a/packages/api/src/knex/config.ts b/packages/api/src/knex/config.ts index 5a2343fad..5f530efd0 100644 --- a/packages/api/src/knex/config.ts +++ b/packages/api/src/knex/config.ts @@ -2,14 +2,15 @@ import 'dotenv/config' import { knexSnakeCaseMappers } from 'objection' import path, { join } from 'node:path' import { fileURLToPath } from 'url' +import { config } from '../config/index.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const connection = { - host: process.env.PGHOST, - port: Number(process.env.PGPORT), - database: process.env.PGDATABASE, - user: process.env.PGUSER, - password: process.env.PGPASSWORD, + host: config().PGHOST, + port: config().PGPORT, + database: config().PGDATABASE, + user: config().PGUSER, + password: config().PGPASSWORD, } export const simpleKnexConfig = { diff --git a/packages/api/src/scripts/daily.ts b/packages/api/src/scripts/daily.ts index 9f58a40db..d1bfcca5a 100644 --- a/packages/api/src/scripts/daily.ts +++ b/packages/api/src/scripts/daily.ts @@ -7,11 +7,13 @@ import { readFileSync, writeFileSync, createWriteStream } from 'fs' import { documentsClean } from '../tools/documents/clean.js' import * as Console from 'console' import pg from 'pg' +import { config } from '../config/index.js' +import { isNotNullNorUndefined } from 'camino-common/src/typescript-tools.js' const logFile = '/tmp/cron.log' const output = createWriteStream(logFile) -if (process.env.CAMINO_STAGE) { +if (isNotNullNorUndefined(config().CAMINO_STAGE)) { const logger = new Console.Console({ stdout: output, stderr: output }) // eslint-disable-next-line no-console console.log = logger.log @@ -20,10 +22,10 @@ if (process.env.CAMINO_STAGE) { // Le pool ne doit être qu'aux entrypoints : le daily, le monthly, et l'application. const pool = new pg.Pool({ - host: process.env.PGHOST, - user: process.env.PGUSER, - password: process.env.PGPASSWORD, - database: process.env.PGDATABASE, + host: config().PGHOST, + user: config().PGUSER, + password: config().PGPASSWORD, + database: config().PGDATABASE, }) const tasks = async () => { @@ -32,7 +34,7 @@ const tasks = async () => { writeFileSync(logFile, '') try { await daily(pool) - if (process.env.CAMINO_STAGE) { + if (isNotNullNorUndefined(config().CAMINO_STAGE)) { await documentsClean(pool) await documentsCheck(pool) } @@ -40,10 +42,10 @@ const tasks = async () => { console.error('Erreur durant le daily', e) } - if (process.env.CAMINO_STAGE) { - const emailBody = `Résultats de ${process.env.ENV} \n${readFileSync(logFile).toString()}` - await mailjetSend([process.env.ADMIN_EMAIL!], { - Subject: `[Camino][${process.env.ENV}] Résultats du daily`, + if (isNotNullNorUndefined(config().CAMINO_STAGE)) { + const emailBody = `Résultats de ${config().ENV} \n${readFileSync(logFile).toString()}` + await mailjetSend([config().ADMIN_EMAIL], { + Subject: `[Camino][${config().ENV}] Résultats du daily`, 'Text-part': emailBody, }) } diff --git a/packages/api/src/scripts/keycloak-migration.ts b/packages/api/src/scripts/keycloak-migration.ts index 2799e2eca..1c1a20354 100644 --- a/packages/api/src/scripts/keycloak-migration.ts +++ b/packages/api/src/scripts/keycloak-migration.ts @@ -1,10 +1,11 @@ import { getKeycloakApiToken } from '../api/rest/utilisateurs.js' +import { config } from '../config/index.js' import '../init.js' import { knex } from '../knex.js' const migrate = async (): Promise<void> => { const token = await getKeycloakApiToken() - const url = process.env.KEYCLOAK_URL + const url = config().KEYCLOAK_URL if (!url) { throw new Error('variables KEYCLOAK_API_CLIENT_ID and KEYCLOAK_API_CLIENT_SECRET and KEYCLOAK_URL must be set') diff --git a/packages/api/src/scripts/matrices.ts b/packages/api/src/scripts/matrices.ts index 3184ed13a..4c0b07695 100644 --- a/packages/api/src/scripts/matrices.ts +++ b/packages/api/src/scripts/matrices.ts @@ -1,16 +1,15 @@ -import { getCurrentAnnee, toCaminoAnnee } from 'camino-common/src/date.js' import { matrices } from '../business/matrices.js' import pg from 'pg' -import { isNotNullNorUndefined } from 'camino-common/src/typescript-tools.js' +import { config } from '../config/index.js' // Le pool ne doit être qu'aux entrypoints : le daily, le monthly, et l'application. const pool = new pg.Pool({ - host: process.env.PGHOST, - user: process.env.PGUSER, - password: process.env.PGPASSWORD, - database: process.env.PGDATABASE, + host: config().PGHOST, + user: config().PGUSER, + password: config().PGPASSWORD, + database: config().PGDATABASE, }) -matrices(isNotNullNorUndefined(process.env.ANNEE) ? toCaminoAnnee(process.env.ANNEE) : getCurrentAnnee(), pool) +matrices(config().ANNEE, pool) .then(() => { process.exit() }) diff --git a/packages/api/src/scripts/monthly.ts b/packages/api/src/scripts/monthly.ts index 22ba514eb..366ac2f7c 100644 --- a/packages/api/src/scripts/monthly.ts +++ b/packages/api/src/scripts/monthly.ts @@ -5,19 +5,21 @@ import { readFileSync, writeFileSync, createWriteStream } from 'fs' import * as Console from 'console' import { monthly } from '../business/monthly.js' import pg from 'pg' +import { config } from '../config/index.js' +import { isNotNullNorUndefined } from 'camino-common/src/typescript-tools.js' const logFile = '/tmp/monthly.log' // Le pool ne doit être qu'aux entrypoints : le daily, le monthly, et l'application. const pool = new pg.Pool({ - host: process.env.PGHOST, - user: process.env.PGUSER, - password: process.env.PGPASSWORD, - database: process.env.PGDATABASE, + host: config().PGHOST, + user: config().PGUSER, + password: config().PGPASSWORD, + database: config().PGDATABASE, }) const output = createWriteStream(logFile) -if (process.env.CAMINO_STAGE) { +if (isNotNullNorUndefined(config().CAMINO_STAGE)) { const logger = new Console.Console({ stdout: output, stderr: output }) // eslint-disable-next-line no-console console.log = logger.log @@ -34,10 +36,10 @@ const tasks = async () => { console.error('Erreur durant le monthly', e) } - if (process.env.CAMINO_STAGE) { - const emailBody = `Résultats de ${process.env.ENV} \n${readFileSync(logFile).toString()}` - await mailjetSend([process.env.ADMIN_EMAIL!], { - Subject: `[Camino][${process.env.ENV}] Résultats du monthly`, + if (isNotNullNorUndefined(config().CAMINO_STAGE)) { + const emailBody = `Résultats de ${config().ENV} \n${readFileSync(logFile).toString()}` + await mailjetSend([config().ADMIN_EMAIL], { + Subject: `[Camino][${config().ENV}] Résultats du monthly`, 'Text-part': emailBody, }) } diff --git a/packages/api/src/server/auth-jwt.ts b/packages/api/src/server/auth-jwt.ts index d2e43da08..7c1b000b1 100644 --- a/packages/api/src/server/auth-jwt.ts +++ b/packages/api/src/server/auth-jwt.ts @@ -1,20 +1,20 @@ -import { Algorithm } from 'jsonwebtoken' import { expressjwt } from 'express-jwt' import { CaminoRequest } from '../api/rest/express-type' +import { config } from '../config/index.js' +import { isNotNullNorUndefined } from 'camino-common/src/typescript-tools.js' export const authJwt = expressjwt({ credentialsRequired: false, getToken: (req: CaminoRequest) => { - if (req.headers) { + if (isNotNullNorUndefined(req.headers)) { const token = req.headers['x-forwarded-access-token'] - if (token) { + if (isNotNullNorUndefined(token)) { return Array.isArray(token) ? token[0] : token } } return undefined }, - secret: process.env.JWT_SECRET || 'jwtSecret should be declared in .env', - // TODO 2023-03-15: vérifier ça au runtime ? - algorithms: [(process.env.JWT_SECRET_ALGORITHM as Algorithm) || 'HS256'], + secret: config().JWT_SECRET, + algorithms: [config().JWT_SECRET_ALGORITHM], }) diff --git a/packages/api/src/server/config.test.integration.ts b/packages/api/src/server/config.test.integration.ts index 1d178abd3..261ffc63b 100644 --- a/packages/api/src/server/config.test.integration.ts +++ b/packages/api/src/server/config.test.integration.ts @@ -10,7 +10,9 @@ describe('config', () => { const tested = await restCall(null as unknown as Pool, '/config', {}, undefined) expect(tested.body).toMatchInlineSnapshot(` { - "environment": "dev", + "API_MATOMO_ID": "plop", + "API_MATOMO_URL": "plop", + "SENTRY_DSN": "plop", } `) }) diff --git a/packages/api/src/server/rest.ts b/packages/api/src/server/rest.ts index cae62ae83..31375be77 100644 --- a/packages/api/src/server/rest.ts +++ b/packages/api/src/server/rest.ts @@ -51,6 +51,7 @@ import { geojsonImport, geojsonImportPoints, convertGeojsonPointsToGeoSystemeId, import { getDataGouvStats } from '../api/rest/statistiques/datagouv.js' import { addAdministrationActiviteTypeEmails, deleteAdministrationActiviteTypeEmails, getAdministrationActiviteTypeEmails, getAdministrationUtilisateurs } from '../api/rest/administrations.js' import { titreDemandeCreer } from '../api/rest/titre-demande.js' +import { config } from '../config/index.js' interface IRestResolverResult { nom: string @@ -84,16 +85,15 @@ type Transform<Route> = (Route extends GetRestRoutes ? { get: RestGetCall<Route> (Route extends NewDownloadRestRoutes ? { newDownload: NewDownload } : {}) & (Route extends DownloadRestRoutes ? { download: RestDownloadCall } : {}) -const config = (_pool: Pool) => async (_req: CaminoRequest, res: CustomResponse<CaminoConfig>) => { - const config: CaminoConfig = { - sentryDsn: process.env.SENTRY_DSN, - caminoStage: process.env.CAMINO_STAGE, - environment: process.env.ENV ?? 'dev', - matomoHost: process.env.API_MATOMO_URL, - matomoSiteId: process.env.API_MATOMO_ID, +const getConfig = (_pool: Pool) => async (_req: CaminoRequest, res: CustomResponse<CaminoConfig>) => { + const caminoConfig: CaminoConfig = { + CAMINO_STAGE: config().CAMINO_STAGE, + SENTRY_DSN: config().SENTRY_DSN, + API_MATOMO_URL: config().API_MATOMO_URL, + API_MATOMO_ID: config().API_MATOMO_ID, } - res.json(caminoConfigValidator.parse(config)) + res.json(caminoConfigValidator.parse(caminoConfig)) } const restRouteImplementations: Readonly<{ [key in CaminoRestRoute]: Transform<key> }> = { @@ -115,7 +115,7 @@ const restRouteImplementations: Readonly<{ [key in CaminoRestRoute]: Transform<k // NE PAS TOUCHER A CES ROUTES, ELLES SONT UTILISÉES HORS UI '/moi': { get: moi }, - '/config': { get: config }, + '/config': { get: getConfig }, '/rest/titres/:id/titreLiaisons': { get: getTitreLiaisons, post: postTitreLiaisons }, '/rest/etapesTypes/:demarcheId/:date': { get: getEtapesTypesEtapesStatusWithMainStep }, '/rest/titres': { post: titreDemandeCreer }, @@ -165,24 +165,30 @@ export const restWithPool = (dbPool: Pool) => { .forEach(route => { const maRoute = restRouteImplementations[route] if ('get' in maRoute) { + console.info(`GET ${route}`) rest.get(route, restCatcher(maRoute.get(dbPool))) } if ('post' in maRoute) { + console.info(`POST ${route}`) rest.post(route, restCatcher(maRoute.post(dbPool))) } if ('put' in maRoute) { + console.info(`PUT ${route}`) rest.put(route, restCatcher(maRoute.put(dbPool))) } if ('delete' in maRoute) { + console.info(`delete ${route}`) rest.delete(route, restCatcher(maRoute.delete(dbPool))) } if ('download' in maRoute) { + console.info(`download ${route}`) rest.get(route, restDownload(maRoute.download(dbPool))) } if ('newDownload' in maRoute) { + console.info(`newDownload ${route}`) rest.get(route, restNewDownload(dbPool, maRoute.newDownload)) } }) diff --git a/packages/api/src/server/user-loader.ts b/packages/api/src/server/user-loader.ts index 73b3c9be3..0c7d11022 100644 --- a/packages/api/src/server/user-loader.ts +++ b/packages/api/src/server/user-loader.ts @@ -8,6 +8,7 @@ import { formatUser } from '../types.js' import { getCurrent } from 'camino-common/src/date.js' import { EmailTemplateId } from '../tools/api-mailjet/types.js' import { isNotNullNorUndefined, isNullOrUndefined } from 'camino-common/src/typescript-tools.js' +import { config } from '../config/index.js' export type JWTUser = { email?: string; family_name?: string; given_name?: string; sub: string | undefined } export const userLoader = async (req: JWTRequest<{ email?: string; family_name?: string; given_name?: string; sub?: string }>, _res: express.Response, next: express.NextFunction) => { @@ -37,9 +38,9 @@ export const userLoader = async (req: JWTRequest<{ email?: string; family_name?: ) await emailsSend( - [process.env.ADMIN_EMAIL!], + [config().ADMIN_EMAIL], `Nouvel utilisateur ${user.email} créé`, - `L'utilisateur ${user.nom} ${user.prenom} vient de se créer un compte : ${process.env.OAUTH_URL}/utilisateurs/${user.id}` + `L'utilisateur ${user.nom} ${user.prenom} vient de se créer un compte : ${config().OAUTH_URL}/utilisateurs/${user.id}` ) if (isNotNullNorUndefined(reqUser.email)) { await emailsWithTemplateSend([reqUser.email], EmailTemplateId.CREATION_COMPTE, {}) diff --git a/packages/api/src/tools/api-administrations/index.ts b/packages/api/src/tools/api-administrations/index.ts index b6a0a8af0..7e805d3ba 100644 --- a/packages/api/src/tools/api-administrations/index.ts +++ b/packages/api/src/tools/api-administrations/index.ts @@ -2,10 +2,12 @@ import errorLog from '../error-log.js' import { DepartementId } from 'camino-common/src/static/departement.js' import { Administration, AdministrationId, AdministrationTypeId } from 'camino-common/src/static/administrations.js' +import { config } from '../../config/index.js' +import { isNotNullNorUndefined } from 'camino-common/src/typescript-tools.js' const MAX_CALLS_MINUTE = 200 -const { API_ADMINISTRATION_URL } = process.env +const API_ADMINISTRATION_URL = config().API_ADMINISTRATION_URL interface IOrganisme { features: { @@ -26,10 +28,6 @@ interface IOrganisme { } const organismeFetch = async (departementId: string, nom: 'paris_ppp' | 'prefecture') => { - if (!API_ADMINISTRATION_URL) { - throw new Error("impossible de se connecter à l'API administration car la variable d'environnement est absente") - } - console.info(`API administration: requête ${departementId}, ${nom}`) const response = await fetch(`${API_ADMINISTRATION_URL}/v3/departements/${departementId}/${nom}`, { @@ -56,7 +54,7 @@ const organismeDepartementCall = async (departementId: string, nom: 'paris_ppp' try { return await organismeFetch(departementId, nom) } catch (err: any) { - const error = err.error ? `${err.error}: ${err.error_description}` : err + const error = isNotNullNorUndefined(err.error) ? `${err.error}: ${err.error_description}` : err errorLog(`API administrations ${departementId} ${nom}:`, error) return null @@ -92,16 +90,16 @@ const organismeFormat = (e: IOrganisme, departementId: DepartementId) => { telephone: p.telephone, departementId, } - const adresse2 = adresseB ? adresseB.lignes.join(', ') : null - if (adresse2) { + const adresse2 = isNotNullNorUndefined(adresseB) ? adresseB.lignes.join(', ') : null + if (isNotNullNorUndefined(adresse2)) { organisme.adresse2 = adresse2 } const email = p.email && p.email.match('@') ? p.email : null - if (email) { + if (isNotNullNorUndefined(email)) { organisme.email = email } const url = p.url || null - if (url) { + if (isNotNullNorUndefined(url)) { organisme.url = url } diff --git a/packages/api/src/tools/api-insee/fetch.ts b/packages/api/src/tools/api-insee/fetch.ts index 45b2f2826..3904e5f77 100644 --- a/packages/api/src/tools/api-insee/fetch.ts +++ b/packages/api/src/tools/api-insee/fetch.ts @@ -5,6 +5,8 @@ import { IApiSirenQueryTypes, IApiSirenQueryToken, IApiSirenEtablissement, IApiS import errorLog from '../error-log.js' import { CaminoDate, dateAddDays, daysBetween, getCurrent } from 'camino-common/src/date.js' import { Siren } from 'camino-common/src/entreprise.js' +import { config } from '../../config/index.js' +import { isNotNullNorUndefinedNorEmpty, isNullOrUndefined } from 'camino-common/src/typescript-tools.js' const MAX_CALLS_MINUTE = 30 const MAX_RESULTS = 20 @@ -13,7 +15,7 @@ const TOKEN_VALIDITY_IN_DAYS = 1 // utilise `tokenInitialize` pour l'initialiser let apiToken: { validUntil: CaminoDate; token: string } | null = null -const { API_INSEE_URL, API_INSEE_KEY, API_INSEE_SECRET } = process.env +const { API_INSEE_URL, API_INSEE_KEY, API_INSEE_SECRET } = config() // VisibleForTesting export const tokenInitialize = async (tokenFetchCall = tokenFetch, today = getCurrent()): Promise<string> => { @@ -34,6 +36,7 @@ export const tokenInitialize = async (tokenFetchCall = tokenFetch, today = getCu } catch (e: any) { errorLog( "API Insee: impossible de générer le token de l'API INSEE ", + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions (e.header && e.header.message) || (e.fault && `${e.fault.message}: ${e.fault.description}`) || (e.error && `${e.error}: ${e.error_description}`) || e.message || e ) @@ -43,11 +46,7 @@ export const tokenInitialize = async (tokenFetchCall = tokenFetch, today = getCu const tokenFetch = async (): Promise<IApiSirenQueryToken | null> => { try { - if (!API_INSEE_URL) { - throw new Error("impossible de se connecter car la variable d'environnement est absente") - } - - console.info(`API Insee: récupération du token ${API_INSEE_KEY?.substring(0, 5)}...:${API_INSEE_SECRET?.substring(0, 5)}...`) + console.info(`API Insee: récupération du token ${API_INSEE_KEY.substring(0, 5)}...:${API_INSEE_SECRET.substring(0, 5)}...`) const auth = Buffer.from(`${API_INSEE_KEY}:${API_INSEE_SECRET}`).toString('base64') @@ -67,7 +66,7 @@ const tokenFetch = async (): Promise<IApiSirenQueryToken | null> => { throw result } - if (!result) { + if (isNullOrUndefined(result)) { throw new Error('contenu de la réponse vide') } @@ -75,6 +74,7 @@ const tokenFetch = async (): Promise<IApiSirenQueryToken | null> => { } catch (e: any) { errorLog( `API Insee: tokenFetch `, + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions (e.header && e.header.message) || (e.fault && `${e.fault.message}: ${e.fault.description}`) || (e.error && `${e.error}: ${e.error_description}`) || e.message || e ) @@ -105,7 +105,7 @@ const typeFetch = async (type: 'siren' | 'siret', q: string) => { throw result } - if (!result) { + if (isNullOrUndefined(result)) { throw new Error('API Insee: contenu de la réponse vide') } @@ -117,6 +117,7 @@ const typeFetch = async (type: 'siren' | 'siret', q: string) => { } catch (e: any) { errorLog( `API Insee: typeFetch `, + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions (e.header && e.header.message) || (e.fault && `${e.fault.message}: ${e.fault.description}`) || (e.error && `${e.error}: ${e.error_description}`) || e.message || e ) @@ -132,6 +133,7 @@ const typeMultiFetch = async (type: 'siren' | 'siret', field: 'etablissements' | } catch (e: any) { errorLog( `API Insee: ${type} get ${ids.join(', ')}`, + // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions JSON.stringify((e.header && e.header.message) || (e.fault && `${e.fault.message}: ${e.fault.description}`) || (e.error && `${e.error}: ${e.error_description}`) || e.message || e) ) @@ -155,7 +157,7 @@ export const entreprisesEtablissementsFetch = async (ids: Siren[]) => { const results = [] for (const batch of batches) { const result = (await typeMultiFetch('siren', 'unitesLegales', batch, queryFormat(batch))) as IApiSirenUniteLegale[] - if (result) { + if (isNotNullNorUndefinedNorEmpty(result)) { results.push(...result) } } @@ -175,7 +177,7 @@ export const entreprisesFetch = async (ids: Siren[]) => { const results = [] for (const batch of batches) { const result = (await typeMultiFetch('siret', 'etablissements', batch, queryFormat(batch))) as IApiSirenEtablissement[] - if (result) { + if (isNotNullNorUndefinedNorEmpty(result)) { results.push(...result) } } diff --git a/packages/api/src/tools/api-mailjet/emails.ts b/packages/api/src/tools/api-mailjet/emails.ts index 037cb271b..ac9b0e8e9 100644 --- a/packages/api/src/tools/api-mailjet/emails.ts +++ b/packages/api/src/tools/api-mailjet/emails.ts @@ -2,9 +2,10 @@ import { convert } from 'html-to-text' import { mailjet } from './index.js' import { EmailTemplateId } from './types.js' import { emailCheck } from '../email-check.js' +import { config } from '../../config/index.js' const from = { - email: process.env.API_MAILJET_EMAIL, + email: config().API_MAILJET_EMAIL, name: 'Camino - le cadastre minier', } @@ -22,8 +23,8 @@ export const mailjetSend = async (emails: string[], options: Record<string, any> // si on est pas sur le serveur de prod // l'adresse email du destinataire est remplacée - if (process.env.NODE_ENV !== 'production' || process.env.ENV !== 'prod') { - emails = [process.env.ADMIN_EMAIL!] + if (config().NODE_ENV !== 'production' || config().ENV !== 'prod') { + emails = [config().ADMIN_EMAIL!] } const res = (await mailjet.post('send', { version: 'v3' }).request({ @@ -34,6 +35,7 @@ export const mailjetSend = async (emails: string[], options: Record<string, any> FromName: from.name, Recipients: emails.map(Email => ({ Email })), ...options, + Headers: { 'Reply-To': config().API_MAILJET_REPLY_TO_EMAIL }, }, ], })) as { @@ -50,8 +52,8 @@ export const mailjetSend = async (emails: string[], options: Record<string, any> } export const emailsSend = async (emails: string[], subject: string, html: string) => { - if (process.env.NODE_ENV !== 'production' || process.env.ENV !== 'prod') { - html = `<p style="color: red">destinataire(s): ${emails.join(', ')} | env: ${process.env.ENV} | node: ${process.env.NODE_ENV}</p> ${html}` + if (config().NODE_ENV !== 'production' || config().ENV !== 'prod') { + html = `<p style="color: red">destinataire(s): ${emails.join(', ')} | env: ${config().ENV} | node: ${config().NODE_ENV}</p> ${html}` } mailjetSend(emails, { diff --git a/packages/api/src/tools/api-mailjet/index.ts b/packages/api/src/tools/api-mailjet/index.ts index 3c6060faf..cc4b8a134 100644 --- a/packages/api/src/tools/api-mailjet/index.ts +++ b/packages/api/src/tools/api-mailjet/index.ts @@ -1,7 +1,8 @@ import Mailjet from 'node-mailjet' +import { config } from '../../config/index.js' // eslint-disable-next-line new-cap export const mailjet = new Mailjet({ - apiKey: process.env.API_MAILJET_KEY || 'fakeKey', - apiSecret: process.env.API_MAILJET_SECRET || 'fakeSecret', + apiKey: config().API_MAILJET_KEY, + apiSecret: config().API_MAILJET_SECRET, }) diff --git a/packages/api/src/tools/api-mailjet/newsletter.ts b/packages/api/src/tools/api-mailjet/newsletter.ts index e1b3dae5e..85413bfd3 100644 --- a/packages/api/src/tools/api-mailjet/newsletter.ts +++ b/packages/api/src/tools/api-mailjet/newsletter.ts @@ -1,3 +1,5 @@ +import { isNotNullNorUndefined, isNullOrUndefined } from 'camino-common/src/typescript-tools.js' +import { config } from '../../config/index.js' import { mailjet } from './index.js' interface IContactListAdd { @@ -5,15 +7,15 @@ interface IContactListAdd { Action: string } -const newsLetterContactListId = Number(process.env.API_MAILJET_CONTACTS_LIST_ID!) -const exploitantsGuyaneContactListId = Number(process.env.API_MAILJET_EXPLOITANTS_GUYANE_LIST_ID!) +const newsLetterContactListId = config().API_MAILJET_CONTACTS_LIST_ID +const exploitantsGuyaneContactListId = config().API_MAILJET_EXPLOITANTS_GUYANE_LIST_ID export const isSubscribedToNewsLetter = async (email: string | null | undefined): Promise<boolean> => { return isSubscribed(newsLetterContactListId, email) } const isSubscribed = async (contactListId: number, email: string | null | undefined): Promise<boolean> => { - if (email) { + if (isNotNullNorUndefined(email) && email !== '') { const recipientsResult = await mailjet.get('listrecipient', { version: 'v3' }).request( {}, { @@ -31,7 +33,7 @@ const isSubscribed = async (contactListId: number, email: string | null | undefi return false } -const contactListSubscribe = async (email: string, contactListId: number, Action: 'addforce' | 'unsub') => { +const contactListSubscribe = async (email: string, contactListId: number, Action: 'addforce' | 'unsub'): Promise<boolean> => { const contactResult = (await mailjet .post('contact', { version: 'v3' }) .id(encodeURIComponent(email)) @@ -42,7 +44,7 @@ const contactListSubscribe = async (email: string, contactListId: number, Action const contactListAdded = contactResult.body.Data[0] - return !!contactListAdded + return isNotNullNorUndefined(contactListAdded) } const contactAdd = async (email: string): Promise<void> => { @@ -50,10 +52,12 @@ const contactAdd = async (email: string): Promise<void> => { await mailjet.post('contact', { version: 'v3' }).request({ Email: email }) } catch (e: any) { // MJ18 -> erreur mailjet contact déjà existant - if (!e.statusText.includes('MJ18')) { + const statusText: string = e?.statusText ?? '' + if (statusText.includes('MJ18')) { + console.info(`utilisateur ${email} déjà existant chez mailjet`) + } else { throw e } - console.info(`utilisateur ${email} déjà existant chez mailjet`) } } @@ -76,7 +80,7 @@ export const exploitantsGuyaneSubscriberUpdate = async (users: { email: string; } export const newsletterSubscriberUpdate = async (email: string | undefined | null, subscribed: boolean): Promise<string> => { - if (!email) { + if (isNullOrUndefined(email) || email === '') { return '' } await isSubscribed(newsLetterContactListId, email) diff --git a/packages/api/src/tools/api-openfisca/index.ts b/packages/api/src/tools/api-openfisca/index.ts index 92cc0041f..14af035f7 100644 --- a/packages/api/src/tools/api-openfisca/index.ts +++ b/packages/api/src/tools/api-openfisca/index.ts @@ -1,6 +1,7 @@ import { SubstanceFiscale, SubstanceFiscaleId, SubstancesFiscales } from 'camino-common/src/static/substancesFiscales.js' import { Unite, Unites } from 'camino-common/src/static/unites.js' import Decimal from 'decimal.js' +import { config } from '../../config/index.js' type Attribute = 'surface_communale' | 'surface_communale_proportionnee' | 'taxe_guyane_brute' | 'taxe_guyane_deduction' | 'taxe_guyane' | string const openfiscaSubstanceFiscaleNom = (substanceFiscale: SubstanceFiscale): string => substanceFiscale.openFisca?.nom ?? substanceFiscale.nom @@ -81,11 +82,7 @@ export interface OpenfiscaConstants { } const apiOpenfiscaFetch = async <T>(call: (apiOpenfiscaUrl: string) => Promise<Response>): Promise<T> => { - const apiOpenfiscaUrl = process.env.API_OPENFISCA_URL - if (!apiOpenfiscaUrl) { - throw new Error("impossible de se connecter à l'API Openfisca car la variable d'environnement est absente") - } - + const apiOpenfiscaUrl = config().API_OPENFISCA_URL const response = await call(apiOpenfiscaUrl) const result = (await response.json()) as T diff --git a/packages/api/test-env.ts b/packages/api/test-env.ts new file mode 100644 index 000000000..e0fe62a3b --- /dev/null +++ b/packages/api/test-env.ts @@ -0,0 +1,32 @@ +export const testEnv = { + SENTRY_DSN: 'plop', + API_MATOMO_URL: 'plop', + API_MATOMO_ID: 'plop', + API_HOST: 'plop', + KEYCLOAK_API_CLIENT_ID: 'plop', + KEYCLOAK_API_CLIENT_SECRET: 'plop', + KEYCLOAK_URL: 'http://KEYCLOAK_URL.plop', + KEYCLOAK_RESET_PASSWORD_URL: 'plop', + KEYCLOAK_CLIENT_ID: 'plop', + KEYCLOAK_LOGOUT_URL: 'plop', + OAUTH_URL: 'http://plop.plop', + APPLICATION_VERSION: 'plop', + PGHOST: 'localhost', + PGUSER: 'postgres', + PGPASSWORD: 'password', + PGDATABASE: 'camino_test', + API_PORT: '32', + ADMIN_EMAIL: 'plop@plop.plop', + JWT_SECRET: 'plop', + API_INSEE_URL: 'plop', + API_INSEE_KEY: 'plop', + API_INSEE_SECRET: 'plop', + API_MAILJET_EMAIL: 'plop@plop.plop', + API_MAILJET_REPLY_TO_EMAIL: 'plop@plop.plop', + API_ADMINISTRATION_URL: 'https://plop.plop', + API_MAILJET_KEY: 'plop', + API_MAILJET_SECRET: 'plop', + API_MAILJET_CONTACTS_LIST_ID: '12', + API_MAILJET_EXPLOITANTS_GUYANE_LIST_ID: '12', + API_OPENFISCA_URL: 'http://plop.plop', +} diff --git a/packages/api/tests/_utils/index.ts b/packages/api/tests/_utils/index.ts index 59cef0d29..1ba1495e4 100644 --- a/packages/api/tests/_utils/index.ts +++ b/packages/api/tests/_utils/index.ts @@ -16,6 +16,7 @@ import { z } from 'zod' import { newUtilisateurId } from '../../src/database/models/_format/id-create.js' import { idUserKeycloakRecognised } from '../keycloak.js' import { isNotNullNorUndefined } from 'camino-common/src/typescript-tools.js' +import { config } from '../../src/config/index.js' export const queryImport = (nom: string) => fs @@ -24,10 +25,7 @@ export const queryImport = (nom: string) => .toString() const tokenCreate = (user: Partial<IUtilisateur>) => { - if (isNotNullNorUndefined(process.env.JWT_SECRET)) { - return jwt.sign(JSON.stringify(user), process.env.JWT_SECRET) - } - throw new Error('La variable d’environnement JWT_SECRET est manquante') + return jwt.sign(JSON.stringify(user), config().JWT_SECRET) } export const graphQLCall = async (pool: Pool, query: string, variables: Index<string | boolean | Index<string | boolean | Index<string>[] | any>>, user: TestUser | undefined) => { diff --git a/packages/api/tests/db-manager.ts b/packages/api/tests/db-manager.ts index b57037fca..9ae7543c6 100644 --- a/packages/api/tests/db-manager.ts +++ b/packages/api/tests/db-manager.ts @@ -6,6 +6,7 @@ import { knexInstanceSet } from '../src/knex.js' import knex, { Knex } from 'knex' import pg, { Client } from 'pg' import { knexSnakeCaseMappers, Model } from 'objection' +import { config } from '../src/config/index.js' class DbManager { private readonly dbName: string @@ -17,11 +18,11 @@ class DbManager { } private static getPgUser() { - return process.env.PGUSER ?? 'postgres' + return config().PGUSER } private static getPgPassword() { - return process.env.PGPASSWORD ?? 'password' + return config().PGPASSWORD } private async init(): Promise<void> { diff --git a/packages/api/vitest.integration.config.ts b/packages/api/vitest.integration.config.ts index 80b806cd6..30fc8ba93 100644 --- a/packages/api/vitest.integration.config.ts +++ b/packages/api/vitest.integration.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from 'vitest/config' import path from 'path' +import { testEnv } from './test-env' export default defineConfig({ test: { @@ -8,5 +9,6 @@ export default defineConfig({ setupFiles: path.resolve(__dirname, './tests/vitestSetup.ts'), testTimeout: 10000, hookTimeout: 30000, + env: testEnv, }, }) diff --git a/packages/api/vitest.unit.config.ts b/packages/api/vitest.unit.config.ts index 73e289018..c5d9cba9f 100644 --- a/packages/api/vitest.unit.config.ts +++ b/packages/api/vitest.unit.config.ts @@ -1,8 +1,10 @@ import { defineConfig } from 'vitest/config' import path from 'path' +import { testEnv } from './test-env' export default defineConfig({ test: { setupFiles: path.resolve(__dirname, './tests/vitestSetup.ts'), + env: testEnv, }, }) diff --git a/packages/common/src/static/config.ts b/packages/common/src/static/config.ts index f0053b226..19784c550 100644 --- a/packages/common/src/static/config.ts +++ b/packages/common/src/static/config.ts @@ -1,11 +1,10 @@ import { z } from 'zod' export const caminoConfigValidator = z.object({ - caminoStage: z.enum(['dev', 'preprod', 'prod']).optional(), - sentryDsn: z.string().optional(), - environment: z.string(), - matomoHost: z.string().optional(), - matomoSiteId: z.string().optional(), + CAMINO_STAGE: z.enum(['prod', 'preprod', 'dev']).optional(), + SENTRY_DSN: z.string(), + API_MATOMO_URL: z.string(), + API_MATOMO_ID: z.string(), }) export type CaminoConfig = z.infer<typeof caminoConfigValidator> diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index dc474a63b..ad68baeb1 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -14,6 +14,7 @@ import { initMatomo } from './stats/matomo' import type { User } from 'camino-common/src/roles' import { userKey, entreprisesKey } from './moi' import type { Entreprise } from 'camino-common/src/entreprise' +import { isNotNullNorUndefined } from 'camino-common/src/typescript-tools' // Le Timeout du sse côté backend est mis à 30 secondes, toujours avoir une valeur plus haute ici const sseTimeoutInSeconds = 45 @@ -67,13 +68,13 @@ Promise.resolve().then(async (): Promise<void> => { // TODO 2024-03-04 à supprimer quand on a plus etape-edition.vue app.config.globalProperties.user = user app.config.globalProperties.entreprises = entreprises - if (configFromJson.caminoStage) { + if (isNotNullNorUndefined(configFromJson.CAMINO_STAGE)) { try { - if (!configFromJson.sentryDsn) throw new Error('dsn manquant') + if (!configFromJson.SENTRY_DSN) throw new Error('dsn manquant') Sentry.init({ app, - dsn: configFromJson.sentryDsn, - environment: configFromJson.environment, + dsn: configFromJson.SENTRY_DSN, + environment: configFromJson.CAMINO_STAGE, autoSessionTracking: false, integrations: [ new BrowserTracing({ @@ -88,12 +89,12 @@ Promise.resolve().then(async (): Promise<void> => { console.error('erreur : Sentry :', e) } try { - if (!configFromJson.matomoHost || !configFromJson.matomoSiteId || !configFromJson.environment) throw new Error('host et/ou siteId manquant(s)') + if (!configFromJson.API_MATOMO_URL || !configFromJson.API_MATOMO_ID || !configFromJson.CAMINO_STAGE) throw new Error('host et/ou siteId manquant(s)') await initMatomo({ - host: configFromJson.matomoHost, - siteId: configFromJson.matomoSiteId, - environnement: configFromJson.environment, + host: configFromJson.API_MATOMO_URL, + siteId: configFromJson.API_MATOMO_ID, + environnement: configFromJson.CAMINO_STAGE, router, }) } catch (e) { -- GitLab