feat(sites) : API CRUD + rattachement User<->Site + admin (ticket 2/4)

Exposition de Site via API Platform (5 operations RBAC sites.view/sites.manage),
relation User.sites (M2M user_site EAGER) + User.currentSite (M2O nullable,
ON DELETE SET NULL). Endpoint PATCH /api/me/current-site via ressource
virtuelle + processor (SiteNotAuthorizedException → 403). UserRbacProcessor
etendu avec gardes post-persist : auto-reset si currentSite retire, auto-select
premier site si null + sites non vide.

Page /admin/sites (DataTable + drawer creation/edition + modale suppression).
UserRbacDrawer etendu avec section "Sites autorises". Colonne "Sites" ajoutee
dans la table /admin/users (liste des noms separes par virgule). Sidebar
entree Sites (module: sites, permission: sites.view).

Refactor adresse : split full_address en street + complement (nullable) + getter
computed Site::getFullAddress() multi-lignes. Migration ALTER dediee pour
compat devs ayant deja joue le ticket 1. Fixtures avec vraies adresses
(Chatellerault/Fontenet/Pommevic).

Doctrine : inversedBy synchrone User.sites <-> Site.users pour maintenir la
collection inverse en memoire. User::switchCurrentSite() porte la garde
domaine (throw SiteNotAuthorizedException), aligne sur Role::ensureDeletable.
Helper skipIfSitesModuleDisabled centralise dans AbstractApiTestCase.

Tests : 182/182 (182/182 aussi module desactive, 2 skipped). 29 nouveaux tests
PHPUnit (CRUD API, switch currentSite, cascade DB, /api/me enrichi, extension
/rbac, gardes structurelles fullAddress/currentSite ignores, anti-cycle
Site.users). 11 tests Vitest sur la validation hex couleur.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-20 10:09:05 +02:00
parent 105574ba2f
commit d137828919
32 changed files with 2271 additions and 117 deletions

View File

@@ -48,6 +48,13 @@ return [
'module' => 'core',
'permission' => 'core.users.view',
],
[
'label' => 'sidebar.core.sites',
'to' => '/admin/sites',
'icon' => 'mdi:domain',
'module' => 'sites',
'permission' => 'sites.view',
],
[
'label' => 'sidebar.general.logout',
'to' => '/logout',

View File

@@ -25,7 +25,8 @@
},
"core": {
"roles": "Gestion des rôles",
"users": "Utilisateurs"
"users": "Utilisateurs",
"sites": "Sites"
}
},
"dashboard": {
@@ -54,6 +55,9 @@
"put": "Erreur lors de la mise a jour",
"patch": "Erreur lors de la modification",
"delete": "Erreur lors de la suppression"
},
"sites": {
"notAuthorized": "Vous n'êtes pas autorisé à sélectionner ce site."
}
},
"success": {
@@ -102,7 +106,8 @@
"username": "Nom d'utilisateur",
"admin": "Administrateur",
"roles": "Roles",
"directPermissions": "Permissions directes"
"directPermissions": "Permissions directes",
"sites": "Sites"
},
"drawer": {
"title": "Permissions de {username}",
@@ -110,6 +115,7 @@
"adminToggle": "Administrateur (bypass total)",
"rolesSection": "Rôles",
"directPermissionsSection": "Permissions directes",
"sitesSection": "Sites autorisés",
"summarySection": "Résumé des permissions effectives",
"noEffectivePermissions": "Aucune permission effective",
"sourceRole": "via {role}",
@@ -119,6 +125,39 @@
"toast": {
"updated": "Permissions mises à jour avec succès"
}
},
"sites": {
"title": "Gestion des sites",
"newSite": "Nouveau site",
"editSite": "Modifier le site",
"createSite": "Créer un site",
"noSites": "Aucun site configuré",
"table": {
"name": "Nom",
"city": "Ville",
"postalCode": "Code postal",
"color": "Couleur",
"fullAddress": "Adresse complète"
},
"form": {
"name": "Nom",
"street": "Rue",
"complement": "Complément d'adresse",
"complementPlaceholder": "Bâtiment, escalier, BP... (optionnel)",
"postalCode": "Code postal",
"city": "Ville",
"color": "Couleur (format #RRGGBB)",
"colorInvalid": "Format attendu : #RRGGBB (6 caractères hexadécimaux)"
},
"delete": {
"title": "Supprimer le site",
"message": "Êtes-vous sûr de vouloir supprimer le site \"{name}\" ? Cette action est irréversible et retirera ce site à tous les utilisateurs rattachés."
},
"toast": {
"created": "Site créé avec succès",
"updated": "Site mis à jour avec succès",
"deleted": "Site supprimé avec succès"
}
}
}
}

View File

