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
+1 -1
View File
@@ -176,7 +176,7 @@ Anti-N+1 (le code fera foi) : le `DoctrineProviderRepository` ne fetch-joine PAS
- **Filtre LISTE** (`ProviderProvider` ou query extension dédiée `ProviderSiteScopeExtension`) : si l'user **n'a pas** `sites.bypass_scope` ET que `CurrentSiteProvider::get()` retourne un site → ne renvoyer que les prestataires dont `provider.sites` **contient** le `currentSite` (jointure `provider_site` + `WHERE site = :currentSite`). Si l'user a `bypass_scope` (Admin, profils consolidation) → aucun filtre (tous sites). Si `currentSite = null` (mode dégradé / module Sites off) → aligné `site-aware.md § 5` (no-op lecture, à documenter). - **Filtre LISTE** (`ProviderProvider` ou query extension dédiée `ProviderSiteScopeExtension`) : si l'user **n'a pas** `sites.bypass_scope` ET que `CurrentSiteProvider::get()` retourne un site → ne renvoyer que les prestataires dont `provider.sites` **contient** le `currentSite` (jointure `provider_site` + `WHERE site = :currentSite`). Si l'user a `bypass_scope` (Admin, profils consolidation) → aucun filtre (tous sites). Si `currentSite = null` (mode dégradé / module Sites off) → aligné `site-aware.md § 5` (no-op lecture, à documenter).
- **Filtre DÉTAIL** (`Get`) : un user sans `bypass_scope` qui demande un prestataire **hors de son site courant****404** (cohérence : ne pas révéler l'existence d'une ligne hors périmètre). - **Filtre DÉTAIL** (`Get`) : un user sans `bypass_scope` qui demande un prestataire **hors de son site courant****404** (cohérence : ne pas révéler l'existence d'une ligne hors périmètre).
- **Écriture (décision Matthieu, 11/06)** : un user **sans** `bypass_scope` ne peut attacher **que les sites dont il dispose** (ses `user_site`) — sur le formulaire principal (`provider.sites`, RG-3.03) **comme** sur chaque adresse (`provider_address.sites`, RG-3.05). Tout site hors de ses `user_site` dans le payload → **422** sur `sites`. Un user `bypass_scope` (Admin) peut attacher n'importe quel site. Garde porté par le `ProviderProcessor` (POST + PATCH + sous-ressource adresses). - **Écriture (décision Matthieu, 11/06)** : un user **sans** `bypass_scope` ne peut attacher **que les sites dont il dispose** (ses `user_site`) — sur le formulaire principal (`provider.sites`, RG-3.03) **comme** sur chaque adresse (`provider_address.sites`, RG-3.05). Tout site hors de ses `user_site` dans le payload → **422** sur `sites`. Un user `bypass_scope` (Admin) peut attacher n'importe quel site. Garde porté par le `ProviderProcessor` (POST + PATCH + sous-ressource adresses).
- **Cohérence sous-ressources** (`/providers/{id}/...`) : le détail étant déjà gardé en 404 hors périmètre, les sous-ressources héritent du garde-fou parent (cf. `site-aware.md § 6.1`). - **Cohérence sous-ressources** (Contacts / Adresses / RIB) : le cloisonnement du parent **n'est PAS hérité automatiquement** — les opérations `Get` / `Patch` / `Delete` des sous-ressources passent par le provider Doctrine par défaut (et `SiteScopedQueryExtension` ne filtre que les `SiteAwareInterface`, ce que ces entités ne sont pas). Le garde-fou est donc posé **explicitement** : (a) en lecture/édition/suppression via le provider décoré `ProviderSubResourceItemProvider` (un parent hors périmètre → **404**) ; (b) en création (`POST /providers/{id}/...`) via `ProviderSiteScopeChecker::assertInScope` dans chaque processor (parent hors périmètre → **404**). La décision de scope est centralisée dans `ProviderSiteScopeChecker` (source unique partagée avec `ProviderProvider`). ⚠️ Ne pas retirer ces gardes en les croyant redondants : sans eux, un user cloisonné peut lire l'IBAN/BIC d'un RIB d'un autre site.
> **Conséquence RBAC** : la colonne « Consultation » du docx (« Tout » vs « son site uniquement ») se réalise **par `sites.bypass_scope`**, pas par le code de rôle. Décision d'attribution par défaut (à acter au ticket RBAC) : `bypass_scope` aux profils Admin (auto) + Bureau + Compta + Commerciale (ils voient « Tout » d'après le docx) ; **Usine ne l'a pas** → cloisonné à son site. Si MALIO préfère que Bureau/Commerciale soient aussi cloisonnés, il suffit de ne pas leur donner `bypass_scope` — **aucun code à changer** (c'est l'intérêt de piloter par user/permission et non par rôle). > **Conséquence RBAC** : la colonne « Consultation » du docx (« Tout » vs « son site uniquement ») se réalise **par `sites.bypass_scope`**, pas par le code de rôle. Décision d'attribution par défaut (à acter au ticket RBAC) : `bypass_scope` aux profils Admin (auto) + Bureau + Compta + Commerciale (ils voient « Tout » d'après le docx) ; **Usine ne l'a pas** → cloisonné à son site. Si MALIO préfère que Bureau/Commerciale soient aussi cloisonnés, il suffit de ne pas leur donner `bypass_scope` — **aucun code à changer** (c'est l'intérêt de piloter par user/permission et non par rôle).
+3 -3
View File
@@ -252,7 +252,7 @@ final class Version20260612100000 extends AbstractMigration
updated_by INT DEFAULT NULL, updated_by INT DEFAULT NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
CONSTRAINT chk_provider_contact_name CONSTRAINT chk_provider_contact_name
CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL), CHECK (first_name IS NOT NULL OR last_name IS NOT NULL OR job_title IS NOT NULL OR phone_primary IS NOT NULL OR email IS NOT NULL),
CONSTRAINT fk_provider_contact_provider CONSTRAINT fk_provider_contact_provider
FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE, FOREIGN KEY (provider_id) REFERENCES provider (id) ON DELETE CASCADE,
CONSTRAINT fk_provider_contact_created_by CONSTRAINT fk_provider_contact_created_by
@@ -263,12 +263,12 @@ final class Version20260612100000 extends AbstractMigration
SQL); SQL);
$this->addSql('CREATE INDEX idx_provider_contact_provider ON provider_contact (provider_id)'); $this->addSql('CREATE INDEX idx_provider_contact_provider ON provider_contact (provider_id)');
$this->comment('provider_contact', '_table', 'Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/telephone/email (RG-3.04, chk_provider_contact_name).'); $this->comment('provider_contact', '_table', 'Contacts d un prestataire (1:n) — au moins un champ rempli parmi prenom/nom/fonction/telephone/email (RG-3.04, chk_provider_contact_name).');
$this->comment('provider_contact', 'id', 'Identifiant interne auto-incremente.'); $this->comment('provider_contact', 'id', 'Identifiant interne auto-incremente.');
$this->comment('provider_contact', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du contact.'); $this->comment('provider_contact', 'provider_id', 'FK -> provider.id, ON DELETE CASCADE — prestataire proprietaire du contact.');
$this->comment('provider_contact', 'first_name', 'Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).'); $this->comment('provider_contact', 'first_name', 'Prenom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).');
$this->comment('provider_contact', 'last_name', 'Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).'); $this->comment('provider_contact', 'last_name', 'Nom du contact (capitalise serveur). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).');
$this->comment('provider_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres).'); $this->comment('provider_contact', 'job_title', 'Fonction / intitule de poste du contact (≤ 120 caracteres). Au moins un champ du contact requis (RG-3.04, chk_provider_contact_name).');
$this->comment('provider_contact', 'phone_primary', 'Telephone principal du contact — chiffres uniquement (normalisation serveur).'); $this->comment('provider_contact', 'phone_primary', 'Telephone principal du contact — chiffres uniquement (normalisation serveur).');
$this->comment('provider_contact', 'phone_secondary', 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).'); $this->comment('provider_contact', 'phone_secondary', 'Telephone secondaire du contact — chiffres uniquement (normalisation serveur).');
$this->comment('provider_contact', 'email', 'Email du contact (lowercase serveur).'); $this->comment('provider_contact', 'email', 'Email du contact (lowercase serveur).');
@@ -65,6 +65,23 @@ final class ProviderFieldNormalizer
return '' === $value ? null : mb_strtolower($value, 'UTF-8'); return '' === $value ? null : mb_strtolower($value, 'UTF-8');
} }
/**
* Texte libre simplement trim (ex : jobTitle / Fonction du contact). Pas de
* changement de casse — on preserve la saisie. Une chaine vide apres trim
* devient null (evite de persister "" et de faire passer a tort le garde-fou
* RG-3.04 / le CHECK chk_provider_contact_name sur une Fonction vide).
*/
public function normalizeText(?string $value): ?string
{
if (null === $value) {
return null;
}
$value = trim($value);
return '' === $value ? null : $value;
}
/** /**
* Telephone reduit aux chiffres (RG-3.11) : "06.12.34.56.78" -> * Telephone reduit aux chiffres (RG-3.11) : "06.12.34.56.78" ->
* "0612345678". Une valeur sans aucun chiffre devient null. * "0612345678". Une valeur sans aucun chiffre devient null.
@@ -11,6 +11,7 @@ use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderAddressProcessor; use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderAddressProcessor;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderSubResourceItemProvider;
use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\CategoryInterface; use App\Shared\Domain\Contract\CategoryInterface;
@@ -60,6 +61,8 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
// site:read + category:read : embarquent les Site / Category lies // site:read + category:read : embarquent les Site / Category lies
// (maillon (c)) plutot que des IRI nus dans le retour. // (maillon (c)) plutot que des IRI nus dans le retour.
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']], normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
provider: ProviderSubResourceItemProvider::class,
), ),
new Post( new Post(
uriTemplate: '/providers/{providerId}/addresses', uriTemplate: '/providers/{providerId}/addresses',
@@ -80,10 +83,12 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
security: "is_granted('technique.providers.manage')", security: "is_granted('technique.providers.manage')",
normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']], normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']],
denormalizationContext: ['groups' => ['provider:write:addresses']], denormalizationContext: ['groups' => ['provider:write:addresses']],
provider: ProviderSubResourceItemProvider::class,
processor: ProviderAddressProcessor::class, processor: ProviderAddressProcessor::class,
), ),
new Delete( new Delete(
security: "is_granted('technique.providers.manage')", security: "is_granted('technique.providers.manage')",
provider: ProviderSubResourceItemProvider::class,
processor: ProviderAddressProcessor::class, processor: ProviderAddressProcessor::class,
), ),
], ],
@@ -92,7 +97,7 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
#[ORM\Table(name: 'provider_address')] #[ORM\Table(name: 'provider_address')]
#[ORM\Index(name: 'idx_provider_address_provider', columns: ['provider_id'])] #[ORM\Index(name: 'idx_provider_address_provider', columns: ['provider_id'])]
#[Auditable] #[Auditable]
class ProviderAddress implements TimestampableInterface, BlamableInterface class ProviderAddress implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
{ {
use TimestampableBlamableTrait; use TimestampableBlamableTrait;
@@ -11,6 +11,7 @@ use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderContactProcessor; use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderContactProcessor;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderSubResourceItemProvider;
use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
@@ -47,6 +48,8 @@ use Symfony\Component\Validator\Constraints as Assert;
new Get( new Get(
security: "is_granted('technique.providers.view')", security: "is_granted('technique.providers.view')",
normalizationContext: ['groups' => ['provider:item:read']], normalizationContext: ['groups' => ['provider:item:read']],
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
provider: ProviderSubResourceItemProvider::class,
), ),
new Post( new Post(
uriTemplate: '/providers/{providerId}/contacts', uriTemplate: '/providers/{providerId}/contacts',
@@ -67,10 +70,12 @@ use Symfony\Component\Validator\Constraints as Assert;
security: "is_granted('technique.providers.manage')", security: "is_granted('technique.providers.manage')",
normalizationContext: ['groups' => ['provider:item:read']], normalizationContext: ['groups' => ['provider:item:read']],
denormalizationContext: ['groups' => ['provider:write:contacts']], denormalizationContext: ['groups' => ['provider:write:contacts']],
provider: ProviderSubResourceItemProvider::class,
processor: ProviderContactProcessor::class, processor: ProviderContactProcessor::class,
), ),
new Delete( new Delete(
security: "is_granted('technique.providers.manage')", security: "is_granted('technique.providers.manage')",
provider: ProviderSubResourceItemProvider::class,
processor: ProviderContactProcessor::class, processor: ProviderContactProcessor::class,
), ),
], ],
@@ -79,7 +84,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'provider_contact')] #[ORM\Table(name: 'provider_contact')]
#[ORM\Index(name: 'idx_provider_contact_provider', columns: ['provider_id'])] #[ORM\Index(name: 'idx_provider_contact_provider', columns: ['provider_id'])]
#[Auditable] #[Auditable]
class ProviderContact implements TimestampableInterface, BlamableInterface class ProviderContact implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
{ {
use TimestampableBlamableTrait; use TimestampableBlamableTrait;
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Domain\Entity;
/**
* Contrat des sous-ressources d'un prestataire (Contact, Adresse, RIB) : chacune
* appartient a un Provider parent. Permet au provider decore
* ProviderSubResourceItemProvider d'appliquer le cloisonnement par site du parent
* (§ 2.13 / RG-3.17) de maniere uniforme, sans connaitre le type concret.
*/
interface ProviderOwnedInterface
{
public function getProvider(): ?Provider;
}
@@ -11,6 +11,7 @@ use ApiPlatform\Metadata\Link;
use ApiPlatform\Metadata\Patch; use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderRibProcessor; use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderRibProcessor;
use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderSubResourceItemProvider;
use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Attribute\Auditable;
use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\BlamableInterface;
use App\Shared\Domain\Contract\TimestampableInterface; use App\Shared\Domain\Contract\TimestampableInterface;
@@ -49,6 +50,8 @@ use Symfony\Component\Validator\Constraints as Assert;
new Get( new Get(
security: "is_granted('technique.providers.accounting.view')", security: "is_granted('technique.providers.accounting.view')",
normalizationContext: ['groups' => ['provider:read:accounting']], normalizationContext: ['groups' => ['provider:read:accounting']],
// Cloisonnement par site du prestataire parent (§ 2.13) : 404 hors perimetre.
provider: ProviderSubResourceItemProvider::class,
), ),
new Post( new Post(
uriTemplate: '/providers/{providerId}/ribs', uriTemplate: '/providers/{providerId}/ribs',
@@ -69,10 +72,12 @@ use Symfony\Component\Validator\Constraints as Assert;
security: "is_granted('technique.providers.accounting.manage')", security: "is_granted('technique.providers.accounting.manage')",
normalizationContext: ['groups' => ['provider:read:accounting']], normalizationContext: ['groups' => ['provider:read:accounting']],
denormalizationContext: ['groups' => ['provider:write:accounting']], denormalizationContext: ['groups' => ['provider:write:accounting']],
provider: ProviderSubResourceItemProvider::class,
processor: ProviderRibProcessor::class, processor: ProviderRibProcessor::class,
), ),
new Delete( new Delete(
security: "is_granted('technique.providers.accounting.manage')", security: "is_granted('technique.providers.accounting.manage')",
provider: ProviderSubResourceItemProvider::class,
processor: ProviderRibProcessor::class, processor: ProviderRibProcessor::class,
), ),
], ],
@@ -81,7 +86,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Table(name: 'provider_rib')] #[ORM\Table(name: 'provider_rib')]
#[ORM\Index(name: 'idx_provider_rib_provider', columns: ['provider_id'])] #[ORM\Index(name: 'idx_provider_rib_provider', columns: ['provider_id'])]
#[Auditable] #[Auditable]
class ProviderRib implements TimestampableInterface, BlamableInterface class ProviderRib implements TimestampableInterface, BlamableInterface, ProviderOwnedInterface
{ {
use TimestampableBlamableTrait; use TimestampableBlamableTrait;
@@ -11,6 +11,7 @@ use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Core\Domain\Entity\User; use App\Module\Core\Domain\Entity\User;
use App\Module\Technique\Domain\Entity\Provider; use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderAddress; use App\Module\Technique\Domain\Entity\ProviderAddress;
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
use App\Shared\Domain\Contract\SiteInterface; use App\Shared\Domain\Contract\SiteInterface;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use JsonException; use JsonException;
@@ -53,6 +54,7 @@ final class ProviderAddressProcessor implements ProcessorInterface
private readonly Security $security, private readonly Security $security,
private readonly RequestStack $requestStack, private readonly RequestStack $requestStack,
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $em,
private readonly ProviderSiteScopeChecker $scopeChecker,
) {} ) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
@@ -98,6 +100,12 @@ final class ProviderAddressProcessor implements ProcessorInterface
throw new NotFoundHttpException('Prestataire introuvable.'); throw new NotFoundHttpException('Prestataire introuvable.');
} }
// Cloisonnement par site (§ 2.13 / RG-3.17) : interdiction de creer une
// sous-ressource sur un prestataire hors du perimetre de l'user -> 404
// (anti-enumeration). Distinct du guardSiteScope ci-dessous, qui cloisonne
// les sites ATTACHES a l'adresse (et non l'acces au prestataire parent).
$this->scopeChecker->assertInScope($provider);
$address->setProvider($provider); $address->setProvider($provider);
} }
@@ -11,6 +11,7 @@ use ApiPlatform\Validator\Exception\ValidationException;
use App\Module\Technique\Application\Service\ProviderFieldNormalizer; use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
use App\Module\Technique\Domain\Entity\Provider; use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderContact; use App\Module\Technique\Domain\Entity\ProviderContact;
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -46,6 +47,7 @@ final class ProviderContactProcessor implements ProcessorInterface
private readonly ProcessorInterface $removeProcessor, private readonly ProcessorInterface $removeProcessor,
private readonly ProviderFieldNormalizer $normalizer, private readonly ProviderFieldNormalizer $normalizer,
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $em,
private readonly ProviderSiteScopeChecker $scopeChecker,
) {} ) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
@@ -94,6 +96,11 @@ final class ProviderContactProcessor implements ProcessorInterface
throw new NotFoundHttpException('Prestataire introuvable.'); throw new NotFoundHttpException('Prestataire introuvable.');
} }
// Cloisonnement par site (§ 2.13 / RG-3.17) : interdiction de creer une
// sous-ressource sur un prestataire hors du perimetre de l'user -> 404
// (anti-enumeration, coherent avec le detail Provider garde en 404).
$this->scopeChecker->assertInScope($provider);
$contact->setProvider($provider); $contact->setProvider($provider);
} }
@@ -105,6 +112,7 @@ final class ProviderContactProcessor implements ProcessorInterface
{ {
$contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName())); $contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName()));
$contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName())); $contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName()));
$contact->setJobTitle($this->normalizer->normalizeText($contact->getJobTitle()));
$contact->setPhonePrimary($this->normalizer->normalizePhone($contact->getPhonePrimary())); $contact->setPhonePrimary($this->normalizer->normalizePhone($contact->getPhonePrimary()));
$contact->setPhoneSecondary($this->normalizer->normalizePhone($contact->getPhoneSecondary())); $contact->setPhoneSecondary($this->normalizer->normalizePhone($contact->getPhoneSecondary()));
$contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail())); $contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail()));
@@ -112,21 +120,22 @@ final class ProviderContactProcessor implements ProcessorInterface
/** /**
* RG-3.04 : un bloc Contact est valide des qu'au moins un champ parmi prenom / * RG-3.04 : un bloc Contact est valide des qu'au moins un champ parmi prenom /
* nom / telephone principal / email est renseigne (double garde avec le CHECK * nom / fonction / telephone principal / email est renseigne (double garde avec
* BDD chk_provider_contact_name — leve une 422 propre rattachee au champ * le CHECK BDD chk_provider_contact_name — leve une 422 propre rattachee au
* `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les * champ `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les
* chaines vides (y compris un phone_secondary seul, hors CHECK) sont deja * chaines vides (y compris une fonction ou un phone_secondary vides) sont deja
* ramenees a null et ne suffisent pas a valider le bloc. * ramenees a null et ne suffisent pas a valider le bloc.
*/ */
private function validateName(ProviderContact $contact): void private function validateName(ProviderContact $contact): void
{ {
if (null === $contact->getFirstName() if (null === $contact->getFirstName()
&& null === $contact->getLastName() && null === $contact->getLastName()
&& null === $contact->getJobTitle()
&& null === $contact->getPhonePrimary() && null === $contact->getPhonePrimary()
&& null === $contact->getEmail()) { && null === $contact->getEmail()) {
$violations = new ConstraintViolationList(); $violations = new ConstraintViolationList();
$violations->add(new ConstraintViolation( $violations->add(new ConstraintViolation(
'Au moins un champ du contact est obligatoire (nom, prénom, téléphone ou email).', 'Au moins un champ du contact est obligatoire (nom, prénom, fonction, téléphone ou email).',
null, null,
[], [],
$contact, $contact,
@@ -9,6 +9,7 @@ use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProcessorInterface;
use App\Module\Technique\Domain\Entity\Provider; use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Entity\ProviderRib; use App\Module\Technique\Domain\Entity\ProviderRib;
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\ConflictHttpException; use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
@@ -42,6 +43,7 @@ final class ProviderRibProcessor implements ProcessorInterface
#[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')] #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')]
private readonly ProcessorInterface $removeProcessor, private readonly ProcessorInterface $removeProcessor,
private readonly EntityManagerInterface $em, private readonly EntityManagerInterface $em,
private readonly ProviderSiteScopeChecker $scopeChecker,
) {} ) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
@@ -88,6 +90,11 @@ final class ProviderRibProcessor implements ProcessorInterface
throw new NotFoundHttpException('Prestataire introuvable.'); throw new NotFoundHttpException('Prestataire introuvable.');
} }
// Cloisonnement par site (§ 2.13 / RG-3.17) : interdiction de creer une
// sous-ressource sur un prestataire hors du perimetre de l'user -> 404
// (anti-enumeration, coherent avec le detail Provider garde en 404).
$this->scopeChecker->assertInScope($provider);
$rib->setProvider($provider); $rib->setProvider($provider);
} }
@@ -9,12 +9,10 @@ use ApiPlatform\Metadata\CollectionOperationInterface;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\Pagination\Pagination; use ApiPlatform\State\Pagination\Pagination;
use ApiPlatform\State\ProviderInterface; use ApiPlatform\State\ProviderInterface;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Technique\Domain\Entity\Provider; use App\Module\Technique\Domain\Entity\Provider;
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface; use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
use App\Shared\Domain\Contract\SiteInterface; use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator; use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
/** /**
@@ -64,12 +62,11 @@ final class ProviderProvider implements ProviderInterface
#[Autowire(service: 'App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository')] #[Autowire(service: 'App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository')]
private readonly ProviderRepositoryInterface $repository, private readonly ProviderRepositoryInterface $repository,
private readonly Pagination $pagination, private readonly Pagination $pagination,
private readonly Security $security, // Decision de cloisonnement par site centralisee (site-aware.md § 6.2) :
// Outillage site-aware sanctionne (site-aware.md § 6.2 : « injecter // source UNIQUE partagee avec le provider decore des sous-ressources
// CurrentSiteProvider dans le service et ajouter la clause WHERE // (ProviderSubResourceItemProvider) et les processors d'ecriture, pour
// manuellement » pour les cas multi-site non couverts par // eviter tout drift entre ces points d'application.
// SiteScopedQueryExtension). Type-hint sur l'interface pour le mock test. private readonly ProviderSiteScopeChecker $scopeChecker,
private readonly CurrentSiteProviderInterface $currentSiteProvider,
) {} ) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Provider|null public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Provider|null
@@ -109,7 +106,7 @@ final class ProviderProvider implements ProviderInterface
// Cloisonnement par site (RG-3.17) AVANT pagination : ajoute une clause // Cloisonnement par site (RG-3.17) AVANT pagination : ajoute une clause
// restreignant au currentSite pour un user non-bypass. S'intersecte avec // restreignant au currentSite pour un user non-bypass. S'intersecte avec
// un eventuel filtre ?siteId du client (deux sous-requetes ANDees). // un eventuel filtre ?siteId du client (deux sous-requetes ANDees).
$scopeSite = $this->siteScopeOrNull(); $scopeSite = $this->scopeChecker->siteScopeOrNull();
if (null !== $scopeSite) { if (null !== $scopeSite) {
$this->repository->applySiteScope($qb, (int) $scopeSite->getId()); $this->repository->applySiteScope($qb, (int) $scopeSite->getId());
} }
@@ -164,44 +161,14 @@ final class ProviderProvider implements ProviderInterface
// Cloisonnement par site (RG-3.17) : un prestataire hors du perimetre de // Cloisonnement par site (RG-3.17) : un prestataire hors du perimetre de
// l'user -> 404 (ne pas reveler son existence). No-op pour bypass_scope ou // l'user -> 404 (ne pas reveler son existence). No-op pour bypass_scope ou
// currentSite null. // currentSite null (delegue au ProviderSiteScopeChecker).
$scopeSite = $this->siteScopeOrNull(); if (!$this->scopeChecker->isInScope($provider)) {
if (null !== $scopeSite && !$this->providerHasSite($provider, (int) $scopeSite->getId())) {
return null; return null;
} }
return $provider; return $provider;
} }
/**
* Site de cloisonnement a appliquer en LECTURE, ou null si aucun cloisonnement
* (user `sites.bypass_scope`, ou pas de site courant resolu — module Sites off
* / user sans currentSite, aligne site-aware.md § 5).
*/
private function siteScopeOrNull(): ?SiteInterface
{
if ($this->security->isGranted('sites.bypass_scope')) {
return null;
}
return $this->currentSiteProvider->get();
}
/**
* Vrai si le prestataire est rattache (relation directe provider.sites) au
* site d'id donne. Comparaison en memoire sur l'entite deja chargee (detail).
*/
private function providerHasSite(Provider $provider, int $siteId): bool
{
foreach ($provider->getSites() as $site) {
if ($site instanceof SiteInterface && $site->getId() === $siteId) {
return true;
}
}
return false;
}
/** /**
* Lit un flag booleen issu des query params. Accepte true / "true" / "1". * Lit un flag booleen issu des query params. Accepte true / "true" / "1".
*/ */
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Provider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\Module\Technique\Domain\Entity\ProviderOwnedInterface;
use App\Module\Technique\Infrastructure\Security\ProviderSiteScopeChecker;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provider d'item des sous-ressources d'un prestataire (Contact / Adresse / RIB).
* Decore le provider Doctrine par defaut et applique le cloisonnement par site du
* PARENT (§ 2.13 / RG-3.17) sur Get / Patch / Delete.
*
* Sans ce garde, un user cloisonne pourrait lire / editer / supprimer une
* sous-ressource d'un prestataire hors de son site : le detail Provider est bien
* garde en 404 (ProviderProvider), mais les sous-ressources ne passent pas par lui
* (provider Doctrine par defaut, et SiteScopedQueryExtension ne filtre que les
* resources SiteAwareInterface — ce que ces entites ne sont pas). Le RIB est
* particulierement sensible (IBAN / BIC).
*
* Hors perimetre -> retour null -> 404 (anti-enumeration, coherent avec le detail
* Provider). La decision de scope est deleguee a ProviderSiteScopeChecker (source
* unique partagee avec le ProviderProvider et les processors).
*
* @implements ProviderInterface<ProviderOwnedInterface>
*/
final class ProviderSubResourceItemProvider implements ProviderInterface
{
public function __construct(
#[Autowire(service: 'api_platform.doctrine.orm.state.item_provider')]
private readonly ProviderInterface $itemProvider,
private readonly ProviderSiteScopeChecker $scopeChecker,
) {}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): ?object
{
$entity = $this->itemProvider->provide($operation, $uriVariables, $context);
if ($entity instanceof ProviderOwnedInterface) {
$parent = $entity->getProvider();
if (null === $parent || !$this->scopeChecker->isInScope($parent)) {
return null;
}
}
return $entity;
}
}
@@ -0,0 +1,85 @@
<?php
declare(strict_types=1);
namespace App\Module\Technique\Infrastructure\Security;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Technique\Domain\Entity\Provider;
use App\Shared\Domain\Contract\SiteInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
/**
* Decision centralisee du cloisonnement par site des prestataires (§ 2.13 /
* RG-3.17). Source UNIQUE partagee par le ProviderProvider (liste + detail), le
* provider decore des sous-ressources (ProviderSubResourceItemProvider) et les
* processors d'ecriture des sous-ressources — afin d'eviter tout drift entre ces
* points d'application.
*
* Regle : un user SANS `sites.bypass_scope` ET avec un site courant ne voit /
* n'opere que sur les prestataires rattaches (relation directe provider.sites) a
* son site courant. `bypass_scope` (Admin inclus via isAdmin) ou absence de site
* courant (module Sites off / user sans currentSite) -> aucun cloisonnement
* (no-op, aligne site-aware.md § 5).
*/
final class ProviderSiteScopeChecker
{
public function __construct(
private readonly Security $security,
private readonly CurrentSiteProviderInterface $currentSiteProvider,
) {}
/**
* Site de cloisonnement a appliquer, ou null si aucun cloisonnement
* (`bypass_scope`, ou pas de site courant resolu).
*/
public function siteScopeOrNull(): ?SiteInterface
{
if ($this->security->isGranted('sites.bypass_scope')) {
return null;
}
return $this->currentSiteProvider->get();
}
/**
* Vrai si le prestataire est dans le perimetre site de l'user courant — ou si
* aucun cloisonnement ne s'applique.
*/
public function isInScope(Provider $provider): bool
{
$scopeSite = $this->siteScopeOrNull();
if (null === $scopeSite) {
return true;
}
return $this->providerHasSite($provider, (int) $scopeSite->getId());
}
/**
* Leve un 404 si le prestataire est hors perimetre (anti-enumeration : ne pas
* reveler l'existence d'une ligne hors site). No-op si dans le perimetre.
*/
public function assertInScope(Provider $provider): void
{
if (!$this->isInScope($provider)) {
throw new NotFoundHttpException('Prestataire introuvable.');
}
}
/**
* Vrai si le prestataire est rattache (relation directe provider.sites) au site
* d'id donne. Comparaison en memoire sur l'entite deja chargee.
*/
private function providerHasSite(Provider $provider, int $siteId): bool
{
foreach ($provider->getSites() as $site) {
if ($site instanceof SiteInterface && $site->getId() === $siteId) {
return true;
}
}
return false;
}
}
@@ -9,7 +9,7 @@ use App\Module\Technique\Domain\Entity\Provider;
/** /**
* Tests fonctionnels des sous-ressources Contacts / Adresses / RIB du prestataire * 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 * (M3, spec § 4.5 — ERP-135). Couvrent : normalisation contact (RG-3.11), RG-3.04
* (au moins un champ parmi prenom/nom/telephone/email), RG-3.05 (>= 1 site sur * (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), * 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`), * 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 * RG-3.08 (DELETE dernier RIB sous LCR -> 409), DELETE contact libre au M3 (pas de
@@ -53,18 +53,37 @@ final class ProviderSubResourceApiTest extends AbstractProviderApiTestCase
} }
/** /**
* RG-3.04 : un bloc sans aucun champ du CHECK (prenom/nom/telephone/email) est * RG-3.04 : un bloc Contact est valide des qu'AU MOINS UN champ est rempli parmi
* rejete avant la base (chk_provider_contact_name) -> 422 rattachee a firstName. * prenom / nom / FONCTION / telephone / email (spec § RG-3.04, ligne 926). Ici
* Ici seul jobTitle est fourni (hors CHECK). * seul jobTitle (Fonction) est fourni -> le bloc est valide -> 201.
*/ */
public function testPostContactWithoutNamedFieldReturns422OnFirstNamePath(): void public function testPostContactWithOnlyJobTitleReturns201(): void
{ {
$client = $this->createAdminClient(); $client = $this->createAdminClient();
$seed = $this->seedProvider('Contact No Name'); $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', [ $response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [
'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD],
'json' => ['jobTitle' => 'Directeur'], 'json' => ['jobTitle' => ' '],
]); ]);
self::assertResponseStatusCodeSame(422); self::assertResponseStatusCodeSame(422);
@@ -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']);
}
}