Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions apps/server-nestjs/documentation/MIGRATION-PARITY-MATRIX.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Migration parity matrix — `apps/server` (legacy) → `apps/server-nestjs`

Verified audit of plugin-sync behavior between the legacy Fastify backend and the
NestJS target. Purpose: locate incompatibilities that would break at cutover.

## Method

- Legacy trigger surface: `hook.<entity>.<verb>(...)` calls in `apps/server/src/resources/*/business.ts`.
- NestJS trigger surface: `AppEventsService.emit*` → `EventEmitter2.emitAsync('<event>')`.
- NestJS consumer surface: `@OnEvent('<event>')` handlers (each bridges into the plugin system via `capturePluginResult`).
- Severity rule (cpn-dev-workflow): if `apps/server` still owns the route, the gap is latent (breaks at cutover); if `apps/server-nestjs` already owns it, the breakage is LIVE.

## Findings (baseline: 2026-08-25)

Unit suite before any change: **578 passed / 59 skipped** (`pnpm --filter @cpn-console/server-nestjs run test`, Node 24).

### Event / hook parity

| Entity.verb (legacy hook) | Emitted in nestjs | `@OnEvent` consumer | Status | Severity |
| --- | --- | --- | --- | --- |
| `project.upsert` | ✅ `project.service`, `deployment`, `environment`, `project-roles`, `project-hooks`, `repository` | ✅ gitlab, argocd, keycloak, nexus, registry, sonarqube, observability, vault, plugin | wired | — |
| `project.delete` | ✅ `project.service` | ✅ gitlab, argocd, keycloak, nexus, registry, sonarqube, observability, vault | wired | — |
| `repository.sync` | ✅ `repository.service` | ✅ gitlab | wired | — |
| `projectMember.upsert` / `.delete` | ✅ `project-members.service` | ❌ none | emitted, no consumer — member sync is folded into `project.upsert` (gitlab/keycloak re-sync on upsert) | low (redundant, not a regression) |
| `zone.upsert` / `.delete` | ❌ NOT emitted (no zone module in nestjs) | ✅ vault (`vault.service`) | **dangling consumer** — vault zone-sync has no emitter yet | HIGH at cutover (legacy still owns zone route today) |
| `adminRole.upsert` / `.delete` | ❌ not migrated | ❌ | legacy-only (no admin-role module in nestjs) | cutover-blocker when admin-role route moves |
| `cluster.upsert` / `.delete` | ❌ not migrated | ❌ | legacy-only (no cluster module in nestjs) | cutover-blocker when cluster route moves |
| `projectRole.upsert` / `.delete` | folded → emits `project.upsert` (`project-roles.service:128`) | (via project.upsert consumers) | by-design folding | — |
| `misc.sync` (legacy repository) | folded → `repository.sync` | gitlab | by-design folding | — |

### Interpretation

1. **LIVE regressions: none.** All events `apps/server-nestjs` currently emits have matching consumers.
2. **Latent cutover gaps (must close before the corresponding route moves off legacy):**
- `zone.*` — vault already listens; nestjs must emit it once a zone module exists.
- `adminRole.*`, `cluster.*` — no nestjs module yet; these are the next migration units, not bugs today.
3. **Redundant emission:** `projectMember.*` events have no consumer. Either add a thin `@OnEvent('projectMember.upsert')` that no-ops intentionally, or stop emitting. Not a bug; note for cleanup.

## In-progress work preserved (do NOT clobber)

Three files are modified-but-uncommitted in this checkout and must survive any
parity work:

- `apps/server-nestjs/src/modules/gitlab/gitlab-client.service.ts` — adds `withTransientRetry` (linear backoff, 3 attempts) around group/project creation (GitLab 500/502/503/504).
- `apps/server-nestjs/src/modules/gitlab/gitlab-client.service.spec.ts` — covers the retry (52 lines added).
- `apps/server-nestjs/src/modules/system-settings/system-settings.controller.ts` — `@Put(':key')` → `@Post()` for upsert.

## Integration reachability (BLOCKER for "test against integration")

The integration hosts (`*.dso.cpin-hp.numerique-interieur.fr`) are **DNS-unreachable
from the dev host** (getent → DNS FAIL; no VPN route into that zone from this
machine). `E2E`-gated e2e specs (`test/*.e2e-spec.ts`, 16 files) therefore cannot
connect here. They are runnable on a host with VPN/network access to the
integration environment, or in CI.

