Files
HY2XS_flamy/apps/service/peer_access_test.go
T
founder 162759c599 fix(admin): достроить вторые половины отзыва доступа, лимита и журнала
Разбор кода на c0a43ae со сверкой с официальной документацией Hysteria 2.
Общая тема: операции, у которых была только одна из двух необходимых половин.

Отзыв доступа. Запись disabled=1 видит лишь выборка в Hysteria2Auth, то есть
закрывает БУДУЩИЕ обращения к HTTP-auth; установленная QUIC-сессия живёт своей
жизнью и сама не разрывается. После «Отключить» пир пользовался доступом сколько
угодно долго, а панель показывала его отключённым. Появился DisconnectPeers —
только официальный Traffic Stats /kick, без записи в базу; прежний Hysteria2Kick
вместе с разрывом проставлял banned_until и потому для отключения не годился.
Порядок «запись, затем разрыв» обратному не подлежит и доказан снимком базы в
момент прихода /kick. Неудача разрыва не откатывает disabled и сообщается кодом
peer_disconnect_failed: обычная ошибка означала бы для оператора вывод, прямо
противоположный истине. KickPeer переведён на тот же примитив — он писал
banned_until дважды и мог ответить чистым отказом уже в применённом состоянии.

Ограничение устройств. Отказ /online обрабатывался возвратом успеха
авторизации, то есть недоступность 127.0.0.1 превращала объявленный лимит в
безлимит. Вторая половина дыры была тише: Hysteria2Online отдавал пустую карту
БЕЗ ошибки, когда systemd отвечал «служба неактивна», — а этот ответ не
отличается от «спросить systemctl не удалось». Пути разделены: терпимый для
отображения, строгий для решения о доступе. Hysteria2IsRunning убран с путей
принятия решений совсем.

Журнал. entry.Info() вызывался без аргумента, и logrus писал "msg":"" для
каждого запроса — пустой столбец на экране был точным отражением файла. Ветка
«файла ещё нет» отвечала голым массивом вместо {records,total}, поэтому на
свежей установке страница системных логов не работала вовсе. Битая строка
вызывала vo.Fail И continue: клиент получал два JSON-документа подряд.

Панель. Общий LogViewer и utils/download.ts (копий скачивания было четыре, две
ставили запрос вне try и глушили причину); меню на command с быстрым
включением/отключением; popper-style у подсказки; kick с подтверждением и
названным сроком; отмена подтверждений перестала быть ошибкой. Отдельно:
skipErrorToast гасил и транспортный отказ, при том что страницы писали
«перехватчик уже показал» и молчали, — обрыв связи не показывал ничего.

Закреплено go-тестами против настоящего HTTP, контрактными тестами панели и
двумя гейтами приёмки. Ручная часть — в
docs/acceptance/2026-09-01-v1.0.0-rc2-preflight-findings.md.
2026-09-01 17:17:17 +05:00

