mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-31 16:02:17 +00:00
c3fcddf94c
Five stores set private page caches of 16 to 64 MB per connection. The modernc driver allocates that cache with anonymous mmap outside the Go heap, so GC and GOMEMLIMIT never see it, and each page costs about twice its size. Measured: reading a 107 MB database through a 64 MB cache_size added 117 MB of resident memory, against 6 MB at SQLite's 2 MB default. On a long-running dogfood install about 165 MB of 360 MB resident sat outside the Go heap, with a 126 MB audit database behind a 64 MB cache (#1803). Audit, RBAC, notification queue, unified resources and the alert event log (writer and both readers) now take pdb.PageCachePragma. Pages that miss it come from the kernel file cache, which the kernel can reclaim. The audit suite, including its large legacy migration, takes the same time as before (309 s on both trees). pkg/db tests reject any other cache_size in production code and bound the resident growth of a 30 MB scan. Each store's test pins its effective value. Negative controls: restoring the audit cache fails the source scan, and a 64 MB reader cache grows a scan by 55 MB against a 16 MB budget. modernc sorts _pragma values before applying them, so a second cache_size in one DSN cannot reliably override. Related to #1803
837 lines
21 KiB
Go
837 lines
21 KiB
Go
package audit
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
pdb "github.com/rcourtman/pulse-go-rewrite/pkg/db"
|
|
)
|
|
|
|
func TestNewSQLiteLogger(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
|
|
logger, err := NewSQLiteLogger(SQLiteLoggerConfig{
|
|
DataDir: tempDir,
|
|
CryptoMgr: newMockCryptoManager(),
|
|
RetentionDays: 30,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSQLiteLogger failed: %v", err)
|
|
}
|
|
defer logger.Close()
|
|
|
|
if logger.GetRetentionDays() != 30 {
|
|
t.Errorf("Expected retention days 30, got %d", logger.GetRetentionDays())
|
|
}
|
|
}
|
|
|
|
func TestNewSQLiteLoggerDefaultRetention(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
|
|
logger, err := NewSQLiteLogger(SQLiteLoggerConfig{
|
|
DataDir: tempDir,
|
|
CryptoMgr: newMockCryptoManager(),
|
|
// RetentionDays not set
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSQLiteLogger failed: %v", err)
|
|
}
|
|
defer logger.Close()
|
|
|
|
if logger.GetRetentionDays() != 90 {
|
|
t.Errorf("Expected default retention days 90, got %d", logger.GetRetentionDays())
|
|
}
|
|
}
|
|
|
|
func TestNewSQLiteLoggerExplicitForeverRetention(t *testing.T) {
|
|
logger, err := NewSQLiteLogger(SQLiteLoggerConfig{
|
|
DataDir: t.TempDir(),
|
|
CryptoMgr: newMockCryptoManager(),
|
|
RetentionDays: 0,
|
|
RetentionConfigured: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSQLiteLogger failed: %v", err)
|
|
}
|
|
defer logger.Close()
|
|
if logger.GetRetentionDays() != 0 {
|
|
t.Fatalf("retention days = %d, want forever (0)", logger.GetRetentionDays())
|
|
}
|
|
}
|
|
|
|
func TestNewSQLiteLoggerPreservesConfiguredCleanupInterval(t *testing.T) {
|
|
logger, err := NewSQLiteLogger(SQLiteLoggerConfig{
|
|
DataDir: t.TempDir(),
|
|
RetentionDays: 30,
|
|
RetentionConfigured: true,
|
|
CleanupInterval: 6 * time.Hour,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSQLiteLogger failed: %v", err)
|
|
}
|
|
defer logger.Close()
|
|
|
|
if logger.cleanupInterval != 6*time.Hour {
|
|
t.Fatalf("cleanup interval = %s, want 6h", logger.cleanupInterval)
|
|
}
|
|
}
|
|
|
|
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()
|
|
|
|
logger, err := NewSQLiteLogger(SQLiteLoggerConfig{
|
|
DataDir: tempDir,
|
|
CryptoMgr: newMockCryptoManager(),
|
|
RetentionDays: 30,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSQLiteLogger failed: %v", err)
|
|
}
|
|
defer logger.Close()
|
|
|
|
event := Event{
|
|
ID: uuid.NewString(),
|
|
Timestamp: time.Now(),
|
|
EventType: "test_event",
|
|
User: "testuser",
|
|
IP: "192.168.1.1",
|
|
Path: "/api/test",
|
|
Success: true,
|
|
Details: "test details",
|
|
}
|
|
|
|
err = logger.Record(event)
|
|
if err != nil {
|
|
t.Fatalf("Log failed: %v", err)
|
|
}
|
|
|
|
// Query the event back
|
|
events, err := logger.Query(QueryFilter{ID: event.ID})
|
|
if err != nil {
|
|
t.Fatalf("Query failed: %v", err)
|
|
}
|
|
|
|
if len(events) != 1 {
|
|
t.Fatalf("Expected 1 event, got %d", len(events))
|
|
}
|
|
|
|
retrieved := events[0]
|
|
if retrieved.ID != event.ID {
|
|
t.Errorf("ID mismatch: expected %s, got %s", event.ID, retrieved.ID)
|
|
}
|
|
if retrieved.EventType != event.EventType {
|
|
t.Errorf("EventType mismatch: expected %s, got %s", event.EventType, retrieved.EventType)
|
|
}
|
|
if retrieved.User != event.User {
|
|
t.Errorf("User mismatch: expected %s, got %s", event.User, retrieved.User)
|
|
}
|
|
if retrieved.Success != event.Success {
|
|
t.Errorf("Success mismatch: expected %v, got %v", event.Success, retrieved.Success)
|
|
}
|
|
|
|
// Event should have a signature
|
|
if retrieved.Signature == "" {
|
|
t.Error("Expected event to have a signature")
|
|
}
|
|
}
|
|
|
|
func TestSQLiteLoggerQuery(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
|
|
logger, err := NewSQLiteLogger(SQLiteLoggerConfig{
|
|
DataDir: tempDir,
|
|
CryptoMgr: newMockCryptoManager(),
|
|
RetentionDays: 30,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSQLiteLogger failed: %v", err)
|
|
}
|
|
defer logger.Close()
|
|
|
|
// Log several events
|
|
now := time.Now()
|
|
events := []Event{
|
|
{ID: "e1", Timestamp: now.Add(-2 * time.Hour), EventType: "login", User: "alice", Success: true},
|
|
{ID: "e2", Timestamp: now.Add(-1 * time.Hour), EventType: "login", User: "bob", Success: true},
|
|
{ID: "e3", Timestamp: now, EventType: "logout", User: "alice", Success: true},
|
|
{ID: "e4", Timestamp: now, EventType: "login", User: "charlie", Success: false},
|
|
}
|
|
|
|
for _, e := range events {
|
|
if err := logger.Record(e); err != nil {
|
|
t.Fatalf("Log failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// Test event type filter
|
|
t.Run("FilterByEventType", func(t *testing.T) {
|
|
results, err := logger.Query(QueryFilter{EventType: "login"})
|
|
if err != nil {
|
|
t.Fatalf("Query failed: %v", err)
|
|
}
|
|
if len(results) != 3 {
|
|
t.Errorf("Expected 3 login events, got %d", len(results))
|
|
}
|
|
})
|
|
|
|
// Test user filter
|
|
t.Run("FilterByUser", func(t *testing.T) {
|
|
results, err := logger.Query(QueryFilter{User: "alice"})
|
|
if err != nil {
|
|
t.Fatalf("Query failed: %v", err)
|
|
}
|
|
if len(results) != 2 {
|
|
t.Errorf("Expected 2 events for alice, got %d", len(results))
|
|
}
|
|
})
|
|
|
|
// Test success filter
|
|
t.Run("FilterBySuccess", func(t *testing.T) {
|
|
success := false
|
|
results, err := logger.Query(QueryFilter{Success: &success})
|
|
if err != nil {
|
|
t.Fatalf("Query failed: %v", err)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Errorf("Expected 1 failed event, got %d", len(results))
|
|
}
|
|
})
|
|
|
|
// Test time range filter
|
|
t.Run("FilterByTimeRange", func(t *testing.T) {
|
|
start := now.Add(-90 * time.Minute)
|
|
end := now.Add(-30 * time.Minute)
|
|
results, err := logger.Query(QueryFilter{StartTime: &start, EndTime: &end})
|
|
if err != nil {
|
|
t.Fatalf("Query failed: %v", err)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Errorf("Expected 1 event in time range, got %d", len(results))
|
|
}
|
|
})
|
|
|
|
// Test limit and offset
|
|
t.Run("LimitAndOffset", func(t *testing.T) {
|
|
results, err := logger.Query(QueryFilter{Limit: 2, Offset: 1})
|
|
if err != nil {
|
|
t.Fatalf("Query failed: %v", err)
|
|
}
|
|
if len(results) != 2 {
|
|
t.Errorf("Expected 2 events with limit, got %d", len(results))
|
|
}
|
|
})
|
|
|
|
// SQLite requires LIMIT when using OFFSET; OFFSET-only should still work.
|
|
t.Run("OffsetWithoutLimit", func(t *testing.T) {
|
|
results, err := logger.Query(QueryFilter{Offset: 1})
|
|
if err != nil {
|
|
t.Fatalf("Query failed: %v", err)
|
|
}
|
|
if len(results) != 3 {
|
|
t.Errorf("Expected 3 events with offset-only query, got %d", len(results))
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSQLiteLoggerQueryHandlesLegacyDatetimeTimestampRows(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
|
|
logger, err := NewSQLiteLogger(SQLiteLoggerConfig{
|
|
DataDir: tempDir,
|
|
CryptoMgr: newMockCryptoManager(),
|
|
RetentionDays: 30,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSQLiteLogger failed: %v", err)
|
|
}
|
|
defer logger.Close()
|
|
|
|
legacyTime := time.Date(2026, 7, 5, 16, 4, 13, 0, time.UTC)
|
|
if _, err := logger.db.Exec(`
|
|
INSERT INTO audit_events (id, timestamp, event_type, user, ip, path, success, details, signature)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
"legacy-datetime",
|
|
legacyTime,
|
|
"startup",
|
|
sql.NullString{},
|
|
sql.NullString{},
|
|
"/api/audit",
|
|
1,
|
|
"legacy Pro runtime timestamp",
|
|
"legacy-signature",
|
|
); err != nil {
|
|
t.Fatalf("insert legacy datetime row: %v", err)
|
|
}
|
|
|
|
events, err := logger.Query(QueryFilter{ID: "legacy-datetime", Limit: 1})
|
|
if err != nil {
|
|
t.Fatalf("Query failed for legacy datetime timestamp row: %v", err)
|
|
}
|
|
if len(events) != 1 {
|
|
t.Fatalf("Expected 1 event, got %d", len(events))
|
|
}
|
|
if !events[0].Timestamp.Equal(legacyTime) {
|
|
t.Fatalf("timestamp = %s, want %s", events[0].Timestamp, legacyTime)
|
|
}
|
|
}
|
|
|
|
func TestSQLiteLoggerQueryHandlesLegacyGoMonotonicTimestampRows(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
|
|
logger, err := NewSQLiteLogger(SQLiteLoggerConfig{
|
|
DataDir: tempDir,
|
|
CryptoMgr: newMockCryptoManager(),
|
|
RetentionDays: 30,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSQLiteLogger failed: %v", err)
|
|
}
|
|
defer logger.Close()
|
|
|
|
const legacyTimestamp = "2026-07-05 08:51:23.65653076 +0000 UTC m=+0.009025344"
|
|
want := time.Date(2026, 7, 5, 8, 51, 23, 656530760, time.UTC)
|
|
if _, err := logger.db.Exec(`
|
|
INSERT INTO audit_events (id, timestamp, event_type, user, ip, path, success, details, signature)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
"legacy-go-monotonic",
|
|
legacyTimestamp,
|
|
"startup",
|
|
sql.NullString{},
|
|
sql.NullString{},
|
|
"/api/audit",
|
|
1,
|
|
"legacy Go monotonic timestamp",
|
|
"legacy-signature",
|
|
); err != nil {
|
|
t.Fatalf("insert legacy Go monotonic row: %v", err)
|
|
}
|
|
|
|
events, err := logger.Query(QueryFilter{ID: "legacy-go-monotonic", Limit: 1})
|
|
if err != nil {
|
|
t.Fatalf("Query failed for legacy Go monotonic timestamp row: %v", err)
|
|
}
|
|
if len(events) != 1 {
|
|
t.Fatalf("Expected 1 event, got %d", len(events))
|
|
}
|
|
if !events[0].Timestamp.Equal(want) {
|
|
t.Fatalf("timestamp = %s, want %s", events[0].Timestamp, want)
|
|
}
|
|
}
|
|
|
|
func TestSQLiteLoggerCount(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
|
|
logger, err := NewSQLiteLogger(SQLiteLoggerConfig{
|
|
DataDir: tempDir,
|
|
CryptoMgr: newMockCryptoManager(),
|
|
RetentionDays: 30,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSQLiteLogger failed: %v", err)
|
|
}
|
|
defer logger.Close()
|
|
|
|
// Log several events
|
|
for i := 0; i < 5; i++ {
|
|
event := Event{
|
|
ID: uuid.NewString(),
|
|
Timestamp: time.Now(),
|
|
EventType: "test",
|
|
Success: i%2 == 0,
|
|
}
|
|
if err := logger.Record(event); err != nil {
|
|
t.Fatalf("Log failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// Count all
|
|
count, err := logger.Count(QueryFilter{})
|
|
if err != nil {
|
|
t.Fatalf("Count failed: %v", err)
|
|
}
|
|
if count != 5 {
|
|
t.Errorf("Expected count 5, got %d", count)
|
|
}
|
|
|
|
// Count successful
|
|
success := true
|
|
count, err = logger.Count(QueryFilter{Success: &success})
|
|
if err != nil {
|
|
t.Fatalf("Count failed: %v", err)
|
|
}
|
|
if count != 3 {
|
|
t.Errorf("Expected 3 successful events, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestSQLiteLoggerVerifySignature(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
|
|
logger, err := NewSQLiteLogger(SQLiteLoggerConfig{
|
|
DataDir: tempDir,
|
|
CryptoMgr: newMockCryptoManager(),
|
|
RetentionDays: 30,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSQLiteLogger failed: %v", err)
|
|
}
|
|
defer logger.Close()
|
|
|
|
event := Event{
|
|
ID: uuid.NewString(),
|
|
Timestamp: time.Now(),
|
|
EventType: "verify_test",
|
|
User: "testuser",
|
|
Success: true,
|
|
}
|
|
|
|
if err := logger.Record(event); err != nil {
|
|
t.Fatalf("Log failed: %v", err)
|
|
}
|
|
|
|
// Query the event back
|
|
events, err := logger.Query(QueryFilter{ID: event.ID})
|
|
if err != nil {
|
|
t.Fatalf("Query failed: %v", err)
|
|
}
|
|
|
|
if len(events) != 1 {
|
|
t.Fatalf("Expected 1 event, got %d", len(events))
|
|
}
|
|
|
|
// Verify should succeed
|
|
if !logger.VerifySignature(events[0]) {
|
|
t.Error("VerifySignature should return true for valid event")
|
|
}
|
|
|
|
// Tamper with event and verify should fail
|
|
tamperedEvent := events[0]
|
|
tamperedEvent.Details = "tampered"
|
|
if logger.VerifySignature(tamperedEvent) {
|
|
t.Error("VerifySignature should return false for tampered event")
|
|
}
|
|
}
|
|
|
|
func TestSQLiteLoggerWebhooks(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
|
|
logger, err := NewSQLiteLogger(SQLiteLoggerConfig{
|
|
DataDir: tempDir,
|
|
CryptoMgr: newMockCryptoManager(),
|
|
RetentionDays: 30,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSQLiteLogger failed: %v", err)
|
|
}
|
|
defer logger.Close()
|
|
|
|
// Initially no webhooks
|
|
urls := logger.GetWebhookURLs()
|
|
if len(urls) != 0 {
|
|
t.Errorf("Expected no webhooks initially, got %d", len(urls))
|
|
}
|
|
|
|
// Add webhooks
|
|
testURLs := []string{"https://example.com/webhook1", "https://example.com/webhook2"}
|
|
err = logger.UpdateWebhookURLs(testURLs)
|
|
if err != nil {
|
|
t.Fatalf("UpdateWebhookURLs failed: %v", err)
|
|
}
|
|
|
|
urls = logger.GetWebhookURLs()
|
|
if len(urls) != 2 {
|
|
t.Errorf("Expected 2 webhooks, got %d", len(urls))
|
|
}
|
|
|
|
// Clear webhooks
|
|
err = logger.UpdateWebhookURLs([]string{})
|
|
if err != nil {
|
|
t.Fatalf("UpdateWebhookURLs (clear) failed: %v", err)
|
|
}
|
|
|
|
urls = logger.GetWebhookURLs()
|
|
if len(urls) != 0 {
|
|
t.Errorf("Expected 0 webhooks after clear, got %d", len(urls))
|
|
}
|
|
}
|
|
|
|
func TestSQLiteLoggerRetention(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
|
|
logger, err := NewSQLiteLogger(SQLiteLoggerConfig{
|
|
DataDir: tempDir,
|
|
CryptoMgr: newMockCryptoManager(),
|
|
RetentionDays: 1, // 1 day retention for testing
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSQLiteLogger failed: %v", err)
|
|
}
|
|
defer logger.Close()
|
|
|
|
// Log an old event (2 days ago)
|
|
oldEvent := Event{
|
|
ID: "old-event",
|
|
Timestamp: time.Now().Add(-48 * time.Hour),
|
|
EventType: "old",
|
|
Success: true,
|
|
}
|
|
if err := logger.Record(oldEvent); err != nil {
|
|
t.Fatalf("Log old event failed: %v", err)
|
|
}
|
|
|
|
// Log a recent event
|
|
newEvent := Event{
|
|
ID: "new-event",
|
|
Timestamp: time.Now(),
|
|
EventType: "new",
|
|
Success: true,
|
|
}
|
|
if err := logger.Record(newEvent); err != nil {
|
|
t.Fatalf("Log new event failed: %v", err)
|
|
}
|
|
|
|
// Run cleanup
|
|
logger.cleanupOldEvents()
|
|
|
|
// Old event should be deleted
|
|
events, err := logger.Query(QueryFilter{ID: "old-event"})
|
|
if err != nil {
|
|
t.Fatalf("Query failed: %v", err)
|
|
}
|
|
if len(events) != 0 {
|
|
t.Error("Old event should have been deleted")
|
|
}
|
|
|
|
// New event should still exist
|
|
events, err = logger.Query(QueryFilter{ID: "new-event"})
|
|
if err != nil {
|
|
t.Fatalf("Query failed: %v", err)
|
|
}
|
|
if len(events) != 1 {
|
|
t.Error("New event should still exist")
|
|
}
|
|
}
|
|
|
|
func TestSQLiteLoggerSetRetentionDays(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
|
|
logger, err := NewSQLiteLogger(SQLiteLoggerConfig{
|
|
DataDir: tempDir,
|
|
CryptoMgr: newMockCryptoManager(),
|
|
RetentionDays: 30,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSQLiteLogger failed: %v", err)
|
|
}
|
|
defer logger.Close()
|
|
|
|
logger.SetRetentionDays(60)
|
|
if logger.GetRetentionDays() != 60 {
|
|
t.Errorf("Expected retention days 60, got %d", logger.GetRetentionDays())
|
|
}
|
|
}
|
|
|
|
func TestSQLiteLoggerLoadsPersistedRetentionDays(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
first, err := NewSQLiteLogger(SQLiteLoggerConfig{
|
|
DataDir: dataDir,
|
|
CryptoMgr: newMockCryptoManager(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSQLiteLogger failed: %v", err)
|
|
}
|
|
first.SetRetentionDays(45)
|
|
if err := first.Close(); err != nil {
|
|
t.Fatalf("close first logger: %v", err)
|
|
}
|
|
|
|
restarted, err := NewSQLiteLogger(SQLiteLoggerConfig{
|
|
DataDir: dataDir,
|
|
CryptoMgr: newMockCryptoManager(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("restart logger: %v", err)
|
|
}
|
|
defer restarted.Close()
|
|
if restarted.GetRetentionDays() != 45 {
|
|
t.Fatalf("persisted retention days = %d, want 45", restarted.GetRetentionDays())
|
|
}
|
|
}
|
|
|
|
func TestSQLiteLoggerCloseIsIdempotent(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
|
|
logger, err := NewSQLiteLogger(SQLiteLoggerConfig{
|
|
DataDir: tempDir,
|
|
CryptoMgr: newMockCryptoManager(),
|
|
RetentionDays: 30,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSQLiteLogger failed: %v", err)
|
|
}
|
|
|
|
if err := logger.Close(); err != nil {
|
|
t.Fatalf("first Close failed: %v", err)
|
|
}
|
|
if err := logger.Close(); err != nil {
|
|
t.Fatalf("second Close failed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSQLiteLoggerCloseCancelsStartupRetentionCleanup(t *testing.T) {
|
|
origDelay := retentionStartupCleanupDelay
|
|
origStartupCleanup := retentionStartupCleanup
|
|
retentionStartupCleanupDelay = 25 * time.Millisecond
|
|
startupCleanupCalled := make(chan struct{}, 1)
|
|
retentionStartupCleanup = func(l *SQLiteLogger) {
|
|
select {
|
|
case startupCleanupCalled <- struct{}{}:
|
|
default:
|
|
}
|
|
}
|
|
t.Cleanup(func() {
|
|
retentionStartupCleanupDelay = origDelay
|
|
retentionStartupCleanup = origStartupCleanup
|
|
})
|
|
|
|
tempDir := t.TempDir()
|
|
logger, err := NewSQLiteLogger(SQLiteLoggerConfig{
|
|
DataDir: tempDir,
|
|
CryptoMgr: newMockCryptoManager(),
|
|
RetentionDays: 30,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSQLiteLogger failed: %v", err)
|
|
}
|
|
|
|
if err := logger.Close(); err != nil {
|
|
t.Fatalf("Close failed: %v", err)
|
|
}
|
|
|
|
time.Sleep(75 * time.Millisecond)
|
|
select {
|
|
case <-startupCleanupCalled:
|
|
t.Fatal("startup retention cleanup should not run after logger shutdown")
|
|
default:
|
|
}
|
|
}
|
|
|
|
func TestSQLiteLoggerPersistence(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
|
|
// Create logger and log an event
|
|
logger1, err := NewSQLiteLogger(SQLiteLoggerConfig{
|
|
DataDir: tempDir,
|
|
CryptoMgr: newMockCryptoManager(),
|
|
RetentionDays: 30,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSQLiteLogger failed: %v", err)
|
|
}
|
|
|
|
event := Event{
|
|
ID: "persist-test",
|
|
Timestamp: time.Now(),
|
|
EventType: "persistence_test",
|
|
User: "testuser",
|
|
Success: true,
|
|
}
|
|
if err := logger1.Record(event); err != nil {
|
|
t.Fatalf("Log failed: %v", err)
|
|
}
|
|
|
|
// Close the logger
|
|
logger1.Close()
|
|
|
|
// Create a new logger with same data dir
|
|
logger2, err := NewSQLiteLogger(SQLiteLoggerConfig{
|
|
DataDir: tempDir,
|
|
CryptoMgr: newMockCryptoManager(),
|
|
RetentionDays: 30,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSQLiteLogger (reload) failed: %v", err)
|
|
}
|
|
defer logger2.Close()
|
|
|
|
// Query the event - should still exist
|
|
events, err := logger2.Query(QueryFilter{ID: "persist-test"})
|
|
if err != nil {
|
|
t.Fatalf("Query failed: %v", err)
|
|
}
|
|
if len(events) != 1 {
|
|
t.Error("Event should persist across logger restarts")
|
|
}
|
|
|
|
// Signature should still verify
|
|
if len(events) > 0 && !logger2.VerifySignature(events[0]) {
|
|
t.Error("Signature should still verify after restart")
|
|
}
|
|
}
|
|
|
|
func TestSQLiteLoggerConcurrentAccess(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
|
|
logger, err := NewSQLiteLogger(SQLiteLoggerConfig{
|
|
DataDir: tempDir,
|
|
CryptoMgr: newMockCryptoManager(),
|
|
RetentionDays: 30,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSQLiteLogger failed: %v", err)
|
|
}
|
|
defer logger.Close()
|
|
|
|
// Concurrent writes
|
|
done := make(chan bool)
|
|
for i := 0; i < 10; i++ {
|
|
go func(n int) {
|
|
for j := 0; j < 10; j++ {
|
|
event := Event{
|
|
ID: uuid.NewString(),
|
|
Timestamp: time.Now(),
|
|
EventType: "concurrent_test",
|
|
User: "user",
|
|
Success: true,
|
|
}
|
|
if err := logger.Record(event); err != nil {
|
|
t.Errorf("Concurrent log failed: %v", err)
|
|
}
|
|
}
|
|
done <- true
|
|
}(i)
|
|
}
|
|
|
|
// Wait for all goroutines
|
|
for i := 0; i < 10; i++ {
|
|
<-done
|
|
}
|
|
|
|
// Verify count
|
|
count, err := logger.Count(QueryFilter{EventType: "concurrent_test"})
|
|
if err != nil {
|
|
t.Fatalf("Count failed: %v", err)
|
|
}
|
|
if count != 100 {
|
|
t.Errorf("Expected 100 events, got %d", count)
|
|
}
|
|
}
|
|
|
|
func TestSQLiteLoggerCloseIdempotent(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
|
|
logger, err := NewSQLiteLogger(SQLiteLoggerConfig{
|
|
DataDir: tempDir,
|
|
CryptoMgr: newMockCryptoManager(),
|
|
RetentionDays: 30,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSQLiteLogger failed: %v", err)
|
|
}
|
|
|
|
if err := logger.Close(); err != nil {
|
|
t.Fatalf("first Close failed: %v", err)
|
|
}
|
|
if err := logger.Close(); err != nil {
|
|
t.Fatalf("second Close failed: %v", err)
|
|
}
|
|
}
|
|
|
|
// The audit database grows large on long-running installs, so it must take the
|
|
// shared page cache bound. Its former private 64 MB cache held about 120 MB of
|
|
// resident memory outside the Go heap (#1803).
|
|
func TestSQLiteLoggerUsesSharedPageCacheBound(t *testing.T) {
|
|
logger, err := NewSQLiteLogger(SQLiteLoggerConfig{
|
|
DataDir: t.TempDir(),
|
|
CryptoMgr: newMockCryptoManager(),
|
|
RetentionDays: 30,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewSQLiteLogger failed: %v", err)
|
|
}
|
|
defer logger.Close()
|
|
assertSharedPageCacheBound(t, "audit", logger.db)
|
|
}
|
|
|
|
func assertSharedPageCacheBound(t *testing.T, name string, conn interface {
|
|
QueryRow(query string, args ...any) *sql.Row
|
|
}) {
|
|
t.Helper()
|
|
var size int
|
|
if err := conn.QueryRow(`PRAGMA cache_size`).Scan(&size); err != nil {
|
|
t.Fatalf("%s: read cache_size: %v", name, err)
|
|
}
|
|
if size != -pdb.MaxPageCacheKiB {
|
|
t.Fatalf("%s: cache_size = %d, want %d from pdb.PageCachePragma", name, size, -pdb.MaxPageCacheKiB)
|
|
}
|
|
}
|