feat(audit): Add audit log API endpoints and UI with signature verification

- Add GET /api/audit endpoint for listing events with filters
- Add GET /api/audit/:id/verify endpoint for signature verification
- Add AuditLogPanel UI component with filtering and verification
- Update docs with audit API documentation
- Add localStorage utils for persisting UI state
- Update gitignore patterns
This commit is contained in:
rcourtman 2026-01-08 19:19:57 +00:00
parent 7342191075
commit 73c5128a87
11 changed files with 1089 additions and 25 deletions

2
.gitignore vendored
View file

@ -17,6 +17,8 @@
*.db
*.sqlite
*.sqlite3
active_subs.json
charges.json
# OS files
.DS_Store

View file

@ -198,6 +198,48 @@ Returns a new raw token (shown once) and updates stored hashes:
"requiresRestart": false,
"message": "New API token generated and active immediately! Save this token - it won't be shown again."
}
---
## 🧾 Audit Log (Enterprise)
These endpoints require admin access and the `settings:read` scope. In OSS builds, the list endpoint returns an empty set and `persistentLogging: false`.
### List Audit Events
`GET /api/audit?limit=100&event=login&user=admin&success=true&startTime=2024-01-01T00:00:00Z&endTime=2024-01-31T23:59:59Z`
Response:
```json
{
"events": [
{
"id": "6b3c9c3c-9a2f-4b3c-9a3b-3d0e8c5c5d45",
"timestamp": "2024-01-12T10:15:30Z",
"event": "login",
"user": "admin",
"ip": "10.0.0.10",
"path": "/api/login",
"success": true,
"details": "Successful login",
"signature": "..."
}
],
"total": 1,
"persistentLogging": true
}
```
### Verify Audit Event Signature
`GET /api/audit/<id>/verify`
Response:
```json
{
"available": true,
"verified": true,
"message": "Event signature verified"
}
```
```
### Validate API Token (Admin)

View file

@ -4,6 +4,14 @@ Pulse Pro unlocks advanced AI automation features on top of the free Pulse platf
## What You Get
### Enterprise Audit Log
- Persistent audit trail with SQLite storage and HMAC signing.
- Queryable via `/api/audit` and verified per event in the Security → Audit Log UI.
- Supports filtering, verification badges, and signature checks for tamper detection.
- Configure with `PULSE_AUDIT_SIGNING_KEY`, `PULSE_AUDIT_RETENTION_DAYS`, and `PULSE_AUDIT_CLEANUP_INTERVAL_HOURS`.
- API reference: `docs/API.md`.
- If no signing key is set, events are stored without signatures and verification will fail.
### AI Patrol (LLM-Backed)
Scheduled background analysis that correlates live state + metrics history to produce actionable findings.

View file

@ -43,6 +43,21 @@ sudo pulse bootstrap-token
- Check if your IP is locked out (wait 15 mins).
- If another admin can log in, use `POST /api/security/reset-lockout` to clear the lockout for your username or IP.
**Audit Log verification shows unsigned events**
- **Symptom**: Audit Log entries show “Unsigned” or verification fails in the UI.
- **Root cause**: `PULSE_AUDIT_SIGNING_KEY` is not set, so events are stored without signatures.
- **Fix**: Set `PULSE_AUDIT_SIGNING_KEY` and restart Pulse Pro. Newly created events will be signed; existing unsigned events remain unsigned.
**Audit Log is empty**
- **Symptom**: Audit Log shows zero events or “Console Logging Only.”
- **Root cause**: OSS build uses console logging only, or enterprise audit logging is not enabled.
- **Fix**: Use Pulse Pro with enterprise audit logging enabled, then generate new audit events (logins, token creation, password changes).
**Audit Log verification fails for older events**
- **Symptom**: Older events fail verification while newer events pass.
- **Root cause**: The signing key changed or was rotated, so signatures no longer match.
- **Fix**: Keep `PULSE_AUDIT_SIGNING_KEY` stable. If rotated intentionally, expect older events to fail verification.
### Monitoring Data
**VMs show "-" for disk usage**

View file

@ -116,6 +116,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@ -489,6 +490,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
},
@ -512,6 +514,7 @@
}
],
"license": "MIT",
"peer": true,
"engines": {
"node": ">=18"
}
@ -1586,6 +1589,7 @@
"resolved": "https://registry.npmjs.org/@solidjs/router/-/router-0.10.10.tgz",
"integrity": "sha512-nGl7gMgsojuaupI5MAK2cFtkndmWWSAPhill/8La3IjujY3vMBamcQFymBsA2ejzxEYJjkOlEQHYgp2jNFkwuQ==",
"license": "MIT",
"peer": true,
"peerDependencies": {
"solid-js": "^1.8.6"
}
@ -1755,6 +1759,7 @@
"integrity": "sha512-0l6cjgF0XnihUpndDhk+nyD3exio3iKaYROSgvh/qSevPXax3L8p5DBRFjbvalnwatGgHEQn2R88y2fA3g4irg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@ -1801,6 +1806,7 @@
"integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.49.0",
"@typescript-eslint/types": "8.49.0",
@ -2161,6 +2167,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@ -2472,6 +2479,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@ -3048,6 +3056,7 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@ -4035,6 +4044,7 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"jiti": "bin/jiti.js"
}
@ -4065,6 +4075,7 @@
"integrity": "sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"cssstyle": "^4.0.1",
"data-urls": "^5.0.0",
@ -4743,6 +4754,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
@ -5226,6 +5238,7 @@
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.3.2.tgz",
"integrity": "sha512-RbcPH1n5cfwKrru7v7+zrZvjLurgHhGyso3HTyGtRivGWgYjbOmGuivCQaORNELjNONoK35nj28EoWul9sb1zQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
}
@ -5290,6 +5303,7 @@
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.10.tgz",
"integrity": "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.1.0",
"seroval": "~1.3.0",
@ -5550,6 +5564,7 @@
"integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"arg": "^5.0.2",
@ -5717,6 +5732,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -5848,6 +5864,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@ -5938,6 +5955,7 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@ -6079,6 +6097,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@ -6112,6 +6131,7 @@
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/expect": "3.2.4",

View file

@ -1,5 +1,14 @@
import { createSignal, Show, For, onMount } from 'solid-js';
import { Shield, CheckCircle, XCircle, AlertTriangle, RefreshCw, Filter } from 'lucide-solid';
import { createSignal, Show, For, onMount, createMemo, onCleanup, createEffect } from 'solid-js';
import { Shield, CheckCircle, XCircle, AlertTriangle, RefreshCw, Filter, Info, Play, X } from 'lucide-solid';
import { showTooltip, hideTooltip } from '@/components/shared/Tooltip';
import Toggle from '@/components/shared/Toggle';
import {
createLocalStorageBooleanSignal,
createLocalStorageNumberSignal,
createLocalStorageStringSignal,
STORAGE_KEYS,
} from '@/utils/localStorage';
import { showSuccess, showWarning, showToast } from '@/utils/toast';
interface AuditEvent {
id: string;
@ -19,21 +28,82 @@ interface AuditResponse {
persistentLogging: boolean;
}
interface VerifyResponse {
available: boolean;
verified?: boolean;
message?: string;
}
type VerificationState = {
status: 'verified' | 'failed' | 'unavailable' | 'error';
message: string;
};
export default function AuditLogPanel() {
const [events, setEvents] = createSignal<AuditEvent[]>([]);
const [totalEvents, setTotalEvents] = createSignal(0);
const [loading, setLoading] = createSignal(true);
const [error, setError] = createSignal<string | null>(null);
const [isPersistent, setIsPersistent] = createSignal(false);
const [eventFilter, setEventFilter] = createSignal('');
const [userFilter, setUserFilter] = createSignal('');
const [successFilter, setSuccessFilter] = createSignal<'all' | 'success' | 'failed'>('all');
const [eventFilter, setEventFilter] = createLocalStorageStringSignal(
STORAGE_KEYS.AUDIT_EVENT_FILTER,
'',
);
const [userFilter, setUserFilter] = createLocalStorageStringSignal(
STORAGE_KEYS.AUDIT_USER_FILTER,
'',
);
const [successFilter, setSuccessFilter] = createLocalStorageStringSignal(
STORAGE_KEYS.AUDIT_SUCCESS_FILTER,
'all',
);
const [verificationFilter, setVerificationFilter] = createLocalStorageStringSignal(
STORAGE_KEYS.AUDIT_VERIFICATION_FILTER,
'all',
);
const allowedVerificationFilters = new Set(['all', 'needs', 'verified', 'failed']);
const allowedSuccessFilters = new Set(['all', 'success', 'failed']);
const [pageSize, setPageSize] = createLocalStorageNumberSignal(
STORAGE_KEYS.AUDIT_PAGE_SIZE,
100,
);
const [pageOffset, setPageOffset] = createLocalStorageNumberSignal(
STORAGE_KEYS.AUDIT_PAGE_OFFSET,
0,
);
const [verification, setVerification] = createSignal<Record<string, VerificationState>>({});
const [verifying, setVerifying] = createSignal<Record<string, boolean>>({});
const [verifyingAll, setVerifyingAll] = createSignal(false);
const [verifyAllTotal, setVerifyAllTotal] = createSignal(0);
const [verifyAllDone, setVerifyAllDone] = createSignal(0);
const [autoVerifyEnabled, setAutoVerifyEnabled] = createLocalStorageBooleanSignal(
STORAGE_KEYS.AUDIT_AUTO_VERIFY,
true,
);
const [autoVerifyLimit, setAutoVerifyLimit] = createLocalStorageNumberSignal(
STORAGE_KEYS.AUDIT_AUTO_VERIFY_LIMIT,
50,
);
const [pageInput, setPageInput] = createSignal('');
const [isMounted, setIsMounted] = createSignal(false);
const [cancelVerifyAll, setCancelVerifyAll] = createSignal(false);
const [verifyCanceled, setVerifyCanceled] = createSignal(false);
const [verifyControllers, setVerifyControllers] = createSignal<Record<string, AbortController>>({});
const fetchAuditEvents = async (options?: { limit?: number; offset?: number }) => {
const limit = options?.limit ?? pageSize();
const offset = options?.offset ?? pageOffset();
const fetchAuditEvents = async () => {
setLoading(true);
setError(null);
setVerification({});
setVerifying({});
setVerifyAllTotal(0);
setVerifyAllDone(0);
try {
const params = new URLSearchParams();
params.set('limit', '100');
params.set('limit', String(limit));
params.set('offset', String(Math.max(0, offset)));
if (eventFilter()) params.set('event', eventFilter());
if (userFilter()) params.set('user', userFilter());
if (successFilter() === 'success') params.set('success', 'true');
@ -46,6 +116,23 @@ export default function AuditLogPanel() {
const data: AuditResponse = await response.json();
setEvents(data.events || []);
setIsPersistent(data.persistentLogging);
setTotalEvents(data.total ?? 0);
if (data.total && offset >= data.total) {
const maxOffset = Math.max(0, Math.floor((data.total - 1) / limit) * limit);
if (maxOffset !== offset) {
setPageOffset(maxOffset);
void fetchAuditEvents({ limit, offset: maxOffset });
return;
}
}
if (data.persistentLogging && autoVerifyEnabled()) {
const limit = autoVerifyLimit();
if (limit <= 0) return;
setTimeout(() => {
if (!isMounted()) return;
void verifyAllEvents({ limit, showToast: false });
}, 0);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
@ -53,10 +140,160 @@ export default function AuditLogPanel() {
}
};
const verifyEvent = async (event: AuditEvent) => {
if (!event.signature) return;
setVerifying((prev) => ({ ...prev, [event.id]: true }));
try {
const controller = new AbortController();
setVerifyControllers((prev) => ({ ...prev, [event.id]: controller }));
if (!isMounted()) {
controller.abort();
}
const response = await fetch(`/api/audit/${event.id}/verify`, { signal: controller.signal });
if (!response.ok) {
throw new Error(`Failed to verify signature: ${response.statusText}`);
}
const data: VerifyResponse = await response.json();
let status: VerificationState['status'] = 'unavailable';
if (!data.available) {
status = 'unavailable';
} else if (data.verified) {
status = 'verified';
} else {
status = 'failed';
}
setVerification((prev) => ({
...prev,
[event.id]: { status, message: data.message || '' },
}));
} catch (err) {
if ((err as { name?: string })?.name === 'AbortError') {
return;
}
setVerification((prev) => ({
...prev,
[event.id]: {
status: 'error',
message: err instanceof Error ? err.message : 'Unknown error',
},
}));
} finally {
setVerifying((prev) => ({ ...prev, [event.id]: false }));
setVerifyControllers((prev) => {
const next = { ...prev };
delete next[event.id];
return next;
});
}
};
const verifyAllEvents = async (options?: { limit?: number; showToast?: boolean; resume?: boolean }) => {
const limit = options?.limit;
let signedEvents = events().filter((event) => event.signature);
if (options?.resume) {
signedEvents = signedEvents.filter((event) => {
const state = verification()[event.id];
return !state || state.status === 'failed' || state.status === 'error';
});
}
if (limit !== undefined) {
signedEvents = signedEvents.slice(0, Math.max(0, limit));
}
if (signedEvents.length === 0) return;
setVerifyingAll(true);
setVerifyCanceled(false);
setCancelVerifyAll(false);
setVerifyAllTotal(signedEvents.length);
setVerifyAllDone(0);
for (const event of signedEvents) {
if (cancelVerifyAll()) {
setVerifyingAll(false);
setVerifyCanceled(true);
if (options?.showToast) {
showToast('info', 'Signature verification canceled');
}
const controllers = verifyControllers();
for (const controller of Object.values(controllers)) {
controller.abort();
}
return;
}
await verifyEvent(event);
setVerifyAllDone((prev) => prev + 1);
}
setVerifyingAll(false);
setVerifyCanceled(false);
if (options?.showToast) {
let verified = 0;
let failed = 0;
let errors = 0;
let unavailable = 0;
let unchecked = 0;
for (const event of signedEvents) {
const state = verification()[event.id];
if (!state) {
unchecked += 1;
continue;
}
switch (state.status) {
case 'verified':
verified += 1;
break;
case 'failed':
failed += 1;
break;
case 'error':
errors += 1;
break;
case 'unavailable':
unavailable += 1;
break;
default:
unchecked += 1;
}
}
if (failed > 0 || errors > 0) {
showWarning(
'Signature verification completed',
`Verified ${verified}, failed ${failed}, errors ${errors}, unavailable ${unavailable}.`,
);
} else {
showSuccess('Signature verification completed', `Verified ${verified} events.`);
}
}
};
onMount(() => {
setIsMounted(true);
fetchAuditEvents();
});
createEffect(() => {
const current = verificationFilter();
if (!allowedVerificationFilters.has(current)) {
setVerificationFilter('all');
}
});
createEffect(() => {
const current = successFilter();
if (!allowedSuccessFilters.has(current)) {
setSuccessFilter('all');
}
});
onCleanup(() => {
setIsMounted(false);
setCancelVerifyAll(true);
const controllers = verifyControllers();
for (const controller of Object.values(controllers)) {
controller.abort();
}
});
const formatTimestamp = (ts: string) => {
const date = new Date(ts);
return date.toLocaleString();
@ -78,6 +315,123 @@ export default function AuditLogPanel() {
return colors[event] || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200';
};
const hasSignedEvents = () => events().some((event) => event.signature);
const hasResumeEvents = () =>
events().some((event) => {
if (!event.signature) return false;
const state = verification()[event.id];
return !state || state.status === 'failed' || state.status === 'error';
});
const resumeCount = () =>
events().filter((event) => {
if (!event.signature) return false;
const state = verification()[event.id];
return !state || state.status === 'failed' || state.status === 'error';
}).length;
const verifyAllLabel = () => {
if (!verifyingAll()) return 'Verify All';
if (verifyAllTotal() === 0) return 'Verifying…';
return `Verifying ${verifyAllDone()} of ${verifyAllTotal()}`;
};
const hasNextPage = () => events().length === pageSize();
const pageNumber = () => Math.floor(pageOffset() / pageSize()) + 1;
const totalPages = () => Math.max(1, Math.ceil(totalEvents() / pageSize()));
const pageRangeText = () => {
if (totalEvents() === 0) return 'Showing 0 of 0';
const start = pageOffset() + 1;
const end = Math.min(totalEvents(), pageOffset() + events().length);
return `Showing ${start}-${end} of ${totalEvents()}`;
};
const verificationSummary = createMemo(() => {
const summary = {
total: events().length,
signed: 0,
verified: 0,
failed: 0,
error: 0,
unavailable: 0,
unchecked: 0,
};
for (const event of events()) {
if (!event.signature) continue;
summary.signed += 1;
const state = verification()[event.id];
if (!state) {
summary.unchecked += 1;
continue;
}
switch (state.status) {
case 'verified':
summary.verified += 1;
break;
case 'failed':
summary.failed += 1;
break;
case 'error':
summary.error += 1;
break;
case 'unavailable':
summary.unavailable += 1;
break;
default:
summary.unchecked += 1;
}
}
return summary;
});
const activeFilterCount = () => {
let count = 0;
if (eventFilter()) count += 1;
if (userFilter()) count += 1;
if (successFilter() !== 'all') count += 1;
if (verificationFilter() !== 'all') count += 1;
return count;
};
const activeFilterChips = () => {
const chips: { label: string; key: 'event' | 'user' | 'success' | 'verification' }[] = [];
if (eventFilter()) chips.push({ label: `Event: ${eventFilter()}`, key: 'event' });
if (userFilter()) chips.push({ label: `User: ${userFilter()}`, key: 'user' });
if (successFilter() !== 'all') chips.push({ label: `Success: ${successFilter()}`, key: 'success' });
if (verificationFilter() !== 'all') chips.push({ label: `Verification: ${verificationFilter()}`, key: 'verification' });
return chips;
};
const filteredEvents = createMemo(() => {
const filter = verificationFilter();
if (filter === 'all') return events();
return events().filter((event) => {
if (!event.signature) return false;
const state = verification()[event.id];
if (!state) {
return filter === 'needs';
}
if (filter === 'verified') return state.status === 'verified';
if (filter === 'failed') return state.status === 'failed' || state.status === 'error';
return false;
});
});
const getVerificationBadge = (state?: VerificationState) => {
if (!state) {
return { label: 'Not checked', class: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300' };
}
switch (state.status) {
case 'verified':
return { label: 'Verified', class: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200' };
case 'failed':
return { label: 'Failed', class: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' };
case 'error':
return { label: 'Error', class: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200' };
default:
return { label: 'Unavailable', class: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300' };
}
};
return (
<div class="space-y-6">
{/* Header */}
@ -91,14 +445,55 @@ export default function AuditLogPanel() {
</span>
</Show>
</div>
<button
onClick={fetchAuditEvents}
disabled={loading()}
class="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
>
<RefreshCw class={`w-4 h-4 ${loading() ? 'animate-spin' : ''}`} />
Refresh
</button>
<div class="flex items-center gap-2">
<button
onClick={() => fetchAuditEvents()}
disabled={loading()}
class="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
>
<RefreshCw class={`w-4 h-4 ${loading() ? 'animate-spin' : ''}`} />
Refresh
</button>
<button
onClick={() => verifyAllEvents({ showToast: true })}
disabled={!isPersistent() || loading() || verifyingAll() || !hasSignedEvents()}
class="flex items-center gap-2 px-3 py-2 text-sm font-medium text-indigo-700 dark:text-indigo-200 bg-indigo-50 dark:bg-indigo-900/40 border border-indigo-200 dark:border-indigo-700 rounded-lg hover:bg-indigo-100 dark:hover:bg-indigo-900/60 disabled:opacity-50"
>
<CheckCircle class="w-4 h-4" />
{verifyAllLabel()}
</button>
<button
onClick={() => setCancelVerifyAll(true)}
disabled={!verifyingAll()}
class="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
>
Cancel
</button>
<button
onClick={() => verifyAllEvents({ showToast: true, resume: true })}
disabled={verifyingAll() || !hasResumeEvents()}
class="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
onMouseEnter={(e) => {
if (!hasResumeEvents()) return;
const rect = e.currentTarget.getBoundingClientRect();
showTooltip(
'Retries failed, error, or unchecked signatures on this page.',
rect.left + rect.width / 2,
rect.top,
{ align: 'center', direction: 'up', maxWidth: 260 },
);
}}
onMouseLeave={() => hideTooltip()}
>
<Play class="w-4 h-4" />
Resume{resumeCount() > 0 ? ` (${resumeCount()})` : ''}
</button>
<Show when={verifyCanceled() && !verifyingAll()}>
<span class="text-xs text-amber-600 dark:text-amber-400">
Verification canceled
</span>
</Show>
</div>
</div>
{/* OSS Notice */}
@ -142,19 +537,164 @@ export default function AuditLogPanel() {
/>
<select
value={successFilter()}
onChange={(e) => setSuccessFilter(e.currentTarget.value as any)}
onChange={(e) => setSuccessFilter(e.currentTarget.value)}
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="all">All</option>
<option value="success">Success Only</option>
<option value="failed">Failed Only</option>
</select>
<select
value={verificationFilter()}
onChange={(e) => setVerificationFilter(e.currentTarget.value)}
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="all">All Verification</option>
<option value="needs">Needs Verification</option>
<option value="verified">Verified</option>
<option value="failed">Failed/Error</option>
</select>
<select
value={String(pageSize())}
onChange={(e) => setPageSize(Number(e.currentTarget.value))}
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
>
<option value="25">25 / page</option>
<option value="50">50 / page</option>
<option value="100">100 / page</option>
<option value="200">200 / page</option>
</select>
<button
onClick={fetchAuditEvents}
onClick={() => {
setPageOffset(0);
void fetchAuditEvents({ offset: 0 });
}}
class="px-3 py-1.5 text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 rounded-lg"
>
Apply
</button>
<button
onClick={() => {
const hadFilters = activeFilterCount() > 0;
setEventFilter('');
setUserFilter('');
setSuccessFilter('all');
setVerificationFilter('all');
setPageOffset(0);
void fetchAuditEvents({ offset: 0 });
if (hadFilters) {
showSuccess('Audit filters cleared');
}
}}
class="px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600"
>
Clear{activeFilterCount() > 0 ? ` (${activeFilterCount()})` : ''}
</button>
</div>
<Show when={activeFilterCount() > 0}>
<div class="mt-2 flex flex-wrap gap-2">
<For each={activeFilterChips()}>
{(chip) => (
<button
type="button"
onClick={() => {
if (chip.key === 'event') setEventFilter('');
if (chip.key === 'user') setUserFilter('');
if (chip.key === 'success') setSuccessFilter('all');
if (chip.key === 'verification') setVerificationFilter('all');
setPageOffset(0);
void fetchAuditEvents({ offset: 0 });
}}
class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600"
title="Click to clear filter"
aria-label={`Clear ${chip.label}`}
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
showTooltip('Click to clear filter', rect.left + rect.width / 2, rect.top, {
align: 'center',
direction: 'up',
});
}}
onMouseLeave={() => hideTooltip()}
onKeyDown={(e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
if (chip.key === 'event') setEventFilter('');
if (chip.key === 'user') setUserFilter('');
if (chip.key === 'success') setSuccessFilter('all');
if (chip.key === 'verification') setVerificationFilter('all');
setPageOffset(0);
void fetchAuditEvents({ offset: 0 });
}}
>
{chip.label}
<X class="w-3 h-3 text-gray-500 dark:text-gray-300" />
</button>
)}
</For>
</div>
</Show>
</Show>
{/* Verification Preferences */}
<Show when={isPersistent()}>
<div class="flex items-center justify-between gap-4 px-1">
<Toggle
label="Auto-verify signatures"
description="Verify signatures after loading audit events."
checked={autoVerifyEnabled()}
onChange={(e) => setAutoVerifyEnabled(e.currentTarget.checked)}
/>
<div class="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
<label class="flex items-center gap-2">
<span>Auto-verify limit</span>
<input
type="number"
min="0"
value={autoVerifyLimit()}
disabled={!autoVerifyEnabled()}
onInput={(e) => {
const raw = Number(e.currentTarget.value);
if (!Number.isFinite(raw)) {
setAutoVerifyLimit(0);
return;
}
const clamped = Math.max(0, Math.min(500, Math.floor(raw)));
setAutoVerifyLimit(clamped);
}}
class="w-20 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-xs text-gray-900 dark:text-gray-100"
/>
</label>
<span class="text-gray-500 dark:text-gray-400">0 disables, max 500</span>
<span
class="inline-flex items-center text-gray-400 cursor-help"
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
showTooltip(
'Large verification batches can add load. Use a smaller limit on busy systems.',
rect.left + rect.width / 2,
rect.top,
{ align: 'center', direction: 'up', maxWidth: 260 },
);
}}
onMouseLeave={() => hideTooltip()}
>
<Info class="w-3 h-3" />
</span>
<button
onClick={() => {
setAutoVerifyEnabled(true);
setAutoVerifyLimit(50);
setPageSize(100);
setPageOffset(0);
setPageInput('');
showSuccess('Audit preferences reset');
}}
class="ml-2 px-2 py-1 text-xs font-medium text-gray-600 dark:text-gray-300 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800"
>
Reset preferences
</button>
</div>
</div>
</Show>
@ -173,7 +713,41 @@ export default function AuditLogPanel() {
</Show>
{/* Events Table */}
<Show when={!loading() && isPersistent() && events().length > 0}>
<Show when={!loading() && isPersistent() && filteredEvents().length > 0}>
<div class="flex flex-wrap items-center gap-3 text-xs text-gray-600 dark:text-gray-300">
<span>Total: {totalEvents()}</span>
<span>Signed: {verificationSummary().signed}</span>
<span>Verified: {verificationSummary().verified}</span>
<span>Failed: {verificationSummary().failed}</span>
<span>Errors: {verificationSummary().error}</span>
<span>Unavailable: {verificationSummary().unavailable}</span>
<span>Unchecked: {verificationSummary().unchecked}</span>
<span
class="inline-flex items-center gap-1 text-gray-500 dark:text-gray-400 cursor-help"
onMouseEnter={(e) => {
const rect = e.currentTarget.getBoundingClientRect();
const content = [
'Unsigned: no signature stored for this event',
'Unavailable: verification not supported by the logger',
'Not checked: signature exists but has not been verified',
'Failed: signature mismatch or tampering detected',
'Error: verification request failed',
].join('\n');
showTooltip(content, rect.left + rect.width / 2, rect.top, {
align: 'center',
direction: 'up',
maxWidth: 320,
});
}}
onMouseLeave={() => hideTooltip()}
>
<Info class="w-3 h-3" />
Legend
</span>
<span class="ml-auto text-gray-500 dark:text-gray-400">
{pageRangeText()} • Page {pageNumber()} of {totalPages()}
</span>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
@ -184,11 +758,11 @@ export default function AuditLogPanel() {
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">User</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">IP</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Details</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Signed</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Verification</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
<For each={events()}>
<For each={filteredEvents()}>
{(event) => (
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-4 py-3 whitespace-nowrap">
@ -212,8 +786,34 @@ export default function AuditLogPanel() {
{event.details || '-'}
</td>
<td class="px-4 py-3 whitespace-nowrap">
<Show when={event.signature} fallback={<span class="text-gray-400">-</span>}>
<CheckCircle class="w-4 h-4 text-green-500" />
<Show
when={event.signature}
fallback={<span class="text-gray-400">Unsigned</span>}
>
{(() => {
const badge = getVerificationBadge(verification()[event.id]);
return (
<div class="flex items-center gap-2">
<Show
when={verifying()[event.id]}
fallback={
<span class={`px-2 py-0.5 text-xs font-medium rounded-full ${badge.class}`}>
{badge.label}
</span>
}
>
<span class="text-xs text-gray-500 dark:text-gray-400">Verifying…</span>
</Show>
<button
onClick={() => verifyEvent(event)}
disabled={verifying()[event.id]}
class="text-xs font-medium text-indigo-600 dark:text-indigo-400 hover:underline disabled:opacity-50"
>
Verify
</button>
</div>
);
})()}
</Show>
</td>
</tr>
@ -225,10 +825,98 @@ export default function AuditLogPanel() {
</Show>
{/* Empty State */}
<Show when={!loading() && isPersistent() && events().length === 0}>
<Show when={!loading() && isPersistent() && filteredEvents().length === 0}>
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
<Shield class="w-12 h-12 mx-auto mb-4 opacity-50" />
<p>No audit events found</p>
<Show when={verificationFilter() !== 'all'}>
<p class="mt-2 text-xs text-gray-400 dark:text-gray-500">
Try clearing the verification filter.
</p>
</Show>
</div>
</Show>
{/* Pagination */}
<Show when={!loading() && isPersistent()}>
<div class="flex items-center justify-end gap-2">
<div class="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300">
<label class="flex items-center gap-2">
<span>Page</span>
<input
type="number"
min="1"
value={pageInput()}
onInput={(e) => setPageInput(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key !== 'Enter') return;
const parsed = Number(pageInput());
if (!Number.isFinite(parsed)) return;
const clamped = Math.max(1, Math.min(totalPages(), Math.floor(parsed)));
const nextOffset = (clamped - 1) * pageSize();
setPageOffset(nextOffset);
void fetchAuditEvents({ offset: nextOffset });
}}
class="w-16 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-xs text-gray-900 dark:text-gray-100"
/>
</label>
<button
onClick={() => {
const parsed = Number(pageInput());
if (!Number.isFinite(parsed)) return;
const clamped = Math.max(1, Math.min(totalPages(), Math.floor(parsed)));
const nextOffset = (clamped - 1) * pageSize();
setPageOffset(nextOffset);
void fetchAuditEvents({ offset: nextOffset });
}}
class="px-2 py-1 text-xs font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600"
>
Go
</button>
</div>
<button
onClick={() => {
setPageOffset(0);
void fetchAuditEvents({ offset: 0 });
}}
disabled={pageOffset() === 0}
class="px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
>
First
</button>
<button
onClick={() => {
const nextOffset = Math.max(0, pageOffset() - pageSize());
setPageOffset(nextOffset);
void fetchAuditEvents({ offset: nextOffset });
}}
disabled={pageOffset() === 0}
class="px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
>
Previous
</button>
<button
onClick={() => {
const nextOffset = pageOffset() + pageSize();
setPageOffset(nextOffset);
void fetchAuditEvents({ offset: nextOffset });
}}
disabled={!hasNextPage()}
class="px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
>
Next
</button>
<button
onClick={() => {
const lastOffset = Math.max(0, (totalPages() - 1) * pageSize());
setPageOffset(lastOffset);
void fetchAuditEvents({ offset: lastOffset });
}}
disabled={!hasNextPage()}
class="px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50"
>
Last
</button>
</div>
</Show>
</div>

View file

@ -50,6 +50,43 @@ export function createLocalStorageBooleanSignal(
);
}
/**
* Creates a number signal that syncs with localStorage
* @param key - The localStorage key
* @param defaultValue - Default value if nothing in localStorage
*/
export function createLocalStorageNumberSignal(
key: string,
defaultValue: number = 0,
): Signal<number> {
return createLocalStorageSignal(
key,
defaultValue,
(val) => {
const parsed = Number(val);
return Number.isFinite(parsed) ? parsed : defaultValue;
},
(val) => String(val),
);
}
/**
* Creates a string signal that syncs with localStorage
* @param key - The localStorage key
* @param defaultValue - Default value if nothing in localStorage
*/
export function createLocalStorageStringSignal(
key: string,
defaultValue: string = '',
): Signal<string> {
return createLocalStorageSignal(
key,
defaultValue,
(val) => String(val),
(val) => String(val),
);
}
/**
* Creates a number signal that syncs with localStorage
* @param key - The localStorage key
@ -126,4 +163,14 @@ export const STORAGE_KEYS = {
// Feature discovery
DISMISSED_FEATURE_TIPS: 'pulse-dismissed-feature-tips',
// Audit log
AUDIT_AUTO_VERIFY: 'pulse-audit-auto-verify',
AUDIT_AUTO_VERIFY_LIMIT: 'pulse-audit-auto-verify-limit',
AUDIT_PAGE_SIZE: 'pulse-audit-page-size',
AUDIT_PAGE_OFFSET: 'pulse-audit-page-offset',
AUDIT_VERIFICATION_FILTER: 'pulse-audit-verification-filter',
AUDIT_EVENT_FILTER: 'pulse-audit-event-filter',
AUDIT_USER_FILTER: 'pulse-audit-user-filter',
AUDIT_SUCCESS_FILTER: 'pulse-audit-success-filter',
} as const;

View file

@ -69,18 +69,30 @@ func (h *AuditHandlers) HandleListAuditEvents(w http.ResponseWriter, r *http.Req
filter.Success = &success
}
logger := audit.GetLogger()
// Query events from the current logger
events, err := audit.GetLogger().Query(filter)
events, err := logger.Query(filter)
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "query_failed", "Failed to query audit events", nil)
return
}
countFilter := filter
countFilter.Limit = 0
countFilter.Offset = 0
totalCount, err := logger.Count(countFilter)
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, "query_failed", "Failed to count audit events", nil)
return
}
// For OSS (ConsoleLogger), events will be empty
// Return a response indicating the feature status
response := map[string]interface{}{
"events": events,
"total": len(events),
"total": totalCount,
"persistentLogging": len(events) > 0 || isPersistentLogger(),
}

View file

@ -0,0 +1,209 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/rcourtman/pulse-go-rewrite/pkg/audit"
)
type verifyResponse struct {
Available bool `json:"available"`
Verified bool `json:"verified"`
Message string `json:"message"`
}
type testAuditLogger struct {
events []audit.Event
verifyResult bool
queryErr error
}
func (l *testAuditLogger) Log(event audit.Event) error {
l.events = append(l.events, event)
return nil
}
func (l *testAuditLogger) Query(filter audit.QueryFilter) ([]audit.Event, error) {
if l.queryErr != nil {
return nil, l.queryErr
}
if filter.ID != "" {
for _, event := range l.events {
if event.ID == filter.ID {
return []audit.Event{event}, nil
}
}
return []audit.Event{}, nil
}
return l.events, nil
}
func (l *testAuditLogger) Count(filter audit.QueryFilter) (int, error) {
events, err := l.Query(filter)
if err != nil {
return 0, err
}
return len(events), nil
}
func (l *testAuditLogger) Close() error {
return nil
}
func (l *testAuditLogger) VerifySignature(event audit.Event) bool {
return l.verifyResult
}
type testAuditLoggerNoVerify struct {
events []audit.Event
}
func (l *testAuditLoggerNoVerify) Log(event audit.Event) error {
l.events = append(l.events, event)
return nil
}
func (l *testAuditLoggerNoVerify) Query(filter audit.QueryFilter) ([]audit.Event, error) {
return l.events, nil
}
func (l *testAuditLoggerNoVerify) Count(filter audit.QueryFilter) (int, error) {
return len(l.events), nil
}
func (l *testAuditLoggerNoVerify) Close() error {
return nil
}
func setAuditLogger(t *testing.T, logger audit.Logger) {
prev := audit.GetLogger()
audit.SetLogger(logger)
t.Cleanup(func() {
audit.SetLogger(prev)
})
}
func TestHandleVerifyAuditEvent_InvalidPath(t *testing.T) {
handler := NewAuditHandlers()
req := httptest.NewRequest(http.MethodGet, "/api/audit/verify", nil)
rec := httptest.NewRecorder()
handler.HandleVerifyAuditEvent(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected status %d, got %d", http.StatusNotFound, rec.Code)
}
}
func TestHandleVerifyAuditEvent_NotPersistent(t *testing.T) {
setAuditLogger(t, audit.NewConsoleLogger())
handler := NewAuditHandlers()
req := httptest.NewRequest(http.MethodGet, "/api/audit/abc/verify", nil)
rec := httptest.NewRecorder()
handler.HandleVerifyAuditEvent(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
var resp verifyResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if resp.Available {
t.Fatalf("expected available to be false")
}
if resp.Message == "" {
t.Fatalf("expected message to be set")
}
}
func TestHandleVerifyAuditEvent_NoVerifier(t *testing.T) {
setAuditLogger(t, &testAuditLoggerNoVerify{})
handler := NewAuditHandlers()
req := httptest.NewRequest(http.MethodGet, "/api/audit/abc/verify", nil)
rec := httptest.NewRecorder()
handler.HandleVerifyAuditEvent(rec, req)
if rec.Code != http.StatusNotImplemented {
t.Fatalf("expected status %d, got %d", http.StatusNotImplemented, rec.Code)
}
}
func TestHandleVerifyAuditEvent_NotFound(t *testing.T) {
setAuditLogger(t, &testAuditLogger{})
handler := NewAuditHandlers()
req := httptest.NewRequest(http.MethodGet, "/api/audit/abc/verify", nil)
rec := httptest.NewRecorder()
handler.HandleVerifyAuditEvent(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("expected status %d, got %d", http.StatusNotFound, rec.Code)
}
}
func TestHandleVerifyAuditEvent_Verified(t *testing.T) {
setAuditLogger(t, &testAuditLogger{
events: []audit.Event{{ID: "abc"}},
verifyResult: true,
})
handler := NewAuditHandlers()
req := httptest.NewRequest(http.MethodGet, "/api/audit/abc/verify", nil)
rec := httptest.NewRecorder()
handler.HandleVerifyAuditEvent(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
var resp verifyResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if !resp.Available {
t.Fatalf("expected available to be true")
}
if !resp.Verified {
t.Fatalf("expected verified to be true")
}
}
func TestHandleVerifyAuditEvent_Failed(t *testing.T) {
setAuditLogger(t, &testAuditLogger{
events: []audit.Event{{ID: "abc"}},
verifyResult: false,
})
handler := NewAuditHandlers()
req := httptest.NewRequest(http.MethodGet, "/api/audit/abc/verify", nil)
rec := httptest.NewRecorder()
handler.HandleVerifyAuditEvent(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code)
}
var resp verifyResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if !resp.Available {
t.Fatalf("expected available to be true")
}
if resp.Verified {
t.Fatalf("expected verified to be false")
}
}

View file

@ -51,6 +51,9 @@ type Logger interface {
// Query retrieves audit events matching the filter (optional, may return empty for console logger)
Query(filter QueryFilter) ([]Event, error)
// Count returns the number of audit events matching the filter
Count(filter QueryFilter) (int, error)
// Close releases any resources held by the logger
Close() error
}
@ -150,6 +153,11 @@ func (c *ConsoleLogger) Query(filter QueryFilter) ([]Event, error) {
return []Event{}, nil
}
// Count returns zero for the console logger.
func (c *ConsoleLogger) Count(filter QueryFilter) (int, error) {
return 0, nil
}
// Close is a no-op for the console logger.
func (c *ConsoleLogger) Close() error {
return nil

View file

@ -58,6 +58,19 @@ func TestConsoleLogger_Query(t *testing.T) {
}
}
func TestConsoleLogger_Count(t *testing.T) {
logger := NewConsoleLogger()
count, err := logger.Count(QueryFilter{})
if err != nil {
t.Errorf("ConsoleLogger.Count() returned error: %v", err)
}
if count != 0 {
t.Errorf("ConsoleLogger.Count() should return 0, got %d", count)
}
}
func TestConsoleLogger_Close(t *testing.T) {
logger := NewConsoleLogger()