@@ -64,6 +64,27 @@
</div>
</div>
<!-- Section Sites autorises (ticket 2 module Sites) -->
<div>
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
{{ t('admin.users.drawer.sitesSection') }}
</h4>
<div v-if="allSites.length === 0" class="text-sm text-neutral-400">
{{ t('admin.sites.noSites') }}
</div>
<div class="flex flex-col gap-2">
<MalioCheckbox
v-for="site in allSites"
:id="`site-${site.id}`"
:key="site.id"
:label="site.name"
:model-value="selectedSiteIds.has(site.id)"
label-class="text-sm text-neutral-600"
@update:model-value="(val: boolean) => toggleSite(site.id, val)"
/>
</div>
</div>
<!-- Section Resume permissions effectives -->
<div>
<h4 class="mb-3 text-sm font-semibold text-neutral-700">
@@ -92,6 +113,7 @@
<script setup lang="ts">
import type { Permission, Role, UserListItem, EffectivePermission } from '~/shared/types/rbac'
import type { Site } from '~/shared/types/sites'
interface PermissionModule {
module: string
@@ -115,10 +137,12 @@ const emit = defineEmits<{
const saving = ref(false)
const allRoles = ref<Role[]>([])
const allPermissions = ref<Permission[]>([])
const allSites = ref<Site[]>([])
const form = ref({ isAdmin: false })
const selectedRoleIds = ref(new Set<number>())
const selectedDirectPermissionIds = ref(new Set<number>())
const selectedSiteIds = ref(new Set<number>())
// Detecter l'auto-edition
const isSelfEdit = computed(() => props.user?.id === auth.user?.id)
@@ -182,14 +206,17 @@ const effectivePermissions = computed<EffectivePermission[]>(() => {
.sort((a, b) => a.code.localeCompare(b.code))
})
// Charger roles et permissions
// Charger roles, permissions et sites en parallele pour minimiser le TTFB
// a l'ouverture du drawer.
async function loadData() {
const [rolesData, permsData] = await Promise.all([
const [rolesData, permsData, sitesData] = await Promise.all([
api.get<{ member: Role[] }>('/roles', {}, { toast: false }),
api.get<{ member: Permission[] }>('/permissions', { orphan: false, itemsPerPage: 999 }, { toast: false }),
api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: false }),
])
allRoles.value = rolesData.member
allPermissions.value = permsData.member
allSites.value = sitesData.member
}
// Remplir le formulaire quand le user change
@@ -198,10 +225,12 @@ watch(() => props.user, (user) => {
form.value.isAdmin = user.isAdmin
selectedRoleIds.value = new Set(user.roles.map(iriToId))
selectedDirectPermissionIds.value = new Set(user.directPermissions.map(iriToId))
selectedSiteIds.value = new Set((user.sites ?? []).map(iriToId))
} else {
form.value.isAdmin = false
selectedRoleIds.value = new Set()
selectedDirectPermissionIds.value = new Set()
selectedSiteIds.value = new Set()
}
}, { immediate: true })
@@ -235,6 +264,13 @@ function handleToggleAll(module: string, selected: boolean) {
selectedDirectPermissionIds.value = ids
}
function toggleSite(id: number, selected: boolean) {
const ids = new Set(selectedSiteIds.value)
if (selected) ids.add(id)
else ids.delete(id)
selectedSiteIds.value = ids
}
async function handleSave() {
if (!props.user) return
saving.value = true
@@ -243,6 +279,7 @@ async function handleSave() {
isAdmin: form.value.isAdmin,
roles: Array.from(selectedRoleIds.value).map(id => `/api/roles/${id}`),
directPermissions: Array.from(selectedDirectPermissionIds.value).map(id => `/api/permissions/${id}`),
sites: Array.from(selectedSiteIds.value).map(id => `/api/sites/${id}`),
}, {
toastSuccessMessage: t('admin.users.toast.updated'),
})

View File

@@ -38,6 +38,7 @@
<script setup lang="ts">
import type { UserListItem } from '~/shared/types/rbac'
import type { Site } from '~/shared/types/sites'
const { t } = useI18n()
const api = useApi()
@@ -48,6 +49,7 @@ useHead({ title: t('admin.users.title') })
const canManage = computed(() => can('core.users.manage'))
const users = ref<UserListItem[]>([])
const sitesById = ref(new Map<number, Site>())
const loading = ref(false)
const drawerOpen = ref(false)
const selectedUser = ref<UserListItem | null>(null)
@@ -57,8 +59,14 @@ const columns = [
{ key: 'admin', label: t('admin.users.table.admin') },
{ key: 'roles', label: t('admin.users.table.roles') },
{ key: 'directPermissions', label: t('admin.users.table.directPermissions') },
{ key: 'sites', label: t('admin.users.table.sites') },
]
// Extraire l'id numerique depuis une IRI API Platform type `/api/sites/3`.
function iriToId(iri: string): number {
return Number(iri.split('/').pop())
}
const userItems = computed(() =>
users.value.map(user => ({
id: user.id,
@@ -66,18 +74,27 @@ const userItems = computed(() =>
admin: user.isAdmin,
roles: user.roles.length,
directPermissions: user.directPermissions.length,
}))
// Affichage : liste des noms de sites separes par virgule. Les IRIs
// du payload /api/users (groupe user:list) sont resolues via la Map
// construite en parallele depuis /api/sites.
sites: (user.sites ?? [])
.map(iri => sitesById.value.get(iriToId(iri))?.name)
.filter((name): name is string => Boolean(name))
.join(', '),
})),
)
async function loadUsers() {
loading.value = true
try {
const data = await api.get<{ member: UserListItem[] }>(
'/users',
{},
{ toast: false },
)
users.value = data.member
// Chargement parallele : les sites alimentent la Map de resolution
// IRI→name pour la colonne "Sites" de la table.
const [usersData, sitesData] = await Promise.all([
api.get<{ member: UserListItem[] }>('/users', {}, { toast: false }),
api.get<{ member: Site[] }>('/sites', { itemsPerPage: 999 }, { toast: false }),
])
users.value = usersData.member
sitesById.value = new Map(sitesData.member.map(s => [s.id, s]))
} finally {
loading.value = false
}

View File

@@ -0,0 +1,76 @@
<template>
<Teleport to="body">
<Transition name="fade">
<div
v-if="modelValue"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
@click.self="cancel"
>
<div class="w-full max-w-md rounded-lg bg-white p-6 shadow-xl">
<h3 class="text-lg font-semibold text-neutral-900">
{{ t('admin.sites.delete.title') }}
</h3>
<p class="mt-3 text-sm text-neutral-600">
{{ t('admin.sites.delete.message', { name: siteName }) }}
</p>
<div class="mt-6 flex justify-end gap-3">
<MalioButton
:label="t('common.cancel')"
variant="secondary"
@click="cancel"
/>
<MalioButton
:label="t('common.delete')"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
:disabled="loading"
@click="confirm"
/>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
const { t } = useI18n()
defineProps<{
modelValue: boolean
siteName: string
loading: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
confirm: []
}>()
function cancel() {
emit('update:modelValue', false)
}
function confirm() {
emit('confirm')
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') cancel()
}
onMounted(() => document.addEventListener('keydown', onKeydown))
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,185 @@
<template>
<MalioDrawer
:model-value="modelValue"
:title="isEditMode ? t('admin.sites.editSite') : t('admin.sites.createSite')"
drawer-class="w-full max-w-lg"
@update:model-value="emit('update:modelValue', $event)"
>
<form class="flex flex-col gap-6 p-4" @submit.prevent="handleSave">
<MalioInputText
v-model="form.name"
:label="t('admin.sites.form.name')"
input-class="w-full"
required
/>
<MalioInputText
v-model="form.street"
:label="t('admin.sites.form.street')"
input-class="w-full"
required
/>
<MalioInputText
v-model="form.complement"
:label="t('admin.sites.form.complement')"
:placeholder="t('admin.sites.form.complementPlaceholder')"
input-class="w-full"
/>
<!-- Code postal FR : masque "#####" (5 chiffres stricts) +
maxLength en double securite. La regex backend validera la
forme finale, le masque empeche juste la saisie de
caracteres non numeriques. -->
<MalioInputText
v-model="form.postalCode"
:label="t('admin.sites.form.postalCode')"
input-class="w-full"
mask="#####"
max-length="5"
required
/>
<MalioInputText
v-model="form.city"
:label="t('admin.sites.form.city')"
input-class="w-full"
required
/>
<!-- Champ couleur avec preview puce -->
<div>
<label class="mb-1 block text-sm font-semibold text-neutral-700">
{{ t('admin.sites.form.color') }}
</label>
<div class="flex items-center gap-3">
<MalioInputText
v-model="form.color"
placeholder="#RRGGBB"
input-class="w-full font-mono"
required
/>
<span
:style="{ backgroundColor: isValidHex ? form.color : 'transparent' }"
class="inline-block size-10 shrink-0 rounded-lg border border-neutral-200"
:class="{ 'border-dashed': !isValidHex }"
/>
</div>
<p v-if="form.color && !isValidHex" class="mt-1 text-xs text-red-600">
{{ t('admin.sites.form.colorInvalid') }}
</p>
</div>
<!-- Boutons -->
<div class="flex justify-end gap-3 border-t border-neutral-200 pt-4">
<MalioButton
v-if="isEditMode"
:label="t('common.delete')"
variant="danger"
icon-name="mdi:delete-outline"
icon-position="left"
@click="emit('delete')"
/>
<MalioButton
v-else
:label="t('common.cancel')"
variant="tertiary"
@click="emit('update:modelValue', false)"
/>
<MalioButton
:label="t('common.save')"
variant="primary"
:disabled="saving || !isValidHex"
@click="handleSave"
/>
</div>
</form>
</MalioDrawer>
</template>
<script setup lang="ts">
import type { Site } from '~/shared/types/sites'
import { isValidSiteColor } from '~/modules/sites/utils/color'
const { t } = useI18n()
const api = useApi()
const props = defineProps<{
modelValue: boolean
site: Site | null
}>()
const emit = defineEmits<{
'update:modelValue': [value: boolean]
saved: []
delete: []
}>()
const saving = ref(false)
const form = ref({
name: '',
street: '',
complement: '',
postalCode: '',
city: '',
color: '#000000',
})
const isEditMode = computed(() => props.site !== null)
// Validation locale du format hex #RRGGBB avant envoi backend.
const isValidHex = computed(() => isValidSiteColor(form.value.color))
// Remplir le formulaire quand le site change
watch(() => props.site, (site) => {
if (site) {
form.value.name = site.name
form.value.street = site.street
form.value.complement = site.complement ?? ''
form.value.postalCode = site.postalCode
form.value.city = site.city
form.value.color = site.color
} else {
form.value.name = ''
form.value.street = ''
form.value.complement = ''
form.value.postalCode = ''
form.value.city = ''
form.value.color = '#056CF2'
}
}, { immediate: true })
async function handleSave() {
if (!isValidHex.value) return
saving.value = true
try {
// Le champ complement est optionnel cote DB : on envoie null si vide
// pour que le backend stocke NULL plutot qu'une chaine vide.
const trimmedComplement = form.value.complement.trim()
const payload = {
name: form.value.name,
street: form.value.street,
complement: trimmedComplement === '' ? null : trimmedComplement,
postalCode: form.value.postalCode,
city: form.value.city,
color: form.value.color,
}
if (isEditMode.value && props.site) {
await api.patch(`/sites/${props.site.id}`, payload, {
toastSuccessMessage: t('admin.sites.toast.updated'),
})
} else {
await api.post('/sites', payload, {
toastSuccessMessage: t('admin.sites.toast.created'),
})
}
emit('saved')
emit('update:modelValue', false)
} finally {
saving.value = false
}
}
</script>

View File

@@ -0,0 +1 @@
export default defineNuxtConfig({})

View File

@@ -0,0 +1,164 @@
<template>
<div>
<!-- En-tete -->
<div class="flex items-center justify-between">
<h1 class="text-xl font-bold text-primary-500 sm:text-2xl">
{{ t('admin.sites.title') }}
</h1>
<MalioButton
v-if="can('sites.manage')"
:label="t('admin.sites.newSite')"
icon-name="mdi:plus"
icon-position="left"
@click="openCreateDrawer"
/>
</div>
<!-- Table des sites -->
<MalioDataTable
class="mt-6"
:columns="columns"
:items="siteItems"
:total-items="sites.length"
:row-clickable="canManage"
:empty-message="t('admin.sites.noSites')"
@row-click="onRowClick"
>
<template #cell-color="{ item }">
<span class="inline-flex items-center gap-2">
<span
:style="{ backgroundColor: item.color }"
class="inline-block size-5 rounded-full border border-neutral-200"
/>
<span class="font-mono text-xs">{{ item.color }}</span>
</span>
</template>
<template #cell-fullAddress="{ item }">
<span class="line-clamp-2 text-xs text-neutral-600">
{{ item.fullAddress }}
</span>
</template>
</MalioDataTable>
<!-- Drawer creation/edition -->
<SiteDrawer
v-model="drawerOpen"
:site="selectedSite"
@saved="onSiteSaved"
@delete="onDeleteRequest"
/>
<!-- Modale de suppression -->
<SiteDeleteModal
v-model="deleteModalOpen"
:site-name="siteToDelete?.name ?? ''"
:loading="deleting"
@confirm="handleDelete"
/>
</div>
</template>
<script setup lang="ts">
import type { Site } from '~/shared/types/sites'
const { t } = useI18n()
const api = useApi()
const { can } = usePermissions()
const canManage = computed(() => can('sites.manage'))
useHead({ title: t('admin.sites.title') })
const sites = ref<Site[]>([])
const loading = ref(false)
const columns = [
{ key: 'name', label: t('admin.sites.table.name') },
{ key: 'city', label: t('admin.sites.table.city') },
{ key: 'postalCode', label: t('admin.sites.table.postalCode') },
{ key: 'color', label: t('admin.sites.table.color') },
{ key: 'fullAddress', label: t('admin.sites.table.fullAddress') },
]
// Transformer les sites en items compatibles MalioDataTable.
// `fullAddress` provient du getter computed cote backend (Site::getFullAddress)
// au format multi-lignes — on l'aplatit en virgules pour l'affichage table.
const siteItems = computed(() =>
sites.value.map(site => ({
id: site.id,
name: site.name,
city: site.city,
postalCode: site.postalCode,
color: site.color,
fullAddress: site.fullAddress.split('\n').join(', '),
})),
)
function getSiteById(id: number): Site | undefined {
return sites.value.find(s => s.id === id)
}
function onRowClick(item: Record<string, unknown>) {
const site = getSiteById(item.id as number)
if (site) openEditDrawer(site)
}
const drawerOpen = ref(false)
const selectedSite = ref<Site | null>(null)
const deleteModalOpen = ref(false)
const siteToDelete = ref<Site | null>(null)
const deleting = ref(false)
async function loadSites() {
loading.value = true
try {
const data = await api.get<{ member: Site[] }>(
'/sites',
{ itemsPerPage: 999 },
{ toast: false },
)
sites.value = data.member
} finally {
loading.value = false
}
}
function openCreateDrawer() {
selectedSite.value = null
drawerOpen.value = true
}
function openEditDrawer(site: Site) {
selectedSite.value = site
drawerOpen.value = true
}
function onDeleteRequest() {
if (!selectedSite.value) return
siteToDelete.value = selectedSite.value
deleteModalOpen.value = true
}
async function handleDelete() {
if (!siteToDelete.value) return
deleting.value = true
try {
await api.delete(`/sites/${siteToDelete.value.id}`, {}, {
toastSuccessMessage: t('admin.sites.toast.deleted'),
})
deleteModalOpen.value = false
siteToDelete.value = null
drawerOpen.value = false
await loadSites()
} finally {
deleting.value = false
}
}
function onSiteSaved() {
loadSites()
}
onMounted(() => {
loadSites()
})
</script>

View File

@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest'
import { isValidSiteColor } from '../color'
describe('isValidSiteColor', () => {
it('accepte un hex majuscule', () => {
expect(isValidSiteColor('#ABCDEF')).toBe(true)
})
it('accepte un hex minuscule', () => {
expect(isValidSiteColor('#abcdef')).toBe(true)
})
it('accepte un hex mixte', () => {
expect(isValidSiteColor('#0a1B2c')).toBe(true)
})
it('accepte les couleurs fixtures du projet', () => {
expect(isValidSiteColor('#056CF2')).toBe(true)
expect(isValidSiteColor('#10B981')).toBe(true)
expect(isValidSiteColor('#F59E0B')).toBe(true)
})
it('rejette un nom CSS', () => {
expect(isValidSiteColor('red')).toBe(false)
})
it('rejette un hex court (3 caracteres)', () => {
expect(isValidSiteColor('#FFF')).toBe(false)
})
it('rejette un hex sans diese', () => {
expect(isValidSiteColor('FFFFFF')).toBe(false)
})
it('rejette un format rgb()', () => {
expect(isValidSiteColor('rgb(255, 0, 0)')).toBe(false)
})
it('rejette un hex trop long', () => {
expect(isValidSiteColor('#1234567')).toBe(false)
})
it('rejette un caractere non hex', () => {
expect(isValidSiteColor('#12345G')).toBe(false)
})
it('rejette une chaine vide', () => {
expect(isValidSiteColor('')).toBe(false)
})
})

View File

@@ -0,0 +1,15 @@
/**
* Validation du format de couleur d'un site.
*
* Aligne sur la regex backend (Site entity) : seul le format #RRGGBB
* strict est accepte, avec 6 caracteres hexadecimaux apres le #.
* Tolere la casse (majuscules, minuscules, mixte).
*
* Utilise par SiteDrawer pour bloquer le submit cote front avant qu'une
* requete invalide parte au backend.
*/
const HEX_COLOR_REGEX = /^#[0-9A-Fa-f]{6}$/
export function isValidSiteColor(hex: string): boolean {
return HEX_COLOR_REGEX.test(hex)
}

View File

@@ -21,6 +21,8 @@ export interface UserListItem {
isAdmin: boolean
roles: string[]
directPermissions: string[]
/** IRIs des sites autorises (ticket 2 module Sites). */
sites: string[]
}
export interface EffectivePermission {

View File

@@ -0,0 +1,24 @@
export interface Site {
id: number
name: string
street: string
complement: string | null
postalCode: string
city: string
color: string
/** Adresse complete reconstituee cote backend (getter computed). Lecture seule. */
fullAddress: string
}
/**
* Payload accepte en POST/PATCH /api/sites. Volontairement sans `fullAddress`
* (computed cote backend) ni champs read-only (id, timestamps).
*/
export interface SiteInput {
name: string
street: string
complement: string | null
postalCode: string
city: string
color: string
}

View File

@@ -1,3 +1,5 @@
import type { Site } from './sites'
export interface UserData {
id: number
username: string
@@ -6,4 +8,8 @@ export interface UserData {
isAdmin: boolean
/** Codes de permission effectifs de l'utilisateur, tries alphabetiquement, sans doublon. */
effectivePermissions: string[]
/** Sites autorises pour l'utilisateur (ticket 2 module Sites). */
sites: Site[]
/** Site actuellement selectionne par l'utilisateur, ou null si aucun. */
currentSite: Site | null
}

View File

@@ -39,6 +39,11 @@ final class Version20260417120000 extends AbstractMigration
// - `postal_code` est limite a 10 caracteres pour laisser marge a
// d'eventuels formats etrangers plus tard, tout en le validant
// strictement en 5 chiffres cote applicatif (format FR).
//
// Note : `full_address` est restructure au ticket 2 (migration
// Version20260420130000) en `street` + `complement` (nullable). La
// structure d'origine est conservee ici pour ne pas casser les devs
// qui ont deja joue cette migration en local.
$this->addSql(<<<'SQL'
CREATE TABLE site (
id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL,

View File

@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Module Sites - Ticket 2/4 : rattachement User ↔ Site.
*
* Introduit deux nouvelles structures sur le schema existant :
* - la table de jointure `user_site` (M2M) : liste des sites autorises
* pour chaque utilisateur.
* - la colonne `"user".current_site_id` (M2O nullable) : site actuellement
* selectionne par l'utilisateur pour son contexte UX.
*
* Cascades choisies :
* - `user_site.user_id` → `ON DELETE CASCADE` : supprimer un user purge
* naturellement ses rattachements.
* - `user_site.site_id` → `ON DELETE CASCADE` : supprimer un site purge
* tous les rattachements a ce site.
* - `"user".current_site_id` → `ON DELETE SET NULL` : supprimer un site
* repasse le currentSite des users concernes a NULL (plutot que de
* detruire les users, ce qui serait catastrophique).
*
* Note sur l'emplacement du fichier (namespace racine `DoctrineMigrations`)
* Conforme a l'exception documentee dans `CLAUDE.md` : tant que le bug de
* tri alphabetique des MigrationsComparator Doctrine 3.x n'est pas resolu,
* toute migration touchant a la topologie des tables (creation, FKs
* cross-module) vit au namespace racine. La migration croise ici les tables
* `"user"` (module Core) et `site` (module Sites) — placement racine donc
* justifie pour garantir l'ordre d'execution deterministe vis-a-vis des
* deux migrations d'init deja presentes.
*/
final class Version20260417150000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Module Sites : table user_site (M2M) + colonne user.current_site_id (M2O SET NULL).';
}
public function up(Schema $schema): void
{
// 1) Creation de la table de jointure user_site.
$this->addSql(<<<'SQL'
CREATE TABLE user_site (
user_id INT NOT NULL,
site_id INT NOT NULL,
PRIMARY KEY (user_id, site_id)
)
SQL);
$this->addSql('CREATE INDEX IDX_user_site_user ON user_site (user_id)');
$this->addSql('CREATE INDEX IDX_user_site_site ON user_site (site_id)');
$this->addSql(<<<'SQL'
ALTER TABLE user_site
ADD CONSTRAINT FK_user_site_user
FOREIGN KEY (user_id) REFERENCES "user" (id) ON DELETE CASCADE
SQL);
$this->addSql(<<<'SQL'
ALTER TABLE user_site
ADD CONSTRAINT FK_user_site_site
FOREIGN KEY (site_id) REFERENCES site (id) ON DELETE CASCADE
SQL);
// 2) Ajout de la colonne nullable user.current_site_id + FK SET NULL.
$this->addSql('ALTER TABLE "user" ADD current_site_id INT DEFAULT NULL');
$this->addSql('CREATE INDEX IDX_user_current_site ON "user" (current_site_id)');
$this->addSql(<<<'SQL'
ALTER TABLE "user"
ADD CONSTRAINT FK_user_current_site
FOREIGN KEY (current_site_id) REFERENCES site (id) ON DELETE SET NULL
SQL);
}
public function down(Schema $schema): void
{
// Rollback en ordre inverse : enfants avant parents.
$this->addSql('ALTER TABLE "user" DROP CONSTRAINT FK_user_current_site');
$this->addSql('DROP INDEX IDX_user_current_site');
$this->addSql('ALTER TABLE "user" DROP current_site_id');
$this->addSql('ALTER TABLE user_site DROP CONSTRAINT FK_user_site_site');
$this->addSql('ALTER TABLE user_site DROP CONSTRAINT FK_user_site_user');
$this->addSql('DROP TABLE user_site');
}
}

View File

@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Module Sites - Ticket 2/4 : restructuration de l'adresse.
*
* Splitte la colonne `site.full_address` (TEXT NOT NULL, multi-lignes) en
* deux champs structures :
* - `street` (VARCHAR(255) NOT NULL) : numero + voie ;
* - `complement` (VARCHAR(255) DEFAULT NULL) : batiment, escalier, BP...
*
* L'adresse complete affichable est desormais reconstituee cote applicatif
* par Site::getFullAddress() (concatenation multi-lignes street\n[complement\n]CP ville)
* et exposee en lecture API via le groupe `site:read` + `me:read`. Plus de
* colonne DB redondante.
*
* Strategie de backfill (entre creation des nouvelles colonnes et drop de
* l'ancienne) :
* - `street` recoit la totalite de l'ancien `full_address` pour ne perdre
* aucune donnee. C'est imparfait pour les adresses multi-lignes mais
* safe : aucun risque de tronquage si l'ancienne adresse depasse 255
* chars (PostgreSQL leve une erreur explicite ; charge a l'admin de
* nettoyer manuellement si necessaire).
* - `complement` reste null : pas d'heuristique fiable pour decouper une
* adresse libre en street/complement.
*
* Cette migration evite un `make db-reset` force pour les developpeurs
* ayant deja joue Version20260417120000 dans son etat initial (table site
* avec full_address). Les fixtures sont mises a jour en parallele.
*/
final class Version20260420130000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Module Sites : split full_address en street + complement (getter computed cote applicatif).';
}
public function up(Schema $schema): void
{
// 1) Ajout des nouvelles colonnes en mode permissif :
// - `street` nullable temporairement pour permettre le backfill.
// - `complement` definitivement nullable.
$this->addSql('ALTER TABLE site ADD street VARCHAR(255) DEFAULT NULL');
$this->addSql('ALTER TABLE site ADD complement VARCHAR(255) DEFAULT NULL');
// 2) Backfill : recopier full_address dans street pour ne pas perdre
// les donnees existantes. Les retours a la ligne sont preserves
// (PostgreSQL VARCHAR accepte \n) ; un admin pourra reformater
// apres coup si besoin. Cas d'adresse > 255 chars : la migration
// echoue cleanly (pas de tronquage silencieux).
$this->addSql('UPDATE site SET street = full_address');
// 3) Bascule street en NOT NULL une fois le backfill applique.
$this->addSql('ALTER TABLE site ALTER COLUMN street SET NOT NULL');
// 4) Drop de l'ancienne colonne full_address.
$this->addSql('ALTER TABLE site DROP full_address');
}
public function down(Schema $schema): void
{
// Recreation de full_address (NOT NULL via DEFAULT '' pour eviter
// un crash si la table a deja des lignes), puis backfill inverse,
// puis drop des nouvelles colonnes.
$this->addSql("ALTER TABLE site ADD full_address TEXT NOT NULL DEFAULT ''");
$this->addSql("UPDATE site SET full_address = street || COALESCE(E'\\n' || complement, '')");
$this->addSql('ALTER TABLE site ALTER COLUMN full_address DROP DEFAULT');
$this->addSql('ALTER TABLE site DROP street');
$this->addSql('ALTER TABLE site DROP complement');
}
}

View File

@@ -15,6 +15,8 @@ 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\Provider\MeProvider;
use App\Module\Core\Infrastructure\Doctrine\DoctrineUserRepository;
use App\Module\Sites\Domain\Entity\Site;
use App\Module\Sites\Domain\Exception\SiteNotAuthorizedException;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
@@ -107,6 +109,39 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
#[Groups(['me:read', 'user:list', 'user:rbac:write', 'user:rbac:read'])]
private Collection $directPermissions;
/**
* Sites autorises pour l'utilisateur (ticket 2 du module Sites).
*
* Relation ManyToMany avec table de jointure `user_site`. Fetch EAGER
* pour la meme raison que `$rbacRoles` : garantir que `/api/me` et les
* voters futurs aient toujours la collection hydratee, meme dans un
* contexte de refresh JWT hors EntityManager. Le surcout SQL reste
* negligeable (≤ quelques sites par user en pratique).
*
* @var Collection<int, Site>
*/
#[ORM\ManyToMany(targetEntity: Site::class, inversedBy: 'users', fetch: 'EAGER')]
#[ORM\JoinTable(name: 'user_site')]
#[Groups(['me:read', 'user:list', 'user:rbac:read', 'user:rbac:write'])]
private Collection $sites;
/**
* Site courant selectionne par l'utilisateur (ticket 2 du module Sites).
*
* Relation ManyToOne nullable : un user peut ne pas avoir encore choisi
* de site actif (par ex. apres creation avant premier login). La FK porte
* `onDelete: SET NULL` pour que la suppression d'un site ne detruise pas
* les users qui le pointaient — ils repassent simplement a `null`.
*
* Doit TOUJOURS pointer vers un site present dans `$sites`. L'invariant
* est enforce par UserRbacProcessor qui bascule automatiquement a `null`
* si le site courant est retire des sites autorises.
*/
#[ORM\ManyToOne(targetEntity: Site::class, fetch: 'EAGER')]
#[ORM\JoinColumn(name: 'current_site_id', referencedColumnName: 'id', nullable: true, onDelete: 'SET NULL')]
#[Groups(['me:read', 'user:list'])]
private ?Site $currentSite = null;
#[ORM\Column]
private ?string $password = null;
@@ -121,6 +156,7 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
$this->createdAt = new DateTimeImmutable();
$this->rbacRoles = new ArrayCollection();
$this->directPermissions = new ArrayCollection();
$this->sites = new ArrayCollection();
}
public function getId(): ?int
@@ -313,4 +349,90 @@ class User implements UserInterface, PasswordAuthenticatedUserInterface
{
$this->plainPassword = null;
}
/**
* @return Collection<int, Site>
*/
public function getSites(): Collection
{
return $this->sites;
}
/**
* Idempotent : ajouter deux fois le meme site n'entraine pas de doublon.
* Synchronise la collection inverse Site::$users en memoire pour eviter
* un etat incoherent entre les deux cotes de la M2M dans une meme
* session Doctrine (cf. ticket 2 review point #1).
*/
public function addSite(Site $site): static
{
if (!$this->sites->contains($site)) {
$this->sites->add($site);
$site->addUser($this);
}
return $this;
}
/**
* Retire un site de la collection + maintient la collection inverse en
* memoire (cf. addSite). Attention : ne met PAS a jour `$currentSite`
* si le site retire en etait le courant — cet invariant est enforce
* par UserRbacProcessor (cote applicatif) ou doit etre maintenu
* explicitement par l'appelant. Voir Risque 2 du ticket 2 spec.
*/
public function removeSite(Site $site): static
{
if ($this->sites->removeElement($site)) {
$site->removeUser($this);
}
return $this;
}
/**
* Garde applicative rapide : teste la presence d'un site dans la
* collection autorisee, via comparaison d'identite d'objet Doctrine.
* Utilise par CurrentSiteProcessor pour valider un switch.
*/
public function hasSite(Site $site): bool
{
return $this->sites->contains($site);
}
public function getCurrentSite(): ?Site
{
return $this->currentSite;
}
/**
* Setter brut, sans garde. Usage interne pour les flux qui doivent
* pouvoir positionner un site arbitraire ou null (reset de coherence
* post-PATCH RBAC, fixtures, init). Pour le flux user-facing
* "selectionner un site dans la liste autorisee", utiliser
* switchCurrentSite() qui porte la garde domaine.
*/
public function setCurrentSite(?Site $currentSite): static
{
$this->currentSite = $currentSite;
return $this;
}
/**
* Garde domaine du switch utilisateur : refuse un site qui n'est pas
* dans la collection autorisee. Levee d'une exception domaine que le
* processor HTTP traduit en 403 (pattern aligne sur Role::ensureDeletable
* → SystemRoleDeletionException).
*
* @throws SiteNotAuthorizedException si $site n'appartient pas a $this->sites
*/
public function switchCurrentSite(Site $site): void
{
if (!$this->hasSite($site)) {
throw SiteNotAuthorizedException::forSite($site);
}
$this->currentSite = $site;
}
}

