package service import ( "context" "errors" "sync" "testing" "time" "wucher/internal/domain/audit" ) type mockAuditRepo struct { mu sync.Mutex entries []*audit.AuditLog createCalls int createErr error listRows []audit.AuditLog listTotal int64 listErr error lastFilter string lastMethods []string lastModules []string lastActions []string lastSort string lastLimit int lastOffset int } func (m *mockAuditRepo) Create(_ context.Context, entry *audit.AuditLog) error { m.mu.Lock() defer m.mu.Unlock() m.createCalls++ cp := *entry m.entries = append(m.entries, &cp) return m.createErr } func (m *mockAuditRepo) List(_ context.Context, filter string, methods []string, modules []string, actions []string, sort string, limit, offset int) ([]audit.AuditLog, int64, error) { m.mu.Lock() defer m.mu.Unlock() m.lastFilter = filter m.lastMethods = append([]string(nil), methods...) m.lastModules = append([]string(nil), modules...) m.lastActions = append([]string(nil), actions...) m.lastSort = sort m.lastLimit = limit m.lastOffset = offset if m.listErr != nil { return nil, 0, m.listErr } out := make([]audit.AuditLog, len(m.listRows)) copy(out, m.listRows) return out, m.listTotal, nil } func TestAuditLogService_LogAsync(t *testing.T) { repo := &mockAuditRepo{} svc := NewAuditLogService(repo, AuditLogServiceDependencies{QueueSize: 8, Workers: 1, IPEncryptionKey: "pepper"}) defer close(svc.queue) svc.Log(context.Background(), AuditLogInput{ RequestID: "req-1", Layer: "service", Action: "auth.reset_password.consume", Success: true, StatusCode: 200, IP: "127.0.0.1", Message: "ok", Metadata: map[string]any{ "k": "v", }, }) deadline := time.Now().Add(500 * time.Millisecond) for time.Now().Before(deadline) { repo.mu.Lock() n := len(repo.entries) repo.mu.Unlock() if n > 0 { break } time.Sleep(10 * time.Millisecond) } repo.mu.Lock() defer repo.mu.Unlock() if len(repo.entries) != 1 { t.Fatalf("expected 1 audit entry, got %d", len(repo.entries)) } if repo.entries[0].Action != "auth.reset_password.consume" { t.Fatalf("unexpected action: %s", repo.entries[0].Action) } if repo.entries[0].IP == "127.0.0.1" { t.Fatalf("expected encrypted ip to be persisted") } if plain := decryptStoredIP(repo.entries[0].IP, newIPSecretProtector("pepper")); plain != "127.0.0.1" { t.Fatalf("unexpected decrypted ip: %s", plain) } } func TestAuditLogService_NewAuditLogService_Defaults(t *testing.T) { repo := &mockAuditRepo{} svc := NewAuditLogService(repo, AuditLogServiceDependencies{QueueSize: 0, Workers: 0}) defer close(svc.queue) if cap(svc.queue) != 1024 { t.Fatalf("expected default queue size 1024, got %d", cap(svc.queue)) } svc.Log(context.Background(), AuditLogInput{ Layer: "service", Action: "audit.defaults", }) deadline := time.Now().Add(500 * time.Millisecond) for time.Now().Before(deadline) { repo.mu.Lock() n := len(repo.entries) repo.mu.Unlock() if n > 0 { return } time.Sleep(10 * time.Millisecond) } t.Fatalf("expected default workers to process queued entry") } func TestAuditLogService_Log_NoopAndQueueBehavior(t *testing.T) { t.Run("no-op when receiver is nil", func(t *testing.T) { var svc *AuditLogService svc.Log(context.Background(), AuditLogInput{Layer: "service", Action: "noop"}) }) t.Run("no-op when repo is nil", func(t *testing.T) { svc := &AuditLogService{repo: nil, queue: make(chan *audit.AuditLog, 1)} svc.Log(context.Background(), AuditLogInput{Layer: "service", Action: "noop"}) if len(svc.queue) != 0 { t.Fatalf("expected no queued entries when repo is nil") } }) t.Run("metadata marshal failure does not panic and leaves metadata empty", func(t *testing.T) { svc := &AuditLogService{ repo: &mockAuditRepo{}, queue: make(chan *audit.AuditLog, 1), } svc.Log(context.Background(), AuditLogInput{ Layer: "service", Action: "audit.bad_metadata", Metadata: map[string]any{"bad": func() {}}, }) if len(svc.queue) != 1 { t.Fatalf("expected one queued entry") } entry := <-svc.queue if len(entry.Metadata) != 0 { t.Fatalf("expected metadata to be empty when marshal fails") } }) t.Run("queue full branch drops extra entry", func(t *testing.T) { svc := &AuditLogService{ repo: &mockAuditRepo{}, queue: make(chan *audit.AuditLog, 1), } svc.Log(context.Background(), AuditLogInput{Layer: "service", Action: "first"}) svc.Log(context.Background(), AuditLogInput{Layer: "service", Action: "second"}) if len(svc.queue) != 1 { t.Fatalf("expected queue to contain only first entry, got %d", len(svc.queue)) } entry := <-svc.queue if entry.Action != "first" { t.Fatalf("expected first entry to be retained, got %q", entry.Action) } }) } func TestAuditLogService_runWorker_NilEntryAndPersistError(t *testing.T) { repo := &mockAuditRepo{createErr: errors.New("persist failed")} svc := &AuditLogService{ repo: repo, queue: make(chan *audit.AuditLog, 2), } done := make(chan struct{}) go func() { svc.runWorker() close(done) }() svc.queue <- nil svc.queue <- &audit.AuditLog{ Layer: "service", Action: "audit.persist_error", } close(svc.queue) select { case <-done: case <-time.After(time.Second): t.Fatalf("worker did not stop after queue close") } repo.mu.Lock() defer repo.mu.Unlock() if repo.createCalls != 1 { t.Fatalf("expected exactly one repo.Create call for non-nil entry, got %d", repo.createCalls) } if len(repo.entries) != 1 { t.Fatalf("expected one persisted entry attempt, got %d", len(repo.entries)) } if repo.entries[0].Action != "audit.persist_error" { t.Fatalf("unexpected persisted action: %s", repo.entries[0].Action) } } func TestTruncateBytes(t *testing.T) { original := []byte("abcdef") t.Run("max less than or equal zero returns original bytes", func(t *testing.T) { got := truncateBytes(original, 0) if string(got) != string(original) { t.Fatalf("expected %q, got %q", string(original), string(got)) } }) t.Run("len less than or equal max returns original bytes", func(t *testing.T) { got := truncateBytes(original, len(original)) if string(got) != string(original) { t.Fatalf("expected %q, got %q", string(original), string(got)) } }) t.Run("len greater than max returns truncated bytes", func(t *testing.T) { got := truncateBytes(original, 3) if string(got) != "abc" { t.Fatalf("expected %q, got %q", "abc", string(got)) } if len(got) != 3 { t.Fatalf("expected len 3, got %d", len(got)) } }) } func TestAuditLogService_List(t *testing.T) { encIP := sanitizeIP("127.0.0.1", newIPSecretProtector("pepper")) repo := &mockAuditRepo{ listRows: []audit.AuditLog{ {Layer: "api", Action: "http.request", IP: encIP}, }, listTotal: 1, } svc := NewAuditLogService(repo, AuditLogServiceDependencies{QueueSize: 8, Workers: 1, IPEncryptionKey: "pepper"}) defer close(svc.queue) rows, total, err := svc.List(context.Background(), "http", []string{"GET", "update"}, []string{"helicopters"}, []string{"list"}, "-created_at", 20, 0) if err != nil { t.Fatalf("List returned error: %v", err) } if total != 1 || len(rows) != 1 { t.Fatalf("unexpected list result total=%d len=%d", total, len(rows)) } if rows[0].IP != "127.0.0.1" { t.Fatalf("expected decrypted ip in list response, got %q", rows[0].IP) } repo.mu.Lock() defer repo.mu.Unlock() if repo.lastSort != "created_at DESC" { t.Fatalf("expected normalized sort created_at DESC, got %q", repo.lastSort) } if repo.lastFilter != "http" { t.Fatalf("expected filter propagated, got %q", repo.lastFilter) } if len(repo.lastMethods) != 2 || repo.lastMethods[0] != "PUT" || repo.lastMethods[1] != "PATCH" { t.Fatalf("expected normalized methods [PUT PATCH], got %#v", repo.lastMethods) } if len(repo.lastModules) != 1 || repo.lastModules[0] != "helicopters" { t.Fatalf("expected modules [helicopters], got %#v", repo.lastModules) } if len(repo.lastActions) != 3 || repo.lastActions[0] != "create" || repo.lastActions[1] != "update" || repo.lastActions[2] != "delete" { t.Fatalf("expected actions [create update delete], got %#v", repo.lastActions) } }