Merge remote-tracking branch 'origin/feat/module-site-backend' into feat/admin-tables-filter-pagination

# Conflicts:
#	frontend/modules/sites/pages/admin/sites.vue
This commit is contained in:
2026-04-20 17:04:04 +02:00
24 changed files with 611 additions and 82 deletions

View File

@@ -20,10 +20,12 @@ onMounted(async () => {
// qu'un user suivant (connecte sur le meme onglet) voie l'etat de // qu'un user suivant (connecte sur le meme onglet) voie l'etat de
// l'ancien. Les trois fonctions reset sont synchrones et ne // l'ancien. Les trois fonctions reset sont synchrones et ne
// peuvent pas throw (juste des assignations reactives). // peuvent pas throw (juste des assignations reactives).
// navigateTo est dans le finally pour garantir la redirection
// meme si auth.logout() lance une exception (ex: reseau coupé).
resetSidebar() resetSidebar()
resetModules() resetModules()
resetCurrentSite() resetCurrentSite()
await navigateTo('/login')
} }
await navigateTo('/login')
}) })
</script> </script>

View File

@@ -72,8 +72,14 @@ async function onChange(site: { id: string; name: string; color: string }): Prom
return return
} }
// Ignore les clics sur le site deja actif (pas de PATCH superflu). // TODO(cross-tab) : si l'utilisateur a change de site dans un autre
if (currentSite.value && currentSite.value.id === target.id) return // onglet, currentSite.value ici peut etre obsolete (state singleton
// non synchronise entre onglets). La garde ci-dessous est donc
// intentionnellement supprimee pour garantir qu'un clic sur le tile
// "actif selon cet onglet" envoie quand meme le PATCH et re-synchronise
// l'etat. Amelioration future : ecouter l'evenement `storage` sur la
// cle `coltura:site-switch` pour mettre a jour les onglets inactifs
// sans clic via auth.fetchUser() / auth.refreshUser().
try { try {
await switchSite(target) await switchSite(target)

View File

@@ -29,6 +29,10 @@ vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
vi.stubGlobal('watchEffect', watchEffect) vi.stubGlobal('watchEffect', watchEffect)
vi.stubGlobal('computed', computed) vi.stubGlobal('computed', computed)
vi.stubGlobal('ref', ref) vi.stubGlobal('ref', ref)
// useSidebar et refreshNuxtData sont consommes par useCurrentSite apres
// un switch reussi — stubs minimaux pour eviter ReferenceError au mount.
vi.stubGlobal('useSidebar', () => ({ loadSidebar: vi.fn() }))
vi.stubGlobal('refreshNuxtData', vi.fn())
// Stub de MalioSiteSelector : on se contente de tracker les props recues // Stub de MalioSiteSelector : on se contente de tracker les props recues
// et de re-emettre `change` quand on le simule via `trigger`. Evite de // et de re-emettre `change` quand on le simule via `trigger`. Evite de
@@ -144,13 +148,22 @@ describe('SiteSelector', () => {
) )
}) })
it('clic sur le tile deja actif ne declenche aucun PATCH', async () => { it('clic sur le tile deja actif declenche un PATCH (resync cross-tab)', async () => {
// Le court-circuit "si deja actif, ne rien faire" a ete supprime
// pour couvrir le cas ou un autre onglet a modifie le site courant
// cote serveur : un clic sur la tile localement "active" (etat
// potentiellement stale) force une resync via PATCH. Le prix est un
// PATCH superflu quand l'etat local est effectivement a jour.
const wrapper = mountSelector() const wrapper = mountSelector()
await wrapper.find('[data-testid="tile-1"]').trigger('click') await wrapper.find('[data-testid="tile-1"]').trigger('click')
await flushPromises() await flushPromises()
expect(mockPatch).not.toHaveBeenCalled() expect(mockPatch).toHaveBeenCalledWith(
'/me/current-site',
{ site: '/api/sites/1' },
expect.anything(),
)
}) })
it('rollback visuel : sur erreur PATCH, data-active-id revient au site initial', async () => { it('rollback visuel : sur erreur PATCH, data-active-id revient au site initial', async () => {

View File

@@ -24,6 +24,14 @@ vi.stubGlobal('useAuthStore', () => ({
vi.stubGlobal('useI18n', () => ({ vi.stubGlobal('useI18n', () => ({
t: (key: string) => key, t: (key: string) => key,
})) }))
// useSidebar est consomme par useCurrentSite pour rafraichir la sidebar
// apres un switch reussi. Stub minimal retournant un loadSidebar no-op.
vi.stubGlobal('useSidebar', () => ({
loadSidebar: vi.fn(),
}))
// refreshNuxtData est appele apres un switch pour invalider les donnees
// de page precedemment fetchees. Stub no-op pour les tests unitaires.
vi.stubGlobal('refreshNuxtData', vi.fn())
const SITE_A: Site = { const SITE_A: Site = {
id: 1, id: 1,

View File

@@ -23,11 +23,21 @@
*/ */
import { ref } from 'vue' import { ref } from 'vue'
import type { Site } from '~/shared/types/sites' import type { Site } from '~/shared/types/sites'
import { onAuthSessionCleared } from '~/shared/stores/auth'
const currentSite = ref<Site | null>(null) const currentSite = ref<Site | null>(null)
const availableSites = ref<Site[]>([]) const availableSites = ref<Site[]>([])
const switching = ref(false) const switching = ref(false)
// Enregistrement unique au niveau module (singleton) : quand clearSession()
// est appelee par l'intercepteur 401 de useApi, le state local est purgé
// de la meme facon qu'au logout explicite (logout.vue).
onAuthSessionCleared(() => {
currentSite.value = null
availableSites.value = []
switching.value = false
})
export function useCurrentSite() { export function useCurrentSite() {
// Resolution au setup : les 3 services doivent etre invoques dans un // Resolution au setup : les 3 services doivent etre invoques dans un
// contexte composant. Leur capture ici permet a switchSite() de // contexte composant. Leur capture ici permet a switchSite() de
@@ -35,6 +45,7 @@ export function useCurrentSite() {
const auth = useAuthStore() const auth = useAuthStore()
const api = useApi() const api = useApi()
const { t } = useI18n() const { t } = useI18n()
const { loadSidebar } = useSidebar()
/** /**
* Synchronise le state singleton depuis le store auth. A appeler au * Synchronise le state singleton depuis le store auth. A appeler au
@@ -75,6 +86,21 @@ export function useCurrentSite() {
// N'est appele qu'apres un succes HTTP donc pas de rollback a // N'est appele qu'apres un succes HTTP donc pas de rollback a
// prevoir sur cette ligne. // prevoir sur cette ligne.
auth.setCurrentSite(site) auth.setCurrentSite(site)
// Apres un switch reussi : recharger la sidebar (les filtres de
// modules peuvent dependre du site courant via SiteScopedQueryExtension)
// et invalider toutes les donnees de page pour eviter que l'utilisateur
// voie les donnees de l'ancien site sous un toast "Site change".
try {
await loadSidebar()
} catch {
// No-op : la sidebar non rafraichie n'est pas bloquante.
}
try {
await refreshNuxtData()
} catch {
// No-op : certaines pages n'ont pas de useAsyncData a invalider.
}
} catch (error) { } catch (error) {
currentSite.value = previousLocal currentSite.value = previousLocal
throw error throw error

View File

@@ -90,6 +90,7 @@ import type { Site } from '~/shared/types/sites'
const { t } = useI18n() const { t } = useI18n()
const api = useApi() const api = useApi()
const auth = useAuthStore()
const { can } = usePermissions() const { can } = usePermissions()
const canManage = computed(() => can('sites.manage')) const canManage = computed(() => can('sites.manage'))
@@ -174,6 +175,11 @@ async function handleDelete() {
siteToDelete.value = null siteToDelete.value = null
drawerOpen.value = false drawerOpen.value = false
reload() reload()
// Rafraichit auth.user apres suppression d'un site : le backend
// applique ON DELETE SET NULL sur user.current_site_id, donc
// auth.user.currentSite peut etre devenu null sans que le front
// le sache. refreshUser() resynchronise depuis GET /api/me.
await auth.refreshUser()
} finally { } finally {
deleting.value = false deleting.value = false
} }

View File

@@ -3,6 +3,24 @@ import type { UserData } from '~/shared/types/user-data'
import type { Site } from '~/shared/types/sites' import type { Site } from '~/shared/types/sites'
import { getCurrentUser, login, logout } from '~/shared/services/auth' import { getCurrentUser, login, logout } from '~/shared/services/auth'
/**
* Callbacks enregistres par les composables singletons qui doivent
* reinitialiser leur etat quand la session est invalidee (ex: expiration
* JWT, logout depuis un intercepteur 401). Utilise le pattern
* "callback registration" (Option C) pour eviter une dependance croisee
* depuis shared/ vers modules/ — chaque composable s'auto-enregistre.
*/
const onSessionClearedCallbacks: Array<() => void> = []
/**
* Enregistre un callback a invoquer lorsque clearSession() est appelee.
* Typiquement invoque au setup-time du composable (module-level), donc
* une seule fois par instance de composable singleton.
*/
export function onAuthSessionCleared(cb: () => void): void {
onSessionClearedCallbacks.push(cb)
}
export const useAuthStore = defineStore('auth', { export const useAuthStore = defineStore('auth', {
state: () => ({ state: () => ({
user: null as UserData | null, user: null as UserData | null,
@@ -17,6 +35,10 @@ export const useAuthStore = defineStore('auth', {
this.user = null this.user = null
this.checked = true this.checked = true
this.isLoading = false this.isLoading = false
// Notifie les composables singletons (useCurrentSite, etc.) afin
// qu'ils reinitialisation leur etat — necessaire quand la session
// est invalidee par un intercepteur 401 sans passer par logout.vue.
onSessionClearedCallbacks.forEach((cb) => cb())
}, },
async ensureSession() { async ensureSession() {
if (this.checked) { if (this.checked) {

View File

@@ -18,8 +18,14 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor; use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\UserRbacProcessor;
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider; use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\MeProvider;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository; use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
// Note architecture : User.php utilise SiteInterface (Shared) pour les
// type-hints afin de ne pas coupler le module Core au module Sites.
// La seule reference concrete a Site subsiste dans les metadonnees ORM
// (targetEntity) via FQCN string, ce qui est obligatoire pour Doctrine.
// SiteNotAuthorizedException est importee depuis Shared (sa location canonique).
use App\Module\Sites\Domain\Entity\Site; use App\Module\Sites\Domain\Entity\Site;
use App\Module\Sites\Domain\Exception\SiteNotAuthorizedException; use App\Shared\Domain\Contract\SiteInterface;
use App\Shared\Domain\Exception\SiteNotAuthorizedException;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
@@ -126,17 +132,19 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
/** /**
* Sites autorises pour l'utilisateur (ticket 2 du module Sites). * Sites autorises pour l'utilisateur (ticket 2 du module Sites).
* *
* Relation ManyToMany avec table de jointure `user_site`. Fetch EAGER * Relation ManyToMany avec table de jointure `user_site`. Fetch LAZY :
* pour la meme raison que `$rbacRoles` : garantir que `/api/me` et les * le chargement est defere jusqu'a l'acces explicite a la collection.
* voters futurs aient toujours la collection hydratee, meme dans un * MeProvider (ou un futur provider avec JOIN FETCH) est responsable de
* contexte de refresh JWT hors EntityManager. Le surcout SQL reste * precharger cette collection pour /api/me afin d'eviter N+1.
* negligeable (≤ quelques sites par user en pratique). *
* Le groupe `user:list` a ete retire deliberement (securite : evite
* de fuiter la liste des sites de chaque user via GET /api/users).
* *
* @var Collection<int, Site> * @var Collection<int, Site>
*/ */
#[ORM\ManyToMany(targetEntity: Site::class, inversedBy: 'users', fetch: 'EAGER')] #[ORM\ManyToMany(targetEntity: 'App\Module\Sites\Domain\Entity\Site', inversedBy: 'users', fetch: 'LAZY')]
#[ORM\JoinTable(name: 'user_site')] #[ORM\JoinTable(name: 'user_site')]
#[Groups(['me:read', 'user:list', 'user:rbac:read', 'user:rbac:write'])] #[Groups(['me:read', 'user:rbac:read', 'user:rbac:write'])]
private Collection $sites; private Collection $sites;
/** /**
@@ -150,11 +158,15 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* Doit TOUJOURS pointer vers un site present dans `$sites`. L'invariant * Doit TOUJOURS pointer vers un site present dans `$sites`. L'invariant
* est enforce par UserRbacProcessor qui bascule automatiquement a `null` * est enforce par UserRbacProcessor qui bascule automatiquement a `null`
* si le site courant est retire des sites autorises. * si le site courant est retire des sites autorises.
*
* Fetch LAZY : MeProvider (ou un futur provider avec JOIN FETCH) assure
* le prechargement pour /api/me. Le groupe `user:list` a ete retire
* deliberement (securite : evite de fuiter le site actif via /api/users).
*/ */
#[ORM\ManyToOne(targetEntity: Site::class, fetch: 'EAGER')] #[ORM\ManyToOne(targetEntity: 'App\Module\Sites\Domain\Entity\Site', fetch: 'LAZY')]
#[ORM\JoinColumn(name: 'current_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')] #[ORM\JoinColumn(name: 'current_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['me:read', 'user:list'])] #[Groups(['me:read'])]
private ?Site $currentSite = null; private ?SiteInterface $currentSite = null;
#[ORM\Column] #[ORM\Column]
private ?string $password = null; private ?string $password = null;
@@ -377,11 +389,15 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* Synchronise la collection inverse Site::$users en memoire pour eviter * Synchronise la collection inverse Site::$users en memoire pour eviter
* un etat incoherent entre les deux cotes de la M2M dans une meme * un etat incoherent entre les deux cotes de la M2M dans une meme
* session Doctrine (cf. ticket 2 review point #1). * session Doctrine (cf. ticket 2 review point #1).
*
* Le parametre est type SiteInterface pour eviter le couplage Core → Sites.
* En pratique seule App\Module\Sites\Domain\Entity\Site est passee ici.
*/ */
public function addSite(Site $site): static public function addSite(SiteInterface $site): static
{ {
if (!$this->sites->contains($site)) { if (!$this->sites->contains($site)) {
$this->sites->add($site); $this->sites->add($site);
// @phpstan-ignore-next-line : Site concret toujours passe en pratique
$site->addUser($this); $site->addUser($this);
} }
@@ -395,9 +411,10 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* par UserRbacProcessor (cote applicatif) ou doit etre maintenu * par UserRbacProcessor (cote applicatif) ou doit etre maintenu
* explicitement par l'appelant. Voir Risque 2 du ticket 2 spec. * explicitement par l'appelant. Voir Risque 2 du ticket 2 spec.
*/ */
public function removeSite(Site $site): static public function removeSite(SiteInterface $site): static
{ {
if ($this->sites->removeElement($site)) { if ($this->sites->removeElement($site)) {
// @phpstan-ignore-next-line : Site concret toujours passe en pratique
$site->removeUser($this); $site->removeUser($this);
} }
@@ -409,12 +426,12 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* collection autorisee, via comparaison d'identite d'objet Doctrine. * collection autorisee, via comparaison d'identite d'objet Doctrine.
* Utilise par CurrentSiteProcessor pour valider un switch. * Utilise par CurrentSiteProcessor pour valider un switch.
*/ */
public function hasSite(Site $site): bool public function hasSite(SiteInterface $site): bool
{ {
return $this->sites->contains($site); return $this->sites->contains($site);
} }
public function getCurrentSite(): ?Site public function getCurrentSite(): ?SiteInterface
{ {
return $this->currentSite; return $this->currentSite;
} }
@@ -426,7 +443,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* "selectionner un site dans la liste autorisee", utiliser * "selectionner un site dans la liste autorisee", utiliser
* switchCurrentSite() qui porte la garde domaine. * switchCurrentSite() qui porte la garde domaine.
*/ */
public function setCurrentSite(?Site $currentSite): static public function setCurrentSite(?SiteInterface $currentSite): static
{ {
$this->currentSite = $currentSite; $this->currentSite = $currentSite;
@@ -441,7 +458,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
* *
* @throws SiteNotAuthorizedException si $site n'appartient pas a $this->sites * @throws SiteNotAuthorizedException si $site n'appartient pas a $this->sites
*/ */
public function switchCurrentSite(Site $site): void public function switchCurrentSite(SiteInterface $site): void
{ {
if (!$this->hasSite($site)) { if (!$this->hasSite($site)) {
throw SiteNotAuthorizedException::forSite($site); throw SiteNotAuthorizedException::forSite($site);

View File

@@ -10,9 +10,11 @@ use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Exception\LastAdminProtectionException; use App\Module\Core\Domain\Exception\LastAdminProtectionException;
use App\Module\Core\Domain\Security\AdminHeadcountGuardInterface; use App\Module\Core\Domain\Security\AdminHeadcountGuardInterface;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\PersistentCollection;
use LogicException; use LogicException;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire; use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/** /**
@@ -29,14 +31,21 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
* - Dernier admin global : impossible de retirer `isAdmin` si c'est le * - Dernier admin global : impossible de retirer `isAdmin` si c'est le
* dernier administrateur de l'instance, meme par un tiers. Enforce via * dernier administrateur de l'instance, meme par un tiers. Enforce via
* AdminHeadcountGuardInterface. * AdminHeadcountGuardInterface.
* - Permission sites.manage : si le payload mute la collection `sites`,
* la permission `sites.manage` est requise en plus de `core.users.manage`.
* - Coherence currentSite (ticket 2 module Sites) : apres persist des * - Coherence currentSite (ticket 2 module Sites) : apres persist des
* sites autorises, si le `currentSite` n'est plus dans la collection, * sites autorises, si le `currentSite` n'est plus dans la collection,
* il est repositionne automatiquement : * il est repositionne automatiquement :
* a) repasse a `null` s'il pointait vers un site retire ; * a) repasse a `null` s'il pointait vers un site retire ;
* b) est auto-selectionne sur le premier site de `sites` s'il etait * b) est auto-selectionne sur le premier site de `sites` s'il etait
* null alors que la collection n'est pas vide (pratique pour un * null alors que la collection vient d'etre modifiee et n'est pas vide.
* premier rattachement).
* Un second flush est emis uniquement si la coherence a du etre corrigee. * Un second flush est emis uniquement si la coherence a du etre corrigee.
* La garde coherence est skippee si ni les sites ni le currentSite n'ont
* change (evite le silent site-switch sur un PATCH ne touchant pas aux sites).
*
* Atomicite : persistProcessor->process() + ensureCurrentSiteConsistency() sont
* executes dans une meme transaction wrapInTransaction pour eviter un etat
* partiellement persiste en cas d'erreur entre les deux flush.
* *
* @implements ProcessorInterface<User, User> * @implements ProcessorInterface<User, User>
*/ */
@@ -88,13 +97,51 @@ final class UserRbacProcessor implements ProcessorInterface
} }
} }
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context); // Detection de la mutation de la collection `sites` avant tout flush.
// La collection est deja denormalisee dans $data quand process() est appele.
// On utilise PersistentCollection::isDirty() pour savoir si l'ORM a detecte
// une modification depuis le chargement initial (ajout/retrait d'elements).
$sitesCollection = $data->getSites();
$sitesWereMutated = $sitesCollection instanceof PersistentCollection
&& $sitesCollection->isDirty();
// Garde coherence currentSite (ticket 2 module Sites). // Capture de l'ID du currentSite avant persist pour la detection post-flush.
// Post-persist : le champ `sites` a ete applique par le persist processor. $originalCurrentSiteId = $data->getCurrentSite()?->getId();
// On s'assure que `currentSite` pointe toujours vers un site present
// dans la collection ou est recale automatiquement. // Garde sites.manage : la modification de la collection de sites rattaches
$this->ensureCurrentSiteConsistency($data); // a un user est une operation sensible qui requiert une permission distincte
// de core.users.manage (evite le bypass de sites.manage via /rbac).
if ($sitesWereMutated && !$this->security->isGranted('sites.manage')) {
throw new AccessDeniedHttpException(
'La modification des sites rattaches a un user requiert la permission sites.manage.'
);
}
// Persistance + correction de coherence currentSite dans une seule transaction.
// wrapInTransaction rollback automatiquement sur toute exception et la re-lance,
// ce qui preserve le comportement attendu pour BadRequestHttpException.
$result = null;
$this->entityManager->wrapInTransaction(function () use (
$data,
$operation,
$uriVariables,
$context,
$sitesWereMutated,
$originalCurrentSiteId,
&$result,
): void {
$result = $this->persistProcessor->process($data, $operation, $uriVariables, $context);
// Garde coherence currentSite (ticket 2 module Sites).
// Post-persist : le champ `sites` a ete applique par le persist processor.
// On s'assure que `currentSite` pointe toujours vers un site present
// dans la collection ou est recale automatiquement — mais UNIQUEMENT si
// les sites ou le currentSite ont effectivement ete touches dans ce PATCH.
$currentSiteChangedByPersist = $originalCurrentSiteId !== $data->getCurrentSite()?->getId();
if ($sitesWereMutated || $currentSiteChangedByPersist) {
$this->ensureCurrentSiteConsistency($data);
}
});
return $result; return $result;
} }
@@ -104,11 +151,14 @@ final class UserRbacProcessor implements ProcessorInterface
* - si l'actuel n'est plus dans `sites` apres update → repasse a null ; * - si l'actuel n'est plus dans `sites` apres update → repasse a null ;
* - si null et `sites` non vide → auto-selectionne le premier site * - si null et `sites` non vide → auto-selectionne le premier site
* (coherent avec le choix de ne jamais laisser un user rattache a * (coherent avec le choix de ne jamais laisser un user rattache a
* plusieurs sites sans contexte courant). * plusieurs sites sans contexte courant apres une mutation effective).
* *
* N'emet un flush additionnel que si une correction a ete necessaire : * N'emet un flush additionnel que si une correction a ete necessaire :
* pas de cout DB sur la majorite des requetes /rbac qui ne touchent pas * pas de cout DB sur la majorite des requetes /rbac qui ne touchent pas
* aux sites. * aux sites.
*
* Cette methode ne doit etre appelee que si les sites ont reellement
* ete mutes dans la requete courante (voir logique dans process()).
*/ */
private function ensureCurrentSiteConsistency(User $user): void private function ensureCurrentSiteConsistency(User $user): void
{ {

View File

@@ -14,6 +14,7 @@ use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post; use ApiPlatform\Metadata\Post;
use App\Module\Core\Domain\Entity\User; use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository; use App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository;
use App\Shared\Domain\Contract\SiteInterface;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\Collection;
@@ -75,7 +76,7 @@ use Symfony\Component\Validator\Constraints as Assert;
#[ORM\UniqueConstraint(name: 'uniq_site_name', columns: ['name'])] #[ORM\UniqueConstraint(name: 'uniq_site_name', columns: ['name'])]
#[ORM\HasLifecycleCallbacks] #[ORM\HasLifecycleCallbacks]
#[UniqueEntity(fields: ['name'], message: 'Un site avec ce nom existe deja.')] #[UniqueEntity(fields: ['name'], message: 'Un site avec ce nom existe deja.')]
class Site class Site implements SiteInterface
{ {
#[ORM\Id] #[ORM\Id]
#[ORM\GeneratedValue] #[ORM\GeneratedValue]

View File

@@ -4,24 +4,15 @@ declare(strict_types=1);
namespace App\Module\Sites\Domain\Exception; namespace App\Module\Sites\Domain\Exception;
use App\Module\Sites\Domain\Entity\Site; use App\Shared\Domain\Exception\SiteNotAuthorizedException as SharedSiteNotAuthorizedException;
use DomainException;
/** /**
* Levee lorsqu'un utilisateur tente de selectionner comme site courant un * Alias de retrocompatibilite vers Shared\Domain\Exception\SiteNotAuthorizedException.
* site qui ne fait pas partie de ses sites autorises.
* *
* Exception purement domaine : la traduction HTTP (403) est faite par le * La classe canonique a ete deplacee dans Shared pour rompre le couplage
* CurrentSiteProcessor via try/catch, aligne sur le pattern * Core → Sites. Les consommateurs existants dans le module Sites
* SystemRoleDeletionException du module Core. * (CurrentSiteProcessor) continuent de l'attraper ici sans modification.
*
* @see SharedSiteNotAuthorizedException
*/ */
final class SiteNotAuthorizedException extends DomainException final class SiteNotAuthorizedException extends SharedSiteNotAuthorizedException {}
{
public static function forSite(Site $site): self
{
return new self(sprintf(
'Le site "%s" ne fait pas partie de vos sites autorises.',
$site->getName(),
));
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace App\Module\Sites\Infrastructure\ApiPlatform\Extension;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\SecurityBundle\Security;
use function sprintf;
/**
* Extension API Platform qui restreint les collections et items de la
* resource Site (/api/sites) aux seuls sites auxquels l'utilisateur
* authentifie est rattache (ticket module Sites — prevention de la fuite
* de donnees cross-tenant).
*
* `Site` n'implemente pas `SiteAwareInterface` (ce serait circulaire : un
* site ne s'appartient pas a lui-meme). Cette extension complementaire
* cible specifiquement `Site::class` et applique un filtre IN sur les IDs
* des sites de l'utilisateur.
*
* Comportement selon les cas :
* - resource != Site::class → no-op (les autres resources sont
* gerees par SiteScopedQueryExtension) ;
* - is_granted('sites.bypass_scope') → pas de filtre (admin / bypass) ;
* - user non authentifie → no-op (API Platform renvoie 401 avant) ;
* - user sans aucun site → WHERE 1 = 0 (aucun acces) ;
* - cas normal → WHERE site.id IN (:allowedSites).
*
* Consequence anti-enumeration : GET /api/sites/{id} retourne 404 et non
* 403 si l'item existe mais n'appartient pas aux sites de l'utilisateur
* (comportement natif API Platform quand Doctrine retourne null).
*/
final class SiteCollectionScopedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
public function __construct(
private readonly Security $security,
) {}
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = [],
): void {
$this->applyScope($queryBuilder, $queryNameGenerator, $resourceClass);
}
public function applyToItem(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
array $identifiers,
?Operation $operation = null,
array $context = [],
): void {
$this->applyScope($queryBuilder, $queryNameGenerator, $resourceClass);
}
/**
* Applique le filtre IN sur les IDs de sites autorises si les conditions
* d'application sont remplies. No-op sinon.
*/
private function applyScope(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
): void {
// 1) Cette extension cible uniquement la resource Site.
if (Site::class !== $resourceClass) {
return;
}
// 2) Admin ou user avec bypass explicite : visibilite globale.
if ($this->security->isGranted('sites.bypass_scope')) {
return;
}
// 3) Pas d'user authentifie -> no-op (API Platform gere le 401 en amont).
$user = $this->security->getUser();
if (!$user instanceof User) {
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
// 4) User sans aucun site rattache -> aucun acces possible.
$siteIds = $user->getSites()->map(fn (Site $s) => $s->getId())->toArray();
if (empty($siteIds)) {
$queryBuilder->andWhere('1 = 0');
return;
}
// 5) Cas normal : restriction aux sites autorises de l'utilisateur.
$param = $queryNameGenerator->generateParameterName('allowedSites');
$queryBuilder
->andWhere(sprintf('%s.id IN (:%s)', $rootAlias, $param))
->setParameter($param, $siteIds)
;
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Module\Sites\Infrastructure\ApiPlatform\Extension;
use ApiPlatform\Doctrine\Orm\Extension\QueryCollectionExtensionInterface;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
use Doctrine\ORM\QueryBuilder;
use Symfony\Bundle\SecurityBundle\Security;
use function sprintf;
/**
* Extension API Platform qui restreint /api/users (collection + item) aux
* utilisateurs partageant au moins un site commun avec l'appelant.
*
* Objectif : empecher l'enumeration cross-site des utilisateurs. Sans ce
* filtre, un user du site A pourrait lister tous les users du site B via
* GET /api/users.
*
* Conditions de bypass :
* - is_granted('sites.bypass_scope') → visibilite totale (admin ou bypass
* explicite) ;
* - user non authentifie → no-op (API Platform renvoie 401) ;
*
* Cas particulier — appelant sans aucun site rattache :
* Comportement defensif : l'utilisateur ne voit que lui-meme. Cela evite
* de bloquer completement un user mal configure tout en ne lui revelant
* aucun autre utilisateur.
*
* Strategie DQL : JOIN sur la relation ManyToMany `.sites` + DISTINCT pour
* eviter les doublons si un user partage plusieurs sites avec l'appelant.
* Le alias `s_scope` est utilise pour la jointure intermediaire.
*/
final class UserSiteScopedExtension implements QueryCollectionExtensionInterface, QueryItemExtensionInterface
{
public function __construct(
private readonly Security $security,
) {}
public function applyToCollection(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
?Operation $operation = null,
array $context = [],
): void {
$this->applyScope($queryBuilder, $queryNameGenerator, $resourceClass);
}
public function applyToItem(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
array $identifiers,
?Operation $operation = null,
array $context = [],
): void {
$this->applyScope($queryBuilder, $queryNameGenerator, $resourceClass);
}
/**
* Applique le filtre de partage de site si les conditions d'application
* sont remplies. No-op sinon.
*/
private function applyScope(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
): void {
// 1) Cette extension cible uniquement la resource User.
if (User::class !== $resourceClass) {
return;
}
// 2) Admin ou bypass explicite : visibilite totale.
if ($this->security->isGranted('sites.bypass_scope')) {
return;
}
// 3) Pas d'user authentifie -> no-op (API Platform gere le 401 en amont).
$user = $this->security->getUser();
if (!$user instanceof User) {
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
$callerSiteIds = $user->getSites()->map(fn (Site $s) => $s->getId())->toArray();
// 4) Appelant sans site : comportement defensif -> il ne voit que lui-meme.
if (empty($callerSiteIds)) {
$queryBuilder
->andWhere(sprintf('%s.id = :self', $rootAlias))
->setParameter('self', $user->getId())
;
return;
}
// 5) Cas normal : garder uniquement les users qui partagent au moins
// un site avec l'appelant. JOIN sur la relation ManyToMany `.sites`
// + filtre IN + DISTINCT pour eviter les lignes dupliquees.
$param = $queryNameGenerator->generateParameterName('callerSites');
$queryBuilder
->innerJoin(sprintf('%s.sites', $rootAlias), 's_scope')
->andWhere(sprintf('s_scope.id IN (:%s)', $param))
->setParameter($param, $callerSiteIds)
->distinct()
;
}
}

View File

@@ -10,6 +10,7 @@ use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Exception\SiteNotAuthorizedException; use App\Module\Sites\Domain\Exception\SiteNotAuthorizedException;
use App\Module\Sites\Infrastructure\ApiPlatform\Resource\CurrentSiteResource; use App\Module\Sites\Infrastructure\ApiPlatform\Resource\CurrentSiteResource;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\OptimisticLockException;
use LogicException; use LogicException;
use Symfony\Bundle\SecurityBundle\Security; use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
@@ -21,11 +22,17 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
* Flux : * Flux :
* 1. Recupere l'user authentifie via Security. * 1. Recupere l'user authentifie via Security.
* 2. Extrait le site cible depuis la ressource denormalisee. * 2. Extrait le site cible depuis la ressource denormalisee.
* 3. Valide que le site fait partie des `sites` de l'user — sinon leve * 3. Refresh de l'user depuis la BDD pour eliminer la race condition TOCTOU :
* si un autre thread a revoque le site entre le chargement de session et
* ce PATCH, le refresh garantit que hasSite() reflete l'etat reel en base.
* 4. Valide que le site fait partie des `sites` de l'user — sinon leve
* SiteNotAuthorizedException convertie immediatement en 403. * SiteNotAuthorizedException convertie immediatement en 403.
* 4. Positionne `currentSite`, flush, retourne l'user pour normalisation * 5. Positionne `currentSite`, flush, retourne l'user pour normalisation
* par API Platform via les groupes `me:read` (payload identique a /api/me). * par API Platform via les groupes `me:read` (payload identique a /api/me).
* *
* Les etapes 3-5 sont executees dans une meme transaction pour garantir
* un rollback propre en cas d'erreur entre le refresh et le flush.
*
* @implements ProcessorInterface<CurrentSiteResource, User> * @implements ProcessorInterface<CurrentSiteResource, User>
*/ */
final class CurrentSiteProcessor implements ProcessorInterface final class CurrentSiteProcessor implements ProcessorInterface
@@ -57,15 +64,35 @@ final class CurrentSiteProcessor implements ProcessorInterface
throw new BadRequestHttpException('Le champ "site" est requis.'); throw new BadRequestHttpException('Le champ "site" est requis.');
} }
// Refresh + switchCurrentSite + flush dans une transaction atomique.
// Le refresh elimine la race condition TOCTOU : si un PATCH /rbac concurrent
// a revoque le site de l'user entre le chargement de session et ici, le
// refresh force un re-fetch de l'user et de sa collection de sites depuis
// la BDD, garantissant que hasSite() reflete l'etat reel persisté.
try { try {
$user->switchCurrentSite($targetSite); $this->entityManager->wrapInTransaction(function () use ($user, $targetSite): void {
} catch (SiteNotAuthorizedException $e) { // Re-fetch de l'user + ses collections depuis la BDD (elimination TOCTOU).
// Traduction HTTP immediate (pas de listener kernel necessaire) : $this->entityManager->refresh($user);
// aligne sur le pattern RoleProcessor → SystemRoleDeletionException.
throw new AccessDeniedHttpException($e->getMessage(), $e);
}
$this->entityManager->flush(); try {
$user->switchCurrentSite($targetSite);
} catch (SiteNotAuthorizedException $e) {
// Traduction HTTP immediate (pas de listener kernel necessaire) :
// aligne sur le pattern RoleProcessor → SystemRoleDeletionException.
throw new AccessDeniedHttpException($e->getMessage(), $e);
}
$this->entityManager->flush();
});
} catch (OptimisticLockException $e) {
// Protection future : si un champ @Version est ajoute sur User,
// le conflit de version sera intercepte ici plutot que de remonter
// comme une erreur generique.
throw new BadRequestHttpException(
'Conflit de version detecte lors du changement de site courant. Veuillez reessayer.',
$e,
);
}
return $user; return $user;
} }

View File

@@ -6,9 +6,13 @@ namespace App\Module\Sites\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation; use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface; use ApiPlatform\State\ProcessorInterface;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Application\Service\CurrentSiteProviderInterface; use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Sites\Domain\Entity\Site;
use App\Shared\Domain\Contract\SiteAwareInterface; use App\Shared\Domain\Contract\SiteAwareInterface;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\AsDecorator; use Symfony\Component\DependencyInjection\Attribute\AsDecorator;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/** /**
@@ -23,8 +27,11 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
* *
* Comportement : * Comportement :
* - $data pas SiteAware -> delegation directe (no-op). * - $data pas SiteAware -> delegation directe (no-op).
* - $data SiteAware avec site deja positionne -> delegation directe * - $data SiteAware avec site deja positionne, appelant a `sites.bypass_scope`
* (l'admin qui envoie un site explicite garde ce site). * -> delegation directe (ex: admin qui cree une entite dans un autre site).
* - $data SiteAware avec site deja positionne, appelant SANS `sites.bypass_scope`
* -> validation que le site precise appartient aux sites autorises de l'user.
* Si non, leve AccessDeniedHttpException (cross-site write interdite).
* - $data SiteAware sans site, provider retourne un Site -> injection * - $data SiteAware sans site, provider retourne un Site -> injection
* puis delegation. * puis delegation.
* - $data SiteAware sans site, provider retourne null -> throw 400 * - $data SiteAware sans site, provider retourne null -> throw 400
@@ -43,20 +50,43 @@ final class SiteAwareInjectionProcessor implements ProcessorInterface
public function __construct( public function __construct(
private readonly ProcessorInterface $inner, private readonly ProcessorInterface $inner,
private readonly CurrentSiteProviderInterface $currentSiteProvider, private readonly CurrentSiteProviderInterface $currentSiteProvider,
private readonly Security $security,
) {} ) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{ {
if ($data instanceof SiteAwareInterface && null === $data->getSite()) { if ($data instanceof SiteAwareInterface) {
$currentSite = $this->currentSiteProvider->get(); if (null !== $data->getSite()) {
// Le payload precise un site explicite : on valide que le site
// appartient aux sites autorises de l'utilisateur courant, sauf
// si celui-ci dispose de la permission `sites.bypass_scope`
// (ex: admin effectuant une operation cross-site).
if (!$this->security->isGranted('sites.bypass_scope')) {
$user = $this->security->getUser();
$explicitSite = $data->getSite();
// hasSite() attend un Site concret. Si l'agent entity fait
// evoluer la signature vers SiteInterface, le instanceof
// reste valide (Site implemente SiteInterface) et le cast
// disparaitra naturellement lors du prochain nettoyage.
if ($user instanceof User && $explicitSite instanceof Site && !$user->hasSite($explicitSite)) {
throw new AccessDeniedHttpException(
'Le site specifie n\'est pas dans les sites autorises pour cet utilisateur.'
);
}
}
} else {
// Aucun site dans le payload : injection automatique depuis le
// site courant de l'utilisateur.
$currentSite = $this->currentSiteProvider->get();
if (null === $currentSite) { if (null === $currentSite) {
throw new BadRequestHttpException( throw new BadRequestHttpException(
'Impossible de creer l\'enregistrement : aucun site selectionne.', 'Impossible de creer l\'enregistrement : aucun site selectionne.',
); );
}
$data->setSite($currentSite);
} }
$data->setSite($currentSite);
} }
return $this->inner->process($data, $operation, $uriVariables, $context); return $this->inner->process($data, $operation, $uriVariables, $context);

View File

@@ -4,8 +4,6 @@ declare(strict_types=1);
namespace App\Shared\Domain\Contract; namespace App\Shared\Domain\Contract;
use App\Module\Sites\Domain\Entity\Site;
/** /**
* Contrat opt-in pour les entites dont la visibilite est scopee par site. * Contrat opt-in pour les entites dont la visibilite est scopee par site.
* *
@@ -16,9 +14,13 @@ use App\Module\Sites\Domain\Entity\Site;
* si le payload ne precise pas de site. * si le payload ne precise pas de site.
* *
* L'implementation concrete doit : * L'implementation concrete doit :
* - Declarer une relation ManyToOne(Site::class) avec colonne `site_id` NOT NULL. * - Declarer une relation ManyToOne vers l'entite concrete Site avec colonne
* `site_id` NOT NULL (targetEntity: \App\Module\Sites\Domain\Entity\Site).
* - Indexer `site_id` en base (sinon le filtre WHERE genere un full-scan). * - Indexer `site_id` en base (sinon le filtre WHERE genere un full-scan).
* *
* Les signatures utilisent SiteInterface (et non la classe concrete Site)
* pour que Shared n'importe pas directement le module Sites.
*
* Ne PAS implementer cette interface pour : * Ne PAS implementer cette interface pour :
* - Des entites globales (catalogue partage, roles, permissions, users). * - Des entites globales (catalogue partage, roles, permissions, users).
* - Des entites dont le scope est "par tenant" plus large que le site * - Des entites dont le scope est "par tenant" plus large que le site
@@ -29,7 +31,7 @@ use App\Module\Sites\Domain\Entity\Site;
*/ */
interface SiteAwareInterface interface SiteAwareInterface
{ {
public function getSite(): ?Site; public function getSite(): ?SiteInterface;
public function setSite(Site $site): void; public function setSite(SiteInterface $site): void;
} }

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Contract;
/**
* Interface minimale exposant ce que le noyau (Shared/Core) doit connaitre
* d'un Site, sans creer de couplage direct vers le module Sites.
*
* Implemente par App\Module\Sites\Domain\Entity\Site.
* Utilisee comme type-hint dans SiteAwareInterface, User et toute entite
* Shared/Core qui manipule un site sans avoir besoin des details metier.
*/
interface SiteInterface
{
public function getId(): ?int;
public function getName(): ?string;
}

View File

@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Shared\Domain\Exception;
use App\Shared\Domain\Contract\SiteInterface;
use DomainException;
/**
* Levee lorsqu'un utilisateur tente de selectionner comme site courant un
* site qui ne fait pas partie de ses sites autorises.
*
* Exception purement domaine : la traduction HTTP (403) est faite par le
* CurrentSiteProcessor via try/catch, aligne sur le pattern
* SystemRoleDeletionException du module Core.
*
* Deplacee dans Shared/Domain/Exception/ pour eviter que le module Core
* n'importe directement depuis le module Sites (violation du principe de
* non-couplage inter-modules).
*/
class SiteNotAuthorizedException extends DomainException
{
public static function forSite(SiteInterface $site): self
{
return new self(sprintf(
'Le site "%s" ne fait pas partie de vos sites autorises.',
$site->getName(),
));
}
}

View File

@@ -6,7 +6,9 @@ namespace App\Tests\Fixtures\SiteAware;
use App\Module\Sites\Domain\Entity\Site; use App\Module\Sites\Domain\Entity\Site;
use App\Shared\Domain\Contract\SiteAwareInterface; use App\Shared\Domain\Contract\SiteAwareInterface;
use App\Shared\Domain\Contract\SiteInterface;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use InvalidArgumentException;
/** /**
* Entite fictive utilisee UNIQUEMENT en tests (ticket 4 module Sites). * Entite fictive utilisee UNIQUEMENT en tests (ticket 4 module Sites).
@@ -57,13 +59,16 @@ class FakeSiteAwareEntity implements SiteAwareInterface
$this->name = $name; $this->name = $name;
} }
public function getSite(): ?Site public function getSite(): ?SiteInterface
{ {
return $this->site; return $this->site;
} }
public function setSite(Site $site): void public function setSite(SiteInterface $site): void
{ {
if (!$site instanceof Site) {
throw new InvalidArgumentException('FakeSiteAwareEntity requires a concrete Site (Doctrine ManyToOne target).');
}
$this->site = $site; $this->site = $site;
} }
} }

View File

@@ -9,6 +9,7 @@ use ApiPlatform\Symfony\Bundle\Test\Client;
use App\Module\Core\Domain\Entity\Permission; use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\Role; use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User; use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
@@ -123,6 +124,19 @@ abstract class AbstractApiTestCase extends ApiTestCase
$user->setIsAdmin(false); $user->setIsAdmin(false);
$user->setPassword($hasher->hashPassword($user, $password)); $user->setPassword($hasher->hashPassword($user, $password));
$user->addRbacRole($role); $user->addRbacRole($role);
// Le helper attache le user jetable a tous les sites existants pour
// neutraliser le filtrage par UserSiteScopedExtension : la plupart
// des tests assume une visibilite globale sur les users cibles. Les
// tests qui valident le comportement "sans sites" doivent creer leur
// user a la main (pas via ce helper).
$siteRepository = $em->getRepository(Site::class);
if (null !== $siteRepository) {
foreach ($siteRepository->findAll() as $site) {
$user->addSite($site);
}
}
$em->persist($user); $em->persist($user);
$em->flush(); $em->flush();

View File

@@ -7,6 +7,7 @@ namespace App\Tests\Module\Core\Api;
use App\Module\Core\Domain\Entity\Permission; use App\Module\Core\Domain\Entity\Permission;
use App\Module\Core\Domain\Entity\Role; use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User; use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface; use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/** /**
@@ -41,11 +42,18 @@ final class UserRbacApiTest extends AbstractApiTestCase
/** @var UserPasswordHasherInterface $hasher */ /** @var UserPasswordHasherInterface $hasher */
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class); $hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
// User cible standard (non admin). // User cible standard (non admin). On lui attache tous les sites
// fixtures pour rester visible depuis les callers non-admin munis de
// sites (cf. UserSiteScopedExtension qui filtre `/api/users` par
// intersection de sites). Sans cela, un user `core.users.manage`
// sans site commun avec test_target recevrait un 404 sur le PATCH.
$target = new User(); $target = new User();
$target->setUsername('test_target'); $target->setUsername('test_target');
$target->setIsAdmin(false); $target->setIsAdmin(false);
$target->setPassword($hasher->hashPassword($target, 'secret')); $target->setPassword($hasher->hashPassword($target, 'secret'));
foreach ($em->getRepository(Site::class)->findAll() as $site) {
$target->addSite($site);
}
$em->persist($target); $em->persist($target);
// User admin dedie pour le cas d'auto-suicide (pas l'admin fixture). // User admin dedie pour le cas d'auto-suicide (pas l'admin fixture).

View File

@@ -50,6 +50,14 @@ final class UserRbacProcessorTest extends TestCase
$this->entityManager->method('getUnitOfWork')->willReturn($this->unitOfWork); $this->entityManager->method('getUnitOfWork')->willReturn($this->unitOfWork);
// wrapInTransaction doit executer reellement la closure pour que le
// resultat de persistProcessor->process() soit capture dans $result.
// Sans ce stub, la closure n'est jamais invoquee et $result reste null.
$this->entityManager
->method('wrapInTransaction')
->willReturnCallback(static fn (callable $fn) => $fn())
;
$this->processor = new UserRbacProcessor( $this->processor = new UserRbacProcessor(
$this->persistProcessor, $this->persistProcessor,
$this->entityManager, $this->entityManager,

View File

@@ -39,7 +39,14 @@ final class CurrentSiteSwitchApiTest extends AbstractApiTestCase
public function testUserCannotSwitchToUnauthorizedSite(): void public function testUserCannotSwitchToUnauthorizedSite(): void
{ {
// alice n'a que Chatellerault. Tenter Pommevic → 403. // alice n'a que Chatellerault. Tenter Pommevic → 400 (anti-enumeration).
//
// Depuis l'ajout de SiteCollectionScopedExtension, les sites hors
// du scope de l'user sont filtres a la source : l'IriConverter ne
// peut pas resoudre `/api/sites/{id}` pour un site non autorise et
// leve 400 "Item not found". Reponse identique a "site inexistant",
// ce qui empeche l'enumeration des ids de sites tiers. Avant la PR
// scope, le processor traduisait SiteNotAuthorizedException → 403.
$em = $this->getEm(); $em = $this->getEm();
$pommevic = $em->getRepository(Site::class)->findOneBy(['name' => 'Pommevic']); $pommevic = $em->getRepository(Site::class)->findOneBy(['name' => 'Pommevic']);
self::assertNotNull($pommevic); self::assertNotNull($pommevic);
@@ -50,7 +57,7 @@ final class CurrentSiteSwitchApiTest extends AbstractApiTestCase
'json' => ['site' => '/api/sites/'.$pommevic->getId()], 'json' => ['site' => '/api/sites/'.$pommevic->getId()],
]); ]);
self::assertResponseStatusCodeSame(403); self::assertResponseStatusCodeSame(400);
} }
public function testSwitchWithMissingSiteFieldReturns400(): void public function testSwitchWithMissingSiteFieldReturns400(): void

View File

@@ -11,8 +11,10 @@ use App\Module\Sites\Application\Service\CurrentSiteProviderInterface;
use App\Module\Sites\Domain\Entity\Site; use App\Module\Sites\Domain\Entity\Site;
use App\Module\Sites\Infrastructure\ApiPlatform\State\Processor\SiteAwareInjectionProcessor; use App\Module\Sites\Infrastructure\ApiPlatform\State\Processor\SiteAwareInjectionProcessor;
use App\Shared\Domain\Contract\SiteAwareInterface; use App\Shared\Domain\Contract\SiteAwareInterface;
use App\Shared\Domain\Contract\SiteInterface;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use stdClass; use stdClass;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/** /**
@@ -125,20 +127,27 @@ final class SiteAwareInjectionProcessorTest extends TestCase
$provider = $this->createStub(CurrentSiteProviderInterface::class); $provider = $this->createStub(CurrentSiteProviderInterface::class);
$provider->method('get')->willReturn($currentSite); $provider->method('get')->willReturn($currentSite);
return new SiteAwareInjectionProcessor($inner, $provider); // Stub Security : bypass_scope = true par defaut pour preserver le
// comportement des tests historiques (pas de validation cross-site).
// Les tests dedies a la validation cross-site instancient leur propre
// Security via un helper dedie.
$security = $this->createStub(Security::class);
$security->method('isGranted')->willReturn(true);
return new SiteAwareInjectionProcessor($inner, $provider, $security);
} }
private function makeSiteAwareStub(?Site $initialSite): SiteAwareInterface private function makeSiteAwareStub(?Site $initialSite): SiteAwareInterface
{ {
return new class($initialSite) implements SiteAwareInterface { return new class($initialSite) implements SiteAwareInterface {
public function __construct(private ?Site $site) {} public function __construct(private ?SiteInterface $site) {}
public function getSite(): ?Site public function getSite(): ?SiteInterface
{ {
return $this->site; return $this->site;
} }
public function setSite(Site $site): void public function setSite(SiteInterface $site): void
{ {
$this->site = $site; $this->site = $site;
} }