TQ-Slator/tests/Security/WritableLocaleResolverTest.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

145 lines
5.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Tests\Security;
use App\Entity\Locale;
use App\Entity\Organization;
use App\Entity\Project;
use App\Entity\ProjectLocale;
use App\Entity\ProjectMember;
use App\Entity\User;
use App\Enum\ProjectRole;
use App\Enum\TextDirection;
use App\Repository\ProjectMemberRepository;
use App\Security\WritableLocaleResolver;
use PHPUnit\Framework\TestCase;
use Symfony\Bundle\SecurityBundle\Security;
/**
* Qui a le droit d'écrire, et sur quelles langues.
*
* Ce test existe à cause d'un bug précis, qu'il verrouille : le périmètre de
* langues était évalué AVANT le rôle. Un developer ou un viewer n'ayant aucune
* langue assignée, `coversAllLocales()` répondait vrai et ils ressortaient
* « habilités sur tout ». L'éditeur leur ouvrait donc les champs de saisie, et
* le serveur refusait ensuite en 403 — le refus après l'effort, exactement la
* séquence que ce calcul existe pour éviter.
*
* Rien ne signalait l'erreur : les deux moitiés du système étaient cohérentes
* chacune de son côté, elles se contredisaient seulement entre elles.
*/
final class WritableLocaleResolverTest extends TestCase
{
private Organization $organization;
private Project $project;
private Locale $spanish;
private Locale $german;
protected function setUp(): void
{
$this->organization = new Organization('Test', 'test');
$french = new Locale('fr-FR', 'French', 'Français', TextDirection::Ltr, ['one', 'many', 'other']);
$this->spanish = new Locale('es-ES', 'Spanish', 'Español', TextDirection::Ltr, ['one', 'many', 'other']);
$this->german = new Locale('de-DE', 'German', 'Deutsch', TextDirection::Ltr, ['one', 'other']);
$this->project = new Project($this->organization, 'Test', 'test', $french);
foreach ([$french, $this->spanish, $this->german] as $locale) {
new ProjectLocale($this->project, $locale);
}
}
public function testDeveloperWritesNothingDespiteCoveringAllLocales(): void
{
// Le cas du bug : aucune langue assignée, donc coversAllLocales() est
// vrai. C'est le RÔLE qui doit trancher, et en premier.
$member = $this->member(ProjectRole::Developer);
self::assertTrue($member->coversAllLocales());
self::assertSame([], $this->resolverFor($member)->codesFor($this->project));
self::assertSame([], $this->resolverFor($member)->localesFor($this->project));
}
public function testViewerWritesNothing(): void
{
$member = $this->member(ProjectRole::Viewer);
self::assertSame([], $this->resolverFor($member)->codesFor($this->project));
}
public function testTranslatorIsLimitedToItsLocales(): void
{
$member = $this->member(ProjectRole::Translator);
$member->addLocale($this->spanish);
self::assertSame(['es-ES'], $this->resolverFor($member)->codesFor($this->project));
self::assertSame(
['es-ES'],
array_map(
static fn (Locale $l): string => $l->getCode(),
$this->resolverFor($member)->localesFor($this->project),
),
);
}
public function testTranslatorWithoutLocalesCoversThemAll(): void
{
// null, pas [] : « toutes les langues ». La distinction entre les deux
// porte tout le sens de la classe.
$member = $this->member(ProjectRole::Translator);
self::assertNull($this->resolverFor($member)->codesFor($this->project));
self::assertCount(2, $this->resolverFor($member)->localesFor($this->project));
}
public function testSuperAdminCoversEverythingWithoutMembership(): void
{
$user = new User($this->organization, 'root@tranquilys.test', 'Root');
$user->setRoles([User::ROLE_SUPER_ADMIN]);
$members = $this->createMock(ProjectMemberRepository::class);
// Aucune requête d'appartenance ne doit partir : le super-admin est
// tranché avant, et interroger la base pour rien coûterait une requête
// sur chaque écran.
$members->expects(self::never())->method('findMembership');
$security = $this->createStub(Security::class);
$security->method('getUser')->willReturn($user);
self::assertNull((new WritableLocaleResolver($security, $members))->codesFor($this->project));
}
public function testAnonymousCallerIsNotRestricted(): void
{
// Le CLI présente une clé API, pas un User. Le cloisonnement par langue
// ne le concerne pas : ses permissions sont portées par les scopes de
// la clé et tranchées par les Voters.
$security = $this->createStub(Security::class);
$security->method('getUser')->willReturn(null);
$resolver = new WritableLocaleResolver($security, $this->createStub(ProjectMemberRepository::class));
self::assertNull($resolver->codesFor($this->project));
}
private function member(ProjectRole $role): ProjectMember
{
$user = new User($this->organization, 'someone@tranquilys.test', 'Quelqu\'un');
return new ProjectMember($this->project, $user, $role);
}
private function resolverFor(ProjectMember $member): WritableLocaleResolver
{
$members = $this->createStub(ProjectMemberRepository::class);
$members->method('findMembership')->willReturn($member);
$security = $this->createStub(Security::class);
$security->method('getUser')->willReturn($member->getUser());
return new WritableLocaleResolver($security, $members);
}
}