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.
This commit is contained in:
2026-08-30 06:48:50 +05:00
parent e30fdaa004
commit cf094f6e6f
33 changed files with 1976 additions and 154 deletions
+145
View File
@@ -0,0 +1,145 @@
package dao
import (
"errors"
"path/filepath"
"strings"
"testing"
"hy2xs-admin/model/constant"
)
func newConfigDB(t *testing.T) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
if err := InitSqliteDBAt(dbPath); err != nil {
t.Fatalf("не удалось открыть тестовую базу: %v", err)
}
if err := RunMigrations(); err != nil {
t.Fatalf("не удалось применить миграции: %v", err)
}
t.Cleanup(func() { _ = CloseSqliteDB() })
}
// Запись в отсутствующую строку — отказ, а не тихий успех.
//
// UPDATE без совпавших строк не является ошибкой SQL: gorm возвращает nil и
// RowsAffected = 0. Пока результат определялся только по tx.Error, оператор
// получал подтверждение изменения, которого не произошло, а
// applyRuntimeConfigUpdates следом применял новое значение к планировщику —
// то есть рантайм и база расходились до ближайшего перезапуска сервиса.
func TestUpdateConfigFailsWhenRowIsMissing(t *testing.T) {
newConfigDB(t)
if tx := sqliteDB.Exec("DELETE FROM config WHERE key = ?", constant.ResetTrafficCron); tx.Error != nil {
t.Fatalf("подготовка повреждённой базы: %v", tx.Error)
}
err := UpdateConfig([]string{constant.ResetTrafficCron}, map[string]interface{}{"value": "@weekly"})
if err == nil {
t.Fatal("запись в отсутствующую строку выдана за успех")
}
if !strings.Contains(err.Error(), constant.ResetTrafficCron) {
t.Errorf("отказ не называет ключ: %v", err)
}
// UpdateConfig обновляет существующее и не имеет права заводить ключи:
// иначе он молча создавал бы строки, которых нет в модели продукта.
if _, err := GetConfig("key = ?", constant.ResetTrafficCron); !errors.Is(err, ErrConfigNotFound) {
t.Fatalf("строка появилась после отказа: %v", err)
}
}
// Обычная запись в существующую строку продолжает работать.
func TestUpdateConfigWritesExistingRow(t *testing.T) {
newConfigDB(t)
if err := UpdateConfig([]string{constant.ResetTrafficCron}, map[string]interface{}{"value": "@weekly"}); err != nil {
t.Fatalf("запись существующего ключа отклонена: %v", err)
}
cfg, err := GetConfig("key = ?", constant.ResetTrafficCron)
if err != nil {
t.Fatalf("чтение: %v", err)
}
if *cfg.Value != "@weekly" {
t.Fatalf("значение не сохранено: %q", *cfg.Value)
}
}
// Транзакционная запись обязана вести себя так же: отказ на отсутствующей
// строке откатывает всю партию, а не оставляет её наполовину применённой.
func TestConfigTxRollsBackWhenRowIsMissing(t *testing.T) {
newConfigDB(t)
if tx := sqliteDB.Exec("DELETE FROM config WHERE key = ?", constant.JwtSecret); tx.Error != nil {
t.Fatalf("подготовка: %v", tx.Error)
}
err := WithConfigTx(func(tx ConfigTx) error {
if err := tx.UpdateConfig([]string{constant.ResetTrafficCron}, map[string]interface{}{"value": "@weekly"}); err != nil {
return err
}
return tx.UpdateConfig([]string{constant.JwtSecret}, map[string]interface{}{"value": "x"})
})
if err == nil {
t.Fatal("партия с отсутствующей строкой не отклонена")
}
cfg, getErr := GetConfig("key = ?", constant.ResetTrafficCron)
if getErr != nil {
t.Fatalf("чтение: %v", getErr)
}
if *cfg.Value == "@weekly" {
t.Fatal("первый ключ партии остался применённым после отказа на втором")
}
}
// Отсутствие записи и отказ хранилища — разные ответы.
func TestGetConfigReportsNotFoundDistinctly(t *testing.T) {
newConfigDB(t)
_, err := GetConfig("key = ?", "NO_SUCH_KEY")
if !errors.Is(err, ErrConfigNotFound) {
t.Fatalf("отсутствие ключа не распознано как ErrConfigNotFound: %v", err)
}
if errors.Is(err, ErrStorage) {
t.Fatal("отсутствие ключа выдано за отказ хранилища")
}
}
func TestGetPeerReportsNotFoundDistinctly(t *testing.T) {
newConfigDB(t)
_, err := GetPeer("name = ?", "no-such-peer")
if !errors.Is(err, ErrPeerNotFound) {
t.Fatalf("отсутствие пира не распознано как ErrPeerNotFound: %v", err)
}
if !IsNotFound(err) {
t.Fatal("IsNotFound не распознаёт ErrPeerNotFound")
}
if IsNotFound(ErrStorage) {
t.Fatal("IsNotFound принимает отказ хранилища за отсутствие записи")
}
}
// UpsertConfigValue, в отличие от UpdateConfig, строку заводит: это её работа.
func TestUpsertConfigValueCreatesMissingRow(t *testing.T) {
newConfigDB(t)
if tx := sqliteDB.Exec("DELETE FROM config WHERE key = ?", constant.ResetTrafficCron); tx.Error != nil {
t.Fatalf("подготовка: %v", tx.Error)
}
if err := UpsertConfigValue(constant.ResetTrafficCron, "@daily"); err != nil {
t.Fatalf("upsert отклонён: %v", err)
}
cfg, err := GetConfig("key = ?", constant.ResetTrafficCron)
if err != nil {
t.Fatalf("строка не создана: %v", err)
}
if *cfg.Value != "@daily" {
t.Fatalf("значение не сохранено: %q", *cfg.Value)
}
}