Files
HY2XS_flamy/apps/service/hysteria2_state_test.go
founder cb20d8d28f fix(admin): связать отзыв учётных данных с идентичностью сессий и свести адрес control plane к одному
Отзыв секрета не сходился: `auth_id` при смене секрета оставался прежним,
поэтому сессия, установленная по отозванным учётным данным, была неотличима от
законной, и цикл учёта не имел признака, по которому её следовало завершить. У
состояния есть путь без единой неудачи — Hysteria регистрирует соединение в
Traffic Stats API только после возврата backend-auth, поэтому успешный /kick
может пройти мимо. Новое поколение credentials получает новый auth_id, kick идёт
по старому, пережившая сессия становится orphan.

Адрес Traffic Stats API имел два контракта: оркестратор принимал любой IPv4,
админка всегда шла на loopback. Валидная по всем гейтам конфигурация выключала
лимит устройств, учёт трафика и принудительное отключение разом. Адрес
зафиксирован, а расхождение файла с ним админка называет.

Состояние службы стало трёхзначным: util.Exec выбрасывал вывод systemctl при
ненулевом коде, поэтому «остановлена» и «спросить не удалось» приходили одним
значением, а доступность Traffic Stats API выводилась из него же. Журнал
Hysteria разбирается в фактическом формате upstream (time — дробное число),
страница конфигурации показывает файл вместо дефолтов UI и не возит секреты в
браузер, санитайзер выгрузки следует по YAML-якорям.

Разбор: docs/acceptance/2026-09-02-v1.0.0-rc4-preflight-findings.md
2026-09-02 23:24:01 +05:00

175 lines
7.4 KiB
Go

