2026-09-20 21:26:17 +03:00
|
|
|
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"`
|
2026-09-20 22:10:09 +03:00
|
|
|
HostCPU struct {
|
|
|
|
|
Percent float64 `json:"percent"`
|
|
|
|
|
Cores float64 `json:"cores"`
|
|
|
|
|
Source string `json:"source"`
|
|
|
|
|
} `json:"host_cpu"`
|
|
|
|
|
HostMemory struct {
|
|
|
|
|
UsedBytes int64 `json:"used_bytes"`
|
|
|
|
|
TotalBytes int64 `json:"total_bytes"`
|
|
|
|
|
Percent float64 `json:"percent"`
|
|
|
|
|
Source string `json:"source"`
|
|
|
|
|
} `json:"host_memory"`
|
2026-09-20 21:26:17 +03:00
|
|
|
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)
|
|
|
|
|
}
|
2026-09-20 22:10:09 +03:00
|
|
|
|
|
|
|
|
// Второй ряд дашборда — вся машина.
|
|
|
|
|
if payload.HostCPU.Source == "" {
|
|
|
|
|
t.Log("источники процессора хоста недоступны (не Linux) — проверка пропущена")
|
|
|
|
|
} else if payload.HostCPU.Source != "host" || payload.HostCPU.Cores <= 0 {
|
|
|
|
|
t.Fatalf("процессор хоста = %+v", payload.HostCPU)
|
|
|
|
|
}
|
|
|
|
|
if payload.HostMemory.TotalBytes == 0 {
|
|
|
|
|
t.Log("память хоста недоступна (не Linux) — проверка пропущена")
|
|
|
|
|
} else if payload.HostMemory.Source != "host" || payload.HostMemory.Percent <= 0 {
|
|
|
|
|
t.Fatalf("память хоста = %+v", payload.HostMemory)
|
|
|
|
|
}
|
2026-09-20 21:26:17 +03:00
|
|
|
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("метрики должны пересчитываться на каждый запрос")
|
|
|
|
|
}
|
|
|
|
|
}
|