ci(r0.12): pipeline GitHub Actions — ci-contract, api (couverture ≥ 70 %), web

- ci-contract : régénère docs/openapi.json + schema.d.ts et échoue au
  moindre diff — la règle d'or (ADR-001) devient bloquante
- api : services PostgreSQL 18 + Redis, prisma migrate deploy, typecheck,
  Jest avec coverageThreshold global 70 % (mesuré : 97,5 % stmts / 90,7 % branches)
- web : typecheck + vitest + build de production
- test unitaire PermissionsGuard (23 tests au total) ; badge CI au README

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
pr-daaif
2026-07-15 22:24:59 +01:00
parent 4f3af5af4f
commit 83efc33f0f
5 changed files with 176 additions and 0 deletions

95
.github/workflows/ci.yml vendored Normal file
View File

@@ -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

View File

@@ -1,5 +1,7 @@
# SIOP V2 — GMAO pour la maintenance d'ascenseurs # 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 **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) 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, et adaptée au métier (carnet d'intervention, périodicités réglementaires, PV signés,

View File

@@ -10,5 +10,9 @@ module.exports = {
}, },
setupFiles: ['<rootDir>/test/setup-env.ts'], setupFiles: ['<rootDir>/test/setup-env.ts'],
collectCoverageFrom: ['src/**/*.ts', '!src/main.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, testTimeout: 30000,
}; };

View File

@@ -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 laccorde 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/);
});
});

View File

@@ -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) ## 2026-07-15 — Pr. Daaif (+ Claude) — R0.11 : apps/web (connexion + sélecteur démo, coquille, /design)
**Actions** **Actions**