Files
Starseed/tests/Module/Technique/Api/ProviderSubResourceApiTest.php
T
Matthieu 8b6b4f2dbb fix(technique) : cloisonner par site les sous-ressources prestataire + RG-3.04 fonction (ERP-134, ERP-135)
Les operations Get/Patch/Delete des sous-ressources Contact/Adresse/RIB
passaient par le provider Doctrine par defaut (non cloisonne), et le POST
resolvait le parent sans controle de scope : un user cloisonne pouvait
lire/editer/supprimer une sous-ressource d'un prestataire hors de son site
(IBAN/BIC du RIB inclus). SiteScopedQueryExtension ne filtre que les
SiteAwareInterface, que ces entites ne sont pas.

- ProviderSiteScopeChecker : decision de cloisonnement centralisee (source
  unique), consommee par ProviderProvider (refactore), le provider decore
  et les processors.
- ProviderSubResourceItemProvider : decore le provider par defaut sur
  Get/Patch/Delete des 3 sous-ressources -> 404 si parent hors perimetre.
- Garde assertInScope au POST dans les 3 processors -> 404 si parent hors
  perimetre. ProviderOwnedInterface sur les 3 entites.

RG-3.04 : alignement code <-> spec (ligne 926). La Fonction (jobTitle) rend
desormais un contact valide a elle seule : ajout au validateName, au CHECK
chk_provider_contact_name et normalisation (normalizeText, vide -> null).

Tests : ProviderSubResourceSiteScopeTest (fuite cross-site, 7 cas) ;
RG-3.04 jobTitle reecrit. Spec § 2.13 corrigee (l'heritage n'etait pas
automatique). Suite back complete verte (685 tests).
2026-06-12 16:28:01 +02:00