View File

@@ -29,6 +29,14 @@ use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
* - Dernier admin global : impossible de retirer `isAdmin` si c'est le
* dernier administrateur de l'instance, meme par un tiers. Enforce via
* AdminHeadcountGuardInterface.
* - Coherence currentSite (ticket 2 module Sites) : apres persist des
* sites autorises, si le `currentSite` n'est plus dans la collection,
* il est repositionne automatiquement :
* a) repasse a `null` s'il pointait vers un site retire ;
* 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
* premier rattachement).
* Un second flush est emis uniquement si la coherence a du etre corrigee.
*
* @implements ProcessorInterface<User, User>
*/
@@ -80,6 +88,46 @@ final class UserRbacProcessor implements ProcessorInterface
}
}
return $this->persistProcessor->process($data, $operation, $uriVariables, $context);
$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.
$this->ensureCurrentSiteConsistency($data);
return $result;
}
/**
* Applique deux corrections post-persist sur `currentSite` :
* - si l'actuel n'est plus dans `sites` apres update → repasse a null ;
* - si null et `sites` non vide → auto-selectionne le premier site
* (coherent avec le choix de ne jamais laisser un user rattache a
* plusieurs sites sans contexte courant).
*
* 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
* aux sites.
*/
private function ensureCurrentSiteConsistency(User $user): void
{
$currentSite = $user->getCurrentSite();
$sites = $user->getSites();
$changed = false;
if (null !== $currentSite && !$user->hasSite($currentSite)) {
$user->setCurrentSite(null);
$changed = true;
}
if (null === $user->getCurrentSite() && !$sites->isEmpty()) {
$user->setCurrentSite($sites->first() ?: null);
$changed = true;
}
if ($changed) {
$this->entityManager->flush();
}
}
}

