getEm(); $saintJean = $em->getRepository(Site::class)->findOneBy(['name' => 'Saint-Jean']); self::assertNotNull($saintJean); $alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); $aliceId = $alice->getId(); $em->clear(); $client = $this->authenticatedClient('admin', 'admin'); $client->request('PATCH', '/api/users/'.$aliceId.'/rbac', [ 'headers' => ['Content-Type' => 'application/merge-patch+json'], 'json' => [ 'sites' => ['/api/sites/'.$saintJean->getId()], ], ]); self::assertResponseIsSuccessful(); // Verification cote base. $em = $this->getEm(); $em->clear(); $reloaded = $em->getRepository(User::class)->find($aliceId); self::assertNotNull($reloaded); self::assertCount(1, $reloaded->getSites()); self::assertSame('Saint-Jean', $reloaded->getSites()->first()->getName()); // Restauration pour ne pas polluer les autres tests. $this->restoreAliceSites(); } public function testRemovingCurrentSiteResetsCurrentSiteToNullThenAutoSelectsFirst(): void { // alice a actuellement {Chatellerault}, currentSite=Chatellerault. // On lui attribue {Saint-Jean} : Chatellerault disparait → currentSite // devrait temporairement etre null, PUIS auto-select Saint-Jean (seul // site restant). $em = $this->getEm(); $saintJean = $em->getRepository(Site::class)->findOneBy(['name' => 'Saint-Jean']); $alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); $aliceId = $alice->getId(); $em->clear(); $client = $this->authenticatedClient('admin', 'admin'); $client->request('PATCH', '/api/users/'.$aliceId.'/rbac', [ 'headers' => ['Content-Type' => 'application/merge-patch+json'], 'json' => [ 'sites' => ['/api/sites/'.$saintJean->getId()], ], ]); self::assertResponseIsSuccessful(); $em = $this->getEm(); $em->clear(); $reloaded = $em->getRepository(User::class)->find($aliceId); self::assertNotNull($reloaded->getCurrentSite()); self::assertSame('Saint-Jean', $reloaded->getCurrentSite()->getName()); $this->restoreAliceSites(); } public function testEmptySitesPayloadResetsCurrentSiteToNull(): void { $em = $this->getEm(); $alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); $aliceId = $alice->getId(); $em->clear(); $client = $this->authenticatedClient('admin', 'admin'); $client->request('PATCH', '/api/users/'.$aliceId.'/rbac', [ 'headers' => ['Content-Type' => 'application/merge-patch+json'], 'json' => [ 'sites' => [], ], ]); self::assertResponseIsSuccessful(); $em = $this->getEm(); $em->clear(); $reloaded = $em->getRepository(User::class)->find($aliceId); self::assertCount(0, $reloaded->getSites()); self::assertNull($reloaded->getCurrentSite()); $this->restoreAliceSites(); } public function testCurrentSiteFieldInRbacPayloadIsSilentlyIgnored(): void { // Garde structurelle : `currentSite` n'est pas dans le groupe // user:rbac:write. Un client malveillant qui essaierait de set un // currentSite arbitraire via /rbac doit etre silencieusement // ignore (le seul flux autorise est PATCH /me/current-site). $em = $this->getEm(); $pommevic = $em->getRepository(Site::class)->findOneBy(['name' => 'Pommevic']); $alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); $aliceId = $alice->getId(); $em->clear(); $client = $this->authenticatedClient('admin', 'admin'); $client->request('PATCH', '/api/users/'.$aliceId.'/rbac', [ 'headers' => ['Content-Type' => 'application/merge-patch+json'], 'json' => [ 'currentSite' => '/api/sites/'.$pommevic->getId(), ], ]); self::assertResponseIsSuccessful(); // alice n'a Pommevic ni dans ses sites ni en currentSite (le champ // a ete ignore par le denormalizer). Son currentSite reste son // Chatellerault d'origine. $em = $this->getEm(); $em->clear(); $reloaded = $em->getRepository(User::class)->find($aliceId); self::assertNotNull($reloaded); self::assertNotNull($reloaded->getCurrentSite()); self::assertSame('Chatellerault', $reloaded->getCurrentSite()->getName()); } /** * Defense-in-depth contre un bypass hypothetique de restoreAbsentCollections. * * API Platform rejette deja `sites: null` au denormalize (400 Bad Request, * type mismatch). Le guard `is_array` dans UserRbacProcessor est une * deuxieme ligne de defense si la config denormalizer change un jour. * * Ce test verrouille deux garanties : * 1. `{"sites": null}` → 400 (pas un 500, pas un 200 silencieux) * 2. La collection sites d'alice est intacte apres l'echec */ public function testRbacPatchWithNullSitesReturns400AndDoesNotWipeSitesCollection(): void { $em = $this->getEm(); $alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); $aliceId = $alice->getId(); self::assertCount(1, $alice->getSites(), 'Pre-condition : alice doit avoir exactement 1 site'); $em->clear(); $client = $this->authenticatedClient('admin', 'admin'); $client->request('PATCH', '/api/users/'.$aliceId.'/rbac', [ 'headers' => ['Content-Type' => 'application/merge-patch+json'], 'json' => [ 'isAdmin' => false, 'sites' => null, ], ]); self::assertResponseStatusCodeSame(400); $em = $this->getEm(); $em->clear(); $reloaded = $em->getRepository(User::class)->find($aliceId); self::assertCount( 1, $reloaded->getSites(), 'Un payload `sites: null` rejete en 400 ne doit laisser aucune trace en DB.', ); } public function testRbacPatchWithoutSitesFieldDoesNotChangeCurrentSite(): void { // Garde structurelle : si le payload /rbac ne contient pas le champ // `sites`, ensureCurrentSiteConsistency ne doit pas auto-modifier // le currentSite (alice avait deja Chatellerault). Un PATCH qui // change uniquement isAdmin ou roles ne doit pas remuer la // configuration site de l'user. $em = $this->getEm(); $alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); $aliceId = $alice->getId(); $em->clear(); $client = $this->authenticatedClient('admin', 'admin'); $client->request('PATCH', '/api/users/'.$aliceId.'/rbac', [ 'headers' => ['Content-Type' => 'application/merge-patch+json'], 'json' => [ 'isAdmin' => false, ], ]); self::assertResponseIsSuccessful(); $em = $this->getEm(); $em->clear(); $reloaded = $em->getRepository(User::class)->find($aliceId); self::assertNotNull($reloaded->getCurrentSite()); self::assertSame('Chatellerault', $reloaded->getCurrentSite()->getName()); // Garde non-regression : la collection `sites` elle-meme doit etre // preservee (cf. fix restoreAbsentCollections + initialize LAZY). Un // PATCH /rbac sans cle `sites` ne doit en aucun cas vider la relation. self::assertCount(1, $reloaded->getSites()); self::assertSame('Chatellerault', $reloaded->getSites()->first()->getName()); } public function testRbacPatchWithoutSitesKeyDoesNotAutoSwitchCurrentSiteWhenNull(): void { // Scenario : alice a des sites mais currentSite=null. Un PATCH /rbac // qui ne touche PAS a la clef `sites` ne doit PAS auto-selectionner // silencieusement un site via ensureCurrentSiteConsistency. // // Sans ce garde, un payload { "isAdmin": false } pourrait, si la // denormalisation marque la collection `sites` dirty a tort (ou si // la logique de detection se base sur PersistentCollection::isDirty() // avant restauration), declencher ensureCurrentSiteConsistency et // recaler currentSite sur sites->first() — ce qui est un effet de // bord indetectable par le client. $em = $this->getEm(); $alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); $aliceId = $alice->getId(); $alice->setCurrentSite(null); $em->flush(); $em->clear(); $client = $this->authenticatedClient('admin', 'admin'); $client->request('PATCH', '/api/users/'.$aliceId.'/rbac', [ 'headers' => ['Content-Type' => 'application/merge-patch+json'], 'json' => ['isAdmin' => false], ]); self::assertResponseIsSuccessful(); $em = $this->getEm(); $em->clear(); $reloaded = $em->getRepository(User::class)->find($aliceId); self::assertNotNull($reloaded); // currentSite doit rester null : PATCH /rbac sans clef `sites` ne doit // pas muter la selection de site courant de l'user. self::assertNull( $reloaded->getCurrentSite(), 'PATCH /rbac sans clef `sites` ne doit pas auto-selectionner un site.', ); // Les sites eux-memes doivent etre preserves. self::assertCount(1, $reloaded->getSites()); $this->restoreAliceSites(); } public function testRbacPatchWithoutSitesKeyDoesNotRequireSitesManagePermission(): void { // Scenario Codex : un user non-admin qui porte `core.users.manage` // mais PAS `sites.manage` doit pouvoir PATCHer /rbac sans clef `sites` // sans se faire refuser l'acces. // // Si sitesWereMutated se base uniquement sur PersistentCollection::isDirty() // calcule avant restoreAbsentCollections, une denormalisation qui // marque a tort la collection dirty lorsque la clef est absente du // payload lancerait un 403 parasite. La source de verite doit etre // la presence de la clef dans le payload JSON. $this->skipIfSitesModuleDisabled(); $em = $this->getEm(); $alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); $aliceId = $alice->getId(); $em->clear(); // User jetable : core.users.manage uniquement (pas sites.manage). $creds = $this->createUserWithPermission('core.users.manage'); $client = $this->authenticatedClient($creds['username'], $creds['password']); $client->request('PATCH', '/api/users/'.$aliceId.'/rbac', [ 'headers' => ['Content-Type' => 'application/merge-patch+json'], 'json' => ['isAdmin' => false], ]); // Pas de 403 : la requete ne touche pas aux sites, sites.manage n'est // pas requis. self::assertResponseIsSuccessful(); } /** * Remet alice dans l'etat des fixtures : un seul site Chatellerault, * currentSite Chatellerault. Evite la pollution inter-tests. */ private function restoreAliceSites(): void { $em = $this->getEm(); $chatellerault = $em->getRepository(Site::class)->findOneBy(['name' => 'Chatellerault']); $alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']); // Reset complet des sites foreach ($alice->getSites() as $existing) { $alice->removeSite($existing); } $alice->addSite($chatellerault); $alice->setCurrentSite($chatellerault); $em->flush(); } }