package service
import (
"net/http"
"testing"
"hy2xs-admin/model/vo"
)
// Дашборд не имеет права утверждать физически несовместимые вещи.
//
// Что было:
//
// Hysteria stopped
// Traffic Stats API reachable
// 0 online users
//
// Все три строки выводились из ОДНОГО ответа systemctl: общий Hysteria2Online
// при неактивной службе отдавал пустую карту без ошибки, и сборщик метрик
// выставлял `apiReachable = true`, ни разу не сходив в API.
//
// Матрица ниже проверяет все четыре клетки: два источника, два исхода каждый.
func TestParseSystemdActiveState(t *testing.T) {
cases := []struct {
name string
output string
want HysteriaServiceState
}{
{"active", "active\n", HysteriaServiceActive},
{"inactive", "inactive\n", HysteriaServiceInactive},
{"failed", "failed\n", HysteriaServiceInactive},
{"activating", "activating\n", HysteriaServiceInactive},
{"deactivating", "deactivating\n", HysteriaServiceInactive},
{"пробелы по краям", " active ", HysteriaServiceActive},
{"несколько строк", "active\nactive\n", HysteriaServiceActive},
{"пустой ответ", "", HysteriaServiceUnknown},
// Список состояний systemd пополнялся и раньше. Молчаливое приведение
// незнакомого слова к «остановлена» вернуло бы ровно ту ложь, ради
// которой заведено третье значение.
{"неизвестное состояние", "quantum\n", HysteriaServiceUnknown},
{"текст ошибки вместо состояния", "Failed to connect to bus\n", HysteriaServiceUnknown},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := parseSystemdActiveState(tc.output); got != tc.want {
t.Fatalf("parseSystemdActiveState(%q) = %q, ожидалось %q", tc.output, got, tc.want)
}
})
}
}
// Клетка 1: служба активна, API отвечает.
func TestDashboardStateActiveServiceReachableApi(t *testing.T) {
newTestDB(t)
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 2}})
withHysteriaServiceState(t, HysteriaServiceActive)
CollectMetricsSnapshot()
snapshot := DashboardSnapshot()
if snapshot.Hysteria.ServiceState != string(HysteriaServiceActive) || !snapshot.Hysteria.Running {
t.Fatalf("состояние службы: %+v", snapshot.Hysteria)
}
if !snapshot.Hysteria.ApiReachable {
t.Fatal("API отвечает, но объявлен недоступным")
}
if snapshot.OnlineDevices != 2 || snapshot.OnlinePeers != 1 {
t.Fatalf("картина подключений: пиров %d, устройств %d", snapshot.OnlinePeers, snapshot.OnlineDevices)
}
}
// Клетка 2: служба неактивна, API молчит. Оба факта согласованы, и оба верны.
func TestDashboardStateInactiveServiceUnreachableApi(t *testing.T) {
newTestDB(t)
pointHysteriaConfigAtDeadPort(t)
withHysteriaServiceState(t, HysteriaServiceInactive)
CollectMetricsSnapshot()
snapshot := DashboardSnapshot()
if snapshot.Hysteria.ServiceState != string(HysteriaServiceInactive) {
t.Fatalf("состояние службы объявлено как %q", snapshot.Hysteria.ServiceState)
}
if snapshot.Hysteria.ApiReachable {
t.Fatal("недоступный Traffic Stats API объявлен доступным")
}
}
// Клетка 3 — главная регрессия: спросить systemctl не удалось, но Hysteria
// жива и API отвечает.
//
// Прежний путь показывал «служба остановлена» и «API доступен» одновременно,
// причём второе — не сходив в API. Теперь неизвестность названа неизвестностью,
// а доступность API получена фактическим обращением.
func TestDashboardStateUnknownServiceReachableApi(t *testing.T) {
newTestDB(t)
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 1}})
withHysteriaServiceState(t, HysteriaServiceUnknown)
CollectMetricsSnapshot()
snapshot := DashboardSnapshot()
if snapshot.Hysteria.ServiceState != string(HysteriaServiceUnknown) {
t.Fatalf("неизвестное состояние выдано за %q", snapshot.Hysteria.ServiceState)
}
if snapshot.Hysteria.Running {
t.Fatal("неизвестное состояние выдано за работающую службу")
}
if !snapshot.Hysteria.ApiReachable {
t.Fatal("мнение systemd подменило фактический ответ Traffic Stats API")
}
if snapshot.OnlineDevices != 1 {
t.Fatalf("картина подключений потеряна: устройств %d", snapshot.OnlineDevices)
}
}
// Клетка 4: служба активна, но API отказывает — например, Hysteria ещё
// поднимается или порт занят чужим процессом.
func TestDashboardStateActiveServiceFailingApi(t *testing.T) {
newTestDB(t)
startTrafficStats(t, &trafficStatsStub{onlineStatus: http.StatusInternalServerError})
withHysteriaServiceState(t, HysteriaServiceActive)
CollectMetricsSnapshot()
snapshot := DashboardSnapshot()
if !snapshot.Hysteria.Running {
t.Fatal("активная служба объявлена остановленной")
}
if snapshot.Hysteria.ApiReachable {
t.Fatal("отказ API объявлен доступностью")
}
if snapshot.HysteriaState.Status != "error" {
t.Fatalf("отказ API не попал в состояние данных: %+v", snapshot.HysteriaState)
}
}
// «Состояние неизвестно» и «служба остановлена» — разные сообщения оператору с
// разной срочностью и разными действиями.
func TestDashboardSecurityRisksDistinguishUnknownFromStopped(t *testing.T) {
riskKeys := func(state HysteriaServiceState, apiReachable bool) []string {
summary := vo.DashboardSummaryVo{}
summary.Hysteria.ServiceState = string(state)
summary.Hysteria.Running = state == HysteriaServiceActive
summary.Hysteria.ApiReachable = apiReachable
keys := make([]string, 0)
for _, risk := range DashboardSecurityRisks(summary) {
keys = append(keys, risk.Key)
}
return keys
}
if got := riskKeys(HysteriaServiceActive, true); len(got) != 0 {
t.Fatalf("исправная система получила предупреждения: %v", got)
}
stopped := riskKeys(HysteriaServiceInactive, true)
if len(stopped) != 1 || stopped[0] != "dashboard.security.hysteriaStopped" {
t.Fatalf("остановленная служба: %v", stopped)
}
unknown := riskKeys(HysteriaServiceUnknown, true)
if len(unknown) != 1 || unknown[0] != "dashboard.security.hysteriaStateUnknown" {
t.Fatalf("неизвестное состояние выдано за остановленную службу: %v", unknown)
}
both := riskKeys(HysteriaServiceInactive, false)
if len(both) != 2 {
t.Fatalf("два независимых отказа схлопнулись в один: %v", both)
}
}