289 lines
8.1 KiB
Go
289 lines
8.1 KiB
Go
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)
|
|
}
|
|
}
|