View File

@@ -8,26 +8,50 @@ use App\Module\Core\Domain\Entity\Role;
use App\Module\Core\Domain\Entity\User;
use App\Module\Core\Domain\Repository\RoleRepositoryInterface;
use App\Module\Core\Domain\Security\SystemRoles;
use App\Module\Sites\Domain\Entity\Site;
use App\Module\Sites\Domain\Repository\SiteRepositoryInterface;
use App\Module\Sites\Infrastructure\DataFixtures\SitesFixtures;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\DataFixtures\DependentFixtureInterface;
use Doctrine\Persistence\ObjectManager;
use RuntimeException;
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
/**
* Fixtures de base du module Core : 3 utilisateurs (1 admin + 2 standards)
* rattaches aux roles systeme RBAC seedes par la migration Version20260414150034.
* rattaches aux roles systeme RBAC seedes par la migration Version20260414150034,
* puis (ticket 2 module Sites) rattaches a au moins un site avec un currentSite
* coherent.
*
* Note : le purger Doctrine execute avant load() supprime l'ensemble des
* entites managees, ce qui inclut la table role. On re-seede donc les roles
* systeme de maniere idempotente avant de rattacher les utilisateurs, afin
* que le workflow "make db-reset && make fixtures" reste one-shot.
*
* Dependance explicite a SitesFixtures (ticket 2) : les 3 sites Chatellerault,
* Saint-Jean et Pommevic doivent etre presents en base avant d'etre rattaches
* aux users. L'inversion volontaire de l'ordre (AppFixtures ← SitesFixtures)
* casse l'independance declaree au ticket 1 : c'est un couplage assume car
* apres ticket 2 le modele metier exprime un besoin legitime de rattachement.
*/
class AppFixtures extends Fixture
class AppFixtures extends Fixture implements DependentFixtureInterface
{
public function __construct(
private readonly UserPasswordHasherInterface $passwordHasher,
private readonly RoleRepositoryInterface $roleRepository,
private readonly SiteRepositoryInterface $siteRepository,
) {}
/**
* @return array<int, class-string>
*/
public function getDependencies(): array
{
// SitesFixtures doit tourner AVANT AppFixtures pour que les sites
// soient disponibles au rattachement des users ci-dessous.
return [SitesFixtures::class];
}
public function load(ObjectManager $manager): void
{
$adminRole = $this->ensureSystemRole(
@@ -43,23 +67,43 @@ class AppFixtures extends Fixture
'Role de base sans permission specifique',
);
// Recupere les 3 sites seedes par SitesFixtures. Si absents, c'est
// une misconfiguration (fixture hors purge ou dependance ignoree) :
// on fail fort avec un message explicite plutot que de continuer
// avec des users orphelins de site.
$chatellerault = $this->requireSite('Chatellerault');
$saintJean = $this->requireSite('Saint-Jean');
$pommevic = $this->requireSite('Pommevic');
$admin = new User();
$admin->setUsername('admin');
$admin->setIsAdmin(true);
$admin->setPassword($this->passwordHasher->hashPassword($admin, 'admin'));
$admin->addRbacRole($adminRole);
// Admin rattache aux 3 sites pour faciliter le dev / les tests manuels.
$admin->addSite($chatellerault);
$admin->addSite($saintJean);
$admin->addSite($pommevic);
$admin->setCurrentSite($chatellerault);
$manager->persist($admin);
$alice = new User();
$alice->setUsername('alice');
$alice->setPassword($this->passwordHasher->hashPassword($alice, 'alice'));
$alice->addRbacRole($userRole);
// Alice : un seul site, site courant = ce site.
$alice->addSite($chatellerault);
$alice->setCurrentSite($chatellerault);
$manager->persist($alice);
$bob = new User();
$bob->setUsername('bob');
$bob->setPassword($this->passwordHasher->hashPassword($bob, 'bob'));
$bob->addRbacRole($userRole);
// Bob : site different de Alice, pour prouver le filtrage par site
// dans les futurs tests (ticket 4 outillage SiteAware).
$bob->addSite($saintJean);
$bob->setCurrentSite($saintJean);
$manager->persist($bob);
$manager->flush();
@@ -90,4 +134,19 @@ class AppFixtures extends Fixture
return $role;
}
private function requireSite(string $name): Site
{
$site = $this->siteRepository->findByName($name);
if (null === $site) {
throw new RuntimeException(sprintf(
'SitesFixtures doit avoir seede le site "%s" avant le chargement des users. '
.'Verifier que SitesFixtures est bien en dependance de AppFixtures.',
$name,
));
}
return $site;
}
}

View File

