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).
This commit is contained in:
Matthieu
2026-06-12 16:12:00 +02:00
parent d6ed4f5faf
commit 8b6b4f2dbb
15 changed files with 410 additions and 61 deletions
@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Technique\Api;
use ApiPlatform\Symfony\Bundle\Test\Client;
/**
* Tests du cloisonnement par site des SOUS-RESSOURCES d'un prestataire (Contacts /
* Adresses / RIB) — § 2.13 / RG-3.17. Complement de ProviderSiteScopeTest (qui ne
* couvrait que le Provider lui-meme).
*
* Sans garde dedie, un user cloisonne pouvait lire / editer / supprimer une
* sous-ressource d'un prestataire HORS de son site (le detail Provider est garde en
* 404, mais les sous-ressources passent par le provider Doctrine par defaut, non
* cloisonne — et SiteScopedQueryExtension ne filtre que les SiteAwareInterface).
* Le RIB est particulierement sensible (IBAN / BIC).
*
* Garde pose par ProviderSubResourceItemProvider (Get/Patch/Delete -> 404 hors
* perimetre) + ProviderSiteScopeChecker::assertInScope dans les processors (POST
* sur parent hors perimetre -> 404). Decision de scope partagee (source unique).
*
* @internal
*/
final class ProviderSubResourceSiteScopeTest extends AbstractProviderApiTestCase
{
/** Permissions completes pour exercer view + manage + accounting sur tous les chemins. */
private const array FULL_PERMS = [
'technique.providers.view',
'technique.providers.manage',
'technique.providers.accounting.view',
'technique.providers.accounting.manage',
];
protected function setUp(): void
{
parent::setUp();
$this->skipIfSitesModuleDisabled();
}
public function testGetContactOutOfScopeReturns404ButInScope200(): void
{
$inScope = $this->seedProvider('Presta In Scope', [self::SITE_86]);
$inContactId = $this->addContact($inScope, 'Marie', 'Martin')->getId();
$outScope = $this->seedProvider('Presta Out Scope', [self::SITE_17]);
$outContactId = $this->addContact($outScope, 'Paul', 'Durand')->getId();
$client = $this->scopedClient();
$ok = $client->request('GET', '/api/provider_contacts/'.$inContactId, ['headers' => ['Accept' => self::LD]]);
self::assertSame(200, $ok->getStatusCode());
// Hors perimetre : 404 (ne pas reveler l'existence du contact d'un autre site).
$ko = $client->request('GET', '/api/provider_contacts/'.$outContactId, ['headers' => ['Accept' => self::LD]]);
self::assertSame(404, $ko->getStatusCode());
}
public function testGetRibOutOfScopeReturns404(): void
{
// RIB = donnee bancaire sensible (IBAN/BIC) : le cas le plus critique.
$outScope = $this->seedProvider('Presta Out Rib', [self::SITE_17]);
$ribId = $this->addRib($outScope)->getId();
$client = $this->scopedClient();
$response = $client->request('GET', '/api/provider_ribs/'.$ribId, ['headers' => ['Accept' => self::LD]]);
self::assertSame(404, $response->getStatusCode());
}
public function testPatchRibOutOfScopeReturns404(): void
{
$outScope = $this->seedProvider('Presta Patch Rib', [self::SITE_17]);
$ribId = $this->addRib($outScope)->getId();
$client = $this->scopedClient();
$response = $client->request('PATCH', '/api/provider_ribs/'.$ribId, [
'headers' => ['Content-Type' => self::MERGE],
'json' => ['label' => 'Hacked'],
]);
self::assertSame(404, $response->getStatusCode());
}
public function testDeleteContactOutOfScopeReturns404(): void
{
$outScope = $this->seedProvider('Presta Del Contact', [self::SITE_17]);
$contactId = $this->addContact($outScope, 'Paul', 'Durand')->getId();
$client = $this->scopedClient();
$response = $client->request('DELETE', '/api/provider_contacts/'.$contactId);
self::assertSame(404, $response->getStatusCode());
}
public function testPostContactOnOutOfScopeProviderReturns404(): void
{
$outScope = $this->seedProvider('Presta Post Contact', [self::SITE_17]);
$id = $outScope->getId();
$client = $this->scopedClient();
$response = $client->request('POST', '/api/providers/'.$id.'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['firstName' => 'Intrus'],
]);
self::assertSame(404, $response->getStatusCode());
}
public function testPostRibOnOutOfScopeProviderReturns404(): void
{
$outScope = $this->seedProvider('Presta Post Rib', [self::SITE_17]);
$id = $outScope->getId();
$client = $this->scopedClient();
$response = $client->request('POST', '/api/providers/'.$id.'/ribs', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => [
'label' => 'Intrus',
'iban' => self::VALID_IBAN,
'bic' => self::VALID_BIC,
],
]);
self::assertSame(404, $response->getStatusCode());
}
public function testBypassUserReachesSubResourceOnAnySite(): void
{
// Temoin : l'admin (bypass total) lit bien un contact hors « son » site.
$outScope = $this->seedProvider('Presta Admin Reach', [self::SITE_17]);
$contactId = $this->addContact($outScope, 'Marie', 'Martin')->getId();
$client = $this->createAdminClient();
$response = $client->request('GET', '/api/provider_contacts/'.$contactId, ['headers' => ['Accept' => self::LD]]);
self::assertSame(200, $response->getStatusCode());
}
/**
* Client authentifie comme un user NON-bypass rattache au seul site 86 (avec
* currentSite 86) — sujet des tests de cloisonnement des sous-ressources.
*/
private function scopedClient(): Client
{
$creds = $this->createScopedUser(
self::FULL_PERMS,
sitePostalCodes: [self::SITE_86],
currentSitePostalCode: self::SITE_86,
);
return $this->authenticatedClient($creds['username'], $creds['password']);
}
}