cf094f6e6f
Проход по операциям, которые делают не то, что обещает их имя. 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.
148 lines
5.0 KiB
Go
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 не зарегистрирован")
|
|
}
|
|
}
|