392 lines
15 KiB
Go
392 lines
15 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/company/ai-ops/internal/config"
|
|
"github.com/company/ai-ops/internal/database"
|
|
"github.com/company/ai-ops/internal/domain/model"
|
|
"github.com/company/ai-ops/internal/service"
|
|
)
|
|
|
|
type fakeHandlerAlertRepo struct {
|
|
rules []model.AlertRule
|
|
events []model.AlertEvent
|
|
err error
|
|
}
|
|
|
|
func (r *fakeHandlerAlertRepo) GetOpenCount(context.Context) (*model.AlertCount, error) {
|
|
return &model.AlertCount{}, r.err
|
|
}
|
|
func (r *fakeHandlerAlertRepo) ListRules(context.Context) ([]model.AlertRule, error) {
|
|
return r.rules, r.err
|
|
}
|
|
func (r *fakeHandlerAlertRepo) GetRuleByID(_ context.Context, id string) (*model.AlertRule, error) {
|
|
if r.err != nil {
|
|
return nil, r.err
|
|
}
|
|
return &model.AlertRule{ID: id, Name: "rule"}, nil
|
|
}
|
|
func (r *fakeHandlerAlertRepo) CreateRule(context.Context, *model.AlertRule) error { return r.err }
|
|
func (r *fakeHandlerAlertRepo) UpdateRule(context.Context, *model.AlertRule) error { return r.err }
|
|
func (r *fakeHandlerAlertRepo) DeleteRule(context.Context, string) error { return r.err }
|
|
func (r *fakeHandlerAlertRepo) ListEvents(context.Context, string, int, int) ([]model.AlertEvent, int, error) {
|
|
return r.events, len(r.events), r.err
|
|
}
|
|
func (r *fakeHandlerAlertRepo) CreateEvent(context.Context, *model.AlertEvent) error { return r.err }
|
|
func (r *fakeHandlerAlertRepo) CreateEventWithAggregation(_ context.Context, e *model.AlertEvent, _ time.Duration, _ int) (*model.AlertEvent, error) {
|
|
return e, r.err
|
|
}
|
|
func (r *fakeHandlerAlertRepo) UpdateEventStatus(context.Context, string, string) error { return r.err }
|
|
func (r *fakeHandlerAlertRepo) EscalateEvent(context.Context, string, string) error { return r.err }
|
|
|
|
type fakeHandlerChannelRepo struct {
|
|
channels []model.NotificationChannel
|
|
err error
|
|
}
|
|
|
|
func (r *fakeHandlerChannelRepo) List(context.Context) ([]model.NotificationChannel, error) {
|
|
return r.channels, r.err
|
|
}
|
|
func (r *fakeHandlerChannelRepo) GetByID(_ context.Context, id string) (*model.NotificationChannel, error) {
|
|
if r.err != nil {
|
|
return nil, r.err
|
|
}
|
|
return &model.NotificationChannel{ID: id, Name: "hook"}, nil
|
|
}
|
|
func (r *fakeHandlerChannelRepo) Create(context.Context, *model.NotificationChannel) error {
|
|
return r.err
|
|
}
|
|
func (r *fakeHandlerChannelRepo) Update(context.Context, *model.NotificationChannel) error {
|
|
return r.err
|
|
}
|
|
func (r *fakeHandlerChannelRepo) Delete(context.Context, string) error { return r.err }
|
|
|
|
type fakeHandlerLogRepo struct {
|
|
logs []model.RequestLog
|
|
total int
|
|
err error
|
|
}
|
|
|
|
func (r *fakeHandlerLogRepo) Query(context.Context, model.LogQueryFilter) ([]model.RequestLog, int, error) {
|
|
return r.logs, r.total, r.err
|
|
}
|
|
|
|
func TestAuthHandlerLoginRolesAndValidation(t *testing.T) {
|
|
h := NewAuthHandler(service.NewAuthService("secret"))
|
|
|
|
cases := []struct{ username, wantRole string }{{"admin", "admin"}, {"ops", "operator"}, {"alice", "viewer"}}
|
|
for _, tc := range cases {
|
|
w := httptest.NewRecorder()
|
|
h.Login(w, httptest.NewRequest(http.MethodPost, "/api/v1/ai-ops/login", strings.NewReader(`{"username":"`+tc.username+`","password":"pw"}`)))
|
|
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"role":"`+tc.wantRole+`"`) || !strings.Contains(w.Body.String(), `"token"`) {
|
|
t.Fatalf("login %s failed: status=%d body=%s", tc.username, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
w := httptest.NewRecorder()
|
|
h.Login(w, httptest.NewRequest(http.MethodPost, "/api/v1/ai-ops/login", strings.NewReader(`{"username":"","password":""}`)))
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("invalid login status = %d", w.Code)
|
|
}
|
|
|
|
bad := httptest.NewRecorder()
|
|
h.Login(bad, httptest.NewRequest(http.MethodPost, "/api/v1/ai-ops/login", strings.NewReader(`{`)))
|
|
if bad.Code != http.StatusBadRequest {
|
|
t.Fatalf("bad json status = %d", bad.Code)
|
|
}
|
|
}
|
|
|
|
func TestHealthAndDashboardHandlers(t *testing.T) {
|
|
health := NewHealthHandler()
|
|
w := httptest.NewRecorder()
|
|
health.Health(w, httptest.NewRequest(http.MethodGet, "/actuator/health", nil))
|
|
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"status":"UP"`) {
|
|
t.Fatalf("health = %d %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
live := httptest.NewRecorder()
|
|
health.Live(live, httptest.NewRequest(http.MethodGet, "/actuator/health/live", nil))
|
|
if live.Code != http.StatusOK {
|
|
t.Fatalf("live = %d", live.Code)
|
|
}
|
|
|
|
ready := httptest.NewRecorder()
|
|
health.Ready(ready, httptest.NewRequest(http.MethodGet, "/actuator/health/ready", nil))
|
|
if ready.Code != http.StatusServiceUnavailable || !strings.Contains(ready.Body.String(), `"status":"DOWN"`) {
|
|
t.Fatalf("ready = %d %s", ready.Code, ready.Body.String())
|
|
}
|
|
|
|
dash := httptest.NewRecorder()
|
|
NewDashboardHandler().Dashboard(dash, httptest.NewRequest(http.MethodGet, "/ops/dashboard", nil))
|
|
if dash.Code != http.StatusOK || !strings.Contains(dash.Body.String(), "AI-Ops 运维看板") {
|
|
t.Fatalf("dashboard = %d", dash.Code)
|
|
}
|
|
}
|
|
|
|
func TestRuleHandlerCRUDHappyAndErrorPaths(t *testing.T) {
|
|
repo := &fakeHandlerAlertRepo{rules: []model.AlertRule{{ID: "r1", Name: "rule"}}}
|
|
h := NewRuleHandler(service.NewRuleService(repo))
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
for _, tc := range []struct {
|
|
method, path string
|
|
body string
|
|
want int
|
|
}{
|
|
{http.MethodGet, "/api/v1/ai-ops/rules", "", http.StatusOK},
|
|
{http.MethodGet, "/api/v1/ai-ops/rules/r1", "", http.StatusOK},
|
|
{http.MethodPost, "/api/v1/ai-ops/rules", `{"id":"r2","name":"latency","metric_name":"p99"}`, http.StatusCreated},
|
|
{http.MethodPut, "/api/v1/ai-ops/rules/r2", `{"name":"latency","metric_name":"p99"}`, http.StatusOK},
|
|
{http.MethodDelete, "/api/v1/ai-ops/rules/r2", "", http.StatusNoContent},
|
|
{http.MethodPost, "/api/v1/ai-ops/rules", `{`, http.StatusBadRequest},
|
|
{http.MethodPost, "/api/v1/ai-ops/rules", `{}`, http.StatusBadRequest},
|
|
} {
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)))
|
|
if w.Code != tc.want {
|
|
t.Fatalf("%s %s status=%d want=%d body=%s", tc.method, tc.path, w.Code, tc.want, w.Body.String())
|
|
}
|
|
}
|
|
|
|
errHandler := NewRuleHandler(service.NewRuleService(&fakeHandlerAlertRepo{err: errors.New("db")}))
|
|
errMux := http.NewServeMux()
|
|
errHandler.RegisterRoutes(errMux)
|
|
w := httptest.NewRecorder()
|
|
errMux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/rules", nil))
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Fatalf("error list status = %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestChannelHandlerCRUDHappyAndErrorPaths(t *testing.T) {
|
|
h := NewChannelHandler(service.NewChannelService(&fakeHandlerChannelRepo{channels: []model.NotificationChannel{{ID: "c1", Name: "hook"}}}))
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
for _, tc := range []struct {
|
|
method, path, body string
|
|
want int
|
|
}{
|
|
{http.MethodGet, "/api/v1/ai-ops/channels", "", http.StatusOK},
|
|
{http.MethodGet, "/api/v1/ai-ops/channels/c1", "", http.StatusOK},
|
|
{http.MethodPost, "/api/v1/ai-ops/channels", `{"name":"hook","channel_type":"webhook"}`, http.StatusCreated},
|
|
{http.MethodPut, "/api/v1/ai-ops/channels/c1", `{"name":"hook","channel_type":"webhook"}`, http.StatusOK},
|
|
{http.MethodDelete, "/api/v1/ai-ops/channels/c1", "", http.StatusNoContent},
|
|
{http.MethodPost, "/api/v1/ai-ops/channels/test", `{"channel_id":"c1","message":"hello"}`, http.StatusOK},
|
|
{http.MethodPost, "/api/v1/ai-ops/channels", `{}`, http.StatusBadRequest},
|
|
{http.MethodPost, "/api/v1/ai-ops/channels/test", `{`, http.StatusBadRequest},
|
|
} {
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, httptest.NewRequest(tc.method, tc.path, strings.NewReader(tc.body)))
|
|
if w.Code != tc.want {
|
|
t.Fatalf("%s %s status=%d want=%d body=%s", tc.method, tc.path, w.Code, tc.want, w.Body.String())
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAlertAndLogHandlers(t *testing.T) {
|
|
alertHandler := NewAlertHandler(&fakeHandlerAlertRepo{events: []model.AlertEvent{{ID: "e1", Status: "triggered"}}})
|
|
alertMux := http.NewServeMux()
|
|
alertHandler.RegisterRoutes(alertMux)
|
|
aw := httptest.NewRecorder()
|
|
alertMux.ServeHTTP(aw, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/alerts?status=triggered&page=2&page_size=5", nil))
|
|
if aw.Code != http.StatusOK || !strings.Contains(aw.Body.String(), `"items"`) {
|
|
t.Fatalf("alerts = %d %s", aw.Code, aw.Body.String())
|
|
}
|
|
|
|
logHandler := NewLogHandler(service.NewLogService(&fakeHandlerLogRepo{logs: []model.RequestLog{{Timestamp: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC), Service: "api", Path: "/v1", Method: "GET", StatusCode: 200}}, total: 1}))
|
|
logMux := http.NewServeMux()
|
|
logHandler.RegisterRoutes(logMux)
|
|
lw := httptest.NewRecorder()
|
|
logMux.ServeHTTP(lw, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/logs?page=2&page_size=5&status_code=200", nil))
|
|
if lw.Code != http.StatusOK || !strings.Contains(lw.Body.String(), `"total_pages"`) {
|
|
t.Fatalf("logs = %d %s", lw.Code, lw.Body.String())
|
|
}
|
|
|
|
csv := httptest.NewRecorder()
|
|
logMux.ServeHTTP(csv, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/logs/export", nil))
|
|
if csv.Code != http.StatusOK || !strings.Contains(csv.Body.String(), "时间,服务名") {
|
|
t.Fatalf("csv = %d %s", csv.Code, csv.Body.String())
|
|
}
|
|
|
|
badCSV := httptest.NewRecorder()
|
|
badLogHandler := NewLogHandler(service.NewLogService(&fakeHandlerLogRepo{err: errors.New("export failed")}))
|
|
badLogMux := http.NewServeMux()
|
|
badLogHandler.RegisterRoutes(badLogMux)
|
|
badLogMux.ServeHTTP(badCSV, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/logs/export?start=bad&end=bad&status_code=bad", nil))
|
|
if badCSV.Code != http.StatusInternalServerError {
|
|
t.Fatalf("csv error = %d %s", badCSV.Code, badCSV.Body.String())
|
|
}
|
|
}
|
|
|
|
type fakeHandlerMetricRepo struct {
|
|
realtime *model.RealtimeMetrics
|
|
points []model.MetricPoint
|
|
err error
|
|
}
|
|
|
|
func (r *fakeHandlerMetricRepo) GetRealtime(context.Context) (*model.RealtimeMetrics, error) {
|
|
if r.err != nil {
|
|
return nil, r.err
|
|
}
|
|
return r.realtime, nil
|
|
}
|
|
func (r *fakeHandlerMetricRepo) Query(context.Context, model.MetricQueryRequest) ([]model.MetricPoint, error) {
|
|
if r.err != nil {
|
|
return nil, r.err
|
|
}
|
|
return r.points, nil
|
|
}
|
|
func (r *fakeHandlerMetricRepo) GetLatest(context.Context, string, string) (*model.MetricPoint, error) {
|
|
if r.err != nil {
|
|
return nil, r.err
|
|
}
|
|
return &model.MetricPoint{Value: 1}, nil
|
|
}
|
|
|
|
func TestRegisterRoutesForSmallHandlers(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
NewAuthHandler(service.NewAuthService("secret")).RegisterRoutes(mux)
|
|
NewDashboardHandler().RegisterRoutes(mux)
|
|
NewHealthHandler().RegisterRoutes(mux)
|
|
NewHealingHandler().RegisterRoutes(mux)
|
|
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/healings", nil))
|
|
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"total":0`) {
|
|
t.Fatalf("healings = %d %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
one := httptest.NewRecorder()
|
|
mux.ServeHTTP(one, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/healings/h1", nil))
|
|
if one.Code != http.StatusOK || !strings.Contains(one.Body.String(), `"id":"h1"`) {
|
|
t.Fatalf("healing = %d %s", one.Code, one.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestMetricHandlerRoutesAndErrors(t *testing.T) {
|
|
metricRepo := &fakeHandlerMetricRepo{realtime: &model.RealtimeMetrics{QPS: 9}, points: []model.MetricPoint{{Name: "qps", Value: 1}}}
|
|
alertRepo := &fakeHandlerAlertRepo{}
|
|
h := NewMetricHandler(service.NewMetricService(metricRepo, alertRepo))
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
for _, path := range []string{
|
|
"/api/v1/ai-ops/metrics/realtime",
|
|
"/api/v1/ai-ops/metrics/suppliers/count",
|
|
"/api/v1/ai-ops/alerts/open/count",
|
|
"/api/v1/ai-ops/metrics/query?source=prom&name=qps&start=2026-01-01T00:00:00Z&end=2026-01-01T01:00:00Z",
|
|
} {
|
|
w := httptest.NewRecorder()
|
|
mux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("%s status=%d body=%s", path, w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
errHandler := NewMetricHandler(service.NewMetricService(&fakeHandlerMetricRepo{err: errors.New("metrics down")}, &fakeHandlerAlertRepo{err: errors.New("alerts down")}))
|
|
errMux := http.NewServeMux()
|
|
errHandler.RegisterRoutes(errMux)
|
|
for _, path := range []string{"/api/v1/ai-ops/metrics/realtime", "/api/v1/ai-ops/metrics/query", "/api/v1/ai-ops/alerts/open/count"} {
|
|
w := httptest.NewRecorder()
|
|
errMux.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
|
if w.Code != http.StatusInternalServerError {
|
|
t.Fatalf("%s status=%d", path, w.Code)
|
|
}
|
|
}
|
|
}
|
|
|
|
func setupHandlerAuditDB(t *testing.T) context.Context {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
if database.Pool == nil {
|
|
ports := []int{15432, 5432}
|
|
var lastErr error
|
|
for _, port := range ports {
|
|
lastErr = database.Init(config.DatabaseConfig{Host: "localhost", Port: port, User: "aiops", Password: "aiops123", DBName: "ai_ops", SSLMode: "disable", PoolSize: 4})
|
|
if lastErr == nil {
|
|
break
|
|
}
|
|
database.Close()
|
|
database.Pool = nil
|
|
}
|
|
if lastErr != nil {
|
|
t.Skipf("PostgreSQL integration database not available: %v", lastErr)
|
|
}
|
|
}
|
|
if _, err := database.Pool.Exec(ctx, `SELECT pg_advisory_lock(424242001)`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer database.Pool.Exec(ctx, `SELECT pg_advisory_unlock(424242001)`)
|
|
files, err := filepath.Glob(filepath.Join("..", "..", "tech", "migrations", "*.up.sql"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sort.Strings(files)
|
|
for _, f := range files {
|
|
b, err := os.ReadFile(f)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := database.Pool.Exec(ctx, string(b)); err != nil {
|
|
t.Fatalf("apply migration %s: %v", f, err)
|
|
}
|
|
}
|
|
return ctx
|
|
}
|
|
|
|
func handlerUUID(t *testing.T) string {
|
|
t.Helper()
|
|
b := make([]byte, 16)
|
|
if _, err := rand.Read(b); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b[6] = (b[6] & 0x0f) | 0x40
|
|
b[8] = (b[8] & 0x3f) | 0x80
|
|
return hex.EncodeToString(b[0:4]) + "-" + hex.EncodeToString(b[4:6]) + "-" + hex.EncodeToString(b[6:8]) + "-" + hex.EncodeToString(b[8:10]) + "-" + hex.EncodeToString(b[10:16])
|
|
}
|
|
|
|
func TestAuditHandlerListAndRollback(t *testing.T) {
|
|
ctx := setupHandlerAuditDB(t)
|
|
svc := service.NewAuditService()
|
|
id := handlerUUID(t)
|
|
defer database.Pool.Exec(ctx, `DELETE FROM ai_ops_audits WHERE id=$1 OR parent_audit_id=$1 OR object_id=$1`, id)
|
|
if err := svc.Record(ctx, &service.AuditLog{ID: id, TenantID: "tenant", ObjectType: "rule", ObjectID: id, Action: "update", BeforeState: map[string]any{"enabled": false}, AfterState: map[string]any{"enabled": true}, RequestID: "req", ResultCode: "SUCCESS", SourceIP: "127.0.0.1", ActorID: "actor", RiskLevel: "normal"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := NewAuditHandler(svc)
|
|
mux := http.NewServeMux()
|
|
h.RegisterRoutes(mux)
|
|
|
|
list := httptest.NewRecorder()
|
|
mux.ServeHTTP(list, httptest.NewRequest(http.MethodGet, "/api/v1/ai-ops/audits?object_type=rule&object_id="+id+"&page=0&page_size=999", nil))
|
|
if list.Code != http.StatusOK || !strings.Contains(list.Body.String(), id) {
|
|
t.Fatalf("list audits = %d %s", list.Code, list.Body.String())
|
|
}
|
|
|
|
rollback := httptest.NewRecorder()
|
|
mux.ServeHTTP(rollback, httptest.NewRequest(http.MethodPost, "/api/v1/ai-ops/audits/"+id+"/rollback", nil))
|
|
if rollback.Code != http.StatusOK || !strings.Contains(rollback.Body.String(), `"action":"rollback"`) {
|
|
t.Fatalf("rollback = %d %s", rollback.Code, rollback.Body.String())
|
|
}
|
|
|
|
missing := httptest.NewRecorder()
|
|
mux.ServeHTTP(missing, httptest.NewRequest(http.MethodPost, "/api/v1/ai-ops/audits/"+handlerUUID(t)+"/rollback", nil))
|
|
if missing.Code != http.StatusBadRequest {
|
|
t.Fatalf("missing rollback status = %d", missing.Code)
|
|
}
|
|
}
|