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.
146 lines
5.7 KiB
Go
146 lines
5.7 KiB
Go
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)
|
|
}
|
|
}
|