TQ-Slator/tests/Architecture/TenantIsolationTest.php
Stephan Morand 9025c64c0b Socle complet de TQ-Slator : éditeur, API, CLI, administration
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>
2026-08-21 08:16:05 +02:00

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;
}
}