Files
HY2XS_flamy/apps/router/router_test.go
T
founder cf094f6e6f fix(v1): сделать отзыв доступа, бэкап и диагностику соответствующими своим именам
Проход по операциям, которые делают не то, что обещает их имя.

P0. Удаление bootstrap-admin-peer не было отзывом доступа. Признаком «создавать
пир или нет» служило наличие строки в таблице, а HY2XS_ADMIN_CON_PASS
продолжает жить в /etc/hy2xs/hy2xs.env — его читает systemd-юнит. Оператор
удалял пира, доступ исчезал, и ближайший restart возвращал того же пира с тем же
секретом. Молча. Признаком стала отметка BOOTSTRAP_PEER_SEEDED в таблице config:
«создавался когда-либо», а не «существует сейчас». Отметка и пир пишутся одной
транзакцией.

P1. Резервная копия с includeSecrets=true проглатывала и ошибку расшифровки, и
отсутствие шифртекста, отдавая пира с пустым secret и успешный ответ. Теперь
недоступный секрет любого пира отклоняет весь запрос с указанием имени.

P1. DecryptPeerSecret возвращала содержимое колонки как расшифрованный секрет,
если оно не начиналось с v1: — остаток поколения с открытыми секретами.

P1. doctor перезапускал hysteria-server и hy2xs-admin: диагностика подозрения на
проблему обрывала все живые соединения.

P1. Админка сама генерировала HYSTERIA2_TRAFFIC_STATS_SECRET, записать который в
/etc/hysteria/config.yaml она не может. Сервис объявлял себя здоровым, а machine
auth переставал совпадать.

P1. Обходы проверки зависимостей (accepted-risk/skipped) не могли произвести
артефакт: приёмка требует dependency_security_gate=true. Удалены из сборки и
документации, отсутствие проверяется приёмкой.

P2. UPDATE по отсутствующей строке config считался успехом, и cron
перепланировался при несохранённом значении. Решение по RowsAffected.

P2. Слой данных не отличал «записи нет» от «база не ответила»: sentinel-значения
ErrPeerNotFound / ErrAdminUserNotFound / ErrConfigNotFound / ErrStorage.

P2. Удалены алиасы /:id/client-url и /:id/qr.

Контракт разработки: apps/go.mod объявляет toolchain go1.26.7 (директива go —
языковой baseline, а не выбор компилятора), tools/dev/doctor.sh|.ps1 сверяют
среду с versions.env.
2026-08-30 06:48:50 +05:00

148 lines
5.0 KiB
Go

package router
import (
"strings"
"testing"
"github.com/gin-gonic/gin"
"hy2xs-admin/model/constant"
)
// Регистрация маршрутов — единственное место, где ошибка проявляется паникой
// при старте сервиса, а не ответом с кодом. Смена пространства имён API как
// раз относится к таким изменениям: конфликт с wildcard-маршрутом фронтенда
// или дублирующая регистрация обнаружились бы только на живом сервере.
func buildRoutes(t *testing.T) gin.RoutesInfo {
t.Helper()
gin.SetMode(gin.TestMode)
engine := gin.New()
defer func() {
if r := recover(); r != nil {
t.Fatalf("сборка маршрутов паникует: %v", r)
}
}()
Router(engine)
return engine.Routes()
}
func TestRouterRegistersMachineAuthUnderInternalNamespace(t *testing.T) {
routes := buildRoutes(t)
found := false
for _, route := range routes {
if route.Method == "POST" && route.Path == constant.HysteriaMachineAuthPath {
found = true
}
}
if !found {
t.Fatalf("machine-auth endpoint не зарегистрирован на %s", constant.HysteriaMachineAuthPath)
}
}
func TestRouterKeepsAdminAndAuthUnderApiNamespace(t *testing.T) {
routes := buildRoutes(t)
expected := []string{
constant.AdminAPIBase + "/auth/login",
constant.AdminAPIBase + "/peers",
constant.AdminAPIBase + "/config/listConfig",
constant.AdminAPIBase + "/peer-import",
constant.AdminAPIBase + "/peer-export",
}
present := make(map[string]bool, len(routes))
for _, route := range routes {
present[route.Path] = true
}
for _, path := range expected {
if !present[path] {
t.Errorf("маршрут %s не зарегистрирован", path)
}
}
}
// Старое пространство имён — часть runtime-контракта, а не внутреннее имя:
// его возвращение сломало бы конфиг Hysteria на уже установленных серверах.
func TestRouterHasNoLegacyNamespace(t *testing.T) {
for _, route := range buildRoutes(t) {
if strings.HasPrefix(route.Path, "/hui") {
t.Errorf("вернулось legacy-пространство имён: %s %s", route.Method, route.Path)
}
}
}
// Маршруты, операциями которых продукт не владеет, удалены, а не оставлены
// отвечающими «feature disabled».
func TestRouterHasNoRemovedRoutes(t *testing.T) {
removed := []string{
"exportConfig",
"importConfig",
"updateHysteria2Config",
"importHysteria2Config",
"hysteria2ChangeVersion",
"listRelease",
"restartServer",
"uploadCertFile",
"hysteria2AcmePath",
// getConfig принимал произвольный ключ таблицы `config` и был точечным
// входом к JWT_SECRET, PEER_SECRET_KEY и PEER_SECRET_ENCRYPTION_KEY.
// Потребителей у него не было ни одного.
"getConfig",
// Алиасы клиентской конфигурации, оставленные «на один миграционный
// релиз». Миграционного релиза у clean-install-only продукта нет, а в
// 1.0.0 они стали бы частью публичного API.
"client-url",
"/qr",
}
for _, route := range buildRoutes(t) {
for _, dead := range removed {
if strings.Contains(route.Path, dead) {
t.Errorf("удалённый маршрут вернулся: %s %s", route.Method, route.Path)
}
}
}
}
// Оставшиеся маршруты /config — это чтение по allowlist, запись по allowlist и
// санитизирующая выгрузка серверного конфига. Никакого generic-доступа к
// таблице `config` у API быть не должно.
func TestRouterConfigNamespaceIsClosed(t *testing.T) {
allowed := map[string]bool{
constant.AdminAPIBase + "/config/updateConfigs": true,
constant.AdminAPIBase + "/config/listConfig": true,
constant.AdminAPIBase + "/config/getHysteria2Config": true,
constant.AdminAPIBase + "/config/exportHysteria2Config": true,
}
for _, route := range buildRoutes(t) {
if !strings.HasPrefix(route.Path, constant.AdminAPIBase+"/config") {
continue
}
if !allowed[route.Path] {
t.Errorf("в пространстве /config появился неожиданный маршрут: %s %s", route.Method, route.Path)
}
}
present := make(map[string]bool)
for _, route := range buildRoutes(t) {
present[route.Path] = true
}
for path := range allowed {
if !present[path] {
t.Errorf("маршрут %s пропал", path)
}
}
}
func TestRouterExposesHealthz(t *testing.T) {
found := false
for _, route := range buildRoutes(t) {
if route.Method == "GET" && route.Path == "/healthz" {
found = true
}
}
if !found {
t.Fatal("/healthz не зарегистрирован")
}
}