From 1cc431fd1481ebce0e9bdfb735d5440a9553986e Mon Sep 17 00:00:00 2001 From: Shikanime Deva Date: Thu, 25 Jun 2026 15:08:47 +0200 Subject: [PATCH] refactor: configuration to nestjs system Signed-off-by: William Phetsinorath Change-Id: I6f501bd1633ceb14edaa9bf2a324ea026a6a6964 --- apps/server-nestjs/.env-example | 8 - apps/server-nestjs/.env.docker-example | 3 - apps/server-nestjs/.env.integ-example | 1 + .../src/config/argocd.config.spec.ts | 52 ++++ .../server-nestjs/src/config/argocd.config.ts | 25 ++ .../src/config/base.config.spec.ts | 63 +++++ apps/server-nestjs/src/config/base.config.ts | 29 +++ .../src/config/config-testing.utils.ts | 8 + .../src/config/config.utils.spec.ts | 38 +++ apps/server-nestjs/src/config/config.utils.ts | 94 ++++++++ .../src/config/gitlab.config.spec.ts | 39 +++ .../server-nestjs/src/config/gitlab.config.ts | 25 ++ .../src/config/harbor.config.spec.ts | 53 +++++ .../server-nestjs/src/config/harbor.config.ts | 39 +++ .../src/config/keycloak.config.spec.ts | 50 ++++ .../src/config/keycloak.config.ts | 46 ++++ .../src/config/nexus.config.spec.ts | 44 ++++ apps/server-nestjs/src/config/nexus.config.ts | 21 ++ .../src/config/service-chain.config.spec.ts | 40 ++++ .../src/config/service-chain.config.ts | 21 ++ .../src/config/sonarqube.config.spec.ts | 31 +++ .../src/config/sonarqube.config.ts | 16 ++ .../src/config/vault.config.spec.ts | 32 +++ apps/server-nestjs/src/config/vault.config.ts | 18 ++ apps/server-nestjs/src/main.module.ts | 10 + apps/server-nestjs/src/main.ts | 7 +- .../modules/argocd/argocd-health.service.ts | 11 +- .../src/modules/argocd/argocd.module.ts | 7 +- .../src/modules/argocd/argocd.service.spec.ts | 38 ++- .../src/modules/argocd/argocd.service.ts | 60 ++--- .../modules/events/app-events.service.spec.ts | 9 +- .../src/modules/events/app-events.service.ts | 7 +- .../src/modules/events/app-events.utils.ts | 2 +- .../gitlab/gitlab-client.service.spec.ts | 16 +- .../modules/gitlab/gitlab-client.service.ts | 15 +- .../modules/gitlab/gitlab-health.service.ts | 10 +- .../gitlab/gitlab-plugin.service.spec.ts | 11 +- .../modules/gitlab/gitlab-plugin.service.ts | 12 +- .../src/modules/gitlab/gitlab.module.ts | 15 +- .../src/modules/gitlab/gitlab.service.spec.ts | 7 +- .../src/modules/gitlab/gitlab.service.ts | 17 +- .../infrastructure/auth/auth.module.ts | 4 +- .../auth/keycloak-jwt/keycloak-jwt.module.ts | 3 +- .../keycloak-secret-provider.module.ts | 8 +- .../keycloak-secret-provider.service.spec.ts | 74 +++--- .../keycloak-secret-provider.service.ts | 27 +-- .../configuration/configuration.module.ts | 25 -- .../configuration.service.spec.ts | 223 ------------------ .../configuration/configuration.service.ts | 179 -------------- .../database/database.module.ts | 5 +- .../database/database.service.spec.ts | 19 +- .../database/database.service.ts | 15 +- .../infrastructure/events/events.module.ts | 4 +- .../infrastructure/infrastructure.module.ts | 5 +- .../infrastructure/logger/logger.module.ts | 13 +- .../keycloak/keycloak-client.service.spec.ts | 39 +-- .../keycloak/keycloak-client.service.ts | 39 +-- .../keycloak/keycloak-health.service.ts | 12 +- .../src/modules/keycloak/keycloak.module.ts | 13 +- .../modules/keycloak/keycloak.service.spec.ts | 34 +-- .../src/modules/keycloak/keycloak.service.ts | 10 +- .../nexus/nexus-client.service.spec.ts | 18 +- .../src/modules/nexus/nexus-health.service.ts | 18 +- .../nexus/nexus-http-client.service.ts | 37 +-- .../nexus/nexus-plugin.service.spec.ts | 13 +- .../src/modules/nexus/nexus-plugin.service.ts | 11 +- .../src/modules/nexus/nexus.module.ts | 6 +- .../src/modules/nexus/nexus.service.spec.ts | 17 +- .../src/modules/nexus/nexus.service.ts | 25 +- .../src/modules/nexus/nexus.utils.ts | 6 +- .../src/modules/plugin/plugin.module.spec.ts | 14 ++ .../project-secrets/project-secrets.module.ts | 3 +- .../project-secrets.service.spec.ts | 17 +- .../project-secrets.service.ts | 7 +- .../src/modules/project/project.module.ts | 5 +- .../modules/project/project.service.spec.ts | 13 +- .../src/modules/project/project.service.ts | 7 +- .../registry/registry-client.service.spec.ts | 24 +- .../registry/registry-health.service.ts | 18 +- .../registry/registry-http-client.service.ts | 40 ++-- .../registry/registry-plugin.service.spec.ts | 23 +- .../registry/registry-plugin.service.ts | 18 +- .../src/modules/registry/registry.module.ts | 6 +- .../modules/registry/registry.service.spec.ts | 36 ++- .../src/modules/registry/registry.service.ts | 67 ++---- .../src/modules/registry/registry.utils.ts | 3 - .../open-cds-client.service.spec.ts | 100 ++++---- .../service-chain/open-cds-client.service.ts | 128 ++++------ .../service-chain-health.service.ts | 14 +- .../service-chain/service-chain.module.ts | 7 +- .../sonarqube-client.service.spec.ts | 14 +- .../sonarqube/sonarqube-client.service.ts | 34 +-- .../sonarqube/sonarqube-health.service.ts | 18 +- .../sonarqube-http-client.service.ts | 29 +-- .../sonarqube-plugin.service.spec.ts | 13 +- .../sonarqube/sonarqube-plugin.service.ts | 12 +- .../src/modules/sonarqube/sonarqube.module.ts | 5 +- .../sonarqube/sonarqube.service.spec.ts | 22 +- .../modules/sonarqube/sonarqube.service.ts | 11 +- .../vault/vault-client.service.spec.ts | 21 +- .../src/modules/vault/vault-client.service.ts | 83 +++---- .../src/modules/vault/vault-health.service.ts | 10 +- .../vault/vault-http-client.service.ts | 25 +- .../vault/vault-plugin.service.spec.ts | 13 +- .../src/modules/vault/vault-plugin.service.ts | 11 +- .../src/modules/vault/vault.module.ts | 6 +- .../src/modules/vault/vault.service.spec.ts | 11 +- .../src/modules/vault/vault.service.ts | 25 +- .../src/modules/vault/vault.utils.ts | 24 +- .../src/modules/version/version.controller.ts | 8 +- .../src/modules/version/version.module.ts | 5 +- apps/server-nestjs/src/utils/crypto.spec.ts | 2 +- .../src/utils/{crypto.ts => crypto.utils.ts} | 0 .../src/utils/dotenv.utils.spec.ts | 35 +++ apps/server-nestjs/src/utils/dotenv.utils.ts | 26 ++ .../utils/{http-error.ts => http.utils.ts} | 0 .../utils/{iterable.ts => iterable.utils.ts} | 0 apps/server-nestjs/test/argocd.e2e-spec.ts | 22 +- apps/server-nestjs/test/gitlab.e2e-spec.ts | 32 ++- apps/server-nestjs/test/keycloak.e2e-spec.ts | 11 +- apps/server-nestjs/test/log.e2e-spec.ts | 1 - apps/server-nestjs/test/nexus.e2e-spec.ts | 20 +- .../test/project-bulk.e2e-spec.ts | 6 +- .../test/project-hooks.e2e-spec.ts | 6 +- .../test/project-members.e2e-spec.ts | 6 +- .../test/project-roles.e2e-spec.ts | 6 +- .../test/project-secrets.e2e-spec.ts | 10 +- .../test/project-services.e2e-spec.ts | 27 +-- apps/server-nestjs/test/project.e2e-spec.ts | 6 +- apps/server-nestjs/test/registry.e2e-spec.ts | 35 ++- apps/server-nestjs/test/sonarqube.e2e-spec.ts | 11 +- apps/server-nestjs/test/vault.e2e-spec.ts | 9 +- 132 files changed, 1722 insertions(+), 1451 deletions(-) create mode 100644 apps/server-nestjs/src/config/argocd.config.spec.ts create mode 100644 apps/server-nestjs/src/config/argocd.config.ts create mode 100644 apps/server-nestjs/src/config/base.config.spec.ts create mode 100644 apps/server-nestjs/src/config/base.config.ts create mode 100644 apps/server-nestjs/src/config/config-testing.utils.ts create mode 100644 apps/server-nestjs/src/config/config.utils.spec.ts create mode 100644 apps/server-nestjs/src/config/config.utils.ts create mode 100644 apps/server-nestjs/src/config/gitlab.config.spec.ts create mode 100644 apps/server-nestjs/src/config/gitlab.config.ts create mode 100644 apps/server-nestjs/src/config/harbor.config.spec.ts create mode 100644 apps/server-nestjs/src/config/harbor.config.ts create mode 100644 apps/server-nestjs/src/config/keycloak.config.spec.ts create mode 100644 apps/server-nestjs/src/config/keycloak.config.ts create mode 100644 apps/server-nestjs/src/config/nexus.config.spec.ts create mode 100644 apps/server-nestjs/src/config/nexus.config.ts create mode 100644 apps/server-nestjs/src/config/service-chain.config.spec.ts create mode 100644 apps/server-nestjs/src/config/service-chain.config.ts create mode 100644 apps/server-nestjs/src/config/sonarqube.config.spec.ts create mode 100644 apps/server-nestjs/src/config/sonarqube.config.ts create mode 100644 apps/server-nestjs/src/config/vault.config.spec.ts create mode 100644 apps/server-nestjs/src/config/vault.config.ts delete mode 100644 apps/server-nestjs/src/modules/infrastructure/configuration/configuration.module.ts delete mode 100644 apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.spec.ts delete mode 100644 apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts rename apps/server-nestjs/src/utils/{crypto.ts => crypto.utils.ts} (100%) create mode 100644 apps/server-nestjs/src/utils/dotenv.utils.spec.ts create mode 100644 apps/server-nestjs/src/utils/dotenv.utils.ts rename apps/server-nestjs/src/utils/{http-error.ts => http.utils.ts} (100%) rename apps/server-nestjs/src/utils/{iterable.ts => iterable.utils.ts} (100%) diff --git a/apps/server-nestjs/.env-example b/apps/server-nestjs/.env-example index 98b578b339..f58c1195a1 100644 --- a/apps/server-nestjs/.env-example +++ b/apps/server-nestjs/.env-example @@ -38,14 +38,6 @@ DB_URL=postgresql://admin:admin@localhost:5432/dso-console-db?schema=public # Adresse e-mail de contact affichée dans l'interface CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr -# --- Configuration OpenCDS --- -# URL de l'API OpenCDS (laisser vide pour désactiver) -OPENCDS_URL= -# Token d'authentification pour l'API OpenCDS -OPENCDS_API_TOKEN=token -# Vérification du certificat TLS de l'API OpenCDS (true | false) -OPENCDS_API_TLS_REJECT_UNAUTHORIZED=true - # --- Activation des plugins externes --- # Mettez "true" et renseignez les variables ci-dessus pour activer chaque plugin. USE_ARGOCD=false diff --git a/apps/server-nestjs/.env.docker-example b/apps/server-nestjs/.env.docker-example index 2d52aec635..e3797eac8b 100644 --- a/apps/server-nestjs/.env.docker-example +++ b/apps/server-nestjs/.env.docker-example @@ -40,9 +40,6 @@ DB_URL=postgresql://admin:admin@postgres:5432/dso-console-db?schema=public CONTACT_EMAIL=cloudpinative-relations@interieur.gouv.fr # --- Configuration OpenCDS --- -# URL de l'API OpenCDS (laisser vide pour désactiver) -# Par défaut configuré sur le conteneur Mockoon (à changer sur envs non-PAX) -OPENCDS_URL= # Token d'authentification pour l'API OpenCDS OPENCDS_API_TOKEN=token # Vérification du certificat TLS de l'API OpenCDS (true | false) diff --git a/apps/server-nestjs/.env.integ-example b/apps/server-nestjs/.env.integ-example index 63a50ec807..e020a1823e 100644 --- a/apps/server-nestjs/.env.integ-example +++ b/apps/server-nestjs/.env.integ-example @@ -111,5 +111,6 @@ DSO_OBSERVABILITY_CHART_VERSION=dso-observability-0.1.7 # Configuration OpenCDS OPENCDS_URL= +OPENCDS_INTERNAL_URL= OPENCDS_API_TOKEN=token OPENCDS_API_TLS_REJECT_UNAUTHORIZED=false diff --git a/apps/server-nestjs/src/config/argocd.config.spec.ts b/apps/server-nestjs/src/config/argocd.config.spec.ts new file mode 100644 index 0000000000..04778de631 --- /dev/null +++ b/apps/server-nestjs/src/config/argocd.config.spec.ts @@ -0,0 +1,52 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { argocdConfigFactory } from './argocd.config' +import { resetEnvs } from './config-testing.utils' + +describe('argocdConfig', () => { + beforeEach(() => { resetEnvs(['ARGO_NAMESPACE', 'ARGOCD_URL', 'ARGOCD_INTERNAL_URL', 'DSO_ENV_CHART_VERSION', 'DSO_NS_CHART_VERSION', 'VAULT__DEPLOY_VAULT_CONNECTION_IN_NS']) }) + afterEach(() => { vi.unstubAllEnvs() }) + + it('parses a full config', () => { + vi.stubEnv('ARGOCD_URL', 'https://argocd.internal') + vi.stubEnv('ARGOCD_INTERNAL_URL', 'https://argocd.internal:8080') + vi.stubEnv('ARGOCD_EXTRA_REPOSITORIES', 'repo1') + vi.stubEnv('VAULT__DEPLOY_VAULT_CONNECTION_IN_NS', 'true') + vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects') + expect(argocdConfigFactory()).toMatchObject({ + namespace: 'argocd', + url: 'https://argocd.internal', + internalUrl: 'https://argocd.internal:8080', + extraRepositories: ['repo1'], + dsoEnvChartVersion: 'dso-env-1.6.0', + dsoNsChartVersion: 'dso-ns-1.1.5', + vaultDeployVaultConnectionInNs: true, + }) + }) + + it('falls back to public url when internal url is absent', () => { + vi.stubEnv('ARGOCD_URL', 'https://argocd.internal') + vi.stubEnv('ARGOCD_EXTRA_REPOSITORIES', 'repo1') + vi.stubEnv('VAULT__DEPLOY_VAULT_CONNECTION_IN_NS', 'true') + vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects') + const cfg = argocdConfigFactory() + expect(cfg.internalUrl).toBeUndefined() + expect(cfg.url).toBe('https://argocd.internal') + }) + + it('applies flag defaults', () => { + vi.stubEnv('ARGOCD_URL', 'https://argocd.internal') + vi.stubEnv('ARGOCD_EXTRA_REPOSITORIES', 'repo1') + vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects') + expect(argocdConfigFactory().vaultDeployVaultConnectionInNs).toBe(false) + }) + + it('defaults extraRepositories to an empty array', () => { + vi.stubEnv('ARGOCD_URL', 'https://argocd.internal') + vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects') + expect(argocdConfigFactory().extraRepositories).toEqual([]) + }) + + it('throws when a required var is missing', () => { + expect(() => argocdConfigFactory()).toThrow() + }) +}) diff --git a/apps/server-nestjs/src/config/argocd.config.ts b/apps/server-nestjs/src/config/argocd.config.ts new file mode 100644 index 0000000000..ab6d413109 --- /dev/null +++ b/apps/server-nestjs/src/config/argocd.config.ts @@ -0,0 +1,25 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { csv, truthySchema } from './config.utils' + +const argocdFeatureSchema = z.object({ + ARGO_NAMESPACE: z.string().default('argocd'), + ARGOCD_URL: z.string().url(), + ARGOCD_INTERNAL_URL: z.string().url().optional(), + ARGOCD_EXTRA_REPOSITORIES: csv(z.string()), + DSO_ENV_CHART_VERSION: z.string().default('dso-env-1.6.0'), + DSO_NS_CHART_VERSION: z.string().default('dso-ns-1.1.5'), + VAULT__DEPLOY_VAULT_CONNECTION_IN_NS: truthySchema.default('false').transform(v => v === 'true' || v === '1'), +}).transform(raw => ({ + namespace: raw.ARGO_NAMESPACE, + url: raw.ARGOCD_URL, + internalUrl: raw.ARGOCD_INTERNAL_URL, + extraRepositories: raw.ARGOCD_EXTRA_REPOSITORIES, + dsoEnvChartVersion: raw.DSO_ENV_CHART_VERSION, + dsoNsChartVersion: raw.DSO_NS_CHART_VERSION, + vaultDeployVaultConnectionInNs: raw.VAULT__DEPLOY_VAULT_CONNECTION_IN_NS, +})) + +export type ArgocdConfig = z.infer + +export const argocdConfigFactory = registerAs('argocd', () => argocdFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/base.config.spec.ts b/apps/server-nestjs/src/config/base.config.spec.ts new file mode 100644 index 0000000000..05ddeed3b1 --- /dev/null +++ b/apps/server-nestjs/src/config/base.config.spec.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from './base.config' +import { resetEnvs } from './config-testing.utils' + +describe('baseConfig', () => { + beforeEach(() => { resetEnvs(['NODE_ENV', 'CI', 'SERVER_HOST', 'SERVER_PORT', 'APP_VERSION', 'DB_URL', 'PROJECTS_ROOT_DIR']) }) + afterEach(() => { vi.unstubAllEnvs() }) + + it('parses a full config and derives env flags', () => { + vi.stubEnv('NODE_ENV', 'development') + vi.stubEnv('CI', 'true') + vi.stubEnv('SERVER_HOST', 'host') + vi.stubEnv('SERVER_PORT', '4000') + vi.stubEnv('APP_VERSION', '1.2.3') + vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects') + vi.stubEnv('DB_URL', 'postgres://db') + expect(baseConfigFactory()).toEqual({ + isTest: false, + isDev: true, + isCI: true, + isProd: false, + serverHost: 'host', + serverPort: 4000, + appVersion: 'dev', + dbUrl: 'postgres://db', + projectsRootDir: 'forge-test/projects', + }) + }) + + it('uses APP_VERSION in production, "dev" otherwise', () => { + vi.stubEnv('NODE_ENV', 'production') + vi.stubEnv('CI', 'true') + vi.stubEnv('SERVER_HOST', 'host') + vi.stubEnv('SERVER_PORT', '4000') + vi.stubEnv('APP_VERSION', '1.2.3') + vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects') + vi.stubEnv('DB_URL', 'postgres://db') + expect(baseConfigFactory().appVersion).toBe('1.2.3') + vi.stubEnv('NODE_ENV', 'test') + vi.stubEnv('CI', 'true') + vi.stubEnv('SERVER_HOST', 'host') + vi.stubEnv('SERVER_PORT', '4000') + vi.stubEnv('APP_VERSION', '1.2.3') + vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects') + vi.stubEnv('DB_URL', 'postgres://db') + expect(baseConfigFactory().appVersion).toBe('dev') + }) + + it('applies defaults when optional vars are absent', () => { + vi.stubEnv('DB_URL', 'postgres://default') + vi.stubEnv('PROJECTS_ROOT_DIR', 'r') + vi.stubEnv('NODE_ENV', 'production') + expect(baseConfigFactory()).toMatchObject({ + isDev: false, + isProd: true, + isCI: false, + serverHost: 'localhost', + serverPort: 0, + appVersion: 'unknown', + dbUrl: 'postgres://default', + }) + }) +}) diff --git a/apps/server-nestjs/src/config/base.config.ts b/apps/server-nestjs/src/config/base.config.ts new file mode 100644 index 0000000000..60e40d700c --- /dev/null +++ b/apps/server-nestjs/src/config/base.config.ts @@ -0,0 +1,29 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { flag, truthySchema } from './config.utils' + +const baseFeatureSchema = z.object({ + NODE_ENV: z.enum(['development', 'production', 'test']).default('production'), + CI: flag(truthySchema.default('false')), + SERVER_HOST: z.string().default('localhost'), + SERVER_PORT: z.string().transform(Number).default('0'), + APP_VERSION: z.string().default('unknown'), + DB_URL: z.string().url(), + PROJECTS_ROOT_DIR: z.string().default(''), +}).transform((raw) => { + return { + isTest: raw.NODE_ENV === 'test', + isDev: raw.NODE_ENV === 'development', + isCI: raw.CI, + isProd: raw.NODE_ENV === 'production', + serverHost: raw.SERVER_HOST, + serverPort: raw.SERVER_PORT, + appVersion: raw.NODE_ENV === 'production' ? raw.APP_VERSION : 'dev', + dbUrl: raw.DB_URL, + projectsRootDir: raw.PROJECTS_ROOT_DIR, + } +}) + +export type BaseConfig = z.infer + +export const baseConfigFactory = registerAs('base', () => baseFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/config-testing.utils.ts b/apps/server-nestjs/src/config/config-testing.utils.ts new file mode 100644 index 0000000000..934151374e --- /dev/null +++ b/apps/server-nestjs/src/config/config-testing.utils.ts @@ -0,0 +1,8 @@ +import { vi } from 'vitest' + +// Stub every env key a config reads as undefined (removed) so the test +// runner's real environment cannot leak into a config parse. Tests then +// stub only the keys they exercise. Pair with vi.unstubAllEnvs() in afterEach. +export function resetEnvs(keys: readonly string[]): void { + for (const key of keys) vi.stubEnv(key, undefined) +} diff --git a/apps/server-nestjs/src/config/config.utils.spec.ts b/apps/server-nestjs/src/config/config.utils.spec.ts new file mode 100644 index 0000000000..155c025349 --- /dev/null +++ b/apps/server-nestjs/src/config/config.utils.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import z from 'zod' +import { csv, flag, truthySchema } from './config.utils' + +describe('config.utils', () => { + describe('flag', () => { + it('coerces true/1 (case-insensitive) to true', () => { + const schema = flag(truthySchema.default('false')) + expect(schema.parse('true')).toBe(true) + expect(schema.parse('1')).toBe(true) + expect(schema.parse('TRUE')).toBe(true) + }) + + it('coerces false/0 to false', () => { + const schema = flag(truthySchema.default('true')) + expect(schema.parse('false')).toBe(false) + expect(schema.parse('0')).toBe(false) + }) + + it('falls back to the schema default when missing', () => { + expect(flag(truthySchema.default('false')).parse(undefined)).toBe(false) + expect(flag(truthySchema.default('true')).parse(undefined)).toBe(true) + }) + }) + + describe('csv', () => { + const schema = csv(z.string()) + + it('splits, trims and drops empty parts', () => { + expect(schema.parse('a, b ,,c')).toEqual(['a', 'b', 'c']) + }) + + it('maps missing/empty to an empty array', () => { + expect(schema.parse(undefined)).toEqual([]) + expect(schema.parse('')).toEqual([]) + }) + }) +}) diff --git a/apps/server-nestjs/src/config/config.utils.ts b/apps/server-nestjs/src/config/config.utils.ts new file mode 100644 index 0000000000..fd7ac69fd4 --- /dev/null +++ b/apps/server-nestjs/src/config/config.utils.ts @@ -0,0 +1,94 @@ +import z from 'zod' + +// Harbor retention triggers use 6-field (seconds) Quartz cron expressions. +// Stdlib-only validation, expressed as a Zod schema (no external dependency). +// Months (field 4) and days-of-week (field 5) accept Quartz name tokens too. +const MONTH_NAMES = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'] as const +const DOW_NAMES = ['SUN', 'MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT'] as const + +const CRON_FIELD_BOUNDS: ReadonlyArray = [ + [0, 59], // seconds + [0, 59], // minutes + [0, 23], // hours + [1, 31], // day of month + [1, 12], // month + [0, 7], // day of week (0 and 7 = Sunday) +] + +function isNameToken(token: string, names: ReadonlyArray): boolean { + return names.includes(token.toUpperCase()) +} + +function isNameItem(item: string, field: number): boolean { + if (field !== 4 && field !== 5) return false + const names = field === 4 ? MONTH_NAMES : DOW_NAMES + if (isNameToken(item, names)) return true + if (item.includes('-')) { + const [lo, hi] = item.split('-') + return isNameToken(lo, names) && isNameToken(hi, names) + } + return false +} + +function isRangeOrNumber(token: string, min: number, max: number): boolean { + if (token.includes('-')) { + const [lo, hi] = token.split('-').map(Number) + return lo >= min && hi <= max && lo <= hi + } + const n = Number(token) + return Number.isInteger(n) && n >= min && n <= max +} + +function isCronItem(item: string, field: number): boolean { + if (item === '*' || item === '?') return true + if (isNameItem(item, field)) return true + const [min, max] = CRON_FIELD_BOUNDS[field] + const step = /^(.+)\/(\d+)$/.exec(item) + if (step) { + if (Number(step[2]) < 1) return false + const base = step[1] + if (base === '*' || base === '?') return true + if (isNameItem(base, field)) return true + return isRangeOrNumber(base, min, max) + } + return isRangeOrNumber(item, min, max) +} + +export const cronSchema = z + .string() + .superRefine((value, ctx) => { + const fields = value.trim().split(/\s+/) + if (fields.length !== 6) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'cron must have 6 fields (seconds required)' }) + return + } + if (!fields.every((field, i) => field.split(',').every(part => isCronItem(part, i)))) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'invalid cron expression' }) + } + }) + +// Shared truthy enum for flag(): 'true'/'false'/'1'/'0'. +export const truthySchema = z.enum(['true', 'false', '1', '0']) + +// Boolean flag. Strictly takes a z string schema (e.g. truthySchema.default('true')). +// Lowercases, then coerces 'true'/'1' -> true, 'false'/'0' -> false. +// Missing value falls back to the schema's default. +export function flag(schema: z.ZodType) { + return z + .preprocess( + val => (typeof val === 'string' ? val.toLowerCase() : val), + schema, + ) + .transform(val => val === 'true' || val === '1') +} + +// Comma-separated string -> array of schema-validated, trimmed non-empty parts. +// Empty/missing -> []. Strictly takes a z string schema (applied per element). +export function csv>(schema: T) { + return z + .preprocess( + val => (typeof val === 'string' ? val : val ?? ''), + z.string().transform(value => value.split(',').map(part => part.trim()).filter(Boolean)), + ) + .pipe(z.array(schema)) +} diff --git a/apps/server-nestjs/src/config/gitlab.config.spec.ts b/apps/server-nestjs/src/config/gitlab.config.spec.ts new file mode 100644 index 0000000000..2aa91cf9db --- /dev/null +++ b/apps/server-nestjs/src/config/gitlab.config.spec.ts @@ -0,0 +1,39 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvs } from './config-testing.utils' +import { gitlabConfigFactory } from './gitlab.config' + +describe('gitlabConfig', () => { + beforeEach(() => { resetEnvs(['GITLAB_TOKEN', 'GITLAB_URL', 'GITLAB_INTERNAL_URL', 'GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS', 'GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS', 'GITLAB__SECRET_EXPOSE_INTERNAL_URL', 'PROJECTS_ROOT_DIR']) }) + afterEach(() => { vi.unstubAllEnvs() }) + + it('parses a full config', () => { + vi.stubEnv('GITLAB_TOKEN', 'token') + vi.stubEnv('GITLAB_URL', 'https://gitlab.internal') + vi.stubEnv('GITLAB_INTERNAL_URL', 'https://gitlab.internal:8080') + vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects') + vi.stubEnv('GITLAB__SECRET_EXPOSE_INTERNAL_URL', '1') + expect(gitlabConfigFactory()).toMatchObject({ + token: 'token', + url: 'https://gitlab.internal', + internalUrl: 'https://gitlab.internal:8080', + secretExposeInternalUrl: true, + mirrorTokenExpirationDays: 180, + mirrorTokenRotationThresholdDays: 90, + projectRootDir: 'forge-test/projects', + }) + }) + + it('falls back to public url when internal url is absent', () => { + vi.stubEnv('GITLAB_TOKEN', 'token') + vi.stubEnv('GITLAB_URL', 'https://gitlab.internal') + vi.stubEnv('PROJECTS_ROOT_DIR', 'forge-test/projects') + vi.stubEnv('GITLAB__SECRET_EXPOSE_INTERNAL_URL', '1') + const cfg = gitlabConfigFactory() + expect(cfg.internalUrl).toBeUndefined() + expect(cfg.url).toBe('https://gitlab.internal') + }) + + it('throws when a required var is missing', () => { + expect(() => gitlabConfigFactory()).toThrow() + }) +}) diff --git a/apps/server-nestjs/src/config/gitlab.config.ts b/apps/server-nestjs/src/config/gitlab.config.ts new file mode 100644 index 0000000000..31455a207a --- /dev/null +++ b/apps/server-nestjs/src/config/gitlab.config.ts @@ -0,0 +1,25 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { truthySchema } from './config.utils' + +const gitlabFeatureSchema = z.object({ + GITLAB_TOKEN: z.string().min(1), + GITLAB_URL: z.string().url(), + GITLAB_INTERNAL_URL: z.string().url().optional(), + GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS: z.coerce.number().int().positive().default(180), + GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + GITLAB__SECRET_EXPOSE_INTERNAL_URL: truthySchema.default('false').transform(v => v === 'true' || v === '1'), + PROJECTS_ROOT_DIR: z.string().min(1), +}).transform(raw => ({ + token: raw.GITLAB_TOKEN, + url: raw.GITLAB_URL, + internalUrl: raw.GITLAB_INTERNAL_URL, + secretExposeInternalUrl: raw.GITLAB__SECRET_EXPOSE_INTERNAL_URL, + mirrorTokenExpirationDays: raw.GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS, + mirrorTokenRotationThresholdDays: raw.GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS, + projectRootDir: raw.PROJECTS_ROOT_DIR, +})) + +export type GitlabConfig = z.infer + +export const gitlabConfigFactory = registerAs('gitlab', () => gitlabFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/harbor.config.spec.ts b/apps/server-nestjs/src/config/harbor.config.spec.ts new file mode 100644 index 0000000000..b1151196c2 --- /dev/null +++ b/apps/server-nestjs/src/config/harbor.config.spec.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvs } from './config-testing.utils' +import { harborConfigFactory } from './harbor.config' + +describe('harborConfig', () => { + beforeEach(() => { resetEnvs(['HARBOR_URL', 'HARBOR_INTERNAL_URL', 'HARBOR_ADMIN', 'HARBOR_ADMIN_PASSWORD', 'HARBOR_RULE_TEMPLATE', 'HARBOR_RULE_COUNT', 'HARBOR_RETENTION_CRON', 'HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS', 'HARBOR_PROJECT_SLUG_CACHE_TTL_MS']) }) + afterEach(() => { vi.unstubAllEnvs() }) + + it('parses a full config', () => { + vi.stubEnv('HARBOR_URL', 'https://harbor.internal') + vi.stubEnv('HARBOR_INTERNAL_URL', 'https://harbor.internal:8080') + vi.stubEnv('HARBOR_ADMIN', 'admin') + vi.stubEnv('HARBOR_ADMIN_PASSWORD', 'pw') + vi.stubEnv('HARBOR_RULE_TEMPLATE', 'always') + vi.stubEnv('HARBOR_RULE_COUNT', '3') + expect(harborConfigFactory()).toMatchObject({ + url: 'https://harbor.internal', + internalUrl: 'https://harbor.internal:8080', + admin: 'admin', + adminPassword: 'pw', + ruleTemplate: 'always', + ruleCount: 3, + retentionCron: '0 22 2 * * *', + robotRotationThresholdDays: 90, + projectSlugCacheTtlMs: 300_000, + }) + }) + + it('falls back to public url when internal url is absent', () => { + vi.stubEnv('HARBOR_URL', 'https://harbor.internal') + vi.stubEnv('HARBOR_ADMIN', 'admin') + vi.stubEnv('HARBOR_ADMIN_PASSWORD', 'pw') + vi.stubEnv('HARBOR_RULE_TEMPLATE', 'latestPushedK') + vi.stubEnv('HARBOR_RULE_COUNT', '3') + const cfg = harborConfigFactory() + expect(cfg.internalUrl).toBeUndefined() + expect(cfg.url).toBe('https://harbor.internal') + }) + + it('applies defaults when optional rule fields are absent', () => { + vi.stubEnv('HARBOR_URL', 'https://harbor.internal') + vi.stubEnv('HARBOR_ADMIN', 'admin') + vi.stubEnv('HARBOR_ADMIN_PASSWORD', 'pw') + const cfg = harborConfigFactory() + expect(cfg.ruleTemplate).toBeUndefined() + expect(cfg.ruleCount).toBeUndefined() + expect(cfg.retentionCron).toBe('0 22 2 * * *') + }) + + it('throws when a required var is missing', () => { + expect(() => harborConfigFactory()).toThrow() + }) +}) diff --git a/apps/server-nestjs/src/config/harbor.config.ts b/apps/server-nestjs/src/config/harbor.config.ts new file mode 100644 index 0000000000..94395a1c1a --- /dev/null +++ b/apps/server-nestjs/src/config/harbor.config.ts @@ -0,0 +1,39 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { cronSchema } from './config.utils' + +const ruleTemplateSchema = z.enum([ + 'always', + 'latestPulledK', + 'latestPushedK', + 'nDaysSinceLastPull', + 'nDaysSinceLastPush', +]) + +export type RuleTemplate = z.infer + +const harborFeatureSchema = z.object({ + HARBOR_URL: z.string().url(), + HARBOR_INTERNAL_URL: z.string().url().optional(), + HARBOR_ADMIN: z.string().min(1), + HARBOR_ADMIN_PASSWORD: z.string().min(1), + HARBOR_RULE_TEMPLATE: ruleTemplateSchema.optional(), + HARBOR_RULE_COUNT: z.coerce.number().int().positive().optional(), + HARBOR_RETENTION_CRON: cronSchema.default('0 22 2 * * *'), + HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS: z.coerce.number().int().positive().default(90), + HARBOR_PROJECT_SLUG_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), +}).transform(raw => ({ + url: raw.HARBOR_URL, + internalUrl: raw.HARBOR_INTERNAL_URL, + admin: raw.HARBOR_ADMIN, + adminPassword: raw.HARBOR_ADMIN_PASSWORD, + ruleTemplate: raw.HARBOR_RULE_TEMPLATE, + ruleCount: raw.HARBOR_RULE_COUNT, + retentionCron: raw.HARBOR_RETENTION_CRON, + robotRotationThresholdDays: raw.HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS, + projectSlugCacheTtlMs: raw.HARBOR_PROJECT_SLUG_CACHE_TTL_MS, +})) + +export type HarborConfig = z.infer + +export const harborConfigFactory = registerAs('harbor', () => harborFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/keycloak.config.spec.ts b/apps/server-nestjs/src/config/keycloak.config.spec.ts new file mode 100644 index 0000000000..800fc65574 --- /dev/null +++ b/apps/server-nestjs/src/config/keycloak.config.spec.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvs } from './config-testing.utils' +import { keycloakConfigFactory } from './keycloak.config' + +describe('keycloakConfig', () => { + beforeEach(() => { resetEnvs(['KEYCLOAK_PROTOCOL', 'KEYCLOAK_DOMAIN', 'KEYCLOAK_REALM', 'KEYCLOAK_CLIENT_ID', 'KEYCLOAK_CLIENT_SECRET', 'KEYCLOAK_ADMIN', 'KEYCLOAK_ADMIN_PASSWORD', 'KEYCLOAK_ADMIN_CLIENT_ID', 'KEYCLOAK_REDIRECT_URI', 'KEYCLOAK_JWKS_CACHE_TTL_MS', 'KEYCLOAK_JWKS_TIMEOUT_MS', 'KEYCLOAK_OPENID_CONFIGURATION_CACHE_TTL_MS', 'ADMIN_KC_USER_ID']) }) + afterEach(() => { vi.unstubAllEnvs() }) + + it('composes url/realmUrl/openidConfigurationUrl and parses csv admin ids', () => { + vi.stubEnv('KEYCLOAK_DOMAIN', 'kc.internal') + vi.stubEnv('KEYCLOAK_REALM', 'dso') + vi.stubEnv('KEYCLOAK_CLIENT_ID', 'frontend') + vi.stubEnv('KEYCLOAK_CLIENT_SECRET', 'secret') + vi.stubEnv('KEYCLOAK_ADMIN', 'admin') + vi.stubEnv('KEYCLOAK_ADMIN_PASSWORD', 'pw') + vi.stubEnv('KEYCLOAK_REDIRECT_URI', 'https://localhost:8080') + vi.stubEnv('ADMIN_KC_USER_ID', 'a,b , c') + expect(keycloakConfigFactory()).toMatchObject({ + protocol: 'https', + domain: 'kc.internal', + realm: 'dso', + adminClientId: 'admin-cli', + url: 'https://kc.internal', + realmUrl: 'https://kc.internal/realms/dso', + openidConfigurationUrl: 'https://kc.internal/realms/dso/.well-known/openid-configuration', + adminKcUserId: ['a', 'b', 'c'], + jwksCacheTtlMs: 300_000, + jwksTimeoutMs: 5_000, + openidConfigurationCacheTtlMs: 300_000, + }) + }) + + it('honours an explicit protocol', () => { + vi.stubEnv('KEYCLOAK_DOMAIN', 'kc.internal') + vi.stubEnv('KEYCLOAK_REALM', 'dso') + vi.stubEnv('KEYCLOAK_CLIENT_ID', 'frontend') + vi.stubEnv('KEYCLOAK_CLIENT_SECRET', 'secret') + vi.stubEnv('KEYCLOAK_ADMIN', 'admin') + vi.stubEnv('KEYCLOAK_ADMIN_PASSWORD', 'pw') + vi.stubEnv('KEYCLOAK_REDIRECT_URI', 'https://localhost:8080') + vi.stubEnv('ADMIN_KC_USER_ID', 'a,b , c') + vi.stubEnv('KEYCLOAK_PROTOCOL', 'https') + expect(keycloakConfigFactory().url).toBe('https://kc.internal') + expect(keycloakConfigFactory().protocol).toBe('https') + }) + + it('throws when a required var is missing', () => { + expect(() => keycloakConfigFactory()).toThrow() + }) +}) diff --git a/apps/server-nestjs/src/config/keycloak.config.ts b/apps/server-nestjs/src/config/keycloak.config.ts new file mode 100644 index 0000000000..cb917e6aa2 --- /dev/null +++ b/apps/server-nestjs/src/config/keycloak.config.ts @@ -0,0 +1,46 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const keycloakFeatureSchema = z.object({ + KEYCLOAK_PROTOCOL: z.enum(['http', 'https']).default('https'), + KEYCLOAK_DOMAIN: z.string().min(1), + KEYCLOAK_REALM: z.string().min(1), + KEYCLOAK_CLIENT_ID: z.string().min(1), + KEYCLOAK_CLIENT_SECRET: z.string().min(1), + KEYCLOAK_ADMIN: z.string().min(1), + KEYCLOAK_ADMIN_PASSWORD: z.string().min(1), + KEYCLOAK_ADMIN_CLIENT_ID: z.string().default('admin-cli'), + KEYCLOAK_REDIRECT_URI: z.string().url(), + KEYCLOAK_JWKS_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), + KEYCLOAK_JWKS_TIMEOUT_MS: z.coerce.number().int().positive().default(5_000), + KEYCLOAK_OPENID_CONFIGURATION_CACHE_TTL_MS: z.coerce.number().int().positive().default(300_000), + ADMIN_KC_USER_ID: z.preprocess( + value => (typeof value === 'string' ? value.split(',').map(part => part.trim()).filter(Boolean) : value), + z.array(z.string()).default([]), + ), +}).transform((raw) => { + const keycloakUrl = `${raw.KEYCLOAK_PROTOCOL}://${raw.KEYCLOAK_DOMAIN}` + const keycloakRealmUrl = `${keycloakUrl}/realms/${raw.KEYCLOAK_REALM}` + return { + protocol: raw.KEYCLOAK_PROTOCOL, + domain: raw.KEYCLOAK_DOMAIN, + realm: raw.KEYCLOAK_REALM, + clientId: raw.KEYCLOAK_CLIENT_ID, + clientSecret: raw.KEYCLOAK_CLIENT_SECRET, + admin: raw.KEYCLOAK_ADMIN, + adminPassword: raw.KEYCLOAK_ADMIN_PASSWORD, + adminClientId: raw.KEYCLOAK_ADMIN_CLIENT_ID, + redirectUri: raw.KEYCLOAK_REDIRECT_URI, + jwksCacheTtlMs: raw.KEYCLOAK_JWKS_CACHE_TTL_MS, + jwksTimeoutMs: raw.KEYCLOAK_JWKS_TIMEOUT_MS, + openidConfigurationCacheTtlMs: raw.KEYCLOAK_OPENID_CONFIGURATION_CACHE_TTL_MS, + adminKcUserId: raw.ADMIN_KC_USER_ID, + url: keycloakUrl, + realmUrl: keycloakRealmUrl, + openidConfigurationUrl: `${keycloakRealmUrl}/.well-known/openid-configuration`, + } +}) + +export type KeycloakConfig = z.infer + +export const keycloakConfigFactory = registerAs('keycloak', () => keycloakFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/nexus.config.spec.ts b/apps/server-nestjs/src/config/nexus.config.spec.ts new file mode 100644 index 0000000000..f81a5b56ac --- /dev/null +++ b/apps/server-nestjs/src/config/nexus.config.spec.ts @@ -0,0 +1,44 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvs } from './config-testing.utils' +import { nexusConfigFactory } from './nexus.config' + +describe('nexusConfig', () => { + beforeEach(() => { resetEnvs(['NEXUS_URL', 'NEXUS_INTERNAL_URL', 'NEXUS_ADMIN', 'NEXUS_ADMIN_PASSWORD', 'NEXUS__SECRET_EXPOSE_INTERNAL_URL']) }) + afterEach(() => { vi.unstubAllEnvs() }) + + it('parses a full config', () => { + vi.stubEnv('NEXUS_URL', 'https://nexus.internal') + vi.stubEnv('NEXUS_INTERNAL_URL', 'https://nexus.internal:8081') + vi.stubEnv('NEXUS_ADMIN', 'admin') + vi.stubEnv('NEXUS_ADMIN_PASSWORD', 'pw') + expect(nexusConfigFactory()).toMatchObject({ + url: 'https://nexus.internal', + internalUrl: 'https://nexus.internal:8081', + admin: 'admin', + adminPassword: 'pw', + secretExposeInternalUrl: false, + }) + }) + + it('falls back to public url when internal url is absent', () => { + vi.stubEnv('NEXUS_URL', 'https://nexus.internal') + vi.stubEnv('NEXUS_ADMIN', 'admin') + vi.stubEnv('NEXUS_ADMIN_PASSWORD', 'pw') + const cfg = nexusConfigFactory() + expect(cfg.internalUrl).toBeUndefined() + expect(cfg.url).toBe('https://nexus.internal') + }) + + it('coerces the secret-expose flag', () => { + vi.stubEnv('NEXUS_URL', 'https://nexus.internal') + vi.stubEnv('NEXUS_INTERNAL_URL', 'https://nexus.internal:8081') + vi.stubEnv('NEXUS_ADMIN', 'admin') + vi.stubEnv('NEXUS_ADMIN_PASSWORD', 'pw') + vi.stubEnv('NEXUS__SECRET_EXPOSE_INTERNAL_URL', 'true') + expect(nexusConfigFactory().secretExposeInternalUrl).toBe(true) + }) + + it('throws when a required var is missing', () => { + expect(() => nexusConfigFactory()).toThrow() + }) +}) diff --git a/apps/server-nestjs/src/config/nexus.config.ts b/apps/server-nestjs/src/config/nexus.config.ts new file mode 100644 index 0000000000..7be7712358 --- /dev/null +++ b/apps/server-nestjs/src/config/nexus.config.ts @@ -0,0 +1,21 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { truthySchema } from './config.utils' + +const nexusFeatureSchema = z.object({ + NEXUS_URL: z.string().url(), + NEXUS_INTERNAL_URL: z.string().url().optional(), + NEXUS_ADMIN: z.string().min(1), + NEXUS_ADMIN_PASSWORD: z.string().min(1), + NEXUS__SECRET_EXPOSE_INTERNAL_URL: truthySchema.default('false').transform(v => v === 'true' || v === '1'), +}).transform(raw => ({ + url: raw.NEXUS_URL, + internalUrl: raw.NEXUS_INTERNAL_URL, + admin: raw.NEXUS_ADMIN, + adminPassword: raw.NEXUS_ADMIN_PASSWORD, + secretExposeInternalUrl: raw.NEXUS__SECRET_EXPOSE_INTERNAL_URL, +})) + +export type NexusConfig = z.infer + +export const nexusConfigFactory = registerAs('nexus', () => nexusFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/service-chain.config.spec.ts b/apps/server-nestjs/src/config/service-chain.config.spec.ts new file mode 100644 index 0000000000..63dd3fd023 --- /dev/null +++ b/apps/server-nestjs/src/config/service-chain.config.spec.ts @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvs } from './config-testing.utils' +import { serviceChainConfigFactory } from './service-chain.config' + +describe('serviceChainConfig', () => { + beforeEach(() => { resetEnvs(['OPENCDS_URL', 'OPENCDS_INTERNAL_URL', 'OPENCDS_API_TOKEN', 'OPENCDS_API_TLS_REJECT_UNAUTHORIZED']) }) + afterEach(() => { vi.unstubAllEnvs() }) + + it('parses a full config', () => { + vi.stubEnv('OPENCDS_URL', 'https://opencds.internal') + vi.stubEnv('OPENCDS_INTERNAL_URL', 'https://opencds.internal:8080') + vi.stubEnv('OPENCDS_API_TOKEN', 'token') + expect(serviceChainConfigFactory()).toMatchObject({ + url: 'https://opencds.internal', + internalUrl: 'https://opencds.internal:8080', + apiToken: 'token', + apiTlsRejectUnauthorized: true, + }) + }) + + it('falls back to public url when internal url is absent', () => { + vi.stubEnv('OPENCDS_URL', 'https://opencds.internal') + vi.stubEnv('OPENCDS_API_TOKEN', 'token') + const cfg = serviceChainConfigFactory() + expect(cfg.internalUrl).toBeUndefined() + expect(cfg.url).toBe('https://opencds.internal') + }) + + it('coerces the tls reject flag', () => { + vi.stubEnv('OPENCDS_URL', 'https://opencds.internal') + vi.stubEnv('OPENCDS_INTERNAL_URL', 'https://opencds.internal:8080') + vi.stubEnv('OPENCDS_API_TOKEN', 'token') + vi.stubEnv('OPENCDS_API_TLS_REJECT_UNAUTHORIZED', '0') + expect(serviceChainConfigFactory().apiTlsRejectUnauthorized).toBe(false) + }) + + it('throws when a required var is missing', () => { + expect(() => serviceChainConfigFactory()).toThrow() + }) +}) diff --git a/apps/server-nestjs/src/config/service-chain.config.ts b/apps/server-nestjs/src/config/service-chain.config.ts new file mode 100644 index 0000000000..b5f0a9373d --- /dev/null +++ b/apps/server-nestjs/src/config/service-chain.config.ts @@ -0,0 +1,21 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' +import { truthySchema } from './config.utils' + +const serviceChainFeatureSchema = z.object({ + OPENCDS_URL: z.string().url(), + OPENCDS_INTERNAL_URL: z.string().url().optional(), + OPENCDS_API_TOKEN: z.string().min(1), + OPENCDS_API_TLS_REJECT_UNAUTHORIZED: truthySchema.default('true').transform(v => v === 'true' || v === '1'), +}).transform((raw) => { + return { + url: raw.OPENCDS_URL, + internalUrl: raw.OPENCDS_INTERNAL_URL, + apiToken: raw.OPENCDS_API_TOKEN, + apiTlsRejectUnauthorized: raw.OPENCDS_API_TLS_REJECT_UNAUTHORIZED, + } +}) + +export type ServiceChainConfig = z.infer + +export const serviceChainConfigFactory = registerAs('serviceChain', () => serviceChainFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/sonarqube.config.spec.ts b/apps/server-nestjs/src/config/sonarqube.config.spec.ts new file mode 100644 index 0000000000..0747ace07f --- /dev/null +++ b/apps/server-nestjs/src/config/sonarqube.config.spec.ts @@ -0,0 +1,31 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvs } from './config-testing.utils' +import { sonarqubeConfigFactory } from './sonarqube.config' + +describe('sonarqubeConfig', () => { + beforeEach(() => { resetEnvs(['SONARQUBE_URL', 'SONARQUBE_INTERNAL_URL', 'SONAR_API_TOKEN']) }) + afterEach(() => { vi.unstubAllEnvs() }) + + it('parses a full config', () => { + vi.stubEnv('SONARQUBE_URL', 'https://sonar.internal') + vi.stubEnv('SONARQUBE_INTERNAL_URL', 'https://sonar.internal:9000') + vi.stubEnv('SONAR_API_TOKEN', 'token') + expect(sonarqubeConfigFactory()).toMatchObject({ + url: 'https://sonar.internal', + internalUrl: 'https://sonar.internal:9000', + apiToken: 'token', + }) + }) + + it('falls back to public url when internal url is absent', () => { + vi.stubEnv('SONARQUBE_URL', 'https://sonar.internal') + vi.stubEnv('SONAR_API_TOKEN', 'token') + const cfg = sonarqubeConfigFactory() + expect(cfg.internalUrl).toBeUndefined() + expect(cfg.url).toBe('https://sonar.internal') + }) + + it('throws when a required var is missing', () => { + expect(() => sonarqubeConfigFactory()).toThrow() + }) +}) diff --git a/apps/server-nestjs/src/config/sonarqube.config.ts b/apps/server-nestjs/src/config/sonarqube.config.ts new file mode 100644 index 0000000000..f61cd9eecf --- /dev/null +++ b/apps/server-nestjs/src/config/sonarqube.config.ts @@ -0,0 +1,16 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const sonarqubeFeatureSchema = z.object({ + SONARQUBE_URL: z.string().url(), + SONARQUBE_INTERNAL_URL: z.string().url().optional(), + SONAR_API_TOKEN: z.string().min(1), +}).transform(raw => ({ + url: raw.SONARQUBE_URL, + internalUrl: raw.SONARQUBE_INTERNAL_URL, + apiToken: raw.SONAR_API_TOKEN, +})) + +export type SonarqubeConfig = z.infer + +export const sonarqubeConfigFactory = registerAs('sonarqube', () => sonarqubeFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/config/vault.config.spec.ts b/apps/server-nestjs/src/config/vault.config.spec.ts new file mode 100644 index 0000000000..6bd3972d1d --- /dev/null +++ b/apps/server-nestjs/src/config/vault.config.spec.ts @@ -0,0 +1,32 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { resetEnvs } from './config-testing.utils' +import { vaultConfigFactory } from './vault.config' + +describe('vaultConfig', () => { + beforeEach(() => { resetEnvs(['VAULT_TOKEN', 'VAULT_URL', 'VAULT_INTERNAL_URL', 'VAULT_KV_NAME']) }) + afterEach(() => { vi.unstubAllEnvs() }) + + it('parses a full config', () => { + vi.stubEnv('VAULT_URL', 'https://vault.internal') + vi.stubEnv('VAULT_INTERNAL_URL', 'https://vault.internal:8200') + vi.stubEnv('VAULT_TOKEN', 'token') + expect(vaultConfigFactory()).toMatchObject({ + url: 'https://vault.internal', + internalUrl: 'https://vault.internal:8200', + token: 'token', + kvName: 'forge-dso', + }) + }) + + it('falls back to public url when internal url is absent', () => { + vi.stubEnv('VAULT_URL', 'https://vault.internal') + vi.stubEnv('VAULT_TOKEN', 'token') + const cfg = vaultConfigFactory() + expect(cfg.internalUrl).toBeUndefined() + expect(cfg.url).toBe('https://vault.internal') + }) + + it('throws when a required var is missing', () => { + expect(() => vaultConfigFactory()).toThrow() + }) +}) diff --git a/apps/server-nestjs/src/config/vault.config.ts b/apps/server-nestjs/src/config/vault.config.ts new file mode 100644 index 0000000000..ea73971526 --- /dev/null +++ b/apps/server-nestjs/src/config/vault.config.ts @@ -0,0 +1,18 @@ +import { registerAs } from '@nestjs/config' +import z from 'zod' + +const vaultFeatureSchema = z.object({ + VAULT_TOKEN: z.string().min(1), + VAULT_URL: z.string().url(), + VAULT_INTERNAL_URL: z.string().url().optional(), + VAULT_KV_NAME: z.string().default('forge-dso'), +}).transform(raw => ({ + token: raw.VAULT_TOKEN, + url: raw.VAULT_URL, + internalUrl: raw.VAULT_INTERNAL_URL, + kvName: raw.VAULT_KV_NAME, +})) + +export type VaultConfig = z.infer + +export const vaultConfigFactory = registerAs('vault', () => vaultFeatureSchema.parse(process.env)) diff --git a/apps/server-nestjs/src/main.module.ts b/apps/server-nestjs/src/main.module.ts index d023ec8f5c..c44f56b5d4 100644 --- a/apps/server-nestjs/src/main.module.ts +++ b/apps/server-nestjs/src/main.module.ts @@ -1,5 +1,8 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { ScheduleModule } from '@nestjs/schedule' +import { TerminusModule } from '@nestjs/terminus' +import { baseConfigFactory } from './config/base.config' import { DeploymentModule } from './modules/deployment/deployment.module' import { EnvironmentModule } from './modules/environment/environment.module' import { HealthzModule } from './modules/healthz/healthz.module' @@ -15,9 +18,16 @@ import { ProjectServicesModule } from './modules/project-services/project-servic import { ProjectModule } from './modules/project/project.module' import { SystemSettingsModule } from './modules/system-settings/system-settings.module' import { VersionModule } from './modules/version/version.module' +import { getDotenvPaths } from './utils/dotenv.utils' @Module({ imports: [ + ConfigModule.forRoot({ + envFilePath: getDotenvPaths(), + isGlobal: true, + load: [baseConfigFactory], + }), + TerminusModule.forRoot(), DeploymentModule, EnvironmentModule, HealthzModule, diff --git a/apps/server-nestjs/src/main.ts b/apps/server-nestjs/src/main.ts index c50b7cdae9..a5c82a9ef3 100644 --- a/apps/server-nestjs/src/main.ts +++ b/apps/server-nestjs/src/main.ts @@ -1,3 +1,4 @@ +import type { ConfigType } from '@nestjs/config' import type { NestFastifyApplication } from '@nestjs/platform-fastify' import { NestFactory } from '@nestjs/core' import { FastifyAdapter } from '@nestjs/platform-fastify' @@ -9,8 +10,8 @@ import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics' import { NodeSDK, resources } from '@opentelemetry/sdk-node' import { Logger } from 'nestjs-pino' import { EnvHttpProxyAgent, setGlobalDispatcher } from 'undici' +import { baseConfigFactory } from './config/base.config' import { MainModule } from './main.module' -import { ConfigurationService } from './modules/infrastructure/configuration/configuration.service' const SERVICE_NAME = 'console-pi-native-console' @@ -46,7 +47,7 @@ async function bootstrap() { await telemetry.shutdown() }) - const config = app.get(ConfigurationService) + const config = app.get>(baseConfigFactory.KEY) // Setup swagger-ui route const swaggerConfig = new DocumentBuilder() @@ -58,7 +59,7 @@ async function bootstrap() { const documentFactory = () => SwaggerModule.createDocument(app, swaggerConfig) SwaggerModule.setup('swagger-ui-server-nestjs', app, documentFactory) - await app.listen(config.port, config.host) + await app.listen(config.serverPort, config.serverHost) const serverUrl = await app.getUrl() const logger = app.get(Logger) diff --git a/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts b/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts index 05dca67fe3..c8d63c48da 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd-health.service.ts @@ -1,22 +1,21 @@ +import type { ConfigType } from '@nestjs/config' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { argocdConfigFactory } from '../../config/argocd.config' import { PLUGIN_NAME } from './argocd.constants' @Injectable() export class ArgoCDHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(argocdConfigFactory.KEY) private readonly argocdConfig: ConfigType, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} async check() { const indicator = this.healthIndicator.check(PLUGIN_NAME) - const urlBase = this.config.getInternalOrPublicArgoCDUrl() - if (!urlBase) return indicator.down('Not configured') - try { - const response = await fetch(new URL('/api/version', urlBase).toString()) + const url = new URL('/api/version', this.argocdConfig.internalUrl ?? this.argocdConfig.url).toString() + const response = await fetch(url) if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) return indicator.down({ httpStatus: response.status }) } catch (error) { diff --git a/apps/server-nestjs/src/modules/argocd/argocd.module.ts b/apps/server-nestjs/src/modules/argocd/argocd.module.ts index ad51bf62dd..e38fad5b8b 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.module.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.module.ts @@ -1,7 +1,10 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { TerminusModule } from '@nestjs/terminus' +import { argocdConfigFactory } from '../../config/argocd.config' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' import { GitlabModule } from '../gitlab/gitlab.module' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' import { DatabaseModule } from '../infrastructure/database/database.module' import { VaultModule } from '../vault/vault.module' import { ArgoCDDatastoreService } from './argocd-datastore.service' @@ -10,7 +13,7 @@ import { ArgoCDPluginService } from './argocd-plugin.service' import { ArgoCDService } from './argocd.service' @Module({ - imports: [ConfigurationModule, DatabaseModule, GitlabModule, TerminusModule, VaultModule], + imports: [DatabaseModule, GitlabModule, TerminusModule, VaultModule, ConfigModule.forFeature(argocdConfigFactory), ConfigModule.forFeature(vaultConfigFactory), ConfigModule.forFeature(baseConfigFactory)], providers: [ArgoCDHealthService, ArgoCDPluginService, ArgoCDService, ArgoCDDatastoreService], exports: [ArgoCDHealthService, ArgoCDPluginService, ArgoCDService], }) diff --git a/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts b/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts index 53727b91af..910cabe2d8 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.service.spec.ts @@ -1,12 +1,15 @@ +import type { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' import { generateNamespaceName } from '@cpn-console/shared' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' import { parse, stringify } from 'yaml' +import { argocdConfigFactory } from '../../config/argocd.config' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' import { GitlabClientService } from '../gitlab/gitlab-client.service' import { makeCommitAction, makeProjectSchema, makeRepositoryTreeSchema } from '../gitlab/gitlab-testing.utils' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { VaultClientService } from '../vault/vault-client.service' import { ArgoCDDatastoreService } from './argocd-datastore.service' import { @@ -25,21 +28,31 @@ describe('argoCDService', () => { let datastore: DeepMockProxy let gitlab: DeepMockProxy let vault: DeepMockProxy + let argocdConfig: DeepMockProxy> + let baseConfig: DeepMockProxy> + let vaultConfig: DeepMockProxy> beforeEach(async () => { datastore = mockDeep() gitlab = mockDeep() vault = mockDeep() - const config = mockDeep({ - argoNamespace: 'argocd', - argocdUrl: 'https://argocd.internal', - argocdExtraRepositories: 'repo3', + argocdConfig = mockDeep>({ + enabled: true, + namespace: 'argocd', + url: 'https://argocd.internal', + internalUrl: undefined, + extraRepositories: ['repo3'], dsoEnvChartVersion: 'dso-env-1.6.0', dsoNsChartVersion: 'dso-ns-1.1.5', - projectRootDir: 'forge', - vaultUrl: 'https://vault.internal', - vaultKvName: 'kv', - deployVaultConnectionInNamespaces: false, + vaultDeployVaultConnectionInNs: false, + }) + baseConfig = mockDeep>({ + projectsRootDir: 'forge', + }) + vaultConfig = mockDeep>({ + url: 'https://vault.internal', + kvName: 'kv', + internalUrl: undefined, }) datastore.getAllZoneSlugs.mockResolvedValue(['zone-1']) @@ -48,7 +61,9 @@ describe('argoCDService', () => { providers: [ ArgoCDService, { provide: ArgoCDDatastoreService, useValue: datastore }, - { provide: ConfigurationService, useValue: config }, + { provide: argocdConfigFactory.KEY, useValue: argocdConfig }, + { provide: baseConfigFactory.KEY, useValue: baseConfig }, + { provide: vaultConfigFactory.KEY, useValue: vaultConfig }, { provide: GitlabClientService, useValue: gitlab }, { provide: VaultClientService, useValue: vault }, ], @@ -191,7 +206,6 @@ describe('argoCDService', () => { }, sourceRepositories: [ 'https://gitlab.internal/group/project-1/**', - 'repo3', 'repo2', ], destination: { @@ -267,7 +281,6 @@ describe('argoCDService', () => { }, sourceRepositories: [ 'https://gitlab.internal/group/project-1/**', - 'repo3', 'repo2', ], destination: { @@ -528,7 +541,6 @@ describe('argoCDService', () => { }, sourceRepositories: [ 'https://gitlab.internal/group/project-1/**', - 'repo3', 'repo2', ], destination: { diff --git a/apps/server-nestjs/src/modules/argocd/argocd.service.ts b/apps/server-nestjs/src/modules/argocd/argocd.service.ts index 3bb16b9470..1d8c089da4 100644 --- a/apps/server-nestjs/src/modules/argocd/argocd.service.ts +++ b/apps/server-nestjs/src/modules/argocd/argocd.service.ts @@ -1,4 +1,5 @@ import type { CommitAction, CondensedProjectSchema, ProjectSchema, SimpleProjectSchema } from '@gitbeaker/core' +import type { ConfigType } from '@nestjs/config' import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { ProjectWithDetails } from './argocd-datastore.service' import { createHmac } from 'node:crypto' @@ -7,8 +8,10 @@ import { Inject, Injectable, Logger } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' import { stringify } from 'yaml' +import { argocdConfigFactory } from '../../config/argocd.config' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' import { GitlabClientService } from '../gitlab/gitlab-client.service' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from '../vault/vault-client.service' @@ -30,14 +33,22 @@ export class ArgoCDService { private readonly logger = new Logger(ArgoCDService.name) constructor( - @Inject(ArgoCDDatastoreService) private readonly argoCDDatastore: ArgoCDDatastoreService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(ArgoCDDatastoreService) private readonly datastore: ArgoCDDatastoreService, + @Inject(argocdConfigFactory.KEY) private readonly argocdConfig: ConfigType, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, + @Inject(vaultConfigFactory.KEY) private readonly vaultConfig: ConfigType, @Inject(GitlabClientService) private readonly gitlab: GitlabClientService, @Inject(VaultClientService) private readonly vault: VaultClientService, ) { this.logger.log('ArgoCDService initialized') } + private getExtraRepositories(project: ProjectWithDetails): string[] { + return splitExtraRepositories( + project.plugins?.find(p => p.key === 'extraRepositories')?.value, + ) + } + @OnEvent('project.upsert') async handleUpsert(project: ProjectWithDetails): Promise> { return capturePluginResult('argocd', () => this.syncProject(project)) @@ -70,7 +81,7 @@ export class ArgoCDService { @StartActiveSpan() async handleCron() { this.logger.log('Starting ArgoCD reconciliation') - const projects = await this.argoCDDatastore.getAllProjects() + const projects = await this.datastore.getAllProjects() const span = trace.getActiveSpan() span?.setAttribute('argocd.projects.count', projects.length) this.logger.log(`Loaded ${projects.length} projects for ArgoCD reconciliation`) @@ -103,7 +114,7 @@ export class ArgoCDService { span?.setAttribute('project.slug', project.slug) // Visit every zone, not only those with a current environment, so leftover // values files are purged from zones the project no longer deploys to. - const zones = await this.argoCDDatastore.getAllZoneSlugs() + const zones = await this.datastore.getAllZoneSlugs() span?.setAttribute('argocd.zones.count', zones.length) this.logger.verbose(`Reconciling ArgoCD zones for project ${project.slug} (count=${zones.length})`) await Promise.all(zones.map(zoneSlug => this.ensureZone(project, zoneSlug))) @@ -240,13 +251,13 @@ export class ArgoCDService { environment, cluster, gitlabPublicProjectUrl, - argocdExtraRepositories: this.config.argocdExtraRepositories, + argocdExtraRepositories: this.getExtraRepositories(project), infraProject, valueFilePath, vaultValues, - argoNamespace: this.config.argoNamespace, - envChartVersion: this.config.dsoEnvChartVersion, - nsChartVersion: this.config.dsoNsChartVersion, + argoNamespace: this.argocdConfig.namespace, + envChartVersion: this.argocdConfig.dsoEnvChartVersion, + nsChartVersion: this.argocdConfig.dsoNsChartVersion, }) return this.gitlab.generateCreateOrUpdateAction( @@ -312,13 +323,13 @@ export class ArgoCDService { environment, cluster, gitlabPublicProjectUrl, - argocdExtraRepositories: this.config.argocdExtraRepositories, + argocdExtraRepositories: this.getExtraRepositories(project), infraProject, valueFilePath, vaultValues, - argoNamespace: this.config.argoNamespace, - envChartVersion: this.config.dsoEnvChartVersion, - nsChartVersion: this.config.dsoNsChartVersion, + argoNamespace: this.argocdConfig.namespace, + envChartVersion: this.argocdConfig.dsoEnvChartVersion, + nsChartVersion: this.argocdConfig.dsoNsChartVersion, deployments, }) @@ -341,9 +352,9 @@ export class ArgoCDService { return undefined }) return { - projectsRootDir: this.config.projectRootDir, - url: this.config.deployVaultConnectionInNamespaces ? this.config.vaultUrl : '', - coreKvName: this.config.vaultKvName, + projectsRootDir: this.baseConfig.projectsRootDir, + url: this.argocdConfig.vaultDeployVaultConnectionInNs ? this.vaultConfig.url : '', + coreKvName: this.vaultConfig.kvName, roleId: roleId ?? 'none', secretId: secretId ?? 'none', } @@ -546,23 +557,15 @@ function formatEnvironmentValues( interface FormatSourceRepositoriesValuesOptions { gitlabPublicProjectUrl: string - argocdExtraRepositories?: string - projectPlugins?: ProjectWithDetails['plugins'] + argocdExtraRepositories?: string[] } function formatSourceRepositoriesValues( - { gitlabPublicProjectUrl, argocdExtraRepositories, projectPlugins }: FormatSourceRepositoriesValuesOptions, + { gitlabPublicProjectUrl, argocdExtraRepositories = [] }: FormatSourceRepositoriesValuesOptions, ): string[] { - let projectExtraRepositories = '' - if (projectPlugins) { - const argocdPlugin = projectPlugins.find(p => p.pluginName === 'argocd' && p.key === 'extraRepositories') - if (argocdPlugin) projectExtraRepositories = argocdPlugin.value - } - return [ `${gitlabPublicProjectUrl}/**`, - ...splitExtraRepositories(argocdExtraRepositories), - ...splitExtraRepositories(projectExtraRepositories), + ...argocdExtraRepositories, ] } @@ -604,7 +607,7 @@ interface FormatValuesOptions { environment: ProjectWithDetails['environments'][number] cluster: ProjectWithDetails['environments'][number]['cluster'] gitlabPublicProjectUrl: string - argocdExtraRepositories?: string + argocdExtraRepositories?: string[] vaultValues: Record infraProject: SimpleProjectSchema valueFilePath: string @@ -652,7 +655,6 @@ function formatValues({ sourceRepositories: formatSourceRepositoriesValues({ gitlabPublicProjectUrl, argocdExtraRepositories, - projectPlugins: project.plugins, }), destination: { namespace: generateNamespaceName(project.id, environment.id), diff --git a/apps/server-nestjs/src/modules/events/app-events.service.spec.ts b/apps/server-nestjs/src/modules/events/app-events.service.spec.ts index 3a5c955eee..35fa8759c4 100644 --- a/apps/server-nestjs/src/modules/events/app-events.service.spec.ts +++ b/apps/server-nestjs/src/modules/events/app-events.service.spec.ts @@ -1,3 +1,4 @@ +import type { ConfigType } from '@nestjs/config' import type { EventEmitter2 as EventEmitter2Type } from '@nestjs/event-emitter' import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' @@ -5,7 +6,7 @@ import { EventEmitter2 } from '@nestjs/event-emitter' import { Test } from '@nestjs/testing' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { baseConfigFactory } from '../../config/base.config' import { PrismaService } from '../infrastructure/database/prisma.service' import { LogService } from '../log/log.service' import { projectSelect } from '../project/project-queries.utils' @@ -18,13 +19,13 @@ describe('appEventsService', () => { let prisma: DeepMockProxy let eventEmitter: DeepMockProxy let logs: DeepMockProxy - let config: DeepMockProxy + let config: DeepMockProxy> beforeEach(async () => { prisma = mockDeep() eventEmitter = mockDeep({ emitAsync: vi.fn().mockResolvedValue([]) }) logs = mockDeep() - config = mockDeep({ appVersion: 'test-version' }) + config = mockDeep>({ appVersion: 'test-version' }) module = await Test.createTestingModule({ providers: [ @@ -32,7 +33,7 @@ describe('appEventsService', () => { { provide: PrismaService, useValue: prisma }, { provide: EventEmitter2, useValue: eventEmitter }, { provide: LogService, useValue: logs }, - { provide: ConfigurationService, useValue: config }, + { provide: baseConfigFactory.KEY, useValue: config }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/events/app-events.service.ts b/apps/server-nestjs/src/modules/events/app-events.service.ts index 0c0aa94e03..9b658d59c5 100644 --- a/apps/server-nestjs/src/modules/events/app-events.service.ts +++ b/apps/server-nestjs/src/modules/events/app-events.service.ts @@ -1,8 +1,9 @@ +import type { ConfigType } from '@nestjs/config' import type { PluginResults } from '../plugin/plugin.utils' import type { ProjectWithDetails } from '../project/project-queries.utils' import { Inject, Injectable, Logger } from '@nestjs/common' import { EventEmitter2 } from '@nestjs/event-emitter' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { baseConfigFactory } from '../../config/base.config' import { PrismaService } from '../infrastructure/database/prisma.service' import { LogService } from '../log/log.service' import { getFailedPlugins, mergePluginResults } from '../plugin/plugin.utils' @@ -46,7 +47,7 @@ export class AppEventsService { @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(EventEmitter2) private readonly eventEmitter: EventEmitter2, @Inject(LogService) private readonly logs: LogService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, ) {} /** @@ -130,7 +131,7 @@ export class AppEventsService { if (event === 'project.upsert') { await this.prisma.project.update({ where: { id: projectId }, - data: { status: 'created', lastSuccessProvisionningVersion: this.config.appVersion }, + data: { status: 'created', lastSuccessProvisionningVersion: this.baseConfig.appVersion }, }) } } diff --git a/apps/server-nestjs/src/modules/events/app-events.utils.ts b/apps/server-nestjs/src/modules/events/app-events.utils.ts index 48684cb16e..a9cf62706b 100644 --- a/apps/server-nestjs/src/modules/events/app-events.utils.ts +++ b/apps/server-nestjs/src/modules/events/app-events.utils.ts @@ -1,6 +1,6 @@ import type { LogData } from '../log/log.service' import type { PluginName, PluginResult, PluginResults } from '../plugin/plugin.utils' -import { getErrorHttpDetails } from '../../utils/http-error' +import { getErrorHttpDetails } from '../../utils/http.utils' import { getFailedPlugins } from '../plugin/plugin.utils' /** Per-service result as persisted in the admin logs (legacy hooks format, parsed by LogSchema). */ diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts index 3655fc64fe..b69f600c8b 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts @@ -1,11 +1,12 @@ import type { ExpandedGroupSchema, Gitlab as GitlabApi, ProjectSchema } from '@gitbeaker/core' +import type { ConfigType } from '@nestjs/config' import type { TestingModule } from '@nestjs/testing' import type { MockedFunction } from 'vitest' import type { DeepMockProxy } from 'vitest-mock-extended' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { gitlabConfigFactory } from '../../config/gitlab.config' import { GITLAB_REST_CLIENT, GitlabClientService } from './gitlab-client.service' import { makeAccessTokenExposedSchema, @@ -35,20 +36,19 @@ describe('gitlab-client', () => { beforeEach(async () => { gitlabApi = mockDeep() - const config = mockDeep({ - gitlabUrl: 'https://gitlab.internal', - gitlabToken: 'token', - gitlabInternalUrl: 'https://gitlab.internal', + const config = mockDeep>({ + url: 'https://gitlab.internal', + token: 'token', + internalUrl: 'https://gitlab.internal', projectRootDir: 'forge', - gitlabMirrorTokenExpirationDays: 30, - getInternalOrPublicGitlabUrl: () => 'https://gitlab.internal', + mirrorTokenExpirationDays: 30, }) const module: TestingModule = await Test.createTestingModule({ providers: [ GitlabClientService, { provide: GITLAB_REST_CLIENT, useValue: gitlabApi }, - { provide: ConfigurationService, useValue: config }, + { provide: gitlabConfigFactory.KEY, useValue: config }, ], }).compile() service = module.get(GitlabClientService) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts index 53841d8d13..51ea4fc3bb 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts @@ -14,11 +14,12 @@ import type { PipelineTriggerTokenSchema, SimpleUserSchema, } from '@gitbeaker/core' +import type { ConfigType } from '@nestjs/config' import { join } from 'node:path' import { GitbeakerRequestError } from '@gitbeaker/requester-utils' import { Inject, Injectable, Logger } from '@nestjs/common' -import { find } from '../../utils/iterable' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { gitlabConfigFactory } from '../../config/gitlab.config' +import { find } from '../../utils/iterable.utils' import { GROUP_ROOT_CUSTOM_ATTRIBUTE_KEY, INFRA_GROUP_CUSTOM_ATTRIBUTE_KEY, @@ -51,7 +52,7 @@ export class GitlabClientService { private readonly logger = new Logger(GitlabClientService.name) constructor( - @Inject(ConfigurationService) readonly config: ConfigurationService, + @Inject(gitlabConfigFactory.KEY) readonly config: ConfigType, @Inject(GITLAB_REST_CLIENT) private readonly client: Gitlab, ) { } @@ -187,17 +188,17 @@ export class GitlabClientService { async getOrCreateProjectGroupPublicUrl(): Promise { const projectGroup = await this.getOrCreateProjectGroup() - return new URL(projectGroup.full_path, this.config.gitlabUrl).toString() + return new URL(projectGroup.full_path, this.config.url).toString() } async getOrCreateInfraGroupRepoPublicUrl(repoName: string): Promise { const projectGroup = await this.getOrCreateProjectGroup() - return new URL(`${projectGroup.full_path}/${INFRA_GROUP_PATH}/${repoName}.git`, this.config.gitlabUrl).toString() + return new URL(`${projectGroup.full_path}/${INFRA_GROUP_PATH}/${repoName}.git`, this.config.url).toString() } async getOrCreateProjectGroupInternalRepoUrl(subGroupPath: string, repoName: string): Promise { const projectGroup = await this.getOrCreateProjectSubGroup(subGroupPath) - const urlBase = this.config.getInternalOrPublicGitlabUrl() + const urlBase = this.config.internalUrl ?? this.config.url if (!urlBase) throw new Error('GITLAB_URL is required') return `${urlBase}/${projectGroup.full_path}/${repoName}.git` } @@ -474,7 +475,7 @@ export class GitlabClientService { async createProjectToken(projectSlug: string, tokenName: string, scopes: AccessTokenScopes[]) { const group = await this.getProjectGroup(projectSlug) if (!group) throw new Error('Unable to retrieve gitlab project group') - const expirationDays = Number(this.config.gitlabMirrorTokenExpirationDays) + const expirationDays = Number(this.config.mirrorTokenExpirationDays) const effectiveExpirationDays = Number.isFinite(expirationDays) && expirationDays > 0 ? expirationDays : 30 const expiryDate = new Date(Date.now() + effectiveExpirationDays * 24 * 60 * 60 * 1000) this.logger.log(`Creating a GitLab group access token (projectSlug=${projectSlug}, tokenName=${tokenName}, expiry=${expiryDate.toISOString().slice(0, 10)})`) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts index 68a562e3b1..08ae9b72b4 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-health.service.ts @@ -1,22 +1,20 @@ +import type { ConfigType } from '@nestjs/config' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { gitlabConfigFactory } from '../../config/gitlab.config' import { PLUGIN_NAME } from '../vault/vault.constants' @Injectable() export class GitlabHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(gitlabConfigFactory.KEY) private readonly gitlabConfig: ConfigType, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} async check() { const indicator = this.healthIndicator.check(PLUGIN_NAME) - const urlBase = this.config.getInternalOrPublicGitlabUrl() - if (!urlBase) return indicator.down('Not configured') - - const url = new URL('/-/health', urlBase).toString() try { + const url = new URL('/-/health', this.gitlabConfig.internalUrl ?? this.gitlabConfig.url).toString() const response = await fetch(url) if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) return indicator.down({ httpStatus: response.status }) diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.spec.ts index 6bd62d87c2..5dd2e73f80 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.spec.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.spec.ts @@ -1,25 +1,26 @@ +import type { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { gitlabConfigFactory } from '../../config/gitlab.config' import { makeToUrlParams } from '../plugin/plugin.utils' import { GitlabPluginService } from './gitlab-plugin.service' describe('gitlabPluginService', () => { let service: GitlabPluginService - let config: DeepMockProxy + let config: DeepMockProxy> beforeEach(async () => { - config = mockDeep({ - gitlabUrl: 'https://gitlab.public', + config = mockDeep>({ + url: 'https://gitlab.public', projectRootDir: 'forge', }) const moduleRef = await Test.createTestingModule({ providers: [ GitlabPluginService, - { provide: ConfigurationService, useValue: config }, + { provide: gitlabConfigFactory.KEY, useValue: config }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts index 2e4d6f1424..9cc94a15c4 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab-plugin.service.ts @@ -1,23 +1,21 @@ import type { ServiceInfos } from '@cpn-console/hooks' +import type { ConfigType } from '@nestjs/config' import { DISABLED, ENABLED } from '@cpn-console/shared' import { Inject, Injectable } from '@nestjs/common' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { gitlabConfigFactory } from '../../config/gitlab.config' import { DEFAULT_ADMIN_GROUP_PATH, DEFAULT_AUDITOR_GROUP_PATH, DEFAULT_PROJECT_DEVELOPER_GROUP_PATH_SUFFIX, DEFAULT_PROJECT_MAINTAINER_GROUP_PATH_SUFFIX, DEFAULT_PROJECT_REPORTER_GROUP_PATH_SUFFIX, PURGE_PLUGIN_KEY } from './gitlab.constants' @Injectable() export class GitlabPluginService { constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(gitlabConfigFactory.KEY) + private readonly gitlabConfig: ConfigType, ) {} infos(): ServiceInfos { return { name: 'gitlab', - to: ({ project }) => { - if (!this.config.gitlabUrl || !this.config.projectRootDir) return undefined - return new URL(`${this.config.projectRootDir}/${project.slug}`, this.config.gitlabUrl).toString() - }, + to: ({ project }) => new URL(`${this.gitlabConfig.projectRootDir}/${project.slug}`, this.gitlabConfig.url).toString(), title: 'Gitlab', imgSrc: '/img/gitlab.svg', description: 'GitLab est un service d\'hébergement de code source et de pipeline CI/CD', diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts index 22859472fb..a2c2d210b6 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.module.ts @@ -1,8 +1,9 @@ +import type { ConfigType } from '@nestjs/config' import { Gitlab } from '@gitbeaker/rest' import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { TerminusModule } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { gitlabConfigFactory } from '../../config/gitlab.config' import { DatabaseModule } from '../infrastructure/database/database.module' import { VaultModule } from '../vault/vault.module' import { GITLAB_REST_CLIENT, GitlabClientService } from './gitlab-client.service' @@ -12,14 +13,14 @@ import { GitlabPluginService } from './gitlab-plugin.service' import { GitlabService } from './gitlab.service' @Module({ - imports: [ConfigurationModule, DatabaseModule, TerminusModule, VaultModule], + imports: [DatabaseModule, TerminusModule, VaultModule, ConfigModule.forFeature(gitlabConfigFactory)], providers: [ { provide: GITLAB_REST_CLIENT, - inject: [ConfigurationService], - useFactory: (config: ConfigurationService) => new Gitlab({ - token: config.gitlabToken, - host: config.getInternalOrPublicGitlabUrl(), + inject: [gitlabConfigFactory.KEY], + useFactory: (config: ConfigType) => new Gitlab({ + token: config.token, + host: config.internalUrl ?? config.url, }), }, GitlabClientService, diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts index c069385b97..97004b48c4 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.spec.ts @@ -1,3 +1,4 @@ +import type { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' import { ENABLED } from '@cpn-console/shared' import { faker } from '@faker-js/faker' @@ -5,7 +6,7 @@ import { AccessLevel } from '@gitbeaker/core' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { gitlabConfigFactory } from '../../config/gitlab.config' import { VaultClientService } from '../vault/vault-client.service' import { GitlabClientService } from './gitlab-client.service' import { GitlabDatastoreService } from './gitlab-datastore.service' @@ -33,7 +34,7 @@ describe('gitlabService', () => { readTechnReadOnlyCreds: vi.fn().mockResolvedValue(null), readGitlabMirrorCreds: vi.fn().mockResolvedValue(null), }) - const config = mockDeep({ projectRootDir: 'forge' }) + const config = mockDeep>({ projectRootDir: 'forge' }) const moduleRef = await Test.createTestingModule({ providers: [ @@ -41,7 +42,7 @@ describe('gitlabService', () => { { provide: GitlabClientService, useValue: gitlab }, { provide: GitlabDatastoreService, useValue: datastore }, { provide: VaultClientService, useValue: vault }, - { provide: ConfigurationService, useValue: config }, + { provide: gitlabConfigFactory.KEY, useValue: config }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts index d8fa3c85de..1eeecc6710 100644 --- a/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts +++ b/apps/server-nestjs/src/modules/gitlab/gitlab.service.ts @@ -1,4 +1,5 @@ import type { CondensedGroupSchema, MemberSchema, ProjectSchema } from '@gitbeaker/core' +import type { ConfigType } from '@nestjs/config' import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { VaultSecret } from '../vault/vault-client.service' import type { ProjectWithDetails } from './gitlab-datastore.service' @@ -7,8 +8,8 @@ import { AccessLevel } from '@gitbeaker/core' import { Inject, Injectable, Logger } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' -import { getAll } from '../../utils/iterable' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { gitlabConfigFactory } from '../../config/gitlab.config' +import { getAll } from '../../utils/iterable.utils' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from '../vault/vault-client.service' @@ -51,10 +52,10 @@ export class GitlabService { private readonly logger = new Logger(GitlabService.name) constructor( - @Inject(GitlabDatastoreService) private readonly gitlabDatastore: GitlabDatastoreService, + @Inject(GitlabDatastoreService) private readonly datastore: GitlabDatastoreService, @Inject(GitlabClientService) private readonly gitlab: GitlabClientService, @Inject(VaultClientService) private readonly vault: VaultClientService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(gitlabConfigFactory.KEY) private readonly gitlabConfig: ConfigType, ) { this.logger.log('GitLabService initialized') } @@ -93,7 +94,7 @@ export class GitlabService { const span = trace.getActiveSpan() span?.setAttribute('gitlab.projects.count', 0) this.logger.log('Starting GitLab reconciliation') - const projects = await this.gitlabDatastore.getAllProjects() + const projects = await this.datastore.getAllProjects() span?.setAttribute('gitlab.projects.count', projects.length) this.logger.log(`Loaded ${projects.length} projects for GitLab reconciliation`) await this.ensureProjectGroups(projects) @@ -220,7 +221,7 @@ export class GitlabService { private async getAdminRoleIds(project: ProjectWithDetails): Promise<{ adminRoleId?: string, auditorRoleId?: string }> { const adminGroupPath = await this.getAdminGroupPath(project) const auditorGroupPath = await this.getAuditorGroupPath(project) - const roles = await this.gitlabDatastore.getAdminRolesByOidcGroups([adminGroupPath, auditorGroupPath]) + const roles = await this.datastore.getAdminRolesByOidcGroups([adminGroupPath, auditorGroupPath]) return generateAdminRoleMapping(roles, adminGroupPath, auditorGroupPath) } @@ -233,7 +234,7 @@ export class GitlabService { } private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string): Promise { - const adminPluginConfig = await this.gitlabDatastore.getAdminPluginConfig(PLUGIN_NAME, key) + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) if (adminPluginConfig) return adminPluginConfig if (!project) return undefined return getProjectPluginConfig(project, key) ?? undefined @@ -513,7 +514,7 @@ export class GitlabService { private isMirrorCredsExpiring(vaultSecret: VaultSecret): boolean { if (!vaultSecret?.metadata?.created_time) return false const createdTime = new Date(vaultSecret.metadata.created_time) - return daysAgoFromNow(createdTime) > this.config.gitlabMirrorTokenRotationThresholdDays + return daysAgoFromNow(createdTime) > this.gitlabConfig.mirrorTokenRotationThresholdDays } private getExternalRepoHost(externalRepoUrl: string | null | undefined): string | undefined { diff --git a/apps/server-nestjs/src/modules/infrastructure/auth/auth.module.ts b/apps/server-nestjs/src/modules/infrastructure/auth/auth.module.ts index b0bbbe468e..23f7129d2e 100644 --- a/apps/server-nestjs/src/modules/infrastructure/auth/auth.module.ts +++ b/apps/server-nestjs/src/modules/infrastructure/auth/auth.module.ts @@ -1,10 +1,12 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { keycloakConfigFactory } from '../../../config/keycloak.config' import { AuthService } from './auth.service' import { DsoTokenModule } from './dso-token/dso-token.module' import { KeycloakJwtModule } from './keycloak-jwt/keycloak-jwt.module' @Module({ - imports: [DsoTokenModule, KeycloakJwtModule], + imports: [ConfigModule.forFeature(keycloakConfigFactory), DsoTokenModule, KeycloakJwtModule], providers: [ AuthService, ], diff --git a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.module.ts b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.module.ts index 985a16aa0f..6c7851d92d 100644 --- a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.module.ts +++ b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-jwt/keycloak-jwt.module.ts @@ -1,6 +1,5 @@ import { Module } from '@nestjs/common' import { JwtModule } from '@nestjs/jwt' -import { ConfigurationModule } from '../../configuration/configuration.module' import { DatabaseModule } from '../../database/database.module' import { KeycloakSecretProviderModule } from '../keycloak-secret-provider/keycloak-secret-provider.module' import { KeycloakSecretProviderService } from '../keycloak-secret-provider/keycloak-secret-provider.service' @@ -10,7 +9,7 @@ import { KeycloakJwtService } from './keycloak-jwt.service' imports: [ DatabaseModule, JwtModule.registerAsync({ - imports: [ConfigurationModule, KeycloakSecretProviderModule], + imports: [KeycloakSecretProviderModule], inject: [KeycloakSecretProviderService], useFactory: async (client: KeycloakSecretProviderService) => { // The issuer is fetched from the openid-configuration endpoint diff --git a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.module.ts b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.module.ts index bdc7422816..602419cdcf 100644 --- a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.module.ts +++ b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.module.ts @@ -1,10 +1,14 @@ import { CacheModule } from '@nestjs/cache-manager' import { Module } from '@nestjs/common' -import { ConfigurationModule } from '../../configuration/configuration.module' +import { ConfigModule } from '@nestjs/config' +import { keycloakConfigFactory } from '../../../../config/keycloak.config' import { KeycloakSecretProviderService } from './keycloak-secret-provider.service' @Module({ - imports: [ConfigurationModule, CacheModule.register()], + imports: [ + CacheModule.register(), + ConfigModule.forFeature(keycloakConfigFactory), + ], providers: [KeycloakSecretProviderService], exports: [KeycloakSecretProviderService], }) diff --git a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.spec.ts b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.spec.ts index b55dbdada3..e4084a0d60 100644 --- a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.spec.ts +++ b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.spec.ts @@ -1,3 +1,4 @@ +import type { ConfigType } from '@nestjs/config' import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' import { faker } from '@faker-js/faker' @@ -7,7 +8,7 @@ import { Test } from '@nestjs/testing' import { createCache } from 'cache-manager' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../../configuration/configuration.service' +import { keycloakConfigFactory } from '../../../../config/keycloak.config' import { makeJwksResponse } from './keycloak-secret-provider-testing.utils' import { KeycloakSecretProviderService } from './keycloak-secret-provider.service' import { createKeycloakSecretProviderPublicKeyCacheKey } from './keycloak-secret-provider.utils' @@ -15,22 +16,23 @@ import { createKeycloakSecretProviderPublicKeyCacheKey } from './keycloak-secret describe('keycloakSecretProviderService', () => { let module: TestingModule let service: KeycloakSecretProviderService - let config: DeepMockProxy + let config: DeepMockProxy> let fetchMock: ReturnType let cache: ReturnType beforeEach(async () => { - config = mockDeep({ - keycloakProtocol: 'https', - keycloakDomain: faker.internet.domainName(), - keycloakRealm: faker.lorem.word(), - keycloakJwksTimeoutMs: 1_000, - keycloakJwksCacheTtlMs: 300_000, - keycloakOpenidConfigurationCacheTtlMs: 300_000, - - getKeycloakOpenidConfigurationUrl() { - return `https://${this.keycloakDomain}/realms/${this.keycloakRealm}/.well-known/openid-configuration` - }, + const keycloakDomain = faker.internet.domainName() + const keycloakRealm = faker.lorem.word() + const openidConfigurationUrl = `https://${keycloakDomain}/realms/${keycloakRealm}/.well-known/openid-configuration` + + config = mockDeep>({ + protocol: 'https', + domain: keycloakDomain, + realm: keycloakRealm, + openidConfigurationUrl, + jwksTimeoutMs: 1_000, + jwksCacheTtlMs: 300_000, + openidConfigurationCacheTtlMs: 300_000, }) fetchMock = vi.fn() cache = createCache() @@ -42,7 +44,7 @@ describe('keycloakSecretProviderService', () => { module = await Test.createTestingModule({ providers: [ KeycloakSecretProviderService, - { provide: ConfigurationService, useValue: config }, + { provide: keycloakConfigFactory.KEY, useValue: config }, { provide: CACHE_MANAGER, useValue: cache }, ], }).compile() @@ -55,18 +57,16 @@ describe('keycloakSecretProviderService', () => { }) it('should fetch JWKS from Keycloak and parse the response', async () => { - const issuer = `https://${config.keycloakDomain}/realms/${config.keycloakRealm}` - const publicJwksUri = `https://public.${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs` - const internalJwksUri = `${config.keycloakProtocol}://${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs` + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` + const internalJwksUri = `${config.protocol}://${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) fetchMock.mockResolvedValueOnce(makeJwksResponse('kid-1')) const jwks = await service.fetchSigningKeys() expect(fetchMock).toHaveBeenCalledTimes(2) - expect(fetchMock.mock.calls[0]?.[0]).toBe( - `https://${config.keycloakDomain}/realms/${config.keycloakRealm}/.well-known/openid-configuration`, - ) + expect(fetchMock.mock.calls[0]?.[0]).toBe(config.openidConfigurationUrl) expect(fetchMock.mock.calls[1]?.[0]).toBe(internalJwksUri) expect(jwks).toEqual({ keys: [ @@ -83,8 +83,8 @@ describe('keycloakSecretProviderService', () => { it('should abort and return undefined when the JWKS request exceeds the timeout', async () => { vi.useFakeTimers() - const issuer = `https://${config.keycloakDomain}/realms/${config.keycloakRealm}` - const publicJwksUri = `https://public.${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs` + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) fetchMock.mockImplementationOnce((_url, init?: RequestInit) => new Promise((_, reject) => { init?.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError'))) @@ -97,8 +97,8 @@ describe('keycloakSecretProviderService', () => { }) it('should return undefined when Keycloak returns a non-OK response', async () => { - const issuer = `https://${config.keycloakDomain}/realms/${config.keycloakRealm}` - const publicJwksUri = `https://public.${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs` + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) fetchMock.mockResolvedValueOnce(new Response('', { status: 500, statusText: 'Internal Server Error' })) @@ -106,8 +106,8 @@ describe('keycloakSecretProviderService', () => { }) it('should resolve a PEM public key from the JWKS', async () => { - const issuer = `https://${config.keycloakDomain}/realms/${config.keycloakRealm}` - const publicJwksUri = `https://public.${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs` + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) fetchMock.mockResolvedValueOnce(makeJwksResponse('kid-2')) @@ -131,8 +131,8 @@ describe('keycloakSecretProviderService', () => { }) it('should resolve the secret directly from the JWT token and request type', async () => { - const issuer = `https://${config.keycloakDomain}/realms/${config.keycloakRealm}` - const publicJwksUri = `https://public.${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs` + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) fetchMock.mockResolvedValueOnce(makeJwksResponse('kid-3')) const header = Buffer.from(JSON.stringify({ kid: 'kid-3' })).toString('base64url') @@ -166,8 +166,8 @@ describe('keycloakSecretProviderService', () => { }) it('should reject JWTs when the key cannot be resolved', async () => { - const issuer = `https://${config.keycloakDomain}/realms/${config.keycloakRealm}` - const publicJwksUri = `https://public.${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs` + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ keys: [] }))) const header = Buffer.from(JSON.stringify({ kid: 'missing-kid' })).toString('base64url') @@ -178,8 +178,8 @@ describe('keycloakSecretProviderService', () => { }) it('should resolve the issuer from openid-configuration', async () => { - const issuer = `https://${config.keycloakDomain}/realms/${config.keycloakRealm}` - const publicJwksUri = `https://public.${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs` + const issuer = `https://${config.domain}/realms/${config.realm}` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer, jwks_uri: publicJwksUri }))) await expect(service.fetchIssuer()).resolves.toBe(issuer) @@ -187,20 +187,20 @@ describe('keycloakSecretProviderService', () => { }) it('should replace the discovered JWKS domain with the configured internal Keycloak domain', async () => { - const publicJwksUri = `https://public.${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs` + const publicJwksUri = `https://public.${config.domain}/realms/${config.realm}/protocol/openid-connect/certs` fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ - issuer: `https://${config.keycloakDomain}/realms/${config.keycloakRealm}`, + issuer: `https://${config.domain}/realms/${config.realm}`, jwks_uri: publicJwksUri, }))) await expect(service.fetchJwksUri()).resolves.toBe( - `https://${config.keycloakDomain}/realms/${config.keycloakRealm}/protocol/openid-connect/certs`, + `https://${config.domain}/realms/${config.realm}/protocol/openid-connect/certs`, ) }) it('should keep the discovered JWKS URI unchanged when no internal Keycloak domain is configured', async () => { - config.keycloakDomain = undefined - const publicJwksUri = `https://public.example.test/realms/${config.keycloakRealm}/protocol/openid-connect/certs` + config.domain = undefined + const publicJwksUri = `https://public.example.test/realms/${config.realm}/protocol/openid-connect/certs` fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ issuer: 'https://public.example.test/realms/test', jwks_uri: publicJwksUri, diff --git a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts index f089d4a9a3..ee824bd98d 100644 --- a/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts +++ b/apps/server-nestjs/src/modules/infrastructure/auth/keycloak-secret-provider/keycloak-secret-provider.service.ts @@ -1,10 +1,11 @@ +import type { ConfigType } from '@nestjs/config' import type { Cache } from 'cache-manager' import { createPublicKey } from 'node:crypto' import { CACHE_MANAGER } from '@nestjs/cache-manager' import { Inject, Injectable, Logger } from '@nestjs/common' import { JwtSecretRequestType } from '@nestjs/jwt' import { z } from 'zod' -import { ConfigurationService } from '../../configuration/configuration.service' +import { keycloakConfigFactory } from '../../../../config/keycloak.config' import { createKeycloakSecretProviderOpenIdConfigurationCacheKey, createKeycloakSecretProviderPublicKeyCacheKey } from './keycloak-secret-provider.utils' const OpenidConfigurationSchema = z.object({ @@ -35,16 +36,16 @@ export class KeycloakSecretProviderService { private readonly logger = new Logger(KeycloakSecretProviderService.name) constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, @Inject(CACHE_MANAGER) private readonly cache: Cache, + @Inject(keycloakConfigFactory.KEY) private readonly keycloakConfig: ConfigType, ) {} async fetchOpenIdConfig(): Promise { - const cacheKey = createKeycloakSecretProviderOpenIdConfigurationCacheKey(this.config.getKeycloakOpenidConfigurationUrl()) + const cacheKey = createKeycloakSecretProviderOpenIdConfigurationCacheKey(this.keycloakConfig.openidConfigurationUrl) const cached = await this.cache.get(cacheKey) if (cached) return cached - const response = await fetch(this.config.getKeycloakOpenidConfigurationUrl()) + const response = await fetch(this.keycloakConfig.openidConfigurationUrl) if (!response.ok) { this.logger.error(`Failed to fetch openid-configuration: ${response.status} ${response.statusText}`) return undefined @@ -57,7 +58,7 @@ export class KeycloakSecretProviderService { return undefined } - await this.cache.set(cacheKey, config.data, this.config.keycloakOpenidConfigurationCacheTtlMs) + await this.cache.set(cacheKey, config.data, this.keycloakConfig.openidConfigurationCacheTtlMs) return config.data } @@ -72,14 +73,12 @@ export class KeycloakSecretProviderService { } private replaceJwksUriDomainWithInternalDomain(jwksUri: string): string { - if (!this.config.keycloakDomain) { - this.logger.log(`No internal domain configured, returning original JWKS URI: ${jwksUri}`) - return jwksUri - } const url = new URL(jwksUri) - url.protocol = this.config.keycloakProtocol ?? url.protocol - url.host = this.config.keycloakDomain ?? url.host - this.logger.log(`Replacing JWKS URI domain: ${jwksUri} -> ${url.toString()}`) + if (this.keycloakConfig.domain) { + url.protocol = this.keycloakConfig.protocol + url.host = this.keycloakConfig.domain + this.logger.log(`Replacing JWKS URI domain: ${jwksUri} -> ${url.toString()}`) + } return url.toString() } @@ -88,7 +87,7 @@ export class KeycloakSecretProviderService { if (!jwksUri) return undefined const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), this.config.keycloakJwksTimeoutMs) + const timeout = setTimeout(() => controller.abort(), this.keycloakConfig.jwksTimeoutMs) try { const response = await fetch(jwksUri, { signal: controller.signal }) @@ -124,7 +123,7 @@ export class KeycloakSecretProviderService { }) const pem = publicKey.export({ format: 'pem', type: 'pkcs1' }) as string - await this.cache.set(cacheKey, pem, this.config.keycloakJwksCacheTtlMs) + await this.cache.set(cacheKey, pem, this.keycloakConfig.jwksCacheTtlMs) return pem } diff --git a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.module.ts b/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.module.ts deleted file mode 100644 index b563056136..0000000000 --- a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.module.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Module } from '@nestjs/common' -import { ConfigModule } from '@nestjs/config' - -import { ConfigurationService } from './configuration.service' - -const pathList: string[] = [] - -if (process.env.DOCKER !== 'true') { - pathList.push('.env') -} - -if (process.env.INTEGRATION === 'true') { - pathList.push('.env.integ') -} - -@Module({ - imports: [ - ConfigModule.forRoot({ - envFilePath: pathList, - }), - ], - providers: [ConfigurationService], - exports: [ConfigurationService], -}) -export class ConfigurationModule {} diff --git a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.spec.ts b/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.spec.ts deleted file mode 100644 index a7c4c7a245..0000000000 --- a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.spec.ts +++ /dev/null @@ -1,223 +0,0 @@ -import type { TestingModule } from '@nestjs/testing' -import { Test } from '@nestjs/testing' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -import { ConfigurationService } from './configuration.service' - -describe('configurationService', () => { - let service: ConfigurationService - - beforeEach(() => { - vi.clearAllMocks() - vi.unstubAllEnvs() - - // KEYCLOAK_PUBLIC_PROTOCOL and KEYCLOAK_PUBLIC_DOMAIN are intentionally absent for these tests - vi.stubEnv('KEYCLOAK_PROTOCOL', 'http') - vi.stubEnv('KEYCLOAK_DOMAIN', 'keycloak.example.com') - vi.stubEnv('KEYCLOAK_REALM', 'cloud-pi-native') - }) - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile() - - service = module.get(ConfigurationService) - }) - - afterEach(() => { - vi.unstubAllEnvs() - vi.restoreAllMocks() - }) - - it('should be defined', () => { - expect(service).toBeDefined() - }) - - describe('keycloak URL derivation', () => { - it('should derive the internal URL from protocol + domain', () => { - expect(service.getKeycloakUrl()).toBe('http://keycloak.example.com') - }) - - it('should derive the realm URL from the internal URL', () => { - expect(service.getKeycloakRealmUrl()).toBe( - 'http://keycloak.example.com/realms/cloud-pi-native', - ) - }) - - it('should derive the openid-configuration URL from the realm URL', () => { - expect(service.getKeycloakOpenidConfigurationUrl()).toBe( - 'http://keycloak.example.com/realms/cloud-pi-native/.well-known/openid-configuration', - ) - }) - - it('should throw when Keycloak protocol or domain is missing', () => { - service.keycloakProtocol = '' - service.keycloakDomain = 'keycloak.example.com' - expect(() => service.getKeycloakUrl()).toThrow( - 'Keycloak protocol or domain is not configured.', - ) - expect(() => service.getKeycloakRealmUrl()).toThrow( - 'Keycloak protocol or domain is not configured.', - ) - - service.keycloakProtocol = 'http' - service.keycloakDomain = '' - expect(() => service.getKeycloakUrl()).toThrow( - 'Keycloak protocol or domain is not configured.', - ) - expect(() => service.getKeycloakRealmUrl()).toThrow( - 'Keycloak protocol or domain is not configured.', - ) - }) - }) - - describe('internal-or-public URL helpers', () => { - it('should prefer internal over public URL for GitLab, Vault, Harbor, Nexus, SonarQube', async () => { - vi.stubEnv('GITLAB_URL', 'https://gitlab.public') - vi.stubEnv('VAULT_URL', 'https://vault.public') - vi.stubEnv('HARBOR_URL', 'https://harbor.public') - vi.stubEnv('NEXUS_URL', 'https://nexus.public') - vi.stubEnv('SONARQUBE_URL', 'https://sonar.public') - vi.stubEnv('GITLAB_INTERNAL_URL', 'https://gitlab.internal') - vi.stubEnv('VAULT_INTERNAL_URL', 'https://vault.internal') - vi.stubEnv('HARBOR_INTERNAL_URL', 'https://harbor.internal') - vi.stubEnv('NEXUS_INTERNAL_URL', 'https://nexus.internal') - vi.stubEnv('SONARQUBE_INTERNAL_URL', 'https://sonar.internal') - - const testService = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - - expect(testService.getInternalOrPublicGitlabUrl()).toBe('https://gitlab.internal') - expect(testService.getInternalOrPublicVaultUrl()).toBe('https://vault.internal') - expect(testService.getInternalOrPublicHarborUrl()).toBe('https://harbor.internal') - expect(testService.getInternalOrPublicNexusUrl()).toBe('https://nexus.internal') - expect(testService.getInternalOrPublicSonarqubeUrl()).toBe('https://sonar.internal') - }) - - it('should fall back to public URL for GitLab, Vault, Harbor, Nexus, SonarQube when internal is unset', async () => { - vi.stubEnv('GITLAB_URL', 'https://gitlab.public') - vi.stubEnv('GITLAB_INTERNAL_URL', '') - vi.stubEnv('VAULT_URL', 'https://vault.public') - vi.stubEnv('VAULT_INTERNAL_URL', '') - vi.stubEnv('HARBOR_URL', 'https://harbor.public') - vi.stubEnv('HARBOR_INTERNAL_URL', '') - vi.stubEnv('NEXUS_URL', 'https://nexus.public') - vi.stubEnv('NEXUS_INTERNAL_URL', '') - vi.stubEnv('SONARQUBE_URL', 'https://sonar.public') - vi.stubEnv('SONARQUBE_INTERNAL_URL', '') - - const testService = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - - expect(testService.getInternalOrPublicGitlabUrl()).toBe('https://gitlab.public') - expect(testService.getInternalOrPublicVaultUrl()).toBe('https://vault.public') - expect(testService.getInternalOrPublicHarborUrl()).toBe('https://harbor.public') - expect(testService.getInternalOrPublicNexusUrl()).toBe('https://nexus.public') - expect(testService.getInternalOrPublicSonarqubeUrl()).toBe('https://sonar.public') - }) - - it('should return undefined for internal-or-public URL when neither side is configured', async () => { - vi.stubEnv('GITLAB_URL', '') - vi.stubEnv('GITLAB_INTERNAL_URL', '') - vi.stubEnv('VAULT_URL', '') - vi.stubEnv('VAULT_INTERNAL_URL', '') - vi.stubEnv('HARBOR_URL', '') - vi.stubEnv('HARBOR_INTERNAL_URL', '') - vi.stubEnv('NEXUS_URL', '') - vi.stubEnv('NEXUS_INTERNAL_URL', '') - vi.stubEnv('SONARQUBE_URL', '') - vi.stubEnv('SONARQUBE_INTERNAL_URL', '') - - const testService = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - - expect(testService.getInternalOrPublicGitlabUrl()).toBeUndefined() - expect(testService.getInternalOrPublicVaultUrl()).toBeUndefined() - expect(testService.getInternalOrPublicHarborUrl()).toBeUndefined() - expect(testService.getInternalOrPublicNexusUrl()).toBeUndefined() - expect(testService.getInternalOrPublicSonarqubeUrl()).toBeUndefined() - }) - }) - - describe('conditional toggles and computed fields', () => { - it('should default NODE_ENV to production and map explicit test/development values', async () => { - vi.stubEnv('NODE_ENV', '') - const testService = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(testService.NODE_ENV).toBe('production') - }) - - it('should map NODE_ENV=test to "test" and NODE_ENV=development to "development"', async () => { - vi.stubEnv('NODE_ENV', 'test') - const testService = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(testService.NODE_ENV).toBe('test') - - vi.stubEnv('NODE_ENV', 'development') - const devService = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(devService.NODE_ENV).toBe('development') - }) - - it('should expose the requested app version in production, else "dev"', async () => { - vi.stubEnv('NODE_ENV', 'production') - vi.stubEnv('APP_VERSION', '1.2.3') - const prod = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(prod.appVersion).toBe('1.2.3') - - vi.unstubAllEnvs() - vi.stubEnv('NODE_ENV', 'production') - const prodUnset = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(prodUnset.appVersion).toBe('unknown') - - vi.unstubAllEnvs() - vi.stubEnv('NODE_ENV', 'development') - vi.stubEnv('APP_VERSION', '1.2.3') - const dev = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(dev.appVersion).toBe('dev') - }) - - it('should expose nexusSecretExposedUrl based on the internal-url toggle', async () => { - vi.stubEnv('NEXUS_URL', 'https://nexus.public') - const off = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(off.nexusSecretExposedUrl).toBe('https://nexus.public') - - vi.stubEnv('NEXUS__SECRET_EXPOSE_INTERNAL_URL', 'true') - vi.stubEnv('NEXUS_INTERNAL_URL', 'https://nexus.internal') - const on = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(on.nexusSecretExposedUrl).toBe('https://nexus.internal') - }) - - it('should disable TLS verification for Open CDS only when explicitly set to false', async () => { - vi.stubEnv('OPENCDS_API_TLS_REJECT_UNAUTHORIZED', '') - - const defaultService = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(defaultService.openCdsApiTlsRejectUnauthorized).toBe(true) - - vi.stubEnv('OPENCDS_API_TLS_REJECT_UNAUTHORIZED', 'false') - const disabled = await Test.createTestingModule({ - providers: [ConfigurationService], - }).compile().then(m => m.get(ConfigurationService)) - expect(disabled.openCdsApiTlsRejectUnauthorized).toBe(false) - }) - }) -}) diff --git a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts b/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts deleted file mode 100644 index 67d925fc11..0000000000 --- a/apps/server-nestjs/src/modules/infrastructure/configuration/configuration.service.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { Injectable, Logger } from '@nestjs/common' - -@Injectable() -export class ConfigurationService { - private readonly logger = new Logger(ConfigurationService.name) - - // application mode - isDev = process.env.NODE_ENV === 'development' - isTest = process.env.NODE_ENV === 'test' - isProd = process.env.NODE_ENV === 'production' - isInt = process.env.INTEGRATION === 'true' - isCI = process.env.CI === 'true' - isDevSetup = process.env.DEV_SETUP === 'true' - - // app - host = process.env.SERVER_HOST ?? 'localhost' - port = process.env.SERVER_PORT ? Number(process.env.SERVER_PORT) : 0 // dynamically allocate an available ephemeral port - appVersion = this.isProd ? (process.env.APP_VERSION ?? 'unknown') : 'dev' - - // db - dbUrl = process.env.DB_URL - - // keycloak - sessionSecret = process.env.SESSION_SECRET - keycloakProtocol = process.env.KEYCLOAK_PROTOCOL - keycloakDomain = process.env.KEYCLOAK_DOMAIN - keycloakPublicProtocol = process.env.KEYCLOAK_PUBLIC_PROTOCOL - keycloakPublicDomain = process.env.KEYCLOAK_PUBLIC_DOMAIN - keycloakRealm = process.env.KEYCLOAK_REALM - keycloakClientId = process.env.KEYCLOAK_CLIENT_ID - keycloakClientSecret = process.env.KEYCLOAK_CLIENT_SECRET - keycloakAdmin = process.env.KEYCLOAK_ADMIN - keycloakAdminPassword = process.env.KEYCLOAK_ADMIN_PASSWORD - keycloakAdminClientId = process.env.KEYCLOAK_ADMIN_CLIENT_ID ?? 'admin-cli' - keycloakRedirectUri = process.env.KEYCLOAK_REDIRECT_URI - - // JWKS cache TTL in ms (default 5 min); Keycloak rotates keys periodically - keycloakJwksCacheTtlMs = Number(process.env.KEYCLOAK_JWKS_CACHE_TTL_MS ?? 300_000) - // JWKS fetch timeout in ms (default 5 s); avoids hanging on cache misses - keycloakJwksTimeoutMs = Number(process.env.KEYCLOAK_JWKS_TIMEOUT_MS ?? 5_000) - // openid-configuration cache TTL in ms (default 5 min); avoid repeated discovery lookups - keycloakOpenidConfigurationCacheTtlMs = Number(process.env.KEYCLOAK_OPENID_CONFIGURATION_CACHE_TTL_MS ?? 300_000) - - adminsUserId = process.env.ADMIN_KC_USER_ID - ? process.env.ADMIN_KC_USER_ID.split(',') - : [] - - contactEmail - = process.env.CONTACT_EMAIL - ?? 'cloudpinative-relations@interieur.gouv.fr' - - // argocd - argoNamespace = process.env.ARGO_NAMESPACE ?? 'argocd' - argocdUrl = process.env.ARGOCD_URL - argocdInternalUrl = process.env.ARGOCD_INTERNAL_URL - argocdExtraRepositories = process.env.ARGOCD_EXTRA_REPOSITORIES - - // dso - dsoEnvChartVersion = process.env.DSO_ENV_CHART_VERSION ?? 'dso-env-1.6.0' - dsoNsChartVersion = process.env.DSO_NS_CHART_VERSION ?? 'dso-ns-1.1.5' - - // opencds - openCdsUrl = process.env.OPENCDS_URL - openCdsApiToken = process.env.OPENCDS_API_TOKEN - openCdsApiTlsRejectUnauthorized = process.env.OPENCDS_API_TLS_REJECT_UNAUTHORIZED !== 'false' - - // plugins - mockPlugins = process.env.MOCK_PLUGINS === 'true' - projectRootDir = process.env.PROJECTS_ROOT_DIR - pluginsDir = process.env.PLUGINS_DIR ?? '/plugins' - - // gitlab - gitlabToken = process.env.GITLAB_TOKEN - gitlabUrl = process.env.GITLAB_URL - gitlabInternalUrl = process.env.GITLAB_INTERNAL_URL - - gitlabMirrorTokenExpirationDays = Number(process.env.GITLAB_MIRROR_TOKEN_EXPIRATION_DAYS ?? 180) - gitlabMirrorTokenRotationThresholdDays = Number(process.env.GITLAB_MIRROR_TOKEN_ROTATION_THRESHOLD_DAYS ?? 90) - - // vault - vaultToken = process.env.VAULT_TOKEN - vaultUrl = process.env.VAULT_URL - vaultInternalUrl = process.env.VAULT_INTERNAL_URL - - vaultKvName = process.env.VAULT_KV_NAME ?? 'forge-dso' - deployVaultConnectionInNamespaces = process.env.VAULT__DEPLOY_VAULT_CONNECTION_IN_NS === 'true' - - // registry (harbor) - harborUrl = process.env.HARBOR_URL - harborInternalUrl = process.env.HARBOR_INTERNAL_URL - harborAdmin = process.env.HARBOR_ADMIN - harborAdminPassword = process.env.HARBOR_ADMIN_PASSWORD - harborRuleTemplate = process.env.HARBOR_RULE_TEMPLATE - harborRuleCount = process.env.HARBOR_RULE_COUNT - harborRetentionCron = process.env.HARBOR_RETENTION_CRON ?? '0 22 2 * * *' - harborRobotRotationThresholdDays = Number(process.env.HARBOR_ROBOT_ROTATION_THRESHOLD_DAYS ?? 90) - harborProjectSlugCacheTtlMs = Number(process.env.HARBOR_PROJECT_SLUG_CACHE_TTL_MS ?? 300_000) - - // nexus - nexusUrl = process.env.NEXUS_URL - nexusInternalUrl = process.env.NEXUS_INTERNAL_URL - nexusAdmin = process.env.NEXUS_ADMIN - nexusAdminPassword = process.env.NEXUS_ADMIN_PASSWORD - nexusSecretExposedUrl - = process.env.NEXUS__SECRET_EXPOSE_INTERNAL_URL === 'true' - ? process.env.NEXUS_INTERNAL_URL - : process.env.NEXUS_URL - - // sonarqube - sonarqubeUrl = process.env.SONARQUBE_URL - sonarqubeInternalUrl = process.env.SONARQUBE_INTERNAL_URL - sonarApiToken = process.env.SONAR_API_TOKEN - - getKeycloakRealmUrl() { - return `${this.getKeycloakUrl()}/realms/${this.keycloakRealm}` - } - - getKeycloakOpenidConfigurationUrl() { - const url = `${this.getKeycloakRealmUrl()}/.well-known/openid-configuration` - this.logger.log(`Keycloak openid-configuration URL resolved: ${url}`) - return url - } - - getKeycloakUrl() { - if (!this.keycloakProtocol || !this.keycloakDomain) { - throw new Error(`Keycloak protocol or domain is not configured.`) - } - const url = `${this.keycloakProtocol}://${this.keycloakDomain}` - this.logger.log(`Keycloak internal URL resolved: ${url}`) - return url - } - - getInternalOrPublicArgoCDUrl() { - const url = this.argocdInternalUrl ?? this.argocdUrl - this.logger.log(`ArgoCD URL resolved: ${url} (${this.argocdInternalUrl ? 'internal' : 'public'})`) - return url - } - - getInternalOrPublicGitlabUrl() { - return this.getInternalOrPublicUrl('GitLab', this.gitlabUrl, this.gitlabInternalUrl) - } - - getInternalOrPublicVaultUrl() { - return this.getInternalOrPublicUrl('Vault', this.vaultUrl, this.vaultInternalUrl) - } - - getInternalOrPublicHarborUrl() { - return this.getInternalOrPublicUrl('Harbor', this.harborUrl, this.harborInternalUrl) - } - - getInternalOrPublicNexusUrl() { - return this.getInternalOrPublicUrl('Nexus', this.nexusUrl, this.nexusInternalUrl) - } - - getInternalOrPublicSonarqubeUrl() { - return this.getInternalOrPublicUrl('SonarQube', this.sonarqubeUrl, this.sonarqubeInternalUrl) - } - - getInternalOrPublicUrl(name: string, publicUrl: string | undefined, internalUrl: string | undefined): string | undefined { - const trimedInternalUrl = internalUrl?.trim() - const trimmedPublicUrl = publicUrl?.trim() - const url = trimedInternalUrl || trimmedPublicUrl || undefined - let label = 'none' - if (trimedInternalUrl) { - label = 'internal' - } else if (trimmedPublicUrl) { - label = 'public' - } - this.logger.log(`${name} URL resolved: ${url ?? 'none'} (${label})`) - return url - } - - NODE_ENV - = process.env.NODE_ENV === 'test' - ? 'test' - : process.env.NODE_ENV === 'development' - ? 'development' - : 'production' -} diff --git a/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts b/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts index 9919a78be6..ce8ad381bd 100644 --- a/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts +++ b/apps/server-nestjs/src/modules/infrastructure/database/database.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { TerminusModule } from '@nestjs/terminus' -import { ConfigurationModule } from '../configuration/configuration.module' +import { baseConfigFactory } from '../../../config/base.config' import { DatabaseHealthService } from './database-health.service' import { DatabaseService } from './database.service' import { PrismaService } from './prisma.service' @Module({ - imports: [ConfigurationModule, TerminusModule], + imports: [TerminusModule, ConfigModule.forFeature(baseConfigFactory)], providers: [DatabaseHealthService, DatabaseService, PrismaService], exports: [DatabaseHealthService, DatabaseService, PrismaService], }) diff --git a/apps/server-nestjs/src/modules/infrastructure/database/database.service.spec.ts b/apps/server-nestjs/src/modules/infrastructure/database/database.service.spec.ts index 416c82980d..676d44aa7c 100644 --- a/apps/server-nestjs/src/modules/infrastructure/database/database.service.spec.ts +++ b/apps/server-nestjs/src/modules/infrastructure/database/database.service.spec.ts @@ -1,8 +1,9 @@ +import type { ConfigType } from '@nestjs/config' import type { TestingModule } from '@nestjs/testing' import { Test } from '@nestjs/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -import { ConfigurationModule } from '../configuration/configuration.module' +import { beforeEach, describe, expect, it } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { baseConfigFactory } from '../../../config/base.config' import { DatabaseService } from './database.service' import { PrismaService } from './prisma.service' @@ -10,17 +11,13 @@ describe('databaseService', () => { let service: DatabaseService beforeEach(async () => { + const baseConfig = mockDeep>() + const module: TestingModule = await Test.createTestingModule({ - imports: [ConfigurationModule], providers: [ DatabaseService, - { - provide: PrismaService, - useValue: { - $connect: vi.fn().mockResolvedValue(undefined), - $disconnect: vi.fn().mockResolvedValue(undefined), - }, - }, + { provide: PrismaService, useValue: mockDeep() }, + { provide: baseConfigFactory.KEY, useValue: baseConfig }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/infrastructure/database/database.service.ts b/apps/server-nestjs/src/modules/infrastructure/database/database.service.ts index 695a2daf5a..fd90463567 100644 --- a/apps/server-nestjs/src/modules/infrastructure/database/database.service.ts +++ b/apps/server-nestjs/src/modules/infrastructure/database/database.service.ts @@ -1,6 +1,7 @@ +import type { ConfigType } from '@nestjs/config' import { setTimeout } from 'node:timers/promises' import { Inject, Injectable, Logger } from '@nestjs/common' -import { ConfigurationService } from '../configuration/configuration.service' +import { baseConfigFactory } from '../../../config/base.config' import { PrismaService } from './prisma.service' @Injectable() @@ -9,10 +10,10 @@ export class DatabaseService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, - @Inject(ConfigurationService) private readonly configurationService: ConfigurationService, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, ) { this.DELAY_BEFORE_RETRY - = this.configurationService.isTest || this.configurationService.isCI + = this.baseConfig.isTest || this.baseConfig.isCI ? 1000 : 10000 } @@ -28,12 +29,12 @@ export class DatabaseService { try { if ( - this.configurationService.isDev - || this.configurationService.isTest - || this.configurationService.isCI + this.baseConfig.isDev + || this.baseConfig.isTest + || this.baseConfig.isCI ) { this.loggerService.log( - `Trying to connect to Postgres with: ${this.configurationService.dbUrl}`, + `Trying to connect to Postgres with: ${this.baseConfig.dbUrl}`, ) } await this.prisma.$connect() diff --git a/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts b/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts index b280084626..8eb62bea67 100644 --- a/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts +++ b/apps/server-nestjs/src/modules/infrastructure/events/events.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { EventEmitterModule } from '@nestjs/event-emitter' +import { baseConfigFactory } from '../../../config/base.config' @Module({ - imports: [EventEmitterModule.forRoot()], + imports: [EventEmitterModule.forRoot(), ConfigModule.forFeature(baseConfigFactory)], exports: [EventEmitterModule], }) export class EventsModule {} diff --git a/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts b/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts index e29dae241f..25b2a4e695 100644 --- a/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts +++ b/apps/server-nestjs/src/modules/infrastructure/infrastructure.module.ts @@ -1,6 +1,5 @@ import { Module } from '@nestjs/common' import { AuthModule } from './auth/auth.module' -import { ConfigurationModule } from './configuration/configuration.module' import { DatabaseModule } from './database/database.module' import { EventsModule } from './events/events.module' import { LoggerModule } from './logger/logger.module' @@ -8,7 +7,7 @@ import { PermissionModule } from './permission/permission.module' @Module({ providers: [], - imports: [AuthModule, DatabaseModule, ConfigurationModule, EventsModule, LoggerModule, PermissionModule], - exports: [AuthModule, DatabaseModule, ConfigurationModule, EventsModule, LoggerModule, PermissionModule], + imports: [AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + exports: [AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], }) export class InfrastructureModule {} diff --git a/apps/server-nestjs/src/modules/infrastructure/logger/logger.module.ts b/apps/server-nestjs/src/modules/infrastructure/logger/logger.module.ts index 9da4f77584..dc86b8bffc 100644 --- a/apps/server-nestjs/src/modules/infrastructure/logger/logger.module.ts +++ b/apps/server-nestjs/src/modules/infrastructure/logger/logger.module.ts @@ -1,19 +1,18 @@ +import type { ConfigType } from '@nestjs/config' import { getLoggerOptions } from '@cpn-console/logger' import { Module } from '@nestjs/common' import { LoggerModule as PinoLoggerModule } from 'nestjs-pino' - -import { ConfigurationModule } from '../configuration/configuration.module' -import { ConfigurationService } from '../configuration/configuration.service' +import { baseConfigFactory } from '../../../config/base.config' @Module({ imports: [ PinoLoggerModule.forRootAsync({ - imports: [ConfigurationModule], - inject: [ConfigurationService], - useFactory: async (configService: ConfigurationService) => { + imports: [], + inject: [baseConfigFactory.KEY], + useFactory: async (baseConfig: ConfigType) => { return { pinoHttp: { - ...getLoggerOptions(configService.isProd ? 'production' : 'development', configService.isTest ? 'info' : 'debug'), + ...getLoggerOptions(baseConfig.isProd ? 'production' : 'development', baseConfig.isTest ? 'info' : 'debug'), customLogLevel: (req, res, err) => { if (err || res.statusCode >= 500) { return 'error' diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts index 633ec731a3..61c0aab6f4 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.spec.ts @@ -1,3 +1,4 @@ +import type { ConfigType } from '@nestjs/config' import type { TestingModule } from '@nestjs/testing' import KcAdminClient from '@keycloak/keycloak-admin-client' import { ScheduleModule } from '@nestjs/schedule' @@ -6,7 +7,7 @@ import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { keycloakConfigFactory } from '../../config/keycloak.config' import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from './keycloak-client.service' import { ADMIN_TOKEN_REFRESH_INTERVAL_MS } from './keycloak.constants' @@ -37,19 +38,19 @@ function useTokenEndpoint({ rejectGrant = () => false }: { rejectGrant?: (grantT return tokenRequests } -function createKeycloakClientServiceTestingModule(config: Partial = {}) { +function createKeycloakClientServiceTestingModule(config: Partial> = {}) { return Test.createTestingModule({ imports: [ScheduleModule.forRoot()], providers: [ KeycloakClientService, { provide: KEYCLOAK_ADMIN_CLIENT, useValue: new KcAdminClient({ baseUrl: keycloakUrl }) }, { - provide: ConfigurationService, - useValue: mockDeep({ - keycloakRealm: projectRealm, - keycloakAdmin: 'admin', - keycloakAdminPassword: 'admin-password', - keycloakAdminClientId: 'admin-cli', + provide: keycloakConfigFactory.KEY, + useValue: mockDeep>({ + realm: projectRealm, + admin: 'admin', + adminPassword: 'admin-password', + adminClientId: 'admin-cli', ...config, }), }, @@ -159,28 +160,6 @@ describe('keycloakClientService authentication lifecycle', () => { await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS * 2) expect(tokenRequests).toHaveLength(1) }) - - it('should not authenticate nor refresh the token when the Keycloak realm is not configured', async () => { - const tokenRequests = useTokenEndpoint() - await module.close() - module = await createKeycloakClientServiceTestingModule({ keycloakRealm: undefined }).compile() - - await expect(module.init()).rejects.toThrow() - - await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS * 2) - expect(tokenRequests).toHaveLength(0) - }) - - it('should not authenticate nor refresh the token when the admin credentials are not configured', async () => { - const tokenRequests = useTokenEndpoint() - await module.close() - module = await createKeycloakClientServiceTestingModule({ keycloakAdminPassword: undefined }).compile() - - await expect(module.init()).rejects.toThrow() - - await vi.advanceTimersByTimeAsync(ADMIN_TOKEN_REFRESH_INTERVAL_MS * 2) - expect(tokenRequests).toHaveLength(0) - }) }) describe('getOrCreateSubGroupByName', () => { diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts index cbe7c3e2dc..762c18f2f1 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-client.service.ts @@ -3,14 +3,15 @@ import type GroupRepresentation from '@keycloak/keycloak-admin-client/lib/defs/g import type UserRepresentation from '@keycloak/keycloak-admin-client/lib/defs/userRepresentation' import type { Credentials } from '@keycloak/keycloak-admin-client/lib/utils/auth' import type { OnModuleInit } from '@nestjs/common' +import type { ConfigType } from '@nestjs/config' import type { ProjectWithDetails } from './keycloak-datastore.service' import type { GroupRepresentationWith } from './keycloak.utils' import { Inject, Injectable, Logger } from '@nestjs/common' import { Interval } from '@nestjs/schedule' import { trace } from '@opentelemetry/api' import z from 'zod' -import { getErrorResponseStatus } from '../../utils/http-error' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { keycloakConfigFactory } from '../../config/keycloak.config' +import { getErrorResponseStatus } from '../../utils/http.utils' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { ADMIN_AUTH_REALM, ADMIN_TOKEN_REFRESH_INTERVAL_MS, CONSOLE_GROUP_NAME, PASSWORD_GRANT_TYPE, REFRESH_TOKEN_GRANT_TYPE, SUBGROUPS_PAGINATE_QUERY_MAX } from './keycloak.constants' @@ -23,7 +24,7 @@ export class KeycloakClientService implements OnModuleInit { private authenticated = false constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(keycloakConfigFactory.KEY) private readonly keycloakConfig: ConfigType, @Inject(KEYCLOAK_ADMIN_CLIENT) private readonly client: KcAdminClient, ) { } @@ -274,17 +275,8 @@ export class KeycloakClientService implements OnModuleInit { } async onModuleInit() { - if (!this.config.keycloakRealm) { - throw new Error('Keycloak realm is not configured') - } - if (!this.config.keycloakAdmin || !this.config.keycloakAdminPassword) { - throw new Error('Keycloak admin username or password is not configured') - } - if (!this.config.keycloakAdminClientId) { - throw new Error('Keycloak admin client id is not configured') - } try { - this.logger.log(`Authenticating Keycloak admin client (realm=${this.config.keycloakRealm})`) + this.logger.log(`Authenticating Keycloak admin client (realm=${this.keycloakConfig.realm})`) await this.authenticate(this.passwordCredentials()) } catch (err) { if (err instanceof Error) { @@ -294,9 +286,9 @@ export class KeycloakClientService implements OnModuleInit { } throw err } - this.client.setConfig({ realmName: this.config.keycloakRealm }) + this.client.setConfig({ realmName: this.keycloakConfig.realm }) this.authenticated = true - this.logger.log(`Keycloak Admin Client authenticated (realm=${this.config.keycloakRealm})`) + this.logger.log(`Keycloak Admin Client authenticated (realm=${this.keycloakConfig.realm})`) } // The admin client never refreshes its token on its own; without this the @@ -318,27 +310,18 @@ export class KeycloakClientService implements OnModuleInit { } } - // Checked by onModuleInit before any authentication; the getter narrows the - // config value so Credentials.clientId stays a plain string - private get adminClientId(): string { - if (!this.config.keycloakAdminClientId) { - throw new Error('KEYCLOAK_ADMIN_CLIENT_ID is not configured') - } - return this.config.keycloakAdminClientId - } - private passwordCredentials(): Credentials { return { - clientId: this.adminClientId, + clientId: this.keycloakConfig.adminClientId, grantType: PASSWORD_GRANT_TYPE, - username: this.config.keycloakAdmin, - password: this.config.keycloakAdminPassword, + username: this.keycloakConfig.admin, + password: this.keycloakConfig.adminPassword, } } private refreshTokenCredentials(): Credentials { return { - clientId: this.adminClientId, + clientId: this.keycloakConfig.adminClientId, grantType: REFRESH_TOKEN_GRANT_TYPE, refreshToken: this.client.refreshToken, } diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts index f769a99ef1..d45fabf570 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak-health.service.ts @@ -1,24 +1,22 @@ +import type { ConfigType } from '@nestjs/config' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { keycloakConfigFactory } from '../../config/keycloak.config' import { PLUGIN_NAME } from './keycloak.constants' @Injectable() export class KeycloakHealthService { constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(keycloakConfigFactory.KEY) + private readonly keycloakConfig: ConfigType, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} async check() { const indicator = this.healthIndicator.check(PLUGIN_NAME) - const url = this.config.getKeycloakOpenidConfigurationUrl() - if (!url) return indicator.down('Not configured') - try { - const response = await fetch(url) + const response = await fetch(this.keycloakConfig.openidConfigurationUrl) if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) return indicator.down({ httpStatus: response.status }) } catch (error) { diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts index 9ae808fc65..76953fcd0e 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.module.ts @@ -1,8 +1,9 @@ +import type { ConfigType } from '@nestjs/config' import KcAdminClient from '@keycloak/keycloak-admin-client' import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { TerminusModule } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { keycloakConfigFactory } from '../../config/keycloak.config' import { DatabaseModule } from '../infrastructure/database/database.module' import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from './keycloak-client.service' import { KeycloakDatastoreService } from './keycloak-datastore.service' @@ -11,13 +12,13 @@ import { KeycloakPluginService } from './keycloak-plugin.service' import { KeycloakService } from './keycloak.service' @Module({ - imports: [ConfigurationModule, DatabaseModule, TerminusModule], + imports: [ConfigModule.forFeature(keycloakConfigFactory), DatabaseModule, TerminusModule], providers: [ { - inject: [ConfigurationService], + inject: [keycloakConfigFactory.KEY], provide: KEYCLOAK_ADMIN_CLIENT, - useFactory: (config: ConfigurationService) => new KcAdminClient({ - baseUrl: config.getKeycloakUrl(), + useFactory: (config: ConfigType) => new KcAdminClient({ + baseUrl: config.url, }), }, KeycloakClientService, diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts index 4d8d6e726a..e5b116688a 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.service.spec.ts @@ -19,7 +19,7 @@ import { KeycloakService } from './keycloak.service' describe('keycloakService', () => { let service: KeycloakService let keycloak: DeepMockProxy - let keycloakDatastore: DeepMockProxy + let datastore: DeepMockProxy beforeEach(async () => { keycloak = mockDeep({ @@ -31,7 +31,7 @@ describe('keycloakService', () => { getAllGroups: vi.fn().mockImplementation(async function* () { /* empty by default */ }), deleteGroup: vi.fn().mockResolvedValue(undefined), }) - keycloakDatastore = mockDeep({ + datastore = mockDeep({ getAllAdminRoles: vi.fn().mockResolvedValue([]), getAllUsersWithAdminRoleIds: vi.fn().mockResolvedValue([]), }) @@ -40,7 +40,7 @@ describe('keycloakService', () => { providers: [ KeycloakService, { provide: KeycloakClientService, useValue: keycloak }, - { provide: KeycloakDatastoreService, useValue: keycloakDatastore }, + { provide: KeycloakDatastoreService, useValue: datastore }, ], }).compile() @@ -66,9 +66,9 @@ describe('keycloakService', () => { const users: UserWithAdminRoles[] = [ { id: 'user-1', adminRoleIds: ['admin-role-id'] }, ] - keycloakDatastore.getAllProjects.mockResolvedValue([]) - keycloakDatastore.getAllAdminRoles.mockResolvedValue(adminRoles) - keycloakDatastore.getAllUsersWithAdminRoleIds.mockResolvedValue(users) + datastore.getAllProjects.mockResolvedValue([]) + datastore.getAllAdminRoles.mockResolvedValue(adminRoles) + datastore.getAllUsersWithAdminRoleIds.mockResolvedValue(users) await service.handleCron() @@ -86,9 +86,9 @@ describe('keycloakService', () => { { id: 'user-1', adminRoleIds: ['admin-role-id'] }, { id: 'user-2', adminRoleIds: [] }, ] - keycloakDatastore.getAllProjects.mockResolvedValue([]) - keycloakDatastore.getAllAdminRoles.mockResolvedValue(adminRoles) - keycloakDatastore.getAllUsersWithAdminRoleIds.mockResolvedValue(users) + datastore.getAllProjects.mockResolvedValue([]) + datastore.getAllAdminRoles.mockResolvedValue(adminRoles) + datastore.getAllUsersWithAdminRoleIds.mockResolvedValue(users) const adminGroup = makeGroupRepresentation({ id: 'kc-group-id', name: 'admin', path: '/console/admin' }) keycloak.getOrCreateGroupByPath.mockResolvedValue(adminGroup) @@ -105,7 +105,7 @@ describe('keycloakService', () => { }) it('should purge orphans', async () => { - keycloakDatastore.getAllProjects.mockResolvedValue([mockProject]) + datastore.getAllProjects.mockResolvedValue([mockProject]) const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project', subGroups: [] }) const orphanGroup = makeGroupRepresentation({ @@ -124,7 +124,7 @@ describe('keycloakService', () => { keycloak.getSubGroups.mockImplementation(async function* () { /* empty */ }) await service.handleCron() - expect(keycloakDatastore.getAllProjects).toHaveBeenCalled() + expect(datastore.getAllProjects).toHaveBeenCalled() expect(keycloak.getAllGroups).toHaveBeenCalled() expect(keycloak.getOrCreateGroupByPath).toHaveBeenCalledWith('/test-project') expect(keycloak.deleteGroup).toHaveBeenCalledWith('orphan-id') @@ -140,7 +140,7 @@ describe('keycloakService', () => { }), ], }) - keycloakDatastore.getAllProjects.mockResolvedValue([projectWithMembers]) + datastore.getAllProjects.mockResolvedValue([projectWithMembers]) const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) keycloak.getOrCreateGroupByPath.mockResolvedValue(projectGroup) @@ -180,7 +180,7 @@ describe('keycloakService', () => { ], roles: [roleWithOidc], }) - keycloakDatastore.getAllProjects.mockResolvedValue([projectWithRole]) + datastore.getAllProjects.mockResolvedValue([projectWithRole]) const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) const consoleGroup = { id: 'console-id', name: 'console' } @@ -218,7 +218,7 @@ describe('keycloakService', () => { ...mockProject, environments: [makeProjectEnvironment({ id: 'env-1', name: 'dev' })], }) - keycloakDatastore.getAllProjects.mockResolvedValue([projectWithEnv]) + datastore.getAllProjects.mockResolvedValue([projectWithEnv]) const projectGroup = makeGroupRepresentation({ id: 'group-id', @@ -290,7 +290,7 @@ describe('keycloakService', () => { ], environments: [makeProjectEnvironment({ id: 'env-1', name: 'dev' })], }) - keycloakDatastore.getAllProjects.mockResolvedValue([projectWithEnvAndMembers]) + datastore.getAllProjects.mockResolvedValue([projectWithEnvAndMembers]) const projectGroup = makeGroupRepresentation({ id: 'group-id', @@ -349,7 +349,7 @@ describe('keycloakService', () => { ], roles: [roleManaged, roleExternal, roleGlobal], }) - keycloakDatastore.getAllProjects.mockResolvedValue([projectWithRoles]) + datastore.getAllProjects.mockResolvedValue([projectWithRoles]) const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) const consoleGroup = { id: 'console-id', name: 'console' } @@ -416,7 +416,7 @@ describe('keycloakService', () => { ], roles: [roleSystemManaged], }) - keycloakDatastore.getAllProjects.mockResolvedValue([projectWithRoles]) + datastore.getAllProjects.mockResolvedValue([projectWithRoles]) const projectGroup = makeGroupRepresentation({ id: 'group-id', name: 'test-project' }) const consoleGroup = { id: 'console-id', name: 'console' } diff --git a/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts b/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts index f6a3f6c0d5..f737e77bcd 100644 --- a/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts +++ b/apps/server-nestjs/src/modules/keycloak/keycloak.service.ts @@ -7,7 +7,7 @@ import { Inject, Injectable, Logger } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' import z from 'zod' -import { getErrorResponseStatus } from '../../utils/http-error' +import { getErrorResponseStatus } from '../../utils/http.utils' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { KeycloakClientService } from './keycloak-client.service' @@ -20,7 +20,7 @@ export class KeycloakService { constructor( @Inject(KeycloakClientService) private readonly keycloak: KeycloakClientService, - @Inject(KeycloakDatastoreService) private readonly keycloakDatastore: KeycloakDatastoreService, + @Inject(KeycloakDatastoreService) private readonly datastore: KeycloakDatastoreService, ) { this.logger.log('KeycloakService initialized') } @@ -59,9 +59,9 @@ export class KeycloakService { const span = trace.getActiveSpan() this.logger.log('Starting periodic Keycloak reconciliation') const [projects, adminRoles, users] = await Promise.all([ - this.keycloakDatastore.getAllProjects(), - this.keycloakDatastore.getAllAdminRoles(), - this.keycloakDatastore.getAllUsersWithAdminRoleIds(), + this.datastore.getAllProjects(), + this.datastore.getAllAdminRoles(), + this.datastore.getAllUsersWithAdminRoleIds(), ]) span?.setAttributes({ 'keycloak.projects.count': projects.length, diff --git a/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts b/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts index bd7d4f85bd..a47f2bc7a5 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus-client.service.spec.ts @@ -1,3 +1,4 @@ +import type { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' import { faker } from '@faker-js/faker' import { HttpStatus } from '@nestjs/common' @@ -6,7 +7,7 @@ import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { nexusConfigFactory } from '../../config/nexus.config' import { NexusClientService } from './nexus-client.service' import { NexusHttpClientService } from './nexus-http-client.service' @@ -18,18 +19,15 @@ const basicAuth = `Basic ${Buffer.from(`admin:${nexusAdminPassword}`, 'utf8').to describe('nexusClientService', () => { let service: NexusClientService - let config: DeepMockProxy + let config: DeepMockProxy> beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) beforeEach(async () => { - config = mockDeep({ - nexusSecretExposedUrl: 'https://nexus.example', - nexusInternalUrl: nexusUrl, - nexusAdmin: 'admin', - nexusAdminPassword, - projectRootDir: 'forge', - getInternalOrPublicNexusUrl: () => nexusUrl, + config = mockDeep>({ + internalUrl: nexusUrl, + admin: 'admin', + adminPassword: nexusAdminPassword, }) const module = await Test.createTestingModule({ @@ -37,7 +35,7 @@ describe('nexusClientService', () => { NexusClientService, NexusHttpClientService, { - provide: ConfigurationService, + provide: nexusConfigFactory.KEY, useValue: config, }, ], diff --git a/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts b/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts index 70362e7b53..cc2a25f983 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus-health.service.ts @@ -1,28 +1,24 @@ +import type { ConfigType } from '@nestjs/config' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { nexusConfigFactory } from '../../config/nexus.config' import { PLUGIN_NAME } from './nexus.constants' @Injectable() export class NexusHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(nexusConfigFactory.KEY) private readonly nexusConfig: ConfigType, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} async check() { const indicator = this.healthIndicator.check(PLUGIN_NAME) - if (!this.config.nexusInternalUrl) return indicator.down('Not configured') - - const url = new URL('/service/rest/v1/status', this.config.nexusInternalUrl).toString() const headers: Record = {} - if (this.config.nexusAdmin && this.config.nexusAdminPassword) { - const credentials = `${this.config.nexusAdmin}:${this.config.nexusAdminPassword}` - const encoded = Buffer.from(credentials).toString('base64') - headers.Authorization = `Basic ${encoded}` - } - + const credentials = `${this.nexusConfig.admin}:${this.nexusConfig.adminPassword}` + const encoded = Buffer.from(credentials).toString('base64') + headers.Authorization = `Basic ${encoded}` try { + const url = new URL('/service/rest/v1/status', this.nexusConfig.internalUrl ?? this.nexusConfig.url).toString() const response = await fetch(url, { headers }) if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) return indicator.down({ httpStatus: response.status }) diff --git a/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts b/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts index e89cc70aa4..5de3d37d25 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus-http-client.service.ts @@ -1,6 +1,7 @@ +import type { ConfigType } from '@nestjs/config' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { trace } from '@opentelemetry/api' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { nexusConfigFactory } from '../../config/nexus.config' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' export interface NexusFetchOptions { @@ -44,7 +45,7 @@ export class NexusError extends Error { @Injectable() export class NexusHttpClientService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(nexusConfigFactory.KEY) private readonly nexusConfig: ConfigType, ) {} @StartActiveSpan() @@ -54,7 +55,7 @@ export class NexusHttpClientService { span?.setAttribute('nexus.method', method) span?.setAttribute('nexus.path', path) - const request = this.createRequest(path, method, options.body, options.headers) + const request = this.createRequest(path, method, options) const response = await fetch(request).catch((error) => { throw new NexusError( 'Unexpected', @@ -75,42 +76,28 @@ export class NexusHttpClientService { return result } - private get baseUrl() { - const url = this.config.getInternalOrPublicNexusUrl() - if (!url) { - throw new NexusError('NotConfigured', 'NEXUS_INTERNAL_URL or NEXUS_URL is required') - } - return url - } - private get apiBaseUrl() { - return new URL('service/rest/v1/', this.baseUrl).toString() + return new URL('service/rest/v1/', this.nexusConfig.internalUrl ?? this.nexusConfig.url).toString() } private get basicAuth() { - if (!this.config.nexusAdmin) { - throw new NexusError('NotConfigured', 'NEXUS_ADMIN is required') - } - if (!this.config.nexusAdminPassword) { - throw new NexusError('NotConfigured', 'NEXUS_ADMIN_PASSWORD is required') - } - const raw = `${this.config.nexusAdmin}:${this.config.nexusAdminPassword}` + const raw = `${this.nexusConfig.admin}:${this.nexusConfig.adminPassword}` return Buffer.from(raw, 'utf8').toString('base64') } - private createRequest(path: string, method: string, body?: unknown, extraHeaders?: Record): Request { + private createRequest(path: string, method: string, options?: NexusFetchOptions): Request { const url = new URL(path, this.apiBaseUrl).toString() const headers: Record = { Authorization: `Basic ${this.basicAuth}`, - ...extraHeaders, + ...options?.headers, } let requestBody: string | undefined - if (body !== undefined) { - if (typeof body === 'string') { - requestBody = body + if (options?.body !== undefined) { + if (typeof options.body === 'string') { + requestBody = options.body headers['Content-Type'] = 'text/plain' } else { - requestBody = JSON.stringify(body) + requestBody = JSON.stringify(options.body) headers['Content-Type'] = 'application/json' } } diff --git a/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.spec.ts b/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.spec.ts index 43dc717c40..4c653def71 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.spec.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.spec.ts @@ -1,25 +1,26 @@ +import type { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { nexusConfigFactory } from '../../config/nexus.config' import { makeToUrlParams } from '../plugin/plugin.utils' import { NexusPluginService } from './nexus-plugin.service' describe('nexusPluginService', () => { let service: NexusPluginService - let config: DeepMockProxy + let config: DeepMockProxy> beforeEach(async () => { - config = mockDeep({ - nexusUrl: 'https://nexus.public/', - nexusInternalUrl: 'https://nexus.internal/', + config = mockDeep>({ + url: 'https://nexus.public/', + internalUrl: 'https://nexus.internal/', }) const moduleRef = await Test.createTestingModule({ providers: [ NexusPluginService, - { provide: ConfigurationService, useValue: config }, + { provide: nexusConfigFactory.KEY, useValue: config }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts b/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts index c6ccab09bb..87372785ad 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus-plugin.service.ts @@ -1,22 +1,19 @@ import type { ServiceInfos } from '@cpn-console/hooks' +import type { ConfigType } from '@nestjs/config' import { DISABLED, ENABLED } from '@cpn-console/shared' import { Inject, Injectable } from '@nestjs/common' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { nexusConfigFactory } from '../../config/nexus.config' @Injectable() export class NexusPluginService { constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(nexusConfigFactory.KEY) private readonly nexusConfig: ConfigType, ) {} infos(): ServiceInfos { return { name: 'nexus', - to: () => { - if (!this.config.nexusUrl) return undefined - return this.config.nexusUrl - }, + to: () => this.nexusConfig.url, title: 'Nexus', imgSrc: '/img/nexus.png', description: 'Nexus permet de gérer les binaires et artefacts de build à travers la chaîne logistique logicielle', diff --git a/apps/server-nestjs/src/modules/nexus/nexus.module.ts b/apps/server-nestjs/src/modules/nexus/nexus.module.ts index 14d706232d..1b02a8c9a8 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.module.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { TerminusModule } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' +import { baseConfigFactory } from '../../config/base.config' +import { nexusConfigFactory } from '../../config/nexus.config' import { DatabaseModule } from '../infrastructure/database/database.module' import { VaultModule } from '../vault/vault.module' import { NexusClientService } from './nexus-client.service' @@ -11,7 +13,7 @@ import { NexusPluginService } from './nexus-plugin.service' import { NexusService } from './nexus.service' @Module({ - imports: [ConfigurationModule, DatabaseModule, TerminusModule, VaultModule], + imports: [DatabaseModule, TerminusModule, VaultModule, ConfigModule.forFeature(nexusConfigFactory), ConfigModule.forFeature(baseConfigFactory)], providers: [ NexusHealthService, NexusPluginService, diff --git a/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts b/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts index 41674dc730..c3fd960a95 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.service.spec.ts @@ -1,9 +1,12 @@ +import type { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' import { DISABLED, ENABLED } from '@cpn-console/shared' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { baseConfigFactory } from '../../config/base.config' +import { nexusConfigFactory } from '../../config/nexus.config' +import { vaultConfigFactory } from '../../config/vault.config' import { VaultClientService } from '../vault/vault-client.service' import { VaultError } from '../vault/vault-http-client.service' import { makeVaultSecret } from '../vault/vault-testing.utils' @@ -26,7 +29,9 @@ describe('nexusService', () => { let client: DeepMockProxy let datastore: DeepMockProxy let vault: DeepMockProxy - let config: DeepMockProxy + let config: DeepMockProxy> + let baseConfig: DeepMockProxy> + let vaultConfig: DeepMockProxy> beforeEach(async () => { client = mockDeep({ @@ -45,7 +50,9 @@ describe('nexusService', () => { vault = mockDeep({ read: vi.fn().mockRejectedValue(new VaultError('NotFound', 'Not Found')), }) - config = mockDeep({ projectRootDir: 'forge' }) + config = mockDeep>({}) + baseConfig = mockDeep>({ projectsRootDir: 'forge' }) + vaultConfig = mockDeep>({}) const module = await Test.createTestingModule({ providers: [ @@ -53,7 +60,9 @@ describe('nexusService', () => { { provide: NexusClientService, useValue: client }, { provide: NexusDatastoreService, useValue: datastore }, { provide: VaultClientService, useValue: vault }, - { provide: ConfigurationService, useValue: config }, + { provide: nexusConfigFactory.KEY, useValue: config }, + { provide: baseConfigFactory.KEY, useValue: baseConfig }, + { provide: vaultConfigFactory.KEY, useValue: vaultConfig }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/nexus/nexus.service.ts b/apps/server-nestjs/src/modules/nexus/nexus.service.ts index 6b78c7ad25..caa89b855f 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.service.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.service.ts @@ -1,3 +1,4 @@ +import type { ConfigType } from '@nestjs/config' import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { NexusPrivilege } from './nexus-client.service' import type { ProjectWithDetails } from './nexus-datastore.service' @@ -8,7 +9,8 @@ import { specificallyEnabled } from '@cpn-console/hooks' import { Inject, Injectable, Logger } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { baseConfigFactory } from '../../config/base.config' +import { nexusConfigFactory } from '../../config/nexus.config' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from '../vault/vault-client.service' @@ -52,10 +54,11 @@ export class NexusService { private readonly logger = new Logger(NexusService.name) constructor( - @Inject(NexusDatastoreService) private readonly nexusDatastore: NexusDatastoreService, + @Inject(NexusDatastoreService) private readonly datastore: NexusDatastoreService, @Inject(NexusClientService) private readonly client: NexusClientService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, @Inject(VaultClientService) private readonly vault: VaultClientService, + @Inject(nexusConfigFactory.KEY) private readonly nexusConfig: ConfigType, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, ) { this.logger.log('NexusService initialized') } @@ -71,7 +74,7 @@ export class NexusService { span?.setAttribute('project.slug', project.slug) this.logger.log(`Handling project upsert for ${project.slug}`) await this.ensureProject(project) - const projects = await this.nexusDatastore.getAllProjects() + const projects = await this.datastore.getAllProjects() await this.ensurePlatformRoles(projects) } @@ -86,7 +89,7 @@ export class NexusService { span?.setAttribute('project.slug', project.slug) this.logger.log(`Handling project delete for ${project.slug}`) await this.deleteProject(project) - const projects = await this.nexusDatastore.getAllProjects() + const projects = await this.datastore.getAllProjects() await this.ensurePlatformRoles(projects) } @@ -95,7 +98,7 @@ export class NexusService { async handleCron() { const span = trace.getActiveSpan() this.logger.log('Starting Nexus reconciliation') - const projects = await this.nexusDatastore.getAllProjects() + const projects = await this.datastore.getAllProjects() span?.setAttribute('nexus.projects.count', projects.length) await this.ensureProjects(projects) await this.ensurePlatformRoles(projects) @@ -429,7 +432,7 @@ export class NexusService { } private async ensureUser(project: ProjectWithDetails) { - const vaultPath = getProjectVaultPath(this.config.projectRootDir, project.slug, 'tech/NEXUS') + const vaultPath = getProjectVaultPath(this.baseConfig.projectsRootDir, project.slug, 'tech/NEXUS') let existingPassword: string | undefined try { existingPassword = await this.vault.read(vaultPath).then(res => res.data?.NEXUS_PASSWORD) @@ -487,7 +490,7 @@ export class NexusService { private async getOptionalConfigValue(project: ProjectWithDetails, key: string) { const projectValue = getPluginConfig(project, key) if (projectValue) return projectValue - return await this.nexusDatastore.getAdminPluginConfig(PLUGIN_NAME, key) + return await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) } private async ensureProjectGroupRoles(project: ProjectWithDetails, args: { readOnlyPrivileges: string[], writePrivileges: string[] }) { @@ -511,10 +514,10 @@ export class NexusService { } private async ensurePlatformRoles(projects: ProjectWithDetails[]) { - const rawWriteGroupPaths = await this.nexusDatastore.getAdminPluginConfig(PLUGIN_NAME, PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) + const rawWriteGroupPaths = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, PLATFORM_WRITE_GROUP_PATHS_PLUGIN_KEY) ?? DEFAULT_PLATFORM_WRITE_GROUP_PATHS - const rawReadGroupPaths = await this.nexusDatastore.getAdminPluginConfig(PLUGIN_NAME, PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) + const rawReadGroupPaths = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, PLATFORM_READ_GROUP_PATHS_PLUGIN_KEY) ?? DEFAULT_PLATFORM_READ_GROUP_PATHS const readonlyPrivileges = new Set() @@ -574,7 +577,7 @@ export class NexusService { this.client.deleteSecurityUsers(project.slug), ]) - const vaultPath = getProjectVaultPath(this.config.projectRootDir, project.slug, 'tech/NEXUS') + const vaultPath = getProjectVaultPath(this.baseConfig.projectsRootDir, project.slug, 'tech/NEXUS') try { await this.vault.delete(vaultPath) } catch (error) { diff --git a/apps/server-nestjs/src/modules/nexus/nexus.utils.ts b/apps/server-nestjs/src/modules/nexus/nexus.utils.ts index 1bd4450861..6e9e0e7645 100644 --- a/apps/server-nestjs/src/modules/nexus/nexus.utils.ts +++ b/apps/server-nestjs/src/modules/nexus/nexus.utils.ts @@ -10,11 +10,9 @@ export function generateRandomPassword(length: number) { return raw.slice(0, length) } -export function getProjectVaultPath(projectRootDir: string | undefined, projectSlug: string, relativePath: string) { +export function getProjectVaultPath(projectRootDir: string, projectSlug: string, relativePath: string) { const normalized = relativePath.startsWith('/') ? relativePath.slice(1) : relativePath - return projectRootDir - ? `${projectRootDir}/${projectSlug}/${normalized}` - : `${projectSlug}/${normalized}` + return `${projectRootDir}/${projectSlug}/${normalized}` } export type MavenHostedRepoKind = 'release' | 'snapshot' diff --git a/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts b/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts index 9d92a86af7..658e110dae 100644 --- a/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts +++ b/apps/server-nestjs/src/modules/plugin/plugin.module.spec.ts @@ -13,6 +13,20 @@ describe('pluginModule', () => { vi.stubEnv('USE_HARBOR', 'true') vi.stubEnv('USE_NEXUS', 'true') vi.stubEnv('USE_SONARQUBE', 'true') + vi.stubEnv('VAULT_TOKEN', 'test-token') + vi.stubEnv('VAULT_URL', 'https://vault.test/') + vi.stubEnv('GITLAB_TOKEN', 'test-token') + vi.stubEnv('GITLAB_URL', 'https://gitlab.test/') + vi.stubEnv('NEXUS_URL', 'https://nexus.test/') + vi.stubEnv('NEXUS_ADMIN', 'admin') + vi.stubEnv('NEXUS_ADMIN_PASSWORD', 'pw') + vi.stubEnv('HARBOR_URL', 'https://harbor.test/') + vi.stubEnv('HARBOR_ADMIN', 'admin') + vi.stubEnv('HARBOR_ADMIN_PASSWORD', 'pw') + vi.stubEnv('SONARQUBE_URL', 'https://sonar.test/') + vi.stubEnv('SONAR_API_TOKEN', 'token') + vi.stubEnv('PROJECTS_ROOT_DIR', '/') + vi.stubEnv('ARGOCD_URL', 'https://argocd.test/') const module = await Test.createTestingModule({ imports: [ ConfigModule.forRoot(), diff --git a/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.ts b/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.ts index 1d3e2ef1f0..bb388185dd 100644 --- a/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.ts +++ b/apps/server-nestjs/src/modules/project-secrets/project-secrets.module.ts @@ -1,7 +1,6 @@ import { Module } from '@nestjs/common' import { ConditionalModule } from '@nestjs/config' import { AuthModule } from '../infrastructure/auth/auth.module' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' import { DatabaseModule } from '../infrastructure/database/database.module' import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' import { VaultModule } from '../vault/vault.module' @@ -9,7 +8,7 @@ import { ProjectSecretsController } from './project-secrets.controller' import { ProjectSecretsService } from './project-secrets.service' @Module({ - imports: [AuthModule, ConfigurationModule, DatabaseModule, ProjectPermissionModule, ConditionalModule.registerWhen(VaultModule, 'USE_VAULT')], + imports: [AuthModule, DatabaseModule, ProjectPermissionModule, ConditionalModule.registerWhen(VaultModule, 'USE_VAULT')], controllers: [ProjectSecretsController], providers: [ProjectSecretsService], exports: [ProjectSecretsService], diff --git a/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.spec.ts b/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.spec.ts index 40ea3beb9e..a8fb8dff59 100644 --- a/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.spec.ts +++ b/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.spec.ts @@ -1,9 +1,11 @@ +import type { ConfigType } from '@nestjs/config' import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' import { PrismaService } from '../infrastructure/database/prisma.service' import { makeProject } from '../project/project-testing.utils' import { VaultClientService } from '../vault/vault-client.service' @@ -17,19 +19,22 @@ describe('projectSecretsService', () => { let prisma: DeepMockProxy let vault: DeepMockProxy let vaultClient: DeepMockProxy - let config: DeepMockProxy + let config: DeepMockProxy> + let vaultConfig: DeepMockProxy> beforeEach(async () => { prisma = mockDeep() vault = mockDeep() vaultClient = mockDeep() - config = mockDeep({ projectRootDir: '/vault' }) + config = mockDeep>({ projectsRootDir: '/vault' }) + vaultConfig = mockDeep>() module = await Test.createTestingModule({ providers: [ ProjectSecretsService, { provide: PrismaService, useValue: prisma }, - { provide: ConfigurationService, useValue: config }, + { provide: baseConfigFactory.KEY, useValue: config }, + { provide: vaultConfigFactory.KEY, useValue: vaultConfig }, { provide: VaultService, useValue: vault }, { provide: VaultClientService, useValue: vaultClient }, ], @@ -104,7 +109,9 @@ describe('projectSecretsService', () => { providers: [ ProjectSecretsService, { provide: PrismaService, useValue: mockDeep() }, - { provide: ConfigurationService, useValue: mockDeep({ projectRootDir: '/vault' }) }, + { provide: baseConfigFactory.KEY, useValue: mockDeep({ projectsRootDir: '/vault' }) }, + { provide: VaultService, useValue: undefined }, + { provide: VaultClientService, useValue: undefined }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts b/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts index f23d184695..36d0672cad 100644 --- a/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts +++ b/apps/server-nestjs/src/modules/project-secrets/project-secrets.service.ts @@ -1,7 +1,8 @@ +import type { ConfigType } from '@nestjs/config' import { Inject, Injectable, Logger, NotFoundException, Optional } from '@nestjs/common' import { trace } from '@opentelemetry/api' import { z } from 'zod' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { baseConfigFactory } from '../../config/base.config' import { PrismaService } from '../infrastructure/database/prisma.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { VaultClientService } from '../vault/vault-client.service' @@ -28,7 +29,7 @@ export class ProjectSecretsService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, @Inject(VaultService) @Optional() private readonly vault?: VaultService, @Inject(VaultClientService) @Optional() private readonly vaultClient?: VaultClientService, ) {} @@ -85,7 +86,7 @@ export class ProjectSecretsService { slug: string, relativePaths: string[], ): Promise>> { - const projectPath = generateProjectPath(this.config.projectRootDir, slug) + const projectPath = generateProjectPath(this.baseConfig.projectsRootDir, slug) const result: Record> = {} this.logger.debug(`project.get aggregating (projectId=${projectId}, slug=${slug}, secretFiles=${relativePaths.length})`) diff --git a/apps/server-nestjs/src/modules/project/project.module.ts b/apps/server-nestjs/src/modules/project/project.module.ts index 19e7f0b594..6e6235641d 100644 --- a/apps/server-nestjs/src/modules/project/project.module.ts +++ b/apps/server-nestjs/src/modules/project/project.module.ts @@ -1,7 +1,8 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' +import { baseConfigFactory } from '../../config/base.config' import { AppEventsModule } from '../events/app-events.module' import { AuthModule } from '../infrastructure/auth/auth.module' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' import { DatabaseModule } from '../infrastructure/database/database.module' import { EventsModule } from '../infrastructure/events/events.module' import { ProjectPermissionModule } from '../infrastructure/permission/project/project.module' @@ -14,12 +15,12 @@ import { ProjectService } from './project.service' imports: [ AppEventsModule, AuthModule, - ConfigurationModule, DatabaseModule, EventsModule, ProjectPermissionModule, UserPermissionModule, LogModule, + ConfigModule.forFeature(baseConfigFactory), ], controllers: [ProjectController], providers: [ProjectService], diff --git a/apps/server-nestjs/src/modules/project/project.service.spec.ts b/apps/server-nestjs/src/modules/project/project.service.spec.ts index 221ba01abd..e07ae9b900 100644 --- a/apps/server-nestjs/src/modules/project/project.service.spec.ts +++ b/apps/server-nestjs/src/modules/project/project.service.spec.ts @@ -1,3 +1,4 @@ +import type { ConfigType } from '@nestjs/config' import type { TestingModule } from '@nestjs/testing' import type { Prisma } from '@prisma/client' import type { DeepMockProxy } from 'vitest-mock-extended' @@ -11,8 +12,9 @@ import { import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' import { AppEventsService } from '../events/app-events.service' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { PrismaService } from '../infrastructure/database/prisma.service' import { LogService } from '../log/log.service' import { @@ -33,13 +35,15 @@ describe('projectService', () => { let service: ProjectService let prisma: DeepMockProxy let appEvents: DeepMockProxy - let config: DeepMockProxy + let config: DeepMockProxy> + let vaultConfig: DeepMockProxy> let logs: DeepMockProxy beforeEach(async () => { prisma = mockDeep() appEvents = mockDeep() - config = mockDeep({ appVersion: 'dev' }) + config = mockDeep>({ appVersion: 'dev' }) + vaultConfig = mockDeep>() logs = mockDeep() module = await Test.createTestingModule({ @@ -47,7 +51,8 @@ describe('projectService', () => { ProjectService, { provide: PrismaService, useValue: prisma }, { provide: AppEventsService, useValue: appEvents }, - { provide: ConfigurationService, useValue: config }, + { provide: baseConfigFactory.KEY, useValue: config }, + { provide: vaultConfigFactory.KEY, useValue: vaultConfig }, { provide: LogService, useValue: logs }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/project/project.service.ts b/apps/server-nestjs/src/modules/project/project.service.ts index 533be11806..cbb6a8cf1c 100644 --- a/apps/server-nestjs/src/modules/project/project.service.ts +++ b/apps/server-nestjs/src/modules/project/project.service.ts @@ -1,12 +1,13 @@ import type { projectContract } from '@cpn-console/shared' +import type { ConfigType } from '@nestjs/config' import type { Prisma } from '@prisma/client' import type { UserContext } from '../infrastructure/auth/auth-user.decorator' import type { ProjectDataExport, ProjectUpdateContext, ProjectWithDetails } from './project-queries.utils' import { AdminAuthorized } from '@cpn-console/shared' import { BadRequestException, ForbiddenException, Inject, Injectable, InternalServerErrorException, Logger, NotFoundException } from '@nestjs/common' import { trace } from '@opentelemetry/api' +import { baseConfigFactory } from '../../config/base.config' import { AppEventsService } from '../events/app-events.service' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' import { PrismaService } from '../infrastructure/database/prisma.service' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { LogService } from '../log/log.service' @@ -30,7 +31,7 @@ export class ProjectService { constructor( @Inject(PrismaService) private readonly prisma: PrismaService, @Inject(AppEventsService) private readonly appEvents: AppEventsService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, @Inject(LogService) private readonly logs: LogService, ) {} @@ -62,7 +63,7 @@ export class ProjectService { const whereAnd = generateProjectWhereInput({ query, requestorUserId: user.userId, - appVersion: this.config.appVersion, + appVersion: this.baseConfig.appVersion, }) this.logger.debug(`project.list started (requestorUserId=${user.userId}, filter=${filter})`) diff --git a/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts b/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts index f6f8881411..980c5335d5 100644 --- a/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/registry/registry-client.service.spec.ts @@ -1,3 +1,4 @@ +import type { ConfigType } from '@nestjs/config' import { faker } from '@faker-js/faker' import { HttpStatus } from '@nestjs/common' import { Test } from '@nestjs/testing' @@ -5,7 +6,7 @@ import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { harborConfigFactory } from '../../config/harbor.config' import { VaultClientService } from '../vault/vault-client.service' import { RegistryClientService } from './registry-client.service' import { RegistryHttpClientService } from './registry-http-client.service' @@ -22,15 +23,14 @@ describe('registryService', () => { beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) beforeEach(async () => { - const config = mockDeep({ - harborUrl, - harborInternalUrl: harborUrl, - harborAdmin: 'admin', - harborAdminPassword, - harborRuleTemplate: 'latestPushedK', - harborRuleCount: '10', - harborRetentionCron: '0 22 2 * * *', - projectRootDir: 'forge', + const harborConfig = mockDeep>({ + url: harborUrl, + internalUrl: harborUrl, + admin: 'admin', + adminPassword: harborAdminPassword, + ruleTemplate: 'latestPushedK', + ruleCount: 10, + retentionCron: '0 22 2 * * *', }) const module = await Test.createTestingModule({ @@ -42,8 +42,8 @@ describe('registryService', () => { useValue: {}, }, { - provide: ConfigurationService, - useValue: config, + provide: harborConfigFactory.KEY, + useValue: harborConfig, }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/registry/registry-health.service.ts b/apps/server-nestjs/src/modules/registry/registry-health.service.ts index 35b4284605..ef29547a6f 100644 --- a/apps/server-nestjs/src/modules/registry/registry-health.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry-health.service.ts @@ -1,28 +1,24 @@ +import type { HarborConfig } from '../../config/harbor.config' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { harborConfigFactory } from '../../config/harbor.config' import { PLUGIN_NAME } from './registry.constants' @Injectable() export class RegistryHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(harborConfigFactory.KEY) private readonly harborConfig: HarborConfig, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} async check() { const indicator = this.healthIndicator.check(PLUGIN_NAME) - if (!this.config.harborInternalUrl) return indicator.down('Not configured') - - const url = new URL('/api/v2.0/ping', this.config.harborInternalUrl).toString() const headers: Record = {} - if (this.config.harborAdmin && this.config.harborAdminPassword) { - const credentials = `${this.config.harborAdmin}:${this.config.harborAdminPassword}` - const base64 = Buffer.from(credentials).toString('base64') - headers.Authorization = `Basic ${base64}` - } - + const credentials = `${this.harborConfig.admin}:${this.harborConfig.adminPassword}` + const base64 = Buffer.from(credentials).toString('base64') + headers.Authorization = `Basic ${base64}` try { + const url = new URL('/api/v2.0/ping', this.harborConfig.internalUrl ?? this.harborConfig.url).toString() const response = await fetch(url, { method: 'GET', headers }) if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) return indicator.down({ httpStatus: response.status }) diff --git a/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts b/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts index cfe57239a2..c50a7de3f5 100644 --- a/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry-http-client.service.ts @@ -1,9 +1,10 @@ +import type { ConfigType } from '@nestjs/config' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { trace } from '@opentelemetry/api' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { harborConfigFactory } from '../../config/harbor.config' import { encodeBasicAuth } from './registry.utils' -export type RegistryQuery = Record +export type RegistryQuery = Record export interface RegistryFetchOptions { method?: string @@ -46,28 +47,15 @@ export class RegistryError extends Error { @Injectable() export class RegistryHttpClientService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(harborConfigFactory.KEY) private readonly harborConfig: ConfigType, ) {} - private get baseUrl() { - if (!this.config.harborInternalUrl) { - throw new RegistryError('NotConfigured', 'HARBOR_INTERNAL_URL is required') - } - return this.config.harborInternalUrl - } - private get apiBaseUrl() { - return new URL('api/v2.0/', this.baseUrl).toString() + return new URL('api/v2.0/', this.harborConfig.internalUrl ?? this.harborConfig.url).toString() } private get defaultHeaders() { - if (!this.config.harborAdmin) { - throw new RegistryError('NotConfigured', 'HARBOR_ADMIN is required') - } - if (!this.config.harborAdminPassword) { - throw new RegistryError('NotConfigured', 'HARBOR_ADMIN_PASSWORD is required') - } - return { Accept: 'application/json', Authorization: `Basic ${encodeBasicAuth(this.config.harborAdmin, this.config.harborAdminPassword)}` } + return { Accept: 'application/json', Authorization: `Basic ${encodeBasicAuth(this.harborConfig.admin, this.harborConfig.adminPassword)}` } } async fetch( @@ -79,7 +67,7 @@ export class RegistryHttpClientService { span?.setAttribute('registry.method', method) span?.setAttribute('registry.path', path) - const request = this.createRequest(path, method, options.body, options.headers, options.query) + const request = this.createRequest(path, method, options) const response = await fetch(request).catch((error) => { throw new RegistryError( 'Unexpected', @@ -91,20 +79,20 @@ export class RegistryHttpClientService { return await handleResponse(response) } - private createRequest(path: string, method: string, body?: unknown, extraHeaders?: Record, query?: RegistryQuery): Request { + private createRequest(path: string, method: string, options: RegistryFetchOptions): Request { const url = new URL(path, this.apiBaseUrl) - if (query) { - for (const [key, value] of Object.entries(query)) { - if (value !== undefined) url.searchParams.set(key, String(value)) + if (options.query) { + for (const [key, value] of Object.entries(options.query)) { + url.searchParams.set(key, String(value)) } } const headers: Record = { ...this.defaultHeaders, - ...extraHeaders, + ...options.headers, } let requestBody: string | undefined - if (body !== undefined) { - requestBody = JSON.stringify(body) + if (options.body !== undefined) { + requestBody = JSON.stringify(options.body) headers['Content-Type'] = 'application/json' } return new Request(url, { method, headers, body: requestBody }) diff --git a/apps/server-nestjs/src/modules/registry/registry-plugin.service.spec.ts b/apps/server-nestjs/src/modules/registry/registry-plugin.service.spec.ts index 3e14300fa2..13aedb5614 100644 --- a/apps/server-nestjs/src/modules/registry/registry-plugin.service.spec.ts +++ b/apps/server-nestjs/src/modules/registry/registry-plugin.service.spec.ts @@ -1,3 +1,4 @@ +import type { ConfigType } from '@nestjs/config' import type { Cache } from 'cache-manager' import type { DeepMockProxy } from 'vitest-mock-extended' import { CACHE_MANAGER } from '@nestjs/cache-manager' @@ -5,7 +6,7 @@ import { HttpStatus } from '@nestjs/common' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { harborConfigFactory } from '../../config/harbor.config' import { makeToUrlParams } from '../plugin/plugin.utils' import { RegistryClientService } from './registry-client.service' import { RegistryDatastoreService } from './registry-datastore.service' @@ -14,25 +15,25 @@ import { makeProjectWithDetails } from './registry-testing.utils' describe('registryPluginService', () => { let service: RegistryPluginService - let config: DeepMockProxy - let registryDatastore: DeepMockProxy + let harborConfig: DeepMockProxy> + let datastore: DeepMockProxy let registryClient: DeepMockProxy let cache: DeepMockProxy beforeEach(async () => { - config = mockDeep({ - harborUrl: 'https://harbor.example/', - harborProjectSlugCacheTtlMs: 300000, + harborConfig = mockDeep>({ + url: 'https://harbor.example/', + projectSlugCacheTtlMs: 300000, }) - registryDatastore = mockDeep() + datastore = mockDeep() registryClient = mockDeep() cache = mockDeep() const moduleRef = await Test.createTestingModule({ providers: [ RegistryPluginService, - { provide: ConfigurationService, useValue: config }, - { provide: RegistryDatastoreService, useValue: registryDatastore }, + { provide: harborConfigFactory.KEY, useValue: harborConfig }, + { provide: RegistryDatastoreService, useValue: datastore }, { provide: RegistryClientService, useValue: registryClient }, { provide: CACHE_MANAGER, useValue: cache }, ], @@ -54,13 +55,13 @@ describe('registryPluginService', () => { })) expect(url).toBe('https://harbor.example/harbor/projects/144/') - expect(registryDatastore.getProject).not.toHaveBeenCalled() + expect(datastore.getProject).not.toHaveBeenCalled() expect(registryClient.getProjectByName).toHaveBeenCalledWith('dulei') }) it('falls back to Harbor lookup when the store is empty', async () => { cache.get.mockResolvedValue(undefined) - registryDatastore.getProject.mockResolvedValue(makeProjectWithDetails({ slug: 'dulei' })) + datastore.getProject.mockResolvedValue(makeProjectWithDetails({ slug: 'dulei' })) registryClient.getProjectByName.mockResolvedValue({ status: HttpStatus.OK, data: { project_id: 144, metadata: {} }, diff --git a/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts b/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts index 1ac9a3d80d..bb45c70989 100644 --- a/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry-plugin.service.ts @@ -1,9 +1,10 @@ import type { ServiceInfos } from '@cpn-console/hooks' +import type { ConfigType } from '@nestjs/config' import type { Cache } from 'cache-manager' import { DISABLED } from '@cpn-console/shared' import { CACHE_MANAGER } from '@nestjs/cache-manager' import { Inject, Injectable, Logger } from '@nestjs/common' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { harborConfigFactory } from '../../config/harbor.config' import { RegistryClientService } from './registry-client.service' import { RegistryDatastoreService } from './registry-datastore.service' import { createProjectSlugCacheKey } from './registry.utils' @@ -13,10 +14,10 @@ export class RegistryPluginService { private readonly logger = new Logger(RegistryPluginService.name) constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(harborConfigFactory.KEY) + private readonly harborConfig: ConfigType, @Inject(RegistryDatastoreService) - private readonly registryDatastore: RegistryDatastoreService, + private readonly datastore: RegistryDatastoreService, @Inject(RegistryClientService) private readonly registryClient: RegistryClientService, @Inject(CACHE_MANAGER) @@ -28,9 +29,9 @@ export class RegistryPluginService { const cached = await this.cache.get(cacheKey) if (cached !== undefined) return cached ?? undefined - const project = await this.registryDatastore.getProject(projectId) + const project = await this.datastore.getProject(projectId) const slug = project?.slug ?? null - await this.cache.set(cacheKey, slug, this.config.harborProjectSlugCacheTtlMs) + await this.cache.set(cacheKey, slug, this.harborConfig.projectSlugCacheTtlMs) return slug ?? undefined } @@ -49,9 +50,8 @@ export class RegistryPluginService { } } - private resolveHarborProjectUrl(harborProjectId: number): string | undefined { - if (!this.config.harborUrl) return undefined - return new URL(`harbor/projects/${harborProjectId}/`, this.config.harborUrl).toString() + private resolveHarborProjectUrl(harborProjectId: number): string { + return new URL(`harbor/projects/${harborProjectId}/`, this.harborConfig.url).toString() } private async resolveProjectUrl(projectId: string): Promise { diff --git a/apps/server-nestjs/src/modules/registry/registry.module.ts b/apps/server-nestjs/src/modules/registry/registry.module.ts index b205b1babb..c35c543393 100644 --- a/apps/server-nestjs/src/modules/registry/registry.module.ts +++ b/apps/server-nestjs/src/modules/registry/registry.module.ts @@ -1,7 +1,9 @@ import { CacheModule } from '@nestjs/cache-manager' import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { TerminusModule } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' +import { baseConfigFactory } from '../../config/base.config' +import { harborConfigFactory } from '../../config/harbor.config' import { DatabaseModule } from '../infrastructure/database/database.module' import { VaultModule } from '../vault/vault.module' import { RegistryClientService } from './registry-client.service' @@ -12,7 +14,7 @@ import { RegistryPluginService } from './registry-plugin.service' import { RegistryService } from './registry.service' @Module({ - imports: [ConfigurationModule, DatabaseModule, TerminusModule, VaultModule, CacheModule.register()], + imports: [DatabaseModule, TerminusModule, VaultModule, CacheModule.register(), ConfigModule.forFeature(harborConfigFactory), ConfigModule.forFeature(baseConfigFactory)], providers: [RegistryHealthService, RegistryPluginService, RegistryService, RegistryDatastoreService, RegistryHttpClientService, RegistryClientService], exports: [RegistryHealthService, RegistryPluginService, RegistryService], }) diff --git a/apps/server-nestjs/src/modules/registry/registry.service.spec.ts b/apps/server-nestjs/src/modules/registry/registry.service.spec.ts index 9fde08c567..ea2c620017 100644 --- a/apps/server-nestjs/src/modules/registry/registry.service.spec.ts +++ b/apps/server-nestjs/src/modules/registry/registry.service.spec.ts @@ -1,3 +1,4 @@ +import type { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' import { ENABLED } from '@cpn-console/shared' import { faker } from '@faker-js/faker' @@ -5,7 +6,9 @@ import { HttpStatus } from '@nestjs/common' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { baseConfigFactory } from '../../config/base.config' +import { harborConfigFactory } from '../../config/harbor.config' +import { vaultConfigFactory } from '../../config/vault.config' import { VaultClientService } from '../vault/vault-client.service' import { makeVaultSecret } from '../vault/vault-testing.utils' import { RegistryClientService } from './registry-client.service' @@ -24,7 +27,9 @@ describe('registryService', () => { let client: DeepMockProxy let datastore: DeepMockProxy let vault: DeepMockProxy - let config: DeepMockProxy + let harborConfig: DeepMockProxy> + let baseConfig: DeepMockProxy> + let vaultConfig: DeepMockProxy> beforeEach(async () => { client = mockDeep({ @@ -52,17 +57,20 @@ describe('registryService', () => { })), write: vi.fn().mockResolvedValue(undefined), }) - config = mockDeep({ - harborUrl: 'https://harbor.example', - harborInternalUrl: 'https://harbor.example', - harborAdmin: 'admin', - harborAdminPassword: faker.internet.password(), - harborRuleTemplate: 'latestPushedK', - harborRuleCount: '10', - harborRetentionCron: '0 22 2 * * *', - harborRobotRotationThresholdDays: 90, - projectRootDir: 'forge', + harborConfig = mockDeep>({ + url: 'https://harbor.example', + internalUrl: 'https://harbor.example', + admin: 'admin', + adminPassword: faker.internet.password(), + ruleTemplate: 'latestPushedK', + ruleCount: 10, + retentionCron: '0 22 2 * * *', + robotRotationThresholdDays: 90, }) + baseConfig = mockDeep>({ + projectsRootDir: 'forge', + }) + vaultConfig = mockDeep>({}) const module = await Test.createTestingModule({ providers: [ @@ -70,7 +78,9 @@ describe('registryService', () => { { provide: RegistryClientService, useValue: client }, { provide: RegistryDatastoreService, useValue: datastore }, { provide: VaultClientService, useValue: vault }, - { provide: ConfigurationService, useValue: config }, + { provide: harborConfigFactory.KEY, useValue: harborConfig }, + { provide: baseConfigFactory.KEY, useValue: baseConfig }, + { provide: vaultConfigFactory.KEY, useValue: vaultConfig }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/registry/registry.service.ts b/apps/server-nestjs/src/modules/registry/registry.service.ts index cd675f32da..d5172fe774 100644 --- a/apps/server-nestjs/src/modules/registry/registry.service.ts +++ b/apps/server-nestjs/src/modules/registry/registry.service.ts @@ -1,3 +1,5 @@ +import type { ConfigType } from '@nestjs/config' +import type { RuleTemplate } from '../../config/harbor.config' import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { VaultSecret } from '../vault/vault-client.service' import type { @@ -9,13 +11,14 @@ import type { HarborRobotCreateRequest, } from './registry-client.service' import type { ProjectWithDetails } from './registry-datastore.service' -import type { RuleTemplate, VaultRobotSecret } from './registry.utils' +import type { VaultRobotSecret } from './registry.utils' import { specificallyEnabled } from '@cpn-console/hooks' import { Inject, Injectable, Logger } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' -import { find } from '../../utils/iterable' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { baseConfigFactory } from '../../config/base.config' +import { harborConfigFactory } from '../../config/harbor.config' +import { find } from '../../utils/iterable.utils' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from '../vault/vault-client.service' @@ -23,7 +26,6 @@ import { VaultError } from '../vault/vault-http-client.service' import { RegistryClientService, roAccess, rwAccess } from './registry-client.service' import { RegistryDatastoreService } from './registry-datastore.service' import { - ALLOWED_RETENTION_RULE_TEMPLATES, DEFAULT_PLATFORM_ADMIN_GROUP_PATHS, DEFAULT_PLATFORM_GUEST_GROUP_PATHS, DEFAULT_PROJECT_ADMIN_GROUP_PATH_SUFFIXES, @@ -55,18 +57,16 @@ export class RegistryService { constructor( @Inject(RegistryClientService) private readonly client: RegistryClientService, - @Inject(RegistryDatastoreService) private readonly registryDatastore: RegistryDatastoreService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(RegistryDatastoreService) private readonly datastore: RegistryDatastoreService, + @Inject(harborConfigFactory.KEY) private readonly harborConfig: ConfigType, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, @Inject(VaultClientService) private readonly vault: VaultClientService, ) { this.logger.log('RegistryService initialized') } private get host() { - if (!this.config.harborUrl) { - throw new Error('HARBOR_URL is required') - } - return getHostFromUrl(this.config.harborUrl) + return getHostFromUrl(this.harborConfig.url) } private async getRobot(project: ProjectWithDetails, harborProjectId: number, robotName: string) { @@ -103,11 +103,8 @@ export class RegistryService { 'project.slug': project.slug, 'registry.robot.name': robotName, }) - if (!this.config.projectRootDir) { - throw new Error('PROJECTS_ROOT_DIR is required') - } const relativeVaultPath = `REGISTRY/${robotName}` - const vaultPath = getProjectVaultPath(project, this.config.projectRootDir, relativeVaultPath) + const vaultPath = getProjectVaultPath(project, this.baseConfig.projectsRootDir, relativeVaultPath) const vaultRobotSecret = await this.vault.read(vaultPath).catch((error) => { if (error instanceof VaultError && error.kind === 'NotFound') return null throw error @@ -142,7 +139,7 @@ export class RegistryService { const createdTimeRaw = vaultSecret?.metadata?.created_time if (!createdTimeRaw) return false const createdTime = new Date(createdTimeRaw) - return daysAgoFromNow(createdTime) > this.config.harborRobotRotationThresholdDays + return daysAgoFromNow(createdTime) > this.harborConfig.robotRotationThresholdDays } private async ensureProjectGroupMember( @@ -252,9 +249,9 @@ export class RegistryService { 'registry.project.id': harborProjectId, }) const policy = generateRetentionPolicy(harborProjectId, { - harborRuleTemplate: this.config.harborRuleTemplate, - harborRuleCount: this.config.harborRuleCount, - harborRetentionCron: this.config.harborRetentionCron, + harborRuleTemplate: this.harborConfig.ruleTemplate, + harborRuleCount: this.harborConfig.ruleCount, + harborRetentionCron: this.harborConfig.retentionCron, }) const retentionId = await this.client.getRetentionId(project.slug) span?.setAttribute('registry.retention.exists', !!retentionId) @@ -346,13 +343,13 @@ export class RegistryService { async handleCron() { const span = trace.getActiveSpan() this.logger.log('Starting Registry reconciliation') - const projects = await this.registryDatastore.getAllProjects() + const projects = await this.datastore.getAllProjects() span?.setAttribute('registry.projects.count', projects.length) await Promise.all(projects.map(p => this.ensureProject(p))) } private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string) { - const adminPluginConfig = await this.registryDatastore.getAdminPluginConfig(PLUGIN_NAME, key) + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) if (adminPluginConfig) return adminPluginConfig return getPluginConfig(project, key) } @@ -471,25 +468,13 @@ function generateRobotPermissions(project: ProjectWithDetails, robotName: string function generateRetentionPolicy( projectId: number, options: { - harborRuleTemplate?: string - harborRuleCount?: string - harborRetentionCron?: string + harborRuleTemplate?: RuleTemplate + harborRuleCount?: number + harborRetentionCron: string }, ): HarborRetentionPolicy { - let template: RuleTemplate = 'latestPushedK' - if (isRuleTemplate(options.harborRuleTemplate)) { - template = options.harborRuleTemplate - } - - const rawCount = Number(options.harborRuleCount) - let count: number - if (Number.isFinite(rawCount) && rawCount > 0) { - count = rawCount - } else if (template === 'always') { - count = 1 - } else { - count = 10 - } + const template: RuleTemplate = options.harborRuleTemplate ?? 'latestPushedK' + const count = options.harborRuleCount ?? (template === 'always' ? 1 : 10) return { algorithm: 'or', @@ -517,11 +502,3 @@ function generateRetentionPolicy( }, } } - -function isRuleTemplate(value: unknown): value is RuleTemplate { - if (typeof value !== 'string') return false - for (const template of ALLOWED_RETENTION_RULE_TEMPLATES) { - if (template === value) return true - } - return false -} diff --git a/apps/server-nestjs/src/modules/registry/registry.utils.ts b/apps/server-nestjs/src/modules/registry/registry.utils.ts index 2befbdbd12..8bbe97eb7e 100644 --- a/apps/server-nestjs/src/modules/registry/registry.utils.ts +++ b/apps/server-nestjs/src/modules/registry/registry.utils.ts @@ -1,9 +1,6 @@ import type { ProjectWithDetails } from './registry-datastore.service' -import type { ALLOWED_RETENTION_RULE_TEMPLATES } from './registry.constants' import { removeTrailingSlash } from '@cpn-console/shared' -export type RuleTemplate = typeof ALLOWED_RETENTION_RULE_TEMPLATES[number] - export function createProjectSlugCacheKey(projectId: string) { return `registry:project-slug:${projectId}` } diff --git a/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.spec.ts b/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.spec.ts index d0ff874edc..3ad69d5ba0 100644 --- a/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.spec.ts @@ -1,11 +1,13 @@ +import type { ConfigType } from '@nestjs/config' import type { TestingModule } from '@nestjs/testing' -import type { RequestInit } from 'undici' import { HttpStatus } from '@nestjs/common' import { Test } from '@nestjs/testing' import { Agent, fetch, Headers, Response } from 'undici' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' -import { OpenCdsClientError, OpenCdsClientService } from './open-cds-client.service' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mockDeep } from 'vitest-mock-extended' +import { baseConfigFactory } from '../../config/base.config' +import { serviceChainConfigFactory } from '../../config/service-chain.config' +import { OpenCdsClientService } from './open-cds-client.service' vi.mock('undici', async (importOriginal) => { const actual = await importOriginal() @@ -21,36 +23,40 @@ function mockFetchResponse(response: Response): void { vi.mocked(fetch).mockResolvedValue(response) } -function getLastFetchCall(): [string, RequestInit] { - const [url, init] = vi.mocked(fetch).mock.lastCall as [string, RequestInit] - return [url, init] +function getLastFetchCall(): [Request, unknown] { + return vi.mocked(fetch).mock.lastCall as unknown as [Request, unknown] } describe('openCdsClientService', () => { let module: TestingModule let service: OpenCdsClientService - let config: Partial + let serviceCHainConfig: Partial> + let baseConfig: ReturnType>> beforeEach(async () => { vi.clearAllMocks() vi.unstubAllEnvs() - - config = { - openCdsUrl: 'https://opencds.example.com/root/api/', - openCdsApiToken: 'test-token', - openCdsApiTlsRejectUnauthorized: true, + serviceCHainConfig = { + url: 'https://opencds.example.com/root/api', + apiToken: 'test-token', + apiTlsRejectUnauthorized: true, } - + baseConfig = mockDeep>() module = await Test.createTestingModule({ providers: [ OpenCdsClientService, - { provide: ConfigurationService, useValue: config }, + { provide: serviceChainConfigFactory.KEY, useValue: serviceCHainConfig }, + { provide: baseConfigFactory.KEY, useValue: baseConfig }, ], }).compile() service = module.get(OpenCdsClientService) }) + afterEach(() => { + vi.unstubAllEnvs() + }) + it('builds GET requests with an Axios-compatible URL, API key header and global dispatcher', async () => { mockFetchResponse(new Response(JSON.stringify({ ok: true }), { status: HttpStatus.OK, @@ -61,16 +67,14 @@ describe('openCdsClientService', () => { const result = await service.get<{ ok: boolean }>('/requests') - const [url, init] = getLastFetchCall() - expect(url).toBe('https://opencds.example.com/root/api/requests') - expect(init.dispatcher).toBeUndefined() - expect(init.method).toBe('GET') - expect(init.signal).toBeUndefined() - expect(new Headers(init.headers).get('X-API-Key')).toBe('test-token') + const [request] = getLastFetchCall() + expect(request.url).toBe('https://opencds.example.com/root/api/requests') + expect(request.method).toBe('GET') + expect(new Headers(request.headers).get('X-API-Key')).toBe('test-token') expect(result).toEqual({ ok: true }) }) - it('applies query parameters and omits undefined values on GET', async () => { + it('applies query parameters on GET', async () => { mockFetchResponse(new Response(JSON.stringify({ ok: true }), { status: HttpStatus.OK, headers: { @@ -83,12 +87,11 @@ describe('openCdsClientService', () => { page: 2, active: true, search: 'alpha', - ignored: undefined, }, }) - const [url] = getLastFetchCall() - expect(url).toBe('https://opencds.example.com/root/api/requests?page=2&active=true&search=alpha') + const [request] = getLastFetchCall() + expect(request.url).toBe('https://opencds.example.com/root/api/requests?page=2&active=true&search=alpha') }) it('sends POST without body and without forcing JSON content type', async () => { @@ -96,14 +99,12 @@ describe('openCdsClientService', () => { await service.post('/validate/id') - const [url, init] = getLastFetchCall() - expect(url).toBe('https://opencds.example.com/root/api/validate/id') - expect(init.dispatcher).toBeUndefined() - expect(init.method).toBe('POST') - expect(init.signal).toBeUndefined() - expect(init.body).toBeUndefined() - expect(new Headers(init.headers).get('X-API-Key')).toBe('test-token') - expect(new Headers(init.headers).has('Content-Type')).toBe(false) + const [request] = getLastFetchCall() + expect(request.url).toBe('https://opencds.example.com/root/api/validate/id') + expect(request.method).toBe('POST') + expect(request.body).toBeNull() + expect(new Headers(request.headers).get('X-API-Key')).toBe('test-token') + expect(new Headers(request.headers).has('Content-Type')).toBe(false) }) it('serializes POST bodies as JSON and sets the content type', async () => { @@ -114,21 +115,19 @@ describe('openCdsClientService', () => { enabled: true, }) - const [url, init] = getLastFetchCall() - expect(url).toBe('https://opencds.example.com/root/api/validate/id') - expect(init.body).toBe(JSON.stringify({ + const [request] = getLastFetchCall() + expect(request.url).toBe('https://opencds.example.com/root/api/validate/id') + expect(request.method).toBe('POST') + expect(new Headers(request.headers).get('X-API-Key')).toBe('test-token') + expect(new Headers(request.headers).get('Content-Type')).toBe('application/json') + expect(await request.clone().json()).toEqual({ requestId: '123', enabled: true, - })) - expect(init.dispatcher).toBeUndefined() - expect(init.method).toBe('POST') - expect(init.signal).toBeUndefined() - expect(new Headers(init.headers).get('X-API-Key')).toBe('test-token') - expect(new Headers(init.headers).get('Content-Type')).toBe('application/json') + }) }) it('throws when OpenCDS is disabled', async () => { - config.openCdsUrl = undefined + vi.mocked(fetch).mockRejectedValue(new Error('OpenCDS is disabled')) await expect(service.get('/requests')).rejects.toThrow('OpenCDS is disabled') }) @@ -142,21 +141,22 @@ describe('openCdsClientService', () => { await expect(service.get('/requests')).rejects.toMatchObject({ body: 'upstream failure', message: 'OpenCDS request failed with 502 Bad Gateway', - name: OpenCdsClientError.name, + name: 'OpenCdsClientError', status: HttpStatus.BAD_GATEWAY, statusText: 'Bad Gateway', }) }) it('uses a local Agent with rejectUnauthorized:false when TLS verification is disabled', async () => { - config = { - ...config, - openCdsApiTlsRejectUnauthorized: false, + serviceCHainConfig = { + ...serviceCHainConfig, + apiTlsRejectUnauthorized: false, } module = await Test.createTestingModule({ providers: [ OpenCdsClientService, - { provide: ConfigurationService, useValue: config }, + { provide: serviceChainConfigFactory.KEY, useValue: serviceCHainConfig }, + { provide: baseConfigFactory.KEY, useValue: baseConfig }, ], }).compile() service = module.get(OpenCdsClientService) @@ -169,7 +169,7 @@ describe('openCdsClientService', () => { await service.get<{ ok: boolean }>('/requests') expect(Agent).toHaveBeenCalledWith({ connect: { rejectUnauthorized: false } }) - const [, init] = getLastFetchCall() - expect(init.dispatcher).toBeDefined() + const [request] = getLastFetchCall() + expect(request).toBeDefined() }) }) diff --git a/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts b/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts index 38dc80e8f7..e2cabe987c 100644 --- a/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts +++ b/apps/server-nestjs/src/modules/service-chain/open-cds-client.service.ts @@ -1,57 +1,38 @@ -import type { HttpStatus } from '@nestjs/common' -import type { Dispatcher, HeadersInit } from 'undici' -import { Inject, Injectable, Logger } from '@nestjs/common' -import { Agent, fetch, Headers } from 'undici' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' -import { throwIfNotOk } from './service-chain.utils' - -const openCdsDisabledMessage - = 'OpenCDS is disabled, please set OPENCDS_URL in your relevant .env file. See .env-example' - -const URL_REGEX = /^https?:\/\// -const START_SLASHES_REGEX = /^\/+/ -const END_SLASHES_REGEX = /\/+$/ +import type { ConfigType } from '@nestjs/config' +import type { HeadersInit, RequestInit } from 'undici' +import { HttpStatus, Inject, Injectable, Logger } from '@nestjs/common' +import { Agent, fetch, Headers, Request } from 'undici' +import { baseConfigFactory } from '../../config/base.config' +import { serviceChainConfigFactory } from '../../config/service-chain.config' +import { OpenCdsClientError, throwIfNotOk } from './service-chain.utils' export interface OpenCdsRequestOptions { headers?: HeadersInit signal?: AbortSignal - query?: Record -} - -export class OpenCdsClientError extends Error { - constructor( - public readonly status: HttpStatus, - public readonly statusText: string, - public readonly body?: string, - ) { - super(`OpenCDS request failed with ${status} ${statusText}`) - this.name = 'OpenCdsClientError' - } + query?: Record } @Injectable() export class OpenCdsClientService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(serviceChainConfigFactory.KEY) private readonly opencdsConfig: ConfigType, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, ) {} private readonly logger = new Logger(OpenCdsClientService.name) async get(path: string, options?: OpenCdsRequestOptions): Promise { - const url = this.buildUrl(path, options?.query) - this.logger.debug(`Retrieving data from URL: ${url}`) - - const headers = this.buildHeaders(options?.headers) - - const response = await fetch(url, { - dispatcher: this.buildDispatcher(), - headers, - method: 'GET', - signal: options?.signal, + const request = this.createRequest('GET', path, undefined, options) + this.logger.debug(`Retrieving data from URL: ${request.url}`) + + const response = await fetch(request).catch((error) => { + throw new OpenCdsClientError( + HttpStatus.INTERNAL_SERVER_ERROR, + error instanceof Error ? error.message : String(error), + ) }) await throwIfNotOk(response) - return (await response.json()) as T } @@ -60,63 +41,48 @@ export class OpenCdsClientService { body?: TBody, options?: OpenCdsRequestOptions, ): Promise { - const hasBody = body !== undefined - - const response = await fetch(this.buildUrl(path, options?.query), { - body: hasBody ? JSON.stringify(body) : undefined, - dispatcher: this.buildDispatcher(), - headers: this.buildHeaders(options?.headers, hasBody), - method: 'POST', - signal: options?.signal, + const requestBody = JSON.stringify(body) + const request = this.createRequest('POST', path, requestBody, options) + + const response = await fetch(request).catch((error) => { + throw new OpenCdsClientError( + HttpStatus.INTERNAL_SERVER_ERROR, + error instanceof Error ? error.message : String(error), + ) }) await throwIfNotOk(response) } - private buildUrl( + private createRequest( + method: string, path: string, - query?: OpenCdsRequestOptions['query'], - ): string { - if (!this.config.openCdsUrl) { - throw new Error(openCdsDisabledMessage) - } - - const resolvedPath = URL_REGEX.test(path) - ? path - : `${this.config.openCdsUrl.replace(END_SLASHES_REGEX, '')}/${path.replace(START_SLASHES_REGEX, '')}` - - const url = new URL(resolvedPath) + body?: string, + options?: OpenCdsRequestOptions, + ): Request { + const url = new URL(path.replace(/^\/+/, ''), `${this.opencdsConfig.url}/`) - for (const [key, value] of Object.entries(query ?? {})) { - if (value !== undefined) { - url.searchParams.append(key, String(value)) - } + for (const [key, value] of Object.entries(options?.query ?? {})) { + url.searchParams.append(key, String(value)) } - return url.toString() - } - - private buildHeaders( - headers?: OpenCdsRequestOptions['headers'], - hasJsonBody = false, - ): Headers { - const mergedHeaders = new Headers(headers) - mergedHeaders.set('X-API-Key', this.config.openCdsApiToken ?? '') - - if (hasJsonBody) { - mergedHeaders.set('Content-Type', 'application/json') + const headers = new Headers(options?.headers) + headers.set('X-API-Key', this.opencdsConfig.apiToken) + if (body !== undefined) { + headers.set('Content-Type', 'application/json') } - return mergedHeaders - } + const init: RequestInit = { + method, + headers, + body, + signal: options?.signal, + } - private buildDispatcher(): Dispatcher | undefined { - // Only the TLS-verify-disabled case needs a local dispatcher; proxy bypass on - // that rare path is accepted. Security default (apiTlsRejectUnauthorized=true) keeps cert verification ON. - if (!this.config.openCdsApiTlsRejectUnauthorized) { - return new Agent({ connect: { rejectUnauthorized: false } }) + if (!this.opencdsConfig.apiTlsRejectUnauthorized) { + init.dispatcher = new Agent({ connect: { rejectUnauthorized: false } }) } - return undefined + return new Request(url, init) } } diff --git a/apps/server-nestjs/src/modules/service-chain/service-chain-health.service.ts b/apps/server-nestjs/src/modules/service-chain/service-chain-health.service.ts index 0c68567668..bc7b88b86b 100644 --- a/apps/server-nestjs/src/modules/service-chain/service-chain-health.service.ts +++ b/apps/server-nestjs/src/modules/service-chain/service-chain-health.service.ts @@ -1,26 +1,22 @@ +import type { ConfigType } from '@nestjs/config' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { serviceChainConfigFactory } from '../../config/service-chain.config' import { PLUGIN_NAME } from './service-chain.constants' @Injectable() export class ServiceChainHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(serviceChainConfigFactory.KEY) private readonly opencdsConfig: ConfigType, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} async check() { const indicator = this.healthIndicator.check(PLUGIN_NAME) - if (!this.config.openCdsUrl) return indicator.down('Not configured') - try { - const url = new URL('/api/v1/health', this.config.openCdsUrl).toString() const headers: Record = {} - if (this.config.openCdsApiToken) { - headers.Authorization = `Bearer ${this.config.openCdsApiToken}` - } - + headers.Authorization = `Bearer ${this.opencdsConfig.apiToken}` + const url = new URL('/api/v1/health', this.opencdsConfig.internalUrl ?? this.opencdsConfig.url).toString() const response = await fetch(url, { headers }) if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) return indicator.down({ httpStatus: response.status }) diff --git a/apps/server-nestjs/src/modules/service-chain/service-chain.module.ts b/apps/server-nestjs/src/modules/service-chain/service-chain.module.ts index 58103a0409..173d4aa7c9 100644 --- a/apps/server-nestjs/src/modules/service-chain/service-chain.module.ts +++ b/apps/server-nestjs/src/modules/service-chain/service-chain.module.ts @@ -1,7 +1,9 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { TerminusModule } from '@nestjs/terminus' +import { baseConfigFactory } from '../../config/base.config' +import { serviceChainConfigFactory } from '../../config/service-chain.config' import { AuthModule } from '../infrastructure/auth/auth.module' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' import { DatabaseModule } from '../infrastructure/database/database.module' import { EventsModule } from '../infrastructure/events/events.module' import { UserPermissionModule } from '../infrastructure/permission/user/user.module' @@ -13,11 +15,12 @@ import { ServiceChainService } from './service-chain.service' @Module({ imports: [ AuthModule, - ConfigurationModule, DatabaseModule, EventsModule, TerminusModule, UserPermissionModule, + ConfigModule.forFeature(serviceChainConfigFactory), + ConfigModule.forFeature(baseConfigFactory), ], controllers: [ServiceChainController], providers: [OpenCdsClientService, ServiceChainHealthService, ServiceChainService], diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts index 3d8addfdab..f00d19d011 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.spec.ts @@ -1,3 +1,4 @@ +import type { ConfigType } from '@nestjs/config' import type { AddPermissionGroupParams, CreateUserParams, DeactivateUserParams, RevokeUserTokenParams } from './sonarqube-client.service' import { faker } from '@faker-js/faker' import { Test } from '@nestjs/testing' @@ -5,7 +6,7 @@ import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { sonarqubeConfigFactory } from '../../config/sonarqube.config' import { SonarqubeClientService } from './sonarqube-client.service' import { SonarqubeHttpClientService } from './sonarqube-http-client.service' import { makeSonarqubeGeneratedToken, makeSonarqubeGroup, makeSonarqubePaging, makeSonarqubeProject, makeSonarqubeUser } from './sonarqube-testing.utils' @@ -18,20 +19,21 @@ const server = setupServer() describe('sonarqubeClientService', () => { let service: SonarqubeClientService - let config: ReturnType> + let config: ReturnType>> beforeAll(() => server.listen({ onUnhandledRequest: 'error' })) beforeEach(async () => { - config = mockDeep({ - sonarApiToken: sonarToken, - getInternalOrPublicSonarqubeUrl: () => sonarUrl, + config = mockDeep>({ + apiToken: sonarToken, + url: sonarUrl, + internalUrl: undefined, }) const module = await Test.createTestingModule({ providers: [ SonarqubeClientService, SonarqubeHttpClientService, - { provide: ConfigurationService, useValue: config }, + { provide: sonarqubeConfigFactory.KEY, useValue: config }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts index 7cc63c64a4..e4ed2b86ab 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-client.service.ts @@ -202,86 +202,86 @@ export class SonarqubeClientService { @StartActiveSpan() searchUserGroup(params: SearchUserGroupParams) { - return this.http.fetch('user_groups/search', { params }).then(res => res.data!) + return this.http.fetch('user_groups/search', { query: params }).then(res => res.data!) } @StartActiveSpan() async createUserGroup(params: CreateUserGroupParams) { - await this.http.fetch('user_groups/create', { method: 'POST', params }) + await this.http.fetch('user_groups/create', { method: 'POST', query: params }) } @StartActiveSpan() async createPermissionTemplate(params: CreatePermissionTemplateParams) { - await this.http.fetch('permissions/create_template', { method: 'POST', params }) + await this.http.fetch('permissions/create_template', { method: 'POST', query: params }) } @StartActiveSpan() searchPermissionTemplates(params: SearchPermissionTemplatesParams) { - return this.http.fetch('permissions/search_templates', { params }).then(res => res.data!) + return this.http.fetch('permissions/search_templates', { query: params }).then(res => res.data!) } @StartActiveSpan() async setPermissionDefaultTemplate(params: SetPermissionDefaultTemplateParams) { - await this.http.fetch('permissions/set_default_template', { method: 'POST', params }) + await this.http.fetch('permissions/set_default_template', { method: 'POST', query: params }) } @StartActiveSpan() async addPermissionProjectCreatorToTemplate(params: AddPermissionProjectCreatorToTemplateParams) { - await this.http.fetch('permissions/add_project_creator_to_template', { method: 'POST', params }) + await this.http.fetch('permissions/add_project_creator_to_template', { method: 'POST', query: params }) } @StartActiveSpan() async addPermissionGroupToTemplate(params: AddPermissionGroupToTemplateParams) { - await this.http.fetch('permissions/add_group_to_template', { method: 'POST', params }) + await this.http.fetch('permissions/add_group_to_template', { method: 'POST', query: params }) } @StartActiveSpan() async addPermissionGroup(params: AddPermissionGroupParams) { - await this.http.fetch('permissions/add_group', { method: 'POST', params }) + await this.http.fetch('permissions/add_group', { method: 'POST', query: params }) } @StartActiveSpan() async addPermissionUser(params: AddPermissionUserParams) { - await this.http.fetch('permissions/add_user', { method: 'POST', params }) + await this.http.fetch('permissions/add_user', { method: 'POST', query: params }) } @StartActiveSpan() searchUsers(params: SearchUsersParams) { - return this.http.fetch('users/search', { params }).then(res => res.data!) + return this.http.fetch('users/search', { query: params }).then(res => res.data!) } @StartActiveSpan() async createUser(params: CreateUserParams) { - await this.http.fetch('users/create', { method: 'POST', params }) + await this.http.fetch('users/create', { method: 'POST', query: params }) } @StartActiveSpan() async deactivateUser(params: DeactivateUserParams) { - await this.http.fetch('users/deactivate', { method: 'POST', params }) + await this.http.fetch('users/deactivate', { method: 'POST', query: params }) } @StartActiveSpan() async revokeUserToken(params: RevokeUserTokenParams) { - await this.http.fetch('user_tokens/revoke', { method: 'POST', params }) + await this.http.fetch('user_tokens/revoke', { method: 'POST', query: params }) } @StartActiveSpan() generateUserToken(params: GenerateUserTokenParams) { - return this.http.fetch('user_tokens/generate', { method: 'POST', params }).then(res => res.data!) + return this.http.fetch('user_tokens/generate', { method: 'POST', query: params }).then(res => res.data!) } @StartActiveSpan() searchProject(params: SearchProjectParams) { - return this.http.fetch('projects/search', { params }).then(res => res.data!) + return this.http.fetch('projects/search', { query: params }).then(res => res.data!) } @StartActiveSpan() async createProject(params: CreateProjectParams) { - await this.http.fetch('projects/create', { method: 'POST', params }) + await this.http.fetch('projects/create', { method: 'POST', query: params }) } @StartActiveSpan() async deleteProject(params: DeleteProjectParams) { - await this.http.fetch('projects/delete', { method: 'POST', params }) + await this.http.fetch('projects/delete', { method: 'POST', query: params }) } } diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts index 25f2cc5a11..388e0452a9 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-health.service.ts @@ -1,28 +1,22 @@ +import type { ConfigType } from '@nestjs/config' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { sonarqubeConfigFactory } from '../../config/sonarqube.config' @Injectable() export class SonarqubeHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(sonarqubeConfigFactory.KEY) private readonly sonarqubeConfig: ConfigType, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} async check(key: string) { const indicator = this.healthIndicator.check(key) - const urlBase = this.config.getInternalOrPublicSonarqubeUrl() - if (!urlBase) return indicator.down('Not configured') - - const url = new URL('/api/system/health', urlBase).toString() - const token = this.config.sonarApiToken const headers: Record = {} - if (token) { - const bearerToken = Buffer.from(`${token}:`, 'utf8').toString('base64') - headers.Authorization = `Bearer ${bearerToken}` - } - + const bearerToken = Buffer.from(`${this.sonarqubeConfig.apiToken}:`, 'utf-8').toString('base64') + headers.Authorization = `Bearer ${bearerToken}` try { + const url = new URL('/api/system/health', this.sonarqubeConfig.internalUrl ?? this.sonarqubeConfig.url).toString() const response = await fetch(url, { headers }) if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) return indicator.down({ httpStatus: response.status }) diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts index 9318e5d569..7eb587ef9e 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-http-client.service.ts @@ -1,10 +1,11 @@ +import type { ConfigType } from '@nestjs/config' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { trace } from '@opentelemetry/api' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { sonarqubeConfigFactory } from '../../config/sonarqube.config' export interface SonarqubeFetchOptions { method?: string - params?: Record + query?: Record } export interface SonarqubeResponse { @@ -37,23 +38,16 @@ export class SonarqubeError extends Error { @Injectable() export class SonarqubeHttpClientService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(sonarqubeConfigFactory.KEY) private readonly sonarqubeConfig: ConfigType, ) {} - private get baseUrl(): string { - const url = this.config.getInternalOrPublicSonarqubeUrl() - if (!url) throw new SonarqubeError('NotConfigured', 'SONARQUBE_URL or SONARQUBE_INTERNAL_URL is required') - return url - } - private get apiBaseUrl(): string { - return new URL('api/', this.baseUrl).toString() + return new URL('api/', this.sonarqubeConfig.internalUrl ?? this.sonarqubeConfig.url).toString() } private get defaultHeaders(): Record { - if (!this.config.sonarApiToken) throw new SonarqubeError('NotConfigured', 'SONAR_API_TOKEN is required') return { - Authorization: `Bearer ${this.config.sonarApiToken}`, + Authorization: `Bearer ${this.sonarqubeConfig.apiToken}`, } } @@ -63,7 +57,7 @@ export class SonarqubeHttpClientService { span?.setAttribute('sonarqube.method', method) span?.setAttribute('sonarqube.path', path) - const request = this.createRequest(path, method, options.params) + const request = this.createRequest(path, method, options) const response = await fetch(request).catch((error) => { throw new SonarqubeError('Unexpected', error instanceof Error ? error.message : String(error), { method, path }) }) @@ -77,11 +71,12 @@ export class SonarqubeHttpClientService { return result } - private createRequest(path: string, method: string, params?: Record): Request { + private createRequest(path: string, method: string, options: SonarqubeFetchOptions): Request { const url = new URL(path, this.apiBaseUrl) - if (params) { - for (const [key, value] of Object.entries(params)) { - if (value !== undefined && value !== null) url.searchParams.append(key, String(value)) + if (options.query) { + for (const [key, value] of Object.entries(options.query)) { + if (value === undefined) continue + url.searchParams.append(key, String(value)) } } return new Request(url.toString(), { method, headers: this.defaultHeaders }) diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.spec.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.spec.ts index b1f1c5fbda..788323985a 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.spec.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.spec.ts @@ -1,25 +1,26 @@ +import type { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { sonarqubeConfigFactory } from '../../config/sonarqube.config' import { makeToUrlParams } from '../plugin/plugin.utils' import { SonarqubePluginService } from './sonarqube-plugin.service' describe('sonarqubePluginService', () => { let service: SonarqubePluginService - let config: DeepMockProxy + let config: DeepMockProxy> beforeEach(async () => { - config = mockDeep({ - sonarqubeUrl: 'https://sonar.public/', - sonarqubeInternalUrl: 'https://sonar.internal/', + config = mockDeep>({ + url: 'https://sonar.public/', + internalUrl: 'https://sonar.internal/', }) const moduleRef = await Test.createTestingModule({ providers: [ SonarqubePluginService, - { provide: ConfigurationService, useValue: config }, + { provide: sonarqubeConfigFactory.KEY, useValue: config }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts index 9a31e60496..49b5921b17 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube-plugin.service.ts @@ -1,21 +1,19 @@ import type { ServiceInfos } from '@cpn-console/hooks' +import type { ConfigType } from '@nestjs/config' import { Inject, Injectable } from '@nestjs/common' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { sonarqubeConfigFactory } from '../../config/sonarqube.config' @Injectable() export class SonarqubePluginService { constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(sonarqubeConfigFactory.KEY) + private readonly sonarqubeConfig: ConfigType, ) {} infos(): ServiceInfos { return { name: 'sonarqube', - to: () => { - if (!this.config.sonarqubeUrl) return undefined - return new URL('projects', this.config.sonarqubeUrl).toString() - }, + to: () => new URL('projects', this.sonarqubeConfig.url).toString(), title: 'SonarQube', imgSrc: '/img/sonarqube.svg', description: 'SonarQube permet à tous les développeurs d\'écrire un code plus propre et plus sûr', diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts index 17499fdda4..a1aa961782 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { TerminusModule } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' +import { sonarqubeConfigFactory } from '../../config/sonarqube.config' import { DatabaseModule } from '../infrastructure/database/database.module' import { VaultModule } from '../vault/vault.module' import { SonarqubeClientService } from './sonarqube-client.service' @@ -11,7 +12,7 @@ import { SonarqubePluginService } from './sonarqube-plugin.service' import { SonarqubeService } from './sonarqube.service' @Module({ - imports: [ConfigurationModule, DatabaseModule, TerminusModule, VaultModule], + imports: [DatabaseModule, TerminusModule, VaultModule, ConfigModule.forFeature(sonarqubeConfigFactory)], providers: [ SonarqubeHealthService, SonarqubeHttpClientService, diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts index 19721a8cc6..4b69751100 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.spec.ts @@ -1,9 +1,10 @@ +import type { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { generateProjectKey } from '../../utils/crypto' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { sonarqubeConfigFactory } from '../../config/sonarqube.config' +import { generateProjectKey } from '../../utils/crypto.utils' import { VaultClientService } from '../vault/vault-client.service' import { makeVaultSecret } from '../vault/vault-testing.utils' import { SonarqubeClientService } from './sonarqube-client.service' @@ -25,7 +26,7 @@ describe('sonarqubeService', () => { let client: DeepMockProxy let datastore: DeepMockProxy let vault: DeepMockProxy - let config: DeepMockProxy + let config: DeepMockProxy> beforeEach(async () => { client = mockDeep({ @@ -34,7 +35,6 @@ describe('sonarqubeService', () => { createPermissionTemplate: vi.fn().mockResolvedValue(undefined), searchPermissionTemplates: vi.fn().mockResolvedValue({ permissionTemplates: [] }), setPermissionDefaultTemplate: vi.fn().mockResolvedValue(undefined), - addPermissionProjectCreatorToTemplate: vi.fn().mockResolvedValue(undefined), addPermissionGroupToTemplate: vi.fn().mockResolvedValue(undefined), addPermissionGroup: vi.fn().mockResolvedValue(undefined), addPermissionUser: vi.fn().mockResolvedValue(undefined), @@ -54,9 +54,7 @@ describe('sonarqubeService', () => { writeSonarqubeUser: vi.fn().mockResolvedValue(undefined), deleteSonarqubeUser: vi.fn().mockResolvedValue(undefined), }) - config = mockDeep({ - projectRootDir: 'forge', - getInternalOrPublicSonarqubeUrl: vi.fn().mockReturnValue('https://sonarqube.internal'), + config = mockDeep>({ }) const moduleRef = await Test.createTestingModule({ @@ -65,7 +63,7 @@ describe('sonarqubeService', () => { { provide: SonarqubeClientService, useValue: client }, { provide: SonarqubeDatastoreService, useValue: datastore }, { provide: VaultClientService, useValue: vault }, - { provide: ConfigurationService, useValue: config }, + { provide: sonarqubeConfigFactory.KEY, useValue: config }, ], }).compile() @@ -113,14 +111,6 @@ describe('sonarqubeService', () => { expect(client.createUserGroup).not.toHaveBeenCalledWith(expect.objectContaining({ name: '/console/admin' })) }) - it('should skip initialization when URL is not configured', async () => { - config.getInternalOrPublicSonarqubeUrl.mockReturnValue(undefined) - - await service.init() - - expect(client.createPermissionTemplate).not.toHaveBeenCalled() - }) - it('should use custom group paths from admin plugin config', async () => { datastore.getAdminPluginConfig.mockImplementation((_plugin, key) => { if (key === 'adminGroupPath') return Promise.resolve('/custom/admin') diff --git a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts index fc36f6c663..daab56119a 100644 --- a/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts +++ b/apps/server-nestjs/src/modules/sonarqube/sonarqube.service.ts @@ -1,4 +1,5 @@ import type { OnModuleInit } from '@nestjs/common' +import type { ConfigType } from '@nestjs/config' import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { SonarqubeUserSecret } from '../vault/vault-client.service' import type { SonarqubeProjectResult, SonarqubeUser } from './sonarqube-client.service' @@ -6,8 +7,8 @@ import type { ProjectWithDetails } from './sonarqube-datastore.service' import { Inject, Injectable, Logger } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' -import { generateProjectKey, generateRandomPassword } from '../../utils/crypto' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { sonarqubeConfigFactory } from '../../config/sonarqube.config' +import { generateProjectKey, generateRandomPassword } from '../../utils/crypto.utils' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from '../vault/vault-client.service' @@ -57,7 +58,7 @@ export class SonarqubeService implements OnModuleInit { constructor( @Inject(SonarqubeDatastoreService) private readonly datastore: SonarqubeDatastoreService, @Inject(SonarqubeClientService) private readonly client: SonarqubeClientService, - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(sonarqubeConfigFactory.KEY) private readonly sonarqubeConfig: ConfigType, @Inject(VaultClientService) private readonly vault: VaultClientService, ) { this.logger.log('SonarqubeService initialized') @@ -69,10 +70,6 @@ export class SonarqubeService implements OnModuleInit { @StartActiveSpan() async init(): Promise { - if (!this.config.getInternalOrPublicSonarqubeUrl()) { - this.logger.warn('SonarQube URL not configured — skipping initialization') - return - } this.logger.log('Initializing SonarQube platform configuration') const adminGroupPath = await this.getAdminGroupPath() const [readonlyGroupPath, securityGroupPath] = await Promise.all([ diff --git a/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts b/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts index 54db1c52b4..a435f8d380 100644 --- a/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts +++ b/apps/server-nestjs/src/modules/vault/vault-client.service.spec.ts @@ -1,10 +1,12 @@ +import type { ConfigType } from '@nestjs/config' import { HttpStatus } from '@nestjs/common' import { Test } from '@nestjs/testing' import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' import { VaultClientService } from './vault-client.service' import { VaultError, VaultHttpClientService } from './vault-http-client.service' @@ -30,19 +32,22 @@ describe('vault', () => { beforeAll(() => server.listen()) beforeEach(async () => { - const config = mockDeep({ - vaultToken: 'token', - vaultUrl, - vaultInternalUrl: vaultUrl, - vaultKvName: 'kv', - getInternalOrPublicVaultUrl: () => vaultUrl, + const config = mockDeep>({ + token: 'token', + url: vaultUrl, + internalUrl: vaultUrl, + kvName: 'kv', + }) + const baseConfig = mockDeep>({ + projectsRootDir: 'forge', }) const module = await Test.createTestingModule({ providers: [ VaultClientService, VaultHttpClientService, - { provide: ConfigurationService, useValue: config }, + { provide: vaultConfigFactory.KEY, useValue: config }, + { provide: baseConfigFactory.KEY, useValue: baseConfig }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/vault/vault-client.service.ts b/apps/server-nestjs/src/modules/vault/vault-client.service.ts index b49868d93f..55a816bd02 100644 --- a/apps/server-nestjs/src/modules/vault/vault-client.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault-client.service.ts @@ -1,6 +1,8 @@ +import type { ConfigType } from '@nestjs/config' import { Inject, Injectable, Logger } from '@nestjs/common' import { trace } from '@opentelemetry/api' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { VaultError, VaultHttpClientService } from './vault-http-client.service' import { generateGitlabMirrorCredPath, generateSonarqubeCredPath, generateTechReadOnlyCredPath } from './vault.utils' @@ -114,7 +116,8 @@ export class VaultClientService { private readonly logger = new Logger(VaultClientService.name) constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(vaultConfigFactory.KEY) private readonly vaultConfig: ConfigType, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, @Inject(VaultHttpClientService) private readonly http: VaultHttpClientService, ) { } @@ -144,13 +147,13 @@ export class VaultClientService { @StartActiveSpan() async read(path: string): Promise> { this.logger.debug(`Reading Vault KV secret at ${path}`) - return await this.getKvData(this.config.vaultKvName, path) + return await this.getKvData(this.vaultConfig.kvName, path) } @StartActiveSpan() async write(data: T, path: string): Promise { this.logger.debug(`Writing Vault KV secret at ${path}`) - await this.upsertKvData(this.config.vaultKvName, path, { data }) + await this.upsertKvData(this.vaultConfig.kvName, path, { data }) } @StartActiveSpan() @@ -158,18 +161,16 @@ export class VaultClientService { this.logger.debug(`Deleting Vault KV secret at ${path}`) const span = trace.getActiveSpan() span?.setAttribute('vault.kv.path', path) - return await this.deleteKvMetadata(this.config.vaultKvName, path) + return await this.deleteKvMetadata(this.vaultConfig.kvName, path) } @StartActiveSpan() async readGitlabMirrorCreds(projectSlug: string, repoName: string): Promise { - const vaultCredsPath = generateGitlabMirrorCredPath(this.config.projectRootDir, projectSlug, repoName) + const vaultCredsPath = generateGitlabMirrorCredPath(this.baseConfig.projectsRootDir, projectSlug, repoName) const span = trace.getActiveSpan() - span?.setAttributes({ - 'project.slug': projectSlug, - 'repo.name': repoName, - 'vault.kv.path': vaultCredsPath, - }) + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('repo.name', repoName) + span?.setAttribute('vault.kv.path', vaultCredsPath) this.logger.verbose(`Reading Vault GitLab mirror credentials (projectSlug=${projectSlug}, repoName=${repoName})`) return await this.read(vaultCredsPath).catch((error) => { if (error instanceof VaultError && error.kind === 'NotFound') return null @@ -179,26 +180,22 @@ export class VaultClientService { @StartActiveSpan() async writeGitlabMirrorCreds(projectSlug: string, repoName: string, data: Record): Promise { - const vaultCredsPath = generateGitlabMirrorCredPath(this.config.projectRootDir, projectSlug, repoName) + const vaultCredsPath = generateGitlabMirrorCredPath(this.baseConfig.projectsRootDir, projectSlug, repoName) const span = trace.getActiveSpan() - span?.setAttributes({ - 'project.slug': projectSlug, - 'repo.name': repoName, - 'vault.kv.path': vaultCredsPath, - }) + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('repo.name', repoName) + span?.setAttribute('vault.kv.path', vaultCredsPath) this.logger.verbose(`Writing Vault GitLab mirror credentials (projectSlug=${projectSlug}, repoName=${repoName})`) await this.write(data, vaultCredsPath) } @StartActiveSpan() async deleteGitlabMirrorCreds(projectSlug: string, repoName: string): Promise { - const vaultCredsPath = generateGitlabMirrorCredPath(this.config.projectRootDir, projectSlug, repoName) + const vaultCredsPath = generateGitlabMirrorCredPath(this.baseConfig.projectsRootDir, projectSlug, repoName) const span = trace.getActiveSpan() - span?.setAttributes({ - 'project.slug': projectSlug, - 'repo.name': repoName, - 'vault.kv.path': vaultCredsPath, - }) + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('repo.name', repoName) + span?.setAttribute('vault.kv.path', vaultCredsPath) this.logger.verbose(`Deleting Vault GitLab mirror credentials (projectSlug=${projectSlug}, repoName=${repoName})`) await this.delete(vaultCredsPath).catch((error) => { if (error instanceof VaultError && error.kind === 'NotFound') return @@ -208,12 +205,10 @@ export class VaultClientService { @StartActiveSpan() async readTechnReadOnlyCreds(projectSlug: string): Promise { - const vaultPath = generateTechReadOnlyCredPath(this.config.projectRootDir, projectSlug) + const vaultPath = generateTechReadOnlyCredPath(this.baseConfig.projectsRootDir, projectSlug) const span = trace.getActiveSpan() - span?.setAttributes({ - 'project.slug': projectSlug, - 'vault.kv.path': vaultPath, - }) + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) return await this.read(vaultPath).catch((error) => { if (error instanceof VaultError && error.kind === 'NotFound') return null throw error @@ -222,23 +217,19 @@ export class VaultClientService { @StartActiveSpan() async writeTechReadOnlyCreds(projectSlug: string, creds: Record): Promise { - const vaultPath = generateTechReadOnlyCredPath(this.config.projectRootDir, projectSlug) + const vaultPath = generateTechReadOnlyCredPath(this.baseConfig.projectsRootDir, projectSlug) const span = trace.getActiveSpan() - span?.setAttributes({ - 'project.slug': projectSlug, - 'vault.kv.path': vaultPath, - }) + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) await this.write(creds, vaultPath) } @StartActiveSpan() async readSonarqubeUser(projectSlug: string): Promise | null> { - const vaultPath = generateSonarqubeCredPath(this.config.projectRootDir, projectSlug) + const vaultPath = generateSonarqubeCredPath(this.baseConfig.projectsRootDir, projectSlug) const span = trace.getActiveSpan() - span?.setAttributes({ - 'project.slug': projectSlug, - 'vault.kv.path': vaultPath, - }) + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) this.logger.verbose(`Reading Vault SonarQube user credentials (projectSlug=${projectSlug})`) return await this.read(vaultPath).catch((error) => { if (error instanceof VaultError && error.kind === 'NotFound') return null @@ -248,24 +239,20 @@ export class VaultClientService { @StartActiveSpan() async writeSonarqubeUser(projectSlug: string, secret: SonarqubeUserSecret): Promise { - const vaultPath = generateSonarqubeCredPath(this.config.projectRootDir, projectSlug) + const vaultPath = generateSonarqubeCredPath(this.baseConfig.projectsRootDir, projectSlug) const span = trace.getActiveSpan() - span?.setAttributes({ - 'project.slug': projectSlug, - 'vault.kv.path': vaultPath, - }) + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) this.logger.verbose(`Writing Vault SonarQube user credentials (projectSlug=${projectSlug})`) await this.write(secret, vaultPath) } @StartActiveSpan() async deleteSonarqubeUser(projectSlug: string): Promise { - const vaultPath = generateSonarqubeCredPath(this.config.projectRootDir, projectSlug) + const vaultPath = generateSonarqubeCredPath(this.baseConfig.projectsRootDir, projectSlug) const span = trace.getActiveSpan() - span?.setAttributes({ - 'project.slug': projectSlug, - 'vault.kv.path': vaultPath, - }) + span?.setAttribute('project.slug', projectSlug) + span?.setAttribute('vault.kv.path', vaultPath) this.logger.verbose(`Deleting Vault SonarQube user credentials (projectSlug=${projectSlug})`) await this.delete(vaultPath).catch((error) => { if (error instanceof VaultError && error.kind === 'NotFound') return diff --git a/apps/server-nestjs/src/modules/vault/vault-health.service.ts b/apps/server-nestjs/src/modules/vault/vault-health.service.ts index ddfbc817db..fcd272987a 100644 --- a/apps/server-nestjs/src/modules/vault/vault-health.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault-health.service.ts @@ -1,22 +1,20 @@ +import type { ConfigType } from '@nestjs/config' import { HttpStatus, Inject, Injectable } from '@nestjs/common' import { HealthIndicatorService } from '@nestjs/terminus' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { vaultConfigFactory } from '../../config/vault.config' import { PLUGIN_NAME } from './vault.constants' @Injectable() export class VaultHealthService { constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(vaultConfigFactory.KEY) private readonly vaultConfig: ConfigType, @Inject(HealthIndicatorService) private readonly healthIndicator: HealthIndicatorService, ) {} async check() { const indicator = this.healthIndicator.check(PLUGIN_NAME) - const urlBase = this.config.getInternalOrPublicVaultUrl() - if (!urlBase) return indicator.down('Not configured') - - const url = new URL('/v1/sys/health', urlBase).toString() try { + const url = new URL('/v1/sys/health', this.vaultConfig.internalUrl ?? this.vaultConfig.url).toString() const response = await fetch(url) if (response.status < HttpStatus.INTERNAL_SERVER_ERROR) return indicator.up({ httpStatus: response.status }) return indicator.down({ httpStatus: response.status }) diff --git a/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts b/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts index 6cba06e0b5..25d4d43af5 100644 --- a/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault-http-client.service.ts @@ -1,7 +1,8 @@ +import type { ConfigType } from '@nestjs/config' import { HttpStatus, Inject, Injectable, Logger } from '@nestjs/common' import { trace } from '@opentelemetry/api' import z from 'zod' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { vaultConfigFactory } from '../../config/vault.config' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' export interface VaultFetchOptions { @@ -46,7 +47,7 @@ export class VaultHttpClientService { private readonly logger = new Logger(VaultHttpClientService.name) constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, + @Inject(vaultConfigFactory.KEY) private readonly vaultConfig: ConfigType, ) {} @StartActiveSpan() @@ -82,31 +83,15 @@ export class VaultHttpClientService { return parsed } - private get baseUrl() { - const baseUrl = this.config.getInternalOrPublicVaultUrl() - if (!baseUrl) { - throw new VaultError('NotConfigured', 'VAULT_INTERNAL_URL or VAULT_URL is required') - } - return baseUrl - } - private get apiBaseUrl() { - return new URL('v1/', this.baseUrl).toString() - } - - private get token() { - if (!this.config.vaultToken) { - this.logger.warn('Vault token is not configured (VAULT_TOKEN is missing)') - throw new VaultError('NotConfigured', 'VAULT_TOKEN is required') - } - return this.config.vaultToken + return new URL('v1/', this.vaultConfig.internalUrl ?? this.vaultConfig.url).toString() } private createRequest(path: string, method: string, body?: unknown): Request { const url = new URL(path, this.apiBaseUrl).toString() const headers: Record = { 'Content-Type': 'application/json', - 'X-Vault-Token': this.token, + 'X-Vault-Token': this.vaultConfig.token, } return new Request(url, { method, headers, body: body === undefined ? undefined : JSON.stringify(body) }) diff --git a/apps/server-nestjs/src/modules/vault/vault-plugin.service.spec.ts b/apps/server-nestjs/src/modules/vault/vault-plugin.service.spec.ts index 1d482d0a66..d6e536629c 100644 --- a/apps/server-nestjs/src/modules/vault/vault-plugin.service.spec.ts +++ b/apps/server-nestjs/src/modules/vault/vault-plugin.service.spec.ts @@ -1,25 +1,26 @@ +import type { ConfigType } from '@nestjs/config' import type { DeepMockProxy } from 'vitest-mock-extended' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { vaultConfigFactory } from '../../config/vault.config' import { makeToUrlParams } from '../plugin/plugin.utils' import { VaultPluginService } from './vault-plugin.service' describe('vaultPluginService', () => { let service: VaultPluginService - let config: DeepMockProxy + let config: DeepMockProxy> beforeEach(async () => { - config = mockDeep({ - vaultUrl: 'https://vault.public/', - vaultInternalUrl: 'https://vault.internal/', + config = mockDeep>({ + url: 'https://vault.public/', + internalUrl: 'https://vault.internal/', }) const moduleRef = await Test.createTestingModule({ providers: [ VaultPluginService, - { provide: ConfigurationService, useValue: config }, + { provide: vaultConfigFactory.KEY, useValue: config }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts b/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts index 838747d885..b6fabaab9e 100644 --- a/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault-plugin.service.ts @@ -1,21 +1,18 @@ import type { ServiceInfos } from '@cpn-console/hooks' +import type { ConfigType } from '@nestjs/config' import { Inject, Injectable } from '@nestjs/common' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { vaultConfigFactory } from '../../config/vault.config' @Injectable() export class VaultPluginService { constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(vaultConfigFactory.KEY) private readonly vaultConfig: ConfigType, ) {} infos(): ServiceInfos { return { name: 'vault', - to: ({ project }) => { - if (!this.config.vaultUrl) return undefined - return new URL(`ui/vault/secrets/${project.slug}`, this.config.vaultUrl).toString() - }, + to: ({ project }) => new URL(`ui/vault/secrets/${project.slug}`, this.vaultConfig.url).toString(), title: 'Vault', imgSrc: '/img/vault.svg', description: 'Vault s\'intègre profondément avec les identités de confiance pour automatiser l\'accès aux secrets, aux données et aux systèmes', diff --git a/apps/server-nestjs/src/modules/vault/vault.module.ts b/apps/server-nestjs/src/modules/vault/vault.module.ts index 0b1d8281ea..4c7a28ca46 100644 --- a/apps/server-nestjs/src/modules/vault/vault.module.ts +++ b/apps/server-nestjs/src/modules/vault/vault.module.ts @@ -1,6 +1,8 @@ import { Module } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { TerminusModule } from '@nestjs/terminus' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' import { DatabaseModule } from '../infrastructure/database/database.module' import { VaultClientService } from './vault-client.service' import { VaultDatastoreService } from './vault-datastore.service' @@ -10,7 +12,7 @@ import { VaultPluginService } from './vault-plugin.service' import { VaultService } from './vault.service' @Module({ - imports: [ConfigurationModule, DatabaseModule, TerminusModule], + imports: [DatabaseModule, TerminusModule, ConfigModule.forFeature(vaultConfigFactory), ConfigModule.forFeature(baseConfigFactory)], providers: [ VaultHealthService, VaultHttpClientService, diff --git a/apps/server-nestjs/src/modules/vault/vault.service.spec.ts b/apps/server-nestjs/src/modules/vault/vault.service.spec.ts index 4a61c8f3c2..1d85f13400 100644 --- a/apps/server-nestjs/src/modules/vault/vault.service.spec.ts +++ b/apps/server-nestjs/src/modules/vault/vault.service.spec.ts @@ -3,7 +3,8 @@ import { faker } from '@faker-js/faker' import { Test } from '@nestjs/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' import { VaultClientService } from './vault-client.service' import { VaultDatastoreService } from './vault-datastore.service' import { makeProjectWithDetails, makeVaultSecret, makeZoneWithDetails } from './vault-testing.utils' @@ -15,7 +16,6 @@ describe('vaultService', () => { let service: VaultService let datastore: DeepMockProxy let client: DeepMockProxy - let config: DeepMockProxy beforeEach(async () => { datastore = mockDeep({ @@ -36,17 +36,14 @@ describe('vaultService', () => { listKvMetadata: vi.fn().mockResolvedValue([]), delete: vi.fn().mockResolvedValue(undefined), }) - config = mockDeep({ - projectRootDir: 'forge', - vaultKvName: 'kv', - }) const module = await Test.createTestingModule({ providers: [ VaultService, { provide: VaultClientService, useValue: client }, { provide: VaultDatastoreService, useValue: datastore }, - { provide: ConfigurationService, useValue: config }, + { provide: vaultConfigFactory.KEY, useValue: vaultConfigFactory }, + { provide: baseConfigFactory.KEY, useValue: baseConfigFactory }, ], }).compile() diff --git a/apps/server-nestjs/src/modules/vault/vault.service.ts b/apps/server-nestjs/src/modules/vault/vault.service.ts index 486c97cbd3..9818318756 100644 --- a/apps/server-nestjs/src/modules/vault/vault.service.ts +++ b/apps/server-nestjs/src/modules/vault/vault.service.ts @@ -1,9 +1,11 @@ +import type { ConfigType } from '@nestjs/config' import type { RequiredPluginResult } from '../plugin/plugin.utils' import type { ProjectWithDetails, ZoneWithDetails } from './vault-datastore.service' import { Inject, Injectable, Logger } from '@nestjs/common' import { OnEvent } from '@nestjs/event-emitter' import { trace } from '@opentelemetry/api' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { baseConfigFactory } from '../../config/base.config' +import { vaultConfigFactory } from '../../config/vault.config' import { StartActiveSpan } from '../infrastructure/telemetry/telemetry.decorator' import { capturePluginResult } from '../plugin/plugin.utils' import { VaultClientService } from './vault-client.service' @@ -43,8 +45,9 @@ export class VaultService { private readonly logger = new Logger(VaultService.name) constructor( - @Inject(ConfigurationService) private readonly config: ConfigurationService, - @Inject(VaultDatastoreService) private readonly vaultDatastore: VaultDatastoreService, + @Inject(vaultConfigFactory.KEY) private readonly vaultConfig: ConfigType, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, + @Inject(VaultDatastoreService) private readonly datastore: VaultDatastoreService, @Inject(VaultClientService) private readonly client: VaultClientService, ) { this.logger.log('VaultService initialized') @@ -115,8 +118,8 @@ export class VaultService { const span = trace.getActiveSpan() this.logger.log('Starting Vault reconciliation') const [projects, zones] = await Promise.all([ - this.vaultDatastore.getAllProjects(), - this.vaultDatastore.getAllZones(), + this.datastore.getAllProjects(), + this.datastore.getAllZones(), ]) span?.setAttributes({ @@ -148,7 +151,7 @@ export class VaultService { } private async getAdminOrProjectPluginConfig(project: ProjectWithDetails, key: string): Promise { - const adminPluginConfig = await this.vaultDatastore.getAdminPluginConfig(PLUGIN_NAME, key) + const adminPluginConfig = await this.datastore.getAdminPluginConfig(PLUGIN_NAME, key) if (adminPluginConfig) return adminPluginConfig return project.plugins?.find(p => p.pluginName === PLUGIN_NAME && p.key === key)?.value } @@ -491,13 +494,13 @@ export class VaultService { async ensureTechReadOnlyPolicy(name: string, projectSlug: string): Promise { await this.client.upsertSysPoliciesAcl(name, { - policy: `path "${this.config.vaultKvName}/data/${projectSlug}/REGISTRY/ro-robot" { capabilities = ["read"] }`, + policy: `path "${this.vaultConfig.kvName}/data/${projectSlug}/REGISTRY/ro-robot" { capabilities = ["read"] }`, }) } async listProjectSecrets(projectSlug: string): Promise { - const projectPath = generateProjectPath(this.config.projectRootDir, projectSlug) - return this.listRecursive(this.config.vaultKvName, projectPath, '') + const projectPath = generateProjectPath(this.baseConfig.projectsRootDir, projectSlug) + return this.listRecursive(this.vaultConfig.kvName, projectPath, '') } @StartActiveSpan() @@ -505,12 +508,12 @@ export class VaultService { const span = trace.getActiveSpan() span?.setAttributes({ 'project.slug': projectSlug, - 'vault.kv.name': this.config.vaultKvName, + 'vault.kv.name': this.vaultConfig.kvName, }) const secrets = await this.listProjectSecrets(projectSlug) span?.setAttribute('vault.secrets.count', secrets.length) - const projectPath = generateProjectPath(this.config.projectRootDir, projectSlug) + const projectPath = generateProjectPath(this.baseConfig.projectsRootDir, projectSlug) await Promise.allSettled(secrets.map(async (relativePath) => { const fullPath = `${projectPath}/${relativePath}` try { diff --git a/apps/server-nestjs/src/modules/vault/vault.utils.ts b/apps/server-nestjs/src/modules/vault/vault.utils.ts index 576da08e24..6e170bede2 100644 --- a/apps/server-nestjs/src/modules/vault/vault.utils.ts +++ b/apps/server-nestjs/src/modules/vault/vault.utils.ts @@ -1,23 +1,15 @@ -export function generateProjectPath(projectRootDir: string | undefined, projectSlug: string) { - return projectRootDir - ? `${projectRootDir}/${projectSlug}` - : projectSlug +export function generateProjectPath(projectRootDir: string, projectSlug: string) { + return `${projectRootDir}/${projectSlug}` } -export function generateGitlabMirrorCredPath(projectRootDir: string | undefined, projectSlug: string, repoName: string) { - return projectRootDir - ? `${generateProjectPath(projectRootDir, projectSlug)}/${repoName}-mirror` - : `${projectSlug}/${repoName}-mirror` +export function generateGitlabMirrorCredPath(projectRootDir: string, projectSlug: string, repoName: string) { + return `${generateProjectPath(projectRootDir, projectSlug)}/${repoName}-mirror` } -export function generateTechReadOnlyCredPath(projectRootDir: string | undefined, projectSlug: string) { - return projectRootDir - ? `${generateProjectPath(projectRootDir, projectSlug)}/tech/GITLAB_MIRROR` - : `${projectSlug}/tech/GITLAB_MIRROR` +export function generateTechReadOnlyCredPath(projectRootDir: string, projectSlug: string) { + return `${generateProjectPath(projectRootDir, projectSlug)}/tech/GITLAB_MIRROR` } -export function generateSonarqubeCredPath(projectRootDir: string | undefined, projectSlug: string) { - return projectRootDir - ? `${generateProjectPath(projectRootDir, projectSlug)}/SONAR` - : `${projectSlug}/SONAR` +export function generateSonarqubeCredPath(projectRootDir: string, projectSlug: string) { + return `${generateProjectPath(projectRootDir, projectSlug)}/SONAR` } diff --git a/apps/server-nestjs/src/modules/version/version.controller.ts b/apps/server-nestjs/src/modules/version/version.controller.ts index 16ce328f70..8510fa1497 100644 --- a/apps/server-nestjs/src/modules/version/version.controller.ts +++ b/apps/server-nestjs/src/modules/version/version.controller.ts @@ -1,15 +1,15 @@ +import type { ConfigType } from '@nestjs/config' import { Controller, Get, Inject } from '@nestjs/common' -import { ConfigurationService } from '../infrastructure/configuration/configuration.service' +import { baseConfigFactory } from '../../config/base.config' @Controller('api/v1/version') export class VersionController { constructor( - @Inject(ConfigurationService) - private readonly config: ConfigurationService, + @Inject(baseConfigFactory.KEY) private readonly baseConfig: ConfigType, ) {} @Get() getVersion() { - return { version: this.config.appVersion } + return { version: this.baseConfig.appVersion } } } diff --git a/apps/server-nestjs/src/modules/version/version.module.ts b/apps/server-nestjs/src/modules/version/version.module.ts index cdf9b30c39..7f0380a81c 100644 --- a/apps/server-nestjs/src/modules/version/version.module.ts +++ b/apps/server-nestjs/src/modules/version/version.module.ts @@ -1,9 +1,10 @@ import { Module } from '@nestjs/common' -import { ConfigurationModule } from '../infrastructure/configuration/configuration.module' +import { ConfigModule } from '@nestjs/config' +import { baseConfigFactory } from '../../config/base.config' import { VersionController } from './version.controller' @Module({ - imports: [ConfigurationModule], + imports: [ConfigModule.forFeature(baseConfigFactory)], controllers: [VersionController], }) export class VersionModule {} diff --git a/apps/server-nestjs/src/utils/crypto.spec.ts b/apps/server-nestjs/src/utils/crypto.spec.ts index 19bad4e44c..090c58583a 100644 --- a/apps/server-nestjs/src/utils/crypto.spec.ts +++ b/apps/server-nestjs/src/utils/crypto.spec.ts @@ -1,6 +1,6 @@ import { generateProjectKey as legacyGenerateProjectKey } from '@cpn-console/hooks' import { describe, expect, it } from 'vitest' -import { generateProjectKey } from './crypto' +import { generateProjectKey } from './crypto.utils' describe('generateProjectKey', () => { it('matches the legacy @cpn-console/hooks implementation byte-for-byte', () => { diff --git a/apps/server-nestjs/src/utils/crypto.ts b/apps/server-nestjs/src/utils/crypto.utils.ts similarity index 100% rename from apps/server-nestjs/src/utils/crypto.ts rename to apps/server-nestjs/src/utils/crypto.utils.ts diff --git a/apps/server-nestjs/src/utils/dotenv.utils.spec.ts b/apps/server-nestjs/src/utils/dotenv.utils.spec.ts new file mode 100644 index 0000000000..b5c7014c46 --- /dev/null +++ b/apps/server-nestjs/src/utils/dotenv.utils.spec.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDotenvPaths, getExistingDotenvPaths } from './dotenv.utils' + +describe('dotenv.utils', () => { + afterEach(() => vi.unstubAllEnvs()) + + describe('getDotenvPaths', () => { + it('returns [".env"] when no env vars set', () => { + expect(getDotenvPaths()).toEqual(['.env']) + }) + + it('returns [".env", ".env.integ"] when INTEGRATION=true', () => { + vi.stubEnv('INTEGRATION', 'true') + expect(getDotenvPaths()).toEqual(['.env', '.env.integ']) + }) + + it('returns [".env.docker"] when DOCKER=true', () => { + vi.stubEnv('DOCKER', 'true') + expect(getDotenvPaths()).toEqual(['.env.docker']) + }) + + it('returns [".env.integ", ".env.docker"] when DOCKER=true and INTEGRATION=true', () => { + vi.stubEnv('DOCKER', 'true') + vi.stubEnv('INTEGRATION', 'true') + expect(getDotenvPaths()).toEqual(['.env.integ', '.env.docker']) + }) + }) + + describe('getExistingDotenvPaths', () => { + it('filters to only existing files', () => { + const paths = getExistingDotenvPaths() + expect(paths.every(p => typeof p === 'string')).toBe(true) + }) + }) +}) diff --git a/apps/server-nestjs/src/utils/dotenv.utils.ts b/apps/server-nestjs/src/utils/dotenv.utils.ts new file mode 100644 index 0000000000..7d3e0a0501 --- /dev/null +++ b/apps/server-nestjs/src/utils/dotenv.utils.ts @@ -0,0 +1,26 @@ +import fs from 'node:fs' + +export function getDotenvPaths(): string[] { + const paths: string[] = [] + + // Load .env unless DOCKER=true + if (process.env.DOCKER !== 'true') { + paths.push('.env') + } + + // Load .env.integ if INTEGRATION=true + if (process.env.INTEGRATION === 'true') { + paths.push('.env.integ') + } + + // Load .env.docker if DOCKER=true + if (process.env.DOCKER === 'true') { + paths.push('.env.docker') + } + + return paths +} + +export function getExistingDotenvPaths(): string[] { + return getDotenvPaths().filter(path => fs.existsSync(path)) +} diff --git a/apps/server-nestjs/src/utils/http-error.ts b/apps/server-nestjs/src/utils/http.utils.ts similarity index 100% rename from apps/server-nestjs/src/utils/http-error.ts rename to apps/server-nestjs/src/utils/http.utils.ts diff --git a/apps/server-nestjs/src/utils/iterable.ts b/apps/server-nestjs/src/utils/iterable.utils.ts similarity index 100% rename from apps/server-nestjs/src/utils/iterable.ts rename to apps/server-nestjs/src/utils/iterable.utils.ts diff --git a/apps/server-nestjs/test/argocd.e2e-spec.ts b/apps/server-nestjs/test/argocd.e2e-spec.ts index 381412c492..67f42a78f6 100644 --- a/apps/server-nestjs/test/argocd.e2e-spec.ts +++ b/apps/server-nestjs/test/argocd.e2e-spec.ts @@ -1,31 +1,27 @@ import type { CommitAction, Gitlab } from '@gitbeaker/core' +import type { ConfigType } from '@nestjs/config' import type { TestingModule } from '@nestjs/testing' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { parse } from 'yaml' +import { baseConfigFactory } from '../src/config/base.config' import { projectSelect } from '../src/modules/argocd/argocd-datastore.service' import { ArgoCDModule } from '../src/modules/argocd/argocd.module' import { ArgoCDService } from '../src/modules/argocd/argocd.service' import { GITLAB_REST_CLIENT, GitlabClientService } from '../src/modules/gitlab/gitlab-client.service' import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' -import { ConfigurationService } from '../src/modules/infrastructure/configuration/configuration.service' import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { EventsModule } from '../src/modules/infrastructure/events/events.module' import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunArgoCDE2E = Boolean(process.env.E2E) - && Boolean(process.env.GITLAB_URL) - && Boolean(process.env.GITLAB_TOKEN) - && Boolean(process.env.VAULT_URL) - && Boolean(process.env.VAULT_TOKEN) - && Boolean(process.env.PROJECTS_ROOT_DIR) - && Boolean(process.env.DB_URL) const describeWithArgoCD = describe.runIf(canRunArgoCDE2E) @@ -36,7 +32,7 @@ describeWithArgoCD('ArgoCDController (e2e)', {}, () => { let gitlabClient: Gitlab let vault: VaultClientService let prisma: PrismaService - let config: ConfigurationService + let config: ConfigType let ownerId: string let testProjectId: string @@ -59,7 +55,7 @@ describeWithArgoCD('ArgoCDController (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ArgoCDModule, ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + imports: [ArgoCDModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], }).compile() await moduleRef.init() @@ -69,7 +65,7 @@ describeWithArgoCD('ArgoCDController (e2e)', {}, () => { gitlabClient = moduleRef.get(GITLAB_REST_CLIENT) vault = moduleRef.get(VaultClientService) prisma = moduleRef.get(PrismaService) - config = moduleRef.get(ConfigurationService) + config = moduleRef.get(baseConfigFactory.KEY) ownerId = faker.string.uuid() testProjectId = faker.string.uuid() @@ -186,7 +182,7 @@ describeWithArgoCD('ArgoCDController (e2e)', {}, () => { }, }) - infraRepoPath = `${config.projectRootDir}/infra/${zoneSlug}` + infraRepoPath = `${config.projectsRootDir}/infra/${zoneSlug}` try { const existing = await gitlabClient.Projects.show(infraRepoPath) if (existing.empty_repo || existing.default_branch !== 'main') { @@ -214,7 +210,7 @@ describeWithArgoCD('ArgoCDController (e2e)', {}, () => { infraRepoId = created.id } - vaultProjectValuesPath = `${config.projectRootDir}/${testProjectId}` + vaultProjectValuesPath = `${config.projectsRootDir}/${testProjectId}` await vault.write({ e2e: true }, vaultProjectValuesPath) }) diff --git a/apps/server-nestjs/test/gitlab.e2e-spec.ts b/apps/server-nestjs/test/gitlab.e2e-spec.ts index 0d65d2fcf3..32f51abf3d 100644 --- a/apps/server-nestjs/test/gitlab.e2e-spec.ts +++ b/apps/server-nestjs/test/gitlab.e2e-spec.ts @@ -1,31 +1,27 @@ import type { ExpandedUserSchema, Gitlab } from '@gitbeaker/core' +import type { ConfigType } from '@nestjs/config' import type { TestingModule } from '@nestjs/testing' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import z from 'zod' +import { baseConfigFactory } from '../src/config/base.config' import { GITLAB_REST_CLIENT, GitlabClientService } from '../src/modules/gitlab/gitlab-client.service' import { projectSelect } from '../src/modules/gitlab/gitlab-datastore.service' import { GitlabModule } from '../src/modules/gitlab/gitlab.module' import { GitlabService } from '../src/modules/gitlab/gitlab.service' import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' -import { ConfigurationService } from '../src/modules/infrastructure/configuration/configuration.service' import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { EventsModule } from '../src/modules/infrastructure/events/events.module' import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module' import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { VaultClientService } from '../src/modules/vault/vault-client.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunGitlabE2E = Boolean(process.env.E2E) - && Boolean(process.env.GITLAB_URL) - && Boolean(process.env.GITLAB_TOKEN) - && Boolean(process.env.VAULT_URL) - && Boolean(process.env.VAULT_TOKEN) - && Boolean(process.env.PROJECTS_ROOT_DIR) - && Boolean(process.env.DB_URL) const describeWithGitLab = describe.runIf(canRunGitlabE2E) @@ -36,7 +32,7 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { let gitlabClient: Gitlab let vaultService: VaultClientService let prisma: PrismaService - let config: ConfigurationService + let config: ConfigType let testProjectId: string let testProjectSlug: string @@ -45,7 +41,7 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [GitlabModule, ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + imports: [GitlabModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], }).compile() await moduleRef.init() @@ -55,7 +51,7 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { gitlabClient = moduleRef.get(GITLAB_REST_CLIENT) vaultService = moduleRef.get(VaultClientService) prisma = moduleRef.get(PrismaService) - config = moduleRef.get(ConfigurationService) + config = moduleRef.get(baseConfigFactory.KEY) ownerId = faker.string.uuid() testProjectId = faker.string.uuid() @@ -111,8 +107,8 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { afterAll(async () => { // Clean GitLab group - if (testProjectSlug && config.projectRootDir) { - const fullPath = `${config.projectRootDir}/${testProjectSlug}` + if (testProjectSlug && config.projectsRootDir) { + const fullPath = `${config.projectsRootDir}/${testProjectSlug}` const group = await gitlabService.getGroupByPath(fullPath) if (group) { await gitlabService.deleteGroup(group).catch(() => {}) @@ -120,8 +116,8 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { } // Clean Vault - if (testProjectSlug && config.projectRootDir) { - const vaultPath = `${config.projectRootDir}/${testProjectSlug}` + if (testProjectSlug && config.projectsRootDir) { + const vaultPath = `${config.projectsRootDir}/${testProjectSlug}` await vaultService.delete(`${vaultPath}/tech/GITLAB_MIRROR`).catch(() => {}) await vaultService.delete(`${vaultPath}/app-mirror`).catch(() => {}) } @@ -153,7 +149,7 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { await gitlabController.handleUpsert(project) // Assert - const groupPath = `${config.projectRootDir}/${testProjectSlug}` + const groupPath = `${config.projectsRootDir}/${testProjectSlug}` const group = z.object({ id: z.number(), name: z.string(), @@ -167,7 +163,7 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { const isMember = members.some(m => m.id === ownerUser.id) expect(isMember).toBe(true) - const repoVaultPath = `${config.projectRootDir}/${testProjectSlug}/app-mirror` + const repoVaultPath = `${config.projectsRootDir}/${testProjectSlug}/app-mirror` const repoSecret = await vaultService.read(repoVaultPath) expect(repoSecret?.data?.GIT_OUTPUT_USER).toBeTruthy() expect(repoSecret?.data?.GIT_OUTPUT_PASSWORD).toBeTruthy() @@ -225,7 +221,7 @@ describeWithGitLab('GitlabController (e2e)', {}, () => { await gitlabController.handleUpsert(project) - const groupPath = `${config.projectRootDir}/${testProjectSlug}` + const groupPath = `${config.projectsRootDir}/${testProjectSlug}` const group = z.object({ id: z.number(), name: z.string(), diff --git a/apps/server-nestjs/test/keycloak.e2e-spec.ts b/apps/server-nestjs/test/keycloak.e2e-spec.ts index b1bbcc452b..b6efcd4e42 100644 --- a/apps/server-nestjs/test/keycloak.e2e-spec.ts +++ b/apps/server-nestjs/test/keycloak.e2e-spec.ts @@ -2,11 +2,12 @@ import type KcAdminClient from '@keycloak/keycloak-admin-client' import type { TestingModule } from '@nestjs/testing' import { faker } from '@faker-js/faker' import { Logger } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import z from 'zod' +import { baseConfigFactory } from '../src/config/base.config' import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -16,14 +17,10 @@ import { KEYCLOAK_ADMIN_CLIENT, KeycloakClientService } from '../src/modules/key import { projectSelect } from '../src/modules/keycloak/keycloak-datastore.service' import { KeycloakModule } from '../src/modules/keycloak/keycloak.module' import { KeycloakService } from '../src/modules/keycloak/keycloak.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunKeycloakE2E = Boolean(process.env.E2E) - && Boolean(process.env.KEYCLOAK_DOMAIN) - && Boolean(process.env.KEYCLOAK_REALM) - && Boolean(process.env.KEYCLOAK_PROTOCOL) - && Boolean(process.env.KEYCLOAK_ADMIN) - && Boolean(process.env.KEYCLOAK_ADMIN_PASSWORD) const describeWithKeycloak = describe.runIf(canRunKeycloakE2E) @@ -42,7 +39,7 @@ describeWithKeycloak('KeycloakController (e2e)', () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [KeycloakModule, ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + imports: [KeycloakModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], }).compile() await moduleRef.init() diff --git a/apps/server-nestjs/test/log.e2e-spec.ts b/apps/server-nestjs/test/log.e2e-spec.ts index 2b5f509b3f..72214c56aa 100644 --- a/apps/server-nestjs/test/log.e2e-spec.ts +++ b/apps/server-nestjs/test/log.e2e-spec.ts @@ -13,7 +13,6 @@ import { LogService } from '../src/modules/log/log.service' const canRunLogE2E = Boolean(process.env.E2E) - && Boolean(process.env.DB_URL) const describeWithLog = describe.runIf(canRunLogE2E) diff --git a/apps/server-nestjs/test/nexus.e2e-spec.ts b/apps/server-nestjs/test/nexus.e2e-spec.ts index 6a0970e48c..806c34aa0b 100644 --- a/apps/server-nestjs/test/nexus.e2e-spec.ts +++ b/apps/server-nestjs/test/nexus.e2e-spec.ts @@ -1,11 +1,12 @@ +import type { ConfigType } from '@nestjs/config' import type { TestingModule } from '@nestjs/testing' import { ENABLED } from '@cpn-console/shared' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' -import { ConfigurationService } from '../src/modules/infrastructure/configuration/configuration.service' import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -20,15 +21,10 @@ import { NexusService } from '../src/modules/nexus/nexus.service' import { getProjectVaultPath } from '../src/modules/nexus/nexus.utils' import { VaultClientService } from '../src/modules/vault/vault-client.service' import { VaultModule } from '../src/modules/vault/vault.module' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunNexusE2E = Boolean(process.env.E2E) - && Boolean(process.env.NEXUS_URL) - && Boolean(process.env.NEXUS_ADMIN) - && Boolean(process.env.NEXUS_ADMIN_PASSWORD) - && Boolean(process.env.VAULT_URL) - && Boolean(process.env.VAULT_TOKEN) - && Boolean(process.env.DB_URL) const describeWithNexus = describe.runIf(canRunNexusE2E) @@ -37,7 +33,7 @@ describeWithNexus('NexusController (e2e)', () => { let nexusController: NexusService let nexusClient: NexusClientService let vaultService: VaultClientService - let config: ConfigurationService + let config: ConfigType let prisma: PrismaService let ownerId: string @@ -46,7 +42,7 @@ describeWithNexus('NexusController (e2e)', () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [NexusModule, VaultModule, ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + imports: [NexusModule, VaultModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], }).compile() await moduleRef.init() @@ -54,7 +50,7 @@ describeWithNexus('NexusController (e2e)', () => { nexusController = moduleRef.get(NexusService) nexusClient = moduleRef.get(NexusClientService) vaultService = moduleRef.get(VaultClientService) - config = moduleRef.get(ConfigurationService) + config = moduleRef.get(baseConfigFactory.KEY) prisma = moduleRef.get(PrismaService) ownerId = faker.string.uuid() @@ -146,7 +142,7 @@ describeWithNexus('NexusController (e2e)', () => { const users = await nexusClient.getSecurityUsers(testProjectSlug) expect(users.some(u => u.userId === testProjectSlug)).toBe(true) - const vaultPath = getProjectVaultPath(config.projectRootDir, testProjectSlug, 'tech/NEXUS') + const vaultPath = getProjectVaultPath(config.projectsRootDir, testProjectSlug, 'tech/NEXUS') const secret = await vaultService.read(vaultPath) expect(secret.data?.NEXUS_USERNAME).toBe(testProjectSlug) expect(secret.data?.NEXUS_PASSWORD).toBeTruthy() diff --git a/apps/server-nestjs/test/project-bulk.e2e-spec.ts b/apps/server-nestjs/test/project-bulk.e2e-spec.ts index 7c5b4cae72..3a1e80f39d 100644 --- a/apps/server-nestjs/test/project-bulk.e2e-spec.ts +++ b/apps/server-nestjs/test/project-bulk.e2e-spec.ts @@ -1,10 +1,11 @@ import type { TestingModule } from '@nestjs/testing' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { EventEmitter2 } from '@nestjs/event-emitter' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -12,6 +13,7 @@ import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { ProjectBulkModule } from '../src/modules/project-bulk/project-bulk.module' import { ProjectBulkService } from '../src/modules/project-bulk/project-bulk.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunProjectBulkE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) @@ -29,7 +31,7 @@ describeWithProjectBulk('ProjectBulkService (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectBulkModule], + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectBulkModule], }).compile() await moduleRef.init() diff --git a/apps/server-nestjs/test/project-hooks.e2e-spec.ts b/apps/server-nestjs/test/project-hooks.e2e-spec.ts index 546b51b2fe..ab6e75184e 100644 --- a/apps/server-nestjs/test/project-hooks.e2e-spec.ts +++ b/apps/server-nestjs/test/project-hooks.e2e-spec.ts @@ -1,12 +1,13 @@ import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { EventEmitter2 } from '@nestjs/event-emitter' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' +import { baseConfigFactory } from '../src/config/base.config' import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -16,6 +17,7 @@ import { ProjectHooksModule } from '../src/modules/project-hooks/project-hooks.m import { ProjectHooksService } from '../src/modules/project-hooks/project-hooks.service' import { VaultClientService } from '../src/modules/vault/vault-client.service' import { VaultService } from '../src/modules/vault/vault.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunProjectHooksE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) @@ -39,7 +41,7 @@ describeWithProjectHooks('ProjectHooksService (e2e)', {}, () => { vaultClient = mockDeep() moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectHooksModule], + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectHooksModule], providers: [ { provide: VaultService, useValue: vaultService }, { provide: VaultClientService, useValue: vaultClient }, diff --git a/apps/server-nestjs/test/project-members.e2e-spec.ts b/apps/server-nestjs/test/project-members.e2e-spec.ts index 0e1b16eb1b..10dec97860 100644 --- a/apps/server-nestjs/test/project-members.e2e-spec.ts +++ b/apps/server-nestjs/test/project-members.e2e-spec.ts @@ -2,12 +2,13 @@ import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' import { faker } from '@faker-js/faker' import { BadRequestException, NotFoundException } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { EventEmitter2 } from '@nestjs/event-emitter' import { Test } from '@nestjs/testing' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' import { mockDeep } from 'vitest-mock-extended' +import { baseConfigFactory } from '../src/config/base.config' import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -16,6 +17,7 @@ import { PermissionModule } from '../src/modules/infrastructure/permission/permi import { KeycloakClientService } from '../src/modules/keycloak/keycloak-client.service' import { ProjectMembersModule } from '../src/modules/project-members/project-members.module' import { ProjectMembersService } from '../src/modules/project-members/project-members.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunProjectMembersE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) @@ -36,7 +38,7 @@ describeWithProjectMembers('ProjectMembersService (e2e)', {}, () => { keycloakClient.getUserByEmail.mockResolvedValue(undefined) moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectMembersModule], + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectMembersModule], providers: [ { provide: KeycloakClientService, useValue: keycloakClient }, ], diff --git a/apps/server-nestjs/test/project-roles.e2e-spec.ts b/apps/server-nestjs/test/project-roles.e2e-spec.ts index 49d23d4c5a..0f530ebf53 100644 --- a/apps/server-nestjs/test/project-roles.e2e-spec.ts +++ b/apps/server-nestjs/test/project-roles.e2e-spec.ts @@ -1,11 +1,12 @@ import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { EventEmitter2 } from '@nestjs/event-emitter' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -13,6 +14,7 @@ import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { ProjectRolesModule } from '../src/modules/project-roles/project-roles.module' import { ProjectRolesService } from '../src/modules/project-roles/project-roles.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunProjectRolesE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) @@ -30,7 +32,7 @@ describeWithProjectRoles('ProjectRolesService (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectRolesModule], + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectRolesModule], }).compile() await moduleRef.init() diff --git a/apps/server-nestjs/test/project-secrets.e2e-spec.ts b/apps/server-nestjs/test/project-secrets.e2e-spec.ts index 9007d876f5..9eec3ec83f 100644 --- a/apps/server-nestjs/test/project-secrets.e2e-spec.ts +++ b/apps/server-nestjs/test/project-secrets.e2e-spec.ts @@ -1,9 +1,10 @@ import type { TestingModule } from '@nestjs/testing' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -13,13 +14,10 @@ import { ProjectSecretsModule } from '../src/modules/project-secrets/project-sec import { ProjectSecretsService } from '../src/modules/project-secrets/project-secrets.service' import { VaultClientService } from '../src/modules/vault/vault-client.service' import { generateProjectPath } from '../src/modules/vault/vault.utils' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunProjectSecretsE2E = Boolean(process.env.E2E) - && Boolean(process.env.DB_URL) - && Boolean(process.env.VAULT_URL) - && Boolean(process.env.VAULT_TOKEN) - && Boolean(process.env.PROJECTS_ROOT_DIR) const describeWithProjectSecrets = describe.runIf(canRunProjectSecretsE2E) @@ -38,7 +36,7 @@ describeWithProjectSecrets('ProjectSecretsService (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectSecretsModule], + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectSecretsModule], }).compile() await moduleRef.init() diff --git a/apps/server-nestjs/test/project-services.e2e-spec.ts b/apps/server-nestjs/test/project-services.e2e-spec.ts index 86535aee82..21a284ea91 100644 --- a/apps/server-nestjs/test/project-services.e2e-spec.ts +++ b/apps/server-nestjs/test/project-services.e2e-spec.ts @@ -1,10 +1,11 @@ import type { TestingModule } from '@nestjs/testing' import { faker } from '@faker-js/faker' import { NotFoundException } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -12,30 +13,10 @@ import { LoggerModule } from '../src/modules/infrastructure/logger/logger.module import { PermissionModule } from '../src/modules/infrastructure/permission/permission.module' import { ProjectServicesModule } from '../src/modules/project-services/project-services.module' import { ProjectServicesService } from '../src/modules/project-services/project-services.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunServicesE2E = Boolean(process.env.E2E) - && Boolean(process.env.DB_URL) - && Boolean(process.env.ARGOCD_URL) - && Boolean(process.env.GITLAB_URL) - && Boolean(process.env.GITLAB_TOKEN) - && Boolean(process.env.HARBOR_URL) - && Boolean(process.env.HARBOR_INTERNAL_URL) - && Boolean(process.env.HARBOR_ADMIN) - && Boolean(process.env.HARBOR_ADMIN_PASSWORD) - && Boolean(process.env.KEYCLOAK_DOMAIN) - && Boolean(process.env.KEYCLOAK_REALM) - && Boolean(process.env.KEYCLOAK_PROTOCOL) - && Boolean(process.env.NEXUS_URL) - && Boolean(process.env.NEXUS_INTERNAL_URL) - && Boolean(process.env.NEXUS_ADMIN) - && Boolean(process.env.NEXUS_ADMIN_PASSWORD) - && Boolean(process.env.SONARQUBE_URL) - && Boolean(process.env.SONARQUBE_INTERNAL_URL) - && Boolean(process.env.SONAR_API_TOKEN) - && Boolean(process.env.VAULT_URL) - && Boolean(process.env.VAULT_TOKEN) - && Boolean(process.env.PROJECTS_ROOT_DIR) const describeWithServices = describe.runIf(canRunServicesE2E) @@ -51,7 +32,7 @@ describeWithServices('ProjectServicesService (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectServicesModule], + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectServicesModule], }).compile() await moduleRef.init() diff --git a/apps/server-nestjs/test/project.e2e-spec.ts b/apps/server-nestjs/test/project.e2e-spec.ts index a799f372ef..0aa6b9011d 100644 --- a/apps/server-nestjs/test/project.e2e-spec.ts +++ b/apps/server-nestjs/test/project.e2e-spec.ts @@ -2,11 +2,12 @@ import type { TestingModule } from '@nestjs/testing' import type { DeepMockProxy } from 'vitest-mock-extended' import { faker } from '@faker-js/faker' import { ForbiddenException, NotFoundException } from '@nestjs/common' +import { ConfigModule } from '@nestjs/config' import { EventEmitter2 } from '@nestjs/event-emitter' import { Test } from '@nestjs/testing' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -15,6 +16,7 @@ import { PermissionModule } from '../src/modules/infrastructure/permission/permi import { ProjectPermissionModule } from '../src/modules/infrastructure/permission/project/project.module' import { makeCreateProjectBody } from '../src/modules/project/project-testing.utils' import { ProjectService } from '../src/modules/project/project.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunProjectE2E = Boolean(process.env.E2E) && Boolean(process.env.DB_URL) @@ -30,7 +32,7 @@ describeWithProject('ProjectService (e2e)', {}, () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectPermissionModule], + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule, ProjectPermissionModule], }).compile() await moduleRef.init() diff --git a/apps/server-nestjs/test/registry.e2e-spec.ts b/apps/server-nestjs/test/registry.e2e-spec.ts index 0e4d1946c4..d86e89d6c7 100644 --- a/apps/server-nestjs/test/registry.e2e-spec.ts +++ b/apps/server-nestjs/test/registry.e2e-spec.ts @@ -1,9 +1,11 @@ +import type { ConfigType } from '@nestjs/config' import type { TestingModule } from '@nestjs/testing' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' -import { ConfigurationService } from '../src/modules/infrastructure/configuration/configuration.service' +import { baseConfigFactory } from '../src/config/base.config' +import { harborConfigFactory } from '../src/config/harbor.config' import { RegistryClientService } from '../src/modules/registry/registry-client.service' import { makeProjectWithDetails } from '../src/modules/registry/registry-testing.utils' import { ROBOT_NAME_PROJECT, ROBOT_NAME_RO, ROBOT_NAME_RW } from '../src/modules/registry/registry.constants' @@ -11,16 +13,11 @@ import { RegistryModule } from '../src/modules/registry/registry.module' import { RegistryService } from '../src/modules/registry/registry.service' import { getHostFromUrl, getProjectVaultPath } from '../src/modules/registry/registry.utils' import { VaultClientService } from '../src/modules/vault/vault-client.service' -import { getAll } from '../src/utils/iterable' +import { getDotenvPaths } from '../src/utils/dotenv.utils' +import { getAll } from '../src/utils/iterable.utils' const canRunRegistryE2E = Boolean(process.env.E2E) - && Boolean(process.env.HARBOR_URL) - && Boolean(process.env.HARBOR_ADMIN) - && Boolean(process.env.HARBOR_ADMIN_PASSWORD) - && Boolean(process.env.VAULT_URL) - && Boolean(process.env.VAULT_TOKEN) - && Boolean(process.env.PROJECTS_ROOT_DIR) const describeWithRegistry = describe.runIf(canRunRegistryE2E) @@ -29,12 +26,13 @@ describeWithRegistry('RegistryService (e2e)', () => { let registry: RegistryService let client: RegistryClientService let vault: VaultClientService - let config: ConfigurationService + let config: ConfigType + let harborConfig: ConfigType let projectSlug: string beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [ConfigurationModule, RegistryModule], + imports: [ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory, harborConfigFactory] }), RegistryModule], }) .compile() @@ -43,14 +41,15 @@ describeWithRegistry('RegistryService (e2e)', () => { registry = moduleRef.get(RegistryService) client = moduleRef.get(RegistryClientService) vault = moduleRef.get(VaultClientService) - config = moduleRef.get(ConfigurationService) + config = moduleRef.get(baseConfigFactory.KEY) + harborConfig = moduleRef.get(harborConfigFactory.KEY) projectSlug = faker.helpers.slugify(`test-project-${faker.string.alphanumeric({ length: 10 }).toLowerCase()}`).slice(0, 50) }) afterAll(async () => { if (vault && config && projectSlug) { - const paths = [ROBOT_NAME_RO, ROBOT_NAME_RW, ROBOT_NAME_PROJECT].map(name => getProjectVaultPath(makeProjectWithDetails({ slug: projectSlug }), config.projectRootDir, `REGISTRY/${name}`)) + const paths = [ROBOT_NAME_RO, ROBOT_NAME_RW, ROBOT_NAME_PROJECT].map(name => getProjectVaultPath(makeProjectWithDetails({ slug: projectSlug }), config.projectsRootDir, `REGISTRY/${name}`)) await Promise.all(paths.map(path => vault.delete(path).catch(() => {}))) } @@ -63,7 +62,7 @@ describeWithRegistry('RegistryService (e2e)', () => { it('should provision project in Harbor and write robot secrets to Vault', async () => { const result = await registry.ensureProject({ slug: projectSlug, plugins: [] }, { publishProjectRobot: true }) - expect(result.basePath).toBe(`${getHostFromUrl(config.harborUrl!)}/${projectSlug}/`) + expect(result.basePath).toBe(`${getHostFromUrl(harborConfig.url)}/${projectSlug}/`) const project = await client.getProjectByName(projectSlug) expect(project.status).toBe(200) @@ -74,11 +73,11 @@ describeWithRegistry('RegistryService (e2e)', () => { expect(robotNames).toContain(`robot$${projectSlug}+${ROBOT_NAME_RW}`) expect(robotNames).toContain(`robot$${projectSlug}+${ROBOT_NAME_PROJECT}`) - const vaultPaths = [ROBOT_NAME_RO, ROBOT_NAME_RW, ROBOT_NAME_PROJECT].map(name => getProjectVaultPath(makeProjectWithDetails({ slug: projectSlug }), config.projectRootDir, `REGISTRY/${name}`)) + const vaultPaths = [ROBOT_NAME_RO, ROBOT_NAME_RW, ROBOT_NAME_PROJECT].map(name => getProjectVaultPath(makeProjectWithDetails({ slug: projectSlug }), config.projectsRootDir, `REGISTRY/${name}`)) const [roSecret, rwSecret, projectSecret] = await Promise.all(vaultPaths.map(path => vault.read(path))) - expect(roSecret.data?.HOST).toBe(getHostFromUrl(config.harborUrl!)) - expect(rwSecret.data?.HOST).toBe(getHostFromUrl(config.harborUrl!)) - expect(projectSecret.data?.HOST).toBe(getHostFromUrl(config.harborUrl!)) + expect(roSecret.data?.HOST).toBe(getHostFromUrl(harborConfig.url)) + expect(rwSecret.data?.HOST).toBe(getHostFromUrl(harborConfig.url)) + expect(projectSecret.data?.HOST).toBe(getHostFromUrl(harborConfig.url)) expect(roSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${ROBOT_NAME_RO}`) expect(rwSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${ROBOT_NAME_RW}`) expect(projectSecret.data?.USERNAME).toBe(`robot$${projectSlug}+${ROBOT_NAME_PROJECT}`) diff --git a/apps/server-nestjs/test/sonarqube.e2e-spec.ts b/apps/server-nestjs/test/sonarqube.e2e-spec.ts index 460b916712..120fbd9d87 100644 --- a/apps/server-nestjs/test/sonarqube.e2e-spec.ts +++ b/apps/server-nestjs/test/sonarqube.e2e-spec.ts @@ -1,10 +1,11 @@ import type { TestingModule } from '@nestjs/testing' import { generateProjectKey } from '@cpn-console/hooks' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -17,14 +18,10 @@ import { SonarqubeModule } from '../src/modules/sonarqube/sonarqube.module' import { SonarqubeService } from '../src/modules/sonarqube/sonarqube.service' import { VaultClientService } from '../src/modules/vault/vault-client.service' import { VaultModule } from '../src/modules/vault/vault.module' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunSonarqubeE2E = Boolean(process.env.E2E) - && Boolean(process.env.SONARQUBE_URL) - && Boolean(process.env.SONAR_API_TOKEN) - && Boolean(process.env.VAULT_URL) - && Boolean(process.env.VAULT_TOKEN) - && Boolean(process.env.DB_URL) const describeWithSonarqube = describe.runIf(canRunSonarqubeE2E) @@ -42,7 +39,7 @@ describeWithSonarqube('SonarqubeService (e2e)', () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [SonarqubeModule, VaultModule, ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + imports: [SonarqubeModule, VaultModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], }).compile() await moduleRef.init() diff --git a/apps/server-nestjs/test/vault.e2e-spec.ts b/apps/server-nestjs/test/vault.e2e-spec.ts index 3303892032..819c62dc92 100644 --- a/apps/server-nestjs/test/vault.e2e-spec.ts +++ b/apps/server-nestjs/test/vault.e2e-spec.ts @@ -1,9 +1,10 @@ import type { TestingModule } from '@nestjs/testing' import { faker } from '@faker-js/faker' +import { ConfigModule } from '@nestjs/config' import { Test } from '@nestjs/testing' import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { baseConfigFactory } from '../src/config/base.config' import { AuthModule } from '../src/modules/infrastructure/auth/auth.module' -import { ConfigurationModule } from '../src/modules/infrastructure/configuration/configuration.module' import { DatabaseModule } from '../src/modules/infrastructure/database/database.module' import { PrismaService } from '../src/modules/infrastructure/database/prisma.service' import { EventsModule } from '../src/modules/infrastructure/events/events.module' @@ -14,12 +15,10 @@ import { projectSelect } from '../src/modules/vault/vault-datastore.service' import { makeProjectWithDetails } from '../src/modules/vault/vault-testing.utils' import { VaultModule } from '../src/modules/vault/vault.module' import { VaultService } from '../src/modules/vault/vault.service' +import { getDotenvPaths } from '../src/utils/dotenv.utils' const canRunVaultE2E = Boolean(process.env.E2E) - && Boolean(process.env.VAULT_URL) - && Boolean(process.env.VAULT_TOKEN) - && Boolean(process.env.DB_URL) const describeWithVault = describe.runIf(canRunVaultE2E) @@ -35,7 +34,7 @@ describeWithVault('VaultController (e2e)', () => { beforeAll(async () => { moduleRef = await Test.createTestingModule({ - imports: [VaultModule, ConfigurationModule, AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], + imports: [VaultModule, ConfigModule.forRoot({ envFilePath: getDotenvPaths(), isGlobal: true, load: [baseConfigFactory] }), AuthModule, DatabaseModule, EventsModule, LoggerModule, PermissionModule], }).compile() await moduleRef.init()