UDT-010: Infraestructura de Auditoría y Trazabilidad — Closes #6 #14
71
src/web/src/api/audit.ts
Normal file
71
src/web/src/api/audit.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { axiosClient } from '@/api/axiosClient'
|
||||
|
||||
/**
|
||||
* Auditoría — UDT-010 Batch 12
|
||||
*
|
||||
* Cliente Axios para GET /api/v1/audit/events.
|
||||
* Endpoint cursor-paginated (NO offset). Permiso requerido: administracion:auditoria:ver.
|
||||
*/
|
||||
|
||||
/** Shape del evento devuelto por el backend (B5/B6). */
|
||||
export interface AuditEventDto {
|
||||
id: number
|
||||
/** ISO datetime (UTC) */
|
||||
occurredAt: string
|
||||
actorUserId: number | null
|
||||
actorUsername: string | null
|
||||
action: string
|
||||
targetType: string
|
||||
targetId: string
|
||||
correlationId: string | null
|
||||
ipAddress: string | null
|
||||
/** JSON string serializado (opcional) */
|
||||
metadata: string | null
|
||||
}
|
||||
|
||||
/** Respuesta cursor-paginated: items + cursor opaco para la siguiente página. */
|
||||
export interface AuditEventsPage {
|
||||
items: AuditEventDto[]
|
||||
nextCursor: string | null
|
||||
}
|
||||
|
||||
/** Filtro de consulta. Todos opcionales; `cursor` pagina; `limit` ≤ 100. */
|
||||
export interface AuditEventsFilter {
|
||||
actor?: number
|
||||
targetType?: string
|
||||
targetId?: string
|
||||
/** ISO datetime inclusive desde */
|
||||
from?: string
|
||||
/** ISO datetime inclusive hasta */
|
||||
to?: string
|
||||
cursor?: string
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Llama GET /api/v1/audit/events con los filtros dados.
|
||||
* Omite params undefined/empty del querystring.
|
||||
*/
|
||||
export async function listAuditEvents(
|
||||
filter: AuditEventsFilter = {},
|
||||
): Promise<AuditEventsPage> {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (filter.actor !== undefined) params.set('actor', String(filter.actor))
|
||||
if (filter.targetType !== undefined && filter.targetType !== '')
|
||||
params.set('targetType', filter.targetType)
|
||||
if (filter.targetId !== undefined && filter.targetId !== '')
|
||||
params.set('targetId', filter.targetId)
|
||||
if (filter.from !== undefined && filter.from !== '')
|
||||
params.set('from', filter.from)
|
||||
if (filter.to !== undefined && filter.to !== '') params.set('to', filter.to)
|
||||
if (filter.cursor !== undefined && filter.cursor !== '')
|
||||
params.set('cursor', filter.cursor)
|
||||
if (filter.limit !== undefined) params.set('limit', String(filter.limit))
|
||||
|
||||
const response = await axiosClient.get<AuditEventsPage>(
|
||||
'/api/v1/audit/events',
|
||||
{ params },
|
||||
)
|
||||
return response.data
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Users,
|
||||
ShieldCheck,
|
||||
KeyRound,
|
||||
FileClock,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
} from 'lucide-react'
|
||||
@@ -23,6 +24,8 @@ interface NavItem {
|
||||
href: string
|
||||
icon: React.ElementType
|
||||
disabled?: boolean
|
||||
/** Si se define, el item solo se muestra si el user tiene este permiso. */
|
||||
requiredPermission?: string
|
||||
}
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
@@ -38,6 +41,12 @@ const adminItems: NavItem[] = [
|
||||
{ label: 'Crear Usuario', href: '/usuarios/nuevo', icon: UserPlus },
|
||||
{ label: 'Roles', href: '/admin/roles', icon: ShieldCheck },
|
||||
{ label: 'Permisos', href: '/admin/permisos', icon: KeyRound },
|
||||
{
|
||||
label: 'Auditoría',
|
||||
href: '/admin/audit',
|
||||
icon: FileClock,
|
||||
requiredPermission: 'administracion:auditoria:ver',
|
||||
},
|
||||
]
|
||||
|
||||
interface SidebarNavProps {
|
||||
@@ -120,14 +129,20 @@ export function SidebarNav({ forceExpanded = false }: SidebarNavProps) {
|
||||
{isAdmin && (
|
||||
<>
|
||||
<SectionLabel collapsed={collapsed}>Administración</SectionLabel>
|
||||
{adminItems.map((item) => (
|
||||
<NavRow
|
||||
key={item.href}
|
||||
item={item}
|
||||
collapsed={collapsed}
|
||||
active={isItemActive(item)}
|
||||
/>
|
||||
))}
|
||||
{adminItems
|
||||
.filter(
|
||||
(item) =>
|
||||
!item.requiredPermission ||
|
||||
user?.permisos.includes(item.requiredPermission),
|
||||
)
|
||||
.map((item) => (
|
||||
<NavRow
|
||||
key={item.href}
|
||||
item={item}
|
||||
collapsed={collapsed}
|
||||
active={isItemActive(item)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
25
src/web/src/features/admin/audit/useAuditEvents.ts
Normal file
25
src/web/src/features/admin/audit/useAuditEvents.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
listAuditEvents,
|
||||
type AuditEventsFilter,
|
||||
type AuditEventsPage,
|
||||
} from '@/api/audit'
|
||||
|
||||
export const auditEventsQueryKey = (filter: AuditEventsFilter) =>
|
||||
['audit', 'events', filter] as const
|
||||
|
||||
/**
|
||||
* Hook TanStack Query para `listAuditEvents`.
|
||||
*
|
||||
* Cursor pagination: el caller pasa `cursor` (o `undefined` para la primera
|
||||
* página) dentro de `filter`. Cada "página" es una query independiente —
|
||||
* NO usamos `useInfiniteQuery` porque el UI usa un botón "Cargar más" que
|
||||
* simplemente re-consulta con el último `nextCursor`.
|
||||
*/
|
||||
export function useAuditEvents(filter: AuditEventsFilter) {
|
||||
return useQuery<AuditEventsPage>({
|
||||
queryKey: auditEventsQueryKey(filter),
|
||||
queryFn: () => listAuditEvents(filter),
|
||||
staleTime: 15_000,
|
||||
})
|
||||
}
|
||||
160
src/web/src/pages/admin/audit/AuditFilters.tsx
Normal file
160
src/web/src/pages/admin/audit/AuditFilters.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
/** Filtros crudos del form (todos strings para binding directo del input). */
|
||||
export interface AuditFiltersValue {
|
||||
actor: string
|
||||
targetType: string
|
||||
targetId: string
|
||||
from: string
|
||||
to: string
|
||||
}
|
||||
|
||||
export const EMPTY_FILTERS: AuditFiltersValue = {
|
||||
actor: '',
|
||||
targetType: '',
|
||||
targetId: '',
|
||||
from: '',
|
||||
to: '',
|
||||
}
|
||||
|
||||
interface AuditFiltersProps {
|
||||
/** Valor inicial (útil cuando el padre mantiene el estado). */
|
||||
initialValue?: AuditFiltersValue
|
||||
onApply: (value: AuditFiltersValue) => void
|
||||
onReset: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 4 filtros + 2 fechas (from/to). Submit explícito vía botón "Aplicar".
|
||||
*
|
||||
* NO hace debounce — el usuario aprieta "Aplicar" o "Limpiar".
|
||||
* Eso evita pedidos intermedios cuando se escribe un GUID largo en targetId.
|
||||
*/
|
||||
export function AuditFilters({
|
||||
initialValue = EMPTY_FILTERS,
|
||||
onApply,
|
||||
onReset,
|
||||
}: AuditFiltersProps) {
|
||||
const [value, setValue] = useState<AuditFiltersValue>(initialValue)
|
||||
|
||||
function handleSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
onApply(value)
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
setValue(EMPTY_FILTERS)
|
||||
onReset()
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
className="surface p-4 space-y-4"
|
||||
aria-label="Filtros de auditoría"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="audit-actor">Usuario (ID)</Label>
|
||||
<Input
|
||||
id="audit-actor"
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
value={value.actor}
|
||||
onChange={(e) => setValue((v) => ({ ...v, actor: e.target.value }))}
|
||||
placeholder="Ej: 42"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="audit-target-type">Tipo de entidad</Label>
|
||||
<Input
|
||||
id="audit-target-type"
|
||||
type="text"
|
||||
value={value.targetType}
|
||||
onChange={(e) =>
|
||||
setValue((v) => ({ ...v, targetType: e.target.value }))
|
||||
}
|
||||
placeholder="Ej: User, Role, Permission"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="audit-target-id">ID de entidad</Label>
|
||||
<Input
|
||||
id="audit-target-id"
|
||||
type="text"
|
||||
value={value.targetId}
|
||||
onChange={(e) =>
|
||||
setValue((v) => ({ ...v, targetId: e.target.value }))
|
||||
}
|
||||
placeholder="Ej: 123 o un GUID"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="audit-from">Desde</Label>
|
||||
<Input
|
||||
id="audit-from"
|
||||
type="datetime-local"
|
||||
value={value.from}
|
||||
onChange={(e) => setValue((v) => ({ ...v, from: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="audit-to">Hasta</Label>
|
||||
<Input
|
||||
id="audit-to"
|
||||
type="datetime-local"
|
||||
value={value.to}
|
||||
onChange={(e) => setValue((v) => ({ ...v, to: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<Button type="button" variant="ghost" onClick={handleReset}>
|
||||
Limpiar
|
||||
</Button>
|
||||
<Button type="submit">Aplicar filtros</Button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Convierte los valores del form (strings) al shape `AuditEventsFilter`
|
||||
* que espera el cliente de API.
|
||||
*
|
||||
* - `actor` vacío o NaN → omitido
|
||||
* - `from`/`to` vienen del `datetime-local` (local time, sin timezone).
|
||||
* Los convertimos a ISO UTC vía `new Date(...).toISOString()`.
|
||||
* - Strings vacíos → omitidos.
|
||||
*/
|
||||
export function toApiFilter(
|
||||
value: AuditFiltersValue,
|
||||
): import('@/api/audit').AuditEventsFilter {
|
||||
const out: import('@/api/audit').AuditEventsFilter = {}
|
||||
|
||||
if (value.actor.trim() !== '') {
|
||||
const n = Number(value.actor)
|
||||
if (Number.isFinite(n) && n > 0) out.actor = Math.floor(n)
|
||||
}
|
||||
if (value.targetType.trim() !== '') out.targetType = value.targetType.trim()
|
||||
if (value.targetId.trim() !== '') out.targetId = value.targetId.trim()
|
||||
if (value.from.trim() !== '') {
|
||||
const d = new Date(value.from)
|
||||
if (!Number.isNaN(d.getTime())) out.from = d.toISOString()
|
||||
}
|
||||
if (value.to.trim() !== '') {
|
||||
const d = new Date(value.to)
|
||||
if (!Number.isNaN(d.getTime())) out.to = d.toISOString()
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
285
src/web/src/pages/admin/audit/AuditPage.tsx
Normal file
285
src/web/src/pages/admin/audit/AuditPage.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import { useEffect, useMemo, useState, useCallback } from 'react'
|
||||
import { Copy } from 'lucide-react'
|
||||
import type { ColumnDef } from '@tanstack/react-table'
|
||||
import { toast } from 'sonner'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { DataTable } from '@/components/ui/data-table'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import type { AuditEventDto, AuditEventsFilter } from '@/api/audit'
|
||||
import { useAuditEvents } from '@/features/admin/audit/useAuditEvents'
|
||||
import {
|
||||
AuditFilters,
|
||||
EMPTY_FILTERS,
|
||||
toApiFilter,
|
||||
type AuditFiltersValue,
|
||||
} from './AuditFilters'
|
||||
|
||||
/** Formatea un ISO datetime a hora local AR (dd/mm/yyyy HH:mm:ss). */
|
||||
function formatOccurredAt(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
if (Number.isNaN(d.getTime())) return iso
|
||||
return d.toLocaleString('es-AR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
/** Copia texto al clipboard con fallback + toast. */
|
||||
async function copyToClipboard(text: string, label: string): Promise<void> {
|
||||
try {
|
||||
if (
|
||||
typeof navigator !== 'undefined' &&
|
||||
navigator.clipboard &&
|
||||
typeof navigator.clipboard.writeText === 'function'
|
||||
) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
} else {
|
||||
// Fallback jsdom / navegadores viejos
|
||||
const ta = document.createElement('textarea')
|
||||
ta.value = text
|
||||
ta.setAttribute('readonly', '')
|
||||
ta.style.position = 'absolute'
|
||||
ta.style.left = '-9999px'
|
||||
document.body.appendChild(ta)
|
||||
ta.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(ta)
|
||||
}
|
||||
toast.success(`${label} copiado al portapapeles`)
|
||||
} catch {
|
||||
toast.error(`No se pudo copiar ${label.toLowerCase()}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function AuditPage() {
|
||||
// Filtros confirmados (los que se mandan a la API). Se actualizan al Aplicar.
|
||||
const [filters, setFilters] = useState<AuditFiltersValue>(EMPTY_FILTERS)
|
||||
// Cursor actual (undefined = primera página).
|
||||
const [cursor, setCursor] = useState<string | undefined>(undefined)
|
||||
// Acumulador de items entre páginas ("cargar más").
|
||||
const [accumulated, setAccumulated] = useState<AuditEventDto[]>([])
|
||||
|
||||
const apiFilter = useMemo<AuditEventsFilter>(() => {
|
||||
const f = toApiFilter(filters)
|
||||
return cursor ? { ...f, cursor } : f
|
||||
}, [filters, cursor])
|
||||
|
||||
const { data, isLoading, isFetching, isError } = useAuditEvents(apiFilter)
|
||||
|
||||
// Acumular items cuando llegan. Si es la primera página (cursor=undefined)
|
||||
// reseteamos; si hay cursor, appendeamos.
|
||||
useEffect(() => {
|
||||
if (!data) return
|
||||
if (cursor === undefined) {
|
||||
setAccumulated(data.items)
|
||||
} else {
|
||||
setAccumulated((prev) => {
|
||||
// Evitar dobles appends en StrictMode: si el último batch ya incluye
|
||||
// este primer id, asumimos que ya fue appendeado.
|
||||
const firstNew = data.items[0]
|
||||
if (
|
||||
firstNew &&
|
||||
prev.length > 0 &&
|
||||
prev[prev.length - 1]?.id === firstNew.id
|
||||
) {
|
||||
return prev
|
||||
}
|
||||
return [...prev, ...data.items]
|
||||
})
|
||||
}
|
||||
}, [data, cursor])
|
||||
|
||||
const handleApply = useCallback((value: AuditFiltersValue) => {
|
||||
setFilters(value)
|
||||
setCursor(undefined)
|
||||
setAccumulated([])
|
||||
}, [])
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
setFilters(EMPTY_FILTERS)
|
||||
setCursor(undefined)
|
||||
setAccumulated([])
|
||||
}, [])
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (data?.nextCursor) {
|
||||
setCursor(data.nextCursor)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
const columns = useMemo<ColumnDef<AuditEventDto>[]>(
|
||||
() => [
|
||||
{
|
||||
accessorKey: 'occurredAt',
|
||||
header: 'Fecha',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-foreground">
|
||||
{formatOccurredAt(row.original.occurredAt)}
|
||||
</span>
|
||||
),
|
||||
meta: { priority: 'high' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'actorUsername',
|
||||
header: 'Usuario',
|
||||
cell: ({ row }) => {
|
||||
const u = row.original.actorUsername
|
||||
if (!u) {
|
||||
return (
|
||||
<span className="text-muted-foreground italic">
|
||||
sistema
|
||||
</span>
|
||||
)
|
||||
}
|
||||
return <span className="font-mono text-xs">{u}</span>
|
||||
},
|
||||
meta: { priority: 'high' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'action',
|
||||
header: 'Acción',
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="secondary" className="font-mono text-[11px]">
|
||||
{row.original.action}
|
||||
</Badge>
|
||||
),
|
||||
meta: { priority: 'high' },
|
||||
},
|
||||
{
|
||||
id: 'target',
|
||||
header: 'Entidad',
|
||||
cell: ({ row }) => (
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-medium text-foreground">
|
||||
{row.original.targetType}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-muted-foreground break-all">
|
||||
{row.original.targetId}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
meta: { priority: 'medium' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'ipAddress',
|
||||
header: 'IP',
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
{row.original.ipAddress ?? '—'}
|
||||
</span>
|
||||
),
|
||||
meta: { priority: 'low' },
|
||||
},
|
||||
{
|
||||
accessorKey: 'correlationId',
|
||||
header: 'Correlation',
|
||||
cell: ({ row }) => {
|
||||
const cid = row.original.correlationId
|
||||
if (!cid) return <span className="text-muted-foreground">—</span>
|
||||
const short = cid.length > 10 ? `${cid.slice(0, 8)}…` : cid
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span
|
||||
className="font-mono text-[11px] text-muted-foreground"
|
||||
title={cid}
|
||||
>
|
||||
{short}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void copyToClipboard(cid, 'Correlation ID')
|
||||
}}
|
||||
aria-label="Copiar correlation ID"
|
||||
className="inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copiar correlation ID</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: { priority: 'low' },
|
||||
},
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
const hasMore = Boolean(data?.nextCursor)
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-xl font-semibold text-foreground">
|
||||
Auditoría
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Historial de eventos del sistema. Resultados paginados por cursor.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AuditFilters
|
||||
initialValue={filters}
|
||||
onApply={handleApply}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
|
||||
{isError ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="surface p-4 text-sm text-destructive"
|
||||
>
|
||||
No se pudieron cargar los eventos de auditoría. Intentá de nuevo.
|
||||
</div>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={accumulated}
|
||||
getRowId={(row) => String(row.id)}
|
||||
isLoading={isLoading && accumulated.length === 0}
|
||||
emptyMessage="Sin resultados — no se encontraron eventos con los filtros seleccionados."
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{accumulated.length > 0
|
||||
? `${accumulated.length} evento${accumulated.length !== 1 ? 's' : ''} cargado${accumulated.length !== 1 ? 's' : ''}`
|
||||
: ''}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasMore || isFetching}
|
||||
onClick={handleLoadMore}
|
||||
aria-label="Cargar más eventos"
|
||||
>
|
||||
{isFetching && cursor !== undefined ? 'Cargando…' : 'Cargar más'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
TODOs para ADM-004:
|
||||
- Drill-down del evento (modal con metadata JSON formatted)
|
||||
- Export CSV de los resultados filtrados
|
||||
- Timeline visualization por entidad
|
||||
*/}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { RolesPage } from './features/roles/pages/RolesPage'
|
||||
import { NewRolPage } from './features/roles/pages/NewRolPage'
|
||||
import { EditRolPage } from './features/roles/pages/EditRolPage'
|
||||
import { RolPermisosPage } from './features/permisos/pages/RolPermisosPage'
|
||||
import { AuditPage } from './pages/admin/audit/AuditPage'
|
||||
import { HomePage } from './pages/HomePage'
|
||||
import { PublicLayout } from './layouts/PublicLayout'
|
||||
import { ProtectedLayout } from './layouts/ProtectedLayout'
|
||||
@@ -154,6 +155,15 @@ export function AppRoutes() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/admin/audit"
|
||||
element={
|
||||
<ProtectedPage requiredPermissions={['administracion:auditoria:ver']}>
|
||||
<AuditPage />
|
||||
</ProtectedPage>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
)
|
||||
|
||||
254
src/web/src/tests/features/admin/audit/AuditPage.test.tsx
Normal file
254
src/web/src/tests/features/admin/audit/AuditPage.test.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
beforeAll,
|
||||
afterAll,
|
||||
afterEach,
|
||||
vi,
|
||||
} from 'vitest'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import { TooltipProvider } from '../../../../components/ui/tooltip'
|
||||
import { AuditPage } from '../../../../pages/admin/audit/AuditPage'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
// Sonner toast is mocked so we can assert it was called without rendering.
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
function makeEvent(id: number, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id,
|
||||
occurredAt: `2026-04-${String(10 + (id % 20)).padStart(2, '0')}T10:00:00Z`,
|
||||
actorUserId: 1,
|
||||
actorUsername: `user${id}`,
|
||||
action: 'user.created',
|
||||
targetType: 'User',
|
||||
targetId: String(100 + id),
|
||||
correlationId: `corr-${id}`,
|
||||
ipAddress: '10.0.0.1',
|
||||
metadata: null,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => {
|
||||
server.resetHandlers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
afterAll(() => server.close())
|
||||
|
||||
function renderPage() {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<TooltipProvider>
|
||||
<MemoryRouter initialEntries={['/admin/audit']}>
|
||||
<AuditPage />
|
||||
</MemoryRouter>
|
||||
</TooltipProvider>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('AuditPage', () => {
|
||||
it('renders the table with rows returned by the API', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/audit/events`, () =>
|
||||
HttpResponse.json({
|
||||
items: [makeEvent(1), makeEvent(2), makeEvent(3)],
|
||||
nextCursor: null,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('user1')).toBeInTheDocument(),
|
||||
)
|
||||
expect(screen.getByText('user2')).toBeInTheDocument()
|
||||
expect(screen.getByText('user3')).toBeInTheDocument()
|
||||
|
||||
// Action badge present
|
||||
const badges = screen.getAllByText('user.created')
|
||||
expect(badges.length).toBe(3)
|
||||
})
|
||||
|
||||
it('shows empty state when no items are returned', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/audit/events`, () =>
|
||||
HttpResponse.json({ items: [], nextCursor: null }),
|
||||
),
|
||||
)
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText(/sin resultados|no se encontraron eventos/i),
|
||||
).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('applies filters on submit — sends actor + targetType as query params', async () => {
|
||||
const requests: string[] = []
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/audit/events`, ({ request }) => {
|
||||
requests.push(request.url)
|
||||
return HttpResponse.json({ items: [], nextCursor: null })
|
||||
}),
|
||||
)
|
||||
|
||||
const u = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await waitFor(() => expect(requests.length).toBeGreaterThan(0))
|
||||
|
||||
const actorInput = screen.getByLabelText(/usuario \(id\)/i)
|
||||
await u.type(actorInput, '42')
|
||||
|
||||
const targetTypeInput = screen.getByLabelText(/tipo de entidad/i)
|
||||
await u.type(targetTypeInput, 'User')
|
||||
|
||||
const applyBtn = screen.getByRole('button', { name: /aplicar filtros/i })
|
||||
await u.click(applyBtn)
|
||||
|
||||
await waitFor(() => {
|
||||
const withFilters = requests.find(
|
||||
(url) => url.includes('actor=42') && url.includes('targetType=User'),
|
||||
)
|
||||
expect(withFilters).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
it('"Cargar más" is disabled when nextCursor is null', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/audit/events`, () =>
|
||||
HttpResponse.json({
|
||||
items: [makeEvent(1)],
|
||||
nextCursor: null,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('user1')).toBeInTheDocument(),
|
||||
)
|
||||
|
||||
const loadMoreBtn = screen.getByRole('button', {
|
||||
name: /cargar más/i,
|
||||
})
|
||||
expect(loadMoreBtn).toBeDisabled()
|
||||
})
|
||||
|
||||
it('"Cargar más" fetches next page with cursor and appends rows', async () => {
|
||||
const requests: string[] = []
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/audit/events`, ({ request }) => {
|
||||
requests.push(request.url)
|
||||
const url = new URL(request.url)
|
||||
const cursor = url.searchParams.get('cursor')
|
||||
if (cursor === 'cursor-page-2') {
|
||||
return HttpResponse.json({
|
||||
items: [makeEvent(10), makeEvent(11)],
|
||||
nextCursor: null,
|
||||
})
|
||||
}
|
||||
return HttpResponse.json({
|
||||
items: [makeEvent(1), makeEvent(2)],
|
||||
nextCursor: 'cursor-page-2',
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const u = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('user1')).toBeInTheDocument(),
|
||||
)
|
||||
expect(screen.getByText('user2')).toBeInTheDocument()
|
||||
|
||||
const loadMoreBtn = screen.getByRole('button', {
|
||||
name: /cargar más/i,
|
||||
})
|
||||
expect(loadMoreBtn).not.toBeDisabled()
|
||||
await u.click(loadMoreBtn)
|
||||
|
||||
// Second request with cursor param
|
||||
await waitFor(() => {
|
||||
const paged = requests.find((url) =>
|
||||
url.includes('cursor=cursor-page-2'),
|
||||
)
|
||||
expect(paged).toBeTruthy()
|
||||
})
|
||||
|
||||
// Appended rows appear alongside originals
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('user10')).toBeInTheDocument(),
|
||||
)
|
||||
expect(screen.getByText('user11')).toBeInTheDocument()
|
||||
// Original rows still visible (append, not replace)
|
||||
expect(screen.getByText('user1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "sistema" placeholder when actorUsername is null', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/audit/events`, () =>
|
||||
HttpResponse.json({
|
||||
items: [makeEvent(1, { actorUsername: null, actorUserId: null })],
|
||||
nextCursor: null,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
renderPage()
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('sistema')).toBeInTheDocument(),
|
||||
)
|
||||
})
|
||||
|
||||
it('"Limpiar" clears the form inputs', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/audit/events`, () =>
|
||||
HttpResponse.json({ items: [], nextCursor: null }),
|
||||
),
|
||||
)
|
||||
|
||||
const u = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
const actorInput = screen.getByLabelText(
|
||||
/usuario \(id\)/i,
|
||||
) as HTMLInputElement
|
||||
await u.type(actorInput, '42')
|
||||
expect(actorInput.value).toBe('42')
|
||||
|
||||
await u.click(screen.getByRole('button', { name: /limpiar/i }))
|
||||
|
||||
// Form field cleared after reset
|
||||
expect(actorInput.value).toBe('')
|
||||
})
|
||||
})
|
||||
137
src/web/src/tests/features/admin/audit/useAuditEvents.test.ts
Normal file
137
src/web/src/tests/features/admin/audit/useAuditEvents.test.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest'
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { setupServer } from 'msw/node'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import React from 'react'
|
||||
import { useAuditEvents } from '../../../../features/admin/audit/useAuditEvents'
|
||||
|
||||
const API_URL = 'http://localhost:5000'
|
||||
|
||||
const server = setupServer()
|
||||
|
||||
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
|
||||
afterEach(() => server.resetHandlers())
|
||||
afterAll(() => server.close())
|
||||
|
||||
function createWrapper() {
|
||||
const qc = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
})
|
||||
return ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(QueryClientProvider, { client: qc }, children)
|
||||
}
|
||||
|
||||
function makeEvent(id: number) {
|
||||
return {
|
||||
id,
|
||||
occurredAt: `2026-04-${String(10 + (id % 20)).padStart(2, '0')}T10:00:00Z`,
|
||||
actorUserId: 1,
|
||||
actorUsername: 'admin',
|
||||
action: 'user.created',
|
||||
targetType: 'User',
|
||||
targetId: String(100 + id),
|
||||
correlationId: `corr-${id}`,
|
||||
ipAddress: '10.0.0.1',
|
||||
metadata: null,
|
||||
}
|
||||
}
|
||||
|
||||
describe('useAuditEvents', () => {
|
||||
it('fetches the first page (no cursor) and returns items + nextCursor', async () => {
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/audit/events`, () =>
|
||||
HttpResponse.json({
|
||||
items: [makeEvent(1), makeEvent(2)],
|
||||
nextCursor: 'cursor-page-2',
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => useAuditEvents({}), {
|
||||
wrapper: createWrapper(),
|
||||
})
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
|
||||
expect(result.current.data?.items).toHaveLength(2)
|
||||
expect(result.current.data?.nextCursor).toBe('cursor-page-2')
|
||||
})
|
||||
|
||||
it('forwards filters as query params (actor, targetType, targetId, from, to)', async () => {
|
||||
let capturedUrl: string | null = null
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/audit/events`, ({ request }) => {
|
||||
capturedUrl = request.url
|
||||
return HttpResponse.json({ items: [], nextCursor: null })
|
||||
}),
|
||||
)
|
||||
|
||||
const { result } = renderHook(
|
||||
() =>
|
||||
useAuditEvents({
|
||||
actor: 42,
|
||||
targetType: 'User',
|
||||
targetId: 'abc-123',
|
||||
from: '2026-04-01T00:00:00Z',
|
||||
to: '2026-04-16T23:59:59Z',
|
||||
}),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
|
||||
expect(capturedUrl).toContain('actor=42')
|
||||
expect(capturedUrl).toContain('targetType=User')
|
||||
expect(capturedUrl).toContain('targetId=abc-123')
|
||||
expect(capturedUrl).toContain('from=2026-04-01')
|
||||
expect(capturedUrl).toContain('to=2026-04-16')
|
||||
})
|
||||
|
||||
it('forwards cursor param for successive pages', async () => {
|
||||
let capturedUrl: string | null = null
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/audit/events`, ({ request }) => {
|
||||
capturedUrl = request.url
|
||||
return HttpResponse.json({
|
||||
items: [makeEvent(3)],
|
||||
nextCursor: null,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useAuditEvents({ cursor: 'cursor-page-2' }),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
expect(capturedUrl).toContain('cursor=cursor-page-2')
|
||||
})
|
||||
|
||||
it('omits undefined/empty filters from the querystring', async () => {
|
||||
let capturedUrl: string | null = null
|
||||
server.use(
|
||||
http.get(`${API_URL}/api/v1/audit/events`, ({ request }) => {
|
||||
capturedUrl = request.url
|
||||
return HttpResponse.json({ items: [], nextCursor: null })
|
||||
}),
|
||||
)
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useAuditEvents({ actor: 1, targetType: '' }),
|
||||
{ wrapper: createWrapper() },
|
||||
)
|
||||
|
||||
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||
|
||||
expect(capturedUrl).toContain('actor=1')
|
||||
expect(capturedUrl).not.toContain('targetType=')
|
||||
expect(capturedUrl).not.toContain('from=')
|
||||
expect(capturedUrl).not.toContain('to=')
|
||||
expect(capturedUrl).not.toContain('cursor=')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user