→ Recovery: run e2e specs on a connected host (`E2E=1 pnpm --filter @cpn-console/server-nestjs exec vitest run test/`), or stand up the VPN/tailscale route to `dso.cpin-hp`.
106 changes: 106 additions & 0 deletions apps/server-nestjs/documentation/TESTING-CAMPAIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# Console usage → server-nestjs testing campaign

## Scope of work (per user)

1. **Catalogue every usage** of the console driven by the legacy `apps/server` (Fastify) + `apps/client` (Vue). This is the source-of-truth surface.
2. **Diff each usage against `apps/server-nestjs`** to find bugs / incoherences.
3. **Automated testing campaign**, layered: unit (vitest) → e2e (`apps/server-nestjs/test/*.e2e-spec.ts`, E2E-gated) → Playwright (`console/playwright/`).
4. **File an issue per bug found**, then iterate (re-run, re-test, re-issue) until the campaign converges.

## Constraints locked in

- `apps/server` (legacy) is **frozen** — never modify.
- The 3 in-progress files (gitlab-client retry, system-settings controller PUT→POST) are **uncommitted WIP** — must not be clobbered.
- Integration hosts `*.dso.cpin-hp.numerique-interieur.fr` are **DNS-unreachable** from this dev host. E2E/Playwright against integration run only on a connected host/CI. Unit tests run here (Node 24).
- Baseline (2026-08-25): **578 passed / 59 skipped** unit suite, green.

## Inventory seed (legacy apps/server resources)

Resources with `business.ts` (the hook call site = plugin-sync surface):

- project, project-member, project-role, repository, deployment, environment, zone, admin-role, cluster, project-service, service-chain, service-monitor, stage, admin-token, system, user, log.

### Legacy hook inventory (the contract surface, by entity.verb count)

```
43 hook.project.upsert ← nestjs: emitted @ project.service, consumed by 8 plugins ✅ parity
8 hook.adminRole.upsert ← nestjs: NO module (u5) cutover-blocker
5 hook.zone.upsert ← nestjs: zone.* LISTENER exists (vault) but NO EMITTER (u4/u8) GAP
5 hook.project.delete ← nestjs: emitted @ project.service, consumed ✅ parity
4 hook.project.getSecrets ← nestjs: handled in vault/project-secrets (check)
4 hook.cluster.upsert ← nestjs: NO module (u6) cutover-blocker
3 hook.projectRole.upsert ← nestjs: folds to project.upsert (project-roles.service:128) OK
3 hook.cluster.delete ← nestjs: NO module cutover-blocker
2 hook.zone.delete ← nestjs: vault LISTENS `@OnEvent('zone.delete')` but no emitter GAP
2 hook.projectMember.upsert ← nestjs: emitted @ project-members.service, NO consumer (redundant) gap
2 hook.adminRole.delete ← nestjs: NO module cutover-blocker
1 hook.projectRole.delete ← nestjs: folds to project.upsert OK
1 hook.projectMember.delete ← nestjs: emitted, NO consumer gap
1 hook.misc.syncRepository ← nestjs: `repository.sync` emitted + consumed ✅ parity
```

### E2E coverage gap matrix (nestjs `test/` vs legacy resources)

e2e specs present: argocd, gitlab, keycloak, log, nexus, project, project-bulk,
project-hooks, project-members, project-roles, project-secrets, repository,
sonarqube, vault, zone.

Missing relative to legacy resources: **deployment, environment, cluster,
admin-role, service-chain, service-monitor, project-service, stage.**

### Confirmed gap to seed the campaign

**vault** has a unit spec (`vault.service.spec.ts`) but **NO e2e spec**. The
`@OnEvent('zone.upsert')` / `@OnEvent('zone.delete')` consumers in
`vault.service` have no corresponding emitter in server-nestjs — the zone route
lives only on legacy `apps/server`. This is the highest-impact runnable-here
test to add: a unit assertion that proves the zone-event contract is live-listening
but never emitted from the new stack (so a future zone-module migration must wire
the emit before cutover).

Plus controller-level resources: auth, user, system-setting.

## Plan shape (decomposable)

Per legacy resource → one campaign unit:

