diff --git a/frontend/modules/commercial/composables/__tests__/useClientReferentials.spec.ts b/frontend/modules/commercial/composables/__tests__/useClientReferentials.spec.ts new file mode 100644 index 0000000..67202f6 --- /dev/null +++ b/frontend/modules/commercial/composables/__tests__/useClientReferentials.spec.ts @@ -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' }]) + }) +}) diff --git a/src/Module/Catalog/CatalogModule.php b/src/Module/Catalog/CatalogModule.php index 1bae95f..1465e57 100644 --- a/src/Module/Catalog/CatalogModule.php +++ b/src/Module/Catalog/CatalogModule.php @@ -38,6 +38,11 @@ final class CatalogModule return [ ['code' => 'catalog.categories.view', 'label' => 'Voir les categories'], ['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)'], ]; } } diff --git a/src/Module/Catalog/Domain/Entity/Category.php b/src/Module/Catalog/Domain/Entity/Category.php index 3c55a94..6f1648c 100644 --- a/src/Module/Catalog/Domain/Entity/Category.php +++ b/src/Module/Catalog/Domain/Entity/Category.php @@ -42,13 +42,19 @@ use Symfony\Component\Validator\Constraints as Assert; */ #[ApiResource( 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( - 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']], provider: CategoryProvider::class, ), 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']], provider: CategoryProvider::class, ), diff --git a/src/Module/Core/Application/Rbac/RbacSeeder.php b/src/Module/Core/Application/Rbac/RbacSeeder.php index 777b943..6c6c327 100644 --- a/src/Module/Core/Application/Rbac/RbacSeeder.php +++ b/src/Module/Core/Application/Rbac/RbacSeeder.php @@ -62,6 +62,9 @@ final class RbacSeeder 'permissions' => [ 'commercial.clients.view', 'commercial.clients.manage', + // Lecture des referentiels transverses pour les selects client (ERP-102). + 'catalog.categories.read_ref', + 'sites.read_ref', ], ], self::ROLE_COMPTA => [ @@ -70,6 +73,9 @@ final class RbacSeeder 'commercial.clients.view', 'commercial.clients.accounting.view', 'commercial.clients.accounting.manage', + // Lecture des referentiels transverses pour les selects client (ERP-102). + 'catalog.categories.read_ref', + 'sites.read_ref', ], ], self::ROLE_COMMERCIALE => [ @@ -77,6 +83,9 @@ final class RbacSeeder 'permissions' => [ 'commercial.clients.view', 'commercial.clients.manage', + // Lecture des referentiels transverses pour les selects client (ERP-102). + 'catalog.categories.read_ref', + 'sites.read_ref', ], ], self::ROLE_USINE => [ diff --git a/src/Module/Sites/Domain/Entity/Site.php b/src/Module/Sites/Domain/Entity/Site.php index fd0855d..cc008ff 100644 --- a/src/Module/Sites/Domain/Entity/Site.php +++ b/src/Module/Sites/Domain/Entity/Site.php @@ -40,13 +40,18 @@ use Symfony\Component\Validator\Constraints as Assert; */ #[ApiResource( 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( normalizationContext: ['groups' => ['site:read']], - security: "is_granted('sites.view')", + security: "is_granted('sites.view') or is_granted('sites.read_ref')", ), new Get( normalizationContext: ['groups' => ['site:read']], - security: "is_granted('sites.view')", + security: "is_granted('sites.view') or is_granted('sites.read_ref')", ), new Post( normalizationContext: ['groups' => ['site:read']], diff --git a/src/Module/Sites/Infrastructure/ApiPlatform/Extension/SiteCollectionScopedExtension.php b/src/Module/Sites/Infrastructure/ApiPlatform/Extension/SiteCollectionScopedExtension.php index 8339159..0486a6f 100644 --- a/src/Module/Sites/Infrastructure/ApiPlatform/Extension/SiteCollectionScopedExtension.php +++ b/src/Module/Sites/Infrastructure/ApiPlatform/Extension/SiteCollectionScopedExtension.php @@ -30,6 +30,8 @@ use function sprintf; * - resource != Site::class → no-op (les autres resources sont * gerees par SiteScopedQueryExtension) ; * - is_granted('sites.bypass_scope') → pas de filtre (admin / bypass) ; + * - is_granted('sites.read_ref') → pas de filtre (lecture-referentiel + * transverse complet, ERP-102) ; * - user non authentifie → no-op (API Platform renvoie 401 avant) ; * - user sans aucun site → WHERE 1 = 0 (aucun acces) ; * - cas normal → WHERE site.id IN (:allowedSites). @@ -84,6 +86,16 @@ final class SiteCollectionScopedExtension implements QueryCollectionExtensionInt return; } + // 2bis) Lecture-referentiel transverse (ERP-102) : `sites.read_ref` donne + // acces a la LISTE COMPLETE des sites (selects d'adresse des modules Tiers). + // Sans ce bypass, le cloisonnement par site rattache reduirait le select + // aux seuls sites de l'utilisateur (voire a rien s'il n'en a aucun) et le + // referentiel ne serait plus "transverse". `read_ref` est une lecture seule : + // il ouvre la visibilite sans permettre la moindre ecriture. + if ($this->security->isGranted('sites.read_ref')) { + return; + } + // 3) Pas d'user authentifie -> no-op (API Platform gere le 401 en amont). $user = $this->security->getUser(); if (!$user instanceof User) { diff --git a/src/Module/Sites/SitesModule.php b/src/Module/Sites/SitesModule.php index d1cceb5..bfcd88f 100644 --- a/src/Module/Sites/SitesModule.php +++ b/src/Module/Sites/SitesModule.php @@ -33,6 +33,11 @@ final class SitesModule ['code' => 'sites.view', 'label' => 'Voir les sites'], ['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)'], + // 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)'], ]; } } diff --git a/tests/Module/Commercial/Api/ClientRBACMatrixTest.php b/tests/Module/Commercial/Api/ClientRBACMatrixTest.php index 772c336..e2afbf8 100644 --- a/tests/Module/Commercial/Api/ClientRBACMatrixTest.php +++ b/tests/Module/Commercial/Api/ClientRBACMatrixTest.php @@ -6,6 +6,7 @@ namespace App\Tests\Module\Commercial\Api; use ApiPlatform\Symfony\Bundle\Test\Client; use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures; +use App\Module\Sites\Domain\Entity\Site; use Symfony\Bundle\FrameworkBundle\Console\Application; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\NullOutput; @@ -272,6 +273,51 @@ final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase 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. + // Le referentiel /sites est TRANSVERSE et COMPLET : le cloisonnement par + // site rattache (SiteCollectionScopedExtension) est neutralise par + // `sites.read_ref` (ERP-102). Les comptes demo ne sont rattaches qu'a un + // seul site (Chatellerault) alors que la base en compte plusieurs : on + // verifie donc que le role voit la TOTALITE du referentiel, pas son seul + // site rattache. Sans le bypass de scope, totalItems vaudrait 1. + $totalSites = $this->getEm()->getRepository(Site::class)->count([]); + self::assertGreaterThan( + 1, + $totalSites, + 'Pre-requis du test : la base doit contenir plusieurs sites pour distinguer scope et bypass.', + ); + + 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)); + + $response = $client->request('GET', '/api/sites', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200, sprintf('Le role %s doit pouvoir lister /sites', $role)); + self::assertSame( + $totalSites, + $response->toArray()['totalItems'] ?? null, + sprintf('Le role %s doit voir tout le referentiel sites (%d), pas seulement son site rattache', $role, $totalSites), + ); + } + + // 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 { return $this->authenticatedClient($role, self::PWD); diff --git a/tests/Module/Sites/SitesModuleTest.php b/tests/Module/Sites/SitesModuleTest.php index be9e330..88d9a31 100644 --- a/tests/Module/Sites/SitesModuleTest.php +++ b/tests/Module/Sites/SitesModuleTest.php @@ -16,17 +16,18 @@ use Symfony\Bundle\FrameworkBundle\Test\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 // tests ou la doc, ce test casse explicitement. Si au contraire une // 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'); sort($codes); self::assertSame( - ['sites.bypass_scope', 'sites.manage', 'sites.view'], + ['sites.bypass_scope', 'sites.manage', 'sites.read_ref', 'sites.view'], $codes, ); }