1888b70623
Auto Tag Develop / tag (push) Successful in 11s
## Contexte
ERP-102 — Découvert pendant ERP-64. Connecté avec un rôle **métier** (bureau / compta / commerciale), `GET /api/categories` et `GET /api/sites` renvoient **403**, alors que `/tva_modes`, `/payment_delays`, `/payment_types`, `/banks` renvoient 200.
Conséquences : page **Création client** inutilisable (le `Promise.all` rejetait → **tous** les selects vides) et **filtres Catégories/Sites vides** au répertoire.
## Cause
La `security` des `GetCollection`/`Get` de `Category` et `Site` exigeait `catalog.categories.view` / `sites.view` — permissions d'**administration** du Catalogue / des Sites. Or ces référentiels sont **transverses** : tout rôle qui gère un tiers doit pouvoir les lire.
## Correctif back — Option C (permission de lecture-référentiel dédiée)
Choix d'archi retenu parmi les 3 du ticket :
- **Pourquoi pas A** (`... or is_granted('commercial.clients.view')`) : coupler `Category`/`Site` à une permission **Commercial** viole l'esprit de la règle ABSOLUE n°1 et ne scale pas (M2 Fournisseurs devrait rajouter un OR).
- **Pourquoi pas B** (donner `.view` aux rôles métier) : `.view` = accès admin → items sidebar admin Catégories/Sites exposés à une commerciale.
- **C** : nouvelle permission `catalog.categories.read_ref` / `sites.read_ref`, distincte de `.view` (pas d'item sidebar) et de `.manage`. Chaque permission appartient à **son** module → isolement inter-module préservé, **réutilisable tel quel par M2 Fournisseurs**. C'est la « permission référentiel lisible » que le ticket pointe lui-même.
Détail :
- `CatalogModule` / `SitesModule` : déclaration des deux permissions `read_ref`.
- `Category` / `Site` : security lecture (liste + item) = `view OR read_ref`.
- `RbacSeeder` (matrice § 2.7) : `read_ref` attaché à bureau / compta / commerciale ; usine reste sans accès.
## Durcissement front (résilience — requis dans tous les cas)
`useClientReferentials.loadCommon` : `Promise.all` → **`Promise.allSettled`** avec affectation isolée par référentiel. L'échec d'un endpoint ne vide plus que **son** select, plus la totalité du formulaire.
## Tests (TDD)
- `ClientRBACMatrixTest::testBusinessRolesCanReadCategoriesAndSitesReferentials` — bureau/compta/commerciale listent `/categories` et `/sites` (200), usine reste 403.
- `SitesModuleTest` — set de permissions porté à 4 codes.
- `useClientReferentials.spec` (Vitest) — un référentiel en échec ne vide que son select.
## Vérifications
- `make test` (back) : **467/467** ✓
- `make nuxt-test` (front) : **131/131** ✓
- `make php-cs-fixer` : conforme ✓
## Note miroirs RBAC
`config/sidebar.php` / `personas.ts` / `SeedE2ECommand.php` **non touchés** : `read_ref` n'ajoute aucun item sidebar, le persona E2E `user-full` lit déjà via `.view`, et aucun persona ne modélise un rôle métier seul. Pas de nouveau test E2E (règle n°7 : bug attrapé avant prod). La source de vérité de la matrice (`RbacSeeder`) est mise à jour et couverte par `ClientRBACMatrixTest`.
Closes ERP-102.
---------
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #53
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
73 lines
2.9 KiB
TypeScript
73 lines
2.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
|
|
// `useApi` est un auto-import Nuxt : on le stubbe globalement pour intercepter
|
|
// les appels de chargement des referentiels et simuler un endpoint en echec
|
|
// (ex: 403 sur /categories pour un role sans la permission de lecture).
|
|
// Meme pattern que useClientsRepository.spec.ts.
|
|
const mockGet = vi.hoisted(() => vi.fn())
|
|
vi.stubGlobal('useApi', () => ({
|
|
get: mockGet,
|
|
post: vi.fn(),
|
|
put: vi.fn(),
|
|
patch: vi.fn(),
|
|
delete: vi.fn(),
|
|
}))
|
|
|
|
// Import APRES le stub pour que useApi soit bien resolu au top-level du module.
|
|
const { useClientReferentials } = await import('../useClientReferentials')
|
|
|
|
describe('useClientReferentials.loadCommon (resilience ERP-102)', () => {
|
|
beforeEach(() => {
|
|
mockGet.mockReset()
|
|
})
|
|
|
|
it('un referentiel en echec (403) ne vide QUE son select, pas les autres', async () => {
|
|
// /categories rejette (simulateur d'un 403), tous les autres repondent.
|
|
mockGet.mockImplementation((url: string) => {
|
|
if (url === '/categories') {
|
|
return Promise.reject(new Error('403 Forbidden'))
|
|
}
|
|
if (url === '/sites') {
|
|
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault' }] })
|
|
}
|
|
return Promise.resolve({
|
|
member: [{ '@id': '/api/x/1', code: 'X', label: 'Libelle X' }],
|
|
})
|
|
})
|
|
|
|
const refs = useClientReferentials()
|
|
// loadCommon ne doit JAMAIS rejeter : l'echec d'un referentiel est isole.
|
|
await refs.loadCommon()
|
|
|
|
// Resilience : les referentiels OK sont peuples malgre l'echec de /categories.
|
|
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: 'Chatellerault' }])
|
|
expect(refs.tvaModes.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
|
|
expect(refs.banks.value).toEqual([{ value: '/api/x/1', label: 'Libelle X' }])
|
|
|
|
// Seul le select en echec reste vide.
|
|
expect(refs.categories.value).toEqual([])
|
|
})
|
|
|
|
it('charge tous les referentiels quand tout repond', async () => {
|
|
mockGet.mockImplementation((url: string) => {
|
|
if (url === '/categories') {
|
|
return Promise.resolve({
|
|
member: [{ '@id': '/api/categories/1', code: 'SECTEUR', name: 'Secteur' }],
|
|
})
|
|
}
|
|
if (url === '/sites') {
|
|
return Promise.resolve({ member: [{ '@id': '/api/sites/1', name: 'Chatellerault' }] })
|
|
}
|
|
return Promise.resolve({ member: [] })
|
|
})
|
|
|
|
const refs = useClientReferentials()
|
|
await refs.loadCommon()
|
|
|
|
expect(refs.categories.value).toEqual([
|
|
{ value: '/api/categories/1', label: 'Secteur', code: 'SECTEUR' },
|
|
])
|
|
expect(refs.sites.value).toEqual([{ value: '/api/sites/1', label: 'Chatellerault' }])
|
|
})
|
|
})
|