- **Unit parity**: `apps/server/<resource>/business.ts` hook call(s) ↔ `apps/server-nestjs` event emit + `@OnEvent` consumer. Gap = bug.
- **Unit coverage**: every `console → external service` client method (gitlab/keycloak/vault/argocd/nexus/sonarqube/registry/harbor) has a spec exercising its own retry/rollback/error path.
- **E2E parity**: each `apps/server-nestjs/test/<module>.e2e-spec.ts` covers the same real-world outcome as a legacy route would.
- **Playwright**: each user-visible journey (create project, add member, sync mirror…) has a deterministic spec; cross-service fragility goes to the socle cahier instead (`../documentation-interne-socle/Tests Fonctionnels/`).

## Decomposition

| Unit | Legacy source | NestJS target | Test layer | Status |
|---|---|---|---|---|
| u1 | `project` hooks (upsert/delete) | `project.service` emit + 8 consumers | unit + e2e + playwright | parity wired |
| u2 | `project-member` hooks | `project-members.service` emit (no consumer) | unit | gap: redundant emit |
| u3 | `repository` hooks (`misc.sync`) | `repository.service` emit + gitlab consumer | unit + e2e | parity wired |
| u4 | `zone` hooks (upsert/delete) | DANGLING consumer (`vault.service`), no emit | unit gap | HIGH at cutover |
| u5 | `admin-role` hooks | no nestjs module | — | cutover-blocker |
| u6 | `cluster` hooks | no nestjs module | — | cutover-blocker |
| u7 | gitlab client retry (in-progress) | gitlab-client retry path | unit (exists) | done |
| u8 | vault sync (zone.* listeners, no emitter) | vault.service | unit + e2e missing | unit exists; e2e GAP; zone emit GAP |
| u8 | keycloak upsert/delete | keycloak.service | unit + e2e missing | e2e gap |
| u9 | vault sync | vault.service | unit + e2e missing | e2e gap |
| u10 | argocd sync | argocd.service | unit + e2e missing | e2e gap |
| u11 | nexus/registry/sonarqube | each service | unit + e2e missing | e2e gap |
| u12 | harbor | **no module at all** | — | module missing |

> Units u5/u6/u12 are not bugs-today; they are migration gaps that become bugs at cutover. Tracked as issues, not code, until the route moves.

## Campaign loop (per unit)

1. Read legacy `business.ts` (the hook contract).
2. Read nestjs service emit + consumer.
3. Add unit spec if missing, assert parity (or assert the intentional gap).
4. Add `test/<module>.e2e-spec.ts` (E2E-gated) if missing, mirroring the real-world outcome.
5. If a discrepancy/bug is found → file issue (cpn-issue) with `Refs <unit>`.
6. Re-run `vitest run` (unit) to keep 578 baseline green+expanded.

## Next action