@@ -4,22 +4,63 @@ declare(strict_types=1);
namespace App\Module\Sites\Domain\Entity;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Delete;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Patch;
use ApiPlatform\Metadata\Post;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Infrastructure\Doctrine\DoctrineSiteRepository;
use DateTimeImmutable;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\Common\Collections\Collection;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
use Symfony\Component\Serializer\Attribute\Groups;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Site physique (usine / etablissement) appartenant a l'instance Coltura.
*
* Brique fondatrice du module Sites : cette entite n'est pas exposee par
* une ressource API Platform dans ce ticket (ticket 1/4). Elle sert de socle
* de donnees aux tickets suivants (rattachement utilisateurs, affichage
* navbar, etc.). Aucune dependance dure depuis le module Core : la table
* est creee meme si le module est desactive (voir migration dediee).
* Adresse decomposee en champs structures (rue, complement, CP, ville) pour
* permettre des recherches/tris fins ulterieurs et eviter les divergences
* entre champs duplique. La methode `getFullAddress()` fournit la version
* concatenee multi-lignes pour les usages d'affichage.
*
* Expose en API Platform pour l'administration CRUD avec RBAC :
* - lecture (GET list / item) : requiert la permission `sites.view`
* - ecriture (POST / PATCH / DELETE) : requiert la permission `sites.manage`
*
* Egalement embarque dans la reponse `/api/me` (groupe `me:read`) pour que
* le frontend connaisse les sites autorises et le site courant de l'user.
*/
#[ApiResource(
operations: [
new GetCollection(
normalizationContext: ['groups' => ['site:read']],
security: "is_granted('sites.view')",
),
new Get(
normalizationContext: ['groups' => ['site:read']],
security: "is_granted('sites.view')",
),
new Post(
normalizationContext: ['groups' => ['site:read']],
denormalizationContext: ['groups' => ['site:write']],
security: "is_granted('sites.manage')",
),
new Patch(
normalizationContext: ['groups' => ['site:read']],
denormalizationContext: ['groups' => ['site:write']],
security: "is_granted('sites.manage')",
),
new Delete(security: "is_granted('sites.manage')"),
],
normalizationContext: ['groups' => ['site:read']],
denormalizationContext: ['groups' => ['site:write']],
)]
#[ORM\Entity(repositoryClass: DoctrineSiteRepository::class)]
#[ORM\Table(name: 'site')]
#[ORM\UniqueConstraint(name: 'uniq_site_name', columns: ['name'])]
@@ -30,17 +71,27 @@ class Site
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
#[Groups(['site:read', 'me:read'])]
private ?int $id = null;
#[ORM\Column(length: 100)]
#[Assert\NotBlank(message: 'Le nom du site est requis.')]
#[Assert\Length(max: 100, maxMessage: 'Le nom du site ne peut pas depasser {{ limit }} caracteres.')]
#[Groups(['site:read', 'site:write', 'me:read'])]
private string $name;
#[ORM\Column(length: 100)]
#[Assert\NotBlank(message: 'La ville du site est requise.')]
#[Assert\Length(max: 100, maxMessage: 'La ville ne peut pas depasser {{ limit }} caracteres.')]
private string $city;
// Premiere ligne d'adresse : numero + voie. Requise.
#[ORM\Column(length: 255)]
#[Assert\NotBlank(message: 'La rue est requise.')]
#[Assert\Length(max: 255, maxMessage: 'La rue ne peut pas depasser {{ limit }} caracteres.')]
#[Groups(['site:read', 'site:write', 'me:read'])]
private string $street;
// Complement d'adresse optionnel : batiment, escalier, BP, etc.
#[ORM\Column(length: 255, nullable: true)]
#[Assert\Length(max: 255, maxMessage: 'Le complement ne peut pas depasser {{ limit }} caracteres.')]
#[Groups(['site:read', 'site:write', 'me:read'])]
private ?string $complement = null;
// Colonne mappee sur le snake_case PostgreSQL (convention projet : noms de
// colonnes en minuscules dans le SQL brut). Le format est contraint au
@@ -52,50 +103,71 @@ class Site
pattern: '/^\d{5}$/',
message: 'Le code postal doit etre compose de 5 chiffres (format FR).',
)]
#[Groups(['site:read', 'site:write', 'me:read'])]
private string $postalCode;
#[ORM\Column(length: 100)]
#[Assert\NotBlank(message: 'La ville du site est requise.')]
#[Assert\Length(max: 100, maxMessage: 'La ville ne peut pas depasser {{ limit }} caracteres.')]
#[Groups(['site:read', 'site:write', 'me:read'])]
private string $city;
// Couleur d'identification visuelle du site au format hex #RRGGBB (7 chars
// incluant le diese). Utilisee plus tard par la navbar (ticket 3) pour
// distinguer les sites d'un coup d'oeil.
// incluant le diese). Utilisee par la navbar (ticket 3) pour distinguer
// les sites d'un coup d'oeil.
#[ORM\Column(length: 7)]
#[Assert\NotBlank(message: 'La couleur est requise.')]
#[Assert\Regex(
pattern: '/^#[0-9A-Fa-f]{6}$/',
message: 'La couleur doit etre un code hex de 7 caracteres au format #RRGGBB.',
)]
#[Groups(['site:read', 'site:write', 'me:read'])]
private string $color;
// Champ TEXT volontaire : l'adresse complete peut courir sur plusieurs
// lignes (voie + complement + mention particuliere). Borne haute a 500
// caracteres : une adresse francaise complete tient tres largement dans
// cette enveloppe, et la limite applicative protege contre les payloads
// anormalement volumineux envoyes par un client (garde DoS basique).
#[ORM\Column(name: 'full_address', type: Types::TEXT)]
#[Assert\NotBlank(message: 'L\'adresse complete est requise.')]
#[Assert\Length(max: 500, maxMessage: 'L\'adresse complete ne peut pas depasser {{ limit }} caracteres.')]
private string $fullAddress;
// createdAt / updatedAt volontairement exclus du groupe `me:read` :
// le payload `/api/me` doit rester leger, ces metadonnees ne sont utiles
// qu'a l'admin (exposees uniquement via `site:read` sur /api/sites).
#[ORM\Column(name: 'created_at', type: Types::DATETIME_IMMUTABLE)]
#[Groups(['site:read'])]
private DateTimeImmutable $createdAt;
#[ORM\Column(name: 'updated_at', type: Types::DATETIME_IMMUTABLE)]
#[Groups(['site:read'])]
private DateTimeImmutable $updatedAt;
/**
* Collection inverse des users rattaches a ce site.
*
* Volontairement SANS `#[Groups]` : la collection n'est jamais exposee via
* l'API pour deux raisons :
* - eviter une boucle de serialisation infinie User → sites → users → ...
* si un jour un developpeur ajoute `me:read` ici par megarde ;
* - l'inverse n'a de valeur qu'en interne (compter les users d'un site,
* iterer en test de cascade).
*
* @var Collection<int, User>
*/
#[ORM\ManyToMany(targetEntity: User::class, mappedBy: 'sites')]
private Collection $users;
public function __construct(
string $name,
string $city,
string $street,
?string $complement,
string $postalCode,
string $city,
string $color,
string $fullAddress,
) {
$this->name = $name;
$this->city = $city;
$this->postalCode = $postalCode;
$this->color = $color;
$this->fullAddress = $fullAddress;
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
$this->name = $name;
$this->street = $street;
$this->complement = $complement;
$this->postalCode = $postalCode;
$this->city = $city;
$this->color = $color;
$now = new DateTimeImmutable();
$this->createdAt = $now;
$this->updatedAt = $now;
$this->users = new ArrayCollection();
}
/**
@@ -125,14 +197,26 @@ class Site
return $this;
}
public function getCity(): string
public function getStreet(): string
{
return $this->city;
return $this->street;
}
public function setCity(string $city): static
public function setStreet(string $street): static
{
$this->city = $city;
$this->street = $street;
return $this;
}
public function getComplement(): ?string
{
return $this->complement;
}
public function setComplement(?string $complement): static
{
$this->complement = $complement;
return $this;
}
@@ -149,6 +233,18 @@ class Site
return $this;
}
public function getCity(): string
{
return $this->city;
}
public function setCity(string $city): static
{
$this->city = $city;
return $this;
}
public function getColor(): string
{
return $this->color;
@@ -161,16 +257,26 @@ class Site
return $this;
}
/**
* Adresse complete reconstituee : street, [complement,] {CP} {ville},
* separes par des sauts de ligne. Methode pure, jamais persistee.
*
* Expose en lecture API (groupes site:read + me:read) pour que les
* consommateurs (frontend, exports PDF) recoivent une adresse prete a
* afficher sans dupliquer la logique de concatenation cote client.
*/
#[Groups(['site:read', 'me:read'])]
public function getFullAddress(): string
{
return $this->fullAddress;
}
$lines = [$this->street];
public function setFullAddress(string $fullAddress): static
{
$this->fullAddress = $fullAddress;
if (null !== $this->complement && '' !== trim($this->complement)) {
$lines[] = $this->complement;
}
return $this;
$lines[] = sprintf('%s %s', $this->postalCode, $this->city);
return implode("\n", $lines);
}
public function getCreatedAt(): DateTimeImmutable
@@ -182,4 +288,39 @@ class Site
{
return $this->updatedAt;
}
/**
* @return Collection<int, User>
*/
public function getUsers(): Collection
{
return $this->users;
}
/**
* Synchronise la collection inverse cote Site quand User::addSite est
* appele. Idempotent. Ne re-appelle pas $user->addSite($this) pour
* eviter une recursion infinie : User::addSite est le point d'entree
* unique de la mutation.
*
* @internal Appele uniquement par User::addSite()
*/
public function addUser(User $user): static
{
if (!$this->users->contains($user)) {
$this->users->add($user);
}
return $this;
}
/**
* @internal Appele uniquement par User::removeSite()
*/
public function removeUser(User $user): static
{
$this->users->removeElement($user);
return $this;
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Module\Sites\Domain\Exception;
use App\Module\Sites\Domain\Entity\Site;
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.
*/
final class SiteNotAuthorizedException extends DomainException
{
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,53 @@
<?php
declare(strict_types=1);
namespace App\Module\Sites\Infrastructure\ApiPlatform\Resource;
use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Patch;
use App\Module\Sites\Domain\Entity\Site;
use App\Module\Sites\Infrastructure\ApiPlatform\State\Processor\CurrentSiteProcessor;
use Symfony\Component\Serializer\Attribute\Groups;
/**
* Ressource API Platform virtuelle (non mappee Doctrine) qui porte
* l'operation `PATCH /api/me/current-site` : basculement du site courant
* de l'utilisateur authentifie.
*
* `read: false` informe API Platform qu'il ne doit pas tenter de charger
* une entite existante via un Provider — l'operation denormalise le payload
* directement dans cette ressource, puis CurrentSiteProcessor prend le relais.
*
* `shortName: 'CurrentSite'` : evite toute collision avec l'entite `Site`
* dans le routage et la documentation OpenAPI.
*
* Securite : l'autorisation "ROLE_USER" suffit au niveau voter — la verification
* fine (le site demande fait-il partie des sites autorises de l'user ?)
* est faite par CurrentSiteProcessor, car elle dependence de l'user
* authentifie, pas d'une permission statique.
*/
#[ApiResource(
shortName: 'CurrentSite',
operations: [
new Patch(
uriTemplate: '/me/current-site',
security: "is_granted('ROLE_USER')",
normalizationContext: ['groups' => ['me:read']],
denormalizationContext: ['groups' => ['current-site:write']],
processor: CurrentSiteProcessor::class,
read: false,
priority: 1,
),
],
)]
final class CurrentSiteResource
{
/**
* Site cible du switch, denormalise depuis l'IRI envoye dans le body :
* `{ "site": "/api/sites/{id}" }`. Resolu automatiquement par
* l'IriConverter d'API Platform en instance de `Site`.
*/
#[Groups(['current-site:write'])]
public ?Site $site = null;
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Module\Sites\Infrastructure\ApiPlatform\State\Processor;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Exception\SiteNotAuthorizedException;
use App\Module\Sites\Infrastructure\ApiPlatform\Resource\CurrentSiteResource;
use Doctrine\ORM\EntityManagerInterface;
use LogicException;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
/**
* Processor de l'operation `PATCH /api/me/current-site`.
*
* Flux :
* 1. Recupere l'user authentifie via Security.
* 2. Extrait le site cible depuis la ressource denormalisee.
* 3. Valide que le site fait partie des `sites` de l'user — sinon leve
* SiteNotAuthorizedException convertie immediatement en 403.
* 4. Positionne `currentSite`, flush, retourne l'user pour normalisation
* par API Platform via les groupes `me:read` (payload identique a /api/me).
*
* @implements ProcessorInterface<CurrentSiteResource, User>
*/
final class CurrentSiteProcessor implements ProcessorInterface
{
public function __construct(
private readonly Security $security,
private readonly EntityManagerInterface $entityManager,
) {}
public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): mixed
{
if (!$data instanceof CurrentSiteResource) {
throw new LogicException(sprintf(
'CurrentSiteProcessor attend une instance de %s, %s recu.',
CurrentSiteResource::class,
get_debug_type($data),
));
}
$user = $this->security->getUser();
if (!$user instanceof User) {
// security: "is_granted('ROLE_USER')" sur l'operation doit deja
// bloquer ce cas — garde defensive si la config change.
throw new AccessDeniedHttpException('Authentification requise pour changer de site courant.');
}
$targetSite = $data->site;
if (null === $targetSite) {
throw new BadRequestHttpException('Le champ "site" est requis.');
}
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();
return $user;
}
}

View File

