feat: auditoría (audit_log en SQLite, endpoint /api/audit y pantalla /admin/auditoria)
Registra sesiones, creaciones y modificaciones en SAP (solo nombres de campos), denegados y cambios de permisos. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4e9d6a757d
commit
fe8c080471
26 files changed
+993
-28
No files matched your search
@@ -0,0 +1,67 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
h1 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.hint {
|
||||
margin: 0;
|
||||
color: var(--quiet);
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 8px 12px;
|
||||
padding: 8px 10px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-panel);
|
||||
}
|
||||
.filters label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
color: var(--quiet);
|
||||
}
|
||||
.filters input,
|
||||
.filters select {
|
||||
min-width: 130px;
|
||||
}
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.error {
|
||||
padding: 6px 10px;
|
||||
border: 1px solid var(--err);
|
||||
background: var(--err-bg);
|
||||
color: var(--err);
|
||||
font-size: 12px;
|
||||
}
|
||||
.link {
|
||||
margin-left: 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
.table {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<header class="head">
|
||||
<div>
|
||||
<h1>Auditoría</h1>
|
||||
<p class="hint">Quién inició sesión, creó o modificó qué en SAP, qué se le denegó y qué permisos se cambiaron. Se guardan los nombres de los campos, nunca sus valores.</p>
|
||||
</div>
|
||||
<button class="btn" type="button" (click)="reload()">Actualizar</button>
|
||||
</header>
|
||||
|
||||
<form class="filters" (submit)="$event.preventDefault(); filter()">
|
||||
<label>
|
||||
Usuario
|
||||
<input class="sbo-in" [value]="draft().webUser" (input)="set('webUser', $any($event.target).value)" />
|
||||
</label>
|
||||
<label>
|
||||
Acción
|
||||
<select class="sbo-sel" [value]="draft().action" (change)="set('action', $any($event.target).value)">
|
||||
<option value="">Todas</option>
|
||||
@for (a of actions; track a.value) {
|
||||
<option [value]="a.value" [selected]="a.value === draft().action">{{ a.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Recurso
|
||||
<input class="sbo-in" placeholder="Orders, BusinessPartners…" [value]="draft().resource" (input)="set('resource', $any($event.target).value)" />
|
||||
</label>
|
||||
<label>
|
||||
Resultado
|
||||
<select class="sbo-sel" [value]="draft().outcome" (change)="set('outcome', $any($event.target).value)">
|
||||
<option value="">Todos</option>
|
||||
@for (o of outcomes; track o.value) {
|
||||
<option [value]="o.value" [selected]="o.value === draft().outcome">{{ o.label }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Desde
|
||||
<input class="sbo-in" type="date" [value]="draft().from" (input)="set('from', $any($event.target).value)" />
|
||||
</label>
|
||||
<label>
|
||||
Hasta
|
||||
<input class="sbo-in" type="date" [value]="draft().to" (input)="set('to', $any($event.target).value)" />
|
||||
</label>
|
||||
<div class="buttons">
|
||||
<button class="btn main" type="submit">Filtrar</button>
|
||||
<button class="btn" type="button" (click)="clear()">Limpiar</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@if (errorMessage()) {
|
||||
<div class="error" role="alert">{{ errorMessage() }} <button class="link" type="button" (click)="reload()">Reintentar</button></div>
|
||||
}
|
||||
|
||||
<sbo-data-table class="table" [columns]="columns" [rows]="rows()" keyField="id" [loading]="loading()" [emptyText]="errorMessage() ? '' : 'Sin registros'" />
|
||||
|
||||
<sbo-pager class="pager" [rangeText]="rangeText()" [hasPrevious]="hasPrevious()" [hasNext]="hasNext()" [busy]="loading()" (previous)="previous()" (next)="next()" />
|
||||
@@ -0,0 +1,164 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { type ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import type { AuditRow } from '@basic-erp/sap-types';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { AUDIT_ACTIONS, AuditPage, describeDetail, formatTimestamp } from './audit.page';
|
||||
|
||||
const row = (over: Partial<AuditRow> = {}): AuditRow => ({
|
||||
id: 1,
|
||||
ts: '2026-10-16T15:04:05.000Z',
|
||||
webUser: 'ana',
|
||||
action: 'sl_update',
|
||||
resource: 'Orders',
|
||||
key: '316',
|
||||
outcome: 'ok',
|
||||
ip: '10.0.0.5',
|
||||
detail: { fields: ['Comments'] },
|
||||
...over,
|
||||
});
|
||||
|
||||
describe('descripción del detalle', () => {
|
||||
it('lista los campos tocados y las filas de cada tabla, nunca valores', () => {
|
||||
expect(describeDetail(row({ detail: { fields: ['CardCode', 'Comments'], collections: { DocumentLines: 3, BPAddresses: 0 } } }))).toBe(
|
||||
'Campos: CardCode, Comments · Tablas: DocumentLines (3), BPAddresses (0)',
|
||||
);
|
||||
});
|
||||
|
||||
it('muestra el motivo de un login fallido y el error de SAP', () => {
|
||||
expect(describeDetail(row({ detail: { reason: 'credenciales' } }))).toBe('credenciales');
|
||||
expect(describeDetail(row({ detail: { fields: ['CardCode'], error: '1250000110 - RUT is missing' } }))).toBe('1250000110 - RUT is missing · Campos: CardCode');
|
||||
});
|
||||
|
||||
it('muestra los permisos asignados y el método denegado', () => {
|
||||
expect(describeDetail(row({ detail: { paths: ['/ventas/*', '/compras/ordenes'] } }))).toBe('Permisos: /ventas/*, /compras/ordenes');
|
||||
expect(describeDetail(row({ detail: { paths: [] } }))).toBe('Permisos: (ninguno)');
|
||||
expect(describeDetail(row({ detail: { method: 'POST' } }))).toBe('POST');
|
||||
});
|
||||
|
||||
it('sin detalle, vacío', () => {
|
||||
expect(describeDetail(row({ detail: null }))).toBe('');
|
||||
});
|
||||
|
||||
it('la fecha se muestra local, y vacía si no hay', () => {
|
||||
expect(formatTimestamp('2026-10-16T15:04:05.000Z')).toMatch(/\d/);
|
||||
expect(formatTimestamp(null)).toBe('');
|
||||
});
|
||||
|
||||
it('cada acción del servidor tiene su nombre en español', () => {
|
||||
for (const a of ['login', 'login_failed', 'logout', 'sl_create', 'sl_update', 'sl_denied', 'permissions_set', 'permissions_remove']) {
|
||||
expect(AUDIT_ACTIONS[a], a).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuditPage', () => {
|
||||
let fixture: ComponentFixture<AuditPage>;
|
||||
let ctrl: HttpTestingController;
|
||||
const el = () => fixture.nativeElement as HTMLElement;
|
||||
const tick = async () => {
|
||||
fixture.detectChanges();
|
||||
await new Promise((r) => setTimeout(r));
|
||||
fixture.detectChanges();
|
||||
};
|
||||
const reply = (rows: AuditRow[], total = rows.length) => ctrl.expectOne((r) => r.url === '/api/audit').flush({ total, rows });
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.resetTestingModule();
|
||||
await TestBed.configureTestingModule({ imports: [AuditPage], providers: [provideHttpClient(), provideHttpClientTesting()] }).compileComponents();
|
||||
ctrl = TestBed.inject(HttpTestingController);
|
||||
fixture = TestBed.createComponent(AuditPage);
|
||||
await tick();
|
||||
});
|
||||
|
||||
it('pide lo más reciente, 50 por página, sin filtros', async () => {
|
||||
const req = ctrl.expectOne((r) => r.url === '/api/audit');
|
||||
expect(req.request.params.get('limit')).toBe('50');
|
||||
expect(req.request.params.get('offset')).toBe('0');
|
||||
expect(req.request.params.keys().sort()).toEqual(['limit', 'offset']);
|
||||
req.flush({ total: 0, rows: [] });
|
||||
await tick();
|
||||
expect(el().textContent).toContain('Sin registros');
|
||||
});
|
||||
|
||||
it('muestra cada hecho con su acción, resultado y detalle', async () => {
|
||||
reply([
|
||||
row(),
|
||||
row({ id: 2, action: 'login_failed', webUser: 'x', outcome: 'error', resource: null, key: null, detail: { reason: 'credenciales' } }),
|
||||
row({ id: 3, action: 'sl_denied', outcome: 'denied', detail: { method: 'POST' } }),
|
||||
]);
|
||||
await tick();
|
||||
const rows = [...el().querySelectorAll('tbody tr')].map((r) => r.textContent ?? '');
|
||||
expect(rows).toHaveLength(3);
|
||||
expect(rows[0]).toContain('Modificación');
|
||||
expect(rows[0]).toContain('Orders');
|
||||
expect(rows[0]).toContain('Campos: Comments');
|
||||
expect(rows[1]).toContain('Inicio de sesión fallido');
|
||||
expect(rows[2]).toContain('Operación denegada');
|
||||
expect([...el().querySelectorAll('tbody .badge')].map((b) => b.textContent?.trim())).toEqual(['Correcto', 'Error', 'Denegado']);
|
||||
expect(el().querySelector('.pager')?.textContent).toContain('1–3 de 3');
|
||||
});
|
||||
|
||||
it('Filtrar envía solo los filtros escritos y vuelve a la primera página', async () => {
|
||||
reply([row()], 120);
|
||||
await tick();
|
||||
const input = (label: string) => [...el().querySelectorAll('label')].find((l) => l.textContent?.trim().startsWith(label))!.querySelector('input, select') as HTMLInputElement;
|
||||
input('Usuario').value = 'ana';
|
||||
input('Usuario').dispatchEvent(new Event('input'));
|
||||
input('Acción').value = 'sl_update';
|
||||
input('Acción').dispatchEvent(new Event('change'));
|
||||
input('Desde').value = '2026-10-01';
|
||||
input('Desde').dispatchEvent(new Event('input'));
|
||||
(el().querySelector('button[type=submit]') as HTMLElement).click();
|
||||
await tick();
|
||||
const req = ctrl.expectOne((r) => r.url === '/api/audit');
|
||||
expect(req.request.params.get('webUser')).toBe('ana');
|
||||
expect(req.request.params.get('action')).toBe('sl_update');
|
||||
expect(req.request.params.get('from')).toBe('2026-10-01');
|
||||
expect(req.request.params.has('resource')).toBe(false);
|
||||
expect(req.request.params.get('offset')).toBe('0');
|
||||
req.flush({ total: 1, rows: [row()] });
|
||||
});
|
||||
|
||||
it('pagina: Siguiente pide la página que sigue y Anterior vuelve', async () => {
|
||||
reply([row()], 120);
|
||||
await tick();
|
||||
expect(el().querySelector('.pager')?.textContent).toContain('1–1 de 120');
|
||||
const nextBtn = [...el().querySelectorAll<HTMLButtonElement>('.pager button')].find((b) => b.textContent?.includes('Siguiente'))!;
|
||||
expect(nextBtn.disabled).toBe(false);
|
||||
nextBtn.click();
|
||||
await tick();
|
||||
const req = ctrl.expectOne((r) => r.url === '/api/audit');
|
||||
expect(req.request.params.get('offset')).toBe('50');
|
||||
req.flush({ total: 120, rows: [row({ id: 51 })] });
|
||||
await tick();
|
||||
const prev = [...el().querySelectorAll<HTMLButtonElement>('.pager button')].find((b) => b.textContent?.includes('Anterior'))!;
|
||||
expect(prev.disabled).toBe(false);
|
||||
prev.click();
|
||||
await tick();
|
||||
expect(ctrl.expectOne((r) => r.url === '/api/audit').request.params.get('offset')).toBe('0');
|
||||
});
|
||||
|
||||
it('Limpiar quita los filtros', async () => {
|
||||
reply([row()]);
|
||||
await tick();
|
||||
const user = [...el().querySelectorAll('label')].find((l) => l.textContent?.trim().startsWith('Usuario'))!.querySelector('input') as HTMLInputElement;
|
||||
user.value = 'ana';
|
||||
user.dispatchEvent(new Event('input'));
|
||||
(el().querySelector('button[type=submit]') as HTMLElement).click();
|
||||
await tick();
|
||||
reply([row()]);
|
||||
await tick();
|
||||
([...el().querySelectorAll('button')].find((b) => b.textContent?.trim() === 'Limpiar') as HTMLElement).click();
|
||||
await tick();
|
||||
const req = ctrl.expectOne((r) => r.url === '/api/audit');
|
||||
expect(req.request.params.has('webUser')).toBe(false);
|
||||
req.flush({ total: 0, rows: [] });
|
||||
});
|
||||
|
||||
it('un error del servidor se avisa con Reintentar', async () => {
|
||||
ctrl.expectOne((r) => r.url === '/api/audit').flush({ message: 'Requiere permisos de administrador' }, { status: 403, statusText: 'Forbidden' });
|
||||
await tick();
|
||||
expect(el().querySelector('.error')?.textContent).toContain('Requiere permisos de administrador');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { httpResource } from '@angular/common/http';
|
||||
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core';
|
||||
import type { AuditListResponse, AuditRow } from '@basic-erp/sap-types';
|
||||
import { API_URL, apiErrorMessage } from '../../core/api';
|
||||
import { DataTable } from '../../shared/list/data-table';
|
||||
import type { ListColumn, ListRow, Tone } from '../../shared/list/list.types';
|
||||
import { Pager } from '../../shared/list/pager';
|
||||
|
||||
/** Qué significa cada acción registrada. */
|
||||
export const AUDIT_ACTIONS: Readonly<Record<string, string>> = {
|
||||
login: 'Inicio de sesión',
|
||||
login_failed: 'Inicio de sesión fallido',
|
||||
logout: 'Cierre de sesión',
|
||||
sl_create: 'Creación',
|
||||
sl_update: 'Modificación',
|
||||
sl_denied: 'Operación denegada',
|
||||
permissions_set: 'Permisos asignados',
|
||||
permissions_remove: 'Permisos quitados',
|
||||
};
|
||||
|
||||
const OUTCOMES: Readonly<Record<string, { label: string; tone: Tone }>> = {
|
||||
ok: { label: 'Correcto', tone: 'ok' },
|
||||
error: { label: 'Error', tone: 'err' },
|
||||
denied: { label: 'Denegado', tone: 'warn' },
|
||||
};
|
||||
|
||||
const PAGE = 50;
|
||||
|
||||
/** `2026-10-16T15:04:05.000Z` → fecha y hora locales. */
|
||||
export const formatTimestamp = (ts: unknown): string =>
|
||||
typeof ts === 'string' && ts ? new Date(ts).toLocaleString('es-CL', { dateStyle: 'short', timeStyle: 'medium' }) : '';
|
||||
|
||||
/** Resumen legible del detalle: motivo o error, y los campos tocados (nunca valores). */
|
||||
export function describeDetail(row: Pick<AuditRow, 'detail'>): string {
|
||||
const d = row.detail;
|
||||
if (!d) return '';
|
||||
const parts: string[] = [];
|
||||
if (typeof d['reason'] === 'string') parts.push(d['reason']);
|
||||
if (typeof d['error'] === 'string') parts.push(d['error']);
|
||||
if (Array.isArray(d['fields']) && d['fields'].length) parts.push(`Campos: ${(d['fields'] as string[]).join(', ')}`);
|
||||
const collections = d['collections'];
|
||||
if (collections && typeof collections === 'object') {
|
||||
const list = Object.entries(collections as Record<string, number>).map(([k, n]) => `${k} (${n})`);
|
||||
if (list.length) parts.push(`Tablas: ${list.join(', ')}`);
|
||||
}
|
||||
if (Array.isArray(d['paths'])) parts.push(`Permisos: ${(d['paths'] as string[]).join(', ') || '(ninguno)'}`);
|
||||
if (typeof d['method'] === 'string') parts.push(d['method']);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
interface Filters {
|
||||
webUser: string;
|
||||
action: string;
|
||||
resource: string;
|
||||
outcome: string;
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
const EMPTY: Filters = { webUser: '', action: '', resource: '', outcome: '', from: '', to: '' };
|
||||
|
||||
/**
|
||||
* Registro de auditoría (solo acceso total): quién inició sesión, creó o modificó qué en SAP, qué se le
|
||||
* denegó y qué permisos se cambiaron. Lo más reciente primero, 50 por página, con filtros.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'sbo-audit-page',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [DataTable, Pager],
|
||||
templateUrl: './audit.page.html',
|
||||
styleUrl: './audit.page.css',
|
||||
})
|
||||
export class AuditPage {
|
||||
protected readonly actions = Object.entries(AUDIT_ACTIONS).map(([value, label]) => ({ value, label }));
|
||||
protected readonly outcomes = Object.entries(OUTCOMES).map(([value, o]) => ({ value, label: o.label }));
|
||||
|
||||
/** Lo que se está escribiendo en la barra de filtros. */
|
||||
protected readonly draft = signal<Filters>({ ...EMPTY });
|
||||
/** Lo que ya se aplicó (solo cambia con «Filtrar» o «Limpiar»). */
|
||||
private readonly applied = signal<Filters>({ ...EMPTY });
|
||||
private readonly offset = signal(0);
|
||||
|
||||
private readonly data = httpResource<AuditListResponse>(() => {
|
||||
const f = this.applied();
|
||||
const params: Record<string, string | number> = { limit: PAGE, offset: this.offset() };
|
||||
for (const [k, v] of Object.entries(f)) if (v) params[k] = v;
|
||||
return { url: `${API_URL}/audit`, params };
|
||||
});
|
||||
|
||||
protected readonly rows = computed<ListRow[]>(() => (this.data.hasValue() ? (this.data.value().rows as unknown as ListRow[]) : []));
|
||||
protected readonly total = computed(() => (this.data.hasValue() ? this.data.value().total : 0));
|
||||
protected readonly loading = computed(() => this.data.isLoading());
|
||||
protected readonly errorMessage = computed(() => (this.data.error() ? apiErrorMessage(this.data.error()) : ''));
|
||||
|
||||
protected readonly columns: readonly ListColumn[] = [
|
||||
{ key: 'ts', title: 'Fecha y hora', width: '160px', format: (r) => formatTimestamp(r['ts']) },
|
||||
{ key: 'webUser', title: 'Usuario', width: '150px' },
|
||||
{ key: 'action', title: 'Acción', width: '190px', format: (r) => AUDIT_ACTIONS[String(r['action'])] ?? String(r['action']) },
|
||||
{ key: 'resource', title: 'Recurso', width: '170px' },
|
||||
{ key: 'key', title: 'Clave', width: '110px', mono: true },
|
||||
{ key: 'outcome', title: 'Resultado', width: '100px', format: (r) => OUTCOMES[String(r['outcome'])]?.label ?? String(r['outcome']), tone: (r) => OUTCOMES[String(r['outcome'])]?.tone },
|
||||
{ key: 'ip', title: 'IP', width: '130px', mono: true },
|
||||
{ key: 'detail', title: 'Detalle', format: (r) => describeDetail(r as unknown as AuditRow) },
|
||||
];
|
||||
|
||||
protected readonly rangeText = computed(() => {
|
||||
const total = this.total();
|
||||
if (total === 0) return '0 registros';
|
||||
return `${this.offset() + 1}–${Math.min(this.offset() + this.rows().length, total)} de ${total}`;
|
||||
});
|
||||
protected readonly hasPrevious = computed(() => this.offset() > 0);
|
||||
protected readonly hasNext = computed(() => this.offset() + PAGE < this.total());
|
||||
|
||||
protected set(key: keyof Filters, value: string): void {
|
||||
this.draft.update((d) => ({ ...d, [key]: value }));
|
||||
}
|
||||
|
||||
protected filter(): void {
|
||||
this.offset.set(0);
|
||||
this.applied.set({ ...this.draft() });
|
||||
}
|
||||
|
||||
protected clear(): void {
|
||||
this.draft.set({ ...EMPTY });
|
||||
this.offset.set(0);
|
||||
this.applied.set({ ...EMPTY });
|
||||
}
|
||||
|
||||
protected reload(): void {
|
||||
this.data.reload();
|
||||
}
|
||||
|
||||
protected previous(): void {
|
||||
this.offset.update((o) => Math.max(0, o - PAGE));
|
||||
}
|
||||
|
||||
protected next(): void {
|
||||
this.offset.update((o) => o + PAGE);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user