621 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"testing"
"hy2xs-admin/dao"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/dto"
)
// Отзыв доступа проверяется ПРОТИВ НАСТОЯЩЕГО HTTP, а не против подменённого
// клиента.
//
// Контракт, который здесь доказывается, — официальный Traffic Stats API
// Hysteria: метод, путь, заголовок Authorization и JSON-массив идентификаторов
// в теле. Подменённый на уровне Go клиент доказал бы только то, что вызвана
// нужная функция, и молча пережил бы, например, потерю заголовка. Слой proxy
// ходит на 127.0.0.1:<порт из конфига>, а httptest слушает ровно там, поэтому
// настоящий запрос ставится без единой правки продуктового кода.
const testTrafficStatsSecret = "test-traffic-stats-secret"
// trafficStatsStub — Traffic Stats API Hysteria в объёме, который использует
// админка.
type trafficStatsStub struct {
mu sync.Mutex
// online отдаётся на GET /online.
online map[string]int64
// onlineStatus != 0 подменяет ответ /online кодом отказа.
onlineStatus int
// kickStatus != 0 подменяет ответ /kick кодом отказа.
kickStatus int
// Наблюдения.
onlineCalls int
kickCalls int
kickedKeys [][]string
kickAuth []string
// disabledAtKick — состояние колонки disabled на момент прихода /kick.
// Именно этим доказывается порядок «сначала запись, потом разрыв»:
// проверить его после операции нельзя, там оба шага уже завершены.
disabledAtKick []map[string]int64
bannedAtKick []map[string]int64
}
func (s *trafficStatsStub) snapshot(t *testing.T) (map[string]int64, map[string]int64) {
t.Helper()
disabled := map[string]int64{}
banned := map[string]int64{}
peers, err := dao.ListPeer("1=1")
if err != nil {
return disabled, banned
}
for _, peer := range peers {
if peer.Name == nil {
continue
}
if peer.Disabled != nil {
disabled[*peer.Name] = *peer.Disabled
}
if peer.BannedUntil != nil {
banned[*peer.Name] = *peer.BannedUntil
}
}
return disabled, banned
}
// startTrafficStats поднимает Traffic Stats API на 127.0.0.1 и указывает на
// него конфиг Hysteria, который читает админка.
func startTrafficStats(t *testing.T, stub *trafficStatsStub) *trafficStatsStub {
t.Helper()
if stub == nil {
stub = &trafficStatsStub{}
}
if stub.online == nil {
stub.online = map[string]int64{}
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
stub.mu.Lock()
defer stub.mu.Unlock()
switch r.URL.Path {
case "/online":
stub.onlineCalls++
if stub.onlineStatus != 0 {
w.WriteHeader(stub.onlineStatus)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(stub.online)
case "/kick":
stub.kickCalls++
stub.kickAuth = append(stub.kickAuth, r.Header.Get("Authorization"))
disabled, banned := stub.snapshot(t)
stub.disabledAtKick = append(stub.disabledAtKick, disabled)
stub.bannedAtKick = append(stub.bannedAtKick, banned)
var keys []string
if err := json.NewDecoder(r.Body).Decode(&keys); err != nil {
w.WriteHeader(http.StatusBadRequest)
return
}
stub.kickedKeys = append(stub.kickedKeys, keys)
if stub.kickStatus != 0 {
w.WriteHeader(stub.kickStatus)
return
}
w.WriteHeader(http.StatusOK)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(server.Close)
pointHysteriaConfigAt(t, server.URL)
if err := dao.UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, testTrafficStatsSecret); err != nil {
t.Fatalf("не удалось записать секрет Traffic Stats API: %v", err)
}
return stub
}
// pointHysteriaConfigAt пишет конфиг Hysteria, в котором Traffic Stats API
// слушает порт переданного адреса.
func pointHysteriaConfigAt(t *testing.T, serverURL string) {
t.Helper()
host := serverURL
for _, prefix := range []string{"http://", "https://"} {
if len(host) > len(prefix) && host[:len(prefix)] == prefix {
host = host[len(prefix):]
}
}
_, port, err := net.SplitHostPort(host)
if err != nil {
t.Fatalf("не удалось разобрать адрес тестового сервера %q: %v", serverURL, err)
}
path := filepath.Join(t.TempDir(), "config.yaml")
content := fmt.Sprintf("listen: \":443\"\ntrafficStats:\n listen: \"127.0.0.1:%s\"\n secret: %q\n",
port, testTrafficStatsSecret)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("не удалось записать тестовый конфиг Hysteria: %v", err)
}
previous := constant.Hysteria2ConfigPath
constant.Hysteria2ConfigPath = path
t.Cleanup(func() { constant.Hysteria2ConfigPath = previous })
}
// pointHysteriaConfigAtDeadPort указывает конфиг на порт, который никто не
// слушает: так воспроизводится недоступность Traffic Stats API.
func pointHysteriaConfigAtDeadPort(t *testing.T) {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("не удалось занять порт: %v", err)
}
addr := listener.Addr().String()
// Порт освобождается сразу: адрес известен, слушателя на нём нет.
if err := listener.Close(); err != nil {
t.Fatalf("не удалось освободить порт: %v", err)
}
pointHysteriaConfigAt(t, "http://"+addr)
if err := dao.UpsertConfigValue(constant.Hysteria2TrafficStatsSecret, testTrafficStatsSecret); err != nil {
t.Fatalf("не удалось записать секрет Traffic Stats API: %v", err)
}
}
// withHysteriaRunning подменяет ответ systemd о состоянии службы.
func withHysteriaRunning(t *testing.T, running bool) {
t.Helper()
previous := hysteria2IsRunning
hysteria2IsRunning = func() bool { return running }
t.Cleanup(func() { hysteria2IsRunning = previous })
}
func peerByName(t *testing.T, name string) (disabled int64, bannedUntil int64) {
t.Helper()
peer, ok := snapshotPeers(t)[name]
if !ok {
t.Fatalf("пир %q не найден", name)
}
if peer.Disabled != nil {
disabled = *peer.Disabled
}
if peer.BannedUntil != nil {
bannedUntil = *peer.BannedUntil
}
return disabled, bannedUntil
}
func requireDisconnectError(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("частичный результат не сообщён: ошибки нет вовсе")
}
var peerErr *PeerError
if !errors.As(err, &peerErr) {
t.Fatalf("отказ не несёт машиночитаемого кода: %v", err)
}
if peerErr.Code != constant.ErrCodePeerDisconnectFailed {
t.Fatalf("ожидался код %q, получен %q", constant.ErrCodePeerDisconnectFailed, peerErr.Code)
}
if peerErr.Field != "" {
t.Errorf("частичный результат отнесён к полю формы %q, хотя относится к операции целиком", peerErr.Field)
}
}
// --- DisconnectPeers ---------------------------------------------------------
func TestDisconnectPeersSendsOfficialKickContract(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
if err := DisconnectPeers([]int64{id}); err != nil {
t.Fatalf("разрыв сессии отказал: %v", err)
}
if stub.kickCalls != 1 {
t.Fatalf("ожидался ровно один вызов /kick, было %d", stub.kickCalls)
}
if got := stub.kickedKeys[0]; len(got) != 1 || got[0] != "alpha-auth-id" {
t.Fatalf("в /kick уехали не те идентификаторы: %v", got)
}
if stub.kickAuth[0] != testTrafficStatsSecret {
t.Fatalf("секрет Traffic Stats API не передан в Authorization: %q", stub.kickAuth[0])
}
}
// Разрыв сессии НИЧЕГО не пишет в базу. Прежний Hysteria2Kick заодно
// проставлял banned_until, из-за чего им нельзя было воспользоваться для
// операции «Отключить»: она записала бы временную блокировку — другой механизм
// с другим сроком жизни.
func TestDisconnectPeersDoesNotTouchPeerState(t *testing.T) {
newTestDB(t)
startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
if err := DisconnectPeers([]int64{id}); err != nil {
t.Fatalf("разрыв сессии отказал: %v", err)
}
disabled, bannedUntil := peerByName(t, "alpha1")
if disabled != 0 {
t.Errorf("разрыв сессии изменил disabled: %d", disabled)
}
if bannedUntil != 0 {
t.Errorf("разрыв сессии изменил banned_until: %d", bannedUntil)
}
}
func TestDisconnectPeersIsNoopWithoutTargets(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
if err := DisconnectPeers(nil); err != nil {
t.Fatalf("пустой список признан отказом: %v", err)
}
// Пир без authId Hysteria не знает: рвать нечего.
id := seedPeer(t, "alpha1", "")
if err := DisconnectPeers([]int64{id}); err != nil {
t.Fatalf("пир без authId признан отказом: %v", err)
}
if stub.kickCalls != 0 {
t.Fatalf("сделан вызов /kick без единой цели: %d", stub.kickCalls)
}
}
func TestDisconnectPeersReportsApiFailure(t *testing.T) {
newTestDB(t)
startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError})
id := seedPeer(t, "alpha1", "alpha-auth-id")
if err := DisconnectPeers([]int64{id}); err == nil {
t.Fatal("отказ Traffic Stats API не сообщён")
}
}
// Состояние службы по systemd на этом пути не спрашивается вовсе: ответ
// systemctl не отличает «служба неактивна» от «спросить не удалось», и на
// прежнем пути его сбой отказывал операции при живой Hysteria.
func TestDisconnectPeersIgnoresSystemdOpinion(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
withHysteriaRunning(t, false)
id := seedPeer(t, "alpha1", "alpha-auth-id")
if err := DisconnectPeers([]int64{id}); err != nil {
t.Fatalf("разрыв сессии отказал из-за мнения systemd: %v", err)
}
if stub.kickCalls != 1 {
t.Fatalf("разрыв сессии пропущен из-за мнения systemd: вызовов /kick %d", stub.kickCalls)
}
}
// --- Отключение пира ---------------------------------------------------------
func TestUpdatePeerDisableWritesThenDisconnects(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
one := int64(1)
if err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one}); err != nil {
t.Fatalf("отключение пира отказало: %v", err)
}
disabled, bannedUntil := peerByName(t, "alpha1")
if disabled != 1 {
t.Fatalf("пир не отключён: disabled=%d", disabled)
}
// Временная блокировка — независимый механизм, отключение её не ставит.
if bannedUntil != 0 {
t.Errorf("отключение проставило banned_until=%d", bannedUntil)
}
if stub.kickCalls != 1 {
t.Fatalf("активная сессия не разорвана: вызовов /kick %d", stub.kickCalls)
}
// Порядок: к моменту прихода /kick запрет новых подключений уже в базе.
// При обратном порядке клиент успевает переподключиться в окне между
// разрывом и записью и остаётся на связи с формально отключённым пиром.
if got := stub.disabledAtKick[0]["alpha1"]; got != 1 {
t.Fatalf("/kick пришёл раньше записи disabled: на момент разрыва disabled=%d", got)
}
}
// Неудача разрыва НЕ откатывает запрет новых подключений: безопасная половина
// операции достигнута, и возвращать пиру полный доступ из-за второго шага
// нельзя.
func TestUpdatePeerDisableKeepsStateWhenDisconnectFails(t *testing.T) {
newTestDB(t)
startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError})
id := seedPeer(t, "alpha1", "alpha-auth-id")
one := int64(1)
err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one})
requireDisconnectError(t, err)
if disabled, _ := peerByName(t, "alpha1"); disabled != 1 {
t.Fatalf("запрет новых подключений откачен после неудачного разрыва: disabled=%d", disabled)
}
}
// Включение пира не рвёт сессий и не трогает временную блокировку: это
// независимые механизмы.
func TestUpdatePeerEnableDoesNotDisconnect(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
if err := KickPeer(id, 1_700_000_000_000); err != nil {
t.Fatalf("временная блокировка отказала: %v", err)
}
kicksAfterBan := stub.kickCalls
zero := int64(0)
if err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &zero}); err != nil {
t.Fatalf("включение пира отказало: %v", err)
}
if stub.kickCalls != kicksAfterBan {
t.Fatalf("включение пира вызвало /kick: %d -> %d", kicksAfterBan, stub.kickCalls)
}
disabled, bannedUntil := peerByName(t, "alpha1")
if disabled != 0 {
t.Fatalf("пир не включён: disabled=%d", disabled)
}
if bannedUntil != 1_700_000_000_000 {
t.Errorf("включение сбросило независимую временную блокировку: banned_until=%d", bannedUntil)
}
}
// Правка постороннего поля не является отзывом доступа и сессий не рвёт.
func TestUpdatePeerUnrelatedFieldDoesNotDisconnect(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
if err := UpdatePeer(id, dto.PeerUpdateDto{Remark: strPtr("ноутбук")}); err != nil {
t.Fatalf("правка комментария отказала: %v", err)
}
if stub.kickCalls != 0 {
t.Fatalf("правка комментария разорвала сессии: вызовов /kick %d", stub.kickCalls)
}
}
// Операция повторяема: условие смотрит на ЗАПРОШЕННОЕ состояние, а не на
// переход из включённого. Иначе после неудачного разрыва оператору пришлось бы
// включить пира, чтобы получить право отключить его снова.
func TestUpdatePeerDisableIsRetryable(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError})
id := seedPeer(t, "alpha1", "alpha-auth-id")
one := int64(1)
requireDisconnectError(t, UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one}))
stub.mu.Lock()
stub.kickStatus = 0
stub.mu.Unlock()
if err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one}); err != nil {
t.Fatalf("повторное отключение отказало: %v", err)
}
if stub.kickCalls != 2 {
t.Fatalf("повторная попытка не дошла до /kick: вызовов %d", stub.kickCalls)
}
}
// --- Временная блокировка ----------------------------------------------------
func TestKickPeerWritesBanOnceAndDisconnects(t *testing.T) {
newTestDB(t)
stub := startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
const bannedUntil = int64(1_700_000_000_000)
if err := KickPeer(id, bannedUntil); err != nil {
t.Fatalf("временная блокировка отказала: %v", err)
}
disabled, banned := peerByName(t, "alpha1")
if banned != bannedUntil {
t.Fatalf("banned_until не записан: %d", banned)
}
// Временная блокировка и отключение независимы.
if disabled != 0 {
t.Errorf("временная блокировка изменила disabled: %d", disabled)
}
if stub.kickCalls != 1 {
t.Fatalf("ожидался ровно один вызов /kick, было %d", stub.kickCalls)
}
if got := stub.bannedAtKick[0]["alpha1"]; got != bannedUntil {
t.Fatalf("/kick пришёл раньше записи banned_until: на момент разрыва %d", got)
}
}
// Регрессия: прежний путь начинался с проверки состояния службы и возвращал
// чистый отказ, НЕ СДЕЛАВ ничего, — при том что запись banned_until к этому
// моменту уже применилась. Оператор видел «не сработало» у сработавшей
// блокировки.
func TestKickPeerReportsPartialResultWhenDisconnectFails(t *testing.T) {
newTestDB(t)
startTrafficStats(t, &trafficStatsStub{kickStatus: http.StatusInternalServerError})
id := seedPeer(t, "alpha1", "alpha-auth-id")
const bannedUntil = int64(1_700_000_000_000)
requireDisconnectError(t, KickPeer(id, bannedUntil))
if _, banned := peerByName(t, "alpha1"); banned != bannedUntil {
t.Fatalf("блокировка не применена, хотя сообщён частичный результат: banned_until=%d", banned)
}
}
func TestReleaseKickPeerLeavesDisabledAlone(t *testing.T) {
newTestDB(t)
startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
one := int64(1)
if err := UpdatePeer(id, dto.PeerUpdateDto{Disabled: &one}); err != nil {
t.Fatalf("отключение пира отказало: %v", err)
}
if err := KickPeer(id, 1_700_000_000_000); err != nil {
t.Fatalf("временная блокировка отказала: %v", err)
}
if err := ReleaseKickPeer(id); err != nil {
t.Fatalf("снятие блокировки отказало: %v", err)
}
disabled, banned := peerByName(t, "alpha1")
if banned != 0 {
t.Fatalf("banned_until не сброшен: %d", banned)
}
if disabled != 1 {
t.Fatalf("снятие временной блокировки включило отключённого пира: disabled=%d", disabled)
}
}
// --- Ограничение устройств (fail-closed) -------------------------------------
func TestHysteria2AuthRejectsWhenTrafficStatsRefuses(t *testing.T) {
newTestDB(t)
startTrafficStats(t, &trafficStatsStub{onlineStatus: http.StatusInternalServerError})
seedPeer(t, "alpha1", "alpha-auth-id")
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
t.Fatal("отказ Traffic Stats API впустил подключение без проверки лимита устройств")
}
}
func TestHysteria2AuthRejectsWhenTrafficStatsUnreachable(t *testing.T) {
newTestDB(t)
pointHysteriaConfigAtDeadPort(t)
seedPeer(t, "alpha1", "alpha-auth-id")
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
t.Fatal("недоступность Traffic Stats API впустила подключение без проверки лимита устройств")
}
}
// Регрессия AUTH-02, вторая половина дыры и самая тихая: общий Hysteria2Online
// отдавал ПУСТУЮ КАРТУ БЕЗ ОШИБКИ, когда systemd отвечал «служба неактивна», —
// а этот ответ не отличается от «спросить systemctl не удалось». Лимит
// устройств переставал проверяться, и в журнале не появлялось ни строки.
func TestHysteria2AuthEnforcesLimitWhenSystemdSaysStopped(t *testing.T) {
newTestDB(t)
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 3}})
withHysteriaRunning(t, false)
seedPeer(t, "alpha1", "alpha-auth-id")
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
t.Fatal("мнение systemd отключило проверку лимита устройств")
}
}
func TestHysteria2AuthAcceptsBelowDeviceLimit(t *testing.T) {
newTestDB(t)
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 2}})
id := seedPeer(t, "alpha1", "alpha-auth-id")
gotID, gotAuth, err := Hysteria2Auth("alpha1-secret")
if err != nil {
t.Fatalf("подключение в пределах лимита отклонено: %v", err)
}
if gotID != id || gotAuth != "alpha-auth-id" {
t.Fatalf("авторизация вернула не того пира: id=%d authId=%q", gotID, gotAuth)
}
}
func TestHysteria2AuthRejectsAtDeviceLimit(t *testing.T) {
newTestDB(t)
// seedPeer создаёт пира с maxDevices = 3.
startTrafficStats(t, &trafficStatsStub{online: map[string]int64{"alpha-auth-id": 3}})
seedPeer(t, "alpha1", "alpha-auth-id")
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
t.Fatal("подключение сверх лимита устройств принято")
}
}
// Повреждённая граница — это не «безлимит». Строка без пригодного maxDevices
// на пути принятия решения о доступе обязана вести к отказу.
func TestHysteria2AuthRejectsUnusableDeviceLimit(t *testing.T) {
newTestDB(t)
startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{"max_devices": 0}); err != nil {
t.Fatalf("не удалось подготовить повреждённую строку: %v", err)
}
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
t.Fatal("пир без пригодного лимита устройств принят")
}
}
// Путь машинной авторизации проходят ВСЕ подключения пиров, поэтому
// разыменование nil на нём означает панику в обработчике, а не отказ одному
// клиенту.
func TestHysteria2AuthRejectsPeerWithoutIdentity(t *testing.T) {
newTestDB(t)
startTrafficStats(t, nil)
id := seedPeer(t, "alpha1", "alpha-auth-id")
if err := dao.UpdatePeer([]int64{id}, map[string]interface{}{"auth_id": ""}); err != nil {
t.Fatalf("не удалось подготовить повреждённую строку: %v", err)
}
defer func() {
if recovered := recover(); recovered != nil {
t.Fatalf("повреждённая строка уронила обработчик авторизации: %v", recovered)
}
}()
if _, _, err := Hysteria2Auth("alpha1-secret"); err == nil {
t.Fatal("пир без идентичности принят")
}
}
// Отображение остаётся терпимым: пустая картина подключений — честный ответ на
// вопрос «кто сейчас на связи», когда служба остановлена. Строгость нужна
// только там, где принимается решение о доступе.
func TestHysteria2OnlineStaysTolerantForDisplay(t *testing.T) {
newTestDB(t)
pointHysteriaConfigAtDeadPort(t)
withHysteriaRunning(t, false)
online, err := Hysteria2Online()
if err != nil {
t.Fatalf("отображение подключений стало отказывать: %v", err)
}
if len(online) != 0 {
t.Fatalf("ожидалась пустая картина подключений, получено %v", online)
}
}