fix(audit-log) : applique fixes code review PR #9
Resout les 5 findings de la review automatique + couverture ManyToMany annoncee dans CLAUDE.md : - AuditListener : resolution de la classe via ClassMetadata plutot que `$entity::class` direct (defense proxy Doctrine : sous ORM 2 les lazies sont des `Proxies\__CG__\...`). Test de regression via getReference(). - AuditListener : capture des modifications de collections to-many (OneToMany / ManyToMany) via getScheduledCollectionUpdates / getScheduledCollectionDeletions. Les diffs sont mergees dans le changeset existant ou creent une entree "update" dediee. - AuditLogResource + Provider : filtre multi-valeurs `entity_type[]=X&entity_type[]=Y` (IN clause DBAL via ArrayParameterType::STRING), endpoint `/audit-log-entity-types` pour alimenter le MalioSelectCheckbox cote front. - audit-log.vue : refonte complete. Passage a `MalioDataTable`, composants `Malio*` (MalioInputText, MalioSelectCheckbox, MalioButton), suppression complete de la persistance URL (`readQuery` / `syncQuery` / `route.query`). `datetime-local` conserve avec TODO pointant l'exception CLAUDE.md. - AuditTimeline : fix du saut d'items 11-30. `PAGE_SIZE = 10` aligne avec un `itemsPerPage=10` passe au backend. Token anti-race pour ignorer les reponses tardives quand l'entite affichee change. - AuditLogDetail : affichage des diffs de collections to-many (+ / -) dans le tableau field/old/new existant. - logout.vue : ajout du `resetAuditLog()` au logout pour eviter qu'un user suivant (meme onglet) voie l'etat audit de l'ancien. - Permission / Role / Site : marquage `#[Auditable]`. - Version bump 0.1.32 → 0.1.34. Tests : 228 / 228 (221 assertions → 851, dont regressions proxy + M2M). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,2 +1,2 @@
|
|||||||
parameters:
|
parameters:
|
||||||
app.version: '0.1.32'
|
app.version: '0.1.34'
|
||||||
|
|||||||
@@ -88,13 +88,15 @@
|
|||||||
"date_to": "Au",
|
"date_to": "Au",
|
||||||
"entity_type": "Type d'entité",
|
"entity_type": "Type d'entité",
|
||||||
"user": "Utilisateur",
|
"user": "Utilisateur",
|
||||||
"action": "Action"
|
"action": "Action",
|
||||||
|
"all_actions": "Toutes les actions"
|
||||||
},
|
},
|
||||||
"detail": {
|
"detail": {
|
||||||
"field": "Champ",
|
"field": "Champ",
|
||||||
"old_value": "Ancienne valeur",
|
"old_value": "Ancienne valeur",
|
||||||
"new_value": "Nouvelle valeur"
|
"new_value": "Nouvelle valeur"
|
||||||
}
|
},
|
||||||
|
"detail_title": "Détail de l'entrée"
|
||||||
},
|
},
|
||||||
"success": {
|
"success": {
|
||||||
"auth": {
|
"auth": {
|
||||||
|
|||||||
@@ -8,260 +8,271 @@
|
|||||||
|
|
||||||
<!-- Filtres -->
|
<!-- Filtres -->
|
||||||
<section class="mt-4 rounded border border-gray-200 bg-white p-4">
|
<section class="mt-4 rounded border border-gray-200 bg-white p-4">
|
||||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-5">
|
<!-- Labels uniformes au-dessus : les composants Malio sont utilises sans
|
||||||
|
leur `label` flottant interne pour ne pas mixer deux patterns de label. -->
|
||||||
|
<div class="grid grid-cols-1 items-start gap-3 md:grid-cols-5">
|
||||||
|
<!-- TODO(malio-ui): remplacer par un composant Malio quand la lib
|
||||||
|
exposera un datetime picker. Cf. exception documentee dans
|
||||||
|
CLAUDE.md (section "Composants formulaires"). -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-gray-600">
|
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||||
{{ t('audit.filters.date_from') }}
|
{{ t('audit.filters.date_from') }}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
v-model="filters.performedAtAfter"
|
v-model="filters.performedAtAfter"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
class="mt-1 w-full rounded border border-gray-300 px-2 py-1 text-sm"
|
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- TODO(malio-ui): idem ci-dessus. -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-gray-600">
|
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||||
{{ t('audit.filters.date_to') }}
|
{{ t('audit.filters.date_to') }}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
v-model="filters.performedAtBefore"
|
v-model="filters.performedAtBefore"
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
class="mt-1 w-full rounded border border-gray-300 px-2 py-1 text-sm"
|
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-gray-600">
|
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||||
{{ t('audit.filters.entity_type') }}
|
{{ t('audit.filters.entity_type') }}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div class="[&>div>div]:!mt-0">
|
||||||
v-model="filters.entityType"
|
<MalioSelectCheckbox
|
||||||
type="text"
|
v-model="selectedEntityTypes"
|
||||||
placeholder="core.User"
|
:options="entityTypeOptions"
|
||||||
class="mt-1 w-full rounded border border-gray-300 px-2 py-1 text-sm"
|
:display-select-all="true"
|
||||||
>
|
:display-tag="true"
|
||||||
|
min-width="w-full"
|
||||||
|
text-field="text-sm"
|
||||||
|
text-value="text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-gray-600">
|
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||||
{{ t('audit.filters.user') }}
|
{{ t('audit.filters.user') }}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<MalioInputText
|
||||||
v-model="filters.performedBy"
|
v-model="performedByInput"
|
||||||
type="text"
|
icon-name="mdi:account-search"
|
||||||
class="mt-1 w-full rounded border border-gray-300 px-2 py-1 text-sm"
|
input-class="text-sm"
|
||||||
>
|
group-class="h-10"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- TODO(malio-ui): remplacer par MalioSelect quand la lib
|
||||||
|
supportera de maniere fiable des options a valeur string
|
||||||
|
(cf. note Lesstime CLAUDE.md). Exception documentee dans
|
||||||
|
CLAUDE.md (section "Composants formulaires"). -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-medium text-gray-600">
|
<label class="mb-1 block text-xs font-medium text-gray-600">
|
||||||
{{ t('audit.filters.action') }}
|
{{ t('audit.filters.action') }}
|
||||||
</label>
|
</label>
|
||||||
<div class="mt-1 flex flex-wrap gap-2">
|
<select
|
||||||
<label v-for="a in allActions" :key="a" class="flex items-center gap-1 text-xs">
|
v-model="actionValue"
|
||||||
<input
|
class="h-[40px] w-full rounded-md border border-m-muted bg-white px-3 text-sm outline-none focus-visible:border-2 focus-visible:border-m-primary"
|
||||||
type="checkbox"
|
>
|
||||||
:checked="selectedActions.includes(a)"
|
<option value="">{{ t('audit.filters.all_actions') }}</option>
|
||||||
@change="toggleAction(a)"
|
<option v-for="opt in actionOptions" :key="opt.value" :value="opt.value">
|
||||||
>
|
{{ opt.label }}
|
||||||
{{ t(`audit.action.${a}`) }}
|
</option>
|
||||||
</label>
|
</select>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-3 flex justify-end">
|
<div class="mt-3 flex justify-end">
|
||||||
<button
|
<MalioButton
|
||||||
type="button"
|
variant="tertiary"
|
||||||
class="px-3 py-1 text-xs rounded border border-gray-300 hover:bg-gray-50"
|
:label="t('audit.filters.reset')"
|
||||||
|
button-class="text-xs"
|
||||||
@click="resetFilters"
|
@click="resetFilters"
|
||||||
>
|
/>
|
||||||
{{ t('audit.filters.reset') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Tableau -->
|
<!-- Tableau -->
|
||||||
<section class="mt-4 rounded border border-gray-200 bg-white overflow-hidden">
|
<MalioDataTable
|
||||||
<table class="min-w-full text-sm">
|
class="mt-4"
|
||||||
<thead class="bg-tertiary-500 text-white">
|
:columns="columns"
|
||||||
<tr>
|
:items="rows"
|
||||||
<th class="px-3 py-2 text-left font-medium">
|
:total-items="totalItems"
|
||||||
{{ t('admin.auditLog.table.performedAt') }}
|
:page="filters.page ?? 1"
|
||||||
</th>
|
:per-page="filters.itemsPerPage ?? 10"
|
||||||
<th class="px-3 py-2 text-left font-medium">
|
:per-page-options="[10, 25, 50]"
|
||||||
{{ t('admin.auditLog.table.performedBy') }}
|
:empty-message="isFiltered ? t('audit.no_results') : t('audit.empty')"
|
||||||
</th>
|
@update:page="onPageChange"
|
||||||
<th class="px-3 py-2 text-left font-medium">
|
@update:per-page="onPerPageChange"
|
||||||
{{ t('admin.auditLog.table.entityType') }}
|
@row-click="onRowClick"
|
||||||
</th>
|
>
|
||||||
<th class="px-3 py-2 text-left font-medium">
|
<template #cell-action="{ item }">
|
||||||
{{ t('admin.auditLog.table.entityId') }}
|
<span
|
||||||
</th>
|
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
|
||||||
<th class="px-3 py-2 text-left font-medium">
|
:class="actionBadgeClass(item.action as string)"
|
||||||
{{ t('admin.auditLog.table.action') }}
|
>
|
||||||
</th>
|
{{ t(`audit.action.${item.action}`) }}
|
||||||
<th class="px-3 py-2 text-left font-medium">
|
</span>
|
||||||
{{ t('admin.auditLog.table.summary') }}
|
</template>
|
||||||
</th>
|
<template #cell-entityType="{ item }">
|
||||||
</tr>
|
<span class="font-mono text-xs">{{ item.entityType }}</span>
|
||||||
</thead>
|
</template>
|
||||||
<tbody>
|
<template #cell-entityId="{ item }">
|
||||||
<template v-if="entries.length > 0">
|
<span class="font-mono text-xs">{{ item.entityId }}</span>
|
||||||
<template v-for="entry in entries" :key="entry.id">
|
</template>
|
||||||
<tr
|
<template #cell-summary="{ item }">
|
||||||
class="border-t border-gray-100 hover:bg-gray-50 cursor-pointer"
|
<span class="text-xs text-gray-600">{{ item.summary }}</span>
|
||||||
@click="toggleExpand(entry.id)"
|
</template>
|
||||||
>
|
</MalioDataTable>
|
||||||
<td class="px-3 py-2">
|
|
||||||
{{ formatDate(entry.performedAt) }}
|
|
||||||
</td>
|
|
||||||
<td class="px-3 py-2">{{ entry.performedBy }}</td>
|
|
||||||
<td class="px-3 py-2 font-mono text-xs">{{ entry.entityType }}</td>
|
|
||||||
<td class="px-3 py-2 font-mono text-xs">{{ entry.entityId }}</td>
|
|
||||||
<td class="px-3 py-2">
|
|
||||||
<span
|
|
||||||
class="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium"
|
|
||||||
:class="actionBadgeClass(entry.action)"
|
|
||||||
>
|
|
||||||
{{ t(`audit.action.${entry.action}`) }}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td class="px-3 py-2 text-xs text-gray-600">
|
|
||||||
{{ summarize(entry) }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<!-- Detail expandable : diff courant + timeline complete de l'entite. -->
|
|
||||||
<tr v-if="expandedId === entry.id" class="bg-gray-50">
|
|
||||||
<td colspan="6" class="px-3 py-3">
|
|
||||||
<AuditLogDetail :entry="entry" />
|
|
||||||
<div class="mt-4 border-t border-gray-200 pt-3">
|
|
||||||
<h3 class="text-sm font-medium text-gray-700 mb-2">
|
|
||||||
{{ entry.entityType }} #{{ entry.entityId }}
|
|
||||||
</h3>
|
|
||||||
<AuditTimeline
|
|
||||||
:entity-type="entry.entityType"
|
|
||||||
:entity-id="entry.entityId"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
<tr v-else-if="!loading">
|
|
||||||
<td colspan="6" class="px-3 py-6 text-center text-sm text-gray-500">
|
|
||||||
{{ isFiltered ? t('audit.no_results') : t('audit.empty') }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr v-else>
|
|
||||||
<td colspan="6" class="px-3 py-6 text-center text-sm text-gray-500">
|
|
||||||
{{ t('common.loading') }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Pagination via hydra (view.next / view.previous) -->
|
<!-- Drawer detail : diff courant + timeline complete de l'entite -->
|
||||||
<nav class="mt-3 flex items-center justify-between text-sm">
|
<MalioDrawer
|
||||||
<span class="text-gray-600">
|
v-model="drawerOpen"
|
||||||
{{ totalItems }} entrée{{ totalItems > 1 ? 's' : '' }}
|
:title="drawerTitle"
|
||||||
</span>
|
drawer-class="max-w-2xl"
|
||||||
<div class="flex gap-2">
|
>
|
||||||
<button
|
<div v-if="selectedEntry">
|
||||||
type="button"
|
<AuditLogDetail :entry="selectedEntry" />
|
||||||
class="px-3 py-1 rounded border border-gray-300 disabled:opacity-60"
|
<div class="mt-4 border-t border-gray-200 pt-3">
|
||||||
:disabled="!hasPrevious || loading"
|
<h3 class="text-sm font-medium text-gray-700 mb-2">
|
||||||
@click="goPrevious"
|
{{ selectedEntry.entityType }} #{{ selectedEntry.entityId }}
|
||||||
>
|
</h3>
|
||||||
{{ t('admin.auditLog.pagination.previous') }}
|
<AuditTimeline
|
||||||
</button>
|
:entity-type="selectedEntry.entityType"
|
||||||
<button
|
:entity-id="selectedEntry.entityId"
|
||||||
type="button"
|
/>
|
||||||
class="px-3 py-1 rounded border border-gray-300 disabled:opacity-60"
|
</div>
|
||||||
:disabled="!hasNext || loading"
|
|
||||||
@click="goNext"
|
|
||||||
>
|
|
||||||
{{ t('admin.auditLog.pagination.next') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</MalioDrawer>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import type { AuditLogEntry, AuditLogFilters } from '~/shared/types'
|
import type { AuditLogEntry, AuditLogFilters } from '~/shared/types'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const { can } = usePermissions()
|
const { can } = usePermissions()
|
||||||
const router = useRouter()
|
const { fetchLogs, fetchEntityTypes } = useAuditLog()
|
||||||
const route = useRoute()
|
|
||||||
const { fetchLogs } = useAuditLog()
|
|
||||||
|
|
||||||
// Protection cote UI : le middleware `modules.global.ts` filtre deja les
|
// Protection cote UI : le middleware `modules.global.ts` filtre deja les
|
||||||
// routes desactivees, mais si quelqu'un atterit ici sans la permission on
|
// routes desactivees, mais si quelqu'un atterit ici sans la permission on
|
||||||
// renvoie sur la page admin parente plutot que de flasher un ecran vide.
|
// renvoie une 403 plutot que de flasher un ecran vide.
|
||||||
if (!can('core.audit_log.view')) {
|
if (!can('core.audit_log.view')) {
|
||||||
throw createError({ statusCode: 403, statusMessage: 'Forbidden' })
|
throw createError({ statusCode: 403, statusMessage: 'Forbidden' })
|
||||||
}
|
}
|
||||||
|
|
||||||
useHead({ title: t('admin.auditLog.title') })
|
useHead({ title: t('admin.auditLog.title') })
|
||||||
|
|
||||||
const allActions = ['create', 'update', 'delete'] as const
|
// Etat des filtres : local uniquement, JAMAIS persiste dans l'URL (cf. regle
|
||||||
type ActionKind = typeof allActions[number]
|
// CLAUDE.md "Tableau : pas de persistance URL").
|
||||||
|
|
||||||
const filters = reactive<AuditLogFilters>({
|
const filters = reactive<AuditLogFilters>({
|
||||||
performedAtAfter: readQuery('after'),
|
performedAtAfter: undefined,
|
||||||
performedAtBefore: readQuery('before'),
|
performedAtBefore: undefined,
|
||||||
entityType: readQuery('entity_type'),
|
entityType: undefined,
|
||||||
performedBy: readQuery('performed_by'),
|
performedBy: undefined,
|
||||||
action: readQuery('action'),
|
action: undefined,
|
||||||
page: Number(readQuery('page') ?? 1) || 1,
|
page: 1,
|
||||||
|
itemsPerPage: 10,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Les checkboxes d'action fonctionnent en multi-select cote UI mais l'API
|
// Multi-selection entity_type : bind dedie au MalioSelectCheckbox.
|
||||||
// ne supporte qu'une valeur a la fois : on combine les cases cochees en un
|
// Attention : les composants Malio attendent `{ label, value }` (pas `{ text }`).
|
||||||
// seul filtre "action=X" lorsque une seule case est active. Si plusieurs ou
|
const selectedEntityTypes = ref<(string | number)[]>([])
|
||||||
// zero sont cochees, on n'applique pas le filtre action (comportement =
|
const entityTypes = ref<string[]>([])
|
||||||
// "toutes actions").
|
const entityTypeOptions = computed(() =>
|
||||||
const selectedActions = ref<ActionKind[]>(filters.action ? [filters.action as ActionKind] : [])
|
entityTypes.value.map(t => ({ value: t, label: t })),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bind champ performedBy : MalioInputText attend `string | null`, on ne peut
|
||||||
|
// pas binder directement un `string | undefined` reactive.
|
||||||
|
const performedByInput = ref<string>('')
|
||||||
|
|
||||||
|
// Action : MalioSelect ne gere pas fiablement des options a valeur string (cf.
|
||||||
|
// note Lesstime CLAUDE.md). On utilise un `<select>` natif stylise comme les
|
||||||
|
// inputs dates pour garder un look coherent. '' = "toutes les actions".
|
||||||
|
const actionValue = ref<string>('')
|
||||||
|
const actionOptions = [
|
||||||
|
{ value: 'create', label: t('audit.action.create') },
|
||||||
|
{ value: 'update', label: t('audit.action.update') },
|
||||||
|
{ value: 'delete', label: t('audit.action.delete') },
|
||||||
|
]
|
||||||
|
|
||||||
const entries = ref<AuditLogEntry[]>([])
|
const entries = ref<AuditLogEntry[]>([])
|
||||||
const totalItems = ref(0)
|
const totalItems = ref(0)
|
||||||
const hasPrevious = ref(false)
|
|
||||||
const hasNext = ref(false)
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const expandedId = ref<string | null>(null)
|
|
||||||
|
const drawerOpen = ref(false)
|
||||||
|
const selectedEntry = ref<AuditLogEntry | null>(null)
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: 'performedAt', label: t('admin.auditLog.table.performedAt') },
|
||||||
|
{ key: 'performedBy', label: t('admin.auditLog.table.performedBy') },
|
||||||
|
{ key: 'entityType', label: t('admin.auditLog.table.entityType') },
|
||||||
|
{ key: 'entityId', label: t('admin.auditLog.table.entityId') },
|
||||||
|
{ key: 'action', label: t('admin.auditLog.table.action') },
|
||||||
|
{ key: 'summary', label: t('admin.auditLog.table.summary') },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Transforme chaque AuditLogEntry en ligne compatible MalioDataTable.
|
||||||
|
// On conserve `id` pour retrouver l'entry complete sur row-click.
|
||||||
|
const rows = computed(() =>
|
||||||
|
entries.value.map(entry => ({
|
||||||
|
id: entry.id,
|
||||||
|
performedAt: formatDate(entry.performedAt),
|
||||||
|
performedBy: entry.performedBy,
|
||||||
|
entityType: entry.entityType,
|
||||||
|
entityId: entry.entityId,
|
||||||
|
action: entry.action,
|
||||||
|
summary: summarize(entry),
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const drawerTitle = computed(() =>
|
||||||
|
selectedEntry.value
|
||||||
|
? `${selectedEntry.value.entityType} #${selectedEntry.value.entityId}`
|
||||||
|
: t('audit.detail_title'),
|
||||||
|
)
|
||||||
|
|
||||||
const isFiltered = computed(() =>
|
const isFiltered = computed(() =>
|
||||||
Boolean(filters.performedAtAfter || filters.performedAtBefore || filters.entityType
|
Boolean(filters.performedAtAfter || filters.performedAtBefore
|
||||||
|
|| (Array.isArray(filters.entityType) ? filters.entityType.length : filters.entityType)
|
||||||
|| filters.performedBy || filters.action),
|
|| filters.performedBy || filters.action),
|
||||||
)
|
)
|
||||||
|
|
||||||
function readQuery(key: string): string | undefined {
|
// Anti-race : chaque fetch incremente un compteur ; seul le dernier en date
|
||||||
const v = route.query[key]
|
// ecrit les resultats dans `entries`/`totalItems`. Evite qu'une reponse tardive
|
||||||
return typeof v === 'string' && v !== '' ? v : undefined
|
// (reseau lent) n'ecrase les resultats d'une requete ulterieure.
|
||||||
}
|
let requestToken = 0
|
||||||
|
|
||||||
function toggleAction(action: ActionKind): void {
|
// Pendant un reset, on suspend temporairement les watchers pour ne pas
|
||||||
const idx = selectedActions.value.indexOf(action)
|
// declencher 4 fetchs paralleles (un par champ mute). Les watchers Vue 3
|
||||||
if (idx >= 0) selectedActions.value.splice(idx, 1)
|
// sont asynchrones (microtask) : il faut attendre un `nextTick` avant de
|
||||||
else selectedActions.value.push(action)
|
// les relacher, sinon le flag est deja `false` au moment ou ils s'executent
|
||||||
filters.action = selectedActions.value.length === 1 ? selectedActions.value[0] : undefined
|
// et les fetchs partent quand meme. Un seul loadEntries() est appele
|
||||||
filters.page = 1
|
// explicitement apres la liberation.
|
||||||
syncQuery()
|
let watchersSuspended = false
|
||||||
}
|
|
||||||
|
|
||||||
function resetFilters(): void {
|
async function resetFilters(): Promise<void> {
|
||||||
|
watchersSuspended = true
|
||||||
filters.performedAtAfter = undefined
|
filters.performedAtAfter = undefined
|
||||||
filters.performedAtBefore = undefined
|
filters.performedAtBefore = undefined
|
||||||
filters.entityType = undefined
|
filters.entityType = undefined
|
||||||
filters.performedBy = undefined
|
filters.performedBy = undefined
|
||||||
filters.action = undefined
|
filters.action = undefined
|
||||||
filters.page = 1
|
filters.page = 1
|
||||||
selectedActions.value = []
|
selectedEntityTypes.value = []
|
||||||
syncQuery()
|
performedByInput.value = ''
|
||||||
|
actionValue.value = ''
|
||||||
|
// Les watchers mute de Vue 3 se planifient en microtask : on attend
|
||||||
|
// leur execution avec le flag `true`, puis on libere.
|
||||||
|
await nextTick()
|
||||||
|
watchersSuspended = false
|
||||||
|
loadEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadEntries(): Promise<void> {
|
async function loadEntries(): Promise<void> {
|
||||||
|
const token = ++requestToken
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await fetchLogs({
|
const data = await fetchLogs({
|
||||||
@@ -270,16 +281,30 @@ async function loadEntries(): Promise<void> {
|
|||||||
performedAtAfter: filters.performedAtAfter ? toIso(filters.performedAtAfter) : undefined,
|
performedAtAfter: filters.performedAtAfter ? toIso(filters.performedAtAfter) : undefined,
|
||||||
performedAtBefore: filters.performedAtBefore ? toIso(filters.performedAtBefore) : undefined,
|
performedAtBefore: filters.performedAtBefore ? toIso(filters.performedAtBefore) : undefined,
|
||||||
})
|
})
|
||||||
|
// Reponse obsolete (un fetch plus recent a ete lance entre-temps) :
|
||||||
|
// on ignore le resultat pour ne pas overwrite l'etat courant.
|
||||||
|
if (token !== requestToken) return
|
||||||
entries.value = data.member ?? []
|
entries.value = data.member ?? []
|
||||||
totalItems.value = data.totalItems ?? 0
|
totalItems.value = data.totalItems ?? 0
|
||||||
const view = data.view
|
|
||||||
hasPrevious.value = Boolean(view?.previous)
|
|
||||||
hasNext.value = Boolean(view?.next)
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (token === requestToken) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debounce utilitaire pour le champ texte performedBy : evite un refetch a
|
||||||
|
// chaque frappe (reseau + SQL) et laisse l'utilisateur finir sa saisie.
|
||||||
|
function debounce<T extends (...args: never[]) => void>(fn: T, delay: number): T {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
return ((...args: Parameters<T>) => {
|
||||||
|
if (null !== timer) clearTimeout(timer)
|
||||||
|
timer = setTimeout(() => fn(...args), delay)
|
||||||
|
}) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedReload = debounce(() => loadEntries(), 300)
|
||||||
|
|
||||||
function toIso(localDateTime: string): string {
|
function toIso(localDateTime: string): string {
|
||||||
// datetime-local n'a pas de timezone : on assume heure locale et on
|
// datetime-local n'a pas de timezone : on assume heure locale et on
|
||||||
// laisse le navigateur generer l'ISO via Date().
|
// laisse le navigateur generer l'ISO via Date().
|
||||||
@@ -309,53 +334,72 @@ function summarize(entry: AuditLogEntry): string {
|
|||||||
return `${keys.slice(0, 3).join(', ')}… (+${keys.length - 3})`
|
return `${keys.slice(0, 3).join(', ')}… (+${keys.length - 3})`
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleExpand(id: string): void {
|
function onRowClick(item: Record<string, unknown>): void {
|
||||||
expandedId.value = expandedId.value === id ? null : id
|
const entry = entries.value.find(e => e.id === item.id)
|
||||||
|
if (entry) {
|
||||||
|
selectedEntry.value = entry
|
||||||
|
drawerOpen.value = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function goPrevious(): void {
|
function onPageChange(value: number): void {
|
||||||
if (!hasPrevious.value || !filters.page) return
|
filters.page = value
|
||||||
filters.page = Math.max(1, filters.page - 1)
|
|
||||||
syncQuery()
|
|
||||||
loadEntries()
|
loadEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
function goNext(): void {
|
function onPerPageChange(value: number): void {
|
||||||
if (!hasNext.value) return
|
filters.itemsPerPage = value
|
||||||
filters.page = (filters.page ?? 1) + 1
|
filters.page = 1
|
||||||
syncQuery()
|
|
||||||
loadEntries()
|
loadEntries()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persiste les filtres dans les query params URL pour que le reload ou le
|
// Sync MalioSelectCheckbox -> filters.entityType + reset page 1 + reload.
|
||||||
// partage de lien retrouve le meme etat.
|
watch(selectedEntityTypes, values => {
|
||||||
function syncQuery(): void {
|
if (watchersSuspended) return
|
||||||
const query: Record<string, string> = {}
|
filters.entityType = values.length > 0 ? values.map(v => String(v)) : undefined
|
||||||
if (filters.performedAtAfter) query.after = filters.performedAtAfter
|
filters.page = 1
|
||||||
if (filters.performedAtBefore) query.before = filters.performedAtBefore
|
loadEntries()
|
||||||
if (filters.entityType) query.entity_type = filters.entityType
|
})
|
||||||
if (filters.performedBy) query.performed_by = filters.performedBy
|
|
||||||
if (filters.action) query.action = filters.action
|
|
||||||
if (filters.page && filters.page !== 1) query.page = String(filters.page)
|
|
||||||
router.replace({ query })
|
|
||||||
}
|
|
||||||
|
|
||||||
// Synchronisation reactive : tout changement de filtre declenche un fetch
|
// Sync select action natif -> filters.action.
|
||||||
// + reset de la pagination a la page 1. La navigation page (prev/next) ne
|
watch(actionValue, value => {
|
||||||
// passe PAS par un watcher : elle appelle `loadEntries()` directement dans
|
if (watchersSuspended) return
|
||||||
// `goPrevious`/`goNext`. Cette separation evite un double-fetch concurrent
|
filters.action = value === '' ? undefined : value
|
||||||
// quand une filtre reset la page a 1 (sinon le watch de `filters.page`
|
filters.page = 1
|
||||||
// serait declenche une seconde fois en parallele).
|
loadEntries()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sync performedBy : frappe utilisateur -> debounce 300ms pour eviter un
|
||||||
|
// refetch par caractere. Le reset passe par debouncedReload egalement pour
|
||||||
|
// coalescer si plusieurs watchers tirent en meme temps.
|
||||||
|
watch(performedByInput, value => {
|
||||||
|
if (watchersSuspended) return
|
||||||
|
filters.performedBy = value === '' ? undefined : value
|
||||||
|
filters.page = 1
|
||||||
|
debouncedReload()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Synchronisation reactive : tout changement de dates declenche un fetch +
|
||||||
|
// reset de la pagination a la page 1.
|
||||||
watch(
|
watch(
|
||||||
() => [filters.performedAtAfter, filters.performedAtBefore, filters.entityType, filters.performedBy, filters.action],
|
() => [filters.performedAtAfter, filters.performedAtBefore],
|
||||||
() => {
|
() => {
|
||||||
|
if (watchersSuspended) return
|
||||||
filters.page = 1
|
filters.page = 1
|
||||||
syncQuery()
|
|
||||||
loadEntries()
|
loadEntries()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
loadEntries()
|
// Charge les entity types en parallele de la liste principale : un
|
||||||
|
// echec du premier endpoint (ex: reseau flaky) ne doit pas empecher
|
||||||
|
// le tableau d'audit de s'afficher. En cas d'erreur, on laisse le
|
||||||
|
// filtre vide — l'utilisateur pourra quand meme consulter le journal.
|
||||||
|
try {
|
||||||
|
entityTypes.value = await fetchEntityTypes()
|
||||||
|
} catch {
|
||||||
|
entityTypes.value = []
|
||||||
|
}
|
||||||
|
await loadEntries()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const auth = useAuthStore()
|
|||||||
const { resetSidebar } = useSidebar()
|
const { resetSidebar } = useSidebar()
|
||||||
const { resetModules } = useModules()
|
const { resetModules } = useModules()
|
||||||
const { resetCurrentSite } = useCurrentSite()
|
const { resetCurrentSite } = useCurrentSite()
|
||||||
|
const { resetAuditLog } = useAuditLog()
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -18,13 +19,14 @@ onMounted(async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
// Les resets sont garantis meme si auth.logout() rejette : eviter
|
// Les resets sont garantis meme si auth.logout() rejette : eviter
|
||||||
// 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. Toutes les 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
|
// navigateTo est dans le finally pour garantir la redirection
|
||||||
// meme si auth.logout() lance une exception (ex: reseau coupé).
|
// meme si auth.logout() lance une exception (ex: reseau coupé).
|
||||||
resetSidebar()
|
resetSidebar()
|
||||||
resetModules()
|
resetModules()
|
||||||
resetCurrentSite()
|
resetCurrentSite()
|
||||||
|
resetAuditLog()
|
||||||
await navigateTo('/login')
|
await navigateTo('/login')
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div v-if="entry.action === 'update'">
|
<div v-if="entry.action === 'update'">
|
||||||
|
<!-- Tableau de comparaison field/old/new. MalioDataTable n'est
|
||||||
|
pas adapte ici : cas presentationnel non-paginable (cf.
|
||||||
|
exception documentee dans CLAUDE.md). -->
|
||||||
<table class="min-w-full border border-gray-200 text-xs">
|
<table class="min-w-full border border-gray-200 text-xs">
|
||||||
<thead class="bg-gray-100">
|
<thead class="bg-gray-100">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -24,6 +27,20 @@
|
|||||||
<td class="px-2 py-1 text-red-700">{{ formatValue(diff.old) }}</td>
|
<td class="px-2 py-1 text-red-700">{{ formatValue(diff.old) }}</td>
|
||||||
<td class="px-2 py-1 text-green-700">{{ formatValue(diff.new) }}</td>
|
<td class="px-2 py-1 text-green-700">{{ formatValue(diff.new) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<!-- Modifications de collections to-many : shape different
|
||||||
|
{ added: [ids], removed: [ids] } → affiche + et - sur
|
||||||
|
la meme ligne pour garder une colonne field unique. -->
|
||||||
|
<tr v-for="(diff, field) in collectionDiff" :key="`col-${field}`" class="border-t border-gray-200">
|
||||||
|
<td class="px-2 py-1 font-mono">{{ field }}</td>
|
||||||
|
<td class="px-2 py-1 text-red-700">
|
||||||
|
<span v-if="diff.removed.length">− {{ diff.removed.join(', ') }}</span>
|
||||||
|
<span v-else class="text-gray-400">∅</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-2 py-1 text-green-700">
|
||||||
|
<span v-if="diff.added.length">+ {{ diff.added.join(', ') }}</span>
|
||||||
|
<span v-else class="text-gray-400">∅</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -45,7 +62,7 @@ const props = defineProps<{ entry: AuditLogEntry }>()
|
|||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
// Extrait les entrees au shape { old, new } pour les updates.
|
// Extrait les entrees au shape { old, new } pour les updates scalaires.
|
||||||
const updateDiff = computed<Record<string, { old: unknown; new: unknown }>>(() => {
|
const updateDiff = computed<Record<string, { old: unknown; new: unknown }>>(() => {
|
||||||
const out: Record<string, { old: unknown; new: unknown }> = {}
|
const out: Record<string, { old: unknown; new: unknown }> = {}
|
||||||
for (const [key, value] of Object.entries(props.entry.changes)) {
|
for (const [key, value] of Object.entries(props.entry.changes)) {
|
||||||
@@ -56,6 +73,22 @@ const updateDiff = computed<Record<string, { old: unknown; new: unknown }>>(() =
|
|||||||
return out
|
return out
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Extrait les entrees au shape { added, removed } pour les modifications
|
||||||
|
// de collections to-many (cf. AuditListener::captureCollectionChange).
|
||||||
|
const collectionDiff = computed<Record<string, { added: unknown[]; removed: unknown[] }>>(() => {
|
||||||
|
const out: Record<string, { added: unknown[]; removed: unknown[] }> = {}
|
||||||
|
for (const [key, value] of Object.entries(props.entry.changes)) {
|
||||||
|
if (value && typeof value === 'object' && 'added' in value && 'removed' in value) {
|
||||||
|
const diff = value as { added: unknown; removed: unknown }
|
||||||
|
out[key] = {
|
||||||
|
added: Array.isArray(diff.added) ? diff.added : [],
|
||||||
|
removed: Array.isArray(diff.removed) ? diff.removed : [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
})
|
||||||
|
|
||||||
function formatValue(value: unknown): string {
|
function formatValue(value: unknown): string {
|
||||||
if (value === null || value === undefined) return '∅'
|
if (value === null || value === undefined) return '∅'
|
||||||
if (typeof value === 'boolean') return value ? 'oui' : 'non'
|
if (typeof value === 'boolean') return value ? 'oui' : 'non'
|
||||||
|
|||||||
@@ -51,6 +51,13 @@
|
|||||||
<span class="mx-1">→</span>
|
<span class="mx-1">→</span>
|
||||||
<span class="text-green-700">{{ formatValue(diff.new) }}</span>
|
<span class="text-green-700">{{ formatValue(diff.new) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- Modifications de collections to-many. -->
|
||||||
|
<div v-for="(diff, field) in collectionDiff(entry)" :key="`col-${field}`">
|
||||||
|
<span class="font-medium">{{ field }}</span> :
|
||||||
|
<span v-if="diff.removed.length" class="text-red-600">−{{ diff.removed.join(', ') }}</span>
|
||||||
|
<span v-if="diff.removed.length && diff.added.length" class="mx-1"> </span>
|
||||||
|
<span v-if="diff.added.length" class="text-green-700">+{{ diff.added.join(', ') }}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="mt-1 text-xs text-gray-600">
|
<div v-else class="mt-1 text-xs text-gray-600">
|
||||||
{{ snapshotSummary(entry) }}
|
{{ snapshotSummary(entry) }}
|
||||||
@@ -104,29 +111,41 @@ const page = ref(1)
|
|||||||
const totalItems = ref(0)
|
const totalItems = ref(0)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
// Lazy loading : 10 items max par page visible cote UX. Le back fixe la
|
// Lazy loading : 10 items par page cote UX. On aligne la pagination backend
|
||||||
// limite a 30 (paginationItemsPerPage de AuditLogResource) ; on coupe a 10
|
// (itemsPerPage=10 dans fetchEntityLogs) avec cette taille pour eviter de
|
||||||
// dans le composant pour ne pas saturer le flux visuel, et on laisse
|
// slicer cote client — sinon les items 11-30 de chaque page etaient ignores.
|
||||||
// l'utilisateur demander plus via "Voir plus".
|
const PAGE_SIZE = 10
|
||||||
const INITIAL_LIMIT = 10
|
|
||||||
|
// Anti-race : un utilisateur qui change rapidement d'entite affichee (ouvre
|
||||||
|
// une ligne puis une autre dans le tableau admin) peut declencher deux fetchs
|
||||||
|
// dont le premier repond en retard et ecrase l'etat de la seconde timeline.
|
||||||
|
// On incremente un token a chaque fetch ; seule la derniere requete ecrit le
|
||||||
|
// resultat. loadMore() est aussi protege : une reponse tardive append sur
|
||||||
|
// une timeline dont l'entite a deja change serait visuellement confuse.
|
||||||
|
let requestToken = 0
|
||||||
|
|
||||||
const hasMore = computed(() => entries.value.length < totalItems.value)
|
const hasMore = computed(() => entries.value.length < totalItems.value)
|
||||||
|
|
||||||
async function loadPage(targetPage: number, append: boolean): Promise<void> {
|
async function loadPage(targetPage: number, append: boolean): Promise<void> {
|
||||||
if (!canView.value) return
|
if (!canView.value) return
|
||||||
|
|
||||||
|
const token = ++requestToken
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const data = await fetchEntityLogs(entityType.value, entityId.value, targetPage)
|
const data = await fetchEntityLogs(entityType.value, entityId.value, targetPage, PAGE_SIZE)
|
||||||
const slice = (data.member ?? []).slice(0, append ? undefined : INITIAL_LIMIT)
|
if (token !== requestToken) return
|
||||||
entries.value = append ? [...entries.value, ...slice] : slice
|
const items = data.member ?? []
|
||||||
|
entries.value = append ? [...entries.value, ...items] : items
|
||||||
totalItems.value = data.totalItems ?? entries.value.length
|
totalItems.value = data.totalItems ?? entries.value.length
|
||||||
page.value = targetPage
|
page.value = targetPage
|
||||||
} catch {
|
} catch {
|
||||||
|
if (token !== requestToken) return
|
||||||
// Erreur silencieuse (timeline secondaire) — useApi n'affiche pas de toast avec toast: false.
|
// Erreur silencieuse (timeline secondaire) — useApi n'affiche pas de toast avec toast: false.
|
||||||
entries.value = append ? entries.value : []
|
entries.value = append ? entries.value : []
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
if (token === requestToken) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,6 +198,22 @@ function updateDiff(entry: AuditLogEntry): Record<string, { old: unknown; new: u
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function collectionDiff(entry: AuditLogEntry): Record<string, { added: unknown[]; removed: unknown[] }> {
|
||||||
|
// Format to-many : { champ: { added: [ids], removed: [ids] } } produit
|
||||||
|
// par AuditListener::captureCollectionChange.
|
||||||
|
const out: Record<string, { added: unknown[]; removed: unknown[] }> = {}
|
||||||
|
for (const [key, value] of Object.entries(entry.changes)) {
|
||||||
|
if (value && typeof value === 'object' && 'added' in value && 'removed' in value) {
|
||||||
|
const diff = value as { added: unknown; removed: unknown }
|
||||||
|
out[key] = {
|
||||||
|
added: Array.isArray(diff.added) ? diff.added : [],
|
||||||
|
removed: Array.isArray(diff.removed) ? diff.removed : [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
function snapshotSummary(entry: AuditLogEntry): string {
|
function snapshotSummary(entry: AuditLogEntry): string {
|
||||||
const keys = Object.keys(entry.changes)
|
const keys = Object.keys(entry.changes)
|
||||||
if (keys.length === 0) return '—'
|
if (keys.length === 0) return '—'
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { AuditLogEntry, AuditLogFilters } from '~/shared/types'
|
import type { AuditLogEntityTypes, AuditLogEntry, AuditLogFilters } from '~/shared/types'
|
||||||
import type { HydraCollection } from '~/shared/utils/api'
|
import type { HydraCollection } from '~/shared/utils/api'
|
||||||
import { onAuthSessionCleared } from '~/shared/stores/auth'
|
import { onAuthSessionCleared } from '~/shared/stores/auth'
|
||||||
|
|
||||||
@@ -29,17 +29,25 @@ onAuthSessionCleared(resetAuditLog)
|
|||||||
*
|
*
|
||||||
* @returns objet plat directement consommable par `useApi().get(url, query)`.
|
* @returns objet plat directement consommable par `useApi().get(url, query)`.
|
||||||
*/
|
*/
|
||||||
function buildQuery(filters: AuditLogFilters | undefined): Record<string, string | number> {
|
function buildQuery(filters: AuditLogFilters | undefined): Record<string, string | number | string[]> {
|
||||||
const query: Record<string, string | number> = {}
|
const query: Record<string, string | number | string[]> = {}
|
||||||
if (!filters) return query
|
if (!filters) return query
|
||||||
|
|
||||||
if (filters.entityType) query.entity_type = filters.entityType
|
// `entity_type` : chaine simple ou liste pour un filtre multi-selection.
|
||||||
|
// Cote PHP, la syntaxe `entity_type[]=X&entity_type[]=Y` est requise pour
|
||||||
|
// que $_GET['entity_type'] soit un tableau (sinon "last wins").
|
||||||
|
if (Array.isArray(filters.entityType)) {
|
||||||
|
if (filters.entityType.length > 0) query['entity_type[]'] = filters.entityType
|
||||||
|
} else if (filters.entityType) {
|
||||||
|
query.entity_type = filters.entityType
|
||||||
|
}
|
||||||
if (filters.entityId) query.entity_id = filters.entityId
|
if (filters.entityId) query.entity_id = filters.entityId
|
||||||
if (filters.action) query.action = filters.action
|
if (filters.action) query.action = filters.action
|
||||||
if (filters.performedBy) query.performed_by = filters.performedBy
|
if (filters.performedBy) query.performed_by = filters.performedBy
|
||||||
if (filters.performedAtAfter) query['performed_at[after]'] = filters.performedAtAfter
|
if (filters.performedAtAfter) query['performed_at[after]'] = filters.performedAtAfter
|
||||||
if (filters.performedAtBefore) query['performed_at[before]'] = filters.performedAtBefore
|
if (filters.performedAtBefore) query['performed_at[before]'] = filters.performedAtBefore
|
||||||
if (filters.page) query.page = filters.page
|
if (filters.page) query.page = filters.page
|
||||||
|
if (filters.itemsPerPage) query.itemsPerPage = filters.itemsPerPage
|
||||||
|
|
||||||
return query
|
return query
|
||||||
}
|
}
|
||||||
@@ -84,18 +92,39 @@ export function useAuditLog() {
|
|||||||
return api.get<AuditLogEntry>(`/audit-logs/${id}`, {}, { toast: false, headers: JSONLD_HEADERS })
|
return api.get<AuditLogEntry>(`/audit-logs/${id}`, {}, { toast: false, headers: JSONLD_HEADERS })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liste des valeurs distinctes de `entity_type` pour alimenter le filtre
|
||||||
|
* multi-selection. Alimente par un endpoint DBAL, aucune cache cote front
|
||||||
|
* (la liste peut evoluer a chaque nouvelle ecriture d'audit).
|
||||||
|
*/
|
||||||
|
async function fetchEntityTypes(): Promise<string[]> {
|
||||||
|
const data = await api.get<AuditLogEntityTypes>(
|
||||||
|
'/audit-log-entity-types',
|
||||||
|
{},
|
||||||
|
{ toast: false, headers: JSONLD_HEADERS },
|
||||||
|
)
|
||||||
|
return data.entityTypes ?? []
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchEntityLogs(
|
async function fetchEntityLogs(
|
||||||
entityType: string,
|
entityType: string,
|
||||||
entityId: string | number,
|
entityId: string | number,
|
||||||
page: number = 1,
|
page: number = 1,
|
||||||
|
itemsPerPage: number = 10,
|
||||||
): Promise<HydraCollection<AuditLogEntry>> {
|
): Promise<HydraCollection<AuditLogEntry>> {
|
||||||
// Volontairement via `fetchLogs` (sans cache) pour ne pas ecraser
|
// Volontairement via `fetchLogs` (sans cache) pour ne pas ecraser
|
||||||
// `lastCollection` — la timeline peut etre rendue simultanement a
|
// `lastCollection` — la timeline peut etre rendue simultanement a
|
||||||
// la page globale et doit rester independante.
|
// la page globale et doit rester independante.
|
||||||
|
//
|
||||||
|
// Le backend pagine a 30 par defaut (paginationItemsPerPage) ; on
|
||||||
|
// passe explicitement itemsPerPage ici pour que la taille de page
|
||||||
|
// soit alignee avec l'UX timeline (10 items + bouton "Voir plus").
|
||||||
|
// Sans ce param, le client slice a 10 et rate 20 entrees par page.
|
||||||
return fetchLogs({
|
return fetchLogs({
|
||||||
entityType,
|
entityType,
|
||||||
entityId: String(entityId),
|
entityId: String(entityId),
|
||||||
page,
|
page,
|
||||||
|
itemsPerPage,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +133,7 @@ export function useAuditLog() {
|
|||||||
fetchLogs: fetchLogsCached,
|
fetchLogs: fetchLogsCached,
|
||||||
fetchLogById,
|
fetchLogById,
|
||||||
fetchEntityLogs,
|
fetchEntityLogs,
|
||||||
|
fetchEntityTypes,
|
||||||
resetAuditLog,
|
resetAuditLog,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,11 +35,18 @@ export interface AuditLogEntry {
|
|||||||
* `performed_at[before]`.
|
* `performed_at[before]`.
|
||||||
*/
|
*/
|
||||||
export interface AuditLogFilters {
|
export interface AuditLogFilters {
|
||||||
entityType?: string
|
/** Chaine pour un seul type, liste pour un filtre multi-selection. */
|
||||||
|
entityType?: string | string[]
|
||||||
entityId?: string
|
entityId?: string
|
||||||
action?: string
|
action?: string
|
||||||
performedBy?: string
|
performedBy?: string
|
||||||
performedAtAfter?: string
|
performedAtAfter?: string
|
||||||
performedAtBefore?: string
|
performedAtBefore?: string
|
||||||
page?: number
|
page?: number
|
||||||
|
itemsPerPage?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuditLogEntityTypes {
|
||||||
|
id: string
|
||||||
|
entityTypes: string[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use ApiPlatform\Metadata\ApiResource;
|
|||||||
use ApiPlatform\Metadata\Get;
|
use ApiPlatform\Metadata\Get;
|
||||||
use ApiPlatform\Metadata\GetCollection;
|
use ApiPlatform\Metadata\GetCollection;
|
||||||
use App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository;
|
use App\Module\Core\Infrastructure\Doctrine\DoctrinePermissionRepository;
|
||||||
|
use App\Shared\Domain\Attribute\Auditable;
|
||||||
use Doctrine\ORM\Mapping as ORM;
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
use Symfony\Component\Serializer\Attribute\Groups;
|
use Symfony\Component\Serializer\Attribute\Groups;
|
||||||
@@ -31,6 +32,7 @@ use Symfony\Component\Serializer\Attribute\Groups;
|
|||||||
#[ApiFilter(BooleanFilter::class, properties: ['orphan'])]
|
#[ApiFilter(BooleanFilter::class, properties: ['orphan'])]
|
||||||
#[ORM\Entity(repositoryClass: DoctrinePermissionRepository::class)]
|
#[ORM\Entity(repositoryClass: DoctrinePermissionRepository::class)]
|
||||||
#[ORM\Table(name: 'permission')]
|
#[ORM\Table(name: 'permission')]
|
||||||
|
#[Auditable]
|
||||||
#[ORM\UniqueConstraint(name: 'uniq_permission_code', columns: ['code'])]
|
#[ORM\UniqueConstraint(name: 'uniq_permission_code', columns: ['code'])]
|
||||||
#[ORM\Index(name: 'idx_permission_module', columns: ['module'])]
|
#[ORM\Index(name: 'idx_permission_module', columns: ['module'])]
|
||||||
#[ORM\Index(name: 'idx_permission_orphan', columns: ['orphan'])]
|
#[ORM\Index(name: 'idx_permission_orphan', columns: ['orphan'])]
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use ApiPlatform\Metadata\Post;
|
|||||||
use App\Module\Core\Domain\Exception\SystemRoleDeletionException;
|
use App\Module\Core\Domain\Exception\SystemRoleDeletionException;
|
||||||
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\RoleProcessor;
|
use App\Module\Core\Infrastructure\ApiPlatform\State\Processor\RoleProcessor;
|
||||||
use App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository;
|
use App\Module\Core\Infrastructure\Doctrine\DoctrineRoleRepository;
|
||||||
|
use App\Shared\Domain\Attribute\Auditable;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
use Doctrine\Common\Collections\Collection;
|
use Doctrine\Common\Collections\Collection;
|
||||||
use Doctrine\DBAL\Types\Types;
|
use Doctrine\DBAL\Types\Types;
|
||||||
@@ -64,6 +65,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
#[ApiFilter(BooleanFilter::class, properties: ['isSystem'])]
|
#[ApiFilter(BooleanFilter::class, properties: ['isSystem'])]
|
||||||
#[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)]
|
#[ORM\Entity(repositoryClass: DoctrineRoleRepository::class)]
|
||||||
#[ORM\Table(name: '`role`')]
|
#[ORM\Table(name: '`role`')]
|
||||||
|
#[Auditable]
|
||||||
#[ORM\UniqueConstraint(name: 'uniq_role_code', columns: ['code'])]
|
#[ORM\UniqueConstraint(name: 'uniq_role_code', columns: ['code'])]
|
||||||
#[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])]
|
#[ORM\Index(name: 'idx_role_is_system', columns: ['is_system'])]
|
||||||
#[UniqueEntity(fields: ['code'], message: 'Un role avec ce code existe deja.')]
|
#[UniqueEntity(fields: ['code'], message: 'Un role avec ce code existe deja.')]
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Core\Infrastructure\ApiPlatform\Resource;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\ApiResource;
|
||||||
|
use ApiPlatform\Metadata\Get;
|
||||||
|
use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\AuditLogEntityTypesProvider;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne la liste des valeurs distinctes de `entity_type` presentes dans
|
||||||
|
* `audit_log`, pour alimenter le filtre multi-selection cote front (journal
|
||||||
|
* d'audit). La liste evolue automatiquement avec les nouvelles entites
|
||||||
|
* `#[Auditable]` au fil des ecritures.
|
||||||
|
*/
|
||||||
|
#[ApiResource(
|
||||||
|
shortName: 'AuditLogEntityTypes',
|
||||||
|
operations: [
|
||||||
|
new Get(
|
||||||
|
uriTemplate: '/audit-log-entity-types',
|
||||||
|
security: "is_granted('core.audit_log.view')",
|
||||||
|
provider: AuditLogEntityTypesProvider::class,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)]
|
||||||
|
final class AuditLogEntityTypesResource
|
||||||
|
{
|
||||||
|
/** @param list<string> $entityTypes */
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $id = 'entity-types',
|
||||||
|
public readonly array $entityTypes = [],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -39,6 +39,8 @@ use App\Module\Core\Infrastructure\ApiPlatform\State\Provider\AuditLogProvider;
|
|||||||
new GetCollection(
|
new GetCollection(
|
||||||
uriTemplate: '/audit-logs',
|
uriTemplate: '/audit-logs',
|
||||||
paginationItemsPerPage: 30,
|
paginationItemsPerPage: 30,
|
||||||
|
paginationClientItemsPerPage: true,
|
||||||
|
paginationMaximumItemsPerPage: 100,
|
||||||
security: "is_granted('core.audit_log.view')",
|
security: "is_granted('core.audit_log.view')",
|
||||||
provider: AuditLogProvider::class,
|
provider: AuditLogProvider::class,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Module\Core\Infrastructure\ApiPlatform\State\Provider;
|
||||||
|
|
||||||
|
use ApiPlatform\Metadata\Operation;
|
||||||
|
use ApiPlatform\State\ProviderInterface;
|
||||||
|
use App\Module\Core\Infrastructure\ApiPlatform\Resource\AuditLogEntityTypesResource;
|
||||||
|
use Doctrine\DBAL\Connection;
|
||||||
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provider DBAL : SELECT DISTINCT entity_type FROM audit_log.
|
||||||
|
*
|
||||||
|
* @implements ProviderInterface<AuditLogEntityTypesResource>
|
||||||
|
*/
|
||||||
|
final readonly class AuditLogEntityTypesProvider implements ProviderInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
#[Autowire(service: 'doctrine.dbal.default_connection')]
|
||||||
|
private Connection $connection,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function provide(Operation $operation, array $uriVariables = [], array $context = []): AuditLogEntityTypesResource
|
||||||
|
{
|
||||||
|
/** @var list<string> $types */
|
||||||
|
$types = $this->connection
|
||||||
|
->executeQuery('SELECT DISTINCT entity_type FROM audit_log ORDER BY entity_type ASC')
|
||||||
|
->fetchFirstColumn()
|
||||||
|
;
|
||||||
|
|
||||||
|
return new AuditLogEntityTypesResource(entityTypes: $types);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ use ApiPlatform\State\ProviderInterface;
|
|||||||
use App\Module\Core\Application\DTO\AuditLogOutput;
|
use App\Module\Core\Application\DTO\AuditLogOutput;
|
||||||
use App\Module\Core\Infrastructure\ApiPlatform\Pagination\DbalPaginator;
|
use App\Module\Core\Infrastructure\ApiPlatform\Pagination\DbalPaginator;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
|
use Doctrine\DBAL\ArrayParameterType;
|
||||||
use Doctrine\DBAL\Connection;
|
use Doctrine\DBAL\Connection;
|
||||||
use Doctrine\DBAL\Query\QueryBuilder;
|
use Doctrine\DBAL\Query\QueryBuilder;
|
||||||
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
use Symfony\Component\DependencyInjection\Attribute\Autowire;
|
||||||
@@ -100,13 +101,30 @@ final readonly class AuditLogProvider implements ProviderInterface
|
|||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $raw
|
* @param array<string, mixed> $raw
|
||||||
*
|
*
|
||||||
* @return array{entity_type?: string, entity_id?: string, action?: string, performed_by?: string, performed_at_after?: string, performed_at_before?: string}
|
* @return array{entity_type?: list<string>|string, entity_id?: string, action?: string, performed_by?: string, performed_at_after?: string, performed_at_before?: string}
|
||||||
*/
|
*/
|
||||||
private function extractFilters(array $raw): array
|
private function extractFilters(array $raw): array
|
||||||
{
|
{
|
||||||
$filters = [];
|
$filters = [];
|
||||||
|
|
||||||
foreach (['entity_type', 'entity_id', 'action', 'performed_by'] as $key) {
|
// `entity_type` accepte soit une chaine, soit une liste (query syntax
|
||||||
|
// `entity_type[]=core.User&entity_type[]=core.Role`) pour le filtre
|
||||||
|
// multi-selection cote front. On normalise en list<string> non-vide.
|
||||||
|
if (isset($raw['entity_type'])) {
|
||||||
|
if (is_string($raw['entity_type']) && '' !== $raw['entity_type']) {
|
||||||
|
$filters['entity_type'] = $raw['entity_type'];
|
||||||
|
} elseif (is_array($raw['entity_type'])) {
|
||||||
|
$cleaned = array_values(array_filter(
|
||||||
|
$raw['entity_type'],
|
||||||
|
static fn ($v): bool => is_string($v) && '' !== $v,
|
||||||
|
));
|
||||||
|
if ([] !== $cleaned) {
|
||||||
|
$filters['entity_type'] = $cleaned;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['entity_id', 'action', 'performed_by'] as $key) {
|
||||||
if (isset($raw[$key]) && is_string($raw[$key]) && '' !== $raw[$key]) {
|
if (isset($raw[$key]) && is_string($raw[$key]) && '' !== $raw[$key]) {
|
||||||
$filters[$key] = $raw[$key];
|
$filters[$key] = $raw[$key];
|
||||||
}
|
}
|
||||||
@@ -127,12 +145,18 @@ final readonly class AuditLogProvider implements ProviderInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, string> $filters
|
* @param array<string, list<string>|string> $filters
|
||||||
*/
|
*/
|
||||||
private function applyFilters(QueryBuilder $qb, array $filters): void
|
private function applyFilters(QueryBuilder $qb, array $filters): void
|
||||||
{
|
{
|
||||||
if (isset($filters['entity_type'])) {
|
if (isset($filters['entity_type'])) {
|
||||||
$qb->andWhere('entity_type = :entity_type')->setParameter('entity_type', $filters['entity_type']);
|
if (is_array($filters['entity_type'])) {
|
||||||
|
$qb->andWhere('entity_type IN (:entity_types)')
|
||||||
|
->setParameter('entity_types', $filters['entity_type'], ArrayParameterType::STRING)
|
||||||
|
;
|
||||||
|
} else {
|
||||||
|
$qb->andWhere('entity_type = :entity_type')->setParameter('entity_type', $filters['entity_type']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (isset($filters['entity_id'])) {
|
if (isset($filters['entity_id'])) {
|
||||||
$qb->andWhere('entity_id = :entity_id')->setParameter('entity_id', $filters['entity_id']);
|
$qb->andWhere('entity_id = :entity_id')->setParameter('entity_id', $filters['entity_id']);
|
||||||
@@ -141,7 +165,15 @@ final readonly class AuditLogProvider implements ProviderInterface
|
|||||||
$qb->andWhere('action = :action')->setParameter('action', $filters['action']);
|
$qb->andWhere('action = :action')->setParameter('action', $filters['action']);
|
||||||
}
|
}
|
||||||
if (isset($filters['performed_by'])) {
|
if (isset($filters['performed_by'])) {
|
||||||
$qb->andWhere('performed_by = :performed_by')->setParameter('performed_by', $filters['performed_by']);
|
// Recherche contains insensible a la casse pour matcher "adm" → "admin".
|
||||||
|
// On echappe `%`, `_` et `\` saisis par l'utilisateur pour qu'ils soient
|
||||||
|
// interpretes comme caracteres litteraux (sinon `%` matche tout, `_`
|
||||||
|
// matche n'importe quel caractere). La clause `ESCAPE '\\'` indique
|
||||||
|
// a PostgreSQL le caractere d'echappement utilise dans le motif.
|
||||||
|
$escaped = str_replace(['\\', '%', '_'], ['\\\\', '\%', '\_'], $filters['performed_by']);
|
||||||
|
$qb->andWhere("performed_by ILIKE :performed_by ESCAPE '\\'")
|
||||||
|
->setParameter('performed_by', '%'.$escaped.'%')
|
||||||
|
;
|
||||||
}
|
}
|
||||||
if (isset($filters['performed_at_after'])) {
|
if (isset($filters['performed_at_after'])) {
|
||||||
$qb->andWhere('performed_at >= :performed_at_after')->setParameter('performed_at_after', $filters['performed_at_after']);
|
$qb->andWhere('performed_at >= :performed_at_after')->setParameter('performed_at_after', $filters['performed_at_after']);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use Doctrine\ORM\Event\OnFlushEventArgs;
|
|||||||
use Doctrine\ORM\Event\PostFlushEventArgs;
|
use Doctrine\ORM\Event\PostFlushEventArgs;
|
||||||
use Doctrine\ORM\Events;
|
use Doctrine\ORM\Events;
|
||||||
use Doctrine\ORM\Mapping\ClassMetadata;
|
use Doctrine\ORM\Mapping\ClassMetadata;
|
||||||
|
use Doctrine\ORM\PersistentCollection;
|
||||||
use Doctrine\ORM\UnitOfWork;
|
use Doctrine\ORM\UnitOfWork;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use ReflectionClass;
|
use ReflectionClass;
|
||||||
@@ -42,10 +43,17 @@ use Throwable;
|
|||||||
* jamais propage. Acceptable pour un CRM interne ; a reconsiderer si besoin
|
* jamais propage. Acceptable pour un CRM interne ; a reconsiderer si besoin
|
||||||
* de garantie forte (dead-letter queue, retry).
|
* de garantie forte (dead-letter queue, retry).
|
||||||
*
|
*
|
||||||
|
* Collections (OneToMany / ManyToMany) :
|
||||||
|
* - Les modifications de collections sont tracees via
|
||||||
|
* `getScheduledCollectionUpdates()` et reportees comme un changement
|
||||||
|
* `{fieldName: {added: [ids], removed: [ids]}}` dans le changeset de
|
||||||
|
* l'entite proprietaire.
|
||||||
|
* - Si l'entite proprietaire est deja scheduled pour insertion, la diff
|
||||||
|
* est merge dans le snapshot create (en tant que liste d'IDs initiaux).
|
||||||
|
* - Si l'entite proprietaire est scheduled pour deletion, les collections
|
||||||
|
* associees sont ignorees (deja couvertes par le snapshot delete).
|
||||||
|
*
|
||||||
* Limitations connues :
|
* Limitations connues :
|
||||||
* - Les changements de collections ManyToMany ne sont pas tracees
|
|
||||||
* (`getEntityChangeSet()` ne les couvre pas). Extension future via
|
|
||||||
* `getScheduledCollectionUpdates()`.
|
|
||||||
* - Les ManyToOne sont tracees par ID (null-safe via `?->getId()`).
|
* - Les ManyToOne sont tracees par ID (null-safe via `?->getId()`).
|
||||||
* - Les DELETE / UPDATE bulk DQL et les `Connection::executeStatement()`
|
* - Les DELETE / UPDATE bulk DQL et les `Connection::executeStatement()`
|
||||||
* bruts BYPASSENT le listener : onFlush n'est jamais appele. Toute
|
* bruts BYPASSENT le listener : onFlush n'est jamais appele. Toute
|
||||||
@@ -105,6 +113,18 @@ final class AuditListener
|
|||||||
foreach ($uow->getScheduledEntityDeletions() as $entity) {
|
foreach ($uow->getScheduledEntityDeletions() as $entity) {
|
||||||
$this->capturePendingLog($entity, $em, $uow, 'delete');
|
$this->capturePendingLog($entity, $em, $uow, 'delete');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collections to-many (OneToMany / ManyToMany) : `getEntityChangeSet()`
|
||||||
|
// ne les expose pas, il faut interroger `UnitOfWork` separement. On
|
||||||
|
// merge la diff dans le log de l'entite proprietaire si elle est deja
|
||||||
|
// scheduled, sinon on cree une entree "update" dediee.
|
||||||
|
foreach ($uow->getScheduledCollectionUpdates() as $collection) {
|
||||||
|
$this->captureCollectionChange($collection, $em, cleared: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($uow->getScheduledCollectionDeletions() as $collection) {
|
||||||
|
$this->captureCollectionChange($collection, $em, cleared: true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function postFlush(PostFlushEventArgs $args): void
|
public function postFlush(PostFlushEventArgs $args): void
|
||||||
@@ -152,18 +172,29 @@ final class AuditListener
|
|||||||
|
|
||||||
private function capturePendingLog(object $entity, EntityManagerInterface $em, UnitOfWork $uow, string $action): void
|
private function capturePendingLog(object $entity, EntityManagerInterface $em, UnitOfWork $uow, string $action): void
|
||||||
{
|
{
|
||||||
$class = $entity::class;
|
// Resolution via ClassMetadata : `$entity::class` renvoie le FQCN du
|
||||||
|
// proxy Doctrine pour une entite chargee en lazy (ex:
|
||||||
|
// `Proxies\__CG__\App\Module\Core\Domain\Entity\User`) — `isAuditable()`
|
||||||
|
// le verrait comme non-auditable car `#[Auditable]` n'est declare que
|
||||||
|
// sur la classe parente.
|
||||||
|
$metadata = $em->getClassMetadata($entity::class);
|
||||||
|
$class = $metadata->getName();
|
||||||
|
|
||||||
if (!$this->isAuditable($class)) {
|
if (!$this->isAuditable($class)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$metadata = $em->getClassMetadata($class);
|
// Sur `delete`, on inclut aussi les collections to-many dans le
|
||||||
|
// snapshot : c'est la derniere occasion de capturer l'etat complet
|
||||||
|
// (ex: quelles permissions etaient rattachees au role supprime).
|
||||||
|
// Sur `create`, les collections initiales sont rapportees via
|
||||||
|
// captureCollectionChange quand l'entite est scheduled avec un
|
||||||
|
// collection update dans le meme flush.
|
||||||
$changes = match ($action) {
|
$changes = match ($action) {
|
||||||
'update' => $this->buildUpdateChanges($entity, $uow, $class),
|
'update' => $this->buildUpdateChanges($entity, $uow, $class),
|
||||||
'create', 'delete' => $this->buildSnapshot($entity, $metadata, $class),
|
'create' => $this->buildSnapshot($entity, $metadata, $class, includeCollections: false),
|
||||||
default => [],
|
'delete' => $this->buildSnapshot($entity, $metadata, $class, includeCollections: true),
|
||||||
|
default => [],
|
||||||
};
|
};
|
||||||
|
|
||||||
if ('update' === $action && [] === $changes) {
|
if ('update' === $action && [] === $changes) {
|
||||||
@@ -187,6 +218,115 @@ final class AuditListener
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture la modification d'une collection to-many.
|
||||||
|
*
|
||||||
|
* Strategie de merge :
|
||||||
|
* - Si l'entite proprietaire est deja scheduled pour `delete` → ignore
|
||||||
|
* (redondant avec le snapshot delete deja produit).
|
||||||
|
* - Si l'entite est deja scheduled pour `create` → on ajoute le champ
|
||||||
|
* collection au snapshot initial, sous forme de liste d'IDs ajoutes.
|
||||||
|
* - Si l'entite est deja scheduled pour `update` → on merge la diff
|
||||||
|
* {added, removed} dans le changeset existant.
|
||||||
|
* - Sinon → on cree une nouvelle entree `update` dediee pour l'entite
|
||||||
|
* proprietaire (cas d'une collection modifiee sans autre changement
|
||||||
|
* sur l'entite elle-meme, ex : ajout d'une permission a un role).
|
||||||
|
*
|
||||||
|
* @param bool $cleared true si la collection entiere est supprimee
|
||||||
|
* (getScheduledCollectionDeletions) — tous les
|
||||||
|
* items du snapshot sont consideres comme retires
|
||||||
|
*/
|
||||||
|
private function captureCollectionChange(PersistentCollection $collection, EntityManagerInterface $em, bool $cleared): void
|
||||||
|
{
|
||||||
|
$owner = $collection->getOwner();
|
||||||
|
if (null === $owner) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Voir capturePendingLog : meme contournement proxy Doctrine.
|
||||||
|
$class = $em->getClassMetadata($owner::class)->getName();
|
||||||
|
if (!$this->isAuditable($class)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fieldName = $collection->getMapping()->fieldName;
|
||||||
|
if (in_array($fieldName, $this->getIgnoredProperties($class), true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cleared) {
|
||||||
|
$added = [];
|
||||||
|
$removed = array_map(
|
||||||
|
fn ($item): mixed => $this->normalizeValue($item),
|
||||||
|
$collection->getSnapshot(),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$added = array_map(
|
||||||
|
fn ($item): mixed => $this->normalizeValue($item),
|
||||||
|
$collection->getInsertDiff(),
|
||||||
|
);
|
||||||
|
$removed = array_map(
|
||||||
|
fn ($item): mixed => $this->normalizeValue($item),
|
||||||
|
$collection->getDeleteDiff(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ([] === $added && [] === $removed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chercher un log deja en attente pour cette entite, pour merger la
|
||||||
|
// diff au lieu de creer une entree d'audit redondante.
|
||||||
|
foreach ($this->pendingLogs as $idx => $log) {
|
||||||
|
if ($log['entity'] !== $owner) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('delete' === $log['action']) {
|
||||||
|
// Deletion de l'entite : la collection suit mecaniquement,
|
||||||
|
// pas d'entree dediee (le snapshot delete contient deja
|
||||||
|
// l'etat a supprimer).
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('create' === $log['action']) {
|
||||||
|
// Insertion : le snapshot create ne contient pas les
|
||||||
|
// collections (buildSnapshot ignore les to-many). On ajoute
|
||||||
|
// donc la liste des items initiaux comme IDs, pour avoir
|
||||||
|
// une trace complete de l'etat a la creation. array_values
|
||||||
|
// garantit un array JSON (pas un objet) si les cles du diff
|
||||||
|
// ne sont pas sequentielles.
|
||||||
|
$this->pendingLogs[$idx]['changes'][$fieldName] = array_values($added);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update : on merge dans le changeset existant.
|
||||||
|
$this->pendingLogs[$idx]['changes'][$fieldName] = [
|
||||||
|
'added' => array_values($added),
|
||||||
|
'removed' => array_values($removed),
|
||||||
|
];
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aucun log existant : l'entite n'a eu QUE des changements de
|
||||||
|
// collection. On cree une entree update minimale.
|
||||||
|
$metadata = $em->getClassMetadata($class);
|
||||||
|
|
||||||
|
$this->pendingLogs[] = [
|
||||||
|
'entity' => $owner,
|
||||||
|
'metadata' => $metadata,
|
||||||
|
'entityType' => $this->formatEntityType($class),
|
||||||
|
'action' => 'update',
|
||||||
|
'changes' => [$fieldName => [
|
||||||
|
'added' => array_values($added),
|
||||||
|
'removed' => array_values($removed),
|
||||||
|
]],
|
||||||
|
'capturedId' => $this->resolveEntityId($owner, $metadata),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build du changeset "update" : {champ: {old, new}} a partir de
|
* Build du changeset "update" : {champ: {old, new}} a partir de
|
||||||
* `UnitOfWork::getEntityChangeSet()`. ManyToOne : on log l'ID,
|
* `UnitOfWork::getEntityChangeSet()`. ManyToOne : on log l'ID,
|
||||||
@@ -218,9 +358,18 @@ final class AuditListener
|
|||||||
* Build d'un snapshot complet (create / delete) : lit toutes les
|
* Build d'un snapshot complet (create / delete) : lit toutes les
|
||||||
* proprietes non-ignorees via Reflection.
|
* proprietes non-ignorees via Reflection.
|
||||||
*
|
*
|
||||||
|
* @param bool $includeCollections si true, les associations to-many sont
|
||||||
|
* aussi snapshotees (liste d'IDs). Utilise
|
||||||
|
* uniquement sur `delete` pour preserver
|
||||||
|
* l'etat des relations au moment de la
|
||||||
|
* suppression. En create, on laisse
|
||||||
|
* captureCollectionChange enrichir le
|
||||||
|
* snapshot si une collection est modifiee
|
||||||
|
* dans le meme flush.
|
||||||
|
*
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
*/
|
*/
|
||||||
private function buildSnapshot(object $entity, ClassMetadata $metadata, string $class): array
|
private function buildSnapshot(object $entity, ClassMetadata $metadata, string $class, bool $includeCollections): array
|
||||||
{
|
{
|
||||||
$ignored = $this->getIgnoredProperties($class);
|
$ignored = $this->getIgnoredProperties($class);
|
||||||
$snapshot = [];
|
$snapshot = [];
|
||||||
@@ -238,18 +387,32 @@ final class AuditListener
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$mapping = $metadata->getAssociationMapping($assoc);
|
if ($metadata->isSingleValuedAssociation($assoc)) {
|
||||||
// On ne snapshot que les references scalaires (to-one) ; les
|
$related = $metadata->getFieldValue($entity, $assoc);
|
||||||
// collections to-many sont volumineuses et souvent non utiles
|
$snapshot[$assoc] = null !== $related && method_exists($related, 'getId')
|
||||||
// a figer dans un audit (cf. limitation ManyToMany).
|
? $related->getId()
|
||||||
if (!$metadata->isSingleValuedAssociation($assoc)) {
|
: null;
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$related = $metadata->getFieldValue($entity, $assoc);
|
if (!$includeCollections) {
|
||||||
$snapshot[$assoc] = null !== $related && method_exists($related, 'getId')
|
continue;
|
||||||
? $related->getId()
|
}
|
||||||
: null;
|
|
||||||
|
// Collection to-many : snapshot = liste d'IDs. On itere la
|
||||||
|
// Collection (PersistentCollection ou ArrayCollection) pour
|
||||||
|
// obtenir les elements. Pour un delete, la collection est deja
|
||||||
|
// chargee (Doctrine en a besoin pour les cascades).
|
||||||
|
$collection = $metadata->getFieldValue($entity, $assoc);
|
||||||
|
if (!is_iterable($collection)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$ids = [];
|
||||||
|
foreach ($collection as $item) {
|
||||||
|
$ids[] = $this->normalizeValue($item);
|
||||||
|
}
|
||||||
|
$snapshot[$assoc] = $ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $snapshot;
|
return $snapshot;
|
||||||
|
|||||||
@@ -12,6 +12,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\Attribute\Auditable;
|
||||||
use App\Shared\Domain\Contract\SiteInterface;
|
use App\Shared\Domain\Contract\SiteInterface;
|
||||||
use DateTimeImmutable;
|
use DateTimeImmutable;
|
||||||
use Doctrine\Common\Collections\ArrayCollection;
|
use Doctrine\Common\Collections\ArrayCollection;
|
||||||
@@ -64,6 +65,7 @@ use Symfony\Component\Validator\Constraints as Assert;
|
|||||||
)]
|
)]
|
||||||
#[ORM\Entity(repositoryClass: DoctrineSiteRepository::class)]
|
#[ORM\Entity(repositoryClass: DoctrineSiteRepository::class)]
|
||||||
#[ORM\Table(name: 'site')]
|
#[ORM\Table(name: 'site')]
|
||||||
|
#[Auditable]
|
||||||
#[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.')]
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class DoctrineSiteRepository extends ServiceEntityRepository implements SiteRepo
|
|||||||
*/
|
*/
|
||||||
public function findAllOrderedByName(): array
|
public function findAllOrderedByName(): array
|
||||||
{
|
{
|
||||||
/** @var list<Site> $sites */
|
// @var list<Site> $sites
|
||||||
return $this->findBy([], ['name' => 'ASC']);
|
return $this->findBy([], ['name' => 'ASC']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,13 +95,6 @@ final class AuditLogApiTest extends AbstractApiTestCase
|
|||||||
self::assertArrayHasKey('totalItems', $data);
|
self::assertArrayHasKey('totalItems', $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Le frontend force `Accept: application/ld+json` dans `useAuditLog` pour
|
|
||||||
* recuperer les cles prefixees `hydra:*` (et `hydra:view` pour la
|
|
||||||
* pagination). Ce test verrouille ce contrat : sans lui, un changement
|
|
||||||
* de configuration API Platform cassant le JSON-LD passerait inaperçu
|
|
||||||
* et le tableau admin apparaitrait silencieusement vide en production.
|
|
||||||
*/
|
|
||||||
/**
|
/**
|
||||||
* Le frontend demande explicitement `application/ld+json` dans `useAuditLog`
|
* Le frontend demande explicitement `application/ld+json` dans `useAuditLog`
|
||||||
* pour obtenir l'objet Hydra complet (`member`, `totalItems`, `view`). Sous
|
* pour obtenir l'objet Hydra complet (`member`, `totalItems`, `view`). Sous
|
||||||
@@ -206,6 +199,139 @@ final class AuditLogApiTest extends AbstractApiTestCase
|
|||||||
self::assertContains($response->getStatusCode(), [404, 405], 'POST doit etre refuse (pas d\'operation d\'ecriture exposee)');
|
self::assertContains($response->getStatusCode(), [404, 405], 'POST doit etre refuse (pas d\'operation d\'ecriture exposee)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filtre multi-valeurs `entity_type[]=X&entity_type[]=Y` : l'union des
|
||||||
|
* deux types est retournee. On seed 2 types differents (core.User et
|
||||||
|
* core.Role) et on verifie que les deux apparaissent sous notre runTag,
|
||||||
|
* et qu'une valeur non existante (`core.Nonexistent`) n'ajoute rien.
|
||||||
|
*
|
||||||
|
* On interroge avec itemsPerPage=100 pour englober nos 5 lignes quel
|
||||||
|
* que soit le bruit de lignes preexistantes dans audit_log.
|
||||||
|
*/
|
||||||
|
public function testFilterByMultipleEntityTypes(): void
|
||||||
|
{
|
||||||
|
// Seed 2 lignes supplementaires avec un autre entity_type.
|
||||||
|
$this->seedExtraRow('core.Role', '1001', 'create');
|
||||||
|
$this->seedExtraRow('core.Role', '1002', 'update');
|
||||||
|
|
||||||
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
|
$response = $client->request('GET', '/api/audit-logs?'.http_build_query([
|
||||||
|
'entity_type' => ['core.User', 'core.Role', 'core.Nonexistent'],
|
||||||
|
'itemsPerPage' => 100,
|
||||||
|
]));
|
||||||
|
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
$data = $response->toArray();
|
||||||
|
|
||||||
|
// Filtre sur notre runTag pour isoler nos 5 lignes (3 User + 2 Role)
|
||||||
|
// independamment des entrees pre-existantes de la table.
|
||||||
|
$ours = array_values(array_filter(
|
||||||
|
$data['member'],
|
||||||
|
fn (array $m) => ($m['requestId'] ?? null) === $this->runTag,
|
||||||
|
));
|
||||||
|
self::assertCount(5, $ours, 'Les 3 lignes core.User + 2 lignes core.Role doivent etre retournees.');
|
||||||
|
|
||||||
|
$types = array_unique(array_map(fn (array $m) => $m['entityType'], $ours));
|
||||||
|
sort($types);
|
||||||
|
self::assertSame(['core.Role', 'core.User'], $types);
|
||||||
|
|
||||||
|
// Verifier qu'aucune ligne hors filtre n'apparait dans la reponse.
|
||||||
|
foreach ($data['member'] as $member) {
|
||||||
|
self::assertContains($member['entityType'], ['core.User', 'core.Role']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche partielle insensible a la casse sur `performed_by` via ILIKE.
|
||||||
|
* Le seed utilise `performed_by=admin` ; on cherche `ADM` pour tester
|
||||||
|
* a la fois la casse et le wildcard contains.
|
||||||
|
*/
|
||||||
|
public function testFilterByPerformedByPartialMatch(): void
|
||||||
|
{
|
||||||
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
|
$response = $client->request('GET', '/api/audit-logs?performed_by=ADM&entity_id=999');
|
||||||
|
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
$data = $response->toArray();
|
||||||
|
$ours = array_filter($data['member'], fn (array $m) => ($m['requestId'] ?? null) === $this->runTag);
|
||||||
|
self::assertGreaterThan(0, count($ours), 'La recherche ILIKE doit matcher "ADM" -> "admin".');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les caracteres wildcard PostgreSQL (`%`, `_`) saisis par l'utilisateur
|
||||||
|
* doivent etre echappes et traites comme caracteres litteraux, pas comme
|
||||||
|
* des metacaracteres LIKE. Idem pour le backslash qui doit etre double
|
||||||
|
* pour ne pas interferer avec la clause ESCAPE.
|
||||||
|
*/
|
||||||
|
public function testFilterByPerformedByEscapesWildcards(): void
|
||||||
|
{
|
||||||
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
|
|
||||||
|
// `%` seul doit matcher 0 ligne (personne n'a `%` dans performed_by).
|
||||||
|
$response = $client->request('GET', '/api/audit-logs?performed_by=%25&entity_id=999');
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
$data = $response->toArray();
|
||||||
|
$ours = array_filter($data['member'], fn (array $m) => ($m['requestId'] ?? null) === $this->runTag);
|
||||||
|
self::assertCount(0, $ours, '% doit etre traite comme literal, pas wildcard.');
|
||||||
|
|
||||||
|
// `_` seul (wildcard single-char en LIKE) doit aussi matcher 0 ligne.
|
||||||
|
$response = $client->request('GET', '/api/audit-logs?performed_by=_&entity_id=999');
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
$data = $response->toArray();
|
||||||
|
$ours = array_filter($data['member'], fn (array $m) => ($m['requestId'] ?? null) === $this->runTag);
|
||||||
|
self::assertCount(0, $ours, '_ doit etre traite comme literal, pas wildcard single-char.');
|
||||||
|
|
||||||
|
// `\` (backslash) dans le motif ne doit pas casser la clause ESCAPE :
|
||||||
|
// on attend une reponse 200 (pas 500), meme si le resultat est vide.
|
||||||
|
$response = $client->request('GET', '/api/audit-logs?performed_by=%5C&entity_id=999');
|
||||||
|
self::assertSame(200, $response->getStatusCode(), 'Un backslash dans le filtre ne doit pas produire de 500.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* L'endpoint `/api/audit-log-entity-types` retourne la liste des valeurs
|
||||||
|
* distinctes de `entity_type` presentes dans la table. La presence du
|
||||||
|
* seed runTag garantit au moins `core.User`.
|
||||||
|
*/
|
||||||
|
public function testEntityTypesEndpointReturnsDistinctTypes(): void
|
||||||
|
{
|
||||||
|
$client = $this->authenticatedClient('admin', 'admin');
|
||||||
|
$response = $client->request('GET', '/api/audit-log-entity-types');
|
||||||
|
|
||||||
|
self::assertSame(200, $response->getStatusCode());
|
||||||
|
$data = $response->toArray();
|
||||||
|
self::assertArrayHasKey('entityTypes', $data);
|
||||||
|
self::assertIsArray($data['entityTypes']);
|
||||||
|
self::assertContains('core.User', $data['entityTypes']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEntityTypesEndpointRequiresPermission(): void
|
||||||
|
{
|
||||||
|
$credentials = $this->createUserWithPermission('core.users.view');
|
||||||
|
$client = $this->authenticatedClient($credentials['username'], $credentials['password']);
|
||||||
|
$response = $client->request('GET', '/api/audit-log-entity-types');
|
||||||
|
|
||||||
|
self::assertSame(403, $response->getStatusCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper interne pour seeder une ligne additionnelle avec un entity_type
|
||||||
|
* arbitraire, taggee runTag pour nettoyage en tearDown.
|
||||||
|
*/
|
||||||
|
private function seedExtraRow(string $entityType, string $entityId, string $action): void
|
||||||
|
{
|
||||||
|
$this->auditConnection->insert('audit_log', [
|
||||||
|
'id' => Uuid::v7()->toRfc4122(),
|
||||||
|
'entity_type' => $entityType,
|
||||||
|
'entity_id' => $entityId,
|
||||||
|
'action' => $action,
|
||||||
|
'changes' => json_encode(['field' => ['old' => 1, 'new' => 2]], JSON_THROW_ON_ERROR),
|
||||||
|
'performed_by' => 'admin',
|
||||||
|
'performed_at' => new DateTimeImmutable('now', new DateTimeZone('UTC'))->format('Y-m-d H:i:sO'),
|
||||||
|
'ip_address' => null,
|
||||||
|
'request_id' => $this->runTag,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Insere 3 lignes temoins taggees avec le runTag pour un nettoyage sur.
|
* Insere 3 lignes temoins taggees avec le runTag pour un nettoyage sur.
|
||||||
*/
|
*/
|
||||||
@@ -220,7 +346,10 @@ final class AuditLogApiTest extends AbstractApiTestCase
|
|||||||
'action' => 'update',
|
'action' => 'update',
|
||||||
'changes' => ['isAdmin' => ['old' => false, 'new' => true]],
|
'changes' => ['isAdmin' => ['old' => false, 'new' => true]],
|
||||||
'performed_by' => 'admin',
|
'performed_by' => 'admin',
|
||||||
'performed_at' => $now->modify('-2 hours'),
|
// Offsets faibles (secondes) : garantit que les 3 lignes
|
||||||
|
// restent parmi les plus recentes de audit_log meme quand la
|
||||||
|
// table contient plusieurs centaines de lignes historiques.
|
||||||
|
'performed_at' => $now->modify('-2 seconds'),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'entity_type' => 'core.User',
|
'entity_type' => 'core.User',
|
||||||
@@ -228,7 +357,7 @@ final class AuditLogApiTest extends AbstractApiTestCase
|
|||||||
'action' => 'update',
|
'action' => 'update',
|
||||||
'changes' => ['username' => ['old' => 'x', 'new' => 'y']],
|
'changes' => ['username' => ['old' => 'x', 'new' => 'y']],
|
||||||
'performed_by' => 'admin',
|
'performed_by' => 'admin',
|
||||||
'performed_at' => $now->modify('-1 hour'),
|
'performed_at' => $now->modify('-1 second'),
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'entity_type' => 'core.User',
|
'entity_type' => 'core.User',
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Tests\Module\Core\Infrastructure\Doctrine;
|
namespace App\Tests\Module\Core\Infrastructure\Doctrine;
|
||||||
|
|
||||||
|
use App\Module\Core\Domain\Entity\Permission;
|
||||||
|
use App\Module\Core\Domain\Entity\Role;
|
||||||
use App\Module\Core\Domain\Entity\User;
|
use App\Module\Core\Domain\Entity\User;
|
||||||
use Doctrine\DBAL\Connection;
|
use Doctrine\DBAL\Connection;
|
||||||
use Doctrine\ORM\EntityManagerInterface;
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
@@ -32,6 +34,9 @@ final class AuditListenerTest extends KernelTestCase
|
|||||||
/** @var list<int> IDs de users crees par le test (nettoyage en tearDown) */
|
/** @var list<int> IDs de users crees par le test (nettoyage en tearDown) */
|
||||||
private array $createdUserIds = [];
|
private array $createdUserIds = [];
|
||||||
|
|
||||||
|
/** @var list<int> IDs de roles crees par le test (nettoyage en tearDown) */
|
||||||
|
private array $createdRoleIds = [];
|
||||||
|
|
||||||
private string $testRunTag;
|
private string $testRunTag;
|
||||||
|
|
||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
@@ -66,6 +71,24 @@ final class AuditListenerTest extends KernelTestCase
|
|||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ([] !== $this->createdRoleIds) {
|
||||||
|
foreach ($this->createdRoleIds as $id) {
|
||||||
|
$role = $this->em->find(Role::class, $id);
|
||||||
|
if (null !== $role) {
|
||||||
|
$this->em->remove($role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->em->flush();
|
||||||
|
// Nettoie egalement les lignes audit de ces roles (entity_id est
|
||||||
|
// une colonne text, on delete en boucle pour simplifier le binding).
|
||||||
|
foreach ($this->createdRoleIds as $id) {
|
||||||
|
$this->auditConnection->executeStatement(
|
||||||
|
'DELETE FROM audit_log WHERE entity_type = \'core.Role\' AND entity_id = :id',
|
||||||
|
['id' => (string) $id],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$this->auditConnection->executeStatement(
|
$this->auditConnection->executeStatement(
|
||||||
"DELETE FROM audit_log WHERE entity_type = 'core.User' AND changes->>'username' LIKE :tag",
|
"DELETE FROM audit_log WHERE entity_type = 'core.User' AND changes->>'username' LIKE :tag",
|
||||||
['tag' => $this->testRunTag.'%'],
|
['tag' => $this->testRunTag.'%'],
|
||||||
@@ -154,6 +177,157 @@ final class AuditListenerTest extends KernelTestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression test : une entite recuperee via `getReference()` (proxy /
|
||||||
|
* ghost object lazy) doit etre auditee avec le FQCN canonique. Sur
|
||||||
|
* Doctrine ORM 3 + PHP 8.4, les lazy ghosts preservent `::class` reel
|
||||||
|
* — mais sous Doctrine 2 ou en cas de retour a un `__CG__\` proxy,
|
||||||
|
* l'audit doit toujours resoudre la classe via `ClassMetadata` et
|
||||||
|
* jamais aboutir a un `entity_type` de type `Proxies\__CG__\...\User`.
|
||||||
|
*/
|
||||||
|
public function testLogsUpdateOnProxyEntity(): void
|
||||||
|
{
|
||||||
|
$user = $this->makeUser();
|
||||||
|
$this->em->persist($user);
|
||||||
|
$this->em->flush();
|
||||||
|
$userId = (int) $user->getId();
|
||||||
|
$this->createdUserIds[] = $userId;
|
||||||
|
|
||||||
|
// Detache puis recupere via getReference : sur Doctrine 2, renvoie
|
||||||
|
// un `Proxies\__CG__\...\User` ; sur Doctrine 3 + PHP 8.4 le ghost
|
||||||
|
// object reste instance de la classe reelle — dans tous les cas la
|
||||||
|
// resolution via ClassMetadata doit produire un audit correct.
|
||||||
|
$this->em->clear();
|
||||||
|
|
||||||
|
$proxy = $this->em->getReference(User::class, $userId);
|
||||||
|
self::assertNotNull($proxy);
|
||||||
|
|
||||||
|
// Reset de la baseline : on ne garde que la ligne update du proxy.
|
||||||
|
$this->auditConnection->executeStatement(
|
||||||
|
'DELETE FROM audit_log WHERE entity_id = :id AND entity_type = \'core.User\'',
|
||||||
|
['id' => (string) $userId],
|
||||||
|
);
|
||||||
|
|
||||||
|
$proxy->setIsAdmin(true);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$rows = $this->fetchAuditRows($userId);
|
||||||
|
self::assertCount(1, $rows, 'La mutation sur un proxy doit etre auditee.');
|
||||||
|
self::assertSame('update', $rows[0]['action']);
|
||||||
|
// L'entity_type doit etre le FQCN canonique, pas celui du proxy.
|
||||||
|
self::assertSame('core.User', $rows[0]['entity_type']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifie que l'ajout d'une permission a un role est bien audite sous
|
||||||
|
* la forme `{permissions: {added: [id], removed: []}}`. Regression test
|
||||||
|
* pour le bug "ManyToMany collections ignorees par getEntityChangeSet".
|
||||||
|
*/
|
||||||
|
public function testLogsManyToManyCollectionAddition(): void
|
||||||
|
{
|
||||||
|
$roleCode = 'audittest_'.bin2hex(random_bytes(3));
|
||||||
|
$role = new Role($roleCode, 'Test role '.$roleCode);
|
||||||
|
$this->em->persist($role);
|
||||||
|
$this->em->flush();
|
||||||
|
$roleId = (int) $role->getId();
|
||||||
|
$this->createdRoleIds[] = $roleId;
|
||||||
|
|
||||||
|
// Reset baseline : on ne veut que le log de l'update de collection.
|
||||||
|
$this->auditConnection->executeStatement(
|
||||||
|
'DELETE FROM audit_log WHERE entity_type = \'core.Role\' AND entity_id = :id',
|
||||||
|
['id' => (string) $roleId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Recupere une permission existante (fixtures garantissent core.users.view).
|
||||||
|
$permission = $this->em->getRepository(Permission::class)->findOneBy(['code' => 'core.users.view']);
|
||||||
|
self::assertNotNull($permission, 'Fixture core.users.view manquante.');
|
||||||
|
|
||||||
|
$role->addPermission($permission);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$rows = $this->fetchRoleAuditRows($roleId);
|
||||||
|
self::assertCount(1, $rows, 'Une ligne update attendue pour l\'ajout de permission.');
|
||||||
|
self::assertSame('update', $rows[0]['action']);
|
||||||
|
|
||||||
|
$changes = json_decode($rows[0]['changes'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
self::assertArrayHasKey('permissions', $changes, 'Le changeset doit contenir le champ "permissions".');
|
||||||
|
self::assertSame([], $changes['permissions']['removed']);
|
||||||
|
self::assertSame([(int) $permission->getId()], $changes['permissions']['added']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Symetrique : retirer une permission d'un role est audite sous
|
||||||
|
* `{permissions: {added: [], removed: [id]}}`.
|
||||||
|
*/
|
||||||
|
public function testLogsManyToManyCollectionRemoval(): void
|
||||||
|
{
|
||||||
|
$permission = $this->em->getRepository(Permission::class)->findOneBy(['code' => 'core.users.view']);
|
||||||
|
self::assertNotNull($permission);
|
||||||
|
|
||||||
|
$roleCode = 'audittest_'.bin2hex(random_bytes(3));
|
||||||
|
$role = new Role($roleCode, 'Test role '.$roleCode);
|
||||||
|
$role->addPermission($permission);
|
||||||
|
$this->em->persist($role);
|
||||||
|
$this->em->flush();
|
||||||
|
$roleId = (int) $role->getId();
|
||||||
|
$this->createdRoleIds[] = $roleId;
|
||||||
|
|
||||||
|
// Reset baseline.
|
||||||
|
$this->auditConnection->executeStatement(
|
||||||
|
'DELETE FROM audit_log WHERE entity_type = \'core.Role\' AND entity_id = :id',
|
||||||
|
['id' => (string) $roleId],
|
||||||
|
);
|
||||||
|
|
||||||
|
$role->removePermission($permission);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$rows = $this->fetchRoleAuditRows($roleId);
|
||||||
|
self::assertCount(1, $rows);
|
||||||
|
$changes = json_decode($rows[0]['changes'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
self::assertSame([], $changes['permissions']['added']);
|
||||||
|
self::assertSame([(int) $permission->getId()], $changes['permissions']['removed']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression test : supprimer un role avec des permissions attachees doit
|
||||||
|
* preserver la liste des permissions dans le snapshot delete. C'etait le
|
||||||
|
* trou principal du fix ManyToMany initial (reviewer Codex round 2).
|
||||||
|
*/
|
||||||
|
public function testDeleteSnapshotIncludesManyToManyIds(): void
|
||||||
|
{
|
||||||
|
$permission = $this->em->getRepository(Permission::class)->findOneBy(['code' => 'core.users.view']);
|
||||||
|
self::assertNotNull($permission);
|
||||||
|
|
||||||
|
$roleCode = 'audittest_'.bin2hex(random_bytes(3));
|
||||||
|
$role = new Role($roleCode, 'Delete test '.$roleCode);
|
||||||
|
$role->addPermission($permission);
|
||||||
|
$this->em->persist($role);
|
||||||
|
$this->em->flush();
|
||||||
|
$roleId = (int) $role->getId();
|
||||||
|
|
||||||
|
$this->em->remove($role);
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
$rows = $this->fetchRoleAuditRows($roleId);
|
||||||
|
// create + update (permission ajoutee) + delete attendus.
|
||||||
|
$actions = array_column($rows, 'action');
|
||||||
|
self::assertContains('delete', $actions);
|
||||||
|
|
||||||
|
$deleteRow = $rows[array_search('delete', $actions, true)];
|
||||||
|
$changes = json_decode($deleteRow['changes'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
// Le snapshot delete doit contenir la liste des IDs de permissions
|
||||||
|
// attachees au role au moment de la suppression.
|
||||||
|
self::assertArrayHasKey('permissions', $changes);
|
||||||
|
self::assertSame([(int) $permission->getId()], $changes['permissions']);
|
||||||
|
|
||||||
|
// Nettoyage manuel (le role est deja supprime, on ne peut plus passer par $this->em).
|
||||||
|
$this->auditConnection->executeStatement(
|
||||||
|
'DELETE FROM audit_log WHERE entity_type = \'core.Role\' AND entity_id = :id',
|
||||||
|
['id' => (string) $roleId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return list<array{id: string, entity_type: string, entity_id: string, action: string, changes: string}>
|
* @return list<array{id: string, entity_type: string, entity_id: string, action: string, changes: string}>
|
||||||
*/
|
*/
|
||||||
@@ -178,4 +352,16 @@ final class AuditListenerTest extends KernelTestCase
|
|||||||
|
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{id: string, entity_type: string, entity_id: string, action: string, changes: string}>
|
||||||
|
*/
|
||||||
|
private function fetchRoleAuditRows(int $roleId): array
|
||||||
|
{
|
||||||
|
// @var list<array{id: string, entity_type: string, entity_id: string, action: string, changes: string}> $rows
|
||||||
|
return $this->auditConnection->fetchAllAssociative(
|
||||||
|
'SELECT id, entity_type, entity_id, action, changes FROM audit_log WHERE entity_type = :type AND entity_id = :id ORDER BY performed_at ASC',
|
||||||
|
['type' => 'core.Role', 'id' => (string) $roleId],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user