From c9c6d043a74e9419a9eec0228958b217ebdf6eb7 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 29 Jun 2026 15:24:08 +0200 Subject: [PATCH 01/11] =?UTF-8?q?feat(catalog)=20:=20M7=20=E2=80=94=20perm?= =?UTF-8?q?issions=20catalog.storages.*=20+=20sidebar=20+=203=20miroirs=20?= =?UTF-8?q?RBAC=20(ERP-210)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/sidebar.php | 10 ++++++++++ frontend/i18n/locales/fr.json | 3 ++- frontend/tests/e2e/_fixtures/personas.ts | 14 ++++++++++---- src/Module/Catalog/CatalogModule.php | 5 +++++ .../Core/Infrastructure/Console/SeedE2ECommand.php | 4 ++++ 5 files changed, 31 insertions(+), 5 deletions(-) diff --git a/config/sidebar.php b/config/sidebar.php index 7fd5bea..82130db 100644 --- a/config/sidebar.php +++ b/config/sidebar.php @@ -172,6 +172,16 @@ return [ 'module' => 'catalog', 'permission' => 'catalog.categories.view', ], + // Stockage (M7, ERP-210). Admin-only : gate par `catalog.storages.view` + // et son module owner `catalog`. Reutilise le referentiel StorageType + // du M6. Place pres des autres items Catalog (produits, categories). + [ + 'label' => 'sidebar.catalog.storages', + 'to' => '/admin/storages', + 'icon' => 'mdi:warehouse', + 'module' => 'catalog', + 'permission' => 'catalog.storages.view', + ], [ 'label' => 'sidebar.core.audit_log', 'to' => '/admin/audit-log', diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index d17f6f8..189cb00 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -53,7 +53,8 @@ }, "catalog": { "categories": "Gestion des catégories", - "products": "Catalogue produits" + "products": "Catalogue produits", + "storages": "Gestion des stockages" } }, "dashboard": { diff --git a/frontend/tests/e2e/_fixtures/personas.ts b/frontend/tests/e2e/_fixtures/personas.ts index 8417805..6299968 100644 --- a/frontend/tests/e2e/_fixtures/personas.ts +++ b/frontend/tests/e2e/_fixtures/personas.ts @@ -35,7 +35,7 @@ export interface Persona { // sidebar-visibility pour driver la matrice. Les valeurs correspondent // aux slugs de route (`/admin/`), volontairement stables quand // la copie/i18n change. - expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log' | 'categories' | 'products'> + expectedAdminLinks: Array<'users' | 'roles' | 'sites' | 'audit-log' | 'categories' | 'products' | 'storages'> } const SHARED_PASSWORD = 'e2e-secret' @@ -47,7 +47,7 @@ export const personas: Record = { password: SHARED_PASSWORD, isAdmin: true, permissions: [], - expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'products', 'audit-log'], + expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'products', 'storages', 'audit-log'], }, 'user-full': { key: 'user-full', @@ -71,6 +71,12 @@ export const personas: Record = { // `/admin/products` -> ajoute le lien `products` a expectedAdminLinks. 'catalog.products.view', 'catalog.products.manage', + // Stockage (M7, ERP-210). Admin-only : mappe sur le persona "tout", + // pas de nouveau persona (regle ABSOLUE n°7). L'item vit dans la + // section Administration sur la route `/admin/storages` -> ajoute le + // lien `storages` a expectedAdminLinks. + 'catalog.storages.view', + 'catalog.storages.manage', // Commercial — Repertoire clients (M1). Mappe ici sur le persona // "tout" en attendant les vrais roles metier (bureau/compta/ // commerciale/usine) seedes par ERP-74. Pas de nouveau persona @@ -121,7 +127,7 @@ export const personas: Record = { 'logistique.weighing_tickets.view', 'logistique.weighing_tickets.manage', ], - expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'products', 'audit-log'], + expectedAdminLinks: ['users', 'roles', 'sites', 'categories', 'products', 'storages', 'audit-log'], }, 'user-readonly': { key: 'user-readonly', @@ -166,4 +172,4 @@ export function getPersona(key: PersonaKey): Persona { return personas[key] } -export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'categories', 'products', 'audit-log'] as const +export const ALL_ADMIN_LINKS = ['users', 'roles', 'sites', 'categories', 'products', 'storages', 'audit-log'] as const diff --git a/src/Module/Catalog/CatalogModule.php b/src/Module/Catalog/CatalogModule.php index c5154f9..c2ed730 100644 --- a/src/Module/Catalog/CatalogModule.php +++ b/src/Module/Catalog/CatalogModule.php @@ -47,6 +47,11 @@ final class CatalogModule // Item sidebar dans la section Administration, sous « Repertoire transporteurs ». ['code' => 'catalog.products.view', 'label' => 'Voir les produits'], ['code' => 'catalog.products.manage', 'label' => 'Gérer les produits (créer, éditer)'], + // Stockage (M7, ERP-210) : admin-only. Reutilise le referentiel + // StorageType du M6. Item sidebar dans la section Administration, + // pres des items Catalog (produits, categories). + ['code' => 'catalog.storages.view', 'label' => 'Voir les stockages'], + ['code' => 'catalog.storages.manage', 'label' => 'Gérer les stockages (créer, éditer)'], ]; } } diff --git a/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php b/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php index 5c7ba71..553230c 100644 --- a/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php +++ b/src/Module/Core/Infrastructure/Console/SeedE2ECommand.php @@ -190,6 +190,10 @@ final class SeedE2ECommand extends Command // p.3) : mappe sur le persona "tout". Miroir de personas.ts. 'catalog.products.view', 'catalog.products.manage', + // Stockage (M7, ERP-210). Admin-only : mappe sur le persona + // "tout". Miroir de personas.ts. + 'catalog.storages.view', + 'catalog.storages.manage', // Commercial — Repertoire clients (M1). Mappe ici sur le // persona "tout" en attendant les vrais roles metier // (bureau/compta/commerciale/usine) seedes par ERP-74. From ca9dbe583a11aad9de6ae6e194c7e34e581008d2 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 29 Jun 2026 15:46:12 +0200 Subject: [PATCH 02/11] =?UTF-8?q?feat(catalog)=20:=20M7=20=E2=80=94=20migr?= =?UTF-8?q?ation=20table=20storage=20(FK=20site/storage=5Ftype,=20unicit?= =?UTF-8?q?=C3=A9=20m=C3=A9tier=20RG-7.01,=20=C3=A9tats=20JSONB=20RG-7.04)?= =?UTF-8?q?=20(ERP-211)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- migrations/Version20260629120000.php | 136 +++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 migrations/Version20260629120000.php diff --git a/migrations/Version20260629120000.php b/migrations/Version20260629120000.php new file mode 100644 index 0000000..c69fa23 --- /dev/null +++ b/migrations/Version20260629120000.php @@ -0,0 +1,136 @@ += 1). Comme pour product.states (M6), PAS de + * DEFAULT '[]'::jsonb : un tableau vide violerait ce CHECK ; la colonne est + * toujours renseignee par l'app (Processor/ORM). + * + * Namespace racine `DoctrineMigrations` (regle ABSOLUE n°11) et NON modulaire : la + * table storage porte des FK cross-module (site, storage_type, user). Le tri par + * timestamp au sein du namespace racine garantit l'ordre apres la creation de ces + * tables sur base vide ; un namespace modulaire trierait par FQCN alphabetique et + * casserait `make db-reset` (cf. Version20260625110000 pour le M6). + * + * Convention IDs (spec § 2.2) : `INT GENERATED BY DEFAULT AS IDENTITY`, + * horodatages `TIMESTAMP(0) WITHOUT TIME ZONE` (le TimestampableBlamableTrait mappe + * `datetime_immutable`). Chaque colonne porte son `COMMENT ON COLUMN` (regle n°12). + * + * NB schema:update (test-db-setup) : `storage` sera mappee en ORM au ticket suivant + * (entite Storage). D'ici la, `schema:update --force` la drope sur la base de TEST + * uniquement (sans impact : aucun test ne la reference encore, et dev/prod ne lancent + * jamais schema:update). Sa description sera ajoutee a ColumnCommentsCatalog au ticket + * entite (comme product / weighing_ticket). + */ +final class Version20260629120000 extends AbstractMigration +{ + public function getDescription(): string + { + return 'ERP-211 (M7) : creation de la table storage (FK site + storage_type, unicite metier partielle RG-7.01, etats JSONB RG-7.04, soft-delete + Timestampable/Blamable).'; + } + + public function up(Schema $schema): void + { + $this->addSql(<<<'SQL' + CREATE TABLE storage ( + id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + site_id INT NOT NULL, + storage_type_id INT NOT NULL, + numero VARCHAR(50) NOT NULL, + -- Pas de DEFAULT : un tableau vide violerait chk_storage_states_not_empty + -- (RG-7.04). La colonne est toujours renseignee par l'app (Processor/ORM). + states JSONB NOT NULL, + deleted_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, + created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + updated_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, + created_by INT DEFAULT NULL, + updated_by INT DEFAULT NULL, + PRIMARY KEY (id), + CONSTRAINT chk_storage_states_not_empty + CHECK (jsonb_array_length(states) >= 1), + CONSTRAINT fk_storage_site + FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE RESTRICT, + CONSTRAINT fk_storage_storage_type + FOREIGN KEY (storage_type_id) REFERENCES storage_type (id) ON DELETE RESTRICT, + CONSTRAINT fk_storage_created_by + FOREIGN KEY (created_by) REFERENCES "user" (id) ON DELETE SET NULL, + CONSTRAINT fk_storage_updated_by + FOREIGN KEY (updated_by) REFERENCES "user" (id) ON DELETE SET NULL + ) + SQL); + + // RG-7.01 : unicite (site, type, numero) parmi les stockages actifs uniquement + // (index partiel) — un numero redevient disponible apres soft-delete. + $this->addSql('CREATE UNIQUE INDEX uq_storage_site_type_numero_active ON storage (site_id, storage_type_id, numero) WHERE deleted_at IS NULL'); + $this->addSql('CREATE INDEX idx_storage_site ON storage (site_id)'); + $this->addSql('CREATE INDEX idx_storage_storage_type ON storage (storage_type_id)'); + $this->addSql('CREATE INDEX idx_storage_deleted_at ON storage (deleted_at)'); + $this->addSql('CREATE INDEX idx_storage_created_by ON storage (created_by)'); + $this->addSql('CREATE INDEX idx_storage_updated_by ON storage (updated_by)'); + + $this->comment('storage', '_table', 'Emplacements de stockage (M7 Catalog) — un stockage = 1 site + 1 type (storage_type) + 1 numero, etats multi-valeur JSONB, soft-delete + Timestampable/Blamable.'); + $this->comment('storage', 'id', 'Identifiant interne auto-incremente.'); + $this->comment('storage', 'site_id', 'Site du stockage. FK -> site.id, ON DELETE RESTRICT. Composante de l unicite metier (RG-7.01).'); + $this->comment('storage', 'storage_type_id', 'Type de stockage (referentiel M6). FK -> storage_type.id, ON DELETE RESTRICT. Composante de l unicite metier (RG-7.01).'); + $this->comment('storage', 'numero', 'Numero du stockage (≤ 50), saisi. Unique par (site, type) parmi les actifs (RG-7.01, uq_storage_site_type_numero_active). Normalise serveur.'); + $this->comment('storage', 'states', 'Etats du stockage (JSON) : tableau non vide (>= 1 element, RG-7.04, chk_storage_states_not_empty). Multi-valeur.'); + $this->comment('storage', 'deleted_at', 'Horodatage du soft-delete technique — null = ligne active. Une ligne supprimee sort de l unicite metier (index partiel uq_storage_site_type_numero_active).'); + $this->addTimestampableBlamableComments('storage'); + } + + public function down(Schema $schema): void + { + $this->addSql('DROP TABLE storage'); + } + + /** + * Pose les 4 commentaires standardises Timestampable/Blamable sur une table, + * en reutilisant le catalogue partage (source unique, ERP-67). + */ + private function addTimestampableBlamableComments(string $table): void + { + foreach (ColumnCommentsCatalog::timestampableBlamableComments() as $column => $description) { + $this->comment($table, $column, $description); + } + } + + /** + * Emet un `COMMENT ON TABLE` (colonne speciale `_table`) ou `COMMENT ON COLUMN` + * en dollar-quoting Postgres ($_$...$_$) pour eviter tout echappement d apostrophe. + */ + private function comment(string $table, string $column, string $description): void + { + $quotedTable = '"'.str_replace('"', '""', $table).'"'; + + if ('_table' === $column) { + $this->addSql(sprintf('COMMENT ON TABLE %s IS $_$%s$_$', $quotedTable, $description)); + + return; + } + + $this->addSql(sprintf( + 'COMMENT ON COLUMN %s.%s IS $_$%s$_$', + $quotedTable, + '"'.str_replace('"', '""', $column).'"', + $description, + )); + } +} From 8c4c34c1a34196d1f64a883009671f686b5e5e1a Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 29 Jun 2026 16:27:02 +0200 Subject: [PATCH 03/11] =?UTF-8?q?feat(catalog)=20:=20M7=20=E2=80=94=20enti?= =?UTF-8?q?t=C3=A9=20Storage=20+=20repository=20+=20contrat=20de=20s=C3=A9?= =?UTF-8?q?rialisation=20(ERP-212)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/i18n/locales/fr.json | 1 + makefile | 1 + src/Module/Catalog/Domain/Entity/Storage.php | 264 ++++++++++++++++++ .../Repository/StorageRepositoryInterface.php | 14 + .../Doctrine/DoctrineStorageRepository.php | 32 +++ .../Database/ColumnCommentsCatalog.php | 14 + 6 files changed, 326 insertions(+) create mode 100644 src/Module/Catalog/Domain/Entity/Storage.php create mode 100644 src/Module/Catalog/Domain/Repository/StorageRepositoryInterface.php create mode 100644 src/Module/Catalog/Infrastructure/Doctrine/DoctrineStorageRepository.php diff --git a/frontend/i18n/locales/fr.json b/frontend/i18n/locales/fr.json index 189cb00..d2f4458 100644 --- a/frontend/i18n/locales/fr.json +++ b/frontend/i18n/locales/fr.json @@ -820,6 +820,7 @@ "sites_site": "Site", "catalog_category": "Catégorie", "catalog_product": "Produit", + "catalog_storage": "Stockage", "commercial_client": "Client", "commercial_clientaddress": "Adresse client", "commercial_clientcontact": "Contact client", diff --git a/makefile b/makefile index 67d070e..a06fe9e 100644 --- a/makefile +++ b/makefile @@ -234,6 +234,7 @@ test-db-setup: $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_provider_company_name_active ON provider (LOWER(company_name)) WHERE is_archived = FALSE AND deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_carrier_name_active ON carrier (LOWER(name)) WHERE is_archived = FALSE AND deleted_at IS NULL" $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_product_code_active ON product (code) WHERE deleted_at IS NULL" + $(SYMFONY_CONSOLE) --env=test dbal:run-sql "CREATE UNIQUE INDEX IF NOT EXISTS uq_storage_site_type_numero_active ON storage (site_id, storage_type_id, numero) WHERE deleted_at IS NULL" fixtures: $(SYMFONY_CONSOLE) --no-interaction doctrine:fixtures:load diff --git a/src/Module/Catalog/Domain/Entity/Storage.php b/src/Module/Catalog/Domain/Entity/Storage.php new file mode 100644 index 0000000..5eafb19 --- /dev/null +++ b/src/Module/Catalog/Domain/Entity/Storage.php @@ -0,0 +1,264 @@ + embed autorise, ne viole pas la regle n°13), createdAt/updatedAt + * (via default:read). L'ecriture passe par storage:write (site, storageType, + * numero, states). + * + * Soft-delete prepare via `deletedAt` (non expose, § 2.8) : pas de Delete dans les + * operations ; la liste exclut les stockages supprimes (Provider, ERP-213). Un + * numero redevient disponible apres soft-delete (index partiel sur les actifs). + * + * NB : `Site` appartient au module Sites, consomme en relation ORM partagee (§ 2.1) + * — on reutilise son read-group `site:read`, sans logique inter-module. `StorageType` + * est dans le meme module Catalog. + * + * @see StorageProvider Lecture (liste paginee filtree soft-delete + item) — ERP-213. + * @see StorageProcessor Ecriture (normalisation, unicite metier RG-7.01) — ERP-213. + */ +#[ApiResource( + operations: [ + new GetCollection( + security: "is_granted('catalog.storages.view')", + normalizationContext: ['groups' => ['storage:read', 'site:read', 'storage_type:read', 'default:read']], + provider: StorageProvider::class, + ), + new Get( + security: "is_granted('catalog.storages.view')", + normalizationContext: ['groups' => ['storage:read', 'site:read', 'storage_type:read', 'default:read']], + provider: StorageProvider::class, + ), + new Post( + security: "is_granted('catalog.storages.manage')", + normalizationContext: ['groups' => ['storage:read', 'site:read', 'storage_type:read', 'default:read']], + denormalizationContext: ['groups' => ['storage:write']], + // Convertit les erreurs de denormalisation (type invalide / null sur une + // relation : site, storageType) en violations 422 portant un propertyPath, + // au lieu d'un 400 qui court-circuite la validation (cf. Product — mapping + // inline useFormErrors, ERP-101). + collectDenormalizationErrors: true, + processor: StorageProcessor::class, + ), + new Patch( + security: "is_granted('catalog.storages.manage')", + normalizationContext: ['groups' => ['storage:read', 'site:read', 'storage_type:read', 'default:read']], + denormalizationContext: ['groups' => ['storage:write']], + collectDenormalizationErrors: true, + provider: StorageProvider::class, + processor: StorageProcessor::class, + ), + // Pas de Delete au M7 (§ 2.8) ; soft-delete prepare non expose. + ], +)] +#[ORM\Entity(repositoryClass: DoctrineStorageRepository::class)] +#[ORM\Table(name: 'storage')] +// Index nommes pour matcher la migration (cf. Product). L'index unique partiel +// `uq_storage_site_type_numero_active` ((site, type, numero) WHERE deleted_at IS +// NULL — unicite metier parmi les actifs, RG-7.01) reste possede par la seule +// migration : Doctrine ORM ne sait pas exprimer un index partiel via attribut. +#[ORM\Index(name: 'idx_storage_site', columns: ['site_id'])] +#[ORM\Index(name: 'idx_storage_storage_type', columns: ['storage_type_id'])] +#[ORM\Index(name: 'idx_storage_deleted_at', columns: ['deleted_at'])] +#[ORM\Index(name: 'idx_storage_created_by', columns: ['created_by'])] +#[ORM\Index(name: 'idx_storage_updated_by', columns: ['updated_by'])] +#[Auditable] +class Storage implements TimestampableInterface, BlamableInterface +{ + // === Timestampable + Blamable === + // Les 4 colonnes (created_at, updated_at, created_by, updated_by) + leurs + // getters/setters viennent du Trait Shared, remplies automatiquement par le + // TimestampableBlamableSubscriber au prePersist / preUpdate. + use TimestampableBlamableTrait; + + /** Etats du stockage (RG-7.04) — valeurs autorisees de la colonne JSONB `states`. */ + public const string STATE_RECEPTION = 'RECEPTION'; + public const string STATE_PRODUCTION = 'PRODUCTION'; + public const string STATE_TRIAGE = 'TRIAGE'; + + #[ORM\Id] + #[ORM\GeneratedValue] + #[ORM\Column] + #[Groups(['storage:read'])] + private ?int $id = null; + + // Site du stockage (obligatoire). FK ON DELETE RESTRICT : un site reference par + // un stockage ne peut etre supprime. Composante de l'unicite metier (RG-7.01). + #[ORM\ManyToOne(targetEntity: Site::class)] + #[ORM\JoinColumn(name: 'site_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')] + #[Assert\NotNull(message: 'Le site est obligatoire.')] + #[Groups(['storage:read', 'storage:write'])] + private ?Site $site = null; + + // Type de stockage (obligatoire, referentiel plat M6). FK ON DELETE RESTRICT : + // un type reference par un stockage ne peut etre supprime. Composante de + // l'unicite metier (RG-7.01). + #[ORM\ManyToOne(targetEntity: StorageType::class)] + #[ORM\JoinColumn(name: 'storage_type_id', referencedColumnName: 'id', nullable: false, onDelete: 'RESTRICT')] + #[Assert\NotNull(message: 'Le type de stockage est obligatoire.')] + #[Groups(['storage:read', 'storage:write'])] + private ?StorageType $storageType = null; + + // Numero du stockage, saisi. Unique par (site, type) parmi les actifs (RG-7.01). + // Normalise serveur (trim) par le StorageProcessor (ERP-213). + #[ORM\Column(length: 50)] + #[Assert\NotBlank(message: 'Le numéro du stockage est obligatoire.', normalizer: 'trim')] + #[Assert\Length(max: 50, maxMessage: 'Le numéro du stockage ne peut pas dépasser {{ limit }} caractères.', normalizer: 'trim')] + #[Groups(['storage:read', 'storage:write'])] + private ?string $numero = null; + + /** + * Etats du stockage (multi-select), sous-ensemble non vide de + * {RECEPTION, PRODUCTION, TRIAGE} (RG-7.04). Stocke en JSONB (tableau de + * chaines), non-vacuite garantie aussi par le CHECK chk_storage_states_not_empty. + * + * Validation des valeurs via Assert\Choice(multiple: true) plutot que + * Assert\All([Choice]) : equivalent fonctionnel, et seul Choice est gere par le + * garde-fou EntityConstraintsHaveFrenchMessageTest (cf. Product::states). + * + * @var list + */ + // jsonb (pas json) : aligne le mapping ORM sur la colonne JSONB creee par la + // migration (CHECK chk_storage_states_not_empty via jsonb_array_length). Sans + // `options: ['jsonb' => true]`, schema:update tente un ALTER states TYPE JSON + // qui casse le CHECK et fait echouer make test-db-setup (cf. Product::states). + #[ORM\Column(type: 'json', options: ['jsonb' => true])] + #[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un état.')] + #[Assert\Choice( + choices: [self::STATE_RECEPTION, self::STATE_PRODUCTION, self::STATE_TRIAGE], + multiple: true, + message: 'État de stockage invalide.', + multipleMessage: 'État de stockage invalide.', + )] + #[Groups(['storage:read', 'storage:write'])] + private array $states = []; + + /** + * Soft-delete technique : null = actif, valeur = supprime logiquement le {date}. + * Non expose (§ 2.8, aucun groupe) : prepare pour une future suppression. La + * liste exclut par defaut les stockages supprimes (Provider, ERP-213) et le + * numero redevient disponible (index partiel sur les actifs, RG-7.01). + */ + #[ORM\Column(name: 'deleted_at', type: 'datetime_immutable', nullable: true)] + private ?DateTimeImmutable $deletedAt = null; + + public function getId(): ?int + { + return $this->id; + } + + public function getSite(): ?Site + { + return $this->site; + } + + public function setSite(?Site $site): static + { + $this->site = $site; + + return $this; + } + + public function getStorageType(): ?StorageType + { + return $this->storageType; + } + + public function setStorageType(?StorageType $storageType): static + { + $this->storageType = $storageType; + + return $this; + } + + public function getNumero(): ?string + { + return $this->numero; + } + + public function setNumero(string $numero): static + { + $this->numero = $numero; + + return $this; + } + + /** + * @return list + */ + public function getStates(): array + { + return $this->states; + } + + /** + * @param list $states + */ + public function setStates(array $states): static + { + $this->states = $states; + + return $this; + } + + public function getDeletedAt(): ?DateTimeImmutable + { + return $this->deletedAt; + } + + public function setDeletedAt(?DateTimeImmutable $deletedAt): static + { + $this->deletedAt = $deletedAt; + + return $this; + } + + /** + * RG-7.05 : libelle d'affichage = libelle du type de stockage suivi du numero + * (ex. « Cellule 12 »). Getter virtuel non persiste, expose en lecture + * (storage:read). Null-safe : `storageType` et `numero` sont garantis non nuls a + * la lecture (NOT NULL en base), le `?? ''` couvre un objet en cours de + * construction sans casser la serialisation. + */ + #[Groups(['storage:read'])] + public function getDisplayName(): string + { + $label = $this->storageType?->getLabel() ?? ''; + + return trim($label.' '.($this->numero ?? '')); + } +} diff --git a/src/Module/Catalog/Domain/Repository/StorageRepositoryInterface.php b/src/Module/Catalog/Domain/Repository/StorageRepositoryInterface.php new file mode 100644 index 0000000..3edcf9b --- /dev/null +++ b/src/Module/Catalog/Domain/Repository/StorageRepositoryInterface.php @@ -0,0 +1,14 @@ + + */ +class DoctrineStorageRepository extends ServiceEntityRepository implements StorageRepositoryInterface +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Storage::class); + } + + public function findById(int $id): ?Storage + { + return $this->find($id); + } + + public function save(Storage $storage): void + { + $this->getEntityManager()->persist($storage); + $this->getEntityManager()->flush(); + } +} diff --git a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php index 9cc42c5..7e6ebfc 100644 --- a/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php +++ b/src/Shared/Infrastructure/Database/ColumnCommentsCatalog.php @@ -610,6 +610,20 @@ final class ColumnCommentsCatalog 'product_id' => 'FK -> product.id, ON DELETE CASCADE — produit concerne.', 'storage_type_id' => 'FK -> storage_type.id, ON DELETE RESTRICT — type de stockage rattache au produit.', ], + + // M7 Catalog (ERP-212) — table desormais mappee par l'entite Storage : + // schema:update (test) la recree sans COMMENT -> app:apply-column-comments + // les rejoue depuis ce catalogue. Strings identiques aux COMMENT de la + // migration Version20260629120000 (ERP-211). + 'storage' => [ + '_table' => 'Emplacements de stockage (M7 Catalog) — un stockage = 1 site + 1 type (storage_type) + 1 numero, etats multi-valeur JSONB, soft-delete + Timestampable/Blamable.', + 'id' => 'Identifiant interne auto-incremente.', + 'site_id' => 'Site du stockage. FK -> site.id, ON DELETE RESTRICT. Composante de l unicite metier (RG-7.01).', + 'storage_type_id' => 'Type de stockage (referentiel M6). FK -> storage_type.id, ON DELETE RESTRICT. Composante de l unicite metier (RG-7.01).', + 'numero' => 'Numero du stockage (≤ 50), saisi. Unique par (site, type) parmi les actifs (RG-7.01, uq_storage_site_type_numero_active). Normalise serveur.', + 'states' => 'Etats du stockage (JSON) : tableau non vide (>= 1 element, RG-7.04, chk_storage_states_not_empty). Multi-valeur.', + 'deleted_at' => 'Horodatage du soft-delete technique — null = ligne active. Une ligne supprimee sort de l unicite metier (index partiel uq_storage_site_type_numero_active).', + ] + self::timestampableBlamableComments(), ]; } From 0aa97b5975a3cbd019445579bacc8f0973df257f Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 29 Jun 2026 16:43:19 +0200 Subject: [PATCH 04/11] =?UTF-8?q?feat(catalog)=20:=20M7=20=E2=80=94=20Stor?= =?UTF-8?q?ageProvider=20+=20StorageProcessor=20(liste=20pagin=C3=A9e=20+?= =?UTF-8?q?=20filtres,=20409=20unicit=C3=A9=20RG-7.01,=20normalisation=20n?= =?UTF-8?q?um=C3=A9ro)=20(ERP-213)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Service/StorageFieldNormalizer.php | 35 +++ .../Repository/StorageRepositoryInterface.php | 34 +++ .../State/Processor/StorageProcessor.php | 112 ++++++++++ .../State/Provider/StorageProvider.php | 172 +++++++++++++++ .../Doctrine/DoctrineStorageRepository.php | 107 +++++++++ .../Api/AbstractStorageApiTestCase.php | 202 +++++++++++++++++ tests/Module/Catalog/Api/StorageApiTest.php | 204 ++++++++++++++++++ 7 files changed, 866 insertions(+) create mode 100644 src/Module/Catalog/Application/Service/StorageFieldNormalizer.php create mode 100644 src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/StorageProcessor.php create mode 100644 src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/StorageProvider.php create mode 100644 tests/Module/Catalog/Api/AbstractStorageApiTestCase.php create mode 100644 tests/Module/Catalog/Api/StorageApiTest.php diff --git a/src/Module/Catalog/Application/Service/StorageFieldNormalizer.php b/src/Module/Catalog/Application/Service/StorageFieldNormalizer.php new file mode 100644 index 0000000..492e1f7 --- /dev/null +++ b/src/Module/Catalog/Application/Service/StorageFieldNormalizer.php @@ -0,0 +1,35 @@ + $siteIds + */ + public function createListQueryBuilder( + bool $includeDeleted = false, + ?string $search = null, + array $siteIds = [], + ?int $storageTypeId = null, + ?string $state = null, + ): QueryBuilder; } diff --git a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/StorageProcessor.php b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/StorageProcessor.php new file mode 100644 index 0000000..4558ab4 --- /dev/null +++ b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Processor/StorageProcessor.php @@ -0,0 +1,112 @@ + 409 ; + * l'index partiel uq_storage_site_type_numero_active reste le filet anti-race + * au flush. + * 3. Persistance via le persist_processor Doctrine ORM. + * + * RG-7.03 (« le type doit etre disponible sur le site choisi ») n'est PAS portee : + * le concept type<->site a ete retire du modele en M6 (StorageType rendu plat, + * jointure storage_type_site droppee — migration Version20260626100000). C'est + * desormais l'entite Storage (1 site + 1 type) qui materialise cette disponibilite ; + * il n'existe plus de referentiel a interroger. A reclarifier cote spec (signale). + * + * Mode strict PATCH (RETEX M1) : la security d'operation exige `catalog.storages.manage` + * pour TOUS les champs ecrivables (un seul niveau de permission au M7 — admin-only). + * Aucun champ « hors-permission » a re-gater finement ici : le 403 global est porte + * par la security d'operation. + * + * @implements ProcessorInterface + */ +final class StorageProcessor implements ProcessorInterface +{ + public function __construct( + #[Autowire(service: 'api_platform.doctrine.orm.state.persist_processor')] + private readonly ProcessorInterface $persistProcessor, + private readonly StorageFieldNormalizer $normalizer, + #[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageRepository')] + private readonly StorageRepositoryInterface $repository, + ) {} + + public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed + { + if (!$data instanceof Storage) { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } + + // 1. RG-7.06 : normalisation serveur (numero trim, pas d'UPPER). + $this->normalize($data); + + // 2. RG-7.01 : unicite metier (site, storageType, numero) parmi les actifs + // (exclut le stockage courant en PATCH). Pre-check explicite -> 409 + // deterministe. Le NotNull site/type + NotBlank numero ont deja joue. + $siteId = $data->getSite()?->getId(); + $typeId = $data->getStorageType()?->getId(); + $numero = (string) $data->getNumero(); + if (null !== $siteId && null !== $typeId && '' !== $numero + && $this->repository->existsActiveBySiteTypeNumero($siteId, $typeId, $numero, $data->getId())) { + throw $this->duplicateConflict($numero); + } + + // 3. Persistance, avec filet anti-race sur l'index partiel. + try { + return $this->persistProcessor->process($data, $operation, $uriVariables, $context); + } catch (UniqueConstraintViolationException $e) { + // Insertion concurrente du meme triplet entre le pre-check et le flush + // (collision sur uq_storage_site_type_numero_active). + throw $this->duplicateConflict($numero, $e); + } + } + + /** + * Normalisation serveur du stockage (RG-7.06). Le setter n'est touche que si une + * valeur est presente, pour ne jamais ecraser l'existant lors d'un PATCH partiel. + * Le cast (string) est sur : NotBlank a deja rejete le vide en amont. + */ + private function normalize(Storage $data): void + { + if (null !== $data->getNumero()) { + $data->setNumero((string) $this->normalizer->normalizeNumero($data->getNumero())); + } + } + + /** + * RG-7.01 : 409 sur doublon (site, type, numero). Le front mappe ce conflit sur + * le champ `numero` (setError('numero', ...) + toast — convention useFormErrors + * ERP-101) : le propertyPath exploitable est `numero`. + */ + private function duplicateConflict(string $numero, ?Throwable $previous = null): ConflictHttpException + { + return new ConflictHttpException( + sprintf('Un stockage portant le numéro « %s » existe déjà pour ce site et ce type de stockage.', $numero), + $previous, + ); + } +} diff --git a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/StorageProvider.php b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/StorageProvider.php new file mode 100644 index 0000000..189098f --- /dev/null +++ b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/StorageProvider.php @@ -0,0 +1,172 @@ + + */ +final class StorageProvider implements ProviderInterface +{ + /** Etats valides du filtre ?state= (enum borne, RG-7.04). */ + private const array VALID_STATES = [ + Storage::STATE_RECEPTION, + Storage::STATE_PRODUCTION, + Storage::STATE_TRIAGE, + ]; + + public function __construct( + #[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageRepository')] + private readonly StorageRepositoryInterface $repository, + private readonly Pagination $pagination, + ) {} + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Storage|null + { + if ($operation instanceof CollectionOperationInterface) { + // includeDeleted toujours false : le soft-delete n'est pas expose (§ 2.8). + $qb = $this->repository->createListQueryBuilder( + false, + $this->readSearch($context), + $this->readSiteIds($context), + $this->readStorageTypeId($context), + $this->readState($context), + ); + + // Echappatoire ?pagination=false : collection complete sans Paginator. + if (!$this->pagination->isEnabled($operation, $context)) { + return $qb->getQuery()->getResult(); + } + + // Branche paginee standard : offset/limit via Pagination, enveloppe dans le + // Paginator ORM. Les jointures site/storageType sont to-ONE (ManyToOne) : + // pas de duplication de lignes, le comptage reste exact. + $limit = $this->pagination->getLimit($operation, $context); + $page = max(1, $this->pagination->getPage($context)); + $offset = ($page - 1) * $limit; + + $qb->setFirstResult($offset)->setMaxResults($limit); + + return new Paginator(new DoctrinePaginator($qb->getQuery())); + } + + // Get unitaire : recharger l'entite, puis appliquer le filtre soft-delete. + $id = $uriVariables['id'] ?? null; + if (!is_int($id) && !(is_string($id) && ctype_digit($id))) { + return null; + } + + $storage = $this->repository->findById((int) $id); + if (null === $storage) { + return null; + } + + // § 2.8 : un stockage soft-deleted n'est jamais expose (404). + if (null !== $storage->getDeletedAt()) { + return null; + } + + return $storage; + } + + /** + * Lit le filtre `?search=` (recherche partielle sur numero). Renvoie la valeur + * trimmee ou null si absente / vide. + */ + private function readSearch(array $context): ?string + { + $raw = $context['filters']['search'] ?? null; + + if (!is_string($raw)) { + return null; + } + + $raw = trim($raw); + + return '' === $raw ? null : $raw; + } + + /** + * Lit le filtre `?siteId[]=` : ids des sites coches (OR). Tolere une valeur + * scalaire unique (`?siteId=1`) ou un tableau. Ignore les entrees non numeriques. + * + * @return list + */ + private function readSiteIds(array $context): array + { + $raw = $context['filters']['siteId'] ?? null; + + if (null === $raw) { + return []; + } + + $values = is_array($raw) ? $raw : [$raw]; + + $ids = []; + foreach ($values as $value) { + if (is_int($value) || (is_string($value) && ctype_digit($value))) { + $ids[] = (int) $value; + } + } + + return array_values(array_unique($ids)); + } + + /** + * Lit le filtre `?storageTypeId=` (drawer « Filtrer »). Renvoie l'id entier ou + * null si absent / non numerique. + */ + private function readStorageTypeId(array $context): ?int + { + $raw = $context['filters']['storageTypeId'] ?? null; + + if (is_int($raw)) { + return $raw; + } + + return is_string($raw) && ctype_digit($raw) ? (int) $raw : null; + } + + /** + * Lit le filtre `?state=` (RECEPTION / PRODUCTION / TRIAGE). Normalise en + * majuscules et n'accepte qu'une valeur de l'enum borne ; toute autre valeur est + * ignoree (null). + */ + private function readState(array $context): ?string + { + $raw = $context['filters']['state'] ?? null; + + if (!is_string($raw) || '' === trim($raw)) { + return null; + } + + $state = mb_strtoupper(trim($raw), 'UTF-8'); + + return in_array($state, self::VALID_STATES, true) ? $state : null; + } +} diff --git a/src/Module/Catalog/Infrastructure/Doctrine/DoctrineStorageRepository.php b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineStorageRepository.php index e876314..216e017 100644 --- a/src/Module/Catalog/Infrastructure/Doctrine/DoctrineStorageRepository.php +++ b/src/Module/Catalog/Infrastructure/Doctrine/DoctrineStorageRepository.php @@ -7,6 +7,7 @@ namespace App\Module\Catalog\Infrastructure\Doctrine; use App\Module\Catalog\Domain\Entity\Storage; use App\Module\Catalog\Domain\Repository\StorageRepositoryInterface; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository; +use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; /** @@ -29,4 +30,110 @@ class DoctrineStorageRepository extends ServiceEntityRepository implements Stora $this->getEntityManager()->persist($storage); $this->getEntityManager()->flush(); } + + public function existsActiveBySiteTypeNumero( + int $siteId, + int $storageTypeId, + string $numero, + ?int $excludeId = null, + ): bool { + $qb = $this->createQueryBuilder('s') + ->select('1') + ->andWhere('s.site = :siteId') + ->andWhere('s.storageType = :storageTypeId') + ->andWhere('s.numero = :numero') + ->andWhere('s.deletedAt IS NULL') + ->setParameter('siteId', $siteId) + ->setParameter('storageTypeId', $storageTypeId) + ->setParameter('numero', $numero) + ->setMaxResults(1) + ; + + if (null !== $excludeId) { + $qb->andWhere('s.id != :excludeId')->setParameter('excludeId', $excludeId); + } + + return [] !== $qb->getQuery()->getResult(); + } + + public function createListQueryBuilder( + bool $includeDeleted = false, + ?string $search = null, + array $siteIds = [], + ?int $storageTypeId = null, + ?string $state = null, + ): QueryBuilder { + // Eager-load des relations embarquees en liste (storage:read) pour eviter un + // N+1 par stockage : site et storageType sont des ManyToOne (to-ONE, sures — + // pas de duplication de lignes, contrairement aux ManyToMany du Product). Les + // jointures servent aussi le tri (site.code, storageType.label). + $qb = $this->createQueryBuilder('s') + ->leftJoin('s.site', 'site')->addSelect('site') + ->leftJoin('s.storageType', 'st')->addSelect('st') + ->orderBy('site.code', 'ASC') + ->addOrderBy('st.label', 'ASC') + ->addOrderBy('s.numero', 'ASC') + ; + + // RG-7.07 : la liste exclut par defaut les stockages soft-deleted. + if (!$includeDeleted) { + $qb->andWhere('s.deletedAt IS NULL'); + } + + // ?search= : recherche partielle case-insensitive sur numero. Les + // metacaracteres LIKE (%, _, \) sont echappes pour rester litteraux. + if (null !== $search && '' !== trim($search)) { + $escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], trim($search)); + $pattern = '%'.mb_strtolower($escaped, 'UTF-8').'%'; + $qb->andWhere('LOWER(s.numero) LIKE :search')->setParameter('search', $pattern); + } + + // ?siteId[]= : stockage rattache a AU MOINS UN des sites passes (OR). site est + // un ManyToOne (to-one) -> filtre direct sur la jointure, sans sous-requete + // EXISTS ni risque de masquer une collection (≠ Product.sites M2M). + if ([] !== $siteIds) { + $qb->andWhere('site.id IN (:siteIds)')->setParameter('siteIds', $siteIds); + } + + // ?storageTypeId= : filtre par type de stockage precis (id). + if (null !== $storageTypeId) { + $qb->andWhere('st.id = :storageTypeId')->setParameter('storageTypeId', $storageTypeId); + } + + // ?state= : appartenance a la colonne JSONB `states`. DQL ne sait pas exprimer + // la containment jsonb -> on resout les ids matchant en SQL natif (operateur + // @>), puis on contraint le QueryBuilder. Ids vides -> condition toujours + // fausse (aucun stockage), sans casser le reste de la requete. + if (null !== $state) { + $stateIds = $this->matchingStateIds($state); + if ([] === $stateIds) { + $qb->andWhere('1 = 0'); + } else { + $qb->andWhere('s.id IN (:stateIds)')->setParameter('stateIds', $stateIds); + } + } + + return $qb; + } + + /** + * Ids des stockages dont la colonne JSONB `states` contient l'etat donne, via + * l'operateur de containment Postgres `@>`. L'etat est borne a l'enum + * {RECEPTION, PRODUCTION, TRIAGE} en amont (StorageProvider) — pas de saisie + * libre ici. + * + * @return list + */ + private function matchingStateIds(string $state): array + { + $rows = $this->getEntityManager()->getConnection() + ->executeQuery( + 'SELECT id FROM storage WHERE states @> CAST(:state AS JSONB)', + ['state' => (string) json_encode([$state])], + ) + ->fetchFirstColumn() + ; + + return array_map(static fn (mixed $id): int => (int) $id, $rows); + } } diff --git a/tests/Module/Catalog/Api/AbstractStorageApiTestCase.php b/tests/Module/Catalog/Api/AbstractStorageApiTestCase.php new file mode 100644 index 0000000..404edb8 --- /dev/null +++ b/tests/Module/Catalog/Api/AbstractStorageApiTestCase.php @@ -0,0 +1,202 @@ +getEm(); + + // Stockages d'abord : ils referencent site / storage_type en FK RESTRICT. + $em->createQuery('DELETE FROM '.Storage::class)->execute(); + + // Types de stockage de test (prefixe code). + $em->createQuery('DELETE FROM '.StorageType::class.' s WHERE s.code LIKE :prefix') + ->setParameter('prefix', self::TEST_STORAGE_TYPE_PREFIX.'%') + ->execute() + ; + + parent::tearDown(); + } + + /** + * Cree un type de stockage de test (code prefixe TESTSTO pour le cleanup). + */ + protected function seedStorageType(string $label = 'Cellule test'): StorageType + { + $em = $this->getEm(); + + $storageType = new StorageType(); + $storageType->setCode($this->uniqueCode(self::TEST_STORAGE_TYPE_PREFIX)); + $storageType->setLabel($label); + + $em->persist($storageType); + $em->flush(); + + return $storageType; + } + + protected function siteByCode(string $code): Site + { + $site = $this->getEm()->getRepository(Site::class)->findOneBy(['code' => $code]); + self::assertInstanceOf(Site::class, $site, sprintf('Le site de code "%s" doit etre seede.', $code)); + + return $site; + } + + protected function firstSite(): Site + { + $site = $this->getEm()->getRepository(Site::class)->findOneBy([]); + self::assertInstanceOf(Site::class, $site, 'Un site fixture est requis (SitesFixtures).'); + + return $site; + } + + /** + * Client non-admin portant seulement `catalog.storages.view`. + */ + protected function authView(): Client + { + $creds = $this->createUserWithPermission('catalog.storages.view'); + + return $this->authenticatedClient($creds['username'], $creds['password']); + } + + /** + * Payload POST de reference : un stockage valide (1 site, 1 type, 1 numero, + * 1 etat). Surchargeable par cle via $overrides (ex: ['numero' => 'A1']). + * + * @param array $overrides + * + * @return array + */ + protected function validStoragePayload(array $overrides = []): array + { + $site = $this->firstSite(); + $storageType = $this->seedStorageType(); + + $base = [ + 'site' => $this->iri('sites', (int) $site->getId()), + 'storageType' => $this->iri('storage_types', (int) $storageType->getId()), + 'numero' => $this->uniqueCode('NUM'), + 'states' => [Storage::STATE_RECEPTION], + ]; + + return array_replace($base, $overrides); + } + + /** + * Seede un stockage directement via l'EM (bypass Processor/Validator). Utile pour + * disposer d'un id existant (RBAC item, PATCH) ou d'un stockage soft-deleted + * (reutilisation du triplet — RG-7.01). Le site / le type manquants sont crees + * a la volee. + * + * @param list $states + */ + protected function seedStorageEntity( + ?string $numero = null, + array $states = [Storage::STATE_RECEPTION], + ?DateTimeImmutable $deletedAt = null, + ?Site $site = null, + ?StorageType $storageType = null, + ): Storage { + $em = $this->getEm(); + $site ??= $this->firstSite(); + + $storage = new Storage(); + $storage->setSite($em->getReference(Site::class, (int) $site->getId())); + $storage->setStorageType($storageType ?? $this->seedStorageType('Seed')); + $storage->setNumero($numero ?? $this->uniqueCode('NUM')); + $storage->setStates($states); + $storage->setDeletedAt($deletedAt); + + $em->persist($storage); + $em->flush(); + + return $storage; + } + + /** + * Construit un IRI API Platform (`/api/{resource}/{id}`). + */ + protected function iri(string $resource, int $id): string + { + return sprintf('/api/%s/%d', $resource, $id); + } + + /** + * Identifiant unique de test (prefixe + nonce), deja en MAJUSCULE. + */ + protected function uniqueCode(string $prefix): string + { + return $prefix.'_'.strtoupper(substr(bin2hex(random_bytes(5)), 0, 10)); + } + + /** + * Extrait les `propertyPath` des violations d'une reponse 422. + * + * @return list + */ + protected function violationPaths(ResponseInterface $response): array + { + $body = $response->toArray(false); + + return array_values(array_map( + static fn (array $violation): string => (string) ($violation['propertyPath'] ?? ''), + $body['violations'] ?? [], + )); + } + + /** + * Retrouve un membre d'une collection Hydra par son id (ou null). + * + * @param array $list + * + * @return null|array + */ + protected function memberById(array $list, int $id): ?array + { + foreach ($list['member'] ?? [] as $member) { + if (($member['id'] ?? null) === $id) { + return $member; + } + } + + return null; + } +} diff --git a/tests/Module/Catalog/Api/StorageApiTest.php b/tests/Module/Catalog/Api/StorageApiTest.php new file mode 100644 index 0000000..fa03a72 --- /dev/null +++ b/tests/Module/Catalog/Api/StorageApiTest.php @@ -0,0 +1,204 @@ + 409 (RG-7.01), reutilisation + * du triplet apres soft-delete, soft-delete jamais expose (§ 2.8), et la matrice RBAC + * admin-only (view lit mais ne gere pas ; personas metier 403 partout). + * + * RG-7.03 (« type disponible sur le site choisi ») n'est PAS testee : le concept + * type<->site a ete retire du modele en M6 (StorageType plat), c'est Storage qui le + * porte desormais — aucun referentiel a interroger (cf. StorageProcessor). + * + * @internal + */ +final class StorageApiTest extends AbstractStorageApiTestCase +{ + /** Personas metier sans permission stockage (admin-only — ERP-210). */ + private const array PERSONAS = ['Bureau', 'Compta', 'Commerciale', 'Usine']; + + public function testCollectionIsPaginatedHydraWithEmbeddedRelations(): void + { + $site = $this->firstSite(); + $type = $this->seedStorageType('Cellule'); + $seed = $this->seedStorageEntity('C3', site: $site, storageType: $type); + + $client = $this->createAdminClient(); + $response = $client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + + $body = $response->toArray(); + // Enveloppe Hydra (regle ABSOLUE n°13 : collection paginee, jamais d'array brut). + self::assertArrayHasKey('totalItems', $body); + self::assertArrayHasKey('member', $body); + + $member = $this->memberById($body, (int) $seed->getId()); + self::assertIsArray($member, 'Le stockage seede doit figurer dans la collection.'); + + // Contrat de serialisation (§ 4.0.bis) : site / storageType en OBJETS embarques + // (pas un IRI nu), displayName present (RG-7.05). + self::assertIsArray($member['site'], 'site doit etre un objet embarque.'); + self::assertSame($site->getCode(), $member['site']['code'] ?? null); + self::assertIsArray($member['storageType'], 'storageType doit etre un objet embarque.'); + self::assertSame('Cellule', $member['storageType']['label'] ?? null); + self::assertSame('Cellule C3', $member['displayName'] ?? null); + } + + public function testAdminCanCreateStorage(): void + { + $client = $this->createAdminClient(); + + $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validStoragePayload(), + ]); + self::assertResponseStatusCodeSame(201); + } + + public function testNumeroIsTrimmedServerSide(): void + { + $client = $this->createAdminClient(); + $response = $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validStoragePayload(['numero' => ' Z9 ']), + ]); + self::assertResponseStatusCodeSame(201); + + // RG-7.06 : trim serveur, sans changement de casse (HP-M7-05). + self::assertSame('Z9', $response->toArray()['numero'] ?? null); + } + + public function testDuplicateTripletReturns409(): void + { + $site = $this->firstSite(); + $type = $this->seedStorageType(); + $this->seedStorageEntity('A1', site: $site, storageType: $type); + + $client = $this->createAdminClient(); + $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'site' => $this->iri('sites', (int) $site->getId()), + 'storageType' => $this->iri('storage_types', (int) $type->getId()), + 'numero' => 'A1', + 'states' => [Storage::STATE_RECEPTION], + ], + ]); + // RG-7.01 : meme (site, type, numero) parmi les actifs -> 409. + self::assertResponseStatusCodeSame(409); + } + + public function testSameNumeroDifferentTypeIsAllowed(): void + { + $site = $this->firstSite(); + $typeA = $this->seedStorageType(); + $typeB = $this->seedStorageType(); + $this->seedStorageEntity('A1', site: $site, storageType: $typeA); + + $client = $this->createAdminClient(); + $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'site' => $this->iri('sites', (int) $site->getId()), + 'storageType' => $this->iri('storage_types', (int) $typeB->getId()), + 'numero' => 'A1', + 'states' => [Storage::STATE_RECEPTION], + ], + ]); + // Unicite portee par le TRIPLET : un meme numero sur un autre type passe. + self::assertResponseStatusCodeSame(201); + } + + public function testSoftDeletedTripletCanBeReused(): void + { + $site = $this->firstSite(); + $type = $this->seedStorageType(); + $this->seedStorageEntity('B2', deletedAt: new DateTimeImmutable(), site: $site, storageType: $type); + + $client = $this->createAdminClient(); + $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'site' => $this->iri('sites', (int) $site->getId()), + 'storageType' => $this->iri('storage_types', (int) $type->getId()), + 'numero' => 'B2', + 'states' => [Storage::STATE_RECEPTION], + ], + ]); + // RG-7.01 : l'unicite ne porte que sur les ACTIFS -> reutilisation OK. + self::assertResponseStatusCodeSame(201); + } + + public function testSoftDeletedIsNotExposed(): void + { + $deleted = $this->seedStorageEntity('SD', deletedAt: new DateTimeImmutable()); + + $client = $this->createAdminClient(); + + // § 2.8 : item soft-deleted -> 404. + $client->request('GET', '/api/storages/'.$deleted->getId(), ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(404); + + // … et absent de la collection (RG-7.07). + $response = $client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]]); + self::assertNull($this->memberById($response->toArray(), (int) $deleted->getId())); + } + + public function testViewPermissionReadsButCannotManage(): void + { + $storage = $this->seedStorageEntity(); + $client = $this->authView(); + + $client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + + $client->request('GET', '/api/storages/'.$storage->getId(), ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + + // view sans manage : creation refusee au niveau securite (403). + $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validStoragePayload(), + ]); + self::assertResponseStatusCodeSame(403); + } + + public function testBusinessPersonasAreForbiddenEverywhere(): void + { + $storage = $this->seedStorageEntity(); + $id = (int) $storage->getId(); + + foreach (self::PERSONAS as $persona) { + $client = $this->createPersonaClient($persona); + + $client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(403, $persona.' ne doit pas lister les stockages.'); + + $client->request('GET', '/api/storages/'.$id, ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(403, $persona.' ne doit pas consulter un stockage.'); + + $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validStoragePayload(), + ]); + self::assertResponseStatusCodeSame(403, $persona.' ne doit pas creer de stockage.'); + + $client->request('PATCH', '/api/storages/'.$id, [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['numero' => 'X'], + ]); + self::assertResponseStatusCodeSame(403, $persona.' ne doit pas modifier un stockage.'); + } + } +} From 0800ed99cf2e8b41a4cd85c7146c89476034263a Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 29 Jun 2026 16:50:40 +0200 Subject: [PATCH 05/11] =?UTF-8?q?feat(catalog)=20:=20M7=20=E2=80=94=20expo?= =?UTF-8?q?rt=20XLSX=20des=20stockages=20(GET=20/api/storages/export.xlsx,?= =?UTF-8?q?=20filtres=20actifs)=20(ERP-214)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Controller/StorageExportController.php | 234 ++++++++++++++++++ .../Api/StorageExportControllerTest.php | 204 +++++++++++++++ 2 files changed, 438 insertions(+) create mode 100644 src/Module/Catalog/Infrastructure/Controller/StorageExportController.php create mode 100644 tests/Module/Catalog/Api/StorageExportControllerTest.php diff --git a/src/Module/Catalog/Infrastructure/Controller/StorageExportController.php b/src/Module/Catalog/Infrastructure/Controller/StorageExportController.php new file mode 100644 index 0000000..e45e363 --- /dev/null +++ b/src/Module/Catalog/Infrastructure/Controller/StorageExportController.php @@ -0,0 +1,234 @@ + 'Réception', + Storage::STATE_PRODUCTION => 'Production', + Storage::STATE_TRIAGE => 'Triage', + ]; + + public function __construct( + #[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageRepository')] + private readonly StorageRepositoryInterface $repository, + private readonly SpreadsheetExporterInterface $exporter, + ) {} + + #[Route('/api/storages/export.xlsx', name: 'catalog_storages_export_xlsx', methods: ['GET'], priority: 1)] + #[IsGranted('catalog.storages.view')] + public function __invoke(Request $request): Response + { + // Memes filtres que la vue liste (StorageProvider) pour que l'export reflete + // exactement ce que l'utilisateur voit a l'ecran : recherche (?search sur + // numero), sites (?siteId[]), type (?storageTypeId), etat (?state). + // includeDeleted reste false : le soft-delete n'est jamais expose (§ 2.8). + $search = $request->query->getString('search') ?: null; + $siteIds = $this->readIntList($request->query->all()['siteId'] ?? []); + $storageTypeId = $this->readIntOrNull($request->query->get('storageTypeId')); + $state = $this->readState($request->query->get('state')); + + /** @var list $storages */ + $storages = $this->repository + ->createListQueryBuilder(false, $search, $siteIds, $storageTypeId, $state) + ->getQuery() + ->getResult() + ; + + $binary = $this->exporter->export( + 'Stockages', + $this->buildHeaders(), + $this->buildRows($storages), + ); + + return $this->buildResponse($binary); + } + + /** + * Colonnes de l'export (spec § 4.5). + * + * @return list + */ + private function buildHeaders(): array + { + return [ + 'Nom', + 'Site', + 'Type de stockage', + 'Numéro', + 'États', + 'Créé le', + 'Modifié le', + ]; + } + + /** + * @param list $storages + * + * @return iterable> + */ + private function buildRows(array $storages): iterable + { + foreach ($storages as $storage) { + yield [ + $storage->getDisplayName(), + $this->formatSite($storage->getSite()), + $storage->getStorageType()?->getLabel(), + $storage->getNumero(), + $this->formatStates($storage), + $this->formatDate($storage->getCreatedAt()), + $this->formatDate($storage->getUpdatedAt()), + ]; + } + } + + /** + * Libelle du site « Nom (Code) » (ex. « Chatellerault (86) »). Le code peut + * etre absent : on retombe alors sur le seul nom. + */ + private function formatSite(?Site $site): string + { + if (null === $site) { + return ''; + } + + $name = (string) $site->getName(); + $code = $site->getCode(); + + return null !== $code && '' !== $code ? sprintf('%s (%s)', $name, $code) : $name; + } + + /** + * Libelles FR des etats du stockage, dans l'ordre canonique (Réception, + * Production, Triage), joints par virgule. Une valeur inattendue est ignoree. + */ + private function formatStates(Storage $storage): string + { + $states = $storage->getStates(); + + $labels = []; + foreach (self::STATE_LABELS as $code => $label) { + if (in_array($code, $states, true)) { + $labels[] = $label; + } + } + + return implode(', ', $labels); + } + + /** + * Formate un horodatage en « jj/mm/aaaa hh:mm » (vide si null). + */ + private function formatDate(?DateTimeImmutable $date): string + { + return $date?->format('d/m/Y H:i') ?? ''; + } + + private function buildResponse(string $binary): Response + { + $filename = sprintf('stockages-%s.xlsx', new DateTimeImmutable()->format('Ymd')); + + $response = new Response($binary); + $response->headers->set('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + $response->headers->set('Content-Disposition', sprintf('attachment; filename="%s"', $filename)); + + return $response; + } + + /** + * Lit le filtre `?state=` comme le StorageProvider : normalise en majuscules et + * n'accepte qu'une valeur de l'enum borne {RECEPTION, PRODUCTION, TRIAGE} ; + * toute autre valeur est ignoree (null). + */ + private function readState(mixed $raw): ?string + { + if (!is_string($raw) || '' === trim($raw)) { + return null; + } + + $state = mb_strtoupper(trim($raw), 'UTF-8'); + + return in_array($state, array_keys(self::STATE_LABELS), true) ? $state : null; + } + + /** + * Lit un identifiant entier positif unique (`?storageTypeId=`). Aligne sur + * StorageProvider (tolere int ou chaine numerique). + */ + private function readIntOrNull(mixed $raw): ?int + { + if (is_int($raw)) { + return $raw > 0 ? $raw : null; + } + + return is_string($raw) && ctype_digit($raw) && (int) $raw > 0 ? (int) $raw : null; + } + + /** + * Normalise un filtre en liste d'identifiants entiers positifs (valeur unique + * ou liste, `?siteId[]=`). Aligne sur StorageProvider. + * + * @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/tests/Module/Catalog/Api/StorageExportControllerTest.php b/tests/Module/Catalog/Api/StorageExportControllerTest.php new file mode 100644 index 0000000..a161a55 --- /dev/null +++ b/tests/Module/Catalog/Api/StorageExportControllerTest.php @@ -0,0 +1,204 @@ +createAdminClient(); + $this->seedStorageEntity('NUM-A'); + + $response = $client->request('GET', self::EXPORT_URL); + + self::assertResponseIsSuccessful(); + $headers = $response->getHeaders(false); + self::assertStringContainsString(self::XLSX_MIME, $headers['content-type'][0] ?? ''); + + $disposition = $headers['content-disposition'][0] ?? ''; + self::assertStringContainsString('attachment; filename="stockages-', $disposition); + self::assertMatchesRegularExpression('/filename="stockages-\d{8}\.xlsx"/', $disposition); + + // Le binaire est un XLSX relisible dont la 1re ligne porte les en-tetes (§ 4.5). + $headerCells = $this->gridFromResponse($response->getContent())[0]; + self::assertSame('Nom', $headerCells[0]); + self::assertSame('Site', $headerCells[1]); + self::assertSame('Type de stockage', $headerCells[2]); + self::assertSame('Numéro', $headerCells[3]); + self::assertSame('États', $headerCells[4]); + self::assertSame('Créé le', $headerCells[5]); + self::assertSame('Modifié le', $headerCells[6]); + + // Au moins une ligne de donnees (le stockage seede) reperee par son numero. + self::assertContains('NUM-A', $this->numeros($response->getContent())); + } + + public function testExportExcludesSoftDeletedByDefault(): void + { + $client = $this->createAdminClient(); + $this->seedStorageEntity('NUM-ACTIVE'); + $this->seedStorageEntity('NUM-DELETED', deletedAt: new DateTimeImmutable()); + + $numeros = $this->numeros($client->request('GET', self::EXPORT_URL)->getContent()); + + self::assertContains('NUM-ACTIVE', $numeros); + self::assertNotContains('NUM-DELETED', $numeros); + } + + public function testExportRespectsSearchFilter(): void + { + $client = $this->createAdminClient(); + $this->seedStorageEntity('ALPHA-1'); + $this->seedStorageEntity('BETA-2'); + + $numeros = $this->numeros( + $client->request('GET', self::EXPORT_URL.'?search=alpha')->getContent(), + ); + + self::assertContains('ALPHA-1', $numeros); + self::assertNotContains('BETA-2', $numeros); + } + + public function testExportRespectsStorageTypeFilter(): void + { + $client = $this->createAdminClient(); + + $typeA = $this->seedStorageType('Cellule A'); + $typeB = $this->seedStorageType('Cellule B'); + $this->seedStorageEntity('TYPE-A', storageType: $typeA); + $this->seedStorageEntity('TYPE-B', storageType: $typeB); + + $numeros = $this->numeros( + $client->request('GET', self::EXPORT_URL.'?storageTypeId='.$typeA->getId())->getContent(), + ); + + self::assertContains('TYPE-A', $numeros); + self::assertNotContains('TYPE-B', $numeros); + } + + public function testExportRespectsStateFilter(): void + { + $client = $this->createAdminClient(); + $this->seedStorageEntity('STATE-PROD', [Storage::STATE_PRODUCTION]); + $this->seedStorageEntity('STATE-RECEP', [Storage::STATE_RECEPTION]); + + $numeros = $this->numeros( + $client->request('GET', self::EXPORT_URL.'?state=PRODUCTION')->getContent(), + ); + + self::assertContains('STATE-PROD', $numeros); + self::assertNotContains('STATE-RECEP', $numeros); + } + + public function testExportPopulatesAllBusinessColumns(): void + { + $client = $this->createAdminClient(); + + $site = $this->firstSite(); + $type = $this->seedStorageType('Cellule'); + $this->seedStorageEntity( + 'C3', + [Storage::STATE_RECEPTION, Storage::STATE_TRIAGE], + site: $site, + storageType: $type, + ); + + $row = $this->rowForNumero($client->request('GET', self::EXPORT_URL)->getContent(), 'C3'); + self::assertNotNull($row, 'Le stockage seede est absent de l\'export.'); + + // 0 Nom | 1 Site | 2 Type | 3 Numéro | 4 États | 5 Créé le | 6 Modifié le + self::assertSame('Cellule C3', $row[0]); + self::assertSame(sprintf('%s (%s)', $site->getName(), $site->getCode()), $row[1]); + self::assertSame('Cellule', $row[2]); + self::assertSame('C3', $row[3]); + // Ordre canonique (Réception avant Triage) independamment de l'ordre en base. + self::assertSame('Réception, Triage', $row[4]); + // Dates renseignees (Timestampable) au format jj/mm/aaaa hh:mm. + self::assertMatchesRegularExpression('#^\d{2}/\d{2}/\d{4} \d{2}:\d{2}$#', (string) $row[5]); + self::assertMatchesRegularExpression('#^\d{2}/\d{2}/\d{4} \d{2}:\d{2}$#', (string) $row[6]); + } + + public function testForbiddenWithoutStoragesViewPermission(): void + { + $creds = $this->createUserWithPermission('core.users.view'); + $client = $this->authenticatedClient($creds['username'], $creds['password']); + + $client->request('GET', self::EXPORT_URL); + + self::assertResponseStatusCodeSame(403); + } + + public function testUnauthorizedWhenAnonymous(): void + { + $client = self::createClient(); + $client->request('GET', self::EXPORT_URL); + + self::assertResponseStatusCodeSame(401); + } + + /** + * Relit le binaire XLSX d'une reponse et renvoie la grille de cellules. + * + * @return array> + */ + private function gridFromResponse(string $binary): array + { + $tmp = tempnam(sys_get_temp_dir(), 'xlsx_storage_export_test_'); + self::assertIsString($tmp); + file_put_contents($tmp, $binary); + + try { + return IOFactory::load($tmp)->getActiveSheet()->toArray(); + } finally { + @unlink($tmp); + } + } + + /** + * Extrait la colonne « Numéro » (4e colonne, index 3) des lignes de donnees. + * + * @return list + */ + private function numeros(string $binary): array + { + $rows = array_slice($this->gridFromResponse($binary), 1); // saute l'en-tete + + return array_values(array_map(static fn (array $row): string => (string) ($row[3] ?? ''), $rows)); + } + + /** + * Renvoie la ligne de donnees dont la colonne « Numéro » vaut $numero, ou null. + * + * @return null|array + */ + private function rowForNumero(string $binary, string $numero): ?array + { + foreach (array_slice($this->gridFromResponse($binary), 1) as $row) { + if ((string) ($row[3] ?? '') === $numero) { + return $row; + } + } + + return null; + } +} From caa558f582c9e886991ae8625952dc7305983d59 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 29 Jun 2026 17:10:59 +0200 Subject: [PATCH 06/11] =?UTF-8?q?test(catalog)=20:=20M7=20=E2=80=94=20test?= =?UTF-8?q?s=20RG-7.01=E2=86=927.08=20+=20contrat=20de=20s=C3=A9rialisatio?= =?UTF-8?q?n=20stockage=20(ERP-215)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/Module/Catalog/Api/StorageApiTest.php | 204 ------------------ .../Catalog/Api/StorageDisplayNameTest.php | 39 ++++ .../Catalog/Api/StorageRBACMatrixTest.php | 90 ++++++++ .../Api/StorageSerializationContractTest.php | 129 +++++++++++ .../Api/StorageStatesValidationTest.php | 75 +++++++ .../Catalog/Api/StorageTypeBySiteTest.php | 34 +++ .../Catalog/Api/StorageUniquenessTest.php | 121 +++++++++++ 7 files changed, 488 insertions(+), 204 deletions(-) delete mode 100644 tests/Module/Catalog/Api/StorageApiTest.php create mode 100644 tests/Module/Catalog/Api/StorageDisplayNameTest.php create mode 100644 tests/Module/Catalog/Api/StorageRBACMatrixTest.php create mode 100644 tests/Module/Catalog/Api/StorageSerializationContractTest.php create mode 100644 tests/Module/Catalog/Api/StorageStatesValidationTest.php create mode 100644 tests/Module/Catalog/Api/StorageTypeBySiteTest.php create mode 100644 tests/Module/Catalog/Api/StorageUniquenessTest.php diff --git a/tests/Module/Catalog/Api/StorageApiTest.php b/tests/Module/Catalog/Api/StorageApiTest.php deleted file mode 100644 index fa03a72..0000000 --- a/tests/Module/Catalog/Api/StorageApiTest.php +++ /dev/null @@ -1,204 +0,0 @@ - 409 (RG-7.01), reutilisation - * du triplet apres soft-delete, soft-delete jamais expose (§ 2.8), et la matrice RBAC - * admin-only (view lit mais ne gere pas ; personas metier 403 partout). - * - * RG-7.03 (« type disponible sur le site choisi ») n'est PAS testee : le concept - * type<->site a ete retire du modele en M6 (StorageType plat), c'est Storage qui le - * porte desormais — aucun referentiel a interroger (cf. StorageProcessor). - * - * @internal - */ -final class StorageApiTest extends AbstractStorageApiTestCase -{ - /** Personas metier sans permission stockage (admin-only — ERP-210). */ - private const array PERSONAS = ['Bureau', 'Compta', 'Commerciale', 'Usine']; - - public function testCollectionIsPaginatedHydraWithEmbeddedRelations(): void - { - $site = $this->firstSite(); - $type = $this->seedStorageType('Cellule'); - $seed = $this->seedStorageEntity('C3', site: $site, storageType: $type); - - $client = $this->createAdminClient(); - $response = $client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]]); - self::assertResponseStatusCodeSame(200); - - $body = $response->toArray(); - // Enveloppe Hydra (regle ABSOLUE n°13 : collection paginee, jamais d'array brut). - self::assertArrayHasKey('totalItems', $body); - self::assertArrayHasKey('member', $body); - - $member = $this->memberById($body, (int) $seed->getId()); - self::assertIsArray($member, 'Le stockage seede doit figurer dans la collection.'); - - // Contrat de serialisation (§ 4.0.bis) : site / storageType en OBJETS embarques - // (pas un IRI nu), displayName present (RG-7.05). - self::assertIsArray($member['site'], 'site doit etre un objet embarque.'); - self::assertSame($site->getCode(), $member['site']['code'] ?? null); - self::assertIsArray($member['storageType'], 'storageType doit etre un objet embarque.'); - self::assertSame('Cellule', $member['storageType']['label'] ?? null); - self::assertSame('Cellule C3', $member['displayName'] ?? null); - } - - public function testAdminCanCreateStorage(): void - { - $client = $this->createAdminClient(); - - $client->request('POST', '/api/storages', [ - 'headers' => ['Content-Type' => self::LD], - 'json' => $this->validStoragePayload(), - ]); - self::assertResponseStatusCodeSame(201); - } - - public function testNumeroIsTrimmedServerSide(): void - { - $client = $this->createAdminClient(); - $response = $client->request('POST', '/api/storages', [ - 'headers' => ['Content-Type' => self::LD], - 'json' => $this->validStoragePayload(['numero' => ' Z9 ']), - ]); - self::assertResponseStatusCodeSame(201); - - // RG-7.06 : trim serveur, sans changement de casse (HP-M7-05). - self::assertSame('Z9', $response->toArray()['numero'] ?? null); - } - - public function testDuplicateTripletReturns409(): void - { - $site = $this->firstSite(); - $type = $this->seedStorageType(); - $this->seedStorageEntity('A1', site: $site, storageType: $type); - - $client = $this->createAdminClient(); - $client->request('POST', '/api/storages', [ - 'headers' => ['Content-Type' => self::LD], - 'json' => [ - 'site' => $this->iri('sites', (int) $site->getId()), - 'storageType' => $this->iri('storage_types', (int) $type->getId()), - 'numero' => 'A1', - 'states' => [Storage::STATE_RECEPTION], - ], - ]); - // RG-7.01 : meme (site, type, numero) parmi les actifs -> 409. - self::assertResponseStatusCodeSame(409); - } - - public function testSameNumeroDifferentTypeIsAllowed(): void - { - $site = $this->firstSite(); - $typeA = $this->seedStorageType(); - $typeB = $this->seedStorageType(); - $this->seedStorageEntity('A1', site: $site, storageType: $typeA); - - $client = $this->createAdminClient(); - $client->request('POST', '/api/storages', [ - 'headers' => ['Content-Type' => self::LD], - 'json' => [ - 'site' => $this->iri('sites', (int) $site->getId()), - 'storageType' => $this->iri('storage_types', (int) $typeB->getId()), - 'numero' => 'A1', - 'states' => [Storage::STATE_RECEPTION], - ], - ]); - // Unicite portee par le TRIPLET : un meme numero sur un autre type passe. - self::assertResponseStatusCodeSame(201); - } - - public function testSoftDeletedTripletCanBeReused(): void - { - $site = $this->firstSite(); - $type = $this->seedStorageType(); - $this->seedStorageEntity('B2', deletedAt: new DateTimeImmutable(), site: $site, storageType: $type); - - $client = $this->createAdminClient(); - $client->request('POST', '/api/storages', [ - 'headers' => ['Content-Type' => self::LD], - 'json' => [ - 'site' => $this->iri('sites', (int) $site->getId()), - 'storageType' => $this->iri('storage_types', (int) $type->getId()), - 'numero' => 'B2', - 'states' => [Storage::STATE_RECEPTION], - ], - ]); - // RG-7.01 : l'unicite ne porte que sur les ACTIFS -> reutilisation OK. - self::assertResponseStatusCodeSame(201); - } - - public function testSoftDeletedIsNotExposed(): void - { - $deleted = $this->seedStorageEntity('SD', deletedAt: new DateTimeImmutable()); - - $client = $this->createAdminClient(); - - // § 2.8 : item soft-deleted -> 404. - $client->request('GET', '/api/storages/'.$deleted->getId(), ['headers' => ['Accept' => self::LD]]); - self::assertResponseStatusCodeSame(404); - - // … et absent de la collection (RG-7.07). - $response = $client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]]); - self::assertNull($this->memberById($response->toArray(), (int) $deleted->getId())); - } - - public function testViewPermissionReadsButCannotManage(): void - { - $storage = $this->seedStorageEntity(); - $client = $this->authView(); - - $client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]]); - self::assertResponseStatusCodeSame(200); - - $client->request('GET', '/api/storages/'.$storage->getId(), ['headers' => ['Accept' => self::LD]]); - self::assertResponseStatusCodeSame(200); - - // view sans manage : creation refusee au niveau securite (403). - $client->request('POST', '/api/storages', [ - 'headers' => ['Content-Type' => self::LD], - 'json' => $this->validStoragePayload(), - ]); - self::assertResponseStatusCodeSame(403); - } - - public function testBusinessPersonasAreForbiddenEverywhere(): void - { - $storage = $this->seedStorageEntity(); - $id = (int) $storage->getId(); - - foreach (self::PERSONAS as $persona) { - $client = $this->createPersonaClient($persona); - - $client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]]); - self::assertResponseStatusCodeSame(403, $persona.' ne doit pas lister les stockages.'); - - $client->request('GET', '/api/storages/'.$id, ['headers' => ['Accept' => self::LD]]); - self::assertResponseStatusCodeSame(403, $persona.' ne doit pas consulter un stockage.'); - - $client->request('POST', '/api/storages', [ - 'headers' => ['Content-Type' => self::LD], - 'json' => $this->validStoragePayload(), - ]); - self::assertResponseStatusCodeSame(403, $persona.' ne doit pas creer de stockage.'); - - $client->request('PATCH', '/api/storages/'.$id, [ - 'headers' => ['Content-Type' => self::MERGE], - 'json' => ['numero' => 'X'], - ]); - self::assertResponseStatusCodeSame(403, $persona.' ne doit pas modifier un stockage.'); - } - } -} diff --git a/tests/Module/Catalog/Api/StorageDisplayNameTest.php b/tests/Module/Catalog/Api/StorageDisplayNameTest.php new file mode 100644 index 0000000..f59c81d --- /dev/null +++ b/tests/Module/Catalog/Api/StorageDisplayNameTest.php @@ -0,0 +1,39 @@ + ». + * + * On asserte sur le CORPS JSON reel renvoye par l'API (pas sur le getter PHP), pour + * figer le contrat consomme par le front. + * + * @internal + */ +final class StorageDisplayNameTest extends AbstractStorageApiTestCase +{ + public function testDisplayNameConcatenatesLabelAndNumero(): void + { + $site = $this->firstSite(); + $type = $this->seedStorageType('Boisseau'); + $numero = $this->uniqueCode('NUM'); + + $client = $this->createAdminClient(); + $created = $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'site' => $this->iri('sites', (int) $site->getId()), + 'storageType' => $this->iri('storage_types', (int) $type->getId()), + 'numero' => $numero, + 'states' => [Storage::STATE_RECEPTION], + ], + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + self::assertSame('Boisseau '.$numero, $created['displayName'] ?? null); + } +} diff --git a/tests/Module/Catalog/Api/StorageRBACMatrixTest.php b/tests/Module/Catalog/Api/StorageRBACMatrixTest.php new file mode 100644 index 0000000..1fce380 --- /dev/null +++ b/tests/Module/Catalog/Api/StorageRBACMatrixTest.php @@ -0,0 +1,90 @@ + 403 partout. Un porteur de + * `view` lit (200) mais ne peut pas creer (403). Anonyme -> 401. + * + * @internal + */ +final class StorageRBACMatrixTest extends AbstractStorageApiTestCase +{ + /** Personas metier sans permission stockage (admin-only). */ + private const array PERSONAS = ['Bureau', 'Compta', 'Commerciale', 'Usine']; + + public function testAdminHasFullAccess(): void + { + $client = $this->createAdminClient(); + + $client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + + $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validStoragePayload(), + ]); + self::assertResponseStatusCodeSame(201); + } + + public function testBusinessPersonasAreForbiddenEverywhere(): void + { + $storage = $this->seedStorageEntity(); + $id = (int) $storage->getId(); + + foreach (self::PERSONAS as $persona) { + $client = $this->createPersonaClient($persona); + + $client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(403, $persona.' ne doit pas lister les stockages.'); + + $client->request('GET', '/api/storages/'.$id, ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(403, $persona.' ne doit pas consulter un stockage.'); + + $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validStoragePayload(), + ]); + self::assertResponseStatusCodeSame(403, $persona.' ne doit pas creer de stockage.'); + + $client->request('PATCH', '/api/storages/'.$id, [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['numero' => 'X'], + ]); + self::assertResponseStatusCodeSame(403, $persona.' ne doit pas modifier un stockage.'); + } + } + + public function testViewPermissionReadsButCannotManage(): void + { + $storage = $this->seedStorageEntity(); + $client = $this->authView(); + + $client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + + $client->request('GET', '/api/storages/'.$storage->getId(), ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(200); + + // view sans manage : creation refusee au niveau securite (403). + $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validStoragePayload(), + ]); + self::assertResponseStatusCodeSame(403); + } + + public function testAnonymousIsUnauthorized(): void + { + $client = self::createClient(); + + $client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(401); + } +} diff --git a/tests/Module/Catalog/Api/StorageSerializationContractTest.php b/tests/Module/Catalog/Api/StorageSerializationContractTest.php new file mode 100644 index 0000000..6a7e323 --- /dev/null +++ b/tests/Module/Catalog/Api/StorageSerializationContractTest.php @@ -0,0 +1,129 @@ + »). + * + * REGLE D'OR : on asserte sur le CORPS JSON reel, jamais sur les annotations. + * DoD (§ 4.0.bis) : avec STORAGE_DOD_DUMP positionnee, ecrit les corps liste + + * detail sous /tmp pour les coller dans la spec avant les ecrans front. + * + * @internal + */ +final class StorageSerializationContractTest extends AbstractStorageApiTestCase +{ + public function testListAndDetailSerializationContract(): void + { + $client = $this->createAdminClient(); + + $site = $this->firstSite(); + $type = $this->seedStorageType('Cellule'); + $numero = $this->uniqueCode('NUM'); + + // Stockage cree par un POST reel (2 etats pour exercer le tableau). + $created = $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => [ + 'site' => $this->iri('sites', (int) $site->getId()), + 'storageType' => $this->iri('storage_types', (int) $type->getId()), + 'numero' => $numero, + 'states' => [Storage::STATE_RECEPTION, Storage::STATE_TRIAGE], + ], + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + $id = (int) $created['id']; + + $detail = $client->request('GET', '/api/storages/'.$id, [ + 'headers' => ['Accept' => self::LD], + ])->toArray(); + $list = $client->request('GET', '/api/storages?search='.$numero, [ + 'headers' => ['Accept' => self::LD], + ])->toArray(); + + // Enveloppe Hydra AP4 (member/totalItems sans prefixe hydra:). + self::assertArrayHasKey('member', $list); + self::assertArrayNotHasKey('hydra:member', $list); + + $row = $this->memberById($list, $id); + self::assertNotNull($row, 'Le stockage cree doit apparaitre dans la liste filtree.'); + + // === Piege #1 : site en OBJET embarque (pas IRI nu) === + self::assertIsArray($row['site'], 'site doit etre un objet embarque (site:read), pas un IRI nu.'); + self::assertArrayHasKey('name', $row['site']); + self::assertArrayHasKey('code', $row['site']); + + // === Piege #2 : storageType en OBJET embarque (pas IRI nu) === + self::assertIsArray($row['storageType'], 'storageType doit etre un objet embarque (storage_type:read), pas un IRI nu.'); + self::assertArrayHasKey('label', $row['storageType']); + self::assertSame('Cellule', $row['storageType']['label']); + + // === Piege #3 : states tableau de chaines === + self::assertSame([Storage::STATE_RECEPTION, Storage::STATE_TRIAGE], $row['states']); + + // === Piege #4 : displayName present + correct (RG-7.05) === + self::assertArrayHasKey('displayName', $row); + self::assertSame('Cellule '.$numero, $row['displayName']); + + // === DETAIL : memes garanties d'embarquement === + self::assertIsArray($detail['site']); + self::assertArrayHasKey('name', $detail['site']); + self::assertIsArray($detail['storageType']); + self::assertArrayHasKey('label', $detail['storageType']); + self::assertSame([Storage::STATE_RECEPTION, Storage::STATE_TRIAGE], $detail['states']); + self::assertSame('Cellule '.$numero, $detail['displayName']); + + $this->dumpDodIfRequested($list, $detail); + } + + /** + * RG-7.07 : la liste (et le detail) n'exposent JAMAIS un stockage soft-deleted. + */ + public function testSoftDeletedIsNotExposed(): void + { + $deleted = $this->seedStorageEntity('SD', deletedAt: new DateTimeImmutable()); + + $client = $this->createAdminClient(); + + // Item soft-deleted -> 404 (§ 2.8). + $client->request('GET', '/api/storages/'.$deleted->getId(), ['headers' => ['Accept' => self::LD]]); + self::assertResponseStatusCodeSame(404); + + // … et absent de la collection (RG-7.07). + $list = $client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]])->toArray(); + self::assertNull($this->memberById($list, (int) $deleted->getId())); + } + + /** + * DoD (§ 4.0.bis) : ecrit les corps JSON reels sous /tmp si STORAGE_DOD_DUMP est + * positionnee (sinon no-op). A coller dans spec-back.md § 4.0.bis. + * + * @param array $list + * @param array $detail + */ + private function dumpDodIfRequested(array $list, array $detail): void + { + if (false === getenv('STORAGE_DOD_DUMP')) { + return; + } + + $flags = JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES; + file_put_contents('/tmp/storage-dod-list.json', json_encode($list, $flags)); + file_put_contents('/tmp/storage-dod-detail.json', json_encode($detail, $flags)); + } +} diff --git a/tests/Module/Catalog/Api/StorageStatesValidationTest.php b/tests/Module/Catalog/Api/StorageStatesValidationTest.php new file mode 100644 index 0000000..12fa054 --- /dev/null +++ b/tests/Module/Catalog/Api/StorageStatesValidationTest.php @@ -0,0 +1,75 @@ + 422 (Assert\Count(min: 1)) sur le champ `states` ; + * - valeur hors enum -> 422 (Assert\Choice) sur le champ `states` ; + * - un seul etat valide -> 201 (borne basse acceptee) ; + * - PATCH vers un tableau d'etats vide -> 422 (RG-7.08). + * + * @internal + */ +final class StorageStatesValidationTest extends AbstractStorageApiTestCase +{ + public function testEmptyStatesIsRejected(): void + { + $client = $this->createAdminClient(); + + $response = $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validStoragePayload(['states' => []]), + ]); + + self::assertResponseStatusCodeSame(422); + self::assertContains('states', $this->violationPaths($response)); + } + + public function testUnknownStateValueIsRejected(): void + { + $client = $this->createAdminClient(); + + $response = $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validStoragePayload(['states' => [Storage::STATE_RECEPTION, 'FOOBAR']]), + ]); + + self::assertResponseStatusCodeSame(422); + self::assertContains('states', $this->violationPaths($response)); + } + + public function testSingleValidStateIsAccepted(): void + { + $client = $this->createAdminClient(); + + $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validStoragePayload(['states' => [Storage::STATE_PRODUCTION]]), + ]); + + self::assertResponseStatusCodeSame(201); + } + + public function testPatchToEmptyStatesIsRejected(): void + { + $storage = $this->seedStorageEntity(); + + // RG-7.08 : la regle RG-7.04 vaut aussi en edition. + $client = $this->createAdminClient(); + $response = $client->request('PATCH', '/api/storages/'.$storage->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['states' => []], + ]); + + self::assertResponseStatusCodeSame(422); + self::assertContains('states', $this->violationPaths($response)); + } +} diff --git a/tests/Module/Catalog/Api/StorageTypeBySiteTest.php b/tests/Module/Catalog/Api/StorageTypeBySiteTest.php new file mode 100644 index 0000000..4abe93b --- /dev/null +++ b/tests/Module/Catalog/Api/StorageTypeBySiteTest.php @@ -0,0 +1,34 @@ +site a ete RETIRE du modele en M6. La jointure storage_type_site a + * ete droppee (migration Version20260626100000) et StorageType est devenu un + * referentiel PLAT, sans relation `sites` — l'entite le documente explicitement + * (« un type n'est PAS rattache a des sites ; la dispo releve de la future entite + * Stockage »). C'est desormais l'entite Storage (1 site + 1 type) qui MATERIALISE + * cette disponibilite ; il n'existe plus de referentiel a interroger pour lever une + * 422. RG-7.03 est donc inimplementable telle quelle. + * + * Ce test est conserve (skippe) pour la TRACABILITE DoD : il documente le gap dans + * la suite et devra etre reactive si la spec reintroduit un lien type<->site. + * + * @internal + */ +final class StorageTypeBySiteTest extends AbstractStorageApiTestCase +{ + public function testTypeUnavailableOnSiteIsRejected(): void + { + self::markTestSkipped( + 'RG-7.03 non portee : StorageType est un referentiel plat depuis le M6 ' + .'(jointure storage_type_site droppee). Aucun referentiel type<->site a ' + .'interroger. A reclarifier cote spec (cf. ERP-213).', + ); + } +} diff --git a/tests/Module/Catalog/Api/StorageUniquenessTest.php b/tests/Module/Catalog/Api/StorageUniquenessTest.php new file mode 100644 index 0000000..05e1415 --- /dev/null +++ b/tests/Module/Catalog/Api/StorageUniquenessTest.php @@ -0,0 +1,121 @@ + 409 (RG-7.08). + * + * @internal + */ +final class StorageUniquenessTest extends AbstractStorageApiTestCase +{ + public function testDuplicateActiveTripletReturns409(): void + { + $site = $this->firstSite(); + $type = $this->seedStorageType(); + $this->seedStorageEntity('A1', site: $site, storageType: $type); + + $client = $this->createAdminClient(); + $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->tripletPayload($site, $type, 'A1'), + ]); + self::assertResponseStatusCodeSame(409); + } + + public function testSameNumeroOnAnotherTypeIsAccepted(): void + { + $site = $this->firstSite(); + $typeA = $this->seedStorageType(); + $typeB = $this->seedStorageType(); + $this->seedStorageEntity('A1', site: $site, storageType: $typeA); + + $client = $this->createAdminClient(); + $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->tripletPayload($site, $typeB, 'A1'), + ]); + self::assertResponseStatusCodeSame(201); + } + + public function testSameNumeroOnAnotherSiteIsAccepted(): void + { + $sites = $this->getEm()->getRepository(Site::class)->findAll(); + if (count($sites) < 2) { + self::markTestSkipped('Au moins 2 sites fixtures requis pour ce cas.'); + } + + $type = $this->seedStorageType(); + $this->seedStorageEntity('A1', site: $sites[0], storageType: $type); + + $client = $this->createAdminClient(); + $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->tripletPayload($sites[1], $type, 'A1'), + ]); + self::assertResponseStatusCodeSame(201); + } + + public function testSoftDeletedTripletCanBeReused(): void + { + $site = $this->firstSite(); + $type = $this->seedStorageType(); + $this->seedStorageEntity('B2', deletedAt: new DateTimeImmutable(), site: $site, storageType: $type); + + $client = $this->createAdminClient(); + $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->tripletPayload($site, $type, 'B2'), + ]); + self::assertResponseStatusCodeSame(201); + } + + public function testPatchToExistingTripletReturns409(): void + { + $site = $this->firstSite(); + $type = $this->seedStorageType(); + $this->seedStorageEntity('A1', site: $site, storageType: $type); + $target = $this->seedStorageEntity('B2', site: $site, storageType: $type); + + // RG-7.08 : PATCH du numero B2 -> A1 (meme site+type) collisionne -> 409. + $client = $this->createAdminClient(); + $client->request('PATCH', '/api/storages/'.$target->getId(), [ + 'headers' => ['Content-Type' => self::MERGE], + 'json' => ['numero' => 'A1'], + ]); + self::assertResponseStatusCodeSame(409); + } + + /** + * Payload POST minimal pour un triplet (site, type, numero) donne. + * + * @return array + */ + private function tripletPayload(Site $site, StorageType $type, string $numero): array + { + return [ + 'site' => $this->iri('sites', (int) $site->getId()), + 'storageType' => $this->iri('storage_types', (int) $type->getId()), + 'numero' => $numero, + 'states' => [Storage::STATE_RECEPTION], + ]; + } +} From 6ee332757c049276f83ac89fe438591d5fa42e4d Mon Sep 17 00:00:00 2001 From: Matthieu Date: Mon, 29 Jun 2026 17:43:35 +0200 Subject: [PATCH 07/11] feat(infra) : branche le SDK Sentry (back + front) vers GlitchTip + CA racine MALIO Error tracking centralise : remontee des erreurs back (Symfony) et front (Nuxt) vers l'instance GlitchTip auto-hebergee. DSN vides par defaut => SDK inerte. Backend : - sentry/sentry-symfony ^5.10, bundle enregistre prod-only - config/packages/sentry.yaml : handler Monolog niveau ERROR+, ignore 4xx/AccessDenied, pas d'APM, release = %app.version% - .env : bloc SENTRY_DSN documente (vide => inerte) Frontend : - @sentry/nuxt ^10.61, module charge uniquement si NUXT_PUBLIC_SENTRY_DSN defini - runtimeConfig.public.sentry + source maps (hidden) + options d'upload - sentry.client.config.ts : init cote client gardee par if (dsn) Deploiement : - Dockerfile : ARG Sentry au build front (prefixe inline du RUN, token non persiste) + CA racine interne MALIO (update-ca-certificates) pour le handshake HTTPS GlitchTip back - build-docker.yml : --build-arg depuis les secrets Gitea - .env.prod.example : SENTRY_DSN (back, runtime) --- .env | 7 + .gitea/workflows/build-docker.yml | 5 + composer.json | 1 + composer.lock | 508 ++++++++++++++++++- config/bundles.php | 2 + config/packages/sentry.yaml | 35 ++ frontend/nuxt.config.ts | 23 +- frontend/package-lock.json | 810 +++++++++++++++++++++++++++++- frontend/package.json | 1 + frontend/sentry.client.config.ts | 18 + infra/prod/.env.prod.example | 4 + infra/prod/Dockerfile | 25 +- infra/prod/malio-dev-root-ca.crt | 31 ++ symfony.lock | 9 + 14 files changed, 1454 insertions(+), 25 deletions(-) create mode 100644 config/packages/sentry.yaml create mode 100644 frontend/sentry.client.config.ts create mode 100644 infra/prod/malio-dev-root-ca.crt diff --git a/.env b/.env index 4a306a1..a0d735e 100644 --- a/.env +++ b/.env @@ -19,3 +19,10 @@ JWT_COOKIE_TTL=86400 DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:${POSTGRES_PORT}/${POSTGRES_DB}?serverVersion=16&charset=utf8" + +###> sentry/sentry-symfony ### +# Error tracking backend → GlitchTip (projet "starseed-api"). Prod only, vide => inerte. +# À définir dans l'env de prod (PAS ici, pas de secret commité). Format : +# SENTRY_DSN=https://@:/ +# SENTRY_DSN= +###< sentry/sentry-symfony ### diff --git a/.gitea/workflows/build-docker.yml b/.gitea/workflows/build-docker.yml index 542607d..de4d379 100644 --- a/.gitea/workflows/build-docker.yml +++ b/.gitea/workflows/build-docker.yml @@ -20,6 +20,11 @@ jobs: run: | docker build \ -f infra/prod/Dockerfile \ + --build-arg NUXT_PUBLIC_SENTRY_DSN="${{ secrets.STARSEED_SENTRY_DSN_FRONT }}" \ + --build-arg SENTRY_URL="${{ secrets.SENTRY_URL }}" \ + --build-arg SENTRY_ORG="${{ secrets.SENTRY_ORG }}" \ + --build-arg SENTRY_PROJECT="${{ secrets.SENTRY_PROJECT }}" \ + --build-arg SENTRY_AUTH_TOKEN="${{ secrets.SENTRY_AUTH_TOKEN }}" \ -t gitea.malio.fr/malio-dev/starseed:${{ gitea.ref_name }} \ -t gitea.malio.fr/malio-dev/starseed:latest \ . diff --git a/composer.json b/composer.json index 4ce5354..53a0161 100644 --- a/composer.json +++ b/composer.json @@ -19,6 +19,7 @@ "phpdocumentor/reflection-docblock": "^5.6|^6.0", "phpoffice/phpspreadsheet": "^5.7", "phpstan/phpdoc-parser": "^2.3", + "sentry/sentry-symfony": "^5.10", "symfony/asset": "8.0.*", "symfony/console": "8.0.*", "symfony/dotenv": "8.0.*", diff --git a/composer.lock b/composer.lock index ba44978..930e7d0 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "224bae08ec63f217eabf5b2b611deaa0", + "content-hash": "b8b93695be3d3ac324dc082fbd6db78c", "packages": [ { "name": "api-platform/doctrine-common", @@ -2675,6 +2675,185 @@ }, "time": "2026-01-02T16:01:13+00:00" }, + { + "name": "guzzlehttp/psr7", + "version": "2.12.3", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/7ec62dc3f44aa218487dbed81a9bf9bc647be55d", + "reference": "7ec62dc3f44aa218487dbed81a9bf9bc647be55d", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0", + "symfony/deprecation-contracts": "^2.5 || ^3.0", + "symfony/polyfill-php80": "^1.25" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "1.1.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.52 || ^9.6.34" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.12.3" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2026-06-23T15:21:08+00:00" + }, + { + "name": "jean85/pretty-package-versions", + "version": "2.1.1", + "source": { + "type": "git", + "url": "https://github.com/Jean85/pretty-package-versions.git", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a", + "reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a", + "shasum": "" + }, + "require": { + "composer-runtime-api": "^2.1.0", + "php": "^7.4|^8.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.2", + "jean85/composer-provided-replaced-stub-package": "^1.0", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^7.5|^8.5|^9.6", + "rector/rector": "^2.0", + "vimeo/psalm": "^4.3 || ^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Jean85\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Alessandro Lai", + "email": "alessandro.lai85@gmail.com" + } + ], + "description": "A library to get pretty versions strings of installed dependencies", + "keywords": [ + "composer", + "package", + "release", + "versions" + ], + "support": { + "issues": "https://github.com/Jean85/pretty-package-versions/issues", + "source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1" + }, + "time": "2025-03-19T14:43:43+00:00" + }, { "name": "lcobucci/jwt", "version": "5.6.0", @@ -4159,6 +4338,50 @@ }, "time": "2021-10-29T13:26:27+00:00" }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "source": { + "type": "git", + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, { "name": "sabberworm/php-css-parser", "version": "v9.4.0", @@ -4239,6 +4462,202 @@ }, "time": "2026-06-18T15:10:53+00:00" }, + { + "name": "sentry/sentry", + "version": "4.29.0", + "source": { + "type": "git", + "url": "https://github.com/getsentry/sentry-php.git", + "reference": "d732a4da195f231cedb2a2a78ae16dd73082afa3" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/getsentry/sentry-php/zipball/d732a4da195f231cedb2a2a78ae16dd73082afa3", + "reference": "d732a4da195f231cedb2a2a78ae16dd73082afa3", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "guzzlehttp/psr7": "^1.8.4|^2.1.1", + "jean85/pretty-package-versions": "^1.5|^2.0.4", + "php": "^7.2|^8.0", + "psr/log": "^1.0|^2.0|^3.0", + "symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0" + }, + "conflict": { + "raven/raven": "*" + }, + "require-dev": { + "carthage-software/mago": "1.30.0", + "friendsofphp/php-cs-fixer": "^3.4", + "guzzlehttp/promises": "^2.0.3", + "monolog/monolog": "^1.6|^2.0|^3.0", + "nyholm/psr7": "^1.8", + "open-telemetry/api": "^1.0", + "open-telemetry/exporter-otlp": "^1.0", + "open-telemetry/sdk": "^1.0", + "open-telemetry/sem-conv": "^1.27", + "phpstan/phpstan": "^1.3", + "phpunit/phpunit": "^8.5.52|^9.6.34", + "spiral/roadrunner-http": "^3.6", + "spiral/roadrunner-worker": "^3.6" + }, + "suggest": { + "ext-excimer": "Enable Sentry profiling with the Excimer PHP extension.", + "monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler." + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Sentry\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sentry", + "email": "accounts@sentry.io" + } + ], + "description": "PHP SDK for Sentry (http://sentry.io)", + "homepage": "http://sentry.io", + "keywords": [ + "crash-reporting", + "crash-reports", + "error-handler", + "error-monitoring", + "log", + "logging", + "profiling", + "sentry", + "tracing" + ], + "support": { + "issues": "https://github.com/getsentry/sentry-php/issues", + "source": "https://github.com/getsentry/sentry-php/tree/4.29.0" + }, + "funding": [ + { + "url": "https://sentry.io/", + "type": "custom" + }, + { + "url": "https://sentry.io/pricing/", + "type": "custom" + } + ], + "time": "2026-06-29T14:47:44+00:00" + }, + { + "name": "sentry/sentry-symfony", + "version": "5.10.0", + "source": { + "type": "git", + "url": "https://github.com/getsentry/sentry-symfony.git", + "reference": "6f49255f4cdcfc43a3a283bd3a1f65d483e9192f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/getsentry/sentry-symfony/zipball/6f49255f4cdcfc43a3a283bd3a1f65d483e9192f", + "reference": "6f49255f4cdcfc43a3a283bd3a1f65d483e9192f", + "shasum": "" + }, + "require": { + "guzzlehttp/psr7": "^2.1.1", + "jean85/pretty-package-versions": "^1.5||^2.0", + "php": "^7.2||^8.0", + "sentry/sentry": "^4.23.0", + "symfony/cache-contracts": "^1.1||^2.4||^3.0", + "symfony/config": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/console": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/dependency-injection": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/event-dispatcher": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/http-kernel": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/polyfill-php80": "^1.22", + "symfony/psr-http-message-bridge": "^1.2||^2.0||^6.4||^7.0||^8.0", + "symfony/yaml": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0" + }, + "require-dev": { + "doctrine/dbal": "^2.13||^3.3||^4.0", + "doctrine/doctrine-bundle": "^2.6||^3.0", + "friendsofphp/php-cs-fixer": "^2.19||^3.40", + "masterminds/html5": "^2.8", + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "1.12.5", + "phpstan/phpstan-phpunit": "1.4.0", + "phpstan/phpstan-symfony": "1.4.10", + "phpunit/phpunit": "^8.5.40||^9.6.21", + "symfony/browser-kit": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/cache": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/dom-crawler": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/framework-bundle": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/http-client": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/messenger": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/monolog-bundle": "^3.4||^4.0", + "symfony/phpunit-bridge": "^5.2.6||^6.0||^7.0||^8.0", + "symfony/process": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/security-core": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/security-http": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "symfony/twig-bundle": "^4.4.20||^5.0.11||^6.0||^7.0||^8.0", + "vimeo/psalm": "^4.3||^5.16.0" + }, + "suggest": { + "doctrine/doctrine-bundle": "Allow distributed tracing of database queries using Sentry.", + "monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler.", + "symfony/cache": "Allow distributed tracing of cache pools using Sentry.", + "symfony/twig-bundle": "Allow distributed tracing of Twig template rendering using Sentry." + }, + "type": "symfony-bundle", + "autoload": { + "files": [ + "src/aliases.php" + ], + "psr-4": { + "Sentry\\SentryBundle\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sentry", + "email": "accounts@sentry.io" + } + ], + "description": "Symfony integration for Sentry (http://getsentry.com)", + "homepage": "http://getsentry.com", + "keywords": [ + "errors", + "logging", + "sentry", + "symfony" + ], + "support": { + "issues": "https://github.com/getsentry/sentry-symfony/issues", + "source": "https://github.com/getsentry/sentry-symfony/tree/5.10.0" + }, + "funding": [ + { + "url": "https://sentry.io/", + "type": "custom" + }, + { + "url": "https://sentry.io/pricing/", + "type": "custom" + } + ], + "time": "2026-04-01T14:50:32+00:00" + }, { "name": "symfony/asset", "version": "v8.0.8", @@ -7216,6 +7635,93 @@ ], "time": "2026-03-30T15:14:47+00:00" }, + { + "name": "symfony/psr-http-message-bridge", + "version": "v8.0.8", + "source": { + "type": "git", + "url": "https://github.com/symfony/psr-http-message-bridge.git", + "reference": "94facc221260c1d5f20e31ee43cd6c6a824b4a19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/94facc221260c1d5f20e31ee43cd6c6a824b4a19", + "reference": "94facc221260c1d5f20e31ee43cd6c6a824b4a19", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "psr/http-message": "^1.0|^2.0", + "symfony/http-foundation": "^7.4|^8.0" + }, + "conflict": { + "php-http/discovery": "<1.15" + }, + "require-dev": { + "nyholm/psr7": "^1.1", + "php-http/discovery": "^1.15", + "psr/log": "^1.1.4|^2|^3", + "symfony/browser-kit": "^7.4|^8.0", + "symfony/config": "^7.4|^8.0", + "symfony/event-dispatcher": "^7.4|^8.0", + "symfony/framework-bundle": "^7.4|^8.0", + "symfony/http-kernel": "^7.4|^8.0", + "symfony/runtime": "^7.4|^8.0" + }, + "type": "symfony-bridge", + "autoload": { + "psr-4": { + "Symfony\\Bridge\\PsrHttpMessage\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "PSR HTTP message bridge", + "homepage": "https://symfony.com", + "keywords": [ + "http", + "http-message", + "psr-17", + "psr-7" + ], + "support": { + "source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.0.8" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-03-30T15:14:47+00:00" + }, { "name": "symfony/rate-limiter", "version": "v8.0.8", diff --git a/config/bundles.php b/config/bundles.php index 6754b65..d7ecd0d 100644 --- a/config/bundles.php +++ b/config/bundles.php @@ -8,6 +8,7 @@ use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle; use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle; use Lexik\Bundle\JWTAuthenticationBundle\LexikJWTAuthenticationBundle; use Nelmio\CorsBundle\NelmioCorsBundle; +use Sentry\SentryBundle\SentryBundle; use Symfony\Bundle\FrameworkBundle\FrameworkBundle; use Symfony\Bundle\MonologBundle\MonologBundle; use Symfony\Bundle\SecurityBundle\SecurityBundle; @@ -24,4 +25,5 @@ return [ LexikJWTAuthenticationBundle::class => ['all' => true], MonologBundle::class => ['all' => true], TwigBundle::class => ['all' => true], + SentryBundle::class => ['prod' => true], ]; diff --git a/config/packages/sentry.yaml b/config/packages/sentry.yaml new file mode 100644 index 0000000..387ccd4 --- /dev/null +++ b/config/packages/sentry.yaml @@ -0,0 +1,35 @@ +# Error tracking → GlitchTip (compatible SDK Sentry). +# Actif uniquement en prod (bundle enregistré prod-only dans bundles.php). +# Si SENTRY_DSN est vide/non défini, le SDK est inerte (rien n'est envoyé). +when@prod: + parameters: + # Valeur par défaut : DSN vide => Sentry désactivé tant qu'il n'est pas fourni. + env(SENTRY_DSN): '' + + sentry: + dsn: '%env(SENTRY_DSN)%' + # Capture des erreurs fatales PHP via le handler. On DÉSACTIVE le listener + # kernel pour éviter les doublons avec le handler Monolog (ci-dessous) : les + # exceptions du kernel sont déjà logguées par Symfony => remontées via Monolog. + register_error_listener: false + register_error_handler: true + options: + environment: '%env(APP_ENV)%' + release: '%app.version%' + # Pas d'APM/tracing (DuckDB hors périmètre du ticket #146). + traces_sample_rate: 0.0 + # Ne pas remonter les 4xx HTTP comme des erreurs (bruit). + ignore_exceptions: + - Symfony\Component\HttpKernel\Exception\NotFoundHttpException + - Symfony\Component\HttpKernel\Exception\MethodNotAllowedHttpException + - Symfony\Component\Security\Core\Exception\AccessDeniedException + + # Handler Monolog -> Sentry : remonte les logs niveau ERROR+ comme Issues GlitchTip + # (en plus des erreurs fatales). Les $logger->error(...) métier deviennent des Issues. + # Le filtre ignore_exceptions ci-dessus s'applique aussi à ces événements. + services: + Sentry\Monolog\Handler: + arguments: + $hub: '@Sentry\State\HubInterface' + $level: !php/const Monolog\Level::Error + $bubble: true diff --git a/frontend/nuxt.config.ts b/frontend/nuxt.config.ts index 9f17d13..26609a3 100644 --- a/frontend/nuxt.config.ts +++ b/frontend/nuxt.config.ts @@ -40,12 +40,33 @@ export default defineNuxtConfig({ 'nuxt-toast', '@nuxtjs/i18n', '@nuxt/icon', + // Error tracking → GlitchTip. Module charge uniquement si un DSN est fourni + // (build prod) ; en dev sans DSN, aucun overhead Sentry. Les options d'upload + // des source maps sont passees en ligne (fournies au build via secrets CI). + ...(process.env.NUXT_PUBLIC_SENTRY_DSN + ? [['@sentry/nuxt/module', { + sourceMapsUploadOptions: { + url: process.env.SENTRY_URL, + org: process.env.SENTRY_ORG, + project: process.env.SENTRY_PROJECT, + authToken: process.env.SENTRY_AUTH_TOKEN, + }, + }] as [string, Record]] + : []), ], runtimeConfig: { public: { - apiBase: process.env.NUXT_PUBLIC_API_BASE + apiBase: process.env.NUXT_PUBLIC_API_BASE, + sentry: { + // DSN du projet GlitchTip "starseed-front" (vide => SDK inerte). + dsn: process.env.NUXT_PUBLIC_SENTRY_DSN || '', + environment: process.env.NODE_ENV || 'development', + }, } }, + // Source maps "hidden" : generees et uploadees vers GlitchTip pour des stacktraces + // lisibles, sans exposer les .map au navigateur. + sourcemap: {client: 'hidden'}, devServer: { port: 3004, }, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 221dc91..ee66477 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "@nuxtjs/i18n": "^10.2.3", "@nuxtjs/tailwindcss": "^6.14.0", "@pinia/nuxt": "^0.11.3", + "@sentry/nuxt": "^10.62.0", "nuxt": "^4.3.1", "nuxt-toast": "^1.4.0", "pinia": "^3.0.4", @@ -57,6 +58,49 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/@apm-js-collab/code-transformer": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer/-/code-transformer-0.15.0.tgz", + "integrity": "sha512-XmXYVs8CzJ1Aj79noVbn2weUO/XWtRyURpGqx7aU7DOXlUQhR0WKOQNF0okh7PCeY37vxf7kU3v57OAkEPm3ww==", + "license": "Apache-2.0", + "dependencies": { + "@types/estree": "^1.0.8", + "astring": "^1.9.0", + "esquery": "^1.7.0", + "meriyah": "^6.1.4", + "semifies": "^1.0.0", + "source-map": "^0.6.0" + }, + "bin": { + "code-transformer": "cli.js" + } + }, + "node_modules/@apm-js-collab/code-transformer-bundler-plugins": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@apm-js-collab/code-transformer-bundler-plugins/-/code-transformer-bundler-plugins-0.5.0.tgz", + "integrity": "sha512-YxLBY5nGlurL7QeJLq6e5g0ouBpAp0pwgyA/5rHXEXwhiPLn9ZHbT+Y2LlP90GT872cSocfjWRYu/fnpuBudNQ==", + "license": "MIT", + "dependencies": { + "@apm-js-collab/code-transformer": "^0.15.0", + "es-module-lexer": "^2.1.0", + "magic-string": "^0.30.21", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@apm-js-collab/tracing-hooks": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@apm-js-collab/tracing-hooks/-/tracing-hooks-0.10.0.tgz", + "integrity": "sha512-2/Z3NTewJTruUkmsSnBC5bJlLNUd9keuD1OLlTEpim4FyLhm6m2Rnfv+wrFdUvFfhmH8CRdiDZBqBrn+wyaGuA==", + "license": "Apache-2.0", + "dependencies": { + "@apm-js-collab/code-transformer": "^0.15.0", + "debug": "^4.4.1", + "module-details-from-path": "^1.0.4" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -2625,6 +2669,101 @@ "dev": true, "license": "MIT" }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/api-logs": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", + "integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.3.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/core": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz", + "integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation": { + "version": "0.214.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.214.0.tgz", + "integrity": "sha512-MHqEX5Dk59cqVah5LiARMACku7jXSVk9iVDWOea4x3cr7VfdByeDCURK6o1lntT1JS/Tsovw01UJrBhN3/uC5w==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.214.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/resources": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz", + "integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/sdk-trace-base": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz", + "integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.8.0", + "@opentelemetry/resources": "2.8.0", + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.3.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.41.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz", + "integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, "node_modules/@oxc-minify/binding-android-arm-eabi": { "version": "0.117.0", "resolved": "https://registry.npmjs.org/@oxc-minify/binding-android-arm-eabi/-/binding-android-arm-eabi-0.117.0.tgz", @@ -4534,6 +4673,556 @@ "win32" ] }, + "node_modules/@sentry/babel-plugin-component-annotate": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-5.3.0.tgz", + "integrity": "sha512-p4q8gn8wcFqZGP/s2MnJCAAd8fTikaU6A0mM97RDHQgStcrYiaS0Sc5zUNfb1V+UOLPuvdEdL6MwyxfzjYJQTA==", + "license": "MIT", + "engines": { + "node": ">= 18" + } + }, + "node_modules/@sentry/browser": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.62.0.tgz", + "integrity": "sha512-uJi0yPssB3Nt/cZ8/S8opW42gaM59/6IyNtPFYD7C0ciudi/nIo5QMVpCYBBI3jnKFOIQLlsMT4pDlOLuxxNuQ==", + "license": "MIT", + "dependencies": { + "@sentry/browser-utils": "10.62.0", + "@sentry/core": "10.62.0", + "@sentry/feedback": "10.62.0", + "@sentry/replay": "10.62.0", + "@sentry/replay-canvas": "10.62.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/browser-utils": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.62.0.tgz", + "integrity": "sha512-mS9HVVuWIdye9o0xUGFmzNOBqktF4n5kugrF8NCOYYDrr5ZV8Cx7BlquHQn5UpCeViVhZtcDlEm4iOK7++Px7A==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.62.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/bundler-plugin-core": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-5.3.0.tgz", + "integrity": "sha512-L5T60sWdAI3qWwdg3Ptwek/0TY59PERrxyqp4XMUkroayQvGd9r5dIW9Q1kSeXX9iJ442nXbFZKAOyCKV4Z13Q==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.18.5", + "@sentry/babel-plugin-component-annotate": "5.3.0", + "@sentry/cli": "^2.58.5", + "dotenv": "^16.3.1", + "find-up": "^5.0.0", + "glob": "^13.0.6", + "magic-string": "~0.30.8" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@sentry/bundler-plugin-core/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/@sentry/cli": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.6.tgz", + "integrity": "sha512-baBcNPLLfUi9WuL+Tpri9BFaAdvugZIKelC5X0tt0Zdy+K0K+PCVSrnNmwMWU/HyaF/SEv6b6UHnXIdqanBlcg==", + "hasInstallScript": true, + "license": "FSL-1.1-MIT", + "dependencies": { + "https-proxy-agent": "^5.0.0", + "node-fetch": "^2.6.7", + "progress": "^2.0.3", + "proxy-from-env": "^1.1.0", + "which": "^2.0.2" + }, + "bin": { + "sentry-cli": "bin/sentry-cli" + }, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@sentry/cli-darwin": "2.58.6", + "@sentry/cli-linux-arm": "2.58.6", + "@sentry/cli-linux-arm64": "2.58.6", + "@sentry/cli-linux-i686": "2.58.6", + "@sentry/cli-linux-x64": "2.58.6", + "@sentry/cli-win32-arm64": "2.58.6", + "@sentry/cli-win32-i686": "2.58.6", + "@sentry/cli-win32-x64": "2.58.6" + } + }, + "node_modules/@sentry/cli-darwin": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.6.tgz", + "integrity": "sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA==", + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.6.tgz", + "integrity": "sha512-pD0LAt5PcUzAinBwvDqc66x9+2CabHEv486yP0gRjWO7SakbaxmfVq/EXd8VLq/Tzi39LAu422UYK1lpW3MILw==", + "cpu": [ + "arm" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-arm64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.6.tgz", + "integrity": "sha512-q8mEcNNmeXMy5i+jWT30TVpH7LcP4HD21CD5XRSPAd/a912HF6EpK0ybf/1USO14WOhoXbAGi9txwaWabSe33g==", + "cpu": [ + "arm64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-i686": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.6.tgz", + "integrity": "sha512-q8vNJi1eOV/4vxAFWBsEwLHoSYapaZHIf4j76KJGJXFKTkEbsjCOOsKbwUIBTQQhRgV4DFWh3ryfsPS/que4Kg==", + "cpu": [ + "x86", + "ia32" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-linux-x64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.6.tgz", + "integrity": "sha512-DZu956Mhi3ZRjTBe1WdbGV46ldVbA8d2rgp/fh51GsI25zjBHah4wZnPTSzpc+YqxU6pJpg579B/r3jrIK530Q==", + "cpu": [ + "x64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "linux", + "freebsd", + "android" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-arm64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.6.tgz", + "integrity": "sha512-nj0Ff/kmAB73EPDhR8B4O9r+NUHK5GkPCkGWC+kXVemqAJWL5jcJ5KdxG0l/S0z6RoEoltID8/43/B+TaMlT7A==", + "cpu": [ + "arm64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-i686": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.6.tgz", + "integrity": "sha512-WNZiDzPbgsEMQWq4avsQ391v/xWKJDIWWWo9GYl+N/w5qcYKkoDW7wQG7T9FasI6ENn68phChTOAPXXxbfAdOg==", + "cpu": [ + "x86", + "ia32" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli-win32-x64": { + "version": "2.58.6", + "resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.6.tgz", + "integrity": "sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA==", + "cpu": [ + "x64" + ], + "license": "FSL-1.1-MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@sentry/cli/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/@sentry/cli/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@sentry/cloudflare": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/cloudflare/-/cloudflare-10.62.0.tgz", + "integrity": "sha512-oHDpXXiO3XpBO2cHiTRQpSrtQOQrsU9JsO3TZ6ukdd24IUE6Tkc3l7hWdwzKqId3nTWP1Ef0Fr+offsrEGJ6UA==", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@sentry/core": "10.62.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^4.x" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/@sentry/conventions": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.12.0.tgz", + "integrity": "sha512-z1JQrl/1SLY+8wpzvork6vl+fpsg/oCCxM7HWWhUnI/R+OGNyoIzieQuggX3uUMY7NBtp8UWCQx6FeFazzOF9g==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@sentry/core": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.62.0.tgz", + "integrity": "sha512-tV69fMg2sS5DUFmQSnS7Jd5qJAp0izxwcsvBVz2ieTM9VMRi99IfOSYW9UYr3p1yfuksk41kefN5PEbeedUE+A==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/feedback": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.62.0.tgz", + "integrity": "sha512-d0BVjJVny6qpBgGJgWL0fbcoQHjtD3z3R8EK/KzTS3RO92JX5n3A536n5D/rh0gZFgcIwiUzBXegmyPOSQn9ng==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.62.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/node/-/node-10.62.0.tgz", + "integrity": "sha512-4hoU67bJY0o3irEDMZu2UIztAOsvEqFkLXA7EUKl1LXMA3Ba1Lb32OUVqlsTypiEInSDs/BtM+aAFKojZ3P3Fw==", + "license": "MIT", + "dependencies": { + "@opentelemetry/api": "^1.9.1", + "@opentelemetry/instrumentation": "^0.214.0", + "@opentelemetry/sdk-trace-base": "^2.6.1", + "@opentelemetry/semantic-conventions": "^1.40.0", + "@sentry/core": "10.62.0", + "@sentry/node-core": "10.62.0", + "@sentry/opentelemetry": "10.62.0", + "@sentry/server-utils": "10.62.0", + "import-in-the-middle": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/node-core": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/node-core/-/node-core-10.62.0.tgz", + "integrity": "sha512-V7rDgbxViiHU0OpcFEDp3l41IFvWTasKHfXw8SQ6yIgtZ8VpFqmz2TR5N7X85iIOmWIvK5HV0yp0eDdsly0+rA==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.12.0", + "@sentry/core": "10.62.0", + "@sentry/opentelemetry": "10.62.0", + "import-in-the-middle": "^3.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/exporter-trace-otlp-http": ">=0.57.0 <1", + "@opentelemetry/instrumentation": ">=0.57.1 <1", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/core": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-http": { + "optional": true + }, + "@opentelemetry/instrumentation": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + } + } + }, + "node_modules/@sentry/nuxt": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/nuxt/-/nuxt-10.62.0.tgz", + "integrity": "sha512-YM9N4mH/uOJP/zr3QmQgCpQFaLzoDRh0/SoMMuNq/EEtzFZLQT6+qd5tYERMAutU4ySHinIaKYC2Gq/hEs5LtA==", + "license": "MIT", + "dependencies": { + "@nuxt/kit": "^3.13.2", + "@sentry/browser": "10.62.0", + "@sentry/cloudflare": "10.62.0", + "@sentry/core": "10.62.0", + "@sentry/node": "10.62.0", + "@sentry/node-core": "10.62.0", + "@sentry/rollup-plugin": "^5.3.0", + "@sentry/vite-plugin": "^5.3.0", + "@sentry/vue": "10.62.0", + "local-pkg": "^1.1.2" + }, + "engines": { + "node": ">=18.19.1" + }, + "peerDependencies": { + "nitro": "2.x || 3.x", + "nuxt": ">=3.7.0 || 4.x || 5.x" + }, + "peerDependenciesMeta": { + "nitro": { + "optional": true + } + } + }, + "node_modules/@sentry/nuxt/node_modules/@nuxt/kit": { + "version": "3.21.8", + "resolved": "https://registry.npmjs.org/@nuxt/kit/-/kit-3.21.8.tgz", + "integrity": "sha512-kg63DUPY5AHPn+9XM7u8rYcdWHXjzwfUscgRDuiC5YUciQ+xdLRhdwXelYFxEAx2nxJHossliiQXbMm/Fleivw==", + "license": "MIT", + "dependencies": { + "c12": "^3.3.4", + "consola": "^3.4.2", + "defu": "^6.1.7", + "destr": "^2.0.5", + "errx": "^0.1.0", + "exsolve": "^1.0.8", + "ignore": "^7.0.5", + "jiti": "^2.7.0", + "klona": "^2.0.6", + "knitwork": "^1.3.0", + "mlly": "^1.8.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "pkg-types": "^2.3.1", + "rc9": "^3.0.1", + "scule": "^1.3.0", + "semver": "^7.8.0", + "tinyglobby": "^0.2.16", + "ufo": "^1.6.4", + "unctx": "^2.5.0", + "untyped": "^2.0.0" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/@sentry/opentelemetry": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/opentelemetry/-/opentelemetry-10.62.0.tgz", + "integrity": "sha512-nFwBgtjfwgY8P5lAuQFWfAsQW1MXxuQ6kR/HtBs+A6julqwGGS2QnQ65OCWMzz6IqDEL/pRgT1405/gU+OXU3A==", + "license": "MIT", + "dependencies": { + "@sentry/conventions": "^0.12.0", + "@sentry/core": "10.62.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/core": "^1.30.1 || ^2.1.0", + "@opentelemetry/sdk-trace-base": "^1.30.1 || ^2.1.0" + } + }, + "node_modules/@sentry/replay": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.62.0.tgz", + "integrity": "sha512-rWp4hBhZOmdQhisxcKzAwTGiRk/LvWnNaElWe7nbRhjsM/usp2095yfjq4iJ47v9MtO7xxY6eUz++fLBycqXKg==", + "license": "MIT", + "dependencies": { + "@sentry/browser-utils": "10.62.0", + "@sentry/core": "10.62.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/replay-canvas": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.62.0.tgz", + "integrity": "sha512-CzPAxmpe5US/ABGA1TzpjFKOFZN5uqlzrRh/uM9/daVuzLVKIAQ0XRNxo/PPEXvlDm/PoMdI5L0qIODuIKnyyw==", + "license": "MIT", + "dependencies": { + "@sentry/core": "10.62.0", + "@sentry/replay": "10.62.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/rollup-plugin": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@sentry/rollup-plugin/-/rollup-plugin-5.3.0.tgz", + "integrity": "sha512-hgPGPYdQJ/G1cGYOxAb7d4z3V+/k/E5/P/5TFPEEBLuIbFFk+JG0CISUDJdzXJjO382Lb99PBJuXGbueBmO79w==", + "license": "MIT", + "dependencies": { + "@sentry/bundler-plugin-core": "5.3.0", + "magic-string": "~0.30.8" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "rollup": ">=3.2.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@sentry/server-utils": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/server-utils/-/server-utils-10.62.0.tgz", + "integrity": "sha512-S5szsj6kKBhxw97b2HA98fYp/PpWXvSizlisEzb2rnL4IH6RAJ8wP05/fnth8pSywTH+gtUu+i6Wn8e8rX5HvA==", + "license": "MIT", + "dependencies": { + "@apm-js-collab/code-transformer": "^0.15.0", + "@apm-js-collab/code-transformer-bundler-plugins": "^0.5.0", + "@apm-js-collab/tracing-hooks": "^0.10.0", + "@sentry/conventions": "^0.12.0", + "@sentry/core": "10.62.0", + "magic-string": "~0.30.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/vite-plugin": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-5.3.0.tgz", + "integrity": "sha512-qcoSzo4n2MulVQ70UUPLq6dTleb2a2HwL2wuwvAgWhPChrYTuk6A6mDg6aQb9fairPAwFPiU9PzOANpoDJcz1A==", + "license": "MIT", + "dependencies": { + "@sentry/bundler-plugin-core": "5.3.0", + "@sentry/rollup-plugin": "5.3.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/@sentry/vue": { + "version": "10.62.0", + "resolved": "https://registry.npmjs.org/@sentry/vue/-/vue-10.62.0.tgz", + "integrity": "sha512-aK3E302Zx/g1dqtUU30Q0jblvCW8MsVXuzwnxM4JSgO47o0jW74zaFh1K3Ym2uQWhLvP1rV2D49BYwCMUc4ovQ==", + "license": "MIT", + "dependencies": { + "@sentry/browser": "10.62.0", + "@sentry/core": "10.62.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@tanstack/vue-router": "^1.64.0", + "pinia": "2.x || 3.x", + "vue": "2.x || 3.x" + }, + "peerDependenciesMeta": { + "@tanstack/vue-router": { + "optional": true + }, + "pinia": { + "optional": true + } + } + }, "node_modules/@simple-git/args-pathspec": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@simple-git/args-pathspec/-/args-pathspec-1.0.2.tgz", @@ -6560,6 +7249,15 @@ "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "license": "MIT", + "bin": { + "astring": "bin/astring" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -7152,6 +7850,12 @@ "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", "license": "MIT" }, + "node_modules/cjs-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", + "license": "MIT" + }, "node_modules/clean-regexp": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/clean-regexp/-/clean-regexp-1.0.0.tgz", @@ -8092,9 +8796,9 @@ } }, "node_modules/es-module-lexer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", - "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz", + "integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==", "license": "MIT" }, "node_modules/es-object-atoms": { @@ -9609,6 +10313,21 @@ "node": ">=4" } }, + "node_modules/import-in-the-middle": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.2.0.tgz", + "integrity": "sha512-vR2B6HKIhaBjcZr2bLpFiJ1VbzOlRQ7aby4/gw5WPIzToLjqpfWw3VJ4sk1uDchoOODEirvO2jyrSPtUSL5CrQ==", + "license": "Apache-2.0", + "dependencies": { + "acorn": "^8.15.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/impound": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/impound/-/impound-1.1.5.tgz", @@ -9998,9 +10717,9 @@ } }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -10836,6 +11555,15 @@ "node": ">= 8" } }, + "node_modules/meriyah": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/meriyah/-/meriyah-6.1.4.tgz", + "integrity": "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ==", + "license": "ISC", + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/methods": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", @@ -10999,6 +11727,12 @@ "integrity": "sha512-aF7yRQr/Q0O2/4pIXm6PZ5G+jAd7QS4Yu8m+WEeEHGnbo+7mE36CbLSDQiXYV8bVL3NfmdeqPJct0tUlnjVSnA==", "license": "MIT" }, + "node_modules/module-details-from-path": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", + "integrity": "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==", + "license": "MIT" + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -13214,13 +13948,13 @@ } }, "node_modules/pkg-types": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz", - "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", "license": "MIT", "dependencies": { - "confbox": "^0.2.2", - "exsolve": "^1.0.7", + "confbox": "^0.2.4", + "exsolve": "^1.0.8", "pathe": "^2.0.3" } }, @@ -13949,6 +14683,15 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/progress": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", + "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/prosemirror-changeset": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz", @@ -14118,6 +14861,12 @@ "dev": true, "license": "ISC" }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -14515,6 +15264,19 @@ "node": ">=0.10.0" } }, + "node_modules/require-in-the-middle": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/require-in-the-middle/-/require-in-the-middle-8.0.1.tgz", + "integrity": "sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.3.5", + "module-details-from-path": "^1.0.3" + }, + "engines": { + "node": ">=9.3.0 || >=8.10.0 <9.0.0" + } + }, "node_modules/reserved-identifiers": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz", @@ -14838,10 +15600,16 @@ "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", "license": "MIT" }, + "node_modules/semifies": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semifies/-/semifies-1.0.0.tgz", + "integrity": "sha512-xXR3KGeoxTNWPD4aBvL5NUpMTT7WMANr3EWnaS190QVkY52lqqcVRD7Q05UVbBhiWDGWMlJEUam9m7uFFGVScw==", + "license": "Apache-2.0" + }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -15795,13 +16563,13 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -16021,9 +16789,9 @@ "license": "MIT" }, "node_modules/ufo": { - "version": "1.6.3", - "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.3.tgz", - "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz", + "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", "license": "MIT" }, "node_modules/ultrahtml": { diff --git a/frontend/package.json b/frontend/package.json index d0cd673..d660e96 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "@nuxtjs/i18n": "^10.2.3", "@nuxtjs/tailwindcss": "^6.14.0", "@pinia/nuxt": "^0.11.3", + "@sentry/nuxt": "^10.62.0", "nuxt": "^4.3.1", "nuxt-toast": "^1.4.0", "pinia": "^3.0.4", diff --git a/frontend/sentry.client.config.ts b/frontend/sentry.client.config.ts new file mode 100644 index 0000000..2ba71e9 --- /dev/null +++ b/frontend/sentry.client.config.ts @@ -0,0 +1,18 @@ +import * as Sentry from '@sentry/nuxt' + +// Init Sentry cote client (SPA). Le DSN provient du build prod (NUXT_PUBLIC_SENTRY_DSN). +// Si le DSN est vide (dev), Sentry.init est un no-op : rien n'est envoye. +const config = useRuntimeConfig() +const dsn = config.public.sentry?.dsn + +if (dsn) { + Sentry.init({ + dsn, + environment: config.public.sentry?.environment, + // Pas d'APM/tracing (hors perimetre) : on ne remonte que les erreurs. + tracesSampleRate: 0, + // Pas de session replay (volume). + replaysSessionSampleRate: 0, + replaysOnErrorSampleRate: 0, + }) +} diff --git a/infra/prod/.env.prod.example b/infra/prod/.env.prod.example index 41c333f..a87ee59 100644 --- a/infra/prod/.env.prod.example +++ b/infra/prod/.env.prod.example @@ -11,3 +11,7 @@ JWT_TOKEN_TTL=86400 JWT_COOKIE_TTL=86400 CORS_ALLOW_ORIGIN='^http://starseed\.malio-dev\.fr$' + +# Sentry / GlitchTip — error tracking backend (projet "starseed-api"). +# Runtime, prod only. Vide/absent => SDK inerte (rien envoye). +# SENTRY_DSN=https://@:/ diff --git a/infra/prod/Dockerfile b/infra/prod/Dockerfile index 42bc406..1b56ed1 100644 --- a/infra/prod/Dockerfile +++ b/infra/prod/Dockerfile @@ -30,21 +30,42 @@ COPY frontend/package.json frontend/package-lock.json ./ RUN npm ci COPY frontend/ ./ + +# Error tracking → GlitchTip (build-time). Vides par defaut => module Sentry inerte +# et aucun upload de source maps. Fournis par la CI via --build-arg (secrets Gitea). +# Passes en prefixe inline du RUN (pas en ENV) pour ne pas persister le token dans +# une couche d'image. +ARG NUXT_PUBLIC_SENTRY_DSN="" +ARG SENTRY_URL="" +ARG SENTRY_ORG="" +ARG SENTRY_PROJECT="" +ARG SENTRY_AUTH_TOKEN="" + ENV CI=1 \ NUXT_TELEMETRY_DISABLED=1 \ NUXT_PUBLIC_API_BASE=/api \ NUXT_PUBLIC_APP_BASE=/ -RUN npm run generate +RUN NUXT_PUBLIC_SENTRY_DSN="$NUXT_PUBLIC_SENTRY_DSN" \ + SENTRY_URL="$SENTRY_URL" \ + SENTRY_ORG="$SENTRY_ORG" \ + SENTRY_PROJECT="$SENTRY_PROJECT" \ + SENTRY_AUTH_TOKEN="$SENTRY_AUTH_TOKEN" \ + npm run generate # --- Stage 3: Production image --- FROM php:8.4-fpm AS production RUN apt-get update && apt-get install -y \ libicu-dev libpq-dev libpng-dev libzip-dev libxml2-dev \ - nginx supervisor \ + nginx supervisor ca-certificates \ && docker-php-ext-install -j$(nproc) intl pdo_pgsql zip gd opcache \ && rm -rf /var/lib/apt/lists/* +# CA racine interne MALIO (auto-signee) — permet au SDK Sentry/HttpClient de +# joindre les services HTTPS internes (ex. GlitchTip sur logs.malio-dev.fr). +COPY infra/prod/malio-dev-root-ca.crt /usr/local/share/ca-certificates/malio-dev-root-ca.crt +RUN update-ca-certificates + # PHP production config RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" diff --git a/infra/prod/malio-dev-root-ca.crt b/infra/prod/malio-dev-root-ca.crt new file mode 100644 index 0000000..087d73d --- /dev/null +++ b/infra/prod/malio-dev-root-ca.crt @@ -0,0 +1,31 @@ +-----BEGIN CERTIFICATE----- +MIIFZzCCA0+gAwIBAgIUOiZigxwgIgtLipnLnu4eSgItc5MwDQYJKoZIhvcNAQEL +BQAwQzELMAkGA1UEBhMCRlIxEjAQBgNVBAoMCU1BTElPLURFVjEgMB4GA1UEAwwX +TUFMSU8tREVWIExvY2FsIFJvb3QgQ0EwHhcNMjYwNjI1MTYxMjIwWhcNMzYwNjIy +MTYxMjIwWjBDMQswCQYDVQQGEwJGUjESMBAGA1UECgwJTUFMSU8tREVWMSAwHgYD +VQQDDBdNQUxJTy1ERVYgTG9jYWwgUm9vdCBDQTCCAiIwDQYJKoZIhvcNAQEBBQAD +ggIPADCCAgoCggIBALqHXVWEae9aKtveLfSpxYy9RS0Aslw2Ls9+LWI33lpMRs02 +QssE9wquf3WGjz8NnHUWl5RM0QHC0DOCCddcbnRBciDRJeTaU43IGdNg+TSY+7aM +3t/jysZrpc/eu/udlIs7npCPaOGnRiuGN68Fkf9Q70FtmaASpusUe7J3jKDinznr +R2hARplO4OF01tFauu039A4yudLrZTUFTldicuZ6a5U3NhajgfNZA+pyJqvL3tLT +lXG3KupPD9BsbWe4zSM96CmyHM22QNlcL+M5XG5+EtDtM07tkDcyxFOsREjQHvSQ +NH+7h6G/QBHHKkYJhdyiuvpj6b5tEJBM2PVgy1T2JX5TuOBOLx6HvHLbNjUY/JI5 +0sIjnHbeybQCOfnKNAwidtnqjAfVg+XJ9UZCiGJOeRJOdN5isvvqEKydsX4ouCTj +89kwBbfCJeCS6BiadvNFUwnM0PksV0ovnOiUEEAPHRiP74jZ3IvH95BEwiZzyLpy +tXiJMW7cJMaqlT3jNwq3P00irfrpJNy4S1Mg2cBQh5ucv+PcMBfQT8YiarzlTQJo +saksh/2C43WH+qIFAL2aeD+rKReVBZcGa1XOBI8FUJTu3rLd37+iS4N2BUKq4fWo +FttuX5NOfeU3BRDLlCJ2AXau7o0czVy896R9iZTfBJC95QWD07PdHgoctuexAgMB +AAGjUzBRMB0GA1UdDgQWBBRNU0WsMg/pqo5XF/WXx78GrAzD5TAfBgNVHSMEGDAW +gBRNU0WsMg/pqo5XF/WXx78GrAzD5TAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3 +DQEBCwUAA4ICAQBFXsuT7Rm2oJBlWT/RsJtmWr95NoFLHovVDycgM8Vjm+E8hv/m +AcSjPjZDmXQLOrN31T/XUAs0nURHxSFgVzdIKpq2gOlGgHkZRMAW/iTON9Cqjn81 +Arjp5fjAJyFkoCiT3eTOElpteF4NhL8xMFaOg1Y2CEfOYO9OZR7Z38HdB6IArVwr +W3Dxq3DPtarCeo1k8SHJmJzUduYCltV8urB43gIiI2Hqd7aAlpkTfDhruKxxr7sJ +3/TpemJDCN9m8XMv2QvxqpMwH6EXg/7oqit5k0MvD445f3xt9vZydmV/x6F7u/A/ +gJitN+ixA4AKv7Lw210vaupiChqdY+78TXgLoPJ2/l2QPWG/R7Fb4yNZ2rEd6lyt +KLPxHDcdZetFnyqyaoB2SNtLx9hNUE5G3udU6DkNhDfQlDhqEG4f7GAInOu/cMWE +2uiIUEjcGSLM+XrrTFRc1tdXy6hnu+sw5ckvhwJ+kjah/pVGz21/y5a0p42AUznI +iN7HBV8YaSkeJLvBPnfakUAat1R98e0l72DucHe8RF44NmZCywpaUBsTpNy+bO2f +atqp4/ZEGJJlJ38rLv9bAuwr6d8x6T+m0oHknqtJHcWfO0kr4l3Lxsd8mRpGgmBe +zOjqjrat4vSc04Rqic4UV2IEoWCiSS/TSiBx8JAB6Ck0+YR9dUgXVQsFFg== +-----END CERTIFICATE----- diff --git a/symfony.lock b/symfony.lock index 7d9505d..6ec2714 100644 --- a/symfony.lock +++ b/symfony.lock @@ -124,6 +124,15 @@ "bin/phpunit" ] }, + "sentry/sentry-symfony": { + "version": "5.10", + "recipe": { + "repo": "github.com/symfony/recipes-contrib", + "branch": "main", + "version": "5.0", + "ref": "aac2bc5220e9ab5b9e3838a7a4da90e7f74e6148" + } + }, "symfony/console": { "version": "8.0", "recipe": { From 024c20b964f3c32e401e0c079d8bb895cba4cd91 Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Mon, 29 Jun 2026 15:45:11 +0000 Subject: [PATCH 08/11] chore: bump version to v0.1.157 --- config/version.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/version.yaml b/config/version.yaml index 53c1282..c511f4b 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,2 +1,2 @@ parameters: - app.version: '0.1.156' + app.version: '0.1.157' From 7075f0f95d365f540b74c76c66345c4f7825c3a9 Mon Sep 17 00:00:00 2001 From: tristan Date: Mon, 29 Jun 2026 18:01:54 +0200 Subject: [PATCH 09/11] =?UTF-8?q?fix(catalog)=20:=20M7=20=E2=80=94=20durci?= =?UTF-8?q?ssement=20stockages=20(=C3=A9tats=20JSONB=20s=C3=A9quentiels=20?= =?UTF-8?q?+=20Assert\Unique,=20neutralisation=20injection=20formules=20XL?= =?UTF-8?q?SX=20partag=C3=A9e,=20parit=C3=A9=20listing/export=20via=20Stor?= =?UTF-8?q?ageListFilters,=20streaming=20export)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Storage.setStates() renormalise en liste séquentielle (array_values) : un states posté en objet JSON ne peut plus être persisté en JSONB objet (jsonb_array_length → 500). Doublons rejetés en 422 via Assert\Unique. - PhpSpreadsheetExporter écrit les cellules chaîne en TYPE_STRING explicite : neutralise l'injection de formules/DDE sur toutes les valeurs saisies (corrige aussi Produit/Client/Logistique/Supplier/Provider/Carrier). - StorageListFilters : source unique de parsing des filtres (?search, ?siteId[], ?storageTypeId, ?state), consommée par le provider ET l'export → fin des divergences (numéro « 0 » coercé à null, param tableau en 400, id non positif). - Export en streaming (toIterable + clear par lot) au lieu de getResult() : mémoire bornée. - Tests : doublon/objet states, normalisation trim RG-7.06, 422 relations nulles, absence de deletedAt, soft-delete liste discriminant, neutralisation formule, parité ?search=0, robustesse param tableau ; garde-fou Assert\Unique enregistré. --- .../Application/Filter/StorageListFilters.php | 131 ++++++++++++++++++ src/Module/Catalog/Domain/Entity/Storage.php | 8 +- .../State/Provider/StorageProvider.php | 101 ++------------ .../Controller/StorageExportController.php | 90 ++++-------- .../Export/PhpSpreadsheetExporter.php | 35 ++++- ...EntityConstraintsHaveFrenchMessageTest.php | 1 + .../Api/StorageExportControllerTest.php | 44 ++++++ .../Api/StorageSerializationContractTest.php | 20 ++- .../Api/StorageStatesValidationTest.php | 36 +++++ .../Api/StorageWriteValidationTest.php | 83 +++++++++++ 10 files changed, 392 insertions(+), 157 deletions(-) create mode 100644 src/Module/Catalog/Application/Filter/StorageListFilters.php create mode 100644 tests/Module/Catalog/Api/StorageWriteValidationTest.php diff --git a/src/Module/Catalog/Application/Filter/StorageListFilters.php b/src/Module/Catalog/Application/Filter/StorageListFilters.php new file mode 100644 index 0000000..c5a4396 --- /dev/null +++ b/src/Module/Catalog/Application/Filter/StorageListFilters.php @@ -0,0 +1,131 @@ + zero drift, chaque nouveau filtre se branche en un seul endroit. + */ +final readonly class StorageListFilters +{ + /** Etats valides du filtre ?state= (enum borne, RG-7.04). */ + private const array VALID_STATES = [ + Storage::STATE_RECEPTION, + Storage::STATE_PRODUCTION, + Storage::STATE_TRIAGE, + ]; + + /** + * @param list $siteIds + */ + private function __construct( + public ?string $search, + public array $siteIds, + public ?int $storageTypeId, + public ?string $state, + ) {} + + /** + * Construit les filtres depuis une source brute : le `$context['filters']` + * d'API Platform cote provider, ou `$request->query->all()` cote controller + * d'export. Tolere scalaire ou tableau, ignore les entrees invalides — jamais + * d'exception sur une saisie malformee (ex: `?search[]=x`). + * + * @param array $query + */ + public static function fromQuery(array $query): self + { + return new self( + self::readSearch($query['search'] ?? null), + self::readSiteIds($query['siteId'] ?? null), + self::readPositiveInt($query['storageTypeId'] ?? null), + self::readState($query['state'] ?? null), + ); + } + + /** + * Recherche partielle sur numero : valeur trimmee, ou null si absente / vide. + * La chaine « 0 » est un numero valide (VARCHAR) et N'EST PAS coercee a null. + */ + private static function readSearch(mixed $raw): ?string + { + if (!is_string($raw)) { + return null; + } + + $raw = trim($raw); + + return '' === $raw ? null : $raw; + } + + /** + * Liste d'identifiants de sites (OR). Tolere une valeur scalaire unique + * (`?siteId=1`) ou un tableau (`?siteId[]=1&siteId[]=2`), dedup, ordre stable. + * + * @return list + */ + private static function readSiteIds(mixed $raw): array + { + if (null === $raw) { + return []; + } + + $values = is_array($raw) ? $raw : [$raw]; + + $ids = []; + foreach ($values as $value) { + $id = self::readPositiveInt($value); + if (null !== $id) { + $ids[] = $id; + } + } + + return array_values(array_unique($ids)); + } + + /** + * Identifiant entier STRICTEMENT POSITIF (un id metier l'est toujours) ou null. + * Un 0 ou un negatif est traite comme « pas de filtre », jamais comme un id + * impossible (qui renverrait une liste vide cote provider mais tout cote export). + */ + private static function readPositiveInt(mixed $raw): ?int + { + if (is_int($raw)) { + return $raw > 0 ? $raw : null; + } + + return is_string($raw) && ctype_digit($raw) && (int) $raw > 0 ? (int) $raw : null; + } + + /** + * Filtre ?state= : normalise en majuscules, n'accepte qu'une valeur de l'enum + * borne {RECEPTION, PRODUCTION, TRIAGE} ; toute autre valeur est ignoree (null). + */ + private static function readState(mixed $raw): ?string + { + if (!is_string($raw) || '' === trim($raw)) { + return null; + } + + $state = mb_strtoupper(trim($raw), 'UTF-8'); + + return in_array($state, self::VALID_STATES, true) ? $state : null; + } +} diff --git a/src/Module/Catalog/Domain/Entity/Storage.php b/src/Module/Catalog/Domain/Entity/Storage.php index 5eafb19..6933e47 100644 --- a/src/Module/Catalog/Domain/Entity/Storage.php +++ b/src/Module/Catalog/Domain/Entity/Storage.php @@ -158,6 +158,7 @@ class Storage implements TimestampableInterface, BlamableInterface // qui casse le CHECK et fait echouer make test-db-setup (cf. Product::states). #[ORM\Column(type: 'json', options: ['jsonb' => true])] #[Assert\Count(min: 1, minMessage: 'Sélectionnez au moins un état.')] + #[Assert\Unique(message: 'Chaque état ne peut être sélectionné qu\'une seule fois.')] #[Assert\Choice( choices: [self::STATE_RECEPTION, self::STATE_PRODUCTION, self::STATE_TRIAGE], multiple: true, @@ -230,7 +231,12 @@ class Storage implements TimestampableInterface, BlamableInterface */ public function setStates(array $states): static { - $this->states = $states; + // `array_values` reseque toujours un tableau SEQUENTIEL : une saisie cliente + // malformee (objet JSON `{"x":"RECEPTION"}` denormalise en tableau associatif) + // ne peut plus etre persistee comme un objet JSONB, ce qui ferait echouer le + // CHECK chk_storage_states_not_empty (jsonb_array_length sur non-tableau) en + // 500. Les doublons eventuels restent rejetes en 422 par Assert\Unique. + $this->states = array_values($states); return $this; } diff --git a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/StorageProvider.php b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/StorageProvider.php index 189098f..9b8fe50 100644 --- a/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/StorageProvider.php +++ b/src/Module/Catalog/Infrastructure/ApiPlatform/State/Provider/StorageProvider.php @@ -9,12 +9,12 @@ use ApiPlatform\Metadata\CollectionOperationInterface; use ApiPlatform\Metadata\Operation; use ApiPlatform\State\Pagination\Pagination; use ApiPlatform\State\ProviderInterface; +use App\Module\Catalog\Application\Filter\StorageListFilters; use App\Module\Catalog\Domain\Entity\Storage; use App\Module\Catalog\Domain\Repository\StorageRepositoryInterface; use Doctrine\ORM\Tools\Pagination\Paginator as DoctrinePaginator; use Symfony\Component\DependencyInjection\Attribute\Autowire; -use function in_array; use function is_int; use function is_string; @@ -22,8 +22,9 @@ use function is_string; * Provider Storage (lecture, ERP-213) : * - LISTE : exclut par defaut les stockages soft-deleted (RG-7.07), trie par * site.code ASC, storageType.label ASC, numero ASC (defaut spec § 4.1), applique - * les filtres (?search sur numero, ?siteId[], ?storageTypeId, ?state) et renvoie - * une collection PAGINEE Hydra (regle ABSOLUE n°13 : jamais d'array brut sur une + * les filtres (?search sur numero, ?siteId[], ?storageTypeId, ?state — parses par + * {@see StorageListFilters}, source partagee avec l'export) et renvoie une + * collection PAGINEE Hydra (regle ABSOLUE n°13 : jamais d'array brut sur une * operation de collection — on enveloppe le QueryBuilder dans le Paginator ORM). * Echappatoire ?pagination=false respectee (alimentation d'un select). * - ITEM : recharge le stockage puis renvoie null (404) s'il est soft-deleted — le @@ -33,13 +34,6 @@ use function is_string; */ final class StorageProvider implements ProviderInterface { - /** Etats valides du filtre ?state= (enum borne, RG-7.04). */ - private const array VALID_STATES = [ - Storage::STATE_RECEPTION, - Storage::STATE_PRODUCTION, - Storage::STATE_TRIAGE, - ]; - public function __construct( #[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageRepository')] private readonly StorageRepositoryInterface $repository, @@ -49,13 +43,16 @@ final class StorageProvider implements ProviderInterface public function provide(Operation $operation, array $uriVariables = [], array $context = []): iterable|Paginator|Storage|null { if ($operation instanceof CollectionOperationInterface) { + // Filtres parses par la source partagee avec l'export (parite garantie). + $filters = StorageListFilters::fromQuery($context['filters'] ?? []); + // includeDeleted toujours false : le soft-delete n'est pas expose (§ 2.8). $qb = $this->repository->createListQueryBuilder( false, - $this->readSearch($context), - $this->readSiteIds($context), - $this->readStorageTypeId($context), - $this->readState($context), + $filters->search, + $filters->siteIds, + $filters->storageTypeId, + $filters->state, ); // Echappatoire ?pagination=false : collection complete sans Paginator. @@ -93,80 +90,4 @@ final class StorageProvider implements ProviderInterface return $storage; } - - /** - * Lit le filtre `?search=` (recherche partielle sur numero). Renvoie la valeur - * trimmee ou null si absente / vide. - */ - private function readSearch(array $context): ?string - { - $raw = $context['filters']['search'] ?? null; - - if (!is_string($raw)) { - return null; - } - - $raw = trim($raw); - - return '' === $raw ? null : $raw; - } - - /** - * Lit le filtre `?siteId[]=` : ids des sites coches (OR). Tolere une valeur - * scalaire unique (`?siteId=1`) ou un tableau. Ignore les entrees non numeriques. - * - * @return list - */ - private function readSiteIds(array $context): array - { - $raw = $context['filters']['siteId'] ?? null; - - if (null === $raw) { - return []; - } - - $values = is_array($raw) ? $raw : [$raw]; - - $ids = []; - foreach ($values as $value) { - if (is_int($value) || (is_string($value) && ctype_digit($value))) { - $ids[] = (int) $value; - } - } - - return array_values(array_unique($ids)); - } - - /** - * Lit le filtre `?storageTypeId=` (drawer « Filtrer »). Renvoie l'id entier ou - * null si absent / non numerique. - */ - private function readStorageTypeId(array $context): ?int - { - $raw = $context['filters']['storageTypeId'] ?? null; - - if (is_int($raw)) { - return $raw; - } - - return is_string($raw) && ctype_digit($raw) ? (int) $raw : null; - } - - /** - * Lit le filtre `?state=` (RECEPTION / PRODUCTION / TRIAGE). Normalise en - * majuscules et n'accepte qu'une valeur de l'enum borne ; toute autre valeur est - * ignoree (null). - */ - private function readState(array $context): ?string - { - $raw = $context['filters']['state'] ?? null; - - if (!is_string($raw) || '' === trim($raw)) { - return null; - } - - $state = mb_strtoupper(trim($raw), 'UTF-8'); - - return in_array($state, self::VALID_STATES, true) ? $state : null; - } } diff --git a/src/Module/Catalog/Infrastructure/Controller/StorageExportController.php b/src/Module/Catalog/Infrastructure/Controller/StorageExportController.php index e45e363..e116dca 100644 --- a/src/Module/Catalog/Infrastructure/Controller/StorageExportController.php +++ b/src/Module/Catalog/Infrastructure/Controller/StorageExportController.php @@ -4,11 +4,13 @@ declare(strict_types=1); namespace App\Module\Catalog\Infrastructure\Controller; +use App\Module\Catalog\Application\Filter\StorageListFilters; use App\Module\Catalog\Domain\Entity\Storage; use App\Module\Catalog\Domain\Repository\StorageRepositoryInterface; use App\Module\Sites\Domain\Entity\Site; use App\Shared\Domain\Contract\SpreadsheetExporterInterface; use DateTimeImmutable; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -17,8 +19,6 @@ use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; use function in_array; -use function is_int; -use function is_string; /** * Export XLSX de la liste des stockages (M7, spec-back § 4.5). Jumeau du @@ -57,10 +57,17 @@ final class StorageExportController Storage::STATE_TRIAGE => 'Triage', ]; + /** + * Taille du lot avant `EntityManager::clear()` pendant le streaming des lignes : + * borne la memoire (identity map) sur un gros export sans tout materialiser. + */ + private const int EXPORT_BATCH_SIZE = 200; + public function __construct( #[Autowire(service: 'App\Module\Catalog\Infrastructure\Doctrine\DoctrineStorageRepository')] private readonly StorageRepositoryInterface $repository, private readonly SpreadsheetExporterInterface $exporter, + private readonly EntityManagerInterface $em, ) {} #[Route('/api/storages/export.xlsx', name: 'catalog_storages_export_xlsx', methods: ['GET'], priority: 1)] @@ -69,18 +76,18 @@ final class StorageExportController { // Memes filtres que la vue liste (StorageProvider) pour que l'export reflete // exactement ce que l'utilisateur voit a l'ecran : recherche (?search sur - // numero), sites (?siteId[]), type (?storageTypeId), etat (?state). + // numero), sites (?siteId[]), type (?storageTypeId), etat (?state). Parses par + // la MEME source que le provider ({@see StorageListFilters}) -> aucune + // divergence possible (numero « 0 », parametre tableau, id non positif). // includeDeleted reste false : le soft-delete n'est jamais expose (§ 2.8). - $search = $request->query->getString('search') ?: null; - $siteIds = $this->readIntList($request->query->all()['siteId'] ?? []); - $storageTypeId = $this->readIntOrNull($request->query->get('storageTypeId')); - $state = $this->readState($request->query->get('state')); + $filters = StorageListFilters::fromQuery($request->query->all()); - /** @var list $storages */ + // Streaming via toIterable() : on ne materialise pas toute la table en memoire + // (cf. buildRows + EXPORT_BATCH_SIZE) avant de construire le classeur. $storages = $this->repository - ->createListQueryBuilder(false, $search, $siteIds, $storageTypeId, $state) + ->createListQueryBuilder(false, $filters->search, $filters->siteIds, $filters->storageTypeId, $filters->state) ->getQuery() - ->getResult() + ->toIterable() ; $binary = $this->exporter->export( @@ -111,12 +118,18 @@ final class StorageExportController } /** - * @param list $storages + * Mappe chaque stockage en ligne d'export, en consommant un iterable paresseux + * (Doctrine `toIterable()`). Toutes les N lignes (EXPORT_BATCH_SIZE), on vide + * l'identity map (`clear()`) pour borner la memoire sur un gros export — sans + * danger ici, le controller ne fait que lire. + * + * @param iterable $storages * * @return iterable> */ - private function buildRows(array $storages): iterable + private function buildRows(iterable $storages): iterable { + $count = 0; foreach ($storages as $storage) { yield [ $storage->getDisplayName(), @@ -127,6 +140,10 @@ final class StorageExportController $this->formatDate($storage->getCreatedAt()), $this->formatDate($storage->getUpdatedAt()), ]; + + if (0 === ++$count % self::EXPORT_BATCH_SIZE) { + $this->em->clear(); + } } } @@ -182,53 +199,4 @@ final class StorageExportController return $response; } - - /** - * Lit le filtre `?state=` comme le StorageProvider : normalise en majuscules et - * n'accepte qu'une valeur de l'enum borne {RECEPTION, PRODUCTION, TRIAGE} ; - * toute autre valeur est ignoree (null). - */ - private function readState(mixed $raw): ?string - { - if (!is_string($raw) || '' === trim($raw)) { - return null; - } - - $state = mb_strtoupper(trim($raw), 'UTF-8'); - - return in_array($state, array_keys(self::STATE_LABELS), true) ? $state : null; - } - - /** - * Lit un identifiant entier positif unique (`?storageTypeId=`). Aligne sur - * StorageProvider (tolere int ou chaine numerique). - */ - private function readIntOrNull(mixed $raw): ?int - { - if (is_int($raw)) { - return $raw > 0 ? $raw : null; - } - - return is_string($raw) && ctype_digit($raw) && (int) $raw > 0 ? (int) $raw : null; - } - - /** - * Normalise un filtre en liste d'identifiants entiers positifs (valeur unique - * ou liste, `?siteId[]=`). Aligne sur StorageProvider. - * - * @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/Shared/Infrastructure/Export/PhpSpreadsheetExporter.php b/src/Shared/Infrastructure/Export/PhpSpreadsheetExporter.php index b479cb1..079c9a1 100644 --- a/src/Shared/Infrastructure/Export/PhpSpreadsheetExporter.php +++ b/src/Shared/Infrastructure/Export/PhpSpreadsheetExporter.php @@ -5,10 +5,15 @@ declare(strict_types=1); namespace App\Shared\Infrastructure\Export; use App\Shared\Domain\Contract\SpreadsheetExporterInterface; +use PhpOffice\PhpSpreadsheet\Cell\Coordinate; +use PhpOffice\PhpSpreadsheet\Cell\DataType; use PhpOffice\PhpSpreadsheet\Spreadsheet; +use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use PhpOffice\PhpSpreadsheet\Writer\Xlsx; use RuntimeException; +use function is_string; + /** * Implementation XLSX du contrat d'export via la librairie PhpSpreadsheet. * @@ -31,19 +36,45 @@ final class PhpSpreadsheetExporter implements SpreadsheetExporterInterface $sheet->setTitle($this->sanitizeSheetTitle($sheetTitle)); // Ligne 1 : en-tete. - $sheet->fromArray($headers, null, 'A1'); + $this->writeRow($sheet, $headers, 1); // Lignes 2..n : donnees. Iteration manuelle pour supporter un iterable // paresseux (generator) sans tout materialiser en memoire. $rowNumber = 2; foreach ($rows as $row) { - $sheet->fromArray($row, null, 'A'.$rowNumber); + $this->writeRow($sheet, $row, $rowNumber); ++$rowNumber; } return $this->toBinary($spreadsheet); } + /** + * Ecrit une ligne cellule par cellule. Toute valeur CHAINE est ecrite en type + * STRING explicite (jamais interpretee comme formule), ce qui neutralise + * l'injection de formules / DDE (« CSV / Formula injection ») : une cellule dont + * la valeur commence par `=` `+` `-` `@` (saisie utilisateur, ex. un numero) n'est + * pas evaluee a l'ouverture du fichier, et ce SANS apostrophe visible. Les valeurs + * non-chaines (int / float / null) gardent leur type naturel. + * + * @param list $row + */ + private function writeRow(Worksheet $sheet, array $row, int $rowNumber): void + { + $column = 1; + foreach ($row as $value) { + $coordinate = Coordinate::stringFromColumnIndex($column).$rowNumber; + + if (is_string($value)) { + $sheet->setCellValueExplicit($coordinate, $value, DataType::TYPE_STRING); + } else { + $sheet->setCellValue($coordinate, $value); + } + + ++$column; + } + } + private function toBinary(Spreadsheet $spreadsheet): string { $writer = new Xlsx($spreadsheet); diff --git a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php index 876aa48..dffa8ec 100644 --- a/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php +++ b/tests/Architecture/EntityConstraintsHaveFrenchMessageTest.php @@ -92,6 +92,7 @@ final class EntityConstraintsHaveFrenchMessageTest extends TestCase Assert\NotNull::class, Assert\Email::class, Assert\Choice::class, + Assert\Unique::class, Assert\Regex::class, Assert\Bic::class, Assert\Iban::class, diff --git a/tests/Module/Catalog/Api/StorageExportControllerTest.php b/tests/Module/Catalog/Api/StorageExportControllerTest.php index a161a55..828a82c 100644 --- a/tests/Module/Catalog/Api/StorageExportControllerTest.php +++ b/tests/Module/Catalog/Api/StorageExportControllerTest.php @@ -138,6 +138,50 @@ final class StorageExportControllerTest extends AbstractStorageApiTestCase self::assertMatchesRegularExpression('#^\d{2}/\d{2}/\d{4} \d{2}:\d{2}$#', (string) $row[6]); } + public function testFormulaInjectionIsNeutralized(): void + { + $client = $this->createAdminClient(); + + // Numero malicieux commencant par « = » (injection de formule / DDE). Seede en + // direct (le numero contournerait de toute facon le normalizer, qui ne fait + // qu'un trim). L'export doit le restituer comme TEXTE litteral, jamais comme + // une formule evaluee : si la cellule etait une formule, IOFactory::load la + // calculerait (resultat 3 ou erreur) et « =1+2 » serait absent de la colonne. + $this->seedStorageEntity('=1+2'); + + $numeros = $this->numeros($client->request('GET', self::EXPORT_URL)->getContent()); + + self::assertContains('=1+2', $numeros, 'Le numero « =1+2 » doit etre stocke en texte, pas evalue.'); + } + + public function testExportKeepsSearchTermZero(): void + { + $client = $this->createAdminClient(); + $this->seedStorageEntity('0'); + $this->seedStorageEntity('X1'); + + // « 0 » est un numero valide : le filtre ?search=0 NE DOIT PAS etre coerce a + // null (parite stricte avec la liste a l'ecran via StorageListFilters). + $numeros = $this->numeros($client->request('GET', self::EXPORT_URL.'?search=0')->getContent()); + + self::assertContains('0', $numeros); + self::assertNotContains('X1', $numeros); + } + + public function testExportToleratesArrayShapedScalarParam(): void + { + $client = $this->createAdminClient(); + $this->seedStorageEntity('NUM-ARR'); + + // ?search[]=foo : parametre tableau la ou un scalaire est attendu. L'export ne + // doit pas planter en 400 (la liste le tolere) : la valeur est simplement + // ignoree -> 200 avec tous les stockages. + $response = $client->request('GET', self::EXPORT_URL.'?search[]=foo'); + + self::assertResponseIsSuccessful(); + self::assertContains('NUM-ARR', $this->numeros($response->getContent())); + } + public function testForbiddenWithoutStoragesViewPermission(): void { $creds = $this->createUserWithPermission('core.users.view'); diff --git a/tests/Module/Catalog/Api/StorageSerializationContractTest.php b/tests/Module/Catalog/Api/StorageSerializationContractTest.php index 6a7e323..da32595 100644 --- a/tests/Module/Catalog/Api/StorageSerializationContractTest.php +++ b/tests/Module/Catalog/Api/StorageSerializationContractTest.php @@ -80,6 +80,15 @@ final class StorageSerializationContractTest extends AbstractStorageApiTestCase self::assertArrayHasKey('displayName', $row); self::assertSame('Cellule '.$numero, $row['displayName']); + // === Piege #5 : le soft-delete n'est JAMAIS expose (§ 2.8) === + // `deletedAt` n'appartient a aucun groupe de lecture : un test « contrat » doit + // garantir son ABSENCE, pas seulement la presence des champs attendus — sinon + // un ajout accidentel a storage:read passerait au vert. (createdBy/updatedBy + // sont, eux, exposes a dessein via la convention `default:read` du Trait + // Timestampable/Blamable — au meme titre que createdAt/updatedAt.) + self::assertArrayNotHasKey('deletedAt', $row, 'deletedAt ne doit pas etre expose en liste (§ 2.8).'); + self::assertArrayNotHasKey('deletedAt', $detail, 'deletedAt ne doit pas etre expose en detail (§ 2.8).'); + // === DETAIL : memes garanties d'embarquement === self::assertIsArray($detail['site']); self::assertArrayHasKey('name', $detail['site']); @@ -93,10 +102,14 @@ final class StorageSerializationContractTest extends AbstractStorageApiTestCase /** * RG-7.07 : la liste (et le detail) n'exposent JAMAIS un stockage soft-deleted. + * On seede 1 actif + 1 supprime pour que l'assertion de liste soit discriminante + * (sinon, avec une collection vide, « absent » ne distingue pas l'exclusion du + * soft-delete d'une page vide). */ public function testSoftDeletedIsNotExposed(): void { - $deleted = $this->seedStorageEntity('SD', deletedAt: new DateTimeImmutable()); + $active = $this->seedStorageEntity('SD-ACTIVE'); + $deleted = $this->seedStorageEntity('SD-DELETED', deletedAt: new DateTimeImmutable()); $client = $this->createAdminClient(); @@ -104,9 +117,10 @@ final class StorageSerializationContractTest extends AbstractStorageApiTestCase $client->request('GET', '/api/storages/'.$deleted->getId(), ['headers' => ['Accept' => self::LD]]); self::assertResponseStatusCodeSame(404); - // … et absent de la collection (RG-7.07). + // Collection : l'actif est present, le supprime est absent (RG-7.07). $list = $client->request('GET', '/api/storages', ['headers' => ['Accept' => self::LD]])->toArray(); - self::assertNull($this->memberById($list, (int) $deleted->getId())); + self::assertNotNull($this->memberById($list, (int) $active->getId()), 'Le stockage actif doit etre liste.'); + self::assertNull($this->memberById($list, (int) $deleted->getId()), 'Le stockage soft-deleted ne doit pas etre liste.'); } /** diff --git a/tests/Module/Catalog/Api/StorageStatesValidationTest.php b/tests/Module/Catalog/Api/StorageStatesValidationTest.php index 12fa054..a3c4fdb 100644 --- a/tests/Module/Catalog/Api/StorageStatesValidationTest.php +++ b/tests/Module/Catalog/Api/StorageStatesValidationTest.php @@ -72,4 +72,40 @@ final class StorageStatesValidationTest extends AbstractStorageApiTestCase self::assertResponseStatusCodeSame(422); self::assertContains('states', $this->violationPaths($response)); } + + public function testDuplicateStatesAreRejected(): void + { + $client = $this->createAdminClient(); + + // Doublon dans le multi-select : 422 (Assert\Unique), pas un stockage avec un + // tableau d'etats incoherent (RG-7.04 = sous-ensemble). + $response = $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validStoragePayload([ + 'states' => [Storage::STATE_TRIAGE, Storage::STATE_TRIAGE], + ]), + ]); + + self::assertResponseStatusCodeSame(422); + self::assertContains('states', $this->violationPaths($response)); + } + + public function testNonSequentialStatesDoNotCrash(): void + { + $client = $this->createAdminClient(); + + // `states` envoye comme OBJET JSON (cle non sequentielle) : auparavant + // persiste tel quel en JSONB objet -> le CHECK jsonb_array_length plantait en + // 500. Doit desormais etre renormalise en liste sequentielle (array_values du + // setter), donc accepte proprement sans 500. + $created = $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validStoragePayload([ + 'states' => [7 => Storage::STATE_RECEPTION], + ]), + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + self::assertSame([Storage::STATE_RECEPTION], $created['states']); + } } diff --git a/tests/Module/Catalog/Api/StorageWriteValidationTest.php b/tests/Module/Catalog/Api/StorageWriteValidationTest.php new file mode 100644 index 0000000..efcd50f --- /dev/null +++ b/tests/Module/Catalog/Api/StorageWriteValidationTest.php @@ -0,0 +1,83 @@ + 422 (Assert\NotBlank) sur `numero` ; + * - relation nulle (site / storageType) -> 422 (Assert\NotNull, via le chemin de + * denormalisation `collectDenormalizationErrors`) portant le bon propertyPath, et + * NON un 400 qui court-circuiterait le mapping inline front (useFormErrors, + * ERP-101). + * + * Pendant ces RG, le contrat de violation 422 (propertyPath aligne sur le champ + * front) est ce que le front consomme : on l'asserte explicitement. + * + * @internal + */ +final class StorageWriteValidationTest extends AbstractStorageApiTestCase +{ + public function testNumeroIsTrimmedServerSide(): void + { + $client = $this->createAdminClient(); + + // RG-7.06 : numero saisi avec des espaces autour -> stocke trimme. + $created = $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validStoragePayload(['numero' => ' A1 ']), + ])->toArray(); + + self::assertResponseStatusCodeSame(201); + self::assertSame('A1', $created['numero'], 'Le numero doit etre trimme cote serveur (RG-7.06).'); + + // Relecture : la normalisation est bien persistee, pas seulement reflechie. + $detail = $client->request('GET', '/api/storages/'.$created['id'], [ + 'headers' => ['Accept' => self::LD], + ])->toArray(); + self::assertSame('A1', $detail['numero']); + } + + public function testBlankNumeroIsRejected(): void + { + $client = $this->createAdminClient(); + + $response = $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validStoragePayload(['numero' => ' ']), + ]); + + self::assertResponseStatusCodeSame(422); + self::assertContains('numero', $this->violationPaths($response)); + } + + public function testNullSiteReturns422WithPropertyPath(): void + { + $client = $this->createAdminClient(); + + // Relation obligatoire a null : doit ressortir en 422 (NotNull) avec un + // propertyPath `site`, pas en 400 (collectDenormalizationErrors). + $response = $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validStoragePayload(['site' => null]), + ]); + + self::assertResponseStatusCodeSame(422); + self::assertContains('site', $this->violationPaths($response)); + } + + public function testNullStorageTypeReturns422WithPropertyPath(): void + { + $client = $this->createAdminClient(); + + $response = $client->request('POST', '/api/storages', [ + 'headers' => ['Content-Type' => self::LD], + 'json' => $this->validStoragePayload(['storageType' => null]), + ]); + + self::assertResponseStatusCodeSame(422); + self::assertContains('storageType', $this->violationPaths($response)); + } +} From 04bcc8cb1fd250971df2209b69b832faa8f51a82 Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Tue, 30 Jun 2026 05:59:13 +0000 Subject: [PATCH 10/11] chore: bump version to v0.1.158 --- config/version.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/version.yaml b/config/version.yaml index c511f4b..e273f85 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,2 +1,2 @@ parameters: - app.version: '0.1.157' + app.version: '0.1.158' From 73c69994409f9e7225ac2cac031d0b62c3185d7d Mon Sep 17 00:00:00 2001 From: gitea-actions Date: Tue, 30 Jun 2026 06:00:24 +0000 Subject: [PATCH 11/11] chore: bump version to v0.1.161 --- config/version.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/version.yaml b/config/version.yaml index e273f85..b17148b 100644 --- a/config/version.yaml +++ b/config/version.yaml @@ -1,2 +1,2 @@ parameters: - app.version: '0.1.158' + app.version: '0.1.161'