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

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