412 lines
16 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
use App\Module\Technique\Domain\Entity\Provider;
/**
* Tests fonctionnels des sous-ressources Contacts / Adresses / RIB du prestataire
* (M3, spec § 4.5 — ERP-135). Couvrent : normalisation contact (RG-3.11), RG-3.04
* (au moins un champ parmi prenom/nom/fonction/telephone/email), RG-3.05 (>= 1 site sur
* l'adresse), RG-3.06 (code postal), RG-3.09 (categorie PRESTATAIRE sur adresse),
* le cloisonnement d'ecriture des sites de l'adresse (§ 2.13 -> 422 sur `sites`),
* RG-3.08 (DELETE dernier RIB sous LCR -> 409), DELETE contact libre au M3 (pas de
* garde « dernier contact ») et le gating selon permission (Contacts/Adresses =
* manage, RIB = accounting.manage). Jumeau de SupplierSubResourceApiTest.
*
* @internal
*/
final class ProviderSubResourceApiTest extends AbstractProviderApiTestCase
{
protected function setUp(): void
{
parent::setUp();
// seedProvider exige >= 1 site (RG-3.03) : le module Sites doit etre actif.
$this->skipIfSitesModuleDisabled();
}
// === Contacts (security: technique.providers.manage) ===
public function testPostContactNormalizesFields(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Contact Host');
$data = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'firstName' => 'JEAN',
'lastName' => 'dupont',
'phonePrimary' => '06.12.34.56.78',
'email' => 'Jean.DUPONT@ACME.FR',
],
])->toArray();
self::assertResponseStatusCodeSame(201);
// RG-3.11 : prenom/nom Title Case, telephone chiffres seuls, email lowercase.
self::assertSame('Jean', $data['firstName']);
self::assertSame('Dupont', $data['lastName']);
self::assertSame('0612345678', $data['phonePrimary']);
self::assertSame('jean.dupont@acme.fr', $data['email']);
}
/**
* RG-3.04 : un bloc Contact est valide des qu'AU MOINS UN champ est rempli parmi
* prenom / nom / FONCTION / telephone / email (spec § RG-3.04, ligne 926). Ici
* seul jobTitle (Fonction) est fourni -> le bloc est valide -> 201.
*/
public function testPostContactWithOnlyJobTitleReturns201(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Contact JobTitle Only');
$data = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['jobTitle' => 'Directeur'],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertSame('Directeur', $data['jobTitle']);
}
/**
* RG-3.04 : un bloc Contact TOTALEMENT vide (aucun champ du CHECK
* chk_provider_contact_name) est rejete avant la base -> 422 rattachee a
* firstName. Une Fonction vide (apres normalisation) ne suffit pas a valider.
*/
public function testPostContactCompletelyEmptyReturns422OnFirstNamePath(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Contact No Field');
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['jobTitle' => ' '],
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('firstName', $this->violationsByPath($response->toArray(false)));
}
public function testPostContactOnMissingProviderReturns404(): void
{
$client = $this->createAdminClient();
$client->request('POST', '/api/providers/999999/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['firstName' => 'Orphan'],
]);
self::assertResponseStatusCodeSame(404);
}
public function testPatchContactNormalizesFields(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Contact Patch');
$contact = $this->addContact($seed, 'Marie', 'Martin');
$data = $client->request('PATCH', '/api/provider_contacts/'.$contact->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['lastName' => 'durand'],
])->toArray();
self::assertResponseStatusCodeSame(200);
// Normalisation aussi sur PATCH : "durand" -> "Durand".
self::assertSame('Durand', $data['lastName']);
}
public function testDeleteLastContactReturns204(): void
{
// M3 : pas de garde « dernier contact » (RG-3.12 front-driven) — la
// suppression du dernier contact est libre (204).
$client = $this->createAdminClient();
$seed = $this->seedProvider('Contact Solo');
$contact = $this->addContact($seed, 'Unique', 'Contact');
$client->request('DELETE', '/api/provider_contacts/'.$contact->getId());
self::assertResponseStatusCodeSame(204);
}
public function testContactWriteWithoutManageReturns403(): void
{
// Un user sans permission technique.providers.manage -> 403 sur la sous-ressource.
$seed = $this->seedProvider('Contact Forbidden');
$creds = $this->createUserWithPermission('core.users.view');
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$http->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD],
'json' => ['firstName' => 'Nope'],
]);
self::assertResponseStatusCodeSame(403);
}
// === Adresses (security: technique.providers.manage) ===
public function testPostAddressWithValidPayloadReturns201(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Address Host');
$category = $this->providerCategory('NETTOYAGE');
$data = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
'categories' => ['/api/categories/'.$category->getId()],
],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertSame('Châtellerault', $data['city']);
}
public function testPostAddressWithoutSiteReturns422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Address No Site');
$client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => [],
'categories' => ['/api/categories/'.$this->providerCategory()->getId()],
],
]);
// RG-3.05 (Assert\Count min 1 sur sites).
self::assertResponseStatusCodeSame(422);
}
public function testPostAddressWithInvalidPostalCodeReturns422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Address Bad CP');
$client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'postalCode' => '123',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
'categories' => ['/api/categories/'.$this->providerCategory()->getId()],
],
]);
// RG-3.06 (Assert\Regex ^[0-9]{4,5}$).
self::assertResponseStatusCodeSame(422);
}
public function testPostAddressWithNonPrestataireCategoryReturns422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Address Bad Cat');
$foreign = $this->foreignCategory(); // type CLIENT -> interdite (RG-3.09).
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
'categories' => ['/api/categories/'.$foreign->getId()],
],
]);
// RG-3.09 -> 422 rattachee a categories.
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false)));
}
public function testDeleteAddressReturns204(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Address Delete');
$category = $this->providerCategory('NETTOYAGE');
$created = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
'categories' => ['/api/categories/'.$category->getId()],
],
])->toArray();
$client->request('DELETE', $created['@id']);
self::assertResponseStatusCodeSame(204);
}
public function testAddressWriteWithoutManageReturns403(): void
{
$seed = $this->seedProvider('Address Forbidden');
$creds = $this->createUserWithPermission('core.users.view');
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$http->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'postalCode' => '86100',
'city' => 'Châtellerault',
'street' => '1 rue du Test',
'sites' => ['/api/sites/'.$this->site(self::SITE_86)->getId()],
],
]);
self::assertResponseStatusCodeSame(403);
}
/**
* § 2.13 / RG-3.05 (cloisonnement d'ECRITURE sur l'adresse) : un user non-bypass
* `sites.read_ref` (qui peut resoudre n'importe quel IRI de site, sinon 400 en
* amont) ne peut attacher a l'adresse que ses propres user_site. Site hors
* perimetre -> 422 sur `sites` (garde ProviderAddressProcessor).
*/
public function testPostAddressWithOutOfScopeSiteReturns422OnSitesPath(): void
{
$seed = $this->seedProvider('Address Scope', [self::SITE_86]);
$category = $this->providerCategory('NETTOYAGE');
$creds = $this->createScopedUser(
['technique.providers.view', 'technique.providers.manage', 'sites.read_ref'],
sitePostalCodes: [self::SITE_86],
currentSitePostalCode: self::SITE_86,
);
$client = $this->authenticatedClient($creds['username'], $creds['password']);
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/addresses', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'postalCode' => '17400',
'city' => 'Saint-Jean-d\'Angély',
'street' => '1 rue du Test',
'sites' => ['/api/sites/'.$this->site(self::SITE_17)->getId()], // hors user_site
'categories' => ['/api/categories/'.$category->getId()],
],
]);
self::assertResponseStatusCodeSame(422);
self::assertArrayHasKey('sites', $this->violationsByPath($response->toArray(false)));
}
// === RIBs (security: technique.providers.accounting.manage) ===
public function testPostRibByAdminReturns201(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Rib Host');
$data = $client->request('POST', '/api/providers/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD],
'json' => [
'label' => 'Compte principal',
'bic' => self::VALID_BIC,
'iban' => self::VALID_IBAN,
],
])->toArray();
self::assertResponseStatusCodeSame(201);
self::assertSame('Compte principal', $data['label']);
}
public function testPostRibWithInvalidIbanReturns422(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Rib Bad Iban');
$client->request('POST', '/api/providers/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD],
'json' => ['label' => 'Compte invalide', 'bic' => self::VALID_BIC, 'iban' => 'INVALID-IBAN'],
]);
self::assertResponseStatusCodeSame(422);
}
/**
* Controle croise pays BIC/IBAN (Assert\Bic ibanPropertyPath) : un BIC (DE) et
* un IBAN (FR) valides isolement mais de pays differents -> 422 sur `bic`.
*/
public function testPostRibWithBicIbanCountryMismatchReturns422OnBic(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Rib Pays Mismatch');
$response = $client->request('POST', '/api/providers/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['label' => 'Compte incoherent', 'bic' => self::FOREIGN_BIC, 'iban' => self::VALID_IBAN],
]);
self::assertResponseStatusCodeSame(422);
$byPath = $this->violationsByPath($response->toArray(false));
self::assertArrayHasKey('bic', $byPath);
self::assertSame('Le BIC ne correspond pas au pays de l\'IBAN.', $byPath['bic']);
}
public function testDeleteRibNonLcrReturns204(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Rib Non LCR');
$rib = $this->addRib($seed);
$client->request('DELETE', '/api/provider_ribs/'.$rib->getId());
self::assertResponseStatusCodeSame(204);
}
public function testDeleteLastRibUnderLcrReturns409(): void
{
$client = $this->createAdminClient();
$seed = $this->seedProvider('Rib LCR Solo');
$rib = $this->addRib($seed);
// Passe le prestataire en LCR (seed direct).
$em = $this->getEm();
$managed = $em->getRepository(Provider::class)->find($seed->getId());
$managed->setPaymentType($this->paymentType('LCR'));
$em->flush();
$client->request('DELETE', '/api/provider_ribs/'.$rib->getId());
// RG-3.08 : LCR exige >= 1 RIB -> suppression du dernier refusee.
self::assertResponseStatusCodeSame(409);
}
public function testRibWriteWithoutAccountingManageReturns403(): void
{
// Un user portant seulement technique.providers.manage (sans accounting.manage)
// ne peut ni creer, ni modifier, ni supprimer un RIB (gating renforce § 4.5).
$seed = $this->seedProvider('Rib Forbidden');
$rib = $this->addRib($seed);
$creds = $this->createUserWithPermission('technique.providers.manage');
$http = $this->authenticatedClient($creds['username'], $creds['password']);
$http->request('POST', '/api/providers/'.$seed->getId().'/ribs', [
'headers' => ['Content-Type' => self::LD],
'json' => ['label' => 'X', 'bic' => self::VALID_BIC, 'iban' => self::VALID_IBAN],
]);
self::assertResponseStatusCodeSame(403);
$http->request('PATCH', '/api/provider_ribs/'.$rib->getId(), [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['label' => 'Y'],
]);
self::assertResponseStatusCodeSame(403);
$http->request('DELETE', '/api/provider_ribs/'.$rib->getId());
self::assertResponseStatusCodeSame(403);
}
}