3fe0f676f6
Auto Tag Develop / tag (push) Successful in 11s
Ticket Lesstime #139 (M3 — Répertoire prestataires, position 1.9). DoD back avant le front : suite PHPUnit consolidée sur la matrice § 8.1 + captures JSON réelles dans la spec § 4.0.bis. ## Contenu - **Fix réfs comptables** : `provider:read:accounting` ajouté sur `TvaMode`/`PaymentDelay`/`PaymentType`/`Bank` — sans ça elles sortaient en IRI nu dans le détail prestataire (réplique du fix ERP-92 du M2, piège #1 § 4.0.bis). - **`ProviderSerializationContractTest`** (13 tests) : gating RIB/scalaires par omission, réfs compta en objet `{id,code,label}`, `isArchived`, embed categories/sites liste+détail, sous-collections, enveloppe AP4 ; `testDodReferenceJsonShape` dumpe le JSON réel (`PROVIDER_DOD_DUMP=1`). - **`ProviderAuditTest`** (5 tests) : create/update/archive (`technique.Provider`), iban/bic dans le diff (`technique.ProviderRib`, pas dAuditIgnore), trace M2M `sites`. - **`ProviderListTest`** étendu : `?pagination=false`, anti-N+1, filtre `?typeCode=PRESTATAIRE`. - **`ProviderRbacGatingTest`** étendu : restauration en conflit de nom → 409 (RG-3.14). - **`ProviderFixtures`** (§ 8.4) : démo idempotente (complet VIREMENT+banque+RIB, LCR+RIB, CHEQUE multi-cat, minimal, archivé) répartie sur sites 86/17/82 ; skip en env `test`. - Helper `seedCompleteProvider` ; spec § 4.0.bis : gabarits remplacés par les captures réelles (liste + détail avec/sans accounting.view). ## Vérifications - `make php-cs-fixer-allow-risky` → 0 fichier - `make test` → OK, 677 tests, 3328 assertions (garde-fous globaux verts) ## Notes - MR stackée sur ERP-138 (base = sa branche). - Fixtures démo exercées en dev via `make fixtures` (autowiring vérifié). --------- Co-authored-by: Matthieu <contact@malio.fr> Reviewed-on: #100
280 lines
11 KiB
PHP
280 lines
11 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Module\Technique\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 prestataires par role metier (spec-back M3
|
|
* § 2.9 + § 2.13, ERP-138). Valide 200/403 par verbe et par onglet pour
|
|
* bureau / compta / commerciale / usine, le gating des champs comptables en
|
|
* lecture (omission de cle) et le cloisonnement par site de l'Usine.
|
|
*
|
|
* 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 —
|
|
* pas de mock de role. Jumeau de SupplierRBACMatrixTest (M2), avec la difference
|
|
* structurante du M3 : l'Usine n'est plus « 403 partout » mais possede
|
|
* `technique.providers.view` en lecture seule, CLOISONNEE a son site courant
|
|
* (pas de `sites.bypass_scope`).
|
|
*
|
|
* Matrice § 2.9 (ERP-138) — rappel :
|
|
* - bureau : providers.view + manage (ni accounting, ni archive) + bypass_scope
|
|
* - compta : providers.view + accounting.view + accounting.manage (PAS manage) + bypass_scope
|
|
* - commerciale : providers.view + manage (PAS accounting) + bypass_scope
|
|
* - usine : providers.view seul, SANS bypass_scope (cloisonne a son site)
|
|
* - archive : admin seul (aucun role metier)
|
|
*
|
|
* @internal
|
|
*/
|
|
final class ProviderRBACMatrixTest extends AbstractProviderApiTestCase
|
|
{
|
|
private const string PWD = RbacDemoFixtures::DEMO_PASSWORD;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
parent::setUp();
|
|
|
|
// Seed idempotent via la commande applicative (roles + matrice § 2.9 +
|
|
// 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 technique.providers.* sont-elles synchronisees (app:sync-permissions) ?',
|
|
);
|
|
|
|
self::ensureKernelShutdown();
|
|
}
|
|
|
|
public function testBureauHasViewAndManageButNoAccountingNoArchive(): void
|
|
{
|
|
$seed = $this->seedProvider('Bureau Cible');
|
|
$client = $this->authAs('bureau');
|
|
|
|
// view
|
|
$client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
|
|
self::assertResponseStatusCodeSame(200);
|
|
|
|
// manage : creation OK (bypass_scope -> peut attacher le site 86)
|
|
$client->request('POST', '/api/providers', [
|
|
'headers' => ['Content-Type' => self::LD],
|
|
'json' => $this->validMainPayload('Bureau Cree'),
|
|
]);
|
|
self::assertResponseStatusCodeSame(201);
|
|
|
|
// manage : edition onglet principal OK
|
|
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
|
'headers' => ['Content-Type' => self::MERGE],
|
|
'json' => ['companyName' => 'Bureau Renomme'],
|
|
]);
|
|
self::assertResponseStatusCodeSame(200);
|
|
|
|
// PAS accounting : edition onglet Comptabilite refusee
|
|
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
|
'headers' => ['Content-Type' => self::MERGE],
|
|
'json' => ['siren' => '123456789'],
|
|
]);
|
|
self::assertResponseStatusCodeSame(403);
|
|
|
|
// PAS archive : archivage refuse
|
|
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
|
'headers' => ['Content-Type' => self::MERGE],
|
|
'json' => ['isArchived' => true],
|
|
]);
|
|
self::assertResponseStatusCodeSame(403);
|
|
}
|
|
|
|
public function testBureauDetailHasNoAccountingFields(): void
|
|
{
|
|
// Bureau a view mais PAS accounting.view : les champs comptables sont
|
|
// ABSENTS du JSON (gating par omission, pas null).
|
|
$provider = $this->seedProvider('Bureau Gating Co', [self::SITE_86], siren: '123456789');
|
|
$client = $this->authAs('bureau');
|
|
|
|
$data = $client->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
|
|
|
self::assertArrayNotHasKey('siren', $data);
|
|
self::assertArrayNotHasKey('accountNumber', $data);
|
|
self::assertArrayNotHasKey('nTva', $data);
|
|
self::assertArrayNotHasKey('tvaMode', $data);
|
|
self::assertArrayNotHasKey('paymentType', $data);
|
|
self::assertArrayNotHasKey('ribs', $data);
|
|
}
|
|
|
|
public function testComptaCanEditAccountingOnly(): void
|
|
{
|
|
$seed = $this->seedProvider('Compta Cible');
|
|
$client = $this->authAs('compta');
|
|
|
|
// view
|
|
$client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
|
|
self::assertResponseStatusCodeSame(200);
|
|
|
|
// PAS manage : creation refusee
|
|
$client->request('POST', '/api/providers', [
|
|
'headers' => ['Content-Type' => self::LD],
|
|
'json' => $this->validMainPayload('Compta Post'),
|
|
]);
|
|
self::assertResponseStatusCodeSame(403);
|
|
|
|
// accounting.manage : edition onglet Comptabilite OK
|
|
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
|
'headers' => ['Content-Type' => self::MERGE],
|
|
'json' => ['siren' => '123456789'],
|
|
]);
|
|
self::assertResponseStatusCodeSame(200);
|
|
|
|
// PAS manage : edition onglet principal refusee (mode strict RG-3.15)
|
|
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
|
'headers' => ['Content-Type' => self::MERGE],
|
|
'json' => ['companyName' => 'Compta Renomme'],
|
|
]);
|
|
self::assertResponseStatusCodeSame(403);
|
|
|
|
// PAS archive : archivage refuse
|
|
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
|
'headers' => ['Content-Type' => self::MERGE],
|
|
'json' => ['isArchived' => true],
|
|
]);
|
|
self::assertResponseStatusCodeSame(403);
|
|
}
|
|
|
|
public function testComptaDetailHasAccountingFields(): void
|
|
{
|
|
// Compta a accounting.view : siren + ribs presents dans le JSON.
|
|
$provider = $this->seedProvider('Compta View Co', [self::SITE_86], siren: '987654321');
|
|
$this->addRib($provider);
|
|
$client = $this->authAs('compta');
|
|
|
|
$data = $client->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
|
|
|
self::assertArrayHasKey('siren', $data);
|
|
self::assertSame('987654321', $data['siren']);
|
|
self::assertArrayHasKey('ribs', $data);
|
|
self::assertNotEmpty($data['ribs']);
|
|
}
|
|
|
|
public function testCommercialeHasViewAndManageButNoAccountingNoArchive(): void
|
|
{
|
|
$seed = $this->seedProvider('Commerciale Cible');
|
|
$client = $this->authAs('commerciale');
|
|
|
|
// view
|
|
$client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
|
|
self::assertResponseStatusCodeSame(200);
|
|
|
|
// manage : creation OK
|
|
$client->request('POST', '/api/providers', [
|
|
'headers' => ['Content-Type' => self::LD],
|
|
'json' => $this->validMainPayload('Commerciale Cree'),
|
|
]);
|
|
self::assertResponseStatusCodeSame(201);
|
|
|
|
// PAS accounting : edition onglet Comptabilite refusee
|
|
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
|
'headers' => ['Content-Type' => self::MERGE],
|
|
'json' => ['siren' => '123456789'],
|
|
]);
|
|
self::assertResponseStatusCodeSame(403);
|
|
|
|
// PAS archive : archivage refuse
|
|
$client->request('PATCH', '/api/providers/'.$seed->getId(), [
|
|
'headers' => ['Content-Type' => self::MERGE],
|
|
'json' => ['isArchived' => true],
|
|
]);
|
|
self::assertResponseStatusCodeSame(403);
|
|
}
|
|
|
|
public function testCommercialeDetailHasNoAccountingFields(): void
|
|
{
|
|
$provider = $this->seedProvider('Commerciale Gating Co', [self::SITE_86], siren: '123456789');
|
|
$client = $this->authAs('commerciale');
|
|
|
|
$data = $client->request('GET', '/api/providers/'.$provider->getId(), ['headers' => ['Accept' => self::LD]])->toArray();
|
|
|
|
self::assertArrayNotHasKey('siren', $data);
|
|
self::assertArrayNotHasKey('accountNumber', $data);
|
|
self::assertArrayNotHasKey('nTva', $data);
|
|
self::assertArrayNotHasKey('tvaMode', $data);
|
|
self::assertArrayNotHasKey('paymentType', $data);
|
|
self::assertArrayNotHasKey('ribs', $data);
|
|
}
|
|
|
|
public function testUsineHasReadOnlyAccessScopedToItsSite(): void
|
|
{
|
|
// Usine a view (lecture seule), SANS manage / accounting / archive, et
|
|
// SANS bypass_scope -> cloisonnee a son site courant (Chatellerault,
|
|
// site 86, pose par ensureDemoUsers).
|
|
$inScope = $this->seedProvider('Usine InScope', [self::SITE_86]);
|
|
$client = $this->authAs('usine');
|
|
|
|
// view : liste OK (pas un 403 comme au M2)
|
|
$client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]);
|
|
self::assertResponseStatusCodeSame(200);
|
|
|
|
// view : detail d'un prestataire de SON site OK
|
|
$client->request('GET', '/api/providers/'.$inScope->getId(), ['headers' => ['Accept' => self::LD]]);
|
|
self::assertResponseStatusCodeSame(200);
|
|
|
|
// PAS manage : creation refusee
|
|
$client->request('POST', '/api/providers', [
|
|
'headers' => ['Content-Type' => self::LD],
|
|
'json' => $this->validMainPayload('Usine Post'),
|
|
]);
|
|
self::assertResponseStatusCodeSame(403);
|
|
|
|
// PAS manage : edition onglet principal refusee
|
|
$client->request('PATCH', '/api/providers/'.$inScope->getId(), [
|
|
'headers' => ['Content-Type' => self::MERGE],
|
|
'json' => ['companyName' => 'Renomme Par Usine'],
|
|
]);
|
|
self::assertResponseStatusCodeSame(403);
|
|
|
|
// PAS accounting : edition onglet Comptabilite refusee
|
|
$client->request('PATCH', '/api/providers/'.$inScope->getId(), [
|
|
'headers' => ['Content-Type' => self::MERGE],
|
|
'json' => ['siren' => '123456789'],
|
|
]);
|
|
self::assertResponseStatusCodeSame(403);
|
|
|
|
// PAS archive : archivage refuse
|
|
$client->request('PATCH', '/api/providers/'.$inScope->getId(), [
|
|
'headers' => ['Content-Type' => self::MERGE],
|
|
'json' => ['isArchived' => true],
|
|
]);
|
|
self::assertResponseStatusCodeSame(403);
|
|
}
|
|
|
|
public function testUsineCannotSeeProviderOutOfItsSite(): void
|
|
{
|
|
// Cloisonnement § 2.13 : un prestataire hors du site courant de l'Usine
|
|
// (site 17, l'Usine est sur le site 86) -> 404 (ne pas reveler la ligne).
|
|
$outOfScope = $this->seedProvider('Usine OutOfScope', [self::SITE_17]);
|
|
$client = $this->authAs('usine');
|
|
|
|
$client->request('GET', '/api/providers/'.$outOfScope->getId(), ['headers' => ['Accept' => self::LD]]);
|
|
self::assertResponseStatusCodeSame(404);
|
|
}
|
|
|
|
private function authAs(string $role): Client
|
|
{
|
|
return $this->authenticatedClient($role, self::PWD);
|
|
}
|
|
}
|