feat(technique) : ProviderProvider + ProviderProcessor + cloisonnement site (ERP-134)
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).
This commit is contained in:
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Technique\Application\Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalisation serveur des champs texte d'un Provider / ProviderContact,
|
||||||
|
* appliquee par le ProviderProcessor (et les processors de sous-ressources,
|
||||||
|
* ticket ulterieur M3) AVANT persistance. Cf. spec-back M3 § 2.11 + RG-3.11.
|
||||||
|
* Jumeau de SupplierFieldNormalizer (M2) — duplique volontairement (isolation
|
||||||
|
* Commercial / Technique, decision § 2.1).
|
||||||
|
*
|
||||||
|
* - companyName : UPPERCASE integral (RG-3.11)
|
||||||
|
* - firstName / lastName (personnes, sur ProviderContact) : Title Case (RG-3.11)
|
||||||
|
* - phone* : chiffres uniquement, ex "06.12.34.56.78" -> "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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,8 @@ use App\Module\Commercial\Domain\Entity\Bank;
|
|||||||
use App\Module\Commercial\Domain\Entity\PaymentDelay;
|
use App\Module\Commercial\Domain\Entity\PaymentDelay;
|
||||||
use App\Module\Commercial\Domain\Entity\PaymentType;
|
use App\Module\Commercial\Domain\Entity\PaymentType;
|
||||||
use App\Module\Commercial\Domain\Entity\TvaMode;
|
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\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository;
|
||||||
use App\Shared\Domain\Attribute\Auditable;
|
use App\Shared\Domain\Attribute\Auditable;
|
||||||
use App\Shared\Domain\Contract\BlamableInterface;
|
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.
|
* reference de donnees de reference, pas de logique inter-module.
|
||||||
*
|
*
|
||||||
* Contrat de serialisation (RETEX M1, 3 maillons — spec § 4.0) : les read-groups
|
* 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
|
* sont poses ICI (source unique). L'#[ApiResource] cable (ERP-134) le ProviderProvider
|
||||||
* + contextes + security) ; le ProviderProvider (liste paginee anti-N+1, exclusion
|
* (liste paginee anti-N+1, exclusion archives, cloisonnement site lecture + detail
|
||||||
* archives, cloisonnement site, gating accounting) et le ProviderProcessor
|
* 404) et le ProviderProcessor (normalisation, archivage, mode strict par groupe,
|
||||||
* (normalisation, archivage, 409 doublon, RG-3.07 / RG-3.08) sont cables au ticket
|
* cloisonnement site ecriture, 409 doublon). Le groupe provider:read:accounting est
|
||||||
* suivant (ERP-134) — ils ne sont volontairement PAS references ici.
|
* 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) +
|
* Audite (#[Auditable], tous champs — y compris RIB embarques, § 2.7) +
|
||||||
* Timestampable / Blamable via le trait Shared.
|
* 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
|
// La liste embarque les categories (code/name, groupe category:read) et
|
||||||
// les sites du prestataire (name/postalCode, groupe site:read — relation
|
// les sites du prestataire (name/postalCode, groupe site:read — relation
|
||||||
// DIRECTE provider.sites, RG-3.03). Maillon (c) : category:read +
|
// DIRECTE provider.sites, RG-3.03). Maillon (c) : category:read +
|
||||||
// site:read presents dans le contexte. L'hydratation anti-N+1 sera
|
// site:read presents dans le contexte. Hydratation anti-N+1 cablee par
|
||||||
// cablee par le ProviderProvider (ERP-134, cf. DoctrineProviderRepository).
|
// le ProviderProvider (cf. DoctrineProviderRepository::hydrateListCollections).
|
||||||
normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']],
|
normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']],
|
||||||
|
provider: ProviderProvider::class,
|
||||||
),
|
),
|
||||||
new Get(
|
new Get(
|
||||||
security: "is_granted('technique.providers.view')",
|
security: "is_granted('technique.providers.view')",
|
||||||
// Detail : prestataire + sous-collections embarquees (contacts, adresses
|
// Detail : prestataire + sous-collections embarquees (contacts, adresses
|
||||||
// + leurs sites/categories/contacts) + RIB (gates compta). Le groupe
|
// + leurs sites/categories/contacts) + RIB (gates compta). Le groupe
|
||||||
// provider:read:accounting est volontairement ABSENT : il sera ajoute au
|
// provider:read:accounting est volontairement ABSENT : il est ajoute au
|
||||||
// contexte par le ProviderProvider / ReadGroupContextBuilder selon la
|
// contexte par le ProviderReadGroupContextBuilder selon la permission
|
||||||
// permission accounting.view (ERP-134, parade fuite IBAN/BIC — bug #4 M1).
|
// accounting.view (parade fuite IBAN/BIC — bug #4 M1).
|
||||||
normalizationContext: ['groups' => [
|
normalizationContext: ['groups' => [
|
||||||
'provider:read',
|
'provider:read',
|
||||||
'provider:item:read',
|
'provider:item:read',
|
||||||
@@ -87,11 +91,13 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
|||||||
'site:read',
|
'site:read',
|
||||||
'default:read',
|
'default:read',
|
||||||
]],
|
]],
|
||||||
|
provider: ProviderProvider::class,
|
||||||
),
|
),
|
||||||
new Post(
|
new Post(
|
||||||
security: "is_granted('technique.providers.manage')",
|
security: "is_granted('technique.providers.manage')",
|
||||||
normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']],
|
normalizationContext: ['groups' => ['provider:read', 'category:read', 'site:read', 'default:read']],
|
||||||
denormalizationContext: ['groups' => ['provider:write:main']],
|
denormalizationContext: ['groups' => ['provider:write:main']],
|
||||||
|
processor: ProviderProcessor::class,
|
||||||
),
|
),
|
||||||
new Patch(
|
new Patch(
|
||||||
// Security elargie : `manage` OU `accounting.manage` — le role Compta n'a
|
// 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:accounting',
|
||||||
'provider:write:archive',
|
'provider:write:archive',
|
||||||
]],
|
]],
|
||||||
|
provider: ProviderProvider::class,
|
||||||
|
processor: ProviderProcessor::class,
|
||||||
),
|
),
|
||||||
// Pas de Delete au M3 (HP M4). Archivage via PATCH { isArchived: true }.
|
// Pas de Delete au M3 (HP M4). Archivage via PATCH { isArchived: true }.
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -13,6 +13,21 @@ interface ProviderRepositoryInterface
|
|||||||
|
|
||||||
public function save(Provider $provider): void;
|
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.
|
* Construit un QueryBuilder de liste pour le repertoire prestataires.
|
||||||
* - Exclut toujours les prestataires soft-deletes (deleted_at IS NOT NULL, RG-3.16).
|
* - Exclut toujours les prestataires soft-deletes (deleted_at IS NOT NULL, RG-3.16).
|
||||||
|
|||||||
+76
@@ -0,0 +1,76 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Technique\Infrastructure\ApiPlatform\Serializer;
|
||||||
|
|
||||||
|
use ApiPlatform\State\SerializerContextBuilderInterface;
|
||||||
|
use App\Module\Technique\Domain\Entity\Provider;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\AutowireDecorated;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decore le context builder de serialisation d'API Platform pour ajouter
|
||||||
|
* DYNAMIQUEMENT le groupe de lecture `provider:read:accounting` sur les
|
||||||
|
* ressources Provider, uniquement si l'utilisateur courant a la permission
|
||||||
|
* `technique.providers.accounting.view` (cf. spec-back M3 § 2.9 / § 4.1 /
|
||||||
|
* § 4.2). Jumeau de SupplierReadGroupContextBuilder (M2).
|
||||||
|
*
|
||||||
|
* Pourquoi un context builder et pas le Provider : un Provider retourne des
|
||||||
|
* donnees mais ne peut pas influencer les groupes de serialisation. Le contexte
|
||||||
|
* de normalisation est construit ici, en amont du serializer — c'est le point
|
||||||
|
* d'extension idiomatique d'API Platform pour conditionner un groupe selon
|
||||||
|
* l'utilisateur. Realise l'intention « gating du groupe accounting » de la spec
|
||||||
|
* (le groupe n'est jamais pose par defaut sur l'operation : il est AJOUTE ici si
|
||||||
|
* la permission est presente — resultat identique au « retrait » decrit en spec).
|
||||||
|
*
|
||||||
|
* S'applique aux operations de LECTURE (normalization) sur Provider : liste ET
|
||||||
|
* detail. Sans la permission, les champs comptables (siren, accountNumber,
|
||||||
|
* tvaMode, nTva, paymentDelay, paymentType, bank) ET les RIB embarques (groupe
|
||||||
|
* provider:read:accounting porte par getRibs()) ne sont jamais serialises — la
|
||||||
|
* cle est totalement absente du JSON (gating par omission, parade bug #4 M1).
|
||||||
|
*
|
||||||
|
* Priorite de decoration -20 : on s'empile APRES les decorateurs Commercial
|
||||||
|
* (ClientReadGroupContextBuilder priorite 0, SupplierReadGroupContextBuilder
|
||||||
|
* priorite -10) sur le meme service `api_platform.serializer.context_builder`.
|
||||||
|
* Chaque decorateur passe la main pour toute ressource autre que la sienne :
|
||||||
|
* l'ordre de chainage n'a donc aucun effet fonctionnel, la priorite explicite ne
|
||||||
|
* sert qu'a lever l'ambiguite de plusieurs decorateurs sur un meme service.
|
||||||
|
*/
|
||||||
|
#[AsDecorator(decorates: 'api_platform.serializer.context_builder', priority: -20)]
|
||||||
|
final readonly class ProviderReadGroupContextBuilder implements SerializerContextBuilderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[AutowireDecorated]
|
||||||
|
private SerializerContextBuilderInterface $decorated,
|
||||||
|
private Security $security,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function createFromRequest(Request $request, bool $normalization, ?array $extractedAttributes = null): array
|
||||||
|
{
|
||||||
|
$context = $this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+559
@@ -0,0 +1,559 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Processor;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProcessorInterface;
|
||||||
|
use ApiPlatform\Validator\Exception\ValidationException;
|
||||||
|
use App\Module\Core\Domain\Entity\User;
|
||||||
|
use App\Module\Technique\Application\Service\ProviderFieldNormalizer;
|
||||||
|
use App\Module\Technique\Domain\Entity\Provider;
|
||||||
|
use App\Shared\Domain\Contract\SiteInterface;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Doctrine\ORM\PersistentCollection;
|
||||||
|
use JsonException;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
use Symfony\Component\HttpFoundation\RequestStack;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\ConflictHttpException;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\UnprocessableEntityHttpException;
|
||||||
|
use Symfony\Component\Validator\ConstraintViolation;
|
||||||
|
use Symfony\Component\Validator\ConstraintViolationList;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processor d'ecriture du repertoire prestataires (M3). Cf. spec-back M3 § 4.3 /
|
||||||
|
* § 4.4 + RG-3.10 / RG-3.13 / RG-3.14 / RG-3.15 / RG-3.17. Jumeau du
|
||||||
|
* SupplierProcessor (M2), avec deux differences structurantes (§ 3.1) :
|
||||||
|
* - PAS d'onglet Information (aucun champ description / competitors / ...) ni de
|
||||||
|
* validation de completude comptable -> 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<Provider, Provider>
|
||||||
|
*/
|
||||||
|
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<string> 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<string> $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<int>
|
||||||
|
*/
|
||||||
|
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<string>
|
||||||
|
*/
|
||||||
|
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<int, object> $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<int, object> $entities
|
||||||
|
*
|
||||||
|
* @return list<mixed>
|
||||||
|
*/
|
||||||
|
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<string>
|
||||||
|
*/
|
||||||
|
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<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string>
|
||||||
|
*/
|
||||||
|
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<string>
|
||||||
|
*/
|
||||||
|
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<string>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Technique\Infrastructure\ApiPlatform\State\Provider;
|
||||||
|
|
||||||
|
use ApiPlatform\Doctrine\Orm\Paginator;
|
||||||
|
use ApiPlatform\Metadata\CollectionOperationInterface;
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\Pagination\Pagination;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
|
||||||
|
use App\Module\Technique\Domain\Entity\Provider;
|
||||||
|
use App\Module\Technique\Domain\Repository\ProviderRepositoryInterface;
|
||||||
|
use App\Shared\Domain\Contract\SiteInterface;
|
||||||
|
use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator;
|
||||||
|
use Symfony\Bundle\SecurityBundle\Security;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider du repertoire prestataires (M3). Cf. spec-back M3 § 4.1 / § 4.2 +
|
||||||
|
* RG-3.16 / RG-3.17. Jumeau du SupplierProvider (M2), augmente du cloisonnement
|
||||||
|
* par site pilote par l'utilisateur (§ 2.13).
|
||||||
|
*
|
||||||
|
* Collection (GET /api/providers) :
|
||||||
|
* - exclut par defaut les archives (is_archived = true) ET les soft-deletes
|
||||||
|
* (deleted_at IS NOT NULL) — RG-3.16 ;
|
||||||
|
* - ?includeArchived=true reintegre les archives (les soft-deletes restent
|
||||||
|
* exclus au M3) — RG-3.16 ;
|
||||||
|
* - tri par defaut companyName ASC — RG-3.16 ;
|
||||||
|
* - filtres ?search=... (fuzzy companyName + contacts lies : firstName /
|
||||||
|
* lastName / email), ?categoryCode=<code> (prestataires ayant >= 1 categorie
|
||||||
|
* de ce code, repetable) et ?siteId=<id> (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 <select> sans pagination.
|
||||||
|
*
|
||||||
|
* Cloisonnement par site (RG-3.17, § 2.13) — applique ICI (le QueryBuilder du
|
||||||
|
* repository ne connait pas l'user courant) :
|
||||||
|
* - si l'user N'A PAS `sites.bypass_scope` ET que CurrentSiteProvider::get()
|
||||||
|
* retourne un site -> la liste est restreinte aux prestataires dont
|
||||||
|
* provider.sites contient le currentSite (repository::applySiteScope), AVANT
|
||||||
|
* pagination : totalItems reflete le perimetre de l'user ;
|
||||||
|
* - le DETAIL (Get / provider de PATCH) d'un prestataire hors perimetre renvoie
|
||||||
|
* 404 (null) — ne pas reveler l'existence d'une ligne hors site ;
|
||||||
|
* - user `bypass_scope` (Admin auto, profils consolidation) -> aucun filtre ;
|
||||||
|
* - currentSite = null (module Sites off / user sans site) -> no-op lecture
|
||||||
|
* (aligne site-aware.md § 5).
|
||||||
|
*
|
||||||
|
* Item (GET /api/providers/{id} + provider de PATCH) :
|
||||||
|
* - 404 si introuvable OU soft-delete (deleted_at non null, jamais expose au
|
||||||
|
* M3) ; les archives restent consultables/restaurables en detail ;
|
||||||
|
* - 404 si hors perimetre site (cloisonnement, cf. ci-dessus).
|
||||||
|
*
|
||||||
|
* Le filtrage des champs comptables en lecture (groupe provider:read:accounting)
|
||||||
|
* n'est PAS fait ici mais dans ProviderReadGroupContextBuilder : un Provider
|
||||||
|
* retourne des donnees mais ne peut pas influencer les groupes de serialisation.
|
||||||
|
*
|
||||||
|
* @implements ProviderInterface<Provider>
|
||||||
|
*/
|
||||||
|
final class ProviderProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(service: 'App\Module\Technique\Infrastructure\Doctrine\DoctrineProviderRepository')]
|
||||||
|
private readonly ProviderRepositoryInterface $repository,
|
||||||
|
private readonly Pagination $pagination,
|
||||||
|
private readonly Security $security,
|
||||||
|
// Outillage site-aware sanctionne (site-aware.md § 6.2 : « injecter
|
||||||
|
// CurrentSiteProvider dans le service et ajouter la clause WHERE
|
||||||
|
// manuellement » pour les cas multi-site non couverts par
|
||||||
|
// SiteScopedQueryExtension). Type-hint sur l'interface pour le mock test.
|
||||||
|
private readonly CurrentSiteProviderInterface $currentSiteProvider,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Provider|null
|
||||||
|
{
|
||||||
|
if ($operation instanceof CollectionOperationInterface) {
|
||||||
|
return $this->provideCollection($operation, $context);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->provideItem($uriVariables);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $context
|
||||||
|
*
|
||||||
|
* @return list<Provider>|Paginator<Provider>
|
||||||
|
*/
|
||||||
|
private function provideCollection(Operation $operation, array $context): array|Paginator
|
||||||
|
{
|
||||||
|
$filters = $context['filters'] ?? [];
|
||||||
|
$includeArchived = $this->readBool($filters['includeArchived'] ?? false);
|
||||||
|
$archivedOnly = $this->readBool($filters['archivedOnly'] ?? false);
|
||||||
|
$search = $filters['search'] ?? null;
|
||||||
|
// categoryCode accepte un code unique (?categoryCode=NETTOYAGE, selects)
|
||||||
|
// OU une liste (?categoryCode[]=A&categoryCode[]=B, drawer multi).
|
||||||
|
$categoryCodes = $this->readStringList($filters['categoryCode'] ?? []);
|
||||||
|
$siteIds = $this->readIntList($filters['siteId'] ?? []);
|
||||||
|
|
||||||
|
// Filtrage delegue au repository (logique partagee avec l'export XLSX).
|
||||||
|
$qb = $this->repository->createListQueryBuilder(
|
||||||
|
$includeArchived,
|
||||||
|
is_string($search) ? $search : null,
|
||||||
|
$categoryCodes,
|
||||||
|
$siteIds,
|
||||||
|
$archivedOnly,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cloisonnement par site (RG-3.17) AVANT pagination : ajoute une clause
|
||||||
|
// restreignant au currentSite pour un user non-bypass. S'intersecte avec
|
||||||
|
// un eventuel filtre ?siteId du client (deux sous-requetes ANDees).
|
||||||
|
$scopeSite = $this->siteScopeOrNull();
|
||||||
|
if (null !== $scopeSite) {
|
||||||
|
$this->repository->applySiteScope($qb, (int) $scopeSite->getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Echappatoire ?pagination=false : collection complete sans Paginator
|
||||||
|
// (regle n°13 — utile pour un <select> cote front).
|
||||||
|
if (!$this->pagination->isEnabled($operation, $context)) {
|
||||||
|
/** @var list<Provider> $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<string, mixed> $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<string>
|
||||||
|
*/
|
||||||
|
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<int>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
public function hydrateContacts(array $providers): void
|
||||||
{
|
{
|
||||||
$ids = $this->collectIds($providers);
|
$ids = $this->collectIds($providers);
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
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\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\Tests\Module\Core\Api\AbstractApiTestCase;
|
||||||
|
use DateTimeImmutable;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base des tests fonctionnels du repertoire prestataires (M3 — module Technique).
|
||||||
|
* Jumelle de la base fournisseurs (M2), recentree sur le perimetre ERP-134
|
||||||
|
* (Provider + Processor + cloisonnement site).
|
||||||
|
*
|
||||||
|
* Donnees (RETEX M1/M2 — pas de fixtures globales pour les tests) : chaque test
|
||||||
|
* seede ses prestataires en base via les helpers ci-dessous, puis le tearDown les
|
||||||
|
* purge. Les 3 sites (Chatellerault 86 / Saint-Jean 17 / Pommevic 82) sont seedes
|
||||||
|
* par SitesFixtures (make test-db-setup) ; on les recupere par code postal.
|
||||||
|
*
|
||||||
|
* Categories : `providerCategory('NETTOYAGE')` fetch-or-create une categorie de
|
||||||
|
* type PRESTATAIRE (requis par RG-3.09). Pour fabriquer une categorie d'un AUTRE
|
||||||
|
* type (test de rejet RG-3.09), utiliser `foreignCategory()`.
|
||||||
|
*
|
||||||
|
* Cleanup : tearDown purge prestataires AVANT categories/users (provider_category
|
||||||
|
* et provider_site sont ON DELETE CASCADE cote provider — le DELETE DQL sur
|
||||||
|
* Provider libere categories et sites pour les purges suivantes).
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
abstract class AbstractProviderApiTestCase extends AbstractApiTestCase
|
||||||
|
{
|
||||||
|
protected const string LD = 'application/ld+json';
|
||||||
|
protected const string MERGE = 'application/merge-patch+json';
|
||||||
|
|
||||||
|
protected const string TEST_CATEGORY_PREFIX = 'test_prov_cat_';
|
||||||
|
|
||||||
|
/** Codes postaux des 3 sites fixtures (cf. SitesFixtures). */
|
||||||
|
protected const string SITE_86 = '86100'; // Chatellerault
|
||||||
|
protected const string SITE_17 = '17400'; // Saint-Jean
|
||||||
|
protected const string SITE_82 = '82400'; // Pommevic
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
$em = $this->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<string> $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<string> $sitePostalCodes
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
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<string> $permissionCodes
|
||||||
|
* @param list<string> $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<string, mixed> $body corps decode (toArray(false))
|
||||||
|
*
|
||||||
|
* @return array<string, string> propertyPath => message
|
||||||
|
*/
|
||||||
|
protected function violationsByPath(array $body): array
|
||||||
|
{
|
||||||
|
$byPath = [];
|
||||||
|
foreach ($body['violations'] ?? [] as $v) {
|
||||||
|
$byPath[$v['propertyPath']] = $v['message'];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $byPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Technique\Api;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests fonctionnels du formulaire principal prestataire (POST + PATCH) — ERP-134.
|
||||||
|
* Couvre : creation (RG-3.03 sites obligatoires, RG-3.09 type categorie),
|
||||||
|
* normalisation companyName (RG-3.11), 409 doublon (RG-3.10).
|
||||||
|
*
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
final class ProviderApiTest extends AbstractProviderApiTestCase
|
||||||
|
{
|
||||||
|
public function testPostMainCreatesProvider(): void
|
||||||
|
{
|
||||||
|
$client = $this->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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Technique\Api;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests de la liste paginee /api/providers (ProviderProvider) — ERP-134.
|
||||||
|
* Couvre : envelope Hydra, tri companyName ASC, exclusion des archives,
|
||||||
|
* ?includeArchived (RG-3.16). Joue en admin (bypass_scope -> 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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Technique\Api;
|
||||||
|
|
||||||
|
use App\Module\Technique\Domain\Entity\Provider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests du gating comptabilite + mode strict par groupe (ProviderProcessor /
|
||||||
|
* ProviderReadGroupContextBuilder) — ERP-134.
|
||||||
|
*
|
||||||
|
* Couvre : gating accounting PAR OMISSION (siren/ribs absents sans accounting.view,
|
||||||
|
* bug #4 M1), mode strict RG-3.15 (403 sur tout le payload), gating archive (RG-3.13).
|
||||||
|
*
|
||||||
|
* Les users sont crees via createUserWithPermissions() (parent) : rattaches a TOUS
|
||||||
|
* les sites SANS currentSite -> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Module\Technique\Api;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests du cloisonnement par site pilote par l'utilisateur (RG-3.17 / § 2.13) —
|
||||||
|
* ERP-134. Couvre la LECTURE (liste filtree avant pagination + totalItems, detail
|
||||||
|
* 404 hors perimetre, bypass voit tout) et l'ECRITURE (sites hors user_site -> 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)));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user