Seed the campaign by writing the first concrete, runnable-here test for the most
impactful gap with a runnable-here unit spec: **vault upsert/delete end-to-end
parity** (unit exists; assert the `zone.*/`projectMember.*` latent gaps). This
locks `full` Ponytail: shortest diff that actually fails when the logic breaks.
Original file line number Diff line number Diff line change
Expand Up @@ -175,4 +175,28 @@ describe('appEventsService', () => {
data: expect.objectContaining({ args: payload }),
}))
})

// Parity contract (cpn-dev-workflow migration-parity-checklist): every event a
// consumer listens for MUST be emitable from the new stack. VaultService
// listens on `zone.upsert` / `zone.delete` but the zone route still lives on
// legacy apps/server (which fires `hook.zone.*` directly). AppEventsService
// must NOT silently grow a zone emit API here: if it did, the cutover would
// drop the emitter-side admin log / project-status update that emitProjectEvent
// owns. This test pins the current surface so a future zone-module migration
// is forced to add the emit path deliberately rather than by accident.
describe('emit surface (migration parity)', () => {
const expectedEmitMethods = ['emitProjectEvent', 'emitProjectMemberEvent', 'emitRepositoryEvent'] as const

it.each(expectedEmitMethods)('exposes %s as a public emit entrypoint', (method) => {
expect(typeof service[method]).toBe('function')
})

it('does NOT expose a zone emit entrypoint (zone.* listeners have no emitter yet — legacy owns the route)', () => {
// Deliberate negative assertion: no `emitZoneEvent` exists until the zone
// module is migrated and wired through AppEventsService. If this fails,
// the zone cutover is being introduced without the migration-parity audit
// (see MIGRATION-PARITY-MATRIX.md, unit u4/u8).
expect('emitZoneEvent' in service).toBe(false)
})
})
})
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { TestingModule } from '@nestjs/testing'
import type { Prisma } from '@prisma/client'
import type { DeepMockProxy } from 'vitest-mock-extended'
import { readdirSync, readFileSync, statSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { faker } from '@faker-js/faker'
import { NotFoundException } from '@nestjs/common'
import { Test } from '@nestjs/testing'
Expand All @@ -17,6 +20,71 @@
} from '../project/project-testing.utils'
import { ProjectMembersService } from './project-members.service'

// --- Migration parity: projectMember event consumer surface --------------------
// Legacy apps/server fires `hook.projectMember.upsert` (add/patch) and
// `hook.projectMember.delete` (remove) in resources/project-member/business.ts.
// The NestJS rewrite emits the equivalent `projectMember.upsert` / `projectMember.delete`
// events from ProjectMembersService (add/patch/remove) but has NO
// `@OnEvent('projectMember.*')` consumer — the events are emitted and dropped.
// See apps/server-nestjs/documentation/MIGRATION-PARITY-MATRIX.md (the
// projectMember.* row and the "Redundant emission" note). This guard pins that
// gap so a half-migration (a listener for one verb but not the other, or a
// listener added without intent) is caught and forces a deliberate decision.
//
// The emit side is already locked by the add/patch/remove tests below
// (appEvents.emitProjectMemberEvent is called with projectMember.upsert /
// projectMember.delete at the correct call sites).

const PROJECT_MEMBER_EVENT_RE = /@OnEvent\(\s*['"](projectMember\.(upsert|delete))['"]/
const ANY_ON_EVENT_RE = /@OnEvent\(\s*['"]([^'"]+)['"]/

function collectSourceFiles(dir: string): string[] {
const out: string[] = []
for (const entry of readdirSync(dir)) {
if (entry === 'node_modules' || entry === 'dist') continue
const full = resolve(dir, entry)
if (statSync(full).isDirectory()) {
out.push(...collectSourceFiles(full))
} else if (entry.endsWith('.ts') && !entry.endsWith('.spec.ts') && !entry.endsWith('.e2e-spec.ts')) {
out.push(full)
}
}
return out
}

function readEventConsumers(re: RegExp): string[] {
const srcRoot = resolve(dirname(fileURLToPath(import.meta.url)), '../../')
const found: string[] = []
for (const file of collectSourceFiles(srcRoot)) {
const m = readFileSync(file, 'utf8').match(re)

Check notice on line 59 in apps/server-nestjs/src/modules/project-members/project-members.service.spec.ts

View check run for this annotation

cloud-pi-native-sonarqube / SonarQube Code Analysis

apps/server-nestjs/src/modules/project-members/project-members.service.spec.ts#L59

Use the "RegExp.exec()" method instead.
if (m) found.push(m[1])
}
return found
}

describe('migration parity: projectMember consumer', () => {
const projectMemberConsumers = readEventConsumers(PROJECT_MEMBER_EVENT_RE)
const allConsumers = readEventConsumers(ANY_ON_EVENT_RE)

it('scanner detects real @OnEvent consumers (guard against a vacuous negative test)', () => {
// Sanity: the walker must find live consumers (e.g. project.upsert) so the
// absence of projectMember.* consumers below is meaningful, not a broken scan.
expect(allConsumers).toContain('project.upsert')
})

it('documents the gap: no @OnEvent(\'projectMember.upsert\') consumer', () => {
// Legacy hook.projectMember.upsert fires on add/patch; nestjs emits
// projectMember.upsert but nothing listens. See MIGRATION-PARITY-MATRIX.md.
expect(projectMemberConsumers).not.toContain('projectMember.upsert')
})

it('documents the gap: no @OnEvent(\'projectMember.delete\') consumer', () => {
// Legacy hook.projectMember.delete fires on remove; nestjs emits
// projectMember.delete but nothing listens. See MIGRATION-PARITY-MATRIX.md.
expect(projectMemberConsumers).not.toContain('projectMember.delete')
})
})

describe('projectMembersService', () => {
let module: TestingModule
let service: ProjectMembersService
Expand Down
51 changes: 51 additions & 0 deletions apps/server-nestjs/src/modules/vault/vault.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { baseConfigFactory } from '../../config/base.config'
import { vaultConfigFactory } from '../../config/vault.config'
import { VaultClientService } from './vault-client.service'
import { VaultError } from './vault-http-client.service'
import { VaultDatastoreService } from './vault-datastore.service'
import { makeProjectWithDetails, makeVaultSecret, makeZoneWithDetails } from './vault-testing.utils'
import { VaultService } from './vault.service'
Expand Down Expand Up @@ -156,4 +157,54 @@
expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-readonly`)
expect(client.deleteIdentityGroupName).toHaveBeenCalledWith(`project-${project.slug}-security`)
})

// Zone sync: the zone route still lives on legacy apps/server (hook.zone.*).
// VaultService listens on `zone.upsert` / `zone.delete` via @OnEvent, but
// AppEventsService exposes no emitZoneEvent (see app-events.service.spec.ts
// parity guard). These specs lock the external-call surface of upsertZone /
// deleteZone so that when the zone module migrates and the emit path lands,
// the Vault external contract is already pinned.
describe('zone sync (external parity)', () => {
it('upserts the zone mount, tech-readonly policy and approle', async () => {
const zone = makeZoneWithDetails()

await service.upsertZone(zone.slug)

expect(client.createSysMount).toHaveBeenCalledWith(`zone-${zone.slug}`, expect.objectContaining({ type: 'kv' }))
expect(client.upsertSysPoliciesAcl).toHaveBeenCalledWith(
`tech--zone-${zone.slug}--ro`,
{ policy: `path \"zone-${zone.slug}/*\" { capabilities = [\"read\"] }` },

Check warning on line 176 in apps/server-nestjs/src/modules/vault/vault.service.spec.ts

View check run for this annotation

cloud-pi-native-sonarqube / SonarQube Code Analysis

apps/server-nestjs/src/modules/vault/vault.service.spec.ts#L176

Unnecessary escape character: \".

Check warning on line 176 in apps/server-nestjs/src/modules/vault/vault.service.spec.ts

View check run for this annotation

cloud-pi-native-sonarqube / SonarQube Code Analysis

apps/server-nestjs/src/modules/vault/vault.service.spec.ts#L176

Unnecessary escape character: \".

Check warning on line 176 in apps/server-nestjs/src/modules/vault/vault.service.spec.ts

View check run for this annotation

cloud-pi-native-sonarqube / SonarQube Code Analysis

apps/server-nestjs/src/modules/vault/vault.service.spec.ts#L176

Unnecessary escape character: \".

Check warning on line 176 in apps/server-nestjs/src/modules/vault/vault.service.spec.ts

View check run for this annotation

cloud-pi-native-sonarqube / SonarQube Code Analysis

apps/server-nestjs/src/modules/vault/vault.service.spec.ts#L176

Unnecessary escape character: \".
)
expect(client.upsertAuthApproleRole).toHaveBeenCalledWith(
`zone-${zone.slug}`,
{ secret_id_num_uses: '0', secret_id_ttl: '0', token_max_ttl: '0', token_num_uses: '0', token_ttl: '0', token_type: 'batch', token_policies: [`tech--zone-${zone.slug}--ro`] },
)
})

it('tunes an existing mount (400) instead of recreating it', async () => {
const zone = makeZoneWithDetails()
client.createSysMount.mockRejectedValueOnce(new VaultError('HttpError', 'Bad Request', { status: 400 }))

await service.upsertZone(zone.slug)

expect(client.tuneSysMount).toHaveBeenCalledWith(`zone-${zone.slug}`, expect.objectContaining({ options: { version: 2 } }))
})

it('deletes the zone mount, policy and approle, swallowing NotFound', async () => {
const zone = makeZoneWithDetails()

await service.deleteZone(zone.slug)

expect(client.deleteSysMounts).toHaveBeenCalledWith(`zone-${zone.slug}`)
expect(client.deleteSysPoliciesAcl).toHaveBeenCalledWith(`tech--zone-${zone.slug}--ro`)
expect(client.deleteAuthApproleRole).toHaveBeenCalledWith(`zone-${zone.slug}`)
})

it('rethrows non-NotFound errors during zone deletion', async () => {
const zone = makeZoneWithDetails()
client.deleteSysMounts.mockRejectedValueOnce(new VaultError('HttpError', 'Bad Gateway', { status: 502 }))

await expect(service.deleteZone(zone.slug)).rejects.toThrow()
})
})
})
Loading