Système de gestion de traductions pensé en CMS headless : un back-office pour ceux qui traduisent, une API pour ce qui consomme. Architecture - Symfony 7.4 / API Platform 4.3 / MariaDB 11.4, SPA React 19 servie en même origine — ce qui rend viable le cookie de session plutôt qu'un jeton en localStorage. - Deux APIs séparées : Management (session ou clé) et Delivery (stateless, clé seule). Les fusionner ferait porter à chaque lecture de bundle le coût de la session. - Stockage canonique en ICU MessageFormat, sérialisation par plateforme. Le format d'une plateforme ne contamine pas la base. - Publication par releases immuables ; le déploiement est un déplacement de pointeur, donc le rollback aussi. - Isolation multi-organisation par filtre Doctrine, avec un test d'architecture qui casse la CI si une entité échappe à l'invariant. Éditeur, deux vues - Par langue : source et cible, jamais douze colonnes. Grille virtualisée, saisie sans bouton « Enregistrer », panneau de contexte permanent. - Par clé : une clé, toutes ses langues empilées et repliées. Répond à « ce libellé est-il prêt partout ? ». - Mode Focus dans les deux : une file à vider, ⌘↵ pour enchaîner. - Le traducteur ne voit jamais d'ICU : pastilles de variables, un champ par catégorie CLDR de la langue cible. Administration - Deux niveaux : projet (membres, clés API, plateformes) et organisation (annuaire des comptes, création de projets). - Invitations par e-mail, jeton 256 bits stocké haché. - Désactiver un compte coupe les sessions en cours, pas seulement les connexions suivantes. - Les plateformes s'archivent ; ni elles ni les environnements ne se suppriment — la trace explique pourquoi telle clé existe. CLI tqs - PHAR autonome de 3 Mo, autoloader généré : le dépôt client ne dépend ni de Composer ni de la disponibilité de TQ-Slator. - init / push / pull / status ; le sync est non destructif par défaut et son prune est scopé plateforme. 126 tests, PHPStan niveau 8. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
146 lines
5.2 KiB
PHP
146 lines
5.2 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Architecture;
|
|
|
|
use App\Entity\Locale;
|
|
use App\Entity\Organization;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Doctrine\ORM\Mapping\ClassMetadata;
|
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
|
|
|
/**
|
|
* Garantit l'invariant de rattachement multi-organisation (décision 1).
|
|
*
|
|
* OrganizationFilter ne contraint que les entités portant réellement
|
|
* l'association `organization`. Les autres ne sont isolées que parce qu'elles
|
|
* sont ATTEIGNABLES depuis l'une d'elles par une chaîne d'associations
|
|
* obligatoires. Cet invariant n'est écrit nulle part dans le code : il tient à
|
|
* la forme du modèle, et rien n'empêche quelqu'un d'ajouter demain une entité
|
|
* qui le rompt.
|
|
*
|
|
* Ce test le rend explicite et exécutable. Une nouvelle entité orpheline casse
|
|
* la CI, au lieu de créer une fuite inter-organisation qui ne se verrait qu'en
|
|
* production, le jour d'une ouverture à des clients externes.
|
|
*/
|
|
final class TenantIsolationTest extends KernelTestCase
|
|
{
|
|
/**
|
|
* Entités exemptées, avec leur justification.
|
|
*
|
|
* Toute addition à cette liste doit être argumentée en revue : c'est le seul
|
|
* endroit où l'on peut légitimement échapper à l'isolation.
|
|
*/
|
|
private const GLOBAL_ENTITIES = [
|
|
// Racine de l'isolation : elle ne peut pas s'appartenir.
|
|
Organization::class => 'racine de rattachement',
|
|
// Référentiel partagé : « français (France) » est identique pour tout le
|
|
// monde, et le dupliquer par organisation ferait diverger les catégories
|
|
// CLDR, donc la validation des pluriels.
|
|
Locale::class => 'référentiel global partagé',
|
|
];
|
|
|
|
public function testEveryEntityIsReachableFromAnOrganization(): void
|
|
{
|
|
self::bootKernel();
|
|
|
|
$entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
|
self::assertInstanceOf(EntityManagerInterface::class, $entityManager);
|
|
|
|
/** @var list<ClassMetadata<object>> $allMetadata */
|
|
$allMetadata = $entityManager->getMetadataFactory()->getAllMetadata();
|
|
|
|
$byName = [];
|
|
foreach ($allMetadata as $metadata) {
|
|
$byName[$metadata->getName()] = $metadata;
|
|
}
|
|
|
|
self::assertNotEmpty($byName, 'Aucune entité mappée : la configuration Doctrine est cassée.');
|
|
|
|
$orphans = [];
|
|
foreach ($byName as $name => $metadata) {
|
|
if (!$this->reachesOrganization($name, $byName, [])) {
|
|
$orphans[] = $name;
|
|
}
|
|
}
|
|
|
|
self::assertSame([], $orphans, sprintf(
|
|
"Ces entités ne sont rattachées à aucune organisation, ni directement ni par une chaîne d'associations obligatoires :\n - %s\n\n".
|
|
"Ajoutez-leur une association `organization` non nullable, ou un ManyToOne non nullable vers une entité déjà rattachée. ".
|
|
"Si l'entité est réellement globale, inscrivez-la dans self::GLOBAL_ENTITIES avec sa justification.",
|
|
implode("\n - ", $orphans),
|
|
));
|
|
}
|
|
|
|
/**
|
|
* Une entité est isolée si elle porte `organization`, si elle figure dans les
|
|
* exemptions, ou si elle possède un ManyToOne OBLIGATOIRE vers une entité
|
|
* elle-même isolée.
|
|
*
|
|
* L'association doit être non nullable : une relation optionnelle ne
|
|
* garantit rien, puisque la ligne peut exister sans parent.
|
|
*
|
|
* @param array<string, ClassMetadata<object>> $byName
|
|
* @param list<string> $visited protection contre les cycles (namespace -> parent)
|
|
*/
|
|
private function reachesOrganization(string $name, array $byName, array $visited): bool
|
|
{
|
|
if (isset(self::GLOBAL_ENTITIES[$name])) {
|
|
return true;
|
|
}
|
|
|
|
if (\in_array($name, $visited, true)) {
|
|
return false;
|
|
}
|
|
|
|
$metadata = $byName[$name] ?? null;
|
|
if (null === $metadata) {
|
|
return false;
|
|
}
|
|
|
|
if ($metadata->hasAssociation('organization')) {
|
|
return true;
|
|
}
|
|
|
|
$visited[] = $name;
|
|
|
|
foreach ($metadata->getAssociationMappings() as $field => $mapping) {
|
|
if (!$metadata->isSingleValuedAssociation($field)) {
|
|
continue;
|
|
}
|
|
|
|
if (!$this->isMandatory($metadata, $field)) {
|
|
continue;
|
|
}
|
|
|
|
$target = $metadata->getAssociationTargetClass($field);
|
|
|
|
if ($this->reachesOrganization($target, $byName, $visited)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* @param ClassMetadata<object> $metadata
|
|
*/
|
|
private function isMandatory(ClassMetadata $metadata, string $field): bool
|
|
{
|
|
$mapping = $metadata->getAssociationMapping($field);
|
|
|
|
// Accès par propriété et non par ArrayAccess : ce dernier est déprécié et
|
|
// disparaîtra en Doctrine ORM 4.
|
|
foreach ($mapping->joinColumns ?? [] as $joinColumn) {
|
|
// Une colonne de jointure est nullable par défaut chez Doctrine :
|
|
// l'absence de la propriété équivaut donc à « optionnelle ».
|
|
if (false === ($joinColumn->nullable ?? true)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|