diff --git a/src/Module/Technique/Domain/Entity/ProviderAddress.php b/src/Module/Technique/Domain/Entity/ProviderAddress.php index 4b26a5d..bc80254 100644 --- a/src/Module/Technique/Domain/Entity/ProviderAddress.php +++ b/src/Module/Technique/Domain/Entity/ProviderAddress.php @@ -4,6 +4,13 @@ declare(strict_types=1); namespace App\Module\Technique\Domain\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderAddressProcessor; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\CategoryInterface; @@ -32,11 +39,55 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; * type PRESTATAIRE attendu (RG-3.09, Assert\Callback validateCategoryType). * * Embarquee sous `provider.addresses` au detail (groupe provider:item:read, - * maillon (a)). L'exposition en SOUS-RESSOURCE API est un ticket ulterieur du M3 : - * pas d'#[ApiResource] ici. + * maillon (a)). + * + * Sous-ressource API (ERP-135, spec § 4.5) : + * - POST /api/providers/{providerId}/addresses : creation rattachee au prestataire + * parent (Link toProperty 'provider'), security technique.providers.manage. + * - PATCH / DELETE /api/provider_addresses/{id} : security technique.providers.manage. + * - GET /api/provider_addresses/{id} : lecture unitaire (security view) — la lecture + * courante reste via le parent. Pas de GET collection autonome. + * Tout passe par le ProviderAddressProcessor (rattachement parent + cloisonnement + * d'ecriture des sites, § 2.13). Les regles RG-3.05/3.06/3.09 sont portees par les + * contraintes de l'entite (jouees avant le processor). * * Audite (#[Auditable]) + Timestampable / Blamable. */ +#[ApiResource( + operations: [ + new Get( + security: "is_granted('technique.providers.view')", + // site:read + category:read : embarquent les Site / Category lies + // (maillon (c)) plutot que des IRI nus dans le retour. + normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']], + ), + new Post( + uriTemplate: '/providers/{providerId}/addresses', + uriVariables: [ + 'providerId' => new Link(fromClass: Provider::class, toProperty: 'provider'), + ], + // read:false : pas de stade lecture du parent. Le Link toProperty + // resoudrait l'enfant (SELECT ProviderAddress ... WHERE provider = :id) + // et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache + // manuellement par ProviderAddressProcessor::linkParent (404 si absent). + read: false, + security: "is_granted('technique.providers.manage')", + normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']], + denormalizationContext: ['groups' => ['provider:write:addresses']], + processor: ProviderAddressProcessor::class, + ), + new Patch( + security: "is_granted('technique.providers.manage')", + normalizationContext: ['groups' => ['provider:item:read', 'site:read', 'category:read', 'default:read']], + denormalizationContext: ['groups' => ['provider:write:addresses']], + processor: ProviderAddressProcessor::class, + ), + new Delete( + security: "is_granted('technique.providers.manage')", + processor: ProviderAddressProcessor::class, + ), + ], +)] #[ORM\Entity] #[ORM\Table(name: 'provider_address')] #[ORM\Index(name: 'idx_provider_address_provider', columns: ['provider_id'])] diff --git a/src/Module/Technique/Domain/Entity/ProviderContact.php b/src/Module/Technique/Domain/Entity/ProviderContact.php index 9abe03e..e286c4c 100644 --- a/src/Module/Technique/Domain/Entity/ProviderContact.php +++ b/src/Module/Technique/Domain/Entity/ProviderContact.php @@ -4,6 +4,13 @@ declare(strict_types=1); namespace App\Module\Technique\Domain\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderContactProcessor; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\TimestampableInterface; @@ -15,19 +22,59 @@ use Symfony\Component\Validator\Constraints as Assert; /** * Contact d'un prestataire (1:n) — onglet Contacts. Un bloc est valide des qu'au * moins un champ est rempli (RG-3.04) : garantie portee par un CHECK BDD - * (chk_provider_contact_name) + le ProviderProcessor (ERP-134) ; l'entite reste - * permissive (tous les champs nullable). + * (chk_provider_contact_name) + le ProviderContactProcessor (ERP-135) ; l'entite + * reste permissive (tous les champs nullable). * * Embarque sous `provider.contacts` au detail (groupe provider:item:read, * maillon (a) du contrat de serialisation). Maximum 2 telephones * (phonePrimary + phoneSecondary). * - * L'exposition en SOUS-RESSOURCE API (POST /providers/{id}/contacts, PATCH / - * DELETE) est un ticket ulterieur du M3 : pas d'#[ApiResource] ici (l'entite est - * pour l'instant uniquement embarquee via le detail du prestataire). + * Sous-ressource API (ERP-135, spec § 4.5) : + * - POST /api/providers/{providerId}/contacts : creation rattachee au prestataire + * parent (Link toProperty 'provider'), security technique.providers.manage. + * - PATCH / DELETE /api/provider_contacts/{id} : security technique.providers.manage. + * Le DELETE est physique et libre (pas de garde « dernier contact » au M3 — + * RG-3.12 front-driven, la collection peut rester vide cote back). + * - GET /api/provider_contacts/{id} : lecture unitaire (security view) — la lecture + * courante reste via le parent (le prestataire embarque ses contacts). Pas de GET + * collection autonome. + * Tout passe par le ProviderContactProcessor (normalisation RG-3.11, RG-3.04). * * Audite (#[Auditable]) + Timestampable / Blamable (pattern Shared standard). */ +#[ApiResource( + operations: [ + new Get( + security: "is_granted('technique.providers.view')", + normalizationContext: ['groups' => ['provider:item:read']], + ), + new Post( + uriTemplate: '/providers/{providerId}/contacts', + uriVariables: [ + 'providerId' => new Link(fromClass: Provider::class, toProperty: 'provider'), + ], + // read:false : pas de stade lecture du parent. Le Link toProperty + // resoudrait l'enfant (SELECT ProviderContact ... WHERE provider = :id) + // et casse en NonUniqueResult des >= 2 enfants. Le parent est rattache + // manuellement par ProviderContactProcessor::linkParent (404 si absent). + read: false, + security: "is_granted('technique.providers.manage')", + normalizationContext: ['groups' => ['provider:item:read']], + denormalizationContext: ['groups' => ['provider:write:contacts']], + processor: ProviderContactProcessor::class, + ), + new Patch( + security: "is_granted('technique.providers.manage')", + normalizationContext: ['groups' => ['provider:item:read']], + denormalizationContext: ['groups' => ['provider:write:contacts']], + processor: ProviderContactProcessor::class, + ), + new Delete( + security: "is_granted('technique.providers.manage')", + processor: ProviderContactProcessor::class, + ), + ], +)] #[ORM\Entity] #[ORM\Table(name: 'provider_contact')] #[ORM\Index(name: 'idx_provider_contact_provider', columns: ['provider_id'])] diff --git a/src/Module/Technique/Domain/Entity/ProviderRib.php b/src/Module/Technique/Domain/Entity/ProviderRib.php index c400c67..2b74bcb 100644 --- a/src/Module/Technique/Domain/Entity/ProviderRib.php +++ b/src/Module/Technique/Domain/Entity/ProviderRib.php @@ -4,6 +4,13 @@ declare(strict_types=1); namespace App\Module\Technique\Domain\Entity; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Delete; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\Link; +use ApiPlatform\Metadata\Patch; +use ApiPlatform\Metadata\Post; +use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderRibProcessor; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; use App\Shared\Domain\Contract\TimestampableInterface; @@ -15,7 +22,7 @@ use Symfony\Component\Validator\Constraints as Assert; /** * Coordonnees bancaires d'un prestataire (1:n) — onglet Comptabilite. Au moins un * RIB est obligatoire si le type de reglement est LCR (RG-3.08, verifie au - * ProviderProcessor : refus du DELETE du dernier RIB sous LCR — ERP-134). + * ProviderRibProcessor : refus du DELETE du dernier RIB sous LCR — ERP-135). * * Embarque sous `provider.ribs` UNIQUEMENT si l'user a accounting.view : le * read-group est `provider:read:accounting`, retire du contexte par le @@ -23,14 +30,53 @@ use Symfony\Component\Validator\Constraints as Assert; * piege n°4 du M1). Aucun #[AuditIgnore] sur iban/bic : l'audit etant admin-only, * la tracabilite RIB est conservee (decision M1 reportee, § 2.7). * - * L'exposition en SOUS-RESSOURCE API (POST /providers/{id}/ribs, PATCH / DELETE, - * gating accounting.manage) est un ticket ulterieur du M3 : pas d'#[ApiResource] - * ici. + * Sous-ressource API (ERP-135, spec § 4.5) — gating comptable renforce : + * - POST /api/providers/{providerId}/ribs : creation rattachee au prestataire + * parent (Link toProperty 'provider'), security technique.providers.accounting.manage. + * - PATCH / DELETE /api/provider_ribs/{id} : security technique.providers.accounting.manage. + * Le DELETE refuse la suppression du dernier RIB sous LCR (RG-3.08, 409). + * - GET /api/provider_ribs/{id} : lecture unitaire, security + * technique.providers.accounting.view (donnees bancaires sensibles). Pas de GET + * collection autonome. + * Tout passe par le ProviderRibProcessor (RG-3.08 sur DELETE). * * Validation IBAN/BIC : Assert\Iban + Assert\Bic standard Symfony (pas de controle * banque reelle), avec controle croise pays BIC/IBAN (ibanPropertyPath). Audite * (#[Auditable]) + Timestampable / Blamable. */ +#[ApiResource( + operations: [ + new Get( + security: "is_granted('technique.providers.accounting.view')", + normalizationContext: ['groups' => ['provider:read:accounting']], + ), + new Post( + uriTemplate: '/providers/{providerId}/ribs', + uriVariables: [ + 'providerId' => new Link(fromClass: Provider::class, toProperty: 'provider'), + ], + // read:false : pas de stade lecture du parent. Le Link toProperty + // resoudrait l'enfant (SELECT ProviderRib ... WHERE provider = :id) et + // casse en NonUniqueResult des >= 2 enfants. Le parent est rattache + // manuellement par ProviderRibProcessor::linkParent (404 si absent). + read: false, + security: "is_granted('technique.providers.accounting.manage')", + normalizationContext: ['groups' => ['provider:read:accounting']], + denormalizationContext: ['groups' => ['provider:write:accounting']], + processor: ProviderRibProcessor::class, + ), + new Patch( + security: "is_granted('technique.providers.accounting.manage')", + normalizationContext: ['groups' => ['provider:read:accounting']], + denormalizationContext: ['groups' => ['provider:write:accounting']], + processor: ProviderRibProcessor::class, + ), + new Delete( + security: "is_granted('technique.providers.accounting.manage')", + processor: ProviderRibProcessor::class, + ), + ], +)] #[ORM\Entity] #[ORM\Table(name: 'provider_rib')] #[ORM\Index(name: 'idx_provider_rib_provider', columns: ['provider_id'])] diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderAddressProcessor.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderAddressProcessor.php new file mode 100644 index 0000000..ea60640 --- /dev/null +++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderAddressProcessor.php @@ -0,0 +1,212 @@ += 1 site, + * Assert\Count), RG-3.09 (categorie de type PRESTATAIRE, Assert\Callback + * ProviderAddress::validateCategoryType). + * - DELETE : aucune regle metier specifique (suppression physique directe). + * + * La security de l'operation (technique.providers.manage) est appliquee par API + * Platform en amont, de meme que la validation Symfony des contraintes d'attribut. + * + * @implements ProcessorInterface + */ +final class ProviderAddressProcessor implements ProcessorInterface +{ + private const string PERM_BYPASS_SCOPE = 'sites.bypass_scope'; + + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private readonly ProcessorInterface $persistProcessor, + #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')] + private readonly ProcessorInterface $removeProcessor, + private readonly Security $security, + private readonly RequestStack $requestStack, + private readonly EntityManagerInterface $em, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof ProviderAddress) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + if ($operation instanceof DeleteOperationInterface) { + return $this->removeProcessor->process($data, $operation, $uriVariables, $context); + } + + $this->linkParent($data, $uriVariables); + $this->guardSiteScope($data); + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + /** + * Rattache l'adresse au prestataire parent de la sous-ressource POST + * (/providers/{providerId}/addresses) : la relation n'est pas peuplee + * automatiquement par le Link sur une ecriture. Sur PATCH, no-op. + */ + private function linkParent(ProviderAddress $address, array $uriVariables): void + { + if (null !== $address->getProvider()) { + return; + } + + $providerId = $uriVariables['providerId'] ?? null; + if (null === $providerId) { + return; + } + + $provider = $providerId instanceof Provider + ? $providerId + : $this->em->getRepository(Provider::class)->find($providerId); + + // read:false sur le POST : sans stade lecture, un parent introuvable n'est + // plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la + // contrainte provider_id NOT NULL). + if (!$provider instanceof Provider) { + throw new NotFoundHttpException('Prestataire introuvable.'); + } + + $address->setProvider($provider); + } + + /** + * RG-3.05 / § 2.13 (cloisonnement d'ECRITURE — decision Matthieu 11/06) : un + * user SANS `sites.bypass_scope` ne peut attacher a CHAQUE adresse que des + * sites figurant dans ses propres `user_site`. Tout site hors perimetre -> 422 + * sur `sites` (propertyPath consommable inline, convention ERP-101). Un user + * `bypass_scope` (Admin) peut attacher n'importe quel site. Miroir de + * ProviderProcessor::guardSiteScope, applique ici a la sous-ressource adresse. + * + * Ne joue que si `sites` est effectivement soumis : POST (entite non geree, + * sites obligatoires RG-3.05) ou PATCH portant la cle `sites`. Un PATCH qui ne + * touche pas aux sites n'est pas re-valide (les sites ont ete cloisonnes a leur + * pose). La validation porte sur l'ETAT RESULTANT (address.getSites()). + */ + private function guardSiteScope(ProviderAddress $address): void + { + if ($this->security->isGranted(self::PERM_BYPASS_SCOPE)) { + return; + } + + // sites non soumis sur un PATCH : rien a cloisonner. + if ($this->em->contains($address) && !in_array('sites', $this->payloadKeys(), true)) { + return; + } + + $allowedSiteIds = $this->currentUserSiteIds(); + + foreach ($address->getSites() as $site) { + if (!$site instanceof SiteInterface) { + continue; + } + if (!in_array($site->getId(), $allowedSiteIds, true)) { + $this->throwSitesViolation($address); + } + } + } + + /** + * Identifiants des sites rattaches a l'utilisateur courant (`user_site`). + * Vide si pas d'user authentifie (cas defensif : la security d'operation + * garantit deja l'authentification). + * + * @return list + */ + private function currentUserSiteIds(): array + { + $user = $this->security->getUser(); + if (!$user instanceof User) { + return []; + } + + $ids = []; + foreach ($user->getSites() as $site) { + if ($site instanceof SiteInterface && null !== $site->getId()) { + $ids[] = $site->getId(); + } + } + + return $ids; + } + + /** + * Cles de premier niveau effectivement envoyees par le client (payload JSON + * brut). Pour un PATCH merge-patch+json, ce sont les seuls champs modifies. + * Corps vide ou JSON invalide -> aucune cle. + * + * @return list + */ + private function payloadKeys(): array + { + $request = $this->requestStack->getCurrentRequest(); + if (null === $request) { + return []; + } + + $content = $request->getContent(); + if ('' === $content) { + return []; + } + + try { + $decoded = json_decode($content, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException) { + return []; + } + + return is_array($decoded) ? array_values(array_filter(array_keys($decoded), 'is_string')) : []; + } + + /** + * Leve une 422 portant une violation unique sur `sites` — meme rendu Hydra que + * les contraintes Symfony, consommable inline par extractApiViolations (ERP-101). + * + * @return never + */ + private function throwSitesViolation(ProviderAddress $address): void + { + $violations = new ConstraintViolationList(); + $violations->add(new ConstraintViolation( + 'Vous ne pouvez rattacher que des sites auxquels vous avez accès.', + null, + [], + $address, + 'sites', + null, + )); + + throw new ValidationException($violations); + } +} diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderContactProcessor.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderContactProcessor.php new file mode 100644 index 0000000..abf16c4 --- /dev/null +++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderContactProcessor.php @@ -0,0 +1,140 @@ + + */ +final class ProviderContactProcessor implements ProcessorInterface +{ + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private readonly ProcessorInterface $persistProcessor, + #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')] + private readonly ProcessorInterface $removeProcessor, + private readonly ProviderFieldNormalizer $normalizer, + private readonly EntityManagerInterface $em, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof ProviderContact) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + if ($operation instanceof DeleteOperationInterface) { + return $this->removeProcessor->process($data, $operation, $uriVariables, $context); + } + + $this->linkParent($data, $uriVariables); + $this->normalize($data); + $this->validateName($data); + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + /** + * Rattache le contact au prestataire parent de la sous-ressource POST + * (/providers/{providerId}/contacts). La relation n'est pas peuplee + * automatiquement par le Link sur une operation d'ecriture : on resout le + * parent depuis l'uri variable. Sur PATCH (entite existante), le prestataire + * est deja present -> no-op. + */ + private function linkParent(ProviderContact $contact, array $uriVariables): void + { + if (null !== $contact->getProvider()) { + return; + } + + $providerId = $uriVariables['providerId'] ?? null; + if (null === $providerId) { + return; + } + + $provider = $providerId instanceof Provider + ? $providerId + : $this->em->getRepository(Provider::class)->find($providerId); + + // read:false sur le POST : sans stade lecture, un parent introuvable n'est + // plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la + // contrainte provider_id NOT NULL). + if (!$provider instanceof Provider) { + throw new NotFoundHttpException('Prestataire introuvable.'); + } + + $contact->setProvider($provider); + } + + /** + * Normalisation serveur (RG-3.11). Toutes les methodes du normalizer sont + * null-safe : une chaine vide apres trim devient null. + */ + private function normalize(ProviderContact $contact): void + { + $contact->setFirstName($this->normalizer->normalizePersonName($contact->getFirstName())); + $contact->setLastName($this->normalizer->normalizePersonName($contact->getLastName())); + $contact->setPhonePrimary($this->normalizer->normalizePhone($contact->getPhonePrimary())); + $contact->setPhoneSecondary($this->normalizer->normalizePhone($contact->getPhoneSecondary())); + $contact->setEmail($this->normalizer->normalizeEmail($contact->getEmail())); + } + + /** + * 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 + * BDD chk_provider_contact_name — leve une 422 propre rattachee au champ + * `firstName` plutot qu'une 500 SQL). Joue apres normalisation, donc les + * chaines vides (y compris un phone_secondary seul, hors CHECK) sont deja + * ramenees a null et ne suffisent pas a valider le bloc. + */ + private function validateName(ProviderContact $contact): void + { + if (null === $contact->getFirstName() + && null === $contact->getLastName() + && null === $contact->getPhonePrimary() + && null === $contact->getEmail()) { + $violations = new ConstraintViolationList(); + $violations->add(new ConstraintViolation( + 'Au moins un champ du contact est obligatoire (nom, prénom, téléphone ou email).', + null, + [], + $contact, + 'firstName', + null, + )); + + throw new ValidationException($violations); + } + } +} diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderRibProcessor.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderRibProcessor.php new file mode 100644 index 0000000..d9ac8f6 --- /dev/null +++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderRibProcessor.php @@ -0,0 +1,113 @@ + + */ +final class ProviderRibProcessor implements ProcessorInterface +{ + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private readonly ProcessorInterface $persistProcessor, + #[Autowire(service: 'api_platform.doctrine.orm.state.remove_processor')] + private readonly ProcessorInterface $removeProcessor, + private readonly EntityManagerInterface $em, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof ProviderRib) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + if ($operation instanceof DeleteOperationInterface) { + $this->guardLastRibDeletionUnderLcr($data); + + return $this->removeProcessor->process($data, $operation, $uriVariables, $context); + } + + $this->linkParent($data, $uriVariables); + + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + /** + * Rattache le RIB au prestataire parent de la sous-ressource POST + * (/providers/{providerId}/ribs) : la relation n'est pas peuplee + * automatiquement par le Link sur une ecriture. Sur PATCH, no-op. + */ + private function linkParent(ProviderRib $rib, array $uriVariables): void + { + if (null !== $rib->getProvider()) { + return; + } + + $providerId = $uriVariables['providerId'] ?? null; + if (null === $providerId) { + return; + } + + $provider = $providerId instanceof Provider + ? $providerId + : $this->em->getRepository(Provider::class)->find($providerId); + + // read:false sur le POST : sans stade lecture, un parent introuvable n'est + // plus intercepte en amont -> 404 explicite (sinon 500 au persist sur la + // contrainte provider_id NOT NULL). + if (!$provider instanceof Provider) { + throw new NotFoundHttpException('Prestataire introuvable.'); + } + + $rib->setProvider($provider); + } + + /** + * RG-3.08 : un prestataire dont le type de reglement est LCR doit conserver au + * moins un RIB. La collection inclut le RIB en cours de suppression : un + * effectif <= 1 signifie qu'il ne resterait aucun RIB -> 409. Pour tout autre + * type de reglement, les RIBs sont optionnels (suppression libre). + */ + private function guardLastRibDeletionUnderLcr(ProviderRib $rib): void + { + $provider = $rib->getProvider(); + if (null === $provider) { + return; + } + + if ('LCR' === $provider->getPaymentType()?->getCode() && $provider->getRibs()->count() <= 1) { + throw new ConflictHttpException( + 'Impossible de supprimer le dernier RIB : le type de règlement LCR exige au moins un RIB.', + ); + } + } +} diff --git a/tests/Module/Technique/Api/AbstractProviderApiTestCase.php b/tests/Module/Technique/Api/AbstractProviderApiTestCase.php index 5e5d5e3..15c6522 100644 --- a/tests/Module/Technique/Api/AbstractProviderApiTestCase.php +++ b/tests/Module/Technique/Api/AbstractProviderApiTestCase.php @@ -7,11 +7,14 @@ namespace App\Tests\Module\Technique\Api; use ApiPlatform\Symfony\Bundle\Test\Client; use App\Module\Catalog\Domain\Entity\Category; use App\Module\Catalog\Domain\Entity\CategoryType; +use App\Module\Commercial\Domain\Entity\PaymentType; use App\Module\Core\Domain\Entity\Permission; use App\Module\Core\Domain\Entity\Role; use App\Module\Core\Domain\Entity\User; use App\Module\Sites\Domain\Entity\Site; use App\Module\Technique\Domain\Entity\Provider; +use App\Module\Technique\Domain\Entity\ProviderContact; +use App\Module\Technique\Domain\Entity\ProviderRib; use App\Tests\Module\Core\Api\AbstractApiTestCase; use DateTimeImmutable; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; @@ -48,6 +51,12 @@ abstract class AbstractProviderApiTestCase extends AbstractApiTestCase protected const string SITE_17 = '17400'; // Saint-Jean protected const string SITE_82 = '82400'; // Pommevic + /** IBAN / BIC valides (memes valeurs que les tests M2) pour les RIB. */ + protected const string VALID_IBAN = 'FR1420041010050500013M02606'; + protected const string VALID_BIC = 'BNPAFRPPXXX'; + /** BIC d'un autre pays (DE) : controle croise pays BIC/IBAN. */ + protected const string FOREIGN_BIC = 'DEUTDEFFXXX'; + protected function tearDown(): void { $em = $this->getEm(); @@ -268,6 +277,64 @@ abstract class AbstractProviderApiTestCase extends AbstractApiTestCase return ['username' => $username, 'password' => $password]; } + /** + * Ajoute un contact a un prestataire deja persiste (seed direct). + */ + protected function addContact( + Provider $provider, + ?string $firstName = 'Marie', + ?string $lastName = 'Martin', + ?string $phonePrimary = null, + ?string $email = null, + int $position = 0, + ): ProviderContact { + $contact = new ProviderContact(); + $contact->setProvider($provider); + $contact->setFirstName($firstName); + $contact->setLastName($lastName); + $contact->setPhonePrimary($phonePrimary); + $contact->setEmail($email); + $contact->setPosition($position); + $provider->addContact($contact); + $this->getEm()->persist($contact); + $this->getEm()->flush(); + + return $contact; + } + + /** + * Ajoute un RIB a un prestataire deja persiste (seed direct). + */ + protected function addRib(Provider $provider, string $label = 'Compte principal'): ProviderRib + { + $rib = new ProviderRib(); + $rib->setProvider($provider); + $rib->setLabel($label); + $rib->setBic(self::VALID_BIC); + $rib->setIban(self::VALID_IBAN); + $provider->addRib($rib); + $this->getEm()->persist($rib); + $this->getEm()->flush(); + + return $rib; + } + + /** + * Recupere un type de reglement seede (CommercialReferentialFixtures) par code + * (ex. LCR, VIREMENT). Echoue explicitement si absent (fixtures non chargees). + */ + protected function paymentType(string $code): PaymentType + { + $paymentType = $this->getEm()->getRepository(PaymentType::class)->findOneBy(['code' => $code]); + + self::assertNotNull( + $paymentType, + sprintf('Type de reglement "%s" introuvable : fixtures comptables chargees (make test-db-setup) ?', $code), + ); + + return $paymentType; + } + /** * Indexe les violations d'un corps 422 par propertyPath (assert ciblee). * diff --git a/tests/Module/Technique/Api/ProviderSubResourceApiTest.php b/tests/Module/Technique/Api/ProviderSubResourceApiTest.php new file mode 100644 index 0000000..7663083 --- /dev/null +++ b/tests/Module/Technique/Api/ProviderSubResourceApiTest.php @@ -0,0 +1,392 @@ += 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 sans aucun champ du CHECK (prenom/nom/telephone/email) est + * rejete avant la base (chk_provider_contact_name) -> 422 rattachee a firstName. + * Ici seul jobTitle est fourni (hors CHECK). + */ + public function testPostContactWithoutNamedFieldReturns422OnFirstNamePath(): void + { + $client = $this->createAdminClient(); + $seed = $this->seedProvider('Contact No Name'); + + $response = $client->request('POST', '/api/providers/'.$seed->getId().'/contacts', [ + 'headers' => ['Content-Type' => self::LD, 'Accept' => self::LD], + 'json' => ['jobTitle' => 'Directeur'], + ]); + + 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); + } +}