setAutoExit(false); $exit = $application->run( new ArrayInput([ 'command' => 'app:seed-rbac', '--with-demo-users' => true, '--password' => self::PWD, ]), new NullOutput(), ); self::assertSame( 0, $exit, 'app:seed-rbac a echoue : les permissions commercial.clients.* sont-elles synchronisees (app:sync-permissions) ?', ); // Liberer le kernel pour que authenticatedClient()/createClient() reparte propre. self::ensureKernelShutdown(); } public function testUsineIsForbiddenEverywhere(): void { $seed = $this->seedClient('Usine Target'); $client = $this->authAs('usine'); // Aucune permission : 403 sur tous les verbes. $client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]]); self::assertResponseStatusCodeSame(403); $client->request('GET', '/api/clients/'.$seed->getId(), ['headers' => ['Accept' => self::LD]]); self::assertResponseStatusCodeSame(403); $client->request('POST', '/api/clients', [ 'headers' => ['Content-Type' => self::LD], 'json' => $this->validMainPayload('Usine Post'), ]); self::assertResponseStatusCodeSame(403); $client->request('PATCH', '/api/clients/'.$seed->getId(), [ 'headers' => ['Content-Type' => self::MERGE], 'json' => ['companyName' => 'Renamed By Usine'], ]); self::assertResponseStatusCodeSame(403); } public function testBureauHasViewAndManageButNoAccountingNoArchive(): void { $seed = $this->seedClient('Bureau Target'); $cat = $this->createCategory('SECTEUR'); $client = $this->authAs('bureau'); // view $client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]]); self::assertResponseStatusCodeSame(200); // manage : creation OK $client->request('POST', '/api/clients', [ 'headers' => ['Content-Type' => self::LD], 'json' => $this->validMainPayload('Bureau Created', $cat->getId()), ]); self::assertResponseStatusCodeSame(201); // manage : edition onglet principal OK $client->request('PATCH', '/api/clients/'.$seed->getId(), [ 'headers' => ['Content-Type' => self::MERGE], 'json' => ['companyName' => 'Bureau Renamed'], ]); self::assertResponseStatusCodeSame(200); // PAS accounting : edition onglet Comptabilite refusee $client->request('PATCH', '/api/clients/'.$seed->getId(), [ 'headers' => ['Content-Type' => self::MERGE], 'json' => ['siren' => '123456789'], ]); self::assertResponseStatusCodeSame(403); // PAS archive : archivage refuse $client->request('PATCH', '/api/clients/'.$seed->getId(), [ 'headers' => ['Content-Type' => self::MERGE], 'json' => ['isArchived' => true], ]); self::assertResponseStatusCodeSame(403); } public function testComptaCanEditAccountingOnly(): void { $seed = $this->seedClient('Compta Target'); $client = $this->authAs('compta'); // view $client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]]); self::assertResponseStatusCodeSame(200); // PAS manage : creation refusee $client->request('POST', '/api/clients', [ 'headers' => ['Content-Type' => self::LD], 'json' => $this->validMainPayload('Compta Post'), ]); self::assertResponseStatusCodeSame(403); // accounting.manage : edition onglet Comptabilite OK $client->request('PATCH', '/api/clients/'.$seed->getId(), [ 'headers' => ['Content-Type' => self::MERGE], 'json' => ['siren' => '123456789'], ]); self::assertResponseStatusCodeSame(200); // PAS manage : edition onglet principal refusee (guardManage) $client->request('PATCH', '/api/clients/'.$seed->getId(), [ 'headers' => ['Content-Type' => self::MERGE], 'json' => ['companyName' => 'Compta Renamed'], ]); self::assertResponseStatusCodeSame(403); // PAS manage : edition onglet Information refusee (guardManage) $client->request('PATCH', '/api/clients/'.$seed->getId(), [ 'headers' => ['Content-Type' => self::MERGE], 'json' => ['description' => 'Une description'], ]); self::assertResponseStatusCodeSame(403); // PAS archive : archivage refuse $client->request('PATCH', '/api/clients/'.$seed->getId(), [ 'headers' => ['Content-Type' => self::MERGE], 'json' => ['isArchived' => true], ]); self::assertResponseStatusCodeSame(403); } public function testCommercialeHasViewAndManageButNoAccountingNoArchive(): void { $seed = $this->seedClient('Commerciale Target'); $client = $this->authAs('commerciale'); // view $client->request('GET', '/api/clients', ['headers' => ['Accept' => self::LD]]); self::assertResponseStatusCodeSame(200); // manage : la creation passe la security d'operation (pas un 403 comme // Compta) mais bute sur RG-1.04 (onglet Information incomplet) -> 422. // C'est la preuve que Commerciale porte `manage` (sinon 403). $client->request('POST', '/api/clients', [ 'headers' => ['Content-Type' => self::LD], 'json' => $this->validMainPayload('Commerciale Post'), ]); self::assertResponseStatusCodeSame(422); // PAS accounting : edition onglet Comptabilite refusee $client->request('PATCH', '/api/clients/'.$seed->getId(), [ 'headers' => ['Content-Type' => self::MERGE], 'json' => ['siren' => '123456789'], ]); self::assertResponseStatusCodeSame(403); // PAS archive : archivage refuse $client->request('PATCH', '/api/clients/'.$seed->getId(), [ 'headers' => ['Content-Type' => self::MERGE], 'json' => ['isArchived' => true], ]); self::assertResponseStatusCodeSame(403); } public function testRG104CommercialePostIncompleteIs422AdminIs201(): void { $cat = $this->createCategory('SECTEUR'); // RG-1.04 durcie : Commerciale POST sans onglet Information complet -> 422. $commerciale = $this->authAs('commerciale'); $commerciale->request('POST', '/api/clients', [ 'headers' => ['Content-Type' => self::LD], 'json' => $this->validMainPayload('RG104 Commerciale', $cat->getId()), ]); self::assertResponseStatusCodeSame(422); // Meme payload par un Admin (non gate par RG-1.04) -> 201. $admin = $this->createAdminClient(); $admin->request('POST', '/api/clients', [ 'headers' => ['Content-Type' => self::LD], 'json' => $this->validMainPayload('RG104 Admin', $cat->getId()), ]); self::assertResponseStatusCodeSame(201); } public function testComptaFullRepresentationPatchWithUnchangedCategoriesIsNotForbidden(): void { // FIX review MR #40 : un Compta (accounting.manage, PAS manage) faisant un // PATCH representation complete de l'onglet Comptabilite et reincluant ses // categories INCHANGEES ne doit PAS prendre de 403. guardManage compare // desormais les categories par valeur (et non par simple presence) : seul // l'onglet Comptabilite change ici -> 200. $seed = $this->seedClient('Compta Cat Unchanged'); $category = $seed->getCategories()->first(); self::assertNotFalse($category); $catId = $category->getId(); $client = $this->authAs('compta'); $client->request('PATCH', '/api/clients/'.$seed->getId(), [ 'headers' => ['Content-Type' => self::MERGE], 'json' => [ 'siren' => '123456789', 'categories' => ['/api/categories/'.$catId], ], ]); self::assertResponseStatusCodeSame(200); } public function testComptaChangingCategoriesIsForbidden(): void { // Non-regression : si le Compta change REELLEMENT l'ensemble des // categories (sans manage) -> 403 via guardManage. La comparaison par // valeur detecte bien le changement. $seed = $this->seedClient('Compta Cat Change'); $newCat = $this->createCategory('SECTEUR'); $client = $this->authAs('compta'); $client->request('PATCH', '/api/clients/'.$seed->getId(), [ 'headers' => ['Content-Type' => self::MERGE], 'json' => ['categories' => ['/api/categories/'.$newCat->getId()]], ]); self::assertResponseStatusCodeSame(403); } public function testBureauChangingCategoriesIsAllowed(): void { // Non-regression : un role porteur de `manage` (Bureau) peut changer les // categories -> 200. $seed = $this->seedClient('Bureau Cat Change'); $newCat = $this->createCategory('SECTEUR'); $client = $this->authAs('bureau'); $client->request('PATCH', '/api/clients/'.$seed->getId(), [ 'headers' => ['Content-Type' => self::MERGE], 'json' => ['categories' => ['/api/categories/'.$newCat->getId()]], ]); 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); } /** * Payload minimal valide de l'onglet principal (companyName + une categorie * SECTEUR ; le contact inline a ete supprime). Si $categoryId est null, une * categorie est creee a la volee. * * @return array */ private function validMainPayload(string $companyName, ?int $categoryId = null): array { $categoryId ??= $this->createCategory('SECTEUR')->getId(); return [ 'companyName' => $companyName, 'categories' => ['/api/categories/'.$categoryId], ]; } }