@@ -40,38 +40,43 @@ class SitesFixtures extends Fixture
$this->ensureSite(
$manager,
name: 'Chatellerault',
city: 'Chatellerault',
street: "14 All. d'Argenson",
complement: null,
postalCode: '86100',
city: 'Châtellerault',
color: '#056CF2',
fullAddress: "1 avenue de l'Europe\n86100 Chatellerault",
);
// Saint-Jean : vert emeraude pour contraster avec le bleu Chatellerault.
// Note : le nom du site (identifier) ne reflete pas la ville reelle
// (Fontenet) — c'est une nomenclature interne client.
$this->ensureSite(
$manager,
name: 'Saint-Jean',
city: 'Saint-Jean-de-Sauves',
postalCode: '86330',
street: 'Z i',
complement: null,
postalCode: '17400',
city: 'Fontenet',
color: '#10B981',
fullAddress: "12 route de Poitiers\n86330 Saint-Jean-de-Sauves",
);
// Pommevic : ambre pour une troisieme teinte nettement distincte.
$this->ensureSite(
$manager,
name: 'Pommevic',
city: 'Pommevic',
street: '1 Av. Jean Duquesne',
complement: null,
postalCode: '82400',
city: 'Pommevic',
color: '#F59E0B',
fullAddress: "5 chemin des Peupliers\n82400 Pommevic",
);
$manager->flush();
}
/**
* Cree le site s'il n'existe pas encore, sinon re-aligne ville, code
* postal, couleur et adresse sur les valeurs de reference.
* Cree le site s'il n'existe pas encore, sinon re-aligne rue, complement,
* code postal, ville et couleur sur les valeurs de reference.
*
* Note : le nom sert de cle de lookup (il est unique en base) et n'est
* donc pas resynchronise. Consequence : renommer un site dans la
@@ -81,24 +86,26 @@ class SitesFixtures extends Fixture
private function ensureSite(
ObjectManager $manager,
string $name,
string $city,
string $street,
?string $complement,
string $postalCode,
string $city,
string $color,
string $fullAddress,
): Site {
$site = $this->siteRepository->findByName($name);
if (null === $site) {
$site = new Site($name, $city, $postalCode, $color, $fullAddress);
$site = new Site($name, $street, $complement, $postalCode, $city, $color);
$manager->persist($site);
return $site;
}
$site->setCity($city);
$site->setStreet($street);
$site->setComplement($complement);
$site->setPostalCode($postalCode);
$site->setCity($city);
$site->setColor($color);
$site->setFullAddress($fullAddress);
return $site;
}

View File

@@ -130,4 +130,34 @@ abstract class AbstractApiTestCase extends ApiTestCase
return ['username' => $username, 'password' => $password];
}
/**
* Skip le test courant si le module Sites est desactive dans
* `config/modules.php` de l'environnement de test.
*
* Mecanisme : on cherche la permission `sites.view` en base. Si le
* module Sites est desactive, `app:sync-permissions` aura marque cette
* permission comme orpheline et l'aura supprimee de la table — donc
* `findOneBy(['code' => 'sites.view'])` renvoie null.
*
* Quand utiliser ce helper : tests qui s'appuient sur
* `createUserWithPermission('sites.*')`. Les tests qui utilisent
* uniquement l'admin (qui bypass via isAdmin) n'en ont pas besoin :
* la classe Site reste mappee Doctrine et exposee via API Platform
* meme module desactive (mapping inconditionnel, decision assumee
* ticket 1).
*/
protected function skipIfSitesModuleDisabled(): void
{
if (!self::$kernel) {
self::bootKernel();
}
$perm = $this->getEm()
->getRepository(Permission::class)
->findOneBy(['code' => 'sites.view'])
;
if (null === $perm) {
self::markTestSkipped('Module Sites desactive : permission sites.view introuvable en base.');
}
}
}

View File

@@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Core\Api;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
/**
* Tests d'extension de l'endpoint PATCH /api/users/{id}/rbac pour assigner
* des sites a un user, avec les deux gardes post-persist :
* - si currentSite n'est plus dans sites → null ;
* - si currentSite null ET sites non vide → auto-select premier site.
*
* @internal
*/
final class UserRbacSitesApiTest extends AbstractApiTestCase
{
public function testAdminCanAssignSitesToUser(): void
{
$em = $this->getEm();
$saintJean = $em->getRepository(Site::class)->findOneBy(['name' => 'Saint-Jean']);
self::assertNotNull($saintJean);
$alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
$aliceId = $alice->getId();
$em->clear();
$client = $this->authenticatedClient('admin', 'admin');
$client->request('PATCH', '/api/users/'.$aliceId.'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => [
'sites' => ['/api/sites/'.$saintJean->getId()],
],
]);
self::assertResponseIsSuccessful();
// Verification cote base.
$em = $this->getEm();
$em->clear();
$reloaded = $em->getRepository(User::class)->find($aliceId);
self::assertNotNull($reloaded);
self::assertCount(1, $reloaded->getSites());
self::assertSame('Saint-Jean', $reloaded->getSites()->first()->getName());
// Restauration pour ne pas polluer les autres tests.
$this->restoreAliceSites();
}
public function testRemovingCurrentSiteResetsCurrentSiteToNullThenAutoSelectsFirst(): void
{
// alice a actuellement {Chatellerault}, currentSite=Chatellerault.
// On lui attribue {Saint-Jean} : Chatellerault disparait → currentSite
// devrait temporairement etre null, PUIS auto-select Saint-Jean (seul
// site restant).
$em = $this->getEm();
$saintJean = $em->getRepository(Site::class)->findOneBy(['name' => 'Saint-Jean']);
$alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
$aliceId = $alice->getId();
$em->clear();
$client = $this->authenticatedClient('admin', 'admin');
$client->request('PATCH', '/api/users/'.$aliceId.'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => [
'sites' => ['/api/sites/'.$saintJean->getId()],
],
]);
self::assertResponseIsSuccessful();
$em = $this->getEm();
$em->clear();
$reloaded = $em->getRepository(User::class)->find($aliceId);
self::assertNotNull($reloaded->getCurrentSite());
self::assertSame('Saint-Jean', $reloaded->getCurrentSite()->getName());
$this->restoreAliceSites();
}
public function testEmptySitesPayloadResetsCurrentSiteToNull(): void
{
$em = $this->getEm();
$alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
$aliceId = $alice->getId();
$em->clear();
$client = $this->authenticatedClient('admin', 'admin');
$client->request('PATCH', '/api/users/'.$aliceId.'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => [
'sites' => [],
],
]);
self::assertResponseIsSuccessful();
$em = $this->getEm();
$em->clear();
$reloaded = $em->getRepository(User::class)->find($aliceId);
self::assertCount(0, $reloaded->getSites());
self::assertNull($reloaded->getCurrentSite());
$this->restoreAliceSites();
}
public function testCurrentSiteFieldInRbacPayloadIsSilentlyIgnored(): void
{
// Garde structurelle : `currentSite` n'est pas dans le groupe
// user:rbac:write. Un client malveillant qui essaierait de set un
// currentSite arbitraire via /rbac doit etre silencieusement
// ignore (le seul flux autorise est PATCH /me/current-site).
$em = $this->getEm();
$pommevic = $em->getRepository(Site::class)->findOneBy(['name' => 'Pommevic']);
$alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
$aliceId = $alice->getId();
$em->clear();
$client = $this->authenticatedClient('admin', 'admin');
$client->request('PATCH', '/api/users/'.$aliceId.'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => [
'currentSite' => '/api/sites/'.$pommevic->getId(),
],
]);
self::assertResponseIsSuccessful();
// alice n'a Pommevic ni dans ses sites ni en currentSite (le champ
// a ete ignore par le denormalizer). Son currentSite reste son
// Chatellerault d'origine.
$em = $this->getEm();
$em->clear();
$reloaded = $em->getRepository(User::class)->find($aliceId);
self::assertNotNull($reloaded);
self::assertNotNull($reloaded->getCurrentSite());
self::assertSame('Chatellerault', $reloaded->getCurrentSite()->getName());
}
public function testRbacPatchWithoutSitesFieldDoesNotChangeCurrentSite(): void
{
// Garde structurelle : si le payload /rbac ne contient pas le champ
// `sites`, ensureCurrentSiteConsistency ne doit pas auto-modifier
// le currentSite (alice avait deja Chatellerault). Un PATCH qui
// change uniquement isAdmin ou roles ne doit pas remuer la
// configuration site de l'user.
$em = $this->getEm();
$alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
$aliceId = $alice->getId();
$em->clear();
$client = $this->authenticatedClient('admin', 'admin');
$client->request('PATCH', '/api/users/'.$aliceId.'/rbac', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => [
'isAdmin' => false,
],
]);
self::assertResponseIsSuccessful();
$em = $this->getEm();
$em->clear();
$reloaded = $em->getRepository(User::class)->find($aliceId);
self::assertNotNull($reloaded->getCurrentSite());
self::assertSame('Chatellerault', $reloaded->getCurrentSite()->getName());
}
/**
* Remet alice dans l'etat des fixtures : un seul site Chatellerault,
* currentSite Chatellerault. Evite la pollution inter-tests.
*/
private function restoreAliceSites(): void
{
$em = $this->getEm();
$chatellerault = $em->getRepository(Site::class)->findOneBy(['name' => 'Chatellerault']);
$alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
// Reset complet des sites
foreach ($alice->getSites() as $existing) {
$alice->removeSite($existing);
}
$alice->addSite($chatellerault);
$alice->setCurrentSite($chatellerault);
$em->flush();
}
}

View File

@@ -0,0 +1,92 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Sites\Api;
use App\Module\Sites\Domain\Entity\Site;
use App\Tests\Module\Core\Api\AbstractApiTestCase;
/**
* Tests fonctionnels de l'endpoint PATCH /api/me/current-site (switch).
*
* Fixtures utilisees :
* - alice : rattachee a Chatellerault uniquement (currentSite = Chatellerault).
* - admin : rattache aux 3 sites.
* - bob : rattache a Saint-Jean uniquement.
*
* @internal
*/
final class CurrentSiteSwitchApiTest extends AbstractApiTestCase
{
public function testUserCanSwitchToAuthorizedSite(): void
{
// admin a les 3 sites. On le bascule de Chatellerault vers Pommevic.
$em = $this->getEm();
$pommevic = $em->getRepository(Site::class)->findOneBy(['name' => 'Pommevic']);
self::assertNotNull($pommevic);
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('PATCH', '/api/me/current-site', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['site' => '/api/sites/'.$pommevic->getId()],
]);
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertSame('Pommevic', $data['currentSite']['name']);
}
public function testUserCannotSwitchToUnauthorizedSite(): void
{
// alice n'a que Chatellerault. Tenter Pommevic → 403.
$em = $this->getEm();
$pommevic = $em->getRepository(Site::class)->findOneBy(['name' => 'Pommevic']);
self::assertNotNull($pommevic);
$client = $this->authenticatedClient('alice', 'alice');
$client->request('PATCH', '/api/me/current-site', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['site' => '/api/sites/'.$pommevic->getId()],
]);
self::assertResponseStatusCodeSame(403);
}
public function testSwitchWithMissingSiteFieldReturns400(): void
{
$client = $this->authenticatedClient('alice', 'alice');
$client->request('PATCH', '/api/me/current-site', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => [],
]);
self::assertResponseStatusCodeSame(400);
}
public function testAnonymousUserCannotSwitch(): void
{
$client = self::createClient();
$client->request('PATCH', '/api/me/current-site', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['site' => '/api/sites/1'],
]);
self::assertResponseStatusCodeSame(401);
}
public function testSwitchWithNonExistentSiteIriReturnsErrorStatus(): void
{
// IRI vers un site qui n'existe pas en base : API Platform leve un
// 400 Bad Request a la denormalisation (l'IriConverter ne peut pas
// resoudre l'IRI). On grave le code de retour reel pour eviter
// qu'une regression silencieuse passe inapercue.
$client = $this->authenticatedClient('alice', 'alice');
$client->request('PATCH', '/api/me/current-site', [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['site' => '/api/sites/999999'],
]);
self::assertResponseStatusCodeSame(400);
}
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Sites\Api;
use App\Module\Core\Domain\Entity\User;
use App\Tests\Module\Core\Api\AbstractApiTestCase;
/**
* Tests d'exposition des sites autorises et du site courant dans /api/me.
*
* Regression-guard du contrat avec le front (ticket 3) : `sites` doit etre
* une liste d'objets Site complets (pas des IRIs), et `currentSite` doit
* etre un objet ou null. Les clients front consomment directement ces
* champs pour alimenter le SiteSelector et le store auth.
*
* @internal
*/
final class MeEndpointSitesTest extends AbstractApiTestCase
{
public function testMeExposesSitesAsObjects(): void
{
$client = $this->authenticatedClient('alice', 'alice');
$response = $client->request('GET', '/api/me');
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertArrayHasKey('sites', $data);
self::assertIsArray($data['sites']);
self::assertCount(1, $data['sites']);
$firstSite = $data['sites'][0];
self::assertIsArray($firstSite, 'Un site doit etre serialise en objet, pas en IRI string.');
self::assertArrayHasKey('id', $firstSite);
self::assertArrayHasKey('name', $firstSite);
self::assertArrayHasKey('street', $firstSite);
self::assertArrayHasKey('city', $firstSite);
self::assertArrayHasKey('color', $firstSite);
// Le getter computed est expose en lecture pour eviter au front
// de redupliquer la logique de concatenation.
self::assertArrayHasKey('fullAddress', $firstSite);
self::assertSame('Chatellerault', $firstSite['name']);
// Garde anti-cycle (cf. Site::$users sans Groups, ticket 2 spec
// section 12 risque 6) : la collection inverse ne doit JAMAIS etre
// serialisee dans /api/me sous peine de boucle infinie
// User → sites → users → sites → ...
self::assertArrayNotHasKey(
'users',
$firstSite,
'Site.users ne doit JAMAIS etre serialise dans /api/me (cycle infini).',
);
}
public function testMeExposesCurrentSiteAsObject(): void
{
$client = $this->authenticatedClient('alice', 'alice');
$response = $client->request('GET', '/api/me');
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertArrayHasKey('currentSite', $data);
self::assertIsArray($data['currentSite'], 'currentSite doit etre un objet, pas une IRI.');
self::assertSame('Chatellerault', $data['currentSite']['name']);
}
public function testAdminHasAllThreeSites(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/me');
$data = $response->toArray();
self::assertCount(3, $data['sites']);
$names = array_column($data['sites'], 'name');
sort($names);
self::assertSame(['Chatellerault', 'Pommevic', 'Saint-Jean'], $names);
}
public function testUserWithoutSitesHasEmptyArrayAndNullCurrent(): void
{
// Creer un user jetable sans rattachement a un site.
$em = $this->getEm();
$suffix = substr(bin2hex(random_bytes(4)), 0, 8);
$username = 'orphan_'.$suffix;
$hasher = self::getContainer()->get('security.user_password_hasher');
$user = new User();
$user->setUsername($username);
$user->setIsAdmin(false);
$user->setPassword($hasher->hashPassword($user, 'testpass'));
$em->persist($user);
$em->flush();
try {
$client = $this->authenticatedClient($username, 'testpass');
$response = $client->request('GET', '/api/me');
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertSame([], $data['sites']);
self::assertNull($data['currentSite']);
} finally {
$em = $this->getEm();
$reloaded = $em->getRepository(User::class)->findOneBy(['username' => $username]);
if (null !== $reloaded) {
$em->remove($reloaded);
$em->flush();
}
}
}
}

View File

@@ -0,0 +1,235 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Sites\Api;
use App\Module\Sites\Domain\Entity\Site;
use App\Tests\Module\Core\Api\AbstractApiTestCase;
/**
* Tests fonctionnels CRUD /api/sites avec matrices RBAC.
*
* Strategie : les 3 sites fixtures (Chatellerault, Saint-Jean, Pommevic)
* sont presents a chaque test. On nettoie les sites crees par les tests
* via un prefixe `Test-` en setUp + tearDown.
*
* @internal
*/
final class SiteApiTest extends AbstractApiTestCase
{
private const TEST_NAME_PREFIX = 'Test-';
protected function setUp(): void
{
parent::setUp();
$this->cleanupTestSites();
}
protected function tearDown(): void
{
$this->cleanupTestSites();
parent::tearDown();
}
public function testAdminCanListSites(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('GET', '/api/sites');
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertGreaterThanOrEqual(3, $data['totalItems']);
}
public function testUserWithSitesViewCanListSites(): void
{
$this->skipIfSitesModuleDisabled();
$credentials = $this->createUserWithPermission('sites.view');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
$client->request('GET', '/api/sites');
self::assertResponseIsSuccessful();
}
public function testUserWithoutPermissionGetsForbidden(): void
{
// alice a la permission via son role "user" ? Non : le role user par
// defaut n'a aucune permission. Elle ne peut donc pas lister.
$client = $this->authenticatedClient('alice', 'alice');
$client->request('GET', '/api/sites');
self::assertResponseStatusCodeSame(403);
}
public function testUnauthenticatedGetCollectionReturns401(): void
{
$client = self::createClient();
$client->request('GET', '/api/sites');
self::assertResponseStatusCodeSame(401);
}
public function testAdminCanCreateSite(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('POST', '/api/sites', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Test-New-Site',
'street' => '1 rue du Test',
'complement' => null,
'postalCode' => '86000',
'city' => 'Poitiers',
'color' => '#AABBCC',
],
]);
self::assertResponseStatusCodeSame(201);
$data = $response->toArray();
self::assertSame('Test-New-Site', $data['name']);
self::assertSame('#AABBCC', $data['color']);
}
public function testAdminCanPatchSite(): void
{
$em = $this->getEm();
$site = new Site('Test-Patch-Site', '1 rue Test', null, '86000', 'Poitiers', '#000000');
$em->persist($site);
$em->flush();
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('PATCH', '/api/sites/'.$site->getId(), [
'headers' => ['Content-Type' => 'application/merge-patch+json'],
'json' => ['color' => '#FF0000'],
]);
self::assertResponseIsSuccessful();
$data = $response->toArray();
self::assertSame('#FF0000', $data['color']);
}
public function testAdminCanDeleteSite(): void
{
$em = $this->getEm();
$site = new Site('Test-Delete-Site', '1 rue Test', null, '86000', 'Poitiers', '#000000');
$em->persist($site);
$em->flush();
$siteId = $site->getId();
$client = $this->authenticatedClient('admin', 'admin');
$client->request('DELETE', '/api/sites/'.$siteId);
self::assertResponseStatusCodeSame(204);
$em->clear();
self::assertNull($em->getRepository(Site::class)->find($siteId));
}
public function testUserWithViewButNotManageCannotDelete(): void
{
$em = $this->getEm();
$site = new Site('Test-Protected', '1 rue Test', null, '86000', 'Poitiers', '#000000');
$em->persist($site);
$em->flush();
$this->skipIfSitesModuleDisabled();
$credentials = $this->createUserWithPermission('sites.view');
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
$client->request('DELETE', '/api/sites/'.$site->getId());
self::assertResponseStatusCodeSame(403);
}
public function testCreateSiteWithDuplicateNameReturns422(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$client->request('POST', '/api/sites', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Chatellerault',
'street' => 'Autre rue',
'postalCode' => '75001',
'city' => 'Autre ville',
'color' => '#FF0000',
],
]);
self::assertResponseStatusCodeSame(422);
}
public function testCreateSiteWithInvalidColorReturns422(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$client->request('POST', '/api/sites', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Test-Invalid-Color',
'street' => '1 rue Test',
'postalCode' => '86000',
'city' => 'Poitiers',
'color' => 'red',
],
]);
self::assertResponseStatusCodeSame(422);
}
public function testCreateSiteIgnoresFullAddressInPayload(): void
{
// Garde structurelle : `fullAddress` est un getter computed cote
// backend (Site::getFullAddress, groupe site:read uniquement). Si un
// client envoie ce champ en POST, API Platform doit l'ignorer
// silencieusement car il n'est pas dans le groupe site:write. On
// grave ce comportement pour qu'un futur dev qui ajouterait un
// setter casse ce test au lieu de casser l'invariant en silence.
$client = $this->authenticatedClient('admin', 'admin');
$response = $client->request('POST', '/api/sites', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Test-FullAddress-Ignored',
'street' => '1 rue Test',
'postalCode' => '86000',
'city' => 'Poitiers',
'color' => '#000000',
'fullAddress' => 'Adresse arbitraire envoyee par le client',
],
]);
self::assertResponseStatusCodeSame(201);
$data = $response->toArray();
// Le getter computed prevaut sur ce qu'envoie le client : street
// determine la 1re ligne, jamais la valeur "Adresse arbitraire...".
self::assertSame("1 rue Test\n86000 Poitiers", $data['fullAddress']);
}
public function testCreateSiteWithInvalidPostalCodeReturns422(): void
{
$client = $this->authenticatedClient('admin', 'admin');
$client->request('POST', '/api/sites', [
'headers' => ['Content-Type' => 'application/ld+json'],
'json' => [
'name' => 'Test-Invalid-CP',
'street' => '1 rue Test',
'postalCode' => '123',
'city' => 'Poitiers',
'color' => '#000000',
],
]);
self::assertResponseStatusCodeSame(422);
}
private function cleanupTestSites(): void
{
if (!self::$kernel) {
self::bootKernel();
}
$em = $this->getEm();
$em->createQuery('DELETE FROM '.Site::class.' s WHERE s.name LIKE :prefix')
->setParameter('prefix', self::TEST_NAME_PREFIX.'%')
->execute()
;
$em->clear();
}
}

View File

@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Tests\Module\Sites\Api;
use App\Module\Core\Domain\Entity\User;
use App\Module\Sites\Domain\Entity\Site;
use App\Tests\Module\Core\Api\AbstractApiTestCase;
/**
* Tests de cascade DB a la suppression d'un site.
*
* Verifie les deux comportements attendus :
* - `user_site` a `ON DELETE CASCADE` : les rattachements sont supprimes ;
* - `user.current_site_id` a `ON DELETE SET NULL` : les users pointant sur
* le site supprime voient leur `currentSite` repasser a NULL.
*
* @internal
*/
final class SiteCascadeTest extends AbstractApiTestCase
{
public function testDeletingSitePurgesUserSiteRows(): void
{
// Creer un site jetable et rattacher alice dessus.
$em = $this->getEm();
$site = new Site('Test-Cascade-Purge', '1 rue Test', null, '12345', 'Ville', '#000000');
$em->persist($site);
$em->flush();
$siteId = $site->getId();
$alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
self::assertNotNull($alice);
$alice->addSite($site);
$em->flush();
$em->clear();
// Verifie presence du rattachement M2M via SQL direct (l'EM est cleared).
$connection = $this->getEm()->getConnection();
$before = (int) $connection->fetchOne(
'SELECT COUNT(*) FROM user_site WHERE site_id = :id',
['id' => $siteId],
);
self::assertSame(1, $before);
// Admin supprime le site.
$client = $this->authenticatedClient('admin', 'admin');
$client->request('DELETE', '/api/sites/'.$siteId);
self::assertResponseStatusCodeSame(204);
// L'entree user_site doit avoir disparu via ON DELETE CASCADE.
$after = (int) $connection->fetchOne(
'SELECT COUNT(*) FROM user_site WHERE site_id = :id',
['id' => $siteId],
);
self::assertSame(0, $after, 'Les rattachements user_site doivent etre purges en cascade.');
}
public function testDeletingSiteSetsCurrentSiteToNullOnReferencingUsers(): void
{
$em = $this->getEm();
$site = new Site('Test-Cascade-Current', '1 rue Test', null, '12345', 'Ville', '#000000');
$em->persist($site);
$em->flush();
$siteId = $site->getId();
$alice = $em->getRepository(User::class)->findOneBy(['username' => 'alice']);
self::assertNotNull($alice);
$aliceId = $alice->getId();
$alice->addSite($site);
$alice->setCurrentSite($site);
$em->flush();
$em->clear();
// Admin supprime le site.
$client = $this->authenticatedClient('admin', 'admin');
$client->request('DELETE', '/api/sites/'.$siteId);
self::assertResponseStatusCodeSame(204);
// currentSite d'alice doit etre passe a NULL via ON DELETE SET NULL.
$em = $this->getEm();
$em->clear();
$reload = $em->getRepository(User::class)->find($aliceId);
self::assertNotNull($reload);
self::assertNull(
$reload->getCurrentSite(),
'currentSite doit etre NULL apres suppression du site reference.',
);
}
}

View File

@@ -10,9 +10,9 @@ use PHPUnit\Framework\TestCase;
use ReflectionClass;
/**
* Tests unitaires de comportement de l'entite Site : etat initial, setters
* et gestion des timestamps. Les contraintes de validation (regex, unicite)
* sont couvertes par SiteValidationTest.
* Tests unitaires de comportement de l'entite Site : etat initial, setters,
* gestion des timestamps et getter d'adresse complete. Les contraintes de
* validation (regex, unicite) sont couvertes par SiteValidationTest.
*
* @internal
*/
@@ -21,26 +21,28 @@ final class SiteTest extends TestCase
public function testConstructorInitialState(): void
{
$site = new Site(
'Chatellerault',
'Chatellerault',
'86100',
'#056CF2',
"1 avenue de l'Europe\n86100 Chatellerault",
name: 'Chatellerault',
street: "1 avenue de l'Europe",
complement: null,
postalCode: '86100',
city: 'Chatellerault',
color: '#056CF2',
);
self::assertNull($site->getId());
self::assertSame('Chatellerault', $site->getName());
self::assertSame('Chatellerault', $site->getCity());
self::assertSame("1 avenue de l'Europe", $site->getStreet());
self::assertNull($site->getComplement());
self::assertSame('86100', $site->getPostalCode());
self::assertSame('Chatellerault', $site->getCity());
self::assertSame('#056CF2', $site->getColor());
self::assertStringContainsString('Chatellerault', $site->getFullAddress());
self::assertInstanceOf(DateTimeImmutable::class, $site->getCreatedAt());
self::assertInstanceOf(DateTimeImmutable::class, $site->getUpdatedAt());
}
public function testCreatedAtAndUpdatedAtAreInitiallyEqual(): void
{
$site = new Site('A', 'B', '12345', '#000000', 'Rue X');
$site = new Site('A', 'Rue X', null, '12345', 'B', '#000000');
// A la creation, les deux timestamps sont seedes avec la meme valeur
// pour garantir updated_at >= created_at au niveau base.
@@ -49,7 +51,7 @@ final class SiteTest extends TestCase
public function testOnPreUpdateAdvancesUpdatedAtOnly(): void
{
$site = new Site('A', 'B', '12345', '#000000', 'Rue X');
$site = new Site('A', 'Rue X', null, '12345', 'B', '#000000');
$originalCreatedAt = $site->getCreatedAt();
// On force updatedAt a une valeur strictement anterieure via reflection
@@ -69,18 +71,63 @@ final class SiteTest extends TestCase
public function testSettersMutateFields(): void
{
$site = new Site('Old', 'OldCity', '12345', '#000000', 'Old Addr');
$site = new Site('Old', 'Old Street', null, '12345', 'OldCity', '#000000');
$site->setName('New');
$site->setCity('NewCity');
$site->setStreet('New Street');
$site->setComplement('Bat A');
$site->setPostalCode('67890');
$site->setCity('NewCity');
$site->setColor('#ABCDEF');
$site->setFullAddress('New Addr');
self::assertSame('New', $site->getName());
self::assertSame('NewCity', $site->getCity());
self::assertSame('New Street', $site->getStreet());
self::assertSame('Bat A', $site->getComplement());
self::assertSame('67890', $site->getPostalCode());
self::assertSame('NewCity', $site->getCity());
self::assertSame('#ABCDEF', $site->getColor());
self::assertSame('New Addr', $site->getFullAddress());
}
public function testFullAddressGetterWithoutComplement(): void
{
$site = new Site(
name: 'Site1',
street: '1 avenue de l\'Europe',
complement: null,
postalCode: '86100',
city: 'Chatellerault',
color: '#000000',
);
self::assertSame(
"1 avenue de l'Europe\n86100 Chatellerault",
$site->getFullAddress(),
);
}
public function testFullAddressGetterWithComplement(): void
{
$site = new Site(
name: 'Site2',
street: '12 route de Poitiers',
complement: 'Batiment B',
postalCode: '86330',
city: 'Saint-Jean-de-Sauves',
color: '#000000',
);
self::assertSame(
"12 route de Poitiers\nBatiment B\n86330 Saint-Jean-de-Sauves",
$site->getFullAddress(),
);
}
public function testFullAddressGetterIgnoresEmptyComplement(): void
{
// Garde defensive : un complement vide ou whitespace-only ne doit
// pas creer une ligne vide visuellement disgracieuse.
$site = new Site('S', 'Rue', ' ', '12345', 'Ville', '#000000');
self::assertSame("Rue\n12345 Ville", $site->getFullAddress());
}
}

View File

@@ -50,9 +50,15 @@ final class SiteValidationTest extends KernelTestCase
public function testValidSitePassesValidation(): void
{
// Reutilise un nom deja present en fixtures (Chatellerault) impliquerait
// une collision UniqueEntity. On prend donc un nom dedie aux tests.
$site = new Site('Test-Valid-'.uniqid('', true), 'Poitiers', '86000', '#056CF2', 'Adresse valide');
$site = $this->makeSite();
$violations = $this->validator->validate($site);
self::assertCount(0, $violations, (string) $violations);
}
public function testValidSiteWithComplementPassesValidation(): void
{
$site = $this->makeSite(complement: 'Batiment C');
$violations = $this->validator->validate($site);
self::assertCount(0, $violations, (string) $violations);
@@ -61,8 +67,7 @@ final class SiteValidationTest extends KernelTestCase
#[DataProvider('invalidColorProvider')]
public function testColorMustBeHexRrggbb(string $color): void
{
$site = new Site('Test-'.uniqid('', true), 'Y', '12345', $color, 'Addr');
$site = $this->makeSite(color: $color);
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count(), sprintf('La couleur "%s" devrait etre rejetee.', $color));
@@ -91,8 +96,7 @@ final class SiteValidationTest extends KernelTestCase
#[DataProvider('validColorProvider')]
public function testValidColorsAreAccepted(string $color): void
{
$site = new Site('Test-'.uniqid('', true), 'Y', '12345', $color, 'Addr');
$site = $this->makeSite(color: $color);
$violations = $this->validator->validate($site);
self::assertCount(0, $violations, sprintf('La couleur "%s" devrait etre acceptee.', $color));
@@ -117,8 +121,7 @@ final class SiteValidationTest extends KernelTestCase
#[DataProvider('invalidPostalCodeProvider')]
public function testPostalCodeMustMatchFrFormat(string $postalCode): void
{
$site = new Site('Test-'.uniqid('', true), 'Y', $postalCode, '#000000', 'Addr');
$site = $this->makeSite(postalCode: $postalCode);
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count(), sprintf('Le CP "%s" devrait etre rejete.', $postalCode));
@@ -145,8 +148,7 @@ final class SiteValidationTest extends KernelTestCase
#[DataProvider('validPostalCodeProvider')]
public function testValidPostalCodesAreAccepted(string $postalCode): void
{
$site = new Site('Test-'.uniqid('', true), 'Y', $postalCode, '#000000', 'Addr');
$site = $this->makeSite(postalCode: $postalCode);
$violations = $this->validator->validate($site);
self::assertCount(0, $violations, (string) $violations);
@@ -168,8 +170,15 @@ final class SiteValidationTest extends KernelTestCase
public function testBlankNameIsRejected(): void
{
$site = new Site('', 'Y', '12345', '#000000', 'Addr');
$site = $this->makeSite(name: '');
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count());
}
public function testBlankStreetIsRejected(): void
{
$site = $this->makeSite(street: '');
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count());
@@ -177,17 +186,7 @@ final class SiteValidationTest extends KernelTestCase
public function testBlankCityIsRejected(): void
{
$site = new Site('Test-'.uniqid('', true), '', '12345', '#000000', 'Addr');
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count());
}
public function testBlankFullAddressIsRejected(): void
{
$site = new Site('Test-'.uniqid('', true), 'Y', '12345', '#000000', '');
$site = $this->makeSite(city: '');
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count());
@@ -195,8 +194,7 @@ final class SiteValidationTest extends KernelTestCase
public function testNameLongerThan100CharsIsRejected(): void
{
$site = new Site(str_repeat('a', 101), 'Y', '12345', '#000000', 'Addr');
$site = $this->makeSite(name: str_repeat('a', 101));
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count());
@@ -204,8 +202,23 @@ final class SiteValidationTest extends KernelTestCase
public function testCityLongerThan100CharsIsRejected(): void
{
$site = new Site('Test-'.uniqid('', true), str_repeat('a', 101), '12345', '#000000', 'Addr');
$site = $this->makeSite(city: str_repeat('a', 101));
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count());
}
public function testStreetLongerThan255CharsIsRejected(): void
{
$site = $this->makeSite(street: str_repeat('a', 256));
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count());
}
public function testComplementLongerThan255CharsIsRejected(): void
{
$site = $this->makeSite(complement: str_repeat('a', 256));
$violations = $this->validator->validate($site);
self::assertGreaterThan(0, $violations->count());
@@ -223,16 +236,13 @@ final class SiteValidationTest extends KernelTestCase
*/
public function testDuplicateNameIsRejected(): void
{
// Nom unique par execution pour eviter toute collision avec les
// fixtures (Chatellerault, Saint-Jean, Pommevic) ou des tests
// paralleles.
$name = 'Test-Duplicate-'.uniqid('', true);
$original = new Site($name, 'Poitiers', '86000', '#056CF2', 'Adresse originale');
$original = $this->makeSite(name: $name);
$this->em->persist($original);
$this->em->flush();
try {
$duplicate = new Site($name, 'Autre ville', '75001', '#FF0000', 'Autre adresse');
$duplicate = $this->makeSite(name: $name, city: 'Autre');
$violations = $this->validator->validate($duplicate);
self::assertGreaterThan(0, $violations->count(), 'Un site homonyme doit lever au moins une violation.');
@@ -256,4 +266,26 @@ final class SiteValidationTest extends KernelTestCase
$this->em->flush();
}
}
/**
* Helper : construit un Site valide avec un nom unique, sur lequel on
* peut superposer un seul champ invalide pour tester une contrainte.
*/
private function makeSite(
?string $name = null,
string $street = '1 rue Test',
?string $complement = null,
string $postalCode = '12345',
string $city = 'Poitiers',
string $color = '#000000',
): Site {
return new Site(
$name ?? 'Test-'.uniqid('', true),
$street,
$complement,
$postalCode,
$city,
$color,
);
}
}