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>
118 lines
4.1 KiB
PHP
118 lines
4.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Tests\Security;
|
|
|
|
use App\Entity\Organization;
|
|
use App\Entity\User;
|
|
use App\Repository\UserRepository;
|
|
use Doctrine\ORM\EntityManagerInterface;
|
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
|
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
|
|
use Symfony\Component\Security\Core\Exception\UserNotFoundException;
|
|
use Symfony\Component\Security\Core\User\InMemoryUser;
|
|
|
|
/**
|
|
* Désactiver un compte doit COUPER les sessions en cours, pas seulement
|
|
* interdire les connexions suivantes.
|
|
*
|
|
* La distinction n'est pas théorique. Le bouton « désactiver » se presse au
|
|
* moment précis où un accès doit cesser : un départ, un poste compromis, un
|
|
* prestataire dont le contrat s'arrête. S'il ne fermait que la porte d'entrée,
|
|
* la personne déjà entrée resterait jusqu'à l'expiration de son cookie.
|
|
*
|
|
* Le piège est invisible à la lecture : sans UserProviderInterface sur le
|
|
* repository, Symfony recharge l'utilisateur par clé primaire et ne repasse
|
|
* jamais par la condition `isActive`. Tout paraît correct, et la révocation ne
|
|
* marche pas. D'où ce test.
|
|
*/
|
|
final class AccountDeactivationTest extends KernelTestCase
|
|
{
|
|
private EntityManagerInterface $entityManager;
|
|
private UserRepository $users;
|
|
private Organization $organization;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
self::bootKernel();
|
|
|
|
$container = self::getContainer();
|
|
|
|
$entityManager = $container->get(EntityManagerInterface::class);
|
|
self::assertInstanceOf(EntityManagerInterface::class, $entityManager);
|
|
$this->entityManager = $entityManager;
|
|
|
|
$users = $container->get(UserRepository::class);
|
|
self::assertInstanceOf(UserRepository::class, $users);
|
|
$this->users = $users;
|
|
|
|
$this->organization = new Organization('Test', 'test');
|
|
$this->entityManager->persist($this->organization);
|
|
$this->entityManager->flush();
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
// Aucune trace laissée en base : les tests suivants partent du même état.
|
|
$this->entityManager->createQuery('DELETE FROM '.User::class)->execute();
|
|
$this->entityManager->createQuery('DELETE FROM '.Organization::class)->execute();
|
|
$this->entityManager->clear();
|
|
|
|
parent::tearDown();
|
|
}
|
|
|
|
public function testRefreshingADeactivatedAccountEndsTheSession(): void
|
|
{
|
|
$user = $this->createUser('revoke-me@tranquilys.test');
|
|
|
|
// Session ouverte : le rechargement fonctionne.
|
|
self::assertSame($user->getUserIdentifier(), $this->users->refreshUser($user)->getUserIdentifier());
|
|
|
|
$user->setIsActive(false);
|
|
$this->entityManager->flush();
|
|
$this->entityManager->clear();
|
|
|
|
$this->expectException(UserNotFoundException::class);
|
|
$this->users->refreshUser($user);
|
|
}
|
|
|
|
public function testLoadingADeactivatedAccountFails(): void
|
|
{
|
|
$user = $this->createUser('no-login@tranquilys.test');
|
|
$user->setIsActive(false);
|
|
$this->entityManager->flush();
|
|
$this->entityManager->clear();
|
|
|
|
$this->expectException(UserNotFoundException::class);
|
|
$this->users->loadUserByIdentifier('no-login@tranquilys.test');
|
|
}
|
|
|
|
public function testActiveAccountStillLoads(): void
|
|
{
|
|
$this->createUser('still-here@tranquilys.test');
|
|
$this->entityManager->clear();
|
|
|
|
$loaded = $this->users->loadUserByIdentifier('still-here@tranquilys.test');
|
|
|
|
self::assertInstanceOf(User::class, $loaded);
|
|
self::assertTrue($loaded->isActive());
|
|
}
|
|
|
|
public function testForeignUserClassIsRejected(): void
|
|
{
|
|
$this->expectException(UnsupportedUserException::class);
|
|
$this->users->refreshUser(new InMemoryUser('someone', null));
|
|
}
|
|
|
|
private function createUser(string $email): User
|
|
{
|
|
$user = new User($this->organization, $email, 'Compte de test');
|
|
$user->setPassword('$2y$04$abcdefghijklmnopqrstuv');
|
|
|
|
$this->entityManager->persist($user);
|
|
$this->entityManager->flush();
|
|
|
|
return $user;
|
|
}
|
|
}
|