Files
HY2XS_flamy/apps/dao/bootstrap_peer_test.go
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

331 lines
12 KiB
Go

package dao
import (
"path/filepath"
"strings"
"testing"
"hy2xs-admin/model/constant"
"hy2xs-admin/model/entity"
)
const (
testAdminPassword = "bootstrap-admin-password"
testBootstrapSecret = "bootstrap-peer-secret-value"
testTrafficStatsToken = "traffic-stats-machine-token"
)
// startService воспроизводит запуск сервиса на указанной базе.
//
// Именно последовательность целиком, а не отдельная функция: дефект, ради
// которого написаны эти тесты, проявлялся только на ВТОРОМ запуске.
func startService(t *testing.T, dbPath string) error {
t.Helper()
t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", testAdminPassword)
t.Setenv("HY2XS_ADMIN_CON_PASS", testBootstrapSecret)
t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", testTrafficStatsToken)
err := InitSqlAt(dbPath)
t.Cleanup(func() { _ = CloseSqliteDB() })
return err
}
func newInstalledService(t *testing.T) string {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
if err := startService(t, dbPath); err != nil {
t.Fatalf("первый запуск сервиса не удался: %v", err)
}
return dbPath
}
func testPeer(name string, authID string, digest string) entity.Peer {
remark := ""
cipher := ""
quota := int64(-1)
zero := int64(0)
maxDevices := int64(3)
return entity.Peer{
Name: &name,
Remark: &remark,
AuthId: &authID,
SecretDigest: &digest,
SecretEncrypted: &cipher,
QuotaBytes: &quota,
DownloadBytes: &zero,
UploadBytes: &zero,
ExpiresAt: &zero,
MaxDevices: &maxDevices,
Disabled: &zero,
BannedUntil: &zero,
LastConnectionAt: &zero,
}
}
func bootstrapPeerExists(t *testing.T) bool {
t.Helper()
_, err := GetPeer("name = ?", BootstrapPeerName)
if err == nil {
return true
}
if !IsNotFound(err) {
t.Fatalf("неожиданная ошибка при чтении пира установщика: %v", err)
}
return false
}
// Установка создаёт пира установщика и отмечает это в базе.
func TestBootstrapPeerIsSeededOnFirstStart(t *testing.T) {
newInstalledService(t)
if !bootstrapPeerExists(t) {
t.Fatal("пир установщика не создан при первом запуске")
}
seeded, err := bootstrapPeerSeeded()
if err != nil {
t.Fatalf("чтение отметки: %v", err)
}
if !seeded {
t.Fatal("отметка о создании пира установщика не выставлена")
}
}
// Обычный перезапуск сервиса ничего не меняет: пир на месте, дублей нет.
func TestBootstrapPeerSurvivesRestartUntouched(t *testing.T) {
dbPath := newInstalledService(t)
before, err := GetPeer("name = ?", BootstrapPeerName)
if err != nil {
t.Fatalf("пир установщика не найден после установки: %v", err)
}
_ = CloseSqliteDB()
if err := startService(t, dbPath); err != nil {
t.Fatalf("перезапуск сервиса не удался: %v", err)
}
after, err := GetPeer("name = ?", BootstrapPeerName)
if err != nil {
t.Fatalf("пир установщика пропал после перезапуска: %v", err)
}
if *after.Id != *before.Id {
t.Fatalf("пир установщика пересоздан: id %d -> %d", *before.Id, *after.Id)
}
peers, err := ListPeer("name = ?", BootstrapPeerName)
if err != nil {
t.Fatalf("список пиров: %v", err)
}
if len(peers) != 1 {
t.Fatalf("после перезапуска пиров установщика %d, ожидался ровно один", len(peers))
}
}
// Главный инвариант: удаление пира установщика — необратимый отзыв доступа.
//
// Раньше HY2XS_ADMIN_CON_PASS оставался в /etc/hy2xs/hy2xs.env, и признаком
// «создавать или нет» служило наличие строки в таблице. Поэтому
// `systemctl restart hy2xs-admin` после удаления возвращал того же пира с тем
// же секретом, молча, и отозванные учётные данные снова становились
// действующими.
func TestDeletedBootstrapPeerIsNotRecreatedOnRestart(t *testing.T) {
dbPath := newInstalledService(t)
peer, err := GetPeer("name = ?", BootstrapPeerName)
if err != nil {
t.Fatalf("пир установщика не найден после установки: %v", err)
}
if err := DeletePeer([]int64{*peer.Id}); err != nil {
t.Fatalf("удаление пира установщика отклонено: %v", err)
}
_ = CloseSqliteDB()
// Переменная окружения на месте — ровно то состояние, в котором сервис и
// живёт после установки.
if err := startService(t, dbPath); err != nil {
t.Fatalf("перезапуск сервиса после отзыва не удался: %v", err)
}
if bootstrapPeerExists(t) {
t.Fatal("пир установщика воскрес после перезапуска: отзыв доступа не переживает restart")
}
}
// Отзыв обязан пережить не один перезапуск, а любое их число.
func TestDeletedBootstrapPeerStaysGoneAcrossRepeatedRestarts(t *testing.T) {
dbPath := newInstalledService(t)
peer, err := GetPeer("name = ?", BootstrapPeerName)
if err != nil {
t.Fatalf("пир установщика не найден: %v", err)
}
if err := DeletePeer([]int64{*peer.Id}); err != nil {
t.Fatalf("удаление: %v", err)
}
_ = CloseSqliteDB()
for i := 0; i < 3; i++ {
if err := startService(t, dbPath); err != nil {
t.Fatalf("перезапуск #%d не удался: %v", i+1, err)
}
if bootstrapPeerExists(t) {
t.Fatalf("пир установщика воскрес на перезапуске #%d", i+1)
}
_ = CloseSqliteDB()
}
}
// Отключённый пир остаётся в базе и не переподписывается сам собой.
func TestDisabledBootstrapPeerKeepsItsStateAcrossRestart(t *testing.T) {
dbPath := newInstalledService(t)
peer, err := GetPeer("name = ?", BootstrapPeerName)
if err != nil {
t.Fatalf("пир установщика не найден: %v", err)
}
if err := UpdatePeer([]int64{*peer.Id}, map[string]interface{}{"disabled": 1}); err != nil {
t.Fatalf("отключение пира установщика: %v", err)
}
_ = CloseSqliteDB()
if err := startService(t, dbPath); err != nil {
t.Fatalf("перезапуск: %v", err)
}
after, err := GetPeer("name = ?", BootstrapPeerName)
if err != nil {
t.Fatalf("пир установщика пропал: %v", err)
}
if after.Disabled == nil || *after.Disabled != 1 {
t.Fatal("перезапуск снял отключение с пира установщика")
}
if *after.SecretDigest != *peer.SecretDigest {
t.Fatal("перезапуск переподписал пира установщика")
}
}
// Отсутствие HY2XS_ADMIN_CON_PASS на чистой базе — отказ старта, а не повод
// придумать секрет: он продублирован в /etc/hy2xs/bootstrap-admin.secret.
func TestFirstStartWithoutBootstrapSecretFails(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", testAdminPassword)
t.Setenv("HY2XS_ADMIN_CON_PASS", "")
t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", testTrafficStatsToken)
err := InitSqlAt(dbPath)
t.Cleanup(func() { _ = CloseSqliteDB() })
if err == nil {
t.Fatal("запуск без HY2XS_ADMIN_CON_PASS не отклонён")
}
if !strings.Contains(err.Error(), "HY2XS_ADMIN_CON_PASS") {
t.Errorf("отказ не называет переменную: %v", err)
}
}
// Перезапуск уже установленного сервиса без переменной обязан работать: пир
// создан однажды, и требовать секрет второй раз незачем.
func TestRestartWithoutBootstrapSecretSucceeds(t *testing.T) {
dbPath := newInstalledService(t)
_ = CloseSqliteDB()
t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", testAdminPassword)
t.Setenv("HY2XS_ADMIN_CON_PASS", "")
t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", testTrafficStatsToken)
if err := InitSqlAt(dbPath); err != nil {
t.Fatalf("перезапуск установленного сервиса без HY2XS_ADMIN_CON_PASS отклонён: %v", err)
}
t.Cleanup(func() { _ = CloseSqliteDB() })
if !bootstrapPeerExists(t) {
t.Fatal("пир установщика пропал при перезапуске без переменной")
}
}
// Отметка и сам пир пишутся неделимо: иначе падение между двумя операциями
// вернуло бы прежнее поведение на следующем старте.
func TestBootstrapSeedIsAtomic(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
if err := InitSqliteDBAt(dbPath); err != nil {
t.Fatalf("открытие базы: %v", err)
}
t.Cleanup(func() { _ = CloseSqliteDB() })
if err := RunMigrations(); err != nil {
t.Fatalf("миграции: %v", err)
}
// Занимаем имя обычной записью: следующая вставка упрётся в UNIQUE(name)
// уже ВНУТРИ транзакции. Отметка не имеет права остаться выставленной.
if _, err := SavePeer(testPeer(BootstrapPeerName, "some-auth-id", "some-digest")); err != nil {
t.Fatalf("подготовка конфликтующей записи: %v", err)
}
if err := seedBootstrapPeerOnce(testPeer(BootstrapPeerName, BootstrapPeerName, "bootstrap-digest")); err == nil {
t.Fatal("вставка с конфликтом UNIQUE(name) не отклонена")
}
seeded, err := bootstrapPeerSeeded()
if err != nil {
t.Fatalf("чтение отметки: %v", err)
}
if seeded {
t.Fatal("отметка выставлена, хотя пир не записан: транзакции нет")
}
}
// Токен trafficStats — межкомпонентный контракт: при пустом env и пустой базе
// админка обязана отказать, а не сгенерировать значение, которого не знает
// Hysteria.
func TestTrafficStatsSecretIsNotInvented(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", testAdminPassword)
t.Setenv("HY2XS_ADMIN_CON_PASS", testBootstrapSecret)
t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", "")
err := InitSqlAt(dbPath)
t.Cleanup(func() { _ = CloseSqliteDB() })
if err == nil {
t.Fatal("запуск без machine token не отклонён: админка придумала его сама")
}
if !strings.Contains(err.Error(), "HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET") {
t.Errorf("отказ не называет переменную: %v", err)
}
cfg, cfgErr := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
if cfgErr == nil && cfg.Value != nil && strings.TrimSpace(*cfg.Value) != "" {
t.Fatal("в базе появился сгенерированный machine token")
}
}
// Уже согласованный токен в базе позволяет запуститься без переменной: это не
// изобретение контракта, а использование существующего.
func TestTrafficStatsSecretFromDatabaseIsAccepted(t *testing.T) {
dbPath := newInstalledService(t)
_ = CloseSqliteDB()
t.Setenv("HY2XS_ADMIN_INITIAL_PASSWORD", testAdminPassword)
t.Setenv("HY2XS_ADMIN_CON_PASS", testBootstrapSecret)
t.Setenv("HY2XS_HYSTERIA_TRAFFIC_STATS_SECRET", "")
if err := InitSqlAt(dbPath); err != nil {
t.Fatalf("перезапуск с токеном в базе отклонён: %v", err)
}
t.Cleanup(func() { _ = CloseSqliteDB() })
cfg, err := GetConfig("key = ?", constant.Hysteria2TrafficStatsSecret)
if err != nil {
t.Fatalf("токен пропал из базы: %v", err)
}
if strings.TrimSpace(*cfg.Value) != testTrafficStatsToken {
t.Fatalf("токен подменён: %q", *cfg.Value)
}
}