Files
Starseed/tests/Module/Commercial/Api/ClientRBACMatrixTest.php
T
Matthieu 49cf798fc9
Pull Request — Quality gate / Backend (PHP CS + PHPUnit) (pull_request) Successful in 1m52s
Pull Request — Quality gate / Frontend (lint + Vitest + build) (pull_request) Successful in 1m16s
test(commercial) : full RBAC matrix (M1)
ClientRBACMatrixTest : matrice § 2.7 par role metier via les comptes demo seedes
par la commande reelle app:seed-rbac --with-demo-users (idempotente). Valide
200/403 par verbe et par onglet pour bureau / compta / commerciale / usine :
- usine : 403 partout ;
- bureau : view + manage, sans accounting ni archive ;
- compta : view + edition accounting (200), POST/main/information/archive -> 403 ;
- commerciale : view + manage, sans accounting ni archive ;
- RG-1.04 : POST Commerciale incomplet -> 422, meme POST par Admin -> 201.
2026-06-01 22:26:56 +02:00

246 lines
9.4 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Module\Commercial\Api;
use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Core\Infrastructure\DataFixtures\RbacDemoFixtures;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\NullOutput;
/**
* Matrice RBAC complete du repertoire clients par role metier (spec-back M1
* § 2.7 + cahier ERP-74). Valide 200/403 par verbe et par onglet pour
* bureau / compta / commerciale / usine, plus le durcissement RG-1.04
* (Commerciale) au POST.
*
* Les comptes demo et la matrice sont seedes via la commande reelle
* `app:seed-rbac --with-demo-users` (le MEME chemin qu'en recette), idempotente.
* Pre-requis du run : `app:sync-permissions` a tourne (cf. make test-db-setup).
*
* @internal
*/
final class ClientRBACMatrixTest extends AbstractCommercialApiTestCase
{
private const string LD = 'application/ld+json';
private const string MERGE = 'application/merge-patch+json';
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
protected function setUp(): void
{
parent::setUp();
// Seed idempotent via la commande applicative (roles + matrice § 2.7 +
// comptes demo). Exerce aussi le chemin de code prod.
self::bootKernel();
$application = new Application(self::$kernel);
$application->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);
}
private function authAs(string $role): Client
{
return $this->authenticatedClient($role, self::PWD);
}
/**
* Payload minimal valide de l'onglet principal (RG-1.01 : un nom de contact ;
* une categorie SECTEUR). Si $categoryId est null, une categorie est creee a
* la volee.
*
* @return array<string, mixed>
*/
private function validMainPayload(string $companyName, ?int $categoryId = null): array
{
$categoryId ??= $this->createCategory('SECTEUR')->getId();
return [
'companyName' => $companyName,
'firstName' => 'Jean',
'phonePrimary' => '0612345678',
'email' => strtolower(str_replace(' ', '', $companyName)).'@matrix.test',
'categories' => ['/api/categories/'.$categoryId],
];
}
}