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:
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
20
src/Shared/Domain/Contract/SiteInterface.php
Normal file
20
src/Shared/Domain/Contract/SiteInterface.php
Normal 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;
|
||||||
|
}
|
||||||
31
src/Shared/Domain/Exception/SiteNotAuthorizedException.php
Normal file
31
src/Shared/Domain/Exception/SiteNotAuthorizedException.php
Normal 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(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user