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>
145 lines
5.5 KiB
PHP
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);
|
|
}
|
|
}
|