package server import ( "net/http" "testing" "time" "glchat/internal/httpx" ) // Тесты дашборда инстанса: доступ только администратору, состав метрик и // healthcheck, отдельный лимит частоты для опроса раз в секунду (AGENT.md 7.19). type metricsBody struct { Metrics struct { CPU struct { Percent float64 `json:"percent"` Cores float64 `json:"cores"` Source string `json:"source"` } `json:"cpu"` Memory struct { UsedBytes int64 `json:"used_bytes"` TotalBytes int64 `json:"total_bytes"` Percent float64 `json:"percent"` Source string `json:"source"` } `json:"memory"` Disk struct { UsedBytes int64 `json:"used_bytes"` TotalBytes int64 `json:"total_bytes"` FreeBytes int64 `json:"free_bytes"` } `json:"disk"` Database struct { Bytes int64 `json:"bytes"` WalBytes int64 `json:"wal_bytes"` } `json:"database"` Checks []struct { Name string `json:"name"` Status string `json:"status"` Message string `json:"message"` } `json:"checks"` Health string `json:"health"` UptimeSeconds int64 `json:"uptime_seconds"` Version string `json:"version"` Commit string `json:"commit"` CollectedAt string `json:"collected_at"` } `json:"metrics"` } // metricsServer готовит сервер с настоящими источниками метрик (cgroup/proc // текущей машины) — тесты проверяют и разбор, и отдачу значений. func metricsServer(t *testing.T) (*Server, *http.Cookie) { t.Helper() srv, _ := newTestServer(t) cookie := registerAndLogin(t, srv, "metrics_admin", "metrics@example.com") promoteAdmin(t, srv, "metrics@example.com") return srv, cookie } func TestInstanceMetricsRequireAdmin(t *testing.T) { srv, _ := newTestServer(t) member := registerAndLogin(t, srv, "metrics_member", "metrics-member@example.com") rec := doJSON(t, srv, http.MethodGet, "/api/v1/instance/metrics", "", member) if rec.Code != http.StatusForbidden { t.Fatalf("обычный участник: статус %d, ожидался 403", rec.Code) } anonymous := doJSON(t, srv, http.MethodGet, "/api/v1/instance/metrics", "") if anonymous.Code != http.StatusUnauthorized { t.Fatalf("без сессии: статус %d, ожидался 401", anonymous.Code) } } func TestInstanceMetricsPayload(t *testing.T) { srv, cookie := metricsServer(t) // Первый вызов только снимает показания процессора, второй считает дельту. doJSON(t, srv, http.MethodGet, "/api/v1/instance/metrics", "", cookie) time.Sleep(20 * time.Millisecond) rec := doJSON(t, srv, http.MethodGet, "/api/v1/instance/metrics", "", cookie) if rec.Code != http.StatusOK { t.Fatalf("статус = %d, тело = %s", rec.Code, rec.Body.String()) } payload := decodeResponse[metricsBody](t, rec).Metrics if payload.Version == "" || payload.Commit != "deadbee" { t.Fatalf("версия/коммит = %q/%q", payload.Version, payload.Commit) } if payload.UptimeSeconds < 0 || payload.CollectedAt == "" { t.Fatalf("время работы/сбора = %d/%q", payload.UptimeSeconds, payload.CollectedAt) } // На macOS нет ни cgroup, ни /proc: источник недоступен, и ручка обязана // деградировать мягко (проверка «metrics» в статусе warn). switch payload.CPU.Source { case "": t.Log("источники процессора недоступны (не Linux) — проверка пропущена") case "cgroup", "host": if payload.CPU.Percent < 0 || payload.CPU.Percent > 100 || payload.CPU.Cores <= 0 { t.Fatalf("процессор = %+v", payload.CPU) } default: t.Fatalf("неизвестный источник процессора = %q", payload.CPU.Source) } if payload.Memory.TotalBytes == 0 { t.Log("память недоступна (не Linux) — проверка пропущена") } else if payload.Memory.UsedBytes <= 0 || payload.Memory.Percent <= 0 || payload.Memory.Percent > 100 { t.Fatalf("память = %+v", payload.Memory) } if payload.Disk.TotalBytes <= 0 || payload.Disk.UsedBytes <= 0 || payload.Disk.FreeBytes <= 0 { t.Fatalf("диск = %+v", payload.Disk) } if payload.Database.Bytes <= 0 { t.Fatalf("размер базы = %d", payload.Database.Bytes) } // Healthcheck: приложение, база, диск, запись, gateway. statuses := map[string]string{} for _, check := range payload.Checks { statuses[check.Name] = check.Status } for _, name := range []string{"app", "database", "disk", "storage", "gateway"} { if statuses[name] != "ok" { t.Fatalf("проверка %s = %q (все: %+v)", name, statuses[name], payload.Checks) } } // На Linux без cgroup/proc не бывает, поэтому «metrics» появляется только // на не-Linux: тогда health = warn, иначе ok. if statuses["metrics"] == "warn" { if payload.Health != "warn" { t.Fatalf("состояние = %q при проблеме с метриками", payload.Health) } } else if payload.Health != "ok" { t.Fatalf("итоговое состояние = %q, проверки: %+v", payload.Health, payload.Checks) } } func TestInstanceMetricsRateLimitIsSeparate(t *testing.T) { srv, cookie := metricsServer(t) // Лимит дашборда — 120/мин с запасом 30: серия из 40 запросов проходит, // хотя общий лимит API (120/мин) в тестах уже израсходован регистрацией. for i := 0; i < 25; i++ { rec := doJSON(t, srv, http.MethodGet, "/api/v1/instance/metrics", "", cookie) if rec.Code != http.StatusOK { t.Fatalf("запрос %d: статус %d (%s)", i+1, rec.Code, rec.Body.String()) } } // Исчерпание своего лимита даёт 429 с подсказкой по паузе: ставим лимитер // на один запрос и расходуем токен. srv.metricsLimiter = httpx.NewRateLimiterWindow(1, time.Hour, 1) doJSON(t, srv, http.MethodGet, "/api/v1/instance/metrics", "", cookie) rec := doJSON(t, srv, http.MethodGet, "/api/v1/instance/metrics", "", cookie) if rec.Code != http.StatusTooManyRequests { t.Fatalf("статус = %d, ожидался 429", rec.Code) } payload := decodeResponse[struct { Error struct { Code string `json:"code"` RetryAfterMS int `json:"retry_after_ms"` } `json:"error"` }](t, rec) if payload.Error.Code != "rate_limited" || payload.Error.RetryAfterMS <= 0 { t.Fatalf("ошибка = %+v", payload.Error) } } func TestInstanceMetricsHealthCacheReusesChecks(t *testing.T) { srv, cookie := metricsServer(t) first := decodeResponse[metricsBody](t, doJSON(t, srv, http.MethodGet, "/api/v1/instance/metrics", "", cookie)).Metrics second := decodeResponse[metricsBody](t, doJSON(t, srv, http.MethodGet, "/api/v1/instance/metrics", "", cookie)).Metrics if len(first.Checks) != len(second.Checks) { t.Fatalf("набор проверок изменился: %d → %d", len(first.Checks), len(second.Checks)) } // Проверки берутся из кэша (TTL 5 секунд) — время сбора метрик при этом // обновляется на каждом запросе. if first.CollectedAt == second.CollectedAt { t.Fatal("метрики должны пересчитываться на каждый запрос") } }