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,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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user