diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..444d993 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,95 @@ +# CI SIOP V2 (R0.12) — trois verrous : +# ci-contract : la règle d'or (Zod → OpenAPI → clients typés) — échoue au +# moindre diff entre le contrat committé et la génération ; +# api : migration + tests Jest (couverture ≥ 70 % bloquante, jest.config) ; +# web : typecheck + vitest + build de production. +# Le parcours Playwright (connexion démo → coquille) complétera R0.12. +name: CI + +on: + push: + branches: [main] + pull_request: + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + contract: + name: ci-contract (règle d'or) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm --filter @siop/shared build + - name: Régénérer la spec et le client typé + run: | + pnpm --filter @siop/shared contract + pnpm --filter @siop/web generate:client + - name: Vérifier qu'aucun artefact ne dérive du contrat + run: | + if ! git diff --exit-code -- docs/openapi.json apps/web/src/api/schema.d.ts; then + echo "::error::Contrat désynchronisé — régénérez spec et clients dans le même commit (pnpm contract && pnpm --filter @siop/web generate:client)." + exit 1 + fi + + api: + name: api (tests + couverture ≥ 70 %) + runs-on: ubuntu-latest + services: + # R0 : PostgreSQL nu suffit (r0_identity). Dès que des tests exigeront + # pgvector/PostGIS (R1+), passer sur l'image infra/postgres. + postgres: + image: postgres:18 + env: + POSTGRES_USER: siop + POSTGRES_PASSWORD: siop + POSTGRES_DB: siop + ports: ['5432:5432'] + options: >- + --health-cmd "pg_isready -U siop -d siop" + --health-interval 5s --health-timeout 3s --health-retries 10 + redis: + image: redis:7.4-alpine + ports: ['6379:6379'] + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s --health-timeout 3s --health-retries 10 + env: + DATABASE_URL: postgresql://siop:siop@localhost:5432/siop + REDIS_URL: redis://localhost:6379 + JWT_SECRET: ci-only-secret-0123456789abcdef + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm --filter @siop/shared build + - run: pnpm --filter @siop/api prisma:generate + - run: pnpm --filter @siop/api exec prisma migrate deploy + - run: pnpm --filter @siop/api typecheck + - run: pnpm --filter @siop/api test:cov + + web: + name: web (typecheck + tests + build) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version-file: .nvmrc + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm --filter @siop/shared build + - run: pnpm --filter @siop/web test + - run: pnpm --filter @siop/web build diff --git a/README.md b/README.md index 09a6b57..d3e708b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # SIOP V2 — GMAO pour la maintenance d'ascenseurs +[![CI](https://github.com/siop-spelev/siop2/actions/workflows/ci.yml/badge.svg)](https://github.com/siop-spelev/siop2/actions/workflows/ci.yml) + **SIOP** (Système Intégré d'Opérations de maintenance Préventive) est une GMAO complète dédiée à la maintenance d'ascenseurs, inspirée fonctionnellement d'[Atlas CMMS](https://github.com/grashjs/cmms) et adaptée au métier (carnet d'intervention, périodicités réglementaires, PV signés, diff --git a/apps/api/jest.config.js b/apps/api/jest.config.js index ed67a39..4b6bac0 100644 --- a/apps/api/jest.config.js +++ b/apps/api/jest.config.js @@ -10,5 +10,9 @@ module.exports = { }, setupFiles: ['/test/setup-env.ts'], collectCoverageFrom: ['src/**/*.ts', '!src/main.ts'], + // Exigence non fonctionnelle (01-cadrage) : couverture backend ≥ 70 % BLOQUANTE + coverageThreshold: { + global: { statements: 70, branches: 70, functions: 70, lines: 70 }, + }, testTimeout: 30000, }; diff --git a/apps/api/src/permissions/permissions.guard.spec.ts b/apps/api/src/permissions/permissions.guard.spec.ts new file mode 100644 index 0000000..d934c7c --- /dev/null +++ b/apps/api/src/permissions/permissions.guard.spec.ts @@ -0,0 +1,58 @@ +import { ForbiddenException } from '@nestjs/common'; +import type { ExecutionContext } from '@nestjs/common'; +import type { Reflector } from '@nestjs/core'; +import { PermissionsGuard } from './permissions.guard'; +import type { PermissionsService } from './permissions.service'; +import type { RequiredPermission } from './require-permission.decorator'; + +function contextWithUser(user?: { userId: string; roleId: string }) { + return { + getHandler: () => ({}), + getClass: () => ({}), + switchToHttp: () => ({ getRequest: () => ({ user }) }), + } as unknown as ExecutionContext; +} + +describe('PermissionsGuard (matrice relue en base — le JWT ne porte aucun droit)', () => { + const reflector = (required?: RequiredPermission) => + ({ getAllAndOverride: () => required }) as unknown as Reflector; + const service = (allowed: boolean) => + ({ can: jest.fn().mockResolvedValue(allowed) }) as unknown as PermissionsService; + + it('laisse passer une route sans @RequirePermission', async () => { + const guard = new PermissionsGuard(reflector(undefined), service(false)); + await expect(guard.canActivate(contextWithUser())).resolves.toBe(true); + }); + + it('refuse si aucun utilisateur authentifié ne porte la requête', async () => { + const guard = new PermissionsGuard( + reflector({ category: 'WORK_ORDERS', right: 'view' }), + service(true), + ); + await expect(guard.canActivate(contextWithUser(undefined))).rejects.toThrow( + ForbiddenException, + ); + }); + + it('autorise quand la matrice accorde le droit', async () => { + const permissions = service(true); + const guard = new PermissionsGuard( + reflector({ category: 'WORK_ORDERS', right: 'create' }), + permissions, + ); + await expect( + guard.canActivate(contextWithUser({ userId: 'u1', roleId: 'r1' })), + ).resolves.toBe(true); + expect(permissions.can).toHaveBeenCalledWith('r1', 'WORK_ORDERS', 'create'); + }); + + it('refuse (403, droit nommé) quand la matrice ne l’accorde pas', async () => { + const guard = new PermissionsGuard( + reflector({ category: 'SETTINGS', right: 'delete' }), + service(false), + ); + await expect( + guard.canActivate(contextWithUser({ userId: 'u1', roleId: 'r1' })), + ).rejects.toThrow(/SETTINGS\.delete/); + }); +}); diff --git a/docs/journal/journal.md b/docs/journal/journal.md index f3c6a2c..72a616c 100644 --- a/docs/journal/journal.md +++ b/docs/journal/journal.md @@ -4,6 +4,23 @@ Trace chronologique des sessions (la plus récente en premier). Le **playbook** --- +## 2026-07-15 — Pr. Daaif (+ Claude) — R0.12 (1/2) : pipeline GitHub Actions + +**Actions** + +- `.github/workflows/ci.yml`, 3 jobs sur push main + PR : **ci-contract** (régénère `docs/openapi.json` + `schema.d.ts`, échoue au moindre diff — la règle d'or devient bloquante), **api** (PostgreSQL 18 + Redis en services, `prisma migrate deploy`, typecheck, Jest avec **couverture ≥ 70 % bloquante** via `coverageThreshold`), **web** (typecheck + vitest + build prod). +- Test unitaire de `PermissionsGuard` ajouté (aucune route `@RequirePermission` en R0 ne l'exerçait) : couverture 97,5 % stmts / 90,7 % branches, 23 tests. +- Badge CI dans le README ; `ci-contract` simulé en local (diff propre) avant push. + +**Décisions** + +- CI sur PostgreSQL nu en R0 (la migration `r0_identity` n'exige aucune extension) ; bascule sur l'image `infra/postgres` dès que des tests toucheront pgvector/PostGIS (R1+). +- Pas de MinIO en CI : `/health` répond `degraded` sans casser les tests — seul `database: up` est exigé. + +**Prochaine étape** : R0.12 (2/2) — ESLint (dont la règle « pas d'import MinIO hors FileStorage »), parcours Playwright (connexion démo → coquille → bascule de rôle), puis R0.13 Dockerfiles + runbook Dokploy. + +--- + ## 2026-07-15 — Pr. Daaif (+ Claude) — R0.11 : apps/web (connexion + sélecteur démo, coquille, /design) **Actions**