Files
fm_be/internal/service/audit_log_service_test.go
2026-07-16 22:16:45 +07:00

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)
}
}