diff --git a/docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-audit-log-store-resilience-2026-05-28.md b/docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-audit-log-store-resilience-2026-05-28.md new file mode 100644 index 000000000..44a23ce04 --- /dev/null +++ b/docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-audit-log-store-resilience-2026-05-28.md @@ -0,0 +1,61 @@ +# Known RC Issue Closure For GA Audit Log Store Resilience Record + +- Date: `2026-05-28` +- Gate: `known-rc-issue-closure-for-ga` +- Lanes: `L6`, `L8`, `L14` +- Issue: `https://github.com/rcourtman/Pulse/issues/1464` +- Result: `fixed-local-proof` + +## Context + +Issue `#1464` reports Pulse v6.0.0-rc.4 returning `Failed to fetch audit +events: Internal Server Error` when the operator opens Settings > Security > +Audit Log. The original server logs did not include the underlying audit query +error, and the follow-up asked for an rc5 retest to capture the exact cause. + +Even without that rc5 log line, the v6 audit path had clear canonical gaps: +audit SQLite busy/locked failures were not retried or classified, missing or +corrupt audit-store failures collapsed into generic 500 responses, the list +endpoint accepted unbounded limits, and the frontend surfaced raw internal +server errors instead of operator-facing audit-log recovery guidance. + +## Disposition + +The v6 audit-log path now treats audit storage as a first-class runtime +boundary: + +- SQLite audit reads, writes, schema initialization, and webhook config writes + retry transient busy/locked failures before surfacing an error. +- The audit package classifies transient store-busy errors separately from + unavailable, corrupt, missing, readonly, or uninitialized audit stores. +- Audit list and verification endpoints return structured `503` responses with + `audit_store_busy` or `audit_store_unavailable` codes instead of generic + `query_failed` 500s for those store conditions. +- Audit list pagination defaults to 100 rows and clamps oversized limits to + 1000 rows. +- Persistent audit logger detection unwraps `AsyncLogger`, so an async console + backend is no longer treated as queryable audit storage. +- The Audit Log settings panel maps structured audit fetch failures to + operator-facing recovery copy instead of displaying raw internal server + errors. + +This does not prove the reporter's rc4 instance hit one exact SQLite failure +mode, because the rc5 query-error log line has not been provided. It does fix +the canonical resilience and presentation gaps that could turn audit-store +pressure or initialization failures into the reported generic 500 experience. + +## Proof + +- `go test ./pkg/audit ./internal/api` +- `npm --prefix frontend-modern test -- --run src/utils/__tests__/auditLogPresentation.test.ts` +- `npm --prefix frontend-modern test -- --run src/components/Settings/__tests__/settingsArchitecture.test.ts` +- `npm --prefix frontend-modern run type-check` +- Browser inspection of `http://127.0.0.1:5173/settings/security-audit` + +## Outcome + +The v6 code path for generic Audit Log internal-server failures is hardened +with local automated proof and browser inspection. No public GitHub comment, +issue retitle, label change, or issue closure was made during this work. +Public issue closure should wait for normal maintainer issue hygiene or a +current rc retest if exact reporter-environment confirmation is required. diff --git a/docs/release-control/v6/internal/status.json b/docs/release-control/v6/internal/status.json index 253c71560..29de02ee9 100644 --- a/docs/release-control/v6/internal/status.json +++ b/docs/release-control/v6/internal/status.json @@ -5940,6 +5940,12 @@ "kind": "file", "evidence_tier": "managed-runtime-exercise" }, + { + "repo": "pulse", + "path": "docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-audit-log-store-resilience-2026-05-28.md", + "kind": "file", + "evidence_tier": "test-proof" + }, { "repo": "pulse", "path": "docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-authorized-keys-symlink-2026-05-01.md", diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index a7b11764f..aa465e34c 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -1566,6 +1566,10 @@ peer infrastructure roots. Those unified audit list endpoints also clamp oversized `limit` requests to the governed maximum, so audit history stays bounded even when callers ask for arbitrarily large pages. +The adjacent enterprise audit-log read path now also preserves structured +store-failure codes (`audit_store_busy`, `audit_store_unavailable`) instead of +generic 500s; lifecycle surfaces may share that API layer, but they do not own +or reinterpret audit-store health. That same shared `internal/api/` dependency also now assumes hosted runtime websocket upgrades trust the cloud proxy only through explicit tenant `PULSE_TRUSTED_PROXY_CIDRS` wiring, so first-session handoff and agent-facing diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 4ff6b0a34..3894808d6 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -557,6 +557,13 @@ the canonical monitored-system blocked payload. resource API JSON, and exercised with backend contract tests plus the canonical `useUnifiedResources` frontend hook proof whenever it changes. 5. Route unified-resource action, lifecycle, and export audit reads through `internal/api/activity_audit_handlers.go`, `internal/api/router_routes_licensing.go`, and `internal/api/contract_test.go` together so the control-plane execution trail stays on a governed API contract instead of a store-only shape + Enterprise audit-log reads are part of that same API boundary. Audit list + and verification handlers must preserve structured storage failure + semantics from `pkg/audit`: transient store pressure returns `503` + `audit_store_busy` with `Retry-After`, unavailable or corrupt audit storage + returns `503` `audit_store_unavailable`, and unrelated query failures remain + `query_failed`. List pagination must stay bounded so audit history reads + cannot become unbounded table scans through API parameters. Plan-only unified action planning is part of that same API-first action contract: `POST /api/actions/plan` must route through `internal/api/actions.go`, `internal/actionplanner/planner.go`, and diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index fdd0a4399..33c376efa 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -2345,6 +2345,11 @@ When the shared runtime-capabilities store reports `paid_runtime_required` for and the panel must render paid-runtime-required copy with the private Pulse Pro download action. The runtime mismatch is not a plan upsell and must not be hidden by ordinary missing-feature navigation filtering. +Audit-log fetch failures must preserve the structured backend error object +through `apiErrorFromResponse` and render customer-facing copy from +`frontend-modern/src/utils/auditLogPresentation.ts`; the settings shell may +own refresh and pagination state, but it must not show raw `Internal Server +Error` strings or unbounded page sizes as local hook behavior. That shared filter-option primitive is also the canonical owner for default `All ` option wording wherever a product surface exposes filter selects or segmented filter choices. Workloads filters, storage source diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index 6e62c4450..7d92c4beb 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -4565,7 +4565,9 @@ "id": "security-privacy", "lane": "L14", "contract": "docs/release-control/v6/internal/subsystems/security-privacy.md", - "owned_prefixes": [], + "owned_prefixes": [ + "pkg/audit/" + ], "owned_files": [ "docs/PRIVACY.md", "frontend-modern/src/api/security.ts", @@ -4598,6 +4600,9 @@ "internal/crypto/crypto.go", "internal/securityutil/secure_storage_dir.go", "internal/telemetry/telemetry.go", + "pkg/audit/async_logger.go", + "pkg/audit/audit.go", + "pkg/audit/sqlite_logger.go", "pkg/tlsutil/fingerprint.go", "scripts/telemetry_adoption_report.py", "SECURITY.md" @@ -4711,6 +4716,28 @@ "frontend-modern/src/utils/__tests__/apiTokenPresentation.test.ts" ] }, + { + "id": "audit-log-runtime-store", + "label": "audit log runtime store proof", + "match_prefixes": [ + "pkg/audit/" + ], + "match_files": [], + "allow_same_subsystem_tests": true, + "test_prefixes": [], + "exact_files": [ + "internal/api/audit_handlers_test.go", + "pkg/audit/audit_test.go", + "pkg/audit/export_test.go", + "pkg/audit/signer_test.go", + "pkg/audit/sqlite_factory_test.go", + "pkg/audit/sqlite_logger_queryplan_test.go", + "pkg/audit/sqlite_logger_test.go", + "pkg/audit/tenant_logger_manager_test.go", + "pkg/audit/webhook_delivery_test.go", + "pkg/audit/webhook_validation_test.go" + ] + }, { "id": "security-runtime-auth-config-and-tls", "label": "security runtime auth config and TLS proof", diff --git a/docs/release-control/v6/internal/subsystems/security-privacy.md b/docs/release-control/v6/internal/subsystems/security-privacy.md index e2b3e7c09..63c40ca2b 100644 --- a/docs/release-control/v6/internal/subsystems/security-privacy.md +++ b/docs/release-control/v6/internal/subsystems/security-privacy.md @@ -60,10 +60,13 @@ controls as normal product settings. 32. `internal/cloudcp/auth/magiclink.go` 33. `internal/cloudcp/auth/magiclink_store.go` 34. `pkg/tlsutil/fingerprint.go` -35. `scripts/telemetry_adoption_report.py` -36. `frontend-modern/src/components/Settings/DataHandlingPanel.tsx` -37. `frontend-modern/src/components/Settings/dataHandlingPanelModel.ts` -38. `internal/api/agent_exec_token_binding.go` +35. `pkg/audit/audit.go` +36. `pkg/audit/async_logger.go` +37. `pkg/audit/sqlite_logger.go` +38. `scripts/telemetry_adoption_report.py` +39. `frontend-modern/src/components/Settings/DataHandlingPanel.tsx` +40. `frontend-modern/src/components/Settings/dataHandlingPanelModel.ts` +41. `internal/api/agent_exec_token_binding.go` ## Shared Boundaries @@ -296,6 +299,14 @@ failure. If `runtime-capabilities` blocks `audit_logging` with active Pro license needs the private Pulse Pro runtime, but they must not expose license keys, billing identity, or plan-upgrade copy as part of that security/privacy feature gate. +Audit-log storage availability is also a security/privacy trust boundary. +The `pkg/audit/` runtime package owns persistent audit-store classification: +transient SQLite busy/locked conditions must be retried and surfaced as +structured `audit_store_busy`, while missing, corrupt, readonly, or +uninitialized audit stores must surface as `audit_store_unavailable`. The +Audit Log settings surface may translate those stable API codes into recovery +copy, but it must not show raw internal server errors or collapse audit-store +state into a generic frontend failure. That shared token-management boundary now also includes `frontend-modern/src/utils/apiTokenPresentation.ts`, so API-token load, generate, and revoke errors stay on one governed customer-facing wording path diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 950b23c6e..90af64324 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -1847,6 +1847,11 @@ changes. Those unified audit list endpoints also clamp oversized `limit` requests to the governed maximum, so adjacent recovery and storage workflows do not turn bounded history reads into unbounded collection scans. +The adjacent enterprise audit-log read path now also preserves structured +store-failure codes (`audit_store_busy`, `audit_store_unavailable`) instead of +generic 500s. Storage and recovery surfaces may coexist with that API layer, +but they must not reinterpret audit-store health as backup, restore, or +recovery-state evidence. The same shared API runtime now also exposes dedicated `/api/resources/{id}/timeline` reads plus the bundled `/api/resources/{id}/facets` surface, but storage and recovery must continue diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index 73c62402e..2aaa0601a 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -445,6 +445,16 @@ describe('settings architecture guardrails', () => { ); }); + it('keeps audit-log fetch errors and page-size normalization on shared owners', () => { + expect(auditLogStateSource).toContain('apiErrorFromResponse'); + expect(auditLogStateSource).toContain('getAuditLogFetchErrorMessage'); + expect(auditLogStateSource).toContain('normalizeAuditPageSize'); + expect(auditLogStateSource).toContain('ALLOWED_AUDIT_PAGE_SIZES'); + expect(auditLogPresentationSource).toContain("code === 'audit_store_busy'"); + expect(auditLogPresentationSource).toContain("code === 'audit_store_unavailable'"); + expect(auditLogPresentationSource).toContain("code === 'query_failed'"); + }); + it('keeps API token Docker and Podman copy on shared presentation helpers', () => { expect(apiAccessPanelSource).toContain('API_TOKEN_ACCESS_PANEL_DESCRIPTION'); expect(apiTokenManagerModelSource).toContain('API_TOKEN_DOCKER_REPORT_PRESET_DESCRIPTION'); diff --git a/frontend-modern/src/components/Settings/useAuditLogPanelState.ts b/frontend-modern/src/components/Settings/useAuditLogPanelState.ts index 949371b29..9b2f6d123 100644 --- a/frontend-modern/src/components/Settings/useAuditLogPanelState.ts +++ b/frontend-modern/src/components/Settings/useAuditLogPanelState.ts @@ -6,13 +6,14 @@ import { createLocalStorageNumberSignal, STORAGE_KEYS, } from '@/utils/localStorage'; -import { apiFetch } from '@/utils/apiClient'; +import { apiErrorFromResponse, apiFetch } from '@/utils/apiClient'; import { showSuccess, showToast, showWarning } from '@/utils/toast'; import { hasFeature, runtimeCapabilitiesLoaded } from '@/stores/license'; import { getUpgradeActionDestination } from '@/stores/licenseCommercial'; import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentationPolicy'; import { getRuntimeCapabilityBlock, loadRuntimeCapabilities } from '@/stores/license'; import { resolveUpgradeDestination } from '@/utils/upgradeNavigation'; +import { getAuditLogFetchErrorMessage } from '@/utils/auditLogPresentation'; export interface AuditEvent { id: string; @@ -62,6 +63,8 @@ const ALLOWED_VERIFICATION_FILTERS = new Set(['all', 'needs', 'verified', 'faile const ALLOWED_SUCCESS_FILTERS = new Set(['all', 'success', 'failed']); const ALLOWED_EVENT_FILTERS = new Set(['', 'login', 'logout', 'config_change', 'startup']); const USER_FILTER_DEBOUNCE_MS = 300; +const DEFAULT_AUDIT_PAGE_SIZE = 100; +const ALLOWED_AUDIT_PAGE_SIZES = new Set([25, 50, 100, 200]); const parseEventFilter = (raw: string | null | undefined): string => ALLOWED_EVENT_FILTERS.has(raw ?? '') ? (raw ?? '') : ''; @@ -69,6 +72,8 @@ const parseSuccessFilter = (raw: string | null | undefined): string => ALLOWED_SUCCESS_FILTERS.has(raw ?? '') ? (raw ?? 'all') : 'all'; const parseVerificationFilter = (raw: string | null | undefined): string => ALLOWED_VERIFICATION_FILTERS.has(raw ?? '') ? (raw ?? 'all') : 'all'; +const normalizeAuditPageSize = (value: number): number => + ALLOWED_AUDIT_PAGE_SIZES.has(value) ? value : DEFAULT_AUDIT_PAGE_SIZE; export const useAuditLogPanelState = () => { const location = useLocation(); @@ -94,8 +99,7 @@ export const useAuditLogPanelState = () => { else params.set('event', value); }); }; - const userFilter: Accessor = () => - new URLSearchParams(location.search).get('user') ?? ''; + const userFilter: Accessor = () => new URLSearchParams(location.search).get('user') ?? ''; const setUserFilter = (value: string): void => { updateSearchParam((params) => { if (value === '') params.delete('user'); @@ -118,7 +122,14 @@ export const useAuditLogPanelState = () => { else params.set('verification', value); }); }; - const [pageSize, setPageSize] = createLocalStorageNumberSignal(STORAGE_KEYS.AUDIT_PAGE_SIZE, 100); + const [storedPageSize, setStoredPageSize] = createLocalStorageNumberSignal( + STORAGE_KEYS.AUDIT_PAGE_SIZE, + DEFAULT_AUDIT_PAGE_SIZE, + ); + const pageSize = () => normalizeAuditPageSize(storedPageSize()); + const setPageSize = (value: number): void => { + setStoredPageSize(normalizeAuditPageSize(value)); + }; const [pageOffset, setPageOffset] = createLocalStorageNumberSignal( STORAGE_KEYS.AUDIT_PAGE_OFFSET, 0, @@ -202,7 +213,10 @@ export const useAuditLogPanelState = () => { return; } if (!response.ok) { - throw new Error(`Failed to fetch audit events: ${response.statusText}`); + throw await apiErrorFromResponse( + response, + `Failed to fetch audit events: ${response.statusText}`, + ); } const data: AuditResponse = await response.json(); @@ -228,7 +242,7 @@ export const useAuditLogPanelState = () => { }, 0); } } catch (err) { - const message = err instanceof Error ? err.message : 'Unknown error'; + const message = getAuditLogFetchErrorMessage(err); if (typeof message === 'string' && /feature not included in license/i.test(message)) { setEvents([]); setTotalEvents(0); diff --git a/frontend-modern/src/utils/__tests__/auditLogPresentation.test.ts b/frontend-modern/src/utils/__tests__/auditLogPresentation.test.ts index 13608d916..a15380cc7 100644 --- a/frontend-modern/src/utils/__tests__/auditLogPresentation.test.ts +++ b/frontend-modern/src/utils/__tests__/auditLogPresentation.test.ts @@ -12,6 +12,7 @@ import { AUDIT_VERIFICATION_FILTER_NEEDS_LABEL, getAuditEventStatusPresentation, getAuditEventTypeBadgeClass, + getAuditLogFetchErrorMessage, getAuditLogFeatureGateCopy, getAuditVerificationBadgePresentation, } from '@/utils/auditLogPresentation'; @@ -55,6 +56,19 @@ describe('auditLogPresentation', () => { }); }); + it('maps structured audit fetch errors to operator-facing copy', () => { + expect(getAuditLogFetchErrorMessage({ code: 'audit_store_busy' })).toContain('storage is busy'); + expect(getAuditLogFetchErrorMessage({ code: 'audit_store_unavailable' })).toContain( + 'storage is unavailable', + ); + expect(getAuditLogFetchErrorMessage({ code: 'query_failed' })).toContain('audit query failure'); + expect( + getAuditLogFetchErrorMessage( + new Error('Failed to fetch audit events: Internal Server Error'), + ), + ).toContain('internal error'); + }); + it('exposes canonical audit action button classes', () => { expect(AUDIT_TOOLBAR_BUTTON_CLASS).toContain('border border-border'); expect(AUDIT_REFRESH_BUTTON_CLASS).toContain('text-base-content'); diff --git a/frontend-modern/src/utils/auditLogPresentation.ts b/frontend-modern/src/utils/auditLogPresentation.ts index 36768e26e..2acdcaa41 100644 --- a/frontend-modern/src/utils/auditLogPresentation.ts +++ b/frontend-modern/src/utils/auditLogPresentation.ts @@ -24,6 +24,12 @@ export interface AuditLogFeatureGateCopyOptions { paidRuntimeRequired?: boolean; } +type AuditLogFetchErrorLike = { + code?: string; + message?: string; + status?: number; +}; + export const AUDIT_TOOLBAR_BUTTON_CLASS = 'flex min-h-10 sm:min-h-10 items-center gap-2 px-3 py-2 text-sm font-medium bg-surface border border-border rounded-md hover:bg-surface-hover disabled:opacity-50'; export const AUDIT_REFRESH_BUTTON_CLASS = `${AUDIT_TOOLBAR_BUTTON_CLASS} text-base-content`; @@ -99,6 +105,26 @@ export function getAuditLogLoadingState() { } as const; } +export function getAuditLogFetchErrorMessage(error: unknown): string { + const errorLike = error as AuditLogFetchErrorLike; + if (errorLike?.code === 'audit_store_busy') { + return 'Audit log storage is busy. Wait a moment, then refresh the audit log.'; + } + if (errorLike?.code === 'audit_store_unavailable') { + return 'Audit log storage is unavailable. Check the server logs for audit database initialization or disk errors, then refresh the audit log.'; + } + if (errorLike?.code === 'query_failed') { + return 'Audit events could not be loaded. Check the server logs for the audit query failure, then refresh the audit log.'; + } + if ( + errorLike?.status === 500 || + /failed to fetch audit events:\s*internal server error/i.test(errorLike?.message ?? '') + ) { + return 'Audit events could not be loaded because the server returned an internal error. Check the server logs, then refresh the audit log.'; + } + return error instanceof Error ? error.message : 'Unknown error'; +} + export function getAuditLogFeatureGateCopy(options: AuditLogFeatureGateCopyOptions = {}) { if (options.paidRuntimeRequired) { return { diff --git a/internal/api/activity_audit_handlers.go b/internal/api/activity_audit_handlers.go index c6e0c9da6..4ec9647d9 100644 --- a/internal/api/activity_audit_handlers.go +++ b/internal/api/activity_audit_handlers.go @@ -23,6 +23,9 @@ var validAuditEventID = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) var resolveWebhookIPs = net.DefaultResolver.LookupIPAddr const maxUnifiedAuditLimit = 1000 +const defaultAuditEventListLimit = 100 +const maxAuditEventListLimit = 1000 +const auditStoreBusyRetryAfterSeconds = 2 // AuditHandlers provides HTTP handlers for audit log endpoints. type AuditHandlers struct { @@ -58,6 +61,11 @@ func (h *AuditHandlers) HandleListAuditEvents(w http.ResponseWriter, r *http.Req orgID := GetOrgID(r.Context()) logger := getLoggerForOrg(orgID) + if !isPersistentLogger(logger) { + log.Error().Str("org_id", orgID).Msg("audit list: persistent audit logger unavailable") + writeAuditStoreUnavailableResponse(w) + return + } query := r.URL.Query() @@ -66,13 +74,11 @@ func (h *AuditHandlers) HandleListAuditEvents(w http.ResponseWriter, r *http.Req User: query.Get("user"), } - // Parse limit + filter.Limit = defaultAuditEventListLimit if limitStr := query.Get("limit"); limitStr != "" { if limit, err := strconv.Atoi(limitStr); err == nil && limit > 0 { - filter.Limit = limit + filter.Limit = min(limit, maxAuditEventListLimit) } - } else { - filter.Limit = 100 // Default limit } // Parse offset @@ -106,7 +112,7 @@ func (h *AuditHandlers) HandleListAuditEvents(w http.ResponseWriter, r *http.Req events, err := logger.Query(filter) if err != nil { log.Error().Err(err).Str("org_id", orgID).Msg("audit list: query failed") - writeErrorResponse(w, http.StatusInternalServerError, "query_failed", "Failed to query audit events", nil) + writeAuditReadErrorResponse(w, err, "Failed to query audit events") return } @@ -117,16 +123,14 @@ func (h *AuditHandlers) HandleListAuditEvents(w http.ResponseWriter, r *http.Req totalCount, err := logger.Count(countFilter) if err != nil { log.Error().Err(err).Str("org_id", orgID).Msg("audit list: count failed") - writeErrorResponse(w, http.StatusInternalServerError, "query_failed", "Failed to count audit events", nil) + writeAuditReadErrorResponse(w, err, "Failed to count audit events") return } - // For OSS (ConsoleLogger), events will be empty - // Return a response indicating the feature status response := map[string]interface{}{ "events": events, "total": totalCount, - "persistentLogging": len(events) > 0 || isPersistentLogger(logger), + "persistentLogging": true, } w.Header().Set("Content-Type", "application/json") @@ -178,7 +182,8 @@ func (h *AuditHandlers) HandleVerifyAuditEvent(w http.ResponseWriter, r *http.Re Limit: 1, }) if err != nil { - writeErrorResponse(w, http.StatusInternalServerError, "query_failed", "Failed to query audit event", nil) + log.Error().Err(err).Str("org_id", orgID).Str("audit_id", eventID).Msg("audit verify: query failed") + writeAuditReadErrorResponse(w, err, "Failed to query audit event") return } if len(events) == 0 { @@ -353,8 +358,26 @@ func isPrivateOrReservedIP(ip net.IP) bool { // isPersistentLogger checks if we're using a persistent audit logger (enterprise). func isPersistentLogger(logger audit.Logger) bool { - _, isConsole := logger.(*audit.ConsoleLogger) - return !isConsole + return audit.IsPersistentLogger(logger) +} + +func writeAuditReadErrorResponse(w http.ResponseWriter, err error, fallbackMessage string) { + if audit.IsStoreBusyError(err) { + w.Header().Set("Retry-After", strconv.Itoa(auditStoreBusyRetryAfterSeconds)) + writeErrorResponse(w, http.StatusServiceUnavailable, "audit_store_busy", + "Audit log storage is temporarily busy. Try again shortly.", nil) + return + } + if audit.IsStoreUnavailableError(err) { + writeAuditStoreUnavailableResponse(w) + return + } + writeErrorResponse(w, http.StatusInternalServerError, "query_failed", fallbackMessage, nil) +} + +func writeAuditStoreUnavailableResponse(w http.ResponseWriter) { + writeErrorResponse(w, http.StatusServiceUnavailable, "audit_store_unavailable", + "Audit log storage is unavailable. Check server logs for audit database initialization or disk errors.", nil) } // HandleExportAuditEvents handles GET /api/audit/export diff --git a/internal/api/audit_handlers_test.go b/internal/api/audit_handlers_test.go index 810e9ae21..ab193c511 100644 --- a/internal/api/audit_handlers_test.go +++ b/internal/api/audit_handlers_test.go @@ -28,6 +28,8 @@ type testAuditLogger struct { queryErr error updateErr error countErr error + lastQuery audit.QueryFilter + lastCount audit.QueryFilter } func (l *testAuditLogger) Log(event audit.Event) error { @@ -36,6 +38,7 @@ func (l *testAuditLogger) Log(event audit.Event) error { } func (l *testAuditLogger) Query(filter audit.QueryFilter) ([]audit.Event, error) { + l.lastQuery = filter if l.queryErr != nil { return nil, l.queryErr } @@ -51,14 +54,11 @@ func (l *testAuditLogger) Query(filter audit.QueryFilter) ([]audit.Event, error) } func (l *testAuditLogger) Count(filter audit.QueryFilter) (int, error) { + l.lastCount = filter if l.countErr != nil { return 0, l.countErr } - events, err := l.Query(filter) - if err != nil { - return 0, err - } - return len(events), nil + return len(l.events), nil } func (l *testAuditLogger) Close() error { @@ -525,6 +525,45 @@ func TestHandleListAuditEvents_Filters(t *testing.T) { } } +func TestHandleListAuditEvents_PaginationDefaults(t *testing.T) { + logger := &testAuditLogger{ + events: []audit.Event{{ID: "1", EventType: "login", Success: true}}, + } + setAuditLogger(t, logger) + handler := NewAuditHandlers() + + req := httptest.NewRequest(http.MethodGet, "/api/audit?limit=0&offset=-4", nil) + rec := httptest.NewRecorder() + handler.HandleListAuditEvents(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + if logger.lastQuery.Limit != defaultAuditEventListLimit { + t.Fatalf("limit = %d, want default %d", logger.lastQuery.Limit, defaultAuditEventListLimit) + } + if logger.lastQuery.Offset != 0 { + t.Fatalf("offset = %d, want 0", logger.lastQuery.Offset) + } + + req = httptest.NewRequest(http.MethodGet, "/api/audit?limit=5000&offset=10", nil) + rec = httptest.NewRecorder() + handler.HandleListAuditEvents(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rec.Code) + } + if logger.lastQuery.Limit != maxAuditEventListLimit { + t.Fatalf("limit = %d, want max %d", logger.lastQuery.Limit, maxAuditEventListLimit) + } + if logger.lastQuery.Offset != 10 { + t.Fatalf("offset = %d, want 10", logger.lastQuery.Offset) + } + if logger.lastCount.Limit != 0 || logger.lastCount.Offset != 0 { + t.Fatalf("count filter must clear pagination, got limit=%d offset=%d", logger.lastCount.Limit, logger.lastCount.Offset) + } +} + func TestHandleListAuditEvents_QueryError(t *testing.T) { setAuditLogger(t, &testAuditLogger{ queryErr: fmt.Errorf("db error"), @@ -550,6 +589,80 @@ func TestHandleListAuditEvents_QueryError(t *testing.T) { t.Errorf("expected count error status %d, got %d", http.StatusInternalServerError, rec.Code) } } + +func TestHandleListAuditEvents_StoreBusyError(t *testing.T) { + setAuditLogger(t, &testAuditLogger{ + queryErr: fmt.Errorf("database is locked (5) (SQLITE_BUSY)"), + }) + handler := NewAuditHandlers() + + req := httptest.NewRequest(http.MethodGet, "/api/audit", nil) + rec := httptest.NewRecorder() + handler.HandleListAuditEvents(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected status %d, got %d", http.StatusServiceUnavailable, rec.Code) + } + if rec.Header().Get("Retry-After") == "" { + t.Fatal("expected Retry-After header") + } + + var resp APIError + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if resp.Code != "audit_store_busy" { + t.Fatalf("code = %q, want audit_store_busy", resp.Code) + } +} + +func TestHandleListAuditEvents_StoreUnavailableError(t *testing.T) { + setAuditLogger(t, &testAuditLogger{ + queryErr: fmt.Errorf("no such table: audit_events"), + }) + handler := NewAuditHandlers() + + req := httptest.NewRequest(http.MethodGet, "/api/audit", nil) + rec := httptest.NewRecorder() + handler.HandleListAuditEvents(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected status %d, got %d", http.StatusServiceUnavailable, rec.Code) + } + + var resp APIError + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if resp.Code != "audit_store_unavailable" { + t.Fatalf("code = %q, want audit_store_unavailable", resp.Code) + } +} + +func TestHandleListAuditEvents_NonPersistentLoggerUnavailable(t *testing.T) { + asyncLogger := audit.NewAsyncLogger(audit.NewConsoleLogger(), audit.AsyncLoggerConfig{BufferSize: 1}) + t.Cleanup(func() { + _ = asyncLogger.Close() + }) + setAuditLogger(t, asyncLogger) + handler := NewAuditHandlers() + + req := httptest.NewRequest(http.MethodGet, "/api/audit", nil) + rec := httptest.NewRecorder() + handler.HandleListAuditEvents(rec, req) + + if rec.Code != http.StatusServiceUnavailable { + t.Fatalf("expected status %d, got %d", http.StatusServiceUnavailable, rec.Code) + } + + var resp APIError + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + if resp.Code != "audit_store_unavailable" { + t.Fatalf("code = %q, want audit_store_unavailable", resp.Code) + } +} func TestHandleExportAuditEvents(t *testing.T) { oldLogger := audit.GetLogger() defer audit.SetLogger(oldLogger) diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index a883ae78f..bf9aa8183 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -45,6 +45,7 @@ import ( "github.com/rcourtman/pulse-go-rewrite/internal/vmware" agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host" "github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts" + "github.com/rcourtman/pulse-go-rewrite/pkg/audit" authpkg "github.com/rcourtman/pulse-go-rewrite/pkg/auth" "github.com/rcourtman/pulse-go-rewrite/pkg/cloudauth" pkglicensing "github.com/rcourtman/pulse-go-rewrite/pkg/licensing" @@ -13328,6 +13329,69 @@ func TestContract_UnifiedAuditLimitCapsOversizedRequests(t *testing.T) { } } +func TestContract_AuditStoreReadErrorsUseStructuredAPIErrorCodes(t *testing.T) { + cases := []struct { + name string + err error + wantStatus int + wantCode string + wantRetryAfter bool + }{ + { + name: "busy", + err: fmt.Errorf("failed to query audit events: %w", fmt.Errorf("database is locked (5) (SQLITE_BUSY)")), + wantStatus: http.StatusServiceUnavailable, + wantCode: "audit_store_busy", + wantRetryAfter: true, + }, + { + name: "unavailable", + err: fmt.Errorf("failed to query audit events: %w", fmt.Errorf("no such table: audit_events")), + wantStatus: http.StatusServiceUnavailable, + wantCode: "audit_store_unavailable", + }, + { + name: "query_failed", + err: fmt.Errorf("failed to query audit events: %w", fmt.Errorf("unexpected query failure")), + wantStatus: http.StatusInternalServerError, + wantCode: "query_failed", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if tc.wantCode == "audit_store_busy" && !audit.IsStoreBusyError(tc.err) { + t.Fatal("busy fixture must satisfy audit store busy classification") + } + if tc.wantCode == "audit_store_unavailable" && !audit.IsStoreUnavailableError(tc.err) { + t.Fatal("unavailable fixture must satisfy audit store unavailable classification") + } + + rec := httptest.NewRecorder() + writeAuditReadErrorResponse(rec, tc.err, "Failed to query audit events") + if rec.Code != tc.wantStatus { + t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus) + } + if got := rec.Header().Get("Retry-After"); tc.wantRetryAfter && got == "" { + t.Fatal("busy audit-store errors must include Retry-After") + } else if !tc.wantRetryAfter && got != "" { + t.Fatalf("Retry-After = %q, want empty", got) + } + + var payload APIError + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode APIError: %v", err) + } + if payload.Code != tc.wantCode { + t.Fatalf("code = %q, want %q", payload.Code, tc.wantCode) + } + if payload.StatusCode != tc.wantStatus { + t.Fatalf("status_code = %d, want %d", payload.StatusCode, tc.wantStatus) + } + }) + } +} + func TestContract_EmbeddedFrontendWarningUsesCanonicalDevEntrypoints(t *testing.T) { path := filepath.Join("DO_NOT_EDIT_FRONTEND_HERE.md") body, err := os.ReadFile(path) diff --git a/pkg/audit/async_logger.go b/pkg/audit/async_logger.go index ca2a58625..391dde7b6 100644 --- a/pkg/audit/async_logger.go +++ b/pkg/audit/async_logger.go @@ -81,6 +81,14 @@ func (l *AsyncLogger) UpdateWebhookURLs(urls []string) error { return l.backend.UpdateWebhookURLs(urls) } +// IsPersistentAuditLogger reports whether the wrapped backend is queryable storage. +func (l *AsyncLogger) IsPersistentAuditLogger() bool { + if l == nil { + return false + } + return IsPersistentLogger(l.backend) +} + // Close drains queued events, stops the worker, and closes the backend logger. func (l *AsyncLogger) Close() error { if l == nil { diff --git a/pkg/audit/audit.go b/pkg/audit/audit.go index 114e7eff6..97c39e7f7 100644 --- a/pkg/audit/audit.go +++ b/pkg/audit/audit.go @@ -65,6 +65,22 @@ type Logger interface { Close() error } +type persistentAuditLogger interface { + IsPersistentAuditLogger() bool +} + +// IsPersistentLogger reports whether a logger provides queryable audit storage. +func IsPersistentLogger(logger Logger) bool { + if logger == nil { + return false + } + if persistent, ok := logger.(persistentAuditLogger); ok { + return persistent.IsPersistentAuditLogger() + } + _, isConsole := logger.(*ConsoleLogger) + return !isConsole +} + // Global logger instance with thread-safe access var ( globalLogger Logger @@ -159,6 +175,11 @@ func NewConsoleLogger() *ConsoleLogger { return &ConsoleLogger{} } +// IsPersistentAuditLogger reports that console logging is not queryable storage. +func (c *ConsoleLogger) IsPersistentAuditLogger() bool { + return false +} + // Log writes an audit event to zerolog. func (c *ConsoleLogger) Log(event Event) error { logContext := log.With(). diff --git a/pkg/audit/sqlite_logger.go b/pkg/audit/sqlite_logger.go index 90bf0d873..1cc55b4a5 100644 --- a/pkg/audit/sqlite_logger.go +++ b/pkg/audit/sqlite_logger.go @@ -2,6 +2,7 @@ package audit import ( "database/sql" + "errors" "fmt" "net/url" "os" @@ -11,7 +12,7 @@ import ( "time" "github.com/rs/zerolog/log" - _ "modernc.org/sqlite" + sqlite "modernc.org/sqlite" ) // SQLiteLoggerConfig configures the SQLite audit logger. @@ -40,6 +41,97 @@ var retentionStartupCleanup = func(l *SQLiteLogger) { l.cleanupOldEvents() } +const ( + sqliteCodeBusy = 5 + sqliteCodeLocked = 6 + sqliteCodeReadonly = 8 + sqliteCodeIOErr = 10 + sqliteCodeCorrupt = 11 + sqliteCodeCantOpen = 14 + sqliteCodeNotADB = 26 + sqliteCodeBusyRecovery = sqliteCodeBusy | (1 << 8) + sqliteCodeBusySnapshot = sqliteCodeBusy | (2 << 8) + sqliteCodeBusyTimeout = sqliteCodeBusy | (3 << 8) + sqliteCodeLockedSharedCache = sqliteCodeLocked | (1 << 8) + sqliteCodeLockedVTab = sqliteCodeLocked | (2 << 8) +) + +var auditSQLiteRetryDelays = []time.Duration{25 * time.Millisecond, 75 * time.Millisecond} +var auditSQLiteRetrySleep = time.Sleep + +// IsStoreBusyError reports whether an audit store error is a transient SQLite lock. +func IsStoreBusyError(err error) bool { + if err == nil { + return false + } + + var sqliteErr *sqlite.Error + if errors.As(err, &sqliteErr) { + switch sqliteErr.Code() { + case sqliteCodeBusy, + sqliteCodeLocked, + sqliteCodeBusyRecovery, + sqliteCodeBusySnapshot, + sqliteCodeBusyTimeout, + sqliteCodeLockedSharedCache, + sqliteCodeLockedVTab: + return true + } + } + + message := strings.ToLower(err.Error()) + return strings.Contains(message, "sqlite_busy") || + strings.Contains(message, "sqlite_locked") || + strings.Contains(message, "database is locked") || + strings.Contains(message, "database table is locked") +} + +// IsStoreUnavailableError reports whether an audit store error means the +// backing database is missing, corrupt, unreadable, or not initialized. +func IsStoreUnavailableError(err error) bool { + if err == nil { + return false + } + if IsStoreBusyError(err) { + return false + } + + var sqliteErr *sqlite.Error + if errors.As(err, &sqliteErr) { + switch sqliteErr.Code() { + case sqliteCodeReadonly, sqliteCodeIOErr, sqliteCodeCorrupt, sqliteCodeCantOpen, sqliteCodeNotADB: + return true + } + } + + message := strings.ToLower(err.Error()) + return strings.Contains(message, "database disk image is malformed") || + strings.Contains(message, "file is not a database") || + strings.Contains(message, "unable to open database file") || + strings.Contains(message, "attempt to write a readonly database") || + strings.Contains(message, "no such table: audit_events") || + strings.Contains(message, "no such table: schema_version") +} + +func withSQLiteRetry(operation string, run func() error) error { + var err error + for attempt := 0; ; attempt++ { + err = run() + if err == nil || !IsStoreBusyError(err) || attempt >= len(auditSQLiteRetryDelays) { + return err + } + + delay := auditSQLiteRetryDelays[attempt] + log.Warn(). + Err(err). + Str("operation", operation). + Int("attempt", attempt+1). + Dur("retryIn", delay). + Msg("Audit SQLite store busy; retrying") + auditSQLiteRetrySleep(delay) + } +} + // NewSQLiteLogger creates a new SQLite-backed audit logger. func NewSQLiteLogger(cfg SQLiteLoggerConfig) (*SQLiteLogger, error) { if cfg.DataDir == "" { @@ -122,6 +214,11 @@ func NewSQLiteLogger(cfg SQLiteLoggerConfig) (*SQLiteLogger, error) { return l, nil } +// IsPersistentAuditLogger reports that SQLite provides queryable audit storage. +func (l *SQLiteLogger) IsPersistentAuditLogger() bool { + return l != nil +} + // initSchema creates the database tables and runs migrations. func (l *SQLiteLogger) initSchema() error { schema := ` @@ -154,22 +251,26 @@ func (l *SQLiteLogger) initSchema() error { ); ` - _, err := l.db.Exec(schema) + var err error + err = withSQLiteRetry("init_schema", func() error { + _, err = l.db.Exec(schema) + return err + }) if err != nil { return fmt.Errorf("failed to create schema: %w", err) } // Record schema version - _, err = l.db.Exec(`INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (1, ?)`, - time.Now().Unix()) + err = withSQLiteRetry("record_schema_version", func() error { + _, err = l.db.Exec(`INSERT OR IGNORE INTO schema_version (version, applied_at) VALUES (1, ?)`, + time.Now().Unix()) + return err + }) return err } // Log records an audit event with HMAC signature. func (l *SQLiteLogger) Log(event Event) error { - l.mu.Lock() - defer l.mu.Unlock() - // Sign the event event.Signature = l.signer.Sign(event) @@ -179,19 +280,26 @@ func (l *SQLiteLogger) Log(event Event) error { success = 1 } - _, err := l.db.Exec(` - INSERT INTO audit_events (id, timestamp, event_type, user, ip, path, success, details, signature) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - event.ID, - event.Timestamp.Unix(), - event.EventType, - event.User, - event.IP, - event.Path, - success, - event.Details, - event.Signature, - ) + var err error + err = withSQLiteRetry("insert_audit_event", func() error { + l.mu.Lock() + defer l.mu.Unlock() + + _, err = l.db.Exec(` + INSERT INTO audit_events (id, timestamp, event_type, user, ip, path, success, details, signature) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + event.ID, + event.Timestamp.Unix(), + event.EventType, + event.User, + event.IP, + event.Path, + success, + event.Details, + event.Signature, + ) + return err + }) if err != nil { return fmt.Errorf("failed to insert audit event: %w", err) @@ -223,9 +331,25 @@ func (l *SQLiteLogger) Log(event Event) error { // Query retrieves audit events matching the filter. func (l *SQLiteLogger) Query(filter QueryFilter) ([]Event, error) { - l.mu.RLock() - defer l.mu.RUnlock() + var events []Event + err := withSQLiteRetry("query_audit_events", func() error { + l.mu.RLock() + defer l.mu.RUnlock() + result, err := l.queryLocked(filter) + if err != nil { + return err + } + events = result + return nil + }) + if err != nil { + return nil, err + } + return events, nil +} + +func (l *SQLiteLogger) queryLocked(filter QueryFilter) ([]Event, error) { query := "SELECT id, timestamp, event_type, user, ip, path, success, details, signature FROM audit_events WHERE 1=1" args := []interface{}{} @@ -307,9 +431,25 @@ func (l *SQLiteLogger) Query(filter QueryFilter) ([]Event, error) { // Count returns the number of events matching the filter. func (l *SQLiteLogger) Count(filter QueryFilter) (int, error) { - l.mu.RLock() - defer l.mu.RUnlock() + var count int + err := withSQLiteRetry("count_audit_events", func() error { + l.mu.RLock() + defer l.mu.RUnlock() + result, err := l.countLocked(filter) + if err != nil { + return err + } + count = result + return nil + }) + if err != nil { + return 0, err + } + return count, nil +} + +func (l *SQLiteLogger) countLocked(filter QueryFilter) (int, error) { query := "SELECT COUNT(*) FROM audit_events WHERE 1=1" args := []interface{}{} @@ -361,15 +501,19 @@ func (l *SQLiteLogger) GetWebhookURLs() []string { // UpdateWebhookURLs updates the webhook configuration. func (l *SQLiteLogger) UpdateWebhookURLs(urls []string) error { - l.mu.Lock() - defer l.mu.Unlock() - // Save to config table value := strings.Join(urls, ",") - _, err := l.db.Exec(` - INSERT INTO audit_config (key, value, updated_at) VALUES ('webhook_urls', ?, ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, - value, time.Now().Unix()) + var err error + err = withSQLiteRetry("update_webhook_urls", func() error { + l.mu.Lock() + defer l.mu.Unlock() + + _, err = l.db.Exec(` + INSERT INTO audit_config (key, value, updated_at) VALUES ('webhook_urls', ?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`, + value, time.Now().Unix()) + return err + }) if err != nil { return fmt.Errorf("failed to save webhook URLs: %w", err) } diff --git a/pkg/audit/sqlite_logger_test.go b/pkg/audit/sqlite_logger_test.go index bb6fd679f..7f9a2225e 100644 --- a/pkg/audit/sqlite_logger_test.go +++ b/pkg/audit/sqlite_logger_test.go @@ -1,6 +1,8 @@ package audit import ( + "errors" + "fmt" "testing" "time" @@ -43,6 +45,71 @@ func TestNewSQLiteLoggerDefaultRetention(t *testing.T) { } } +func TestIsPersistentLoggerUnwrapsAsyncConsoleBackend(t *testing.T) { + console := NewConsoleLogger() + if IsPersistentLogger(console) { + t.Fatal("console logger must not be persistent") + } + + asyncConsole := NewAsyncLogger(console, AsyncLoggerConfig{BufferSize: 1}) + defer asyncConsole.Close() + if IsPersistentLogger(asyncConsole) { + t.Fatal("async console logger must not be persistent") + } +} + +func TestAuditSQLiteStoreErrorClassification(t *testing.T) { + if !IsStoreBusyError(fmt.Errorf("query failed: %w", errors.New("database is locked (5) (SQLITE_BUSY)"))) { + t.Fatal("expected SQLITE_BUSY error to be classified as store busy") + } + if IsStoreBusyError(errors.New("no such table: audit_events")) { + t.Fatal("missing audit table must not be classified as transient busy") + } + if !IsStoreUnavailableError(errors.New("no such table: audit_events")) { + t.Fatal("missing audit table must be classified as store unavailable") + } + if !IsStoreUnavailableError(errors.New("database disk image is malformed")) { + t.Fatal("corrupt audit database must be classified as store unavailable") + } +} + +func TestWithSQLiteRetryRetriesTransientBusyErrors(t *testing.T) { + previousSleep := auditSQLiteRetrySleep + auditSQLiteRetrySleep = func(time.Duration) {} + t.Cleanup(func() { + auditSQLiteRetrySleep = previousSleep + }) + + attempts := 0 + err := withSQLiteRetry("test_retry", func() error { + attempts++ + if attempts < 3 { + return errors.New("database is locked (5) (SQLITE_BUSY)") + } + return nil + }) + if err != nil { + t.Fatalf("expected retry to recover, got %v", err) + } + if attempts != 3 { + t.Fatalf("attempts = %d, want 3", attempts) + } +} + +func TestWithSQLiteRetryDoesNotRetryPermanentErrors(t *testing.T) { + attempts := 0 + err := withSQLiteRetry("test_no_retry", func() error { + attempts++ + return errors.New("no such table: audit_events") + }) + if err == nil { + t.Fatal("expected permanent error") + } + if attempts != 1 { + t.Fatalf("attempts = %d, want 1", attempts) + } +} + func TestSQLiteLoggerLog(t *testing.T) { tempDir := t.TempDir()