Compare commits

..

2 Commits

Author SHA1 Message Date
Matthieu f9c881c771 fix(rbac) : referentiels /categories et /sites lisibles par les roles metier (ERP-102)
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m49s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m18s
Les roles metier (bureau / compta / commerciale) prenaient un 403 sur
GET /api/categories et GET /api/sites : la security des GetCollection/Get
exigeait catalog.categories.view / sites.view, permissions reservees a
l'administration du Catalogue et des Sites. Or ces referentiels sont
transverses (selects de creation/filtre client) : creation de client
cassee et filtres vides pour ces roles.

Correctif back (Option C — permission de lecture-referentiel dediee) :
- Nouvelles permissions catalog.categories.read_ref et sites.read_ref,
  distinctes de .view (pas d'item sidebar admin) et de .manage. Chaque
  permission appartient a son propre module -> aucun couplage inter-module
  (regle ABSOLUE n°1) et reutilisable tel quel par M2 Fournisseurs.
- Security lecture (liste + item) elargie : view OR read_ref sur Category
  et Site.
- Matrice RBAC § 2.7 (RbacSeeder) : read_ref attache a bureau / compta /
  commerciale. Usine reste sans acces.

Durcissement front (resilience, requis dans tous les cas) :
- useClientReferentials.loadCommon passe de Promise.all a Promise.allSettled
  avec affectation isolee par referentiel : l'echec d'un endpoint ne vide
  que SON select, plus la totalite du formulaire.

Tests :
- ClientRBACMatrixTest : les roles metier listent /categories et /sites (200),
  usine reste a 403.
- SitesModuleTest : set de permissions porte a 4 codes.
- useClientReferentials.spec : resilience d'un referentiel en echec.

Miroirs E2E (personas.ts / SeedE2ECommand) non touches : read_ref n'ajoute
aucun lien sidebar, le persona user-full lit deja via .view, et aucun
persona ne modelise un role metier seul ; pas de nouveau test E2E (regle n°7).
2026-06-03 14:12:17 +02:00
matthieu 97301dcd6c refactor(commercial) : découpler l'hydratation des collections de la sélection clients (ERP-100) (#50)
Auto Tag Develop / tag (push) Successful in 7s
## Contexte
Issu de la review ERP-62 (#44). `DoctrineClientRepository::createListQueryBuilder()` portait 3 `leftJoin+addSelect` to-many imbriqués (`categories × addresses × addresses.sites`) **partagés** entre :
- la **liste paginée** (`ClientProvider`) — bornée, OK ;
- l'**export XLSX** et **`?pagination=false`** — `getResult()` sans pagination → hydratation du **produit cartésien sur tout le référentiel** (1 client à 5 cat × 4 adr × 3 sites = 60 lignes SQL, × N clients).

Défaut d'altitude : un « QueryBuilder de liste » (contrat = filtres) imposait une stratégie d'hydratation à tout appelant.

## Changements
- **`createListQueryBuilder()`** redevient **filtres + tri seuls** — conforme au contrat de l'interface.
- Nouvelle méthode **`hydrateListCollections(array $clients)`** : recharge les collections en **2 requêtes `WHERE id IN(...)` séparées** (catégories d'un côté, adresses+sites de l'autre) via l'identity map Doctrine. Casse le triple cartésien en `cat + (addr × site)`.
- **3 appelants** branchés sur cette stratégie unique :
  - liste paginée : `fetchJoinCollection: false` (COUNT simple) + hydratation de la page ;
  - `?pagination=false` : hydratation après `getResult()` ;
  - export XLSX : hydratation après `getResult()`.

## Tests
- `make test` : **465 OK**.
- Nouveau test `ClientExportControllerTest::testExportPopulatesCategoryAndSiteColumns` : garde-fou sur les valeurs Catégories/Sites de l'export (qu'un oubli d'hydratation rendrait silencieusement vides).
- `php-cs-fixer` : 0 correction.

## Notes
- Benchmark « 1000+ clients » non exécuté (pas de jeu de données à cette échelle en dev) ; le cartésien est supprimé structurellement.
- `addr × site` reste un join imbriqué (inévitable pour agréger les sites par adresse), désormais non multiplié par les catégories.

Closes ERP-100.

---------

Co-authored-by: admin malio <malio@yuno.malio.fr>
Co-authored-by: Matthieu <contact@malio.fr>
Reviewed-on: #50
Co-authored-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
Co-committed-by: THOLOT DECHENE Matthieu <matthieu@yuno.malio.fr>
2026-06-03 09:44:31 +00:00
14 changed files with 283 additions and 35 deletions
@@ -0,0 +1,72 @@
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' }])
})
})
@@ -88,23 +88,35 @@ export function useClientReferentials() {
* charges a la demande selon la relation choisie). Les selects compta ne sont * charges a la demande selon la relation choisie). Les selects compta ne sont
* pertinents que si l'utilisateur a acces a l'onglet, mais le cout est * pertinents que si l'utilisateur a acces a l'onglet, mais le cout est
* negligeable et simplifie l'orchestration. * negligeable et simplifie l'orchestration.
*
* Resilience (ERP-102) : chaque referentiel est charge et affecte
* independamment via `Promise.allSettled`. Si UN endpoint echoue (ex: 403,
* coupure reseau), seul SON select reste vide — les autres sont peuples
* normalement. Un `Promise.all` rejetterait au premier echec et viderait la
* TOTALITE des selects, rendant le formulaire de creation client inutilisable.
* `loadCommon` ne rejette donc jamais.
*/ */
async function loadCommon(): Promise<void> { async function loadCommon(): Promise<void> {
const [cats, sitesList, tva, delays, types, banksList] = await Promise.all([ await Promise.allSettled([
fetchAll<CategoryMember>('/categories'), fetchAll<CategoryMember>('/categories').then(cats => {
fetchAll<SiteMember>('/sites'), categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code }))
fetchAll<ReferentialMember>('/tva_modes'), }),
fetchAll<ReferentialMember>('/payment_delays'), fetchAll<SiteMember>('/sites').then(sitesList => {
fetchAll<ReferentialMember>('/payment_types'), sites.value = sitesList.map(s => ({ value: s['@id'], label: s.name }))
fetchAll<ReferentialMember>('/banks'), }),
fetchAll<ReferentialMember>('/tva_modes').then(tva => {
tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label }))
}),
fetchAll<ReferentialMember>('/payment_delays').then(delays => {
paymentDelays.value = delays.map(d => ({ value: d['@id'], label: d.label }))
}),
fetchAll<ReferentialMember>('/payment_types').then(types => {
paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code }))
}),
fetchAll<ReferentialMember>('/banks').then(banksList => {
banks.value = banksList.map(b => ({ value: b['@id'], label: b.label }))
}),
]) ])
categories.value = cats.map(c => ({ value: c['@id'], label: c.name, code: c.code }))
sites.value = sitesList.map(s => ({ value: s['@id'], label: s.name }))
tvaModes.value = tva.map(t => ({ value: t['@id'], label: t.label }))
paymentDelays.value = delays.map(d => ({ value: d['@id'], label: d.label }))
paymentTypes.value = types.map(t => ({ value: t['@id'], label: t.label, code: t.code }))
banks.value = banksList.map(b => ({ value: b['@id'], label: b.label }))
} }
/** Liste des clients pouvant etre choisis comme distributeur (code DISTRIBUTEUR). */ /** Liste des clients pouvant etre choisis comme distributeur (code DISTRIBUTEUR). */
+5
View File
@@ -38,6 +38,11 @@ final class CatalogModule
return [ return [
['code' => 'catalog.categories.view', 'label' => 'Voir les categories'], ['code' => 'catalog.categories.view', 'label' => 'Voir les categories'],
['code' => 'catalog.categories.manage', 'label' => 'Gerer les categories (creer, editer, supprimer)'], ['code' => 'catalog.categories.manage', 'label' => 'Gerer les categories (creer, editer, supprimer)'],
// Lecture-referentiel transverse (ERP-102) : permet de LISTER les categories
// pour alimenter les selects des modules Tiers (clients, fournisseurs...),
// sans donner l'acces d'administration `.view` (qui ouvre la page Catalogue
// dans la sidebar). Accordee aux roles metier via la matrice RBAC § 2.7.
['code' => 'catalog.categories.read_ref', 'label' => 'Lire le referentiel categories (transverse, lecture seule)'],
]; ];
} }
} }
@@ -42,13 +42,19 @@ use Symfony\Component\Validator\Constraints as Assert;
*/ */
#[ApiResource( #[ApiResource(
operations: [ operations: [
// Lecture (liste + item) : permission d'administration `view` OU permission
// de lecture-referentiel transverse `read_ref` (ERP-102). Les referentiels
// categories sont consommes par les modules Tiers (selects creation/filtre
// client) : tout role qui gere des tiers doit pouvoir les lire sans porter
// l'acces admin du Catalogue. `read_ref` est une permission Catalog (pas un
// code d'un autre module) -> isolement inter-module preserve.
new GetCollection( new GetCollection(
security: "is_granted('catalog.categories.view')", security: "is_granted('catalog.categories.view') or is_granted('catalog.categories.read_ref')",
normalizationContext: ['groups' => ['category:read', 'default:read']], normalizationContext: ['groups' => ['category:read', 'default:read']],
provider: CategoryProvider::class, provider: CategoryProvider::class,
), ),
new Get( new Get(
security: "is_granted('catalog.categories.view')", security: "is_granted('catalog.categories.view') or is_granted('catalog.categories.read_ref')",
normalizationContext: ['groups' => ['category:read', 'default:read']], normalizationContext: ['groups' => ['category:read', 'default:read']],
provider: CategoryProvider::class, provider: CategoryProvider::class,
), ),
@@ -33,6 +33,12 @@ interface ClientRepositoryInterface
* la liste paginee (ClientProvider) et l'export (ClientExportController) * la liste paginee (ClientProvider) et l'export (ClientExportController)
* partagent strictement la meme logique de selection. * partagent strictement la meme logique de selection.
* *
* Contrat = SELECTION uniquement (filtres + tri). Aucun fetch-join to-many :
* l'hydratation des collections affichees est une decision de l'appelant
* (cf. {@see self::hydrateListCollections()}), pour ne pas imposer le cout
* d'un produit cartesien a un consommateur qui ne filtrerait/compterait que
* (ERP-100).
*
* @param list<string> $categoryCodes * @param list<string> $categoryCodes
* @param list<int> $siteIds * @param list<int> $siteIds
*/ */
@@ -43,4 +49,19 @@ interface ClientRepositoryInterface
array $siteIds = [], array $siteIds = [],
bool $archivedOnly = false, bool $archivedOnly = false,
): QueryBuilder; ): QueryBuilder;
/**
* Hydrate en lot les collections affichees par le repertoire (categories,
* adresses et leurs sites) sur un jeu de clients DEJA charges, via l'identity
* map Doctrine (memes instances). A appeler apres une selection bornee (page
* courante ou jeu d'export) pour eviter le N+1 a la serialisation, sans
* imposer de fetch-join au QueryBuilder de selection (ERP-100).
*
* Charge les categories et les adresses/sites en DEUX requetes distinctes
* (et non un triple fetch-join) pour ne pas multiplier categories x adresses
* x sites en un seul produit cartesien.
*
* @param list<Client> $clients
*/
public function hydrateListCollections(array $clients): void;
} }
@@ -83,8 +83,13 @@ final class ClientProvider implements ProviderInterface
// Echappatoire ?pagination=false : collection complete sans Paginator // Echappatoire ?pagination=false : collection complete sans Paginator
// (cf. convention ERP-72 — utile pour un <select> cote front). // (cf. convention ERP-72 — utile pour un <select> cote front).
if (!$this->pagination->isEnabled($operation, $context)) { if (!$this->pagination->isEnabled($operation, $context)) {
// @var list<Client> $result /** @var list<Client> $clients */
return $qb->getQuery()->getResult(); $clients = $qb->getQuery()->getResult();
// Hydratation batchee des collections affichees (cf. ERP-100) : evite
// le N+1 si la serialisation touche categories/sites, sans cartesien.
$this->repository->hydrateListCollections($clients);
return $clients;
} }
$limit = $this->pagination->getLimit($operation, $context); $limit = $this->pagination->getLimit($operation, $context);
@@ -93,9 +98,13 @@ final class ClientProvider implements ProviderInterface
$qb->setFirstResult($offset)->setMaxResults($limit); $qb->setFirstResult($offset)->setMaxResults($limit);
// fetchJoinCollection: true pour un COUNT correct des que des JOINs // Le QB de selection ne porte plus de fetch-join to-many (ERP-100) : le
// to-many seront ajoutes (sous-collections embarquees en detail). // COUNT est simple, fetchJoinCollection inutile. On materialise la page
return new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: true)); // puis on hydrate ses collections en lot (memes entites managees).
$paginator = new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false));
$this->repository->hydrateListCollections(iterator_to_array($paginator));
return $paginator;
} }
/** /**
@@ -69,6 +69,11 @@ final class ClientExportController
->getResult() ->getResult()
; ;
// Hydratation batchee des categories + adresses/sites (ERP-100) : le QB de
// selection ne fetch-join plus, on remplit les collections en 2 requetes
// IN bornees plutot que d'hydrater un produit cartesien sur tout le jeu.
$this->repository->hydrateListCollections($clients);
$withSiren = $this->security->isGranted('commercial.clients.accounting.view'); $withSiren = $this->security->isGranted('commercial.clients.accounting.view');
$binary = $this->exporter->export( $binary = $this->exporter->export(
@@ -38,16 +38,12 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
array $siteIds = [], array $siteIds = [],
bool $archivedOnly = false, bool $archivedOnly = false,
): QueryBuilder { ): QueryBuilder {
// SELECTION uniquement (filtres + tri) : pas de fetch-join to-many ici.
// L'hydratation des collections affichees (Catégories / Site(s)) est
// deleguee a hydrateListCollections() une fois le jeu borne, pour ne pas
// imposer un produit cartesien aux chemins non pagines (export,
// ?pagination=false) — ERP-100.
$qb = $this->createQueryBuilder('c') $qb = $this->createQueryBuilder('c')
// Jointures + addSelect pour hydrater en une seule requete les
// collections affichees par le Repertoire (colonnes Catégories /
// Site(s)) : sans cela, la serialisation declenche un N+1 (une
// requete par client, puis par adresse). Le Paginator ORM
// (fetchJoinCollection: true, cf. ClientProvider) gere le COUNT
// malgre ces jointures to-many.
->leftJoin('c.categories', 'cat')->addSelect('cat')
->leftJoin('c.addresses', 'addr')->addSelect('addr')
->leftJoin('addr.sites', 'site')->addSelect('site')
->andWhere('c.deletedAt IS NULL') ->andWhere('c.deletedAt IS NULL')
->orderBy('c.companyName', 'ASC') ->orderBy('c.companyName', 'ASC')
; ;
@@ -66,6 +62,46 @@ class DoctrineClientRepository extends ServiceEntityRepository implements Client
return $qb; return $qb;
} }
public function hydrateListCollections(array $clients): void
{
if ([] === $clients) {
return;
}
// Ids des clients deja charges (entites managees). On rehydrate leurs
// collections via l'identity map : les requetes ci-dessous renvoient les
// MEMES instances Client, dont les collections sont alors remplies.
$ids = [];
foreach ($clients as $client) {
$id = $client->getId();
if (null !== $id) {
$ids[] = $id;
}
}
if ([] === $ids) {
return;
}
// 1re passe : categories (colonne « Catégories »). Produit c x cat seul.
$this->createQueryBuilder('c')
->leftJoin('c.categories', 'cat')->addSelect('cat')
->where('c.id IN (:ids)')->setParameter('ids', $ids)
->getQuery()
->getResult()
;
// 2e passe : adresses + sites (colonne « Site(s) », sites portes par les
// adresses — RG-1.10). Le join addr -> site reste imbrique mais n'est
// plus multiplie par les categories : le cartesien global est casse.
$this->createQueryBuilder('c')
->leftJoin('c.addresses', 'addr')->addSelect('addr')
->leftJoin('addr.sites', 'site')->addSelect('site')
->where('c.id IN (:ids)')->setParameter('ids', $ids)
->getQuery()
->getResult()
;
}
/** /**
* Recherche fuzzy insensible a la casse sur companyName + lastName + email. * Recherche fuzzy insensible a la casse sur companyName + lastName + email.
* Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester * Les metacaracteres LIKE (%, _, \) saisis sont echappes pour rester
@@ -62,6 +62,9 @@ final class RbacSeeder
'permissions' => [ 'permissions' => [
'commercial.clients.view', 'commercial.clients.view',
'commercial.clients.manage', 'commercial.clients.manage',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
], ],
], ],
self::ROLE_COMPTA => [ self::ROLE_COMPTA => [
@@ -70,6 +73,9 @@ final class RbacSeeder
'commercial.clients.view', 'commercial.clients.view',
'commercial.clients.accounting.view', 'commercial.clients.accounting.view',
'commercial.clients.accounting.manage', 'commercial.clients.accounting.manage',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
], ],
], ],
self::ROLE_COMMERCIALE => [ self::ROLE_COMMERCIALE => [
@@ -77,6 +83,9 @@ final class RbacSeeder
'permissions' => [ 'permissions' => [
'commercial.clients.view', 'commercial.clients.view',
'commercial.clients.manage', 'commercial.clients.manage',
// Lecture des referentiels transverses pour les selects client (ERP-102).
'catalog.categories.read_ref',
'sites.read_ref',
], ],
], ],
self::ROLE_USINE => [ self::ROLE_USINE => [
+7 -2
View File
@@ -40,13 +40,18 @@ use Symfony\Component\Validator\Constraints as Assert;
*/ */
#[ApiResource( #[ApiResource(
operations: [ operations: [
// Lecture (liste + item) : permission d'administration `sites.view` OU
// permission de lecture-referentiel transverse `sites.read_ref` (ERP-102).
// Le referentiel sites alimente les selects d'adresse des modules Tiers :
// tout role qui gere des tiers doit pouvoir le lire sans porter l'acces
// admin des Sites.
new GetCollection( new GetCollection(
normalizationContext: ['groups' => ['site:read']], normalizationContext: ['groups' => ['site:read']],
security: "is_granted('sites.view')", security: "is_granted('sites.view') or is_granted('sites.read_ref')",
), ),
new Get( new Get(
normalizationContext: ['groups' => ['site:read']], normalizationContext: ['groups' => ['site:read']],
security: "is_granted('sites.view')", security: "is_granted('sites.view') or is_granted('sites.read_ref')",
), ),
new Post( new Post(
normalizationContext: ['groups' => ['site:read']], normalizationContext: ['groups' => ['site:read']],
+5
View File
@@ -33,6 +33,11 @@ final class SitesModule
['code' => 'sites.view', 'label' => 'Voir les sites'], ['code' => 'sites.view', 'label' => 'Voir les sites'],
['code' => 'sites.manage', 'label' => 'Gerer les sites (creer, editer, supprimer)'], ['code' => 'sites.manage', 'label' => 'Gerer les sites (creer, editer, supprimer)'],
['code' => 'sites.bypass_scope', 'label' => 'Voir les donnees site-scoped de tous les sites (bypass du filtrage)'], ['code' => 'sites.bypass_scope', 'label' => 'Voir les donnees site-scoped de tous les sites (bypass du filtrage)'],
// Lecture-referentiel transverse (ERP-102) : permet de LISTER les sites
// pour alimenter les selects des modules Tiers (adresses client...), sans
// donner l'acces d'administration `.view` (qui ouvre la page Sites dans la
// sidebar). Accordee aux roles metier via la matrice RBAC § 2.7.
['code' => 'sites.read_ref', 'label' => 'Lire le referentiel sites (transverse, lecture seule)'],
]; ];
} }
} }
@@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api; namespace App\Tests\Module\Commercial\Api;
use App\Module\Commercial\Domain\Entity\ClientAddress;
use App\Module\Sites\Domain\Entity\Site;
use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\IOFactory;
/** /**
@@ -88,6 +90,39 @@ final class ClientExportControllerTest extends AbstractCommercialApiTestCase
self::assertNotContains('SECTEUR CO', $names); self::assertNotContains('SECTEUR CO', $names);
} }
/**
* ERP-100 : depuis le decouplage hydratation/selection, le QueryBuilder de
* liste ne fetch-join plus les collections — l'export les recharge en lot via
* hydrateListCollections(). Ce test garde que les colonnes « Catégories » et
* « Site(s) » restent peuplees (un oubli d'hydratation les rendrait vides
* sans erreur).
*/
public function testExportPopulatesCategoryAndSiteColumns(): void
{
$client = $this->createAdminClient();
$seed = $this->seedClient('Hydrate Co', false, 'DISTRIBUTEUR');
$em = $this->getEm();
$site = $em->getRepository(Site::class)->findOneBy([]);
self::assertNotNull($site, 'Aucun site seede : impossible de tester la colonne Site(s).');
$address = new ClientAddress();
$address->setClient($seed);
$address->setPostalCode('86100');
$address->setCity('Châtellerault');
$address->setStreet('1 rue du Test');
$address->addSite($site);
$em->persist($address);
$em->flush();
$flat = $this->flatten($this->gridFromResponse($client->request('GET', self::EXPORT_URL)->getContent()));
// Colonne « Catégories » : libelle de la categorie du client (getName()).
self::assertStringContainsString('test_cli_cat_distributeur', $flat);
// Colonne « Site(s) » : site agrege depuis l'adresse (RG-1.10).
self::assertStringContainsString((string) $site->getName(), $flat);
}
public function testSirenColumnPresentWithAccountingView(): void public function testSirenColumnPresentWithAccountingView(): void
{ {
// L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN. // L'admin bypass le RBAC : il a donc accounting.view -> colonne SIREN.
@@ -272,6 +272,33 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase
self::assertResponseStatusCodeSame(200); self::assertResponseStatusCodeSame(200);
} }
public function testBusinessRolesCanReadCategoriesAndSitesReferentials(): void
{
// ERP-102 : /categories et /sites sont des referentiels TRANSVERSES.
// Tout role qui gere des clients (bureau / compta / commerciale) doit
// pouvoir les LISTER pour alimenter les selects de creation/filtre client,
// via la permission de lecture-referentiel dediee (catalog.categories.read_ref
// / sites.read_ref) attachee par la matrice § 2.7 — sans pour autant porter
// la permission d'administration `.view`. Usine, sans aucune permission,
// reste interdit.
foreach (['bureau', 'compta', 'commerciale'] as $role) {
$client = $this->authAs($role);
$client->request('GET', '/api/categories', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200, sprintf('Le role %s doit pouvoir lister /categories', $role));
$client->request('GET', '/api/sites', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(200, sprintf('Le role %s doit pouvoir lister /sites', $role));
}
// Usine : aucune permission -> reste a 403 sur les referentiels.
$usine = $this->authAs('usine');
$usine->request('GET', '/api/categories', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403, 'Usine ne doit pas pouvoir lister /categories');
$usine->request('GET', '/api/sites', ['headers' => ['Accept' => self::LD]]);
self::assertResponseStatusCodeSame(403, 'Usine ne doit pas pouvoir lister /sites');
}
private function authAs(string $role): Client private function authAs(string $role): Client
{ {
return $this->authenticatedClient($role, self::PWD); return $this->authenticatedClient($role, self::PWD);
+4 -3
View File
@@ -16,17 +16,18 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
*/ */
final class SitesModuleTest extends KernelTestCase final class SitesModuleTest extends KernelTestCase
{ {
public function testPermissionsSetContainsExactlyThreeCodes(): void public function testPermissionsSetContainsExactlyFourCodes(): void
{ {
// Garde-fou : si quelqu'un ajoute une permission sans ajuster les // Garde-fou : si quelqu'un ajoute une permission sans ajuster les
// tests ou la doc, ce test casse explicitement. Si au contraire une // tests ou la doc, ce test casse explicitement. Si au contraire une
// permission disparait (ex: bypass_scope retire par erreur), meme // permission disparait (ex: bypass_scope retire par erreur), meme
// effet. Le set de 3 permissions est fige par ce test. // effet. Le set de permissions est fige par ce test.
// `sites.read_ref` ajoutee en ERP-102 (lecture-referentiel transverse).
$codes = array_column(SitesModule::permissions(), 'code'); $codes = array_column(SitesModule::permissions(), 'code');
sort($codes); sort($codes);
self::assertSame( self::assertSame(
['sites.bypass_scope', 'sites.manage', 'sites.view'], ['sites.bypass_scope', 'sites.manage', 'sites.read_ref', 'sites.view'],
$codes, $codes,
); );
} }