From 0ca1fb159a5a330ea78f0c032143e6fe9527b99f Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 12 Jun 2026 11:03:19 +0200 Subject: [PATCH] feat(technique) : ProviderProvider + ProviderProcessor + cloisonnement site (ERP-134) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coeur API du repertoire prestataires (M3), jumeau du M2 fournisseurs : - ProviderProvider : liste paginee (Paginator ORM), filtres search/categoryCode/siteId/includeArchived, tri companyName ASC, exclusion archives + soft-deletes (RG-3.16). Cloisonnement par site pilote par l'utilisateur (RG-3.17 / § 2.13) : liste restreinte au currentSite avant pagination (totalItems = perimetre), detail hors perimetre -> 404, bypass via sites.bypass_scope. - ProviderProcessor : normalisation companyName (RG-3.11), POST formulaire principal (companyName + categories + sites), PATCH partiels par groupe en mode strict (RG-3.15, 403 sur tout le payload), archivage (RG-3.13/3.14), 409 doublon de nom (RG-3.10), garde d'ecriture cloisonnee des sites (RG-3.03/3.17, 422 sur sites pour les users sites.read_ref). - ProviderReadGroupContextBuilder : gating comptabilite par AJOUT du groupe provider:read:accounting si accounting.view (jamais par retrait). - ProviderFieldNormalizer : miroir SupplierFieldNormalizer. - ApiResource cable (provider + processor) sur l'entite Provider. Tests : ProviderApiTest, ProviderListTest, ProviderRbacGatingTest, ProviderSiteScopeTest (26 tests). Suite complete verte (612 tests). --- .../Service/ProviderFieldNormalizer.php | 82 +++ .../Technique/Domain/Entity/Provider.php | 28 +- .../ProviderRepositoryInterface.php | 15 + .../ProviderReadGroupContextBuilder.php | 76 +++ .../State/Processor/ProviderProcessor.php | 559 ++++++++++++++++++ .../State/Provider/ProviderProvider.php | 256 ++++++++ .../Doctrine/DoctrineProviderRepository.php | 20 + .../Api/AbstractProviderApiTestCase.php | 287 +++++++++ .../Module/Technique/Api/ProviderApiTest.php | 115 ++++ .../Module/Technique/Api/ProviderListTest.php | 83 +++ .../Technique/Api/ProviderRbacGatingTest.php | 159 +++++ .../Technique/Api/ProviderSiteScopeTest.php | 171 ++++++ 12 files changed, 1841 insertions(+), 10 deletions(-) create mode 100644 src/Module/Technique/Application/Service/ProviderFieldNormalizer.php create mode 100644 src/Module/Technique/Infrastructure/ApiPlatform/Serializer/ProviderReadGroupContextBuilder.php create mode 100644 src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderProcessor.php create mode 100644 src/Module/Technique/Infrastructure/ApiPlatform/State/Provider/ProviderProvider.php create mode 100644 tests/Module/Technique/Api/AbstractProviderApiTestCase.php create mode 100644 tests/Module/Technique/Api/ProviderApiTest.php create mode 100644 tests/Module/Technique/Api/ProviderListTest.php create mode 100644 tests/Module/Technique/Api/ProviderRbacGatingTest.php create mode 100644 tests/Module/Technique/Api/ProviderSiteScopeTest.php diff --git a/src/Module/Technique/Application/Service/ProviderFieldNormalizer.php b/src/Module/Technique/Application/Service/ProviderFieldNormalizer.php new file mode 100644 index 0000000..facc4c0 --- /dev/null +++ b/src/Module/Technique/Application/Service/ProviderFieldNormalizer.php @@ -0,0 +1,82 @@ + "0612345678" (RG-3.11). + * Le formatage d'affichage "XX XX XX XX XX" est de la responsabilite du front. + * - email : lowercase integral (RG-3.11) + * + * Toutes les methodes sont null-safe et trim-ent l'entree ; une chaine vide + * apres trim devient null (evite de persister "" dans des colonnes nullable). + */ +final class ProviderFieldNormalizer +{ + /** + * Nom de societe en majuscules (RG-3.11). Conserve null tel quel ; une + * chaine non vide est trim + upper. Une chaine vide reste "" (champ + * obligatoire : c'est l'Assert\NotBlank qui rejette, pas le normalizer). + */ + public function normalizeCompanyName(?string $value): ?string + { + if (null === $value) { + return null; + } + + return mb_strtoupper(trim($value), 'UTF-8'); + } + + /** + * Nom/prenom de personne en Title Case (RG-3.11) : "JEAN dupont" -> + * "Jean Dupont". Une chaine vide apres trim devient null. + */ + public function normalizePersonName(?string $value): ?string + { + if (null === $value) { + return null; + } + + $value = trim($value); + + return '' === $value ? null : mb_convert_case($value, MB_CASE_TITLE, 'UTF-8'); + } + + /** + * Email en minuscules (RG-3.11). Une chaine vide apres trim devient null. + */ + public function normalizeEmail(?string $value): ?string + { + if (null === $value) { + return null; + } + + $value = trim($value); + + return '' === $value ? null : mb_strtolower($value, 'UTF-8'); + } + + /** + * Telephone reduit aux chiffres (RG-3.11) : "06.12.34.56.78" -> + * "0612345678". Une valeur sans aucun chiffre devient null. + */ + public function normalizePhone(?string $value): ?string + { + if (null === $value) { + return null; + } + + $digits = preg_replace('/\D+/', '', $value) ?? ''; + + return '' === $digits ? null : $digits; + } +} diff --git a/src/Module/Technique/Domain/Entity/Provider.php b/src/Module/Technique/Domain/Entity/Provider.php index 1d9a5e0..3b1da19 100644 --- a/src/Module/Technique/Domain/Entity/Provider.php +++ b/src/Module/Technique/Domain/Entity/Provider.php @@ -13,6 +13,8 @@ use App\Module\Commercial\Domain\Entity\Bank; use App\Module\Commercial\Domain\Entity\PaymentDelay; use App\Module\Commercial\Domain\Entity\PaymentType; use App\Module\Commercial\Domain\Entity\TvaMode; +use App\Module\Technique\Infrastructure\ApiPlatform\State\Processor\ProviderProcessor; +use App\Module\Technique\Infrastructure\ApiPlatform\State\Provider\ProviderProvider; use App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository; use App\Shared\Domain\Attribute\Auditable; use App\Shared\Domain\Contract\BlamableInterface; @@ -53,11 +55,12 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; * reference de donnees de reference, pas de logique inter-module. * * Contrat de serialisation (RETEX M1, 3 maillons — spec § 4.0) : les read-groups - * sont poses ICI (source unique). L'#[ApiResource] est ici un SQUELETTE (operations - * + contextes + security) ; le ProviderProvider (liste paginee anti-N+1, exclusion - * archives, cloisonnement site, gating accounting) et le ProviderProcessor - * (normalisation, archivage, 409 doublon, RG-3.07 / RG-3.08) sont cables au ticket - * suivant (ERP-134) — ils ne sont volontairement PAS references ici. + * sont poses ICI (source unique). L'#[ApiResource] cable (ERP-134) le ProviderProvider + * (liste paginee anti-N+1, exclusion archives, cloisonnement site lecture + detail + * 404) et le ProviderProcessor (normalisation, archivage, mode strict par groupe, + * cloisonnement site ecriture, 409 doublon). Le groupe provider:read:accounting est + * ajoute dynamiquement au contexte par le ProviderReadGroupContextBuilder selon la + * permission accounting.view (ERP-134) — jamais pose en dur sur l'operation. * * Audite (#[Auditable], tous champs — y compris RIB embarques, § 2.7) + * Timestampable / Blamable via le trait Shared. @@ -69,17 +72,18 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; // La liste embarque les categories (code/name, groupe category:read) et // les sites du prestataire (name/postalCode, groupe site:read — relation // DIRECTE provider.sites, RG-3.03). Maillon (c) : category:read + - // site:read presents dans le contexte. L'hydratation anti-N+1 sera - // cablee par le ProviderProvider (ERP-134, cf. DoctrineProviderRepository). + // site:read presents dans le contexte. Hydratation anti-N+1 cablee par + // le ProviderProvider (cf. DoctrineProviderRepository::hydrateListCollections). normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']], + provider: ProviderProvider::class, ), new Get( security: "is_granted('technique.providers.view')", // Detail : prestataire + sous-collections embarquees (contacts, adresses // + leurs sites/categories/contacts) + RIB (gates compta). Le groupe - // provider:read:accounting est volontairement ABSENT : il sera ajoute au - // contexte par le ProviderProvider / ReadGroupContextBuilder selon la - // permission accounting.view (ERP-134, parade fuite IBAN/BIC — bug #4 M1). + // provider:read:accounting est volontairement ABSENT : il est ajoute au + // contexte par le ProviderReadGroupContextBuilder selon la permission + // accounting.view (parade fuite IBAN/BIC — bug #4 M1). normalizationContext: ['groups' => [ 'provider:read', 'provider:item:read', @@ -87,11 +91,13 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; 'site:read', 'default:read', ]], + provider: ProviderProvider::class, ), new Post( security: "is_granted('technique.providers.manage')", normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']], denormalizationContext: ['groups' => ['provider:write:main']], + processor: ProviderProcessor::class, ), new Patch( // Security elargie : `manage` OU `accounting.manage` — le role Compta n'a @@ -105,6 +111,8 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface; 'provider:write:accounting', 'provider:write:archive', ]], + provider: ProviderProvider::class, + processor: ProviderProcessor::class, ), // Pas de Delete au M3 (HP M4). Archivage via PATCH { isArchived: true }. ], diff --git a/src/Module/Technique/Domain/Repository/ProviderRepositoryInterface.php b/src/Module/Technique/Domain/Repository/ProviderRepositoryInterface.php index d5541a7..49f5fb1 100644 --- a/src/Module/Technique/Domain/Repository/ProviderRepositoryInterface.php +++ b/src/Module/Technique/Domain/Repository/ProviderRepositoryInterface.php @@ -13,6 +13,21 @@ interface ProviderRepositoryInterface public function save(Provider $provider): void; + /** + * Restreint un QueryBuilder de liste aux prestataires rattaches au site donne + * (relation DIRECTE provider.sites). Sert le cloisonnement par site pilote par + * l'utilisateur (RG-3.17, § 2.13) : le ProviderProvider resout le site courant + * (CurrentSiteProvider) puis appelle cette methode quand l'user n'a pas + * `sites.bypass_scope`. Decouple ainsi la DECISION (Provider, qui connait + * l'user) du DQL (repository, qui ne connait que l'id de site). + * + * Sous-requete IN (et non JOIN sur la M2M) pour ne pas perturber le + * DISTINCT / ORDER BY / pagination du QueryBuilder de selection — meme parti + * pris que les filtres ?categoryCode / ?siteId. Applique AVANT la pagination + * (le COUNT du Paginator reflete alors le perimetre de l'user). + */ + public function applySiteScope(QueryBuilder $qb, int $siteId): void; + /** * Construit un QueryBuilder de liste pour le repertoire prestataires. * - Exclut toujours les prestataires soft-deletes (deleted_at IS NOT NULL, RG-3.16). diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/Serializer/ProviderReadGroupContextBuilder.php b/src/Module/Technique/Infrastructure/ApiPlatform/Serializer/ProviderReadGroupContextBuilder.php new file mode 100644 index 0000000..0ba501f --- /dev/null +++ b/src/Module/Technique/Infrastructure/ApiPlatform/Serializer/ProviderReadGroupContextBuilder.php @@ -0,0 +1,76 @@ +decorated->createFromRequest($request, $normalization, $extractedAttributes); + + // Uniquement en lecture, sur la ressource Provider, avec la permission. + if (!$normalization) { + return $context; + } + + if (Provider::class !== ($context['resource_class'] ?? null)) { + return $context; + } + + if (!$this->security->isGranted('technique.providers.accounting.view')) { + return $context; + } + + $groups = $context['groups'] ?? []; + if (!in_array('provider:read:accounting', $groups, true)) { + $groups[] = 'provider:read:accounting'; + } + $context['groups'] = $groups; + + return $context; + } +} diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderProcessor.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderProcessor.php new file mode 100644 index 0000000..a439a13 --- /dev/null +++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Processor/ProviderProcessor.php @@ -0,0 +1,559 @@ + le prestataire est minimal ; + * - le formulaire principal porte `sites` (M2M provider_site, RG-3.03), soumis au + * CLOISONNEMENT D'ECRITURE par site (RG-3.17, § 2.13) : un user sans + * `sites.bypass_scope` ne peut attacher que les sites de ses `user_site`. + * + * Sequence (POST / PATCH) : + * 1. Autorisation additionnelle par groupe d'onglet (mode strict RG-3.15). La + * security d'operation du PATCH est elargie a `manage` OU `accounting.manage` + * pour laisser entrer le role Compta ; ce processor re-gate alors finement : + * - champ comptable modifie dans le payload -> exige accounting.manage (403) ; + * - champ main (companyName / categories / sites) modifie -> exige manage + * (guardManage, 403) : empeche Compta d'editer un autre onglet ; + * - champ isArchived dans le payload -> exige archive (RG-3.13, 403) et + * interdit toute autre modification dans la meme requete (RG-3.13, 422). + * 2. Cloisonnement d'ECRITURE des sites (RG-3.17 / RG-3.03) : tout site attache + * hors des `user_site` de l'appelant non-bypass -> 422 sur `sites`. + * 3. Normalisation serveur (RG-3.11) via ProviderFieldNormalizer (companyName). + * 4. Pose / retrait de archivedAt (RG-3.13 true=now, RG-3.14 false=null). + * 5. Persistance via le persist_processor Doctrine, avec traduction des + * collisions d'unicite en 409 (RG-3.10 doublon de nom ; RG-3.14 conflit de + * restauration). + * + * La RG-3.09 (categorie de type PRESTATAIRE) est portee par un Assert\Callback + + * ->atPath() sur l'entite Provider (joue par API Platform AVANT ce processor), + * pour que la 422 porte un propertyPath consommable par extractApiViolations + * (mapping inline, pas un toast — convention ERP-101). Les RG-3.07 (Virement -> + * banque) et RG-3.08 (LCR -> RIB) relevent de l'onglet Comptabilite / sous-ressource + * RIB (ticket dedie) et ne sont pas portees ici. + * + * @implements ProcessorInterface + */ +final class ProviderProcessor implements ProcessorInterface +{ + /** Champs de l'onglet principal (groupe provider:write:main). */ + private const array MAIN_FIELDS = [ + 'companyName', 'categories', 'sites', + ]; + + /** Champs de l'onglet Comptabilite (groupe provider:write:accounting). */ + private const array ACCOUNTING_FIELDS = [ + 'siren', 'accountNumber', 'tvaMode', 'nTva', 'paymentDelay', + 'paymentType', 'bank', + ]; + + /** Champ d'archivage (groupe provider:write:archive). */ + private const string ARCHIVE_FIELD = 'isArchived'; + + private const string PERM_MANAGE = 'technique.providers.manage'; + private const string PERM_ACCOUNTING_MANAGE = 'technique.providers.accounting.manage'; + private const string PERM_ARCHIVE = 'technique.providers.archive'; + + private const string PERM_BYPASS_SCOPE = 'sites.bypass_scope'; + + /** + * Memoisation du dernier corps de requete decode, clos par le contenu brut + * (cf. SupplierProcessor) : payloadKeys() est appele plusieurs fois par requete, + * on evite de rejouer json_decode. Cle = contenu lui-meme, calcul pur -> aucune + * fuite entre requetes sur ce service partage. + */ + private ?string $decodedContent = null; + + /** @var list Cles de premier niveau correspondant au corps memoise. */ + private array $decodedPayloadKeys = []; + + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private readonly ProcessorInterface $persistProcessor, + private readonly ProviderFieldNormalizer $normalizer, + 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 Provider) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + // Reinitialisation de la memoisation du payload : le service est partage + // (stateful), on repart du corps de LA requete courante. + $this->decodedContent = null; + $this->decodedPayloadKeys = []; + + $writableKeys = $this->writablePayloadKeys(); + + $isArchiveRequest = $this->guardArchive($data, $writableKeys); + $this->guardAccounting($data); + $this->guardSiteScope($data); + + $this->normalize($data); + + // guardManage apres normalize : la comparaison « change vs etat persiste » + // des champs texte (companyName) se fait sur des valeurs normalisees des + // deux cotes (l'etat persiste l'a deja ete). + $this->guardManage($data); + + try { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } catch (UniqueConstraintViolationException $e) { + // Le seul index unique partiel est uq_provider_company_name_active + // (LOWER(company_name) parmi non-archives/non-deletes — § 2.6). + if ($isArchiveRequest && false === $data->isArchived()) { + // RG-3.14 : restauration en conflit avec un homonyme actif. + throw new ConflictHttpException( + 'Restauration impossible : un autre prestataire a pris le nom entre-temps.', + $e, + ); + } + + // RG-3.10 : doublon de nom de societe. + throw new ConflictHttpException( + sprintf('Un prestataire nommé "%s" existe déjà.', (string) $data->getCompanyName()), + $e, + ); + } + } + + /** + * RG-3.13 / RG-3.14 : si le payload bascule reellement isArchived, exige la + * permission archive (403), interdit toute autre modification (422) et + * pose/retire archivedAt. Retourne true si la requete est une requete + * d'archivage. Restreint a la mise a jour d'un prestataire existant ET au seul + * cas ou isArchived change vraiment (cf. SupplierProcessor). + * + * @param list $writableKeys cles ecrivables du payload (hors @* et champs inconnus) + */ + private function guardArchive(Provider $data, array $writableKeys): bool + { + // POST / entite non geree : l'archivage est une action de mise a jour. + if (!$this->em->contains($data)) { + return false; + } + + // isArchived inchange par rapport a l'etat persiste : pas une requete + // d'archivage (cas du PATCH representation complete). + if (!$this->fieldChanged($data, 'isArchived', $data->isArchived())) { + return false; + } + + if (!$this->security->isGranted(self::PERM_ARCHIVE)) { + throw new AccessDeniedHttpException(sprintf( + 'Le champ "%s" requiert la permission "%s".', + self::ARCHIVE_FIELD, + self::PERM_ARCHIVE, + )); + } + + // RG-3.13 : une requete d'archivage ne modifie aucun autre champ ecrivable. + if ([] !== array_diff($writableKeys, [self::ARCHIVE_FIELD])) { + throw new UnprocessableEntityHttpException( + 'Une requête d\'archivage ne peut modifier aucun autre champ que "isArchived".', + ); + } + + // RG-3.13 (true -> now) / RG-3.14 (false -> null). + $data->setArchivedAt($data->isArchived() ? new DateTimeImmutable() : null); + + return true; + } + + /** + * RG-3.15 : la modification effective d'un champ comptable exige + * accounting.manage, sinon 403 sur l'ensemble du payload (mode strict, pas de + * filtrage silencieux). On ne gate que si un champ change reellement par + * rapport a l'etat persiste (POST/PATCH renvoyant des champs comptables + * inchanges ne declenche pas de 403 parasite). Le message precise le premier + * champ fautif. + */ + private function guardAccounting(Provider $data): void + { + $changed = $this->changedAccountingFields($data); + + if ([] === $changed) { + return; + } + + if (!$this->security->isGranted(self::PERM_ACCOUNTING_MANAGE)) { + throw new AccessDeniedHttpException(sprintf( + 'Le champ "%s" requiert la permission "%s".', + $changed[0], + self::PERM_ACCOUNTING_MANAGE, + )); + } + } + + /** + * § 2.9 / RG-3.15 : la modification effective d'un champ « metier » (onglet + * principal : companyName / categories / sites) exige + * `technique.providers.manage`. Sans cette permission -> 403 sur l'ensemble du + * payload (mode strict, miroir de guardAccounting). C'est ce qui empeche le + * role Compta — qui entre dans le PATCH via `accounting.manage` (security + * d'operation elargie) — d'editer autre chose que l'onglet Comptabilite. + * + * Ne s'applique qu'aux mises a jour (entite geree) : la creation (POST) est + * deja gardee par la security d'operation `manage`. + */ + private function guardManage(Provider $data): void + { + if (!$this->em->contains($data)) { + return; + } + + $changed = $this->changedBusinessFields($data); + + if ([] === $changed) { + return; + } + + if (!$this->security->isGranted(self::PERM_MANAGE)) { + throw new AccessDeniedHttpException(sprintf( + 'Le champ "%s" requiert la permission "%s".', + $changed[0], + self::PERM_MANAGE, + )); + } + } + + /** + * RG-3.17 / RG-3.03 (cloisonnement d'ECRITURE — § 2.13) : un user SANS + * `sites.bypass_scope` ne peut attacher au prestataire 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 auto) peut attacher n'importe quel site. + * + * Interaction avec SiteCollectionScopedExtension (module Sites) : pour un user + * sans `sites.bypass_scope` NI `sites.read_ref`, la resolution de l'IRI de site + * hors perimetre echoue DEJA en amont (item Site « introuvable » -> 400 + * anti-enumeration), avant ce processor. Cette garde reste donc l'enforcement + * AUTORITAIRE de RG-3.17 pour le cas particulier d'un user `sites.read_ref` + * (qui peut resoudre N'IMPORTE quel site comme referentiel transverse mais ne + * doit rattacher que ses propres sites), et une defense en profondeur sinon. + * + * Ne joue que si `sites` est effectivement soumis : POST (entite non geree, + * sites obligatoires RG-3.03) 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 (data.getSites()). + */ + private function guardSiteScope(Provider $data): void + { + if ($this->security->isGranted(self::PERM_BYPASS_SCOPE)) { + return; + } + + // sites non soumis sur un PATCH : rien a cloisonner. + if ($this->em->contains($data) && !in_array('sites', $this->payloadKeys(), true)) { + return; + } + + $allowedSiteIds = $this->currentUserSiteIds(); + + foreach ($data->getSites() as $site) { + if (!$site instanceof SiteInterface) { + continue; + } + if (!in_array($site->getId(), $allowedSiteIds, true)) { + $this->throwSitesViolation($data); + } + } + } + + /** + * 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; + } + + /** + * Champs « metier » (onglet principal : companyName / categories / sites) dont + * la valeur courante differe de l'etat persiste. Scalaires compares par valeur ; + * collections M2M (categories / sites) comparees par ensemble d'identifiants + * (cf. collectionChanged) — la simple presence dans le payload ne suffit pas, + * sous peine de 403 parasite sur un PATCH representation complete. + * + * @return list + */ + private function changedBusinessFields(Provider $data): array + { + $changed = []; + + if ($this->fieldChanged($data, 'companyName', $data->getCompanyName())) { + $changed[] = 'companyName'; + } + + if ($this->collectionChanged($data, 'categories', $data->getCategories()->toArray())) { + $changed[] = 'categories'; + } + + if ($this->collectionChanged($data, 'sites', $data->getSites()->toArray())) { + $changed[] = 'sites'; + } + + return $changed; + } + + /** + * Vrai si une collection M2M (`categories` ou `sites`) differe reellement de + * l'etat persiste. Ces collections ne sont pas tracees par + * getOriginalEntityData : on compare par identifiants (independamment de + * l'ordre) le snapshot de la PersistentCollection (etat charge) a l'etat + * courant (apres application du payload). Symetrique des scalaires : seul un + * changement effectif compte, pas la simple presence dans le payload. + * + * - POST / entite non geree : fournir la collection est un acte metier + * (branche defensive, guardManage ne s'execute que sur entite geree). + * - cle absente du payload (PATCH partiel) : aucun changement. + * + * @param array $current + */ + private function collectionChanged(Provider $data, string $field, array $current): bool + { + if (!$this->em->contains($data)) { + return true; + } + + if (!in_array($field, $this->payloadKeys(), true)) { + return false; + } + + $collection = 'categories' === $field ? $data->getCategories() : $data->getSites(); + + // Hors PersistentCollection (cas limite hors flux PATCH reel) : faute d'etat + // persiste comparable, on se rabat sur la presence payload. + if (!$collection instanceof PersistentCollection) { + return true; + } + + return $this->idSet($current) !== $this->idSet($collection->getSnapshot()); + } + + /** + * Ensemble trie des identifiants d'une liste d'entites — pour une comparaison + * par valeur independante de l'ordre. + * + * @param array $entities + * + * @return list + */ + private function idSet(array $entities): array + { + $ids = array_map( + static fn (object $entity): mixed => method_exists($entity, 'getId') + ? $entity->getId() + : spl_object_id($entity), + array_values($entities), + ); + sort($ids); + + return $ids; + } + + /** + * Champs comptables dont la valeur courante differe de l'etat persiste. Les + * relations (tvaMode, paymentDelay, paymentType, bank) sont comparees par + * identite d'objet : l'identity map Doctrine renvoie la meme instance tant que + * la reference est inchangee. + * + * @return list + */ + private function changedAccountingFields(Provider $data): array + { + $changed = []; + + foreach (self::ACCOUNTING_FIELDS as $field) { + $newValue = match ($field) { + 'siren' => $data->getSiren(), + 'accountNumber' => $data->getAccountNumber(), + 'tvaMode' => $data->getTvaMode(), + 'nTva' => $data->getNTva(), + 'paymentDelay' => $data->getPaymentDelay(), + 'paymentType' => $data->getPaymentType(), + 'bank' => $data->getBank(), + }; + + if ($this->fieldChanged($data, $field, $newValue)) { + $changed[] = $field; + } + } + + return $changed; + } + + /** + * Vrai si la valeur courante d'un champ differe de l'etat persiste. Pour une + * entite non geree (creation/POST), l'etat persiste est vide : toute valeur + * non-null est alors un changement. + */ + private function fieldChanged(Provider $data, string $field, mixed $newValue): bool + { + $original = $this->originalData($data); + + return $newValue !== ($original[$field] ?? null); + } + + /** + * Snapshot des valeurs persistees de l'entite (telles que chargees, avant + * application du payload). Vide pour une entite non geree (POST). + * + * @return array + */ + private function originalData(Provider $data): array + { + if (!$this->em->contains($data)) { + return []; + } + + return $this->em->getUnitOfWork()->getOriginalEntityData($data); + } + + /** + * Normalisation serveur du formulaire principal (RG-3.11). Seul companyName est + * porte par le Provider (les champs de contact sont normalises par le processor + * de sous-ressource ProviderContact, ticket dedie). Le setter non-nullable n'est + * touche que si une valeur est presente, pour ne jamais ecraser l'existant lors + * d'un PATCH partiel. + */ + private function normalize(Provider $data): void + { + if (null !== $data->getCompanyName()) { + $data->setCompanyName((string) $this->normalizer->normalizeCompanyName($data->getCompanyName())); + } + } + + /** + * Cles ecrivables effectivement presentes dans le payload : on retire les cles + * JSON-LD (@id, @context...) et tout champ non rattache a un groupe d'ecriture + * connu. Base du 422 d'archivage (RG-3.13). + * + * @return list + */ + private function writablePayloadKeys(): array + { + $writable = array_merge( + self::MAIN_FIELDS, + self::ACCOUNTING_FIELDS, + [self::ARCHIVE_FIELD], + ); + + return array_values(array_intersect($this->payloadKeys(), $writable)); + } + + /** + * Cles de premier niveau effectivement envoyees par le client (payload JSON + * brut). Pour un PATCH merge-patch+json, ce sont les seuls champs modifies. + * + * @return list + */ + private function payloadKeys(): array + { + $request = $this->requestStack->getCurrentRequest(); + if (null === $request) { + return []; + } + + $content = $request->getContent(); + + // Cache hit : meme corps brut que le dernier decodage -> memes cles. + if ($content === $this->decodedContent) { + return $this->decodedPayloadKeys; + } + + $this->decodedContent = $content; + $this->decodedPayloadKeys = $this->extractPayloadKeys($content); + + return $this->decodedPayloadKeys; + } + + /** + * Decode le corps brut et en extrait les cles de premier niveau (chaines). + * Corps vide ou JSON invalide -> aucune cle. + * + * @return list + */ + private function extractPayloadKeys(string $content): array + { + 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(Provider $root): void + { + $violations = new ConstraintViolationList(); + $violations->add(new ConstraintViolation( + 'Vous ne pouvez rattacher que des sites auxquels vous avez accès.', + null, + [], + $root, + 'sites', + null, + )); + + throw new ValidationException($violations); + } +} diff --git a/src/Module/Technique/Infrastructure/ApiPlatform/State/Provider/ProviderProvider.php b/src/Module/Technique/Infrastructure/ApiPlatform/State/Provider/ProviderProvider.php new file mode 100644 index 0000000..5ed3645 --- /dev/null +++ b/src/Module/Technique/Infrastructure/ApiPlatform/State/Provider/ProviderProvider.php @@ -0,0 +1,256 @@ + (prestataires ayant >= 1 categorie + * de ce code, repetable) et ?siteId= (prestataires rattaches a ce site + * via la relation DIRECTE provider.sites, repetable) ; + * - pagination obligatoire (regle ABSOLUE n°13) : Paginator ORM ; echappatoire + * ?pagination=false pour alimenter un cote front). + if (!$this->pagination->isEnabled($operation, $context)) { + /** @var list $providers */ + $providers = $qb->getQuery()->getResult(); + // Hydratation batchee des collections affichees (§ 2.12) : evite le + // N+1 si la serialisation touche categories/sites, sans cartesien. + $this->repository->hydrateListCollections($providers); + + return $providers; + } + + $limit = $this->pagination->getLimit($operation, $context); + $page = max(1, $this->pagination->getPage($context)); + $offset = ($page - 1) * $limit; + + $qb->setFirstResult($offset)->setMaxResults($limit); + + // Le QB de selection ne porte pas de fetch-join to-many (§ 2.12) : le + // COUNT est simple, fetchJoinCollection inutile. On materialise la page + // puis on hydrate ses collections en lot (memes entites managees). + $paginator = new Paginator(new DoctrinePaginator($qb->getQuery(), fetchJoinCollection: false)); + $this->repository->hydrateListCollections(iterator_to_array($paginator)); + + return $paginator; + } + + /** + * @param array $uriVariables + */ + private function provideItem(array $uriVariables): ?Provider + { + $id = $uriVariables['id'] ?? null; + if (!is_int($id) && !(is_string($id) && ctype_digit($id))) { + return null; + } + + $provider = $this->repository->findById((int) $id); + if (null === $provider) { + return null; + } + + // Soft-delete : jamais expose au M3 (HP-M4) — 404 via retour null. + // Les archives restent visibles en detail (consultation + restauration). + if (null !== $provider->getDeletedAt()) { + return null; + } + + // 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 + // currentSite null. + $scopeSite = $this->siteScopeOrNull(); + if (null !== $scopeSite && !$this->providerHasSite($provider, (int) $scopeSite->getId())) { + return null; + } + + 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". + */ + private function readBool(mixed $raw): bool + { + if (is_bool($raw)) { + return $raw; + } + + return is_string($raw) && in_array(strtolower($raw), ['true', '1'], true); + } + + /** + * Normalise un filtre en liste de chaines. Tolere un code unique (string) + * ou une liste (?key[]=a&key[]=b). Trim + retrait des vides. + * + * @return list + */ + private function readStringList(mixed $raw): array + { + $values = is_array($raw) ? $raw : [$raw]; + + $out = []; + foreach ($values as $value) { + if (is_string($value) && '' !== trim($value)) { + $out[] = trim($value); + } + } + + return $out; + } + + /** + * Normalise un filtre en liste d'identifiants entiers positifs. Tolere une + * valeur unique ou une liste (?key[]=1&key[]=2). + * + * @return list + */ + private function readIntList(mixed $raw): array + { + $values = is_array($raw) ? $raw : [$raw]; + + $out = []; + foreach ($values as $value) { + if ((is_int($value) || (is_string($value) && ctype_digit($value))) && (int) $value > 0) { + $out[] = (int) $value; + } + } + + return $out; + } +} diff --git a/src/Module/Technique/Infrastructure/Doctrine/DoctrineProviderRepository.php b/src/Module/Technique/Infrastructure/Doctrine/DoctrineProviderRepository.php index ec63c4e..ac5be4a 100644 --- a/src/Module/Technique/Infrastructure/Doctrine/DoctrineProviderRepository.php +++ b/src/Module/Technique/Infrastructure/Doctrine/DoctrineProviderRepository.php @@ -89,6 +89,26 @@ class DoctrineProviderRepository extends ServiceEntityRepository implements Prov ; } + public function applySiteScope(QueryBuilder $qb, int $siteId): void + { + // Cloisonnement par site (RG-3.17, § 2.13) : ne garder que les prestataires + // dont provider.sites contient le site donne. Sous-requete IN (alias p5 + // distinct des filtres p2/p3/p4) pour ne pas perturber le tri/pagination du + // QueryBuilder principal — meme parti pris que applyCategoryCodes / applySiteIds. + // Parametre :scopeSiteId distinct de :siteIds (filtre ?siteId du client) pour + // que les deux clauses puissent coexister (intersection) sans collision. + $sub = $this->getEntityManager()->createQueryBuilder() + ->select('p5.id') + ->from(Provider::class, 'p5') + ->join('p5.sites', 'site5') + ->where('site5.id = :scopeSiteId') + ; + + $qb->andWhere($qb->expr()->in('p.id', $sub->getDQL())) + ->setParameter('scopeSiteId', $siteId) + ; + } + public function hydrateContacts(array $providers): void { $ids = $this->collectIds($providers); diff --git a/tests/Module/Technique/Api/AbstractProviderApiTestCase.php b/tests/Module/Technique/Api/AbstractProviderApiTestCase.php new file mode 100644 index 0000000..5e5d5e3 --- /dev/null +++ b/tests/Module/Technique/Api/AbstractProviderApiTestCase.php @@ -0,0 +1,287 @@ +getEm(); + + $em->createQuery('DELETE FROM '.Provider::class)->execute(); + $em->createQuery('DELETE FROM '.Category::class.' c WHERE c.name LIKE :prefix') + ->setParameter('prefix', self::TEST_CATEGORY_PREFIX.'%')->execute() + ; + $em->createQuery('DELETE FROM '.User::class.' u WHERE u.username LIKE :prefix') + ->setParameter('prefix', 'test_%')->execute() + ; + $em->createQuery('DELETE FROM '.Role::class.' r WHERE r.code LIKE :prefix') + ->setParameter('prefix', 'test_%')->execute() + ; + + parent::tearDown(); + } + + protected function createAdminClient(): Client + { + return $this->authenticatedClient('admin', 'admin'); + } + + /** + * Recupere (ou cree) le type PRESTATAIRE. Idempotent (unicite category_type.code). + */ + protected function providerCategoryType(): CategoryType + { + $em = $this->getEm(); + $existing = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'PRESTATAIRE']); + if (null !== $existing) { + return $existing; + } + + $type = new CategoryType(); + $type->setCode('PRESTATAIRE'); + $type->setLabel('Prestataire'); + $em->persist($type); + $em->flush(); + + return $type; + } + + /** + * Fetch-or-create une categorie de type PRESTATAIRE par code (defaut NETTOYAGE). + * Idempotent (lookup par code, aligne sur l'index unique partiel uq_category_code) + * et auto-suffisant. Nom prefixe -> purge par tearDown. + */ + protected function providerCategory(string $code = 'NETTOYAGE'): Category + { + $em = $this->getEm(); + $existing = $em->getRepository(Category::class)->findOneBy(['code' => $code, 'deletedAt' => null]); + if (null !== $existing) { + return $existing; + } + + $category = new Category(); + $category->setName(self::TEST_CATEGORY_PREFIX.strtolower($code)); + $category->setCode($code); + $category->addCategoryType($this->providerCategoryType()); + $em->persist($category); + $em->flush(); + + return $category; + } + + /** + * Cree une categorie d'un type DIFFERENT de PRESTATAIRE (pour tester le rejet + * RG-3.09). Code unique pour ne pas collisionner avec une categorie existante. + */ + protected function foreignCategory(): Category + { + $em = $this->getEm(); + $suffix = substr(bin2hex(random_bytes(4)), 0, 8); + + $type = $em->getRepository(CategoryType::class)->findOneBy(['code' => 'CLIENT']); + if (null === $type) { + $type = new CategoryType(); + $type->setCode('CLIENT'); + $type->setLabel('Client'); + $em->persist($type); + } + + $category = new Category(); + $category->setName(self::TEST_CATEGORY_PREFIX.'foreign_'.$suffix); + $category->setCode('FOREIGN_'.strtoupper($suffix)); + $category->addCategoryType($type); + $em->persist($category); + $em->flush(); + + return $category; + } + + /** + * Recupere un site fixture par code postal (cf. SitesFixtures). Echoue + * explicitement si absent (fixtures non chargees / module Sites off). + */ + protected function site(string $postalCode): Site + { + $site = $this->getEm()->getRepository(Site::class)->findOneBy(['postalCode' => $postalCode]); + + self::assertNotNull( + $site, + sprintf('Site fixture "%s" introuvable : SitesFixtures charge (make test-db-setup) ?', $postalCode), + ); + + return $site; + } + + /** + * Seede directement un Provider minimal (sans passer par l'API), pour les tests + * de liste / archivage / cloisonnement. Nom stocke en MAJUSCULES pour refleter + * l'etat normalise (RG-3.11) qu'aurait produit le ProviderProcessor. Porte une + * categorie PRESTATAIRE + les sites donnes (par code postal). + * + * @param list $sitePostalCodes codes postaux des sites a rattacher + */ + protected function seedProvider( + string $companyName, + array $sitePostalCodes = [self::SITE_86], + bool $isArchived = false, + string $categoryCode = 'NETTOYAGE', + ?string $siren = null, + ): Provider { + $em = $this->getEm(); + $provider = new Provider(); + $provider->setCompanyName(mb_strtoupper($companyName, 'UTF-8')); + $provider->addCategory($this->providerCategory($categoryCode)); + foreach ($sitePostalCodes as $postalCode) { + $provider->addSite($this->site($postalCode)); + } + if (null !== $siren) { + $provider->setSiren($siren); + } + $provider->setIsArchived($isArchived); + if ($isArchived) { + $provider->setArchivedAt(new DateTimeImmutable()); + } + $em->persist($provider); + $em->flush(); + + return $provider; + } + + /** + * Payload minimal valide du formulaire principal (companyName + 1 categorie + * PRESTATAIRE + sites donnes). Categorie NETTOYAGE par defaut. + * + * @param list $sitePostalCodes + * + * @return array + */ + protected function validMainPayload(string $companyName, array $sitePostalCodes = [self::SITE_86]): array + { + $siteIris = array_map(fn (string $pc): string => '/api/sites/'.$this->site($pc)->getId(), $sitePostalCodes); + + return [ + 'companyName' => $companyName, + 'categories' => ['/api/categories/'.$this->providerCategory()->getId()], + 'sites' => $siteIris, + ]; + } + + /** + * Cree un utilisateur non-admin CLOISONNE : porte les permissions donnees via + * un role jetable, rattache aux seuls sites donnes (par code postal), avec un + * currentSite positionne. N'a PAS `sites.bypass_scope` (sauf si fourni dans + * $permissionCodes) -> sujet ideal des tests de cloisonnement (RG-3.17). + * + * Contrairement a createUserWithPermissions() (parent, qui attache TOUS les + * sites et ne pose pas de currentSite), ce helper controle finement le + * perimetre site de l'user. + * + * @param list $permissionCodes + * @param list $sitePostalCodes sites a rattacher (user_site) + * + * @return array{username: string, password: string} + */ + protected function createScopedUser( + array $permissionCodes, + array $sitePostalCodes, + ?string $currentSitePostalCode = null, + ): array { + $em = $this->getEm(); + + $suffix = substr(bin2hex(random_bytes(4)), 0, 8); + $username = 'test_scoped_'.$suffix; + $password = 'testpass'; + + /** @var UserPasswordHasherInterface $hasher */ + $hasher = self::getContainer()->get(UserPasswordHasherInterface::class); + + $role = new Role('test_'.$suffix, 'Test Role '.$suffix, false); + foreach ($permissionCodes as $code) { + $permission = $em->getRepository(Permission::class)->findOneBy(['code' => $code]); + self::assertNotNull($permission, sprintf('Permission "%s" introuvable (app:sync-permissions ?).', $code)); + $role->addPermission($permission); + } + $em->persist($role); + + $user = new User(); + $user->setUsername($username); + $user->setIsAdmin(false); + $user->setPassword($hasher->hashPassword($user, $password)); + $user->addRbacRole($role); + + foreach ($sitePostalCodes as $postalCode) { + $user->addSite($this->site($postalCode)); + } + if (null !== $currentSitePostalCode) { + $user->setCurrentSite($this->site($currentSitePostalCode)); + } + + $em->persist($user); + $em->flush(); + $em->clear(); + + return ['username' => $username, 'password' => $password]; + } + + /** + * Indexe les violations d'un corps 422 par propertyPath (assert ciblee). + * + * @param array $body corps decode (toArray(false)) + * + * @return array propertyPath => message + */ + protected function violationsByPath(array $body): array + { + $byPath = []; + foreach ($body['violations'] ?? [] as $v) { + $byPath[$v['propertyPath']] = $v['message']; + } + + return $byPath; + } +} diff --git a/tests/Module/Technique/Api/ProviderApiTest.php b/tests/Module/Technique/Api/ProviderApiTest.php new file mode 100644 index 0000000..82c8a14 --- /dev/null +++ b/tests/Module/Technique/Api/ProviderApiTest.php @@ -0,0 +1,115 @@ +createAdminClient(); + + $response = $client->request('POST', '/api/providers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Maintenance Pro', [self::SITE_86]), + ]); + + self::assertSame(201, $response->getStatusCode()); + $body = $response->toArray(); + // RG-3.11 : companyName normalise en MAJUSCULES. + self::assertSame('MAINTENANCE PRO', $body['companyName']); + self::assertArrayHasKey('id', $body); + // sites embarque (relation directe, site:read) avec name/postalCode. + self::assertCount(1, $body['sites']); + self::assertSame('86100', $body['sites'][0]['postalCode']); + } + + public function testPostWithoutSiteIsRejected(): void + { + $client = $this->createAdminClient(); + + $payload = $this->validMainPayload('Sans Site', [self::SITE_86]); + $payload['sites'] = []; + + $response = $client->request('POST', '/api/providers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $payload, + ]); + + // RG-3.03 : au moins un site obligatoire. + self::assertSame(422, $response->getStatusCode()); + self::assertArrayHasKey('sites', $this->violationsByPath($response->toArray(false))); + } + + public function testPostWithoutCategoryIsRejected(): void + { + $client = $this->createAdminClient(); + + $payload = $this->validMainPayload('Sans Categorie', [self::SITE_86]); + $payload['categories'] = []; + + $response = $client->request('POST', '/api/providers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $payload, + ]); + + // RG-3.09 : au moins une categorie obligatoire. + self::assertSame(422, $response->getStatusCode()); + self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false))); + } + + public function testPostWithForeignCategoryTypeIsRejected(): void + { + $client = $this->createAdminClient(); + $foreign = $this->foreignCategory(); + + $payload = $this->validMainPayload('Mauvais Type', [self::SITE_86]); + $payload['categories'] = ['/api/categories/'.$foreign->getId()]; + + $response = $client->request('POST', '/api/providers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $payload, + ]); + + // RG-3.09 : categorie hors type PRESTATAIRE -> 422 sur `categories`. + self::assertSame(422, $response->getStatusCode()); + self::assertArrayHasKey('categories', $this->violationsByPath($response->toArray(false))); + } + + public function testDuplicateCompanyNameReturns409(): void + { + $this->seedProvider('Doublon Sarl', [self::SITE_86]); + $client = $this->createAdminClient(); + + $response = $client->request('POST', '/api/providers', [ + 'headers' => ['Content-Type' => self::LD], + // Casse differente : l'unicite est insensible a la casse (LOWER). + 'json' => $this->validMainPayload('doublon sarl', [self::SITE_86]), + ]); + + // RG-3.10 : doublon de nom (case-insensitive) -> 409. + self::assertSame(409, $response->getStatusCode()); + } + + public function testSameNameAfterArchiveIsAllowed(): void + { + // Index partiel : l'unicite ignore les archives -> reutilisation du nom OK. + $this->seedProvider('Recyclage Express', [self::SITE_86], isArchived: true); + $client = $this->createAdminClient(); + + $response = $client->request('POST', '/api/providers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Recyclage Express', [self::SITE_86]), + ]); + + self::assertSame(201, $response->getStatusCode()); + } +} diff --git a/tests/Module/Technique/Api/ProviderListTest.php b/tests/Module/Technique/Api/ProviderListTest.php new file mode 100644 index 0000000..367d253 --- /dev/null +++ b/tests/Module/Technique/Api/ProviderListTest.php @@ -0,0 +1,83 @@ + pas de cloisonnement). + * + * @internal + */ +final class ProviderListTest extends AbstractProviderApiTestCase +{ + public function testListReturnsHydraEnvelopeSortedByName(): void + { + $this->seedProvider('Zeta Services', [self::SITE_86]); + $this->seedProvider('Alpha Nettoyage', [self::SITE_86]); + $this->seedProvider('Mu Maintenance', [self::SITE_86]); + + $client = $this->createAdminClient(); + $response = $client->request('GET', '/api/providers', [ + 'headers' => ['Accept' => self::LD], + ]); + + self::assertSame(200, $response->getStatusCode()); + $body = $response->toArray(); + + // Envelope Hydra : totalItems present + member. + self::assertSame(3, $body['totalItems']); + $names = array_column($body['member'], 'companyName'); + // Tri companyName ASC (RG-3.16) — noms normalises en MAJUSCULES. + self::assertSame(['ALPHA NETTOYAGE', 'MU MAINTENANCE', 'ZETA SERVICES'], $names); + } + + public function testListExcludesArchivedByDefault(): void + { + $this->seedProvider('Actif Sas', [self::SITE_86]); + $this->seedProvider('Archive Sarl', [self::SITE_86], isArchived: true); + + $client = $this->createAdminClient(); + $response = $client->request('GET', '/api/providers', [ + 'headers' => ['Accept' => self::LD], + ]); + + self::assertSame(200, $response->getStatusCode()); + $body = $response->toArray(); + self::assertSame(1, $body['totalItems']); + self::assertSame('ACTIF SAS', $body['member'][0]['companyName']); + } + + public function testListIncludeArchivedReintegratesArchived(): void + { + $this->seedProvider('Actif Sas', [self::SITE_86]); + $this->seedProvider('Archive Sarl', [self::SITE_86], isArchived: true); + + $client = $this->createAdminClient(); + $response = $client->request('GET', '/api/providers?includeArchived=true', [ + 'headers' => ['Accept' => self::LD], + ]); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame(2, $response->toArray()['totalItems']); + } + + public function testListFiltersBySiteIdViaDirectRelation(): void + { + $this->seedProvider('Site 86 Only', [self::SITE_86]); + $this->seedProvider('Site 17 Only', [self::SITE_17]); + + $client = $this->createAdminClient(); + $site17 = $this->site(self::SITE_17); + $response = $client->request('GET', '/api/providers?siteId='.$site17->getId(), [ + 'headers' => ['Accept' => self::LD], + ]); + + self::assertSame(200, $response->getStatusCode()); + $body = $response->toArray(); + self::assertSame(1, $body['totalItems']); + self::assertSame('SITE 17 ONLY', $body['member'][0]['companyName']); + } +} diff --git a/tests/Module/Technique/Api/ProviderRbacGatingTest.php b/tests/Module/Technique/Api/ProviderRbacGatingTest.php new file mode 100644 index 0000000..72ca13b --- /dev/null +++ b/tests/Module/Technique/Api/ProviderRbacGatingTest.php @@ -0,0 +1,159 @@ + CurrentSiteProvider::get() = null -> aucun + * cloisonnement, on isole ainsi le comportement RBAC du comportement site. + * + * @internal + */ +final class ProviderRbacGatingTest extends AbstractProviderApiTestCase +{ + public function testAccountingFieldsOmittedWithoutAccountingView(): void + { + $provider = $this->seedProvider('Compta Masquee', [self::SITE_86], siren: '123456789'); + $id = $provider->getId(); + + // Profil type Commerciale : view + manage SANS accounting.view. + $creds = $this->createUserWithPermissions(['technique.providers.view']); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + $response = $client->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]]); + self::assertSame(200, $response->getStatusCode()); + + $body = $response->toArray(); + // Gating par omission : scalaires comptables ET ribs totalement absents. + self::assertArrayNotHasKey('siren', $body); + self::assertArrayNotHasKey('ribs', $body); + // isArchived reste expose (bug #3 M1 : la cle ne doit pas etre droppee). + self::assertArrayHasKey('isArchived', $body); + } + + public function testAccountingFieldsPresentWithAccountingView(): void + { + $provider = $this->seedProvider('Compta Visible', [self::SITE_86], siren: '987654321'); + $id = $provider->getId(); + + $creds = $this->createUserWithPermissions([ + 'technique.providers.view', + 'technique.providers.accounting.view', + ]); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + $response = $client->request('GET', '/api/providers/'.$id, ['headers' => ['Accept' => self::LD]]); + self::assertSame(200, $response->getStatusCode()); + + $body = $response->toArray(); + self::assertSame('987654321', $body['siren']); + // La cle ribs apparait (collection vide ici, mais presente). + self::assertArrayHasKey('ribs', $body); + } + + public function testStrictModeRejectsMixedGroupsForManageOnlyUser(): void + { + $provider = $this->seedProvider('Strict Cible', [self::SITE_86]); + $id = $provider->getId(); + + // Profil type Bureau : manage SANS accounting.manage. + $creds = $this->createUserWithPermissions([ + 'technique.providers.view', + 'technique.providers.manage', + ]); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + $response = $client->request('PATCH', '/api/providers/'.$id, [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['companyName' => 'Renomme', 'siren' => '111222333'], + ]); + + // RG-3.15 : payload melangeant main + accounting sans accounting.manage + // -> 403 sur tout le payload (mode strict, pas de filtrage silencieux). + self::assertSame(403, $response->getStatusCode()); + + // Aucun champ n'a ete persiste (rollback du mode strict). + $this->getEm()->clear(); + $reloaded = $this->getEm()->getRepository(Provider::class)->find($id); + self::assertSame('STRICT CIBLE', $reloaded->getCompanyName()); + self::assertNull($reloaded->getSiren()); + } + + public function testAccountingOnlyUserCanPatchAccountingButNotMain(): void + { + $provider = $this->seedProvider('Compta Editrice', [self::SITE_86]); + $id = $provider->getId(); + + // Profil type Compta : accounting.view + accounting.manage SANS manage. + $creds = $this->createUserWithPermissions([ + 'technique.providers.view', + 'technique.providers.accounting.view', + 'technique.providers.accounting.manage', + ]); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + // PATCH accounting -> 200. + $ok = $client->request('PATCH', '/api/providers/'.$id, [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['siren' => '555666777'], + ]); + self::assertSame(200, $ok->getStatusCode()); + + // PATCH main (companyName) -> 403 (pas de permission manage). + $ko = $client->request('PATCH', '/api/providers/'.$id, [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['companyName' => 'Interdit'], + ]); + self::assertSame(403, $ko->getStatusCode()); + } + + public function testArchiveRequiresArchivePermission(): void + { + $provider = $this->seedProvider('A Archiver', [self::SITE_86]); + $id = $provider->getId(); + + // Bureau (manage) sans archive -> 403. + $creds = $this->createUserWithPermissions([ + 'technique.providers.view', + 'technique.providers.manage', + ]); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + $response = $client->request('PATCH', '/api/providers/'.$id, [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => true], + ]); + + // RG-3.13 : l'archivage exige technique.providers.archive. + self::assertSame(403, $response->getStatusCode()); + } + + public function testAdminCanArchiveAndSetsArchivedAt(): void + { + $provider = $this->seedProvider('Archivable', [self::SITE_86]); + $id = $provider->getId(); + + $client = $this->createAdminClient(); + $response = $client->request('PATCH', '/api/providers/'.$id, [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['isArchived' => true], + ]); + + self::assertSame(200, $response->getStatusCode()); + + $this->getEm()->clear(); + $reloaded = $this->getEm()->getRepository(Provider::class)->find($id); + self::assertTrue($reloaded->isArchived()); + self::assertNotNull($reloaded->getArchivedAt()); + } +} diff --git a/tests/Module/Technique/Api/ProviderSiteScopeTest.php b/tests/Module/Technique/Api/ProviderSiteScopeTest.php new file mode 100644 index 0000000..21259e9 --- /dev/null +++ b/tests/Module/Technique/Api/ProviderSiteScopeTest.php @@ -0,0 +1,171 @@ + 422). + * + * Cloisonnement pilote par l'USER (pas le role) : on cree des users non-admin SANS + * `sites.bypass_scope`, rattaches a un site precis avec un currentSite. L'admin + * (isAdmin -> bypass total) sert de temoin « voit tout ». + * + * @internal + */ +final class ProviderSiteScopeTest extends AbstractProviderApiTestCase +{ + protected function setUp(): void + { + parent::setUp(); + // Pre-requis : le module Sites doit etre actif (sinon currentSite = null, + // cloisonnement no-op et ces tests perdent leur sens). + $this->skipIfSitesModuleDisabled(); + } + + public function testListIsScopedToCurrentSiteForNonBypassUser(): void + { + $this->seedProvider('Presta Site 86', [self::SITE_86]); + $this->seedProvider('Presta Site 17', [self::SITE_17]); + $this->seedProvider('Presta Site 82', [self::SITE_82]); + + $creds = $this->createScopedUser( + ['technique.providers.view'], + sitePostalCodes: [self::SITE_86], + currentSitePostalCode: self::SITE_86, + ); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + $response = $client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]); + self::assertSame(200, $response->getStatusCode()); + + $body = $response->toArray(); + // totalItems reflete le PERIMETRE de l'user (filtre avant pagination). + self::assertSame(1, $body['totalItems']); + self::assertSame('PRESTA SITE 86', $body['member'][0]['companyName']); + } + + public function testDetailOutOfScopeReturns404(): void + { + $inScope = $this->seedProvider('Dans Perimetre', [self::SITE_86]); + $outOfScope = $this->seedProvider('Hors Perimetre', [self::SITE_17]); + + $creds = $this->createScopedUser( + ['technique.providers.view'], + sitePostalCodes: [self::SITE_86], + currentSitePostalCode: self::SITE_86, + ); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + // In-scope -> 200. + $ok = $client->request('GET', '/api/providers/'.$inScope->getId(), ['headers' => ['Accept' => self::LD]]); + self::assertSame(200, $ok->getStatusCode()); + + // Out-of-scope -> 404 (ne pas reveler l'existence hors perimetre). + $ko = $client->request('GET', '/api/providers/'.$outOfScope->getId(), ['headers' => ['Accept' => self::LD]]); + self::assertSame(404, $ko->getStatusCode()); + } + + public function testBypassUserSeesAllSites(): void + { + $this->seedProvider('Presta Site 86', [self::SITE_86]); + $this->seedProvider('Presta Site 17', [self::SITE_17]); + $this->seedProvider('Presta Site 82', [self::SITE_82]); + + // Admin = bypass total. + $client = $this->createAdminClient(); + $response = $client->request('GET', '/api/providers', ['headers' => ['Accept' => self::LD]]); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame(3, $response->toArray()['totalItems']); + } + + public function testWriteOutOfScopeSiteRejectedAtIriResolution(): void + { + // User non-bypass / non-read_ref : la resolution de l'IRI du site hors + // perimetre echoue en amont (SiteCollectionScopedExtension : item Site + // « introuvable ») -> 400 anti-enumeration, avant le ProviderProcessor. + $creds = $this->createScopedUser( + ['technique.providers.view', 'technique.providers.manage'], + sitePostalCodes: [self::SITE_86], + currentSitePostalCode: self::SITE_86, + ); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + $response = $client->request('POST', '/api/providers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Hors Scope Sas', [self::SITE_17]), + ]); + + self::assertSame(400, $response->getStatusCode()); + } + + public function testWriteOutOfScopeSiteRejectedByProcessorGuard(): void + { + // User `sites.read_ref` : peut RESOUDRE n'importe quel site (referentiel + // transverse) mais n'opere que sur ses user_site. La garde guardSiteScope + // du ProviderProcessor est alors l'enforcement autoritaire de RG-3.17 + // -> 422 sur `sites` (mappable inline, ERP-101). + $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', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Hors Scope Guard', [self::SITE_17]), + ]); + + self::assertSame(422, $response->getStatusCode()); + self::assertArrayHasKey('sites', $this->violationsByPath($response->toArray(false))); + } + + public function testWriteAllowsSiteWithinUserScope(): void + { + $creds = $this->createScopedUser( + ['technique.providers.view', 'technique.providers.manage'], + sitePostalCodes: [self::SITE_86], + currentSitePostalCode: self::SITE_86, + ); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + // Site 86 = un des user_site -> 201. + $response = $client->request('POST', '/api/providers', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validMainPayload('Dans Scope Sas', [self::SITE_86]), + ]); + + self::assertSame(201, $response->getStatusCode()); + } + + public function testPatchAddingOutOfScopeSiteIsRejected(): void + { + $provider = $this->seedProvider('Patch Sites', [self::SITE_86]); + $id = $provider->getId(); + + // read_ref pour pouvoir resoudre l'IRI du site 17 (sinon 400 en amont) et + // exercer la garde guardSiteScope sur le PATCH. + $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']); + + $site86 = $this->site(self::SITE_86)->getId(); + $site17 = $this->site(self::SITE_17)->getId(); + + $response = $client->request('PATCH', '/api/providers/'.$id, [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['sites' => ['/api/sites/'.$site86, '/api/sites/'.$site17]], + ]); + + // RG-3.17 : ajouter un site hors user_site -> 422 (garde Processor). + self::assertSame(422, $response->getStatusCode()); + self::assertArrayHasKey('sites', $this->violationsByPath($response->toArray(false))); + } +}