Files
fm_be/internal/transport/http/handlers/audit_handler_test.go
2026-07-16 22:16:45 +07:00

287 lines
9.1 KiB
Go

package handlers
import (
"context"
"errors"
"io"
"net/http"
"strings"
"sync"
"testing"
"time"
"github.com/gofiber/fiber/v2"
"wucher/internal/domain/audit"
"wucher/internal/service"
"wucher/internal/shared/pkg/uuidv7"
)
type mockAuditListRepo struct {
mu sync.Mutex
rows []audit.AuditLog
total int64
err error
lastFilter string
lastMethods []string
lastModules []string
lastActions []string
lastSort string
lastLimit int
lastOffset int
}
func (m *mockAuditListRepo) Create(_ context.Context, _ *audit.AuditLog) error { return nil }
func (m *mockAuditListRepo) 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.err != nil {
return nil, 0, m.err
}
out := make([]audit.AuditLog, len(m.rows))
copy(out, m.rows)
return out, m.total, nil
}
func TestAuditHandler_List(t *testing.T) {
repo := &mockAuditListRepo{
rows: []audit.AuditLog{
{
ID: uuidv7.MustBytes(),
Layer: "api",
Module: "users",
Action: "list",
Method: "GET",
Path: "/api/v1/users/get-all",
StatusCode: 200,
Success: true,
IP: "127.0.0.1",
CreatedAt: time.Now().UTC(),
},
},
total: 1,
}
svc := service.NewAuditLogService(repo, service.AuditLogServiceDependencies{QueueSize: 8, Workers: 1})
h := NewAuditHandler(svc)
app := fiber.New()
app.Get("/api/v1/audit-logs/get-all", h.List)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/audit-logs/get-all?filter[search]=http&method=UPDATE&filter[module]=user&filter[action]=list&sort=-created_at&page[number]=1&page[size]=20", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
bodyStr := string(body)
if !containsAll(bodyStr, `"type":"audit_log"`, `"total":1`, `"ip":"127.0.0.1"`) {
t.Fatalf("unexpected body: %s", bodyStr)
}
repo.mu.Lock()
defer repo.mu.Unlock()
if repo.lastFilter != "http" {
t.Fatalf("expected filter to be propagated, got %q", repo.lastFilter)
}
if repo.lastSort != "created_at DESC" {
t.Fatalf("expected normalized sort, got %q", repo.lastSort)
}
if len(repo.lastMethods) != 2 || repo.lastMethods[0] != "PUT" || repo.lastMethods[1] != "PATCH" {
t.Fatalf("expected method filter [PUT PATCH], got %#v", repo.lastMethods)
}
if len(repo.lastModules) != 1 || repo.lastModules[0] != "user" {
t.Fatalf("expected module filter [user], got %#v", repo.lastModules)
}
if len(repo.lastActions) != 3 || repo.lastActions[0] != "create" || repo.lastActions[1] != "update" || repo.lastActions[2] != "delete" {
t.Fatalf("expected action filter [create update delete], got %#v", repo.lastActions)
}
if repo.lastLimit != 0 || repo.lastOffset != 0 {
t.Fatalf("unexpected paging limit=%d offset=%d", repo.lastLimit, repo.lastOffset)
}
}
func containsAll(v string, terms ...string) bool {
for i := range terms {
if !strings.Contains(v, terms[i]) {
return false
}
}
return true
}
func TestAuditHandler_List_Branches(t *testing.T) {
t.Run("fallback filter action and clamp page", func(t *testing.T) {
repo := &mockAuditListRepo{
rows: []audit.AuditLog{{ID: uuidv7.MustBytes(), Action: "create", CreatedAt: time.Now().UTC()}},
total: 1,
}
svc := service.NewAuditLogService(repo, service.AuditLogServiceDependencies{QueueSize: 8, Workers: 1})
h := NewAuditHandler(svc)
app := fiber.New()
app.Get("/api/v1/audit-logs/get-all", h.List)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/audit-logs/get-all?filter[action]=create&page[number]=0&page[size]=500&sort=action", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
repo.mu.Lock()
defer repo.mu.Unlock()
if repo.lastFilter != "create" {
t.Fatalf("expected action fallback filter, got %q", repo.lastFilter)
}
if repo.lastSort != "action ASC" {
t.Fatalf("expected normalized sort, got %q", repo.lastSort)
}
if repo.lastLimit != 0 || repo.lastOffset != 0 {
t.Fatalf("unexpected paging limit=%d offset=%d", repo.lastLimit, repo.lastOffset)
}
})
t.Run("fallback filter layer", func(t *testing.T) {
repo := &mockAuditListRepo{
rows: []audit.AuditLog{{ID: uuidv7.MustBytes(), Layer: "service", CreatedAt: time.Now().UTC()}},
total: 1,
}
svc := service.NewAuditLogService(repo, service.AuditLogServiceDependencies{QueueSize: 8, Workers: 1})
h := NewAuditHandler(svc)
app := fiber.New()
app.Get("/api/v1/audit-logs/get-all", h.List)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/audit-logs/get-all?filter[layer]=service&page[number]=1&page[size]=20", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
repo.mu.Lock()
defer repo.mu.Unlock()
if repo.lastFilter != "service" {
t.Fatalf("expected layer fallback filter, got %q", repo.lastFilter)
}
})
t.Run("list error", func(t *testing.T) {
repo := &mockAuditListRepo{err: errors.New("list failed")}
svc := service.NewAuditLogService(repo, service.AuditLogServiceDependencies{QueueSize: 8, Workers: 1})
h := NewAuditHandler(svc)
app := fiber.New()
app.Get("/api/v1/audit-logs/get-all", h.List)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/audit-logs/get-all", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", resp.StatusCode)
}
})
t.Run("default mutation filter applied when query is empty", func(t *testing.T) {
repo := &mockAuditListRepo{
rows: []audit.AuditLog{{ID: uuidv7.MustBytes(), Action: "update", Method: "PATCH", CreatedAt: time.Now().UTC()}},
total: 1,
}
svc := service.NewAuditLogService(repo, service.AuditLogServiceDependencies{QueueSize: 8, Workers: 1})
h := NewAuditHandler(svc)
app := fiber.New()
app.Get("/api/v1/audit-logs/get-all", h.List)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/audit-logs/get-all", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
repo.mu.Lock()
defer repo.mu.Unlock()
if len(repo.lastMethods) != 4 || repo.lastMethods[0] != "POST" || repo.lastMethods[1] != "PUT" || repo.lastMethods[2] != "PATCH" || repo.lastMethods[3] != "DELETE" {
t.Fatalf("expected default method filter [POST PUT PATCH DELETE], got %#v", repo.lastMethods)
}
if len(repo.lastActions) != 3 || repo.lastActions[0] != "create" || repo.lastActions[1] != "update" || repo.lastActions[2] != "delete" {
t.Fatalf("expected default action filter [create update delete], got %#v", repo.lastActions)
}
})
t.Run("invalid module query returns 422", func(t *testing.T) {
repo := &mockAuditListRepo{}
svc := service.NewAuditLogService(repo, service.AuditLogServiceDependencies{QueueSize: 8, Workers: 1})
h := NewAuditHandler(svc)
app := fiber.New()
app.Get("/api/v1/audit-logs/get-all", h.List)
req, _ := http.NewRequest(http.MethodGet, "/api/v1/audit-logs/get-all?module=bad-module", nil)
resp, err := app.Test(req)
if err != nil {
t.Fatalf("app.Test: %v", err)
}
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d", resp.StatusCode)
}
})
}
func TestAuditLogResource_Branches(t *testing.T) {
now := time.Now().UTC().Truncate(time.Second)
row := &audit.AuditLog{
ID: uuidv7.MustBytes(),
RequestID: " req-1 ",
ActorUserID: uuidv7.MustBytes(),
Layer: " service ",
Action: " create ",
Method: " POST ",
Path: " /api/v1/reserve-acs/get-all ",
StatusCode: 201,
Success: true,
IP: " 127.0.0.1 ",
UserAgent: " ua ",
Message: " ok ",
Metadata: []byte(`{"module":"auth","count":2}`),
CreatedAt: now,
}
res := auditLogResource(row)
if res.Type != "audit_log" || res.ID == "" {
t.Fatalf("expected audit resource type/id")
}
if res.Attributes.ActorUserID == "" {
t.Fatalf("expected actor_user_id to be encoded")
}
if res.Attributes.RequestID != "req-1" || res.Attributes.Layer != "service" || res.Attributes.Action != "create" {
t.Fatalf("expected trimmed string attributes")
}
if res.Attributes.Module != "reserve_ac" {
t.Fatalf("expected inferred module reserve_ac, got %q", res.Attributes.Module)
}
md, ok := res.Attributes.Metadata.(map[string]any)
if !ok || md["module"] != "auth" {
t.Fatalf("expected metadata map decoded, got %#v", res.Attributes.Metadata)
}
if res.Attributes.CreatedAt == "" {
t.Fatalf("expected created_at in resource")
}
}