Files
HY2XS_flamy/apps/cmd/reset_test.go
T
founder b22b4b0d99 fix(v1): сделать read-only свойством doctor, а sentinel-ошибки — решением
Два свойства были описаны в документации, но не обеспечены кодом.

1. doctor «не изменяет диагностируемую систему».

   Принудительный skipServiceStart закрывал ровно одну ИЗВЕСТНУЮ мутацию —
   рестарт сервисов. Всё остальное в smoke держалось на том, что автор правки
   выбрал правильный раннер: `test -s`, `grep -q`, `stat`, `sudo -u ... test`
   и `nft -c` шли через мутирующий namespace, хотя ничего не меняют. Ожидание
   между попытками выполнялось подпроцессом `sleep` через runMutatingHidden,
   то есть пауза между двумя чтениями объявлялась изменением системы.

   Следствие: настоящая мутация, случайно добавленная в smoke, ничем бы от них
   не отличалась и была бы разрешена в doctor молча — а включить guard было
   нельзя, он отказал бы на первой же читающей команде.

   Команды классифицированы честно, `sleep` заменён таймером, и doctor целиком
   выполняется под тем же read-only guard, что и PHASE 0 установки. Guard
   снимается в finally. Диагностика при этом не сузилась: слушатели, healthz,
   права, machine auth, trafficStats, версия бинаря, семантика конфига и
   синтаксис nft проверяются полностью.

2. reset-admin различает «администратора нет» и «база не ответила».

   Слой данных специально возвращает разные sentinel'ы, но команда склеивала их
   обычным `if err != nil { создать } else { обновить }`. Опасен здесь не
   только нарушенный смысл: при транзиентном отказе чтения («database is
   locked») ветка создания отрабатывала успешно, и в таблице оказывались ДВЕ
   учётные записи администратора. GetAdminUser берёт First() и о второй строке
   не сообщает — на сервере оставалась вторая рабочая учётка с паролем, уже
   напечатанным на экран, и ни один запрос об этом не говорил.

   Заодно исправлено проглатывание ошибки хеширования: в ветке обновления
   стояло `hash, _ := util.HashPassword(password)` внутри литерала map. При
   отказе bcrypt в password_hash уезжала пустая строка, а на экран печатался
   пароль, которым войти уже невозможно — VerifyPassword отклоняет всё, что не
   bcrypt. Команда восстановления доступа умела молча его отобрать.

Тесты: doctor-readonly.test.ts дополнен поведенческой проверкой guard и
контролем набора раннеров в smoke; apps/cmd/reset_test.go проверяет обе ветки
на настоящей SQLite и отказ чтения при полностью работоспособной базе — ровно
тот случай, который прежний код превращал во второго администратора. Добавлена
dao.CountAdminUsers: до неё появление дубликата было ненаблюдаемым.
2026-08-30 18:28:39 +05:00

227 lines
9.8 KiB
Go

package cmd
import (
"errors"
"path/filepath"
"strings"
"testing"
"hy2xs-admin/dao"
"hy2xs-admin/model/entity"
"hy2xs-admin/util"
)
// reset-admin — команда восстановления доступа, и ошибиться ей дороже, чем
// обычному обработчику: она печатает новые учётные данные на экран и на этом
// основании оператор считает доступ восстановленным.
//
// Здесь закрепляются два дефекта:
//
// 1. отказ хранилища трактовался как «администратора нет», то есть переводил
// команду в ветку СОЗДАНИЯ учётной записи;
// 2. ошибка bcrypt в ветке обновления проглатывалась (`hash, _ := ...`), и в
// password_hash уезжала пустая строка.
func newAdminDB(t *testing.T) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "hy2xs-admin-test.db")
if err := dao.InitSqliteDBAt(dbPath); err != nil {
t.Fatalf("не удалось открыть тестовую базу: %v", err)
}
if err := dao.RunMigrations(); err != nil {
t.Fatalf("не удалось применить миграции: %v", err)
}
t.Cleanup(func() { _ = dao.CloseSqliteDB() })
}
func realLookup() (entity.AdminUser, error) {
return dao.GetAdminUser("1 = 1")
}
func countAdmins(t *testing.T) int64 {
t.Helper()
count, err := dao.CountAdminUsers()
if err != nil {
t.Fatalf("не удалось посчитать администраторов: %v", err)
}
return count
}
func TestResetCreatesAdminWhenNoneExists(t *testing.T) {
newAdminDB(t)
if err := resetAdminCredentials(realLookup, "operator-1", "recovery-password-1", 1700000000000); err != nil {
t.Fatalf("сброс не выполнен: %v", err)
}
if got := countAdmins(t); got != 1 {
t.Fatalf("ожидалась одна учётная запись, получено %d", got)
}
admin, err := dao.GetAdminUser("1 = 1")
if err != nil {
t.Fatalf("учётная запись не читается: %v", err)
}
if *admin.Username != "operator-1" {
t.Errorf("имя пользователя не записано: %q", *admin.Username)
}
if !util.VerifyPassword("recovery-password-1", *admin.PasswordHash) {
t.Error("напечатанный пароль не подходит к сохранённому хешу")
}
if *admin.ForcePasswordChange != 1 {
t.Error("восстановительный пароль обязан требовать смены при первом входе")
}
}
func TestResetUpdatesExistingAdminInPlace(t *testing.T) {
newAdminDB(t)
if err := resetAdminCredentials(realLookup, "operator-1", "recovery-password-1", 1700000000000); err != nil {
t.Fatalf("первичный сброс не выполнен: %v", err)
}
before, err := dao.GetAdminUser("1 = 1")
if err != nil {
t.Fatalf("учётная запись не читается: %v", err)
}
if err := resetAdminCredentials(realLookup, "operator-2", "recovery-password-2", 1700000001000); err != nil {
t.Fatalf("повторный сброс не выполнен: %v", err)
}
if got := countAdmins(t); got != 1 {
t.Fatalf("повторный сброс размножил учётные записи: %d", got)
}
after, err := dao.GetAdminUser("1 = 1")
if err != nil {
t.Fatalf("учётная запись не читается: %v", err)
}
if *after.Id != *before.Id {
t.Errorf("учётная запись пересоздана: было id=%d, стало id=%d", *before.Id, *after.Id)
}
if *after.Username != "operator-2" {
t.Errorf("имя пользователя не обновлено: %q", *after.Username)
}
if !util.VerifyPassword("recovery-password-2", *after.PasswordHash) {
t.Error("новый пароль не подходит к сохранённому хешу")
}
if util.VerifyPassword("recovery-password-1", *after.PasswordHash) {
t.Error("прежний пароль всё ещё действует")
}
// Смена пароля обязана обесценивать выданные ранее токены.
if *after.TokenVersion <= *before.TokenVersion {
t.Errorf("token_version не увеличен: было %d, стало %d", *before.TokenVersion, *after.TokenVersion)
}
}
// Ключевая регрессия. База ПОЛНОСТЬЮ работоспособна, отказало только чтение —
// ровно тот транзиентный случай («database is locked»), из-за которого прежний
// код уходил в ветку создания и оставлял на сервере вторую рабочую учётку с
// паролем, уже напечатанным на экран.
func TestResetRefusesToCreateSecondAdminOnStorageFailure(t *testing.T) {
newAdminDB(t)
if err := resetAdminCredentials(realLookup, "operator-1", "recovery-password-1", 1700000000000); err != nil {
t.Fatalf("первичный сброс не выполнен: %v", err)
}
before, err := dao.GetAdminUser("1 = 1")
if err != nil {
t.Fatalf("учётная запись не читается: %v", err)
}
failingLookup := func() (entity.AdminUser, error) {
return entity.AdminUser{}, dao.ErrStorage
}
err = resetAdminCredentials(failingLookup, "operator-2", "recovery-password-2", 1700000001000)
if err == nil {
t.Fatal("отказ хранилища обязан останавливать сброс, а не трактоваться как отсутствие администратора")
}
if !strings.Contains(err.Error(), "база данных не ответила") {
t.Errorf("сообщение не объясняет причину отказа: %v", err)
}
if got := countAdmins(t); got != 1 {
t.Fatalf("при отказе чтения создана вторая учётная запись: всего %d", got)
}
after, err := dao.GetAdminUser("1 = 1")
if err != nil {
t.Fatalf("учётная запись не читается: %v", err)
}
if *after.Id != *before.Id || *after.Username != *before.Username {
t.Error("существующая учётная запись изменена при отказе чтения")
}
if !util.VerifyPassword("recovery-password-1", *after.PasswordHash) {
t.Error("прежний пароль перестал действовать, хотя сброс не выполнялся")
}
if util.VerifyPassword("recovery-password-2", *after.PasswordHash) {
t.Error("напечатанный при отказе пароль действует")
}
}
// «Записи нет» по-прежнему означает создание: строгость к ErrStorage не имеет
// права сломать штатный путь восстановления на пустой базе.
func TestResetStillCreatesOnNotFoundSentinel(t *testing.T) {
newAdminDB(t)
notFound := func() (entity.AdminUser, error) {
return entity.AdminUser{}, dao.ErrAdminUserNotFound
}
if err := resetAdminCredentials(notFound, "operator-1", "recovery-password-1", 1700000000000); err != nil {
t.Fatalf("сброс на пустой базе не выполнен: %v", err)
}
if got := countAdmins(t); got != 1 {
t.Fatalf("ожидалась одна учётная запись, получено %d", got)
}
}
// Sentinel'ы «нет записи» намеренно НЕСУТ ОДИНАКОВЫЙ ТЕКСТ: WrongPassword
// уезжает в ответ Hysteria при неудачной machine-auth, и менять его ради
// внутренней аккуратности было бы изменением внешнего контракта. Поэтому
// различать их можно только через errors.Is, и решение о ветке обязано
// опираться на идентичность значения, а не на строку.
func TestAdminSentinelsAreDistinguishableOnlyByIdentity(t *testing.T) {
if dao.ErrAdminUserNotFound.Error() != dao.ErrPeerNotFound.Error() {
t.Log("тексты sentinel'ов разошлись; сравнение по идентичности остаётся обязательным")
}
if errors.Is(dao.ErrAdminUserNotFound, dao.ErrPeerNotFound) {
t.Error("sentinel'ы разных таблиц неразличимы по идентичности")
}
if errors.Is(dao.ErrStorage, dao.ErrAdminUserNotFound) {
t.Error("отказ хранилища опознаётся как отсутствие записи")
}
if dao.IsNotFound(dao.ErrStorage) {
t.Error("IsNotFound истинна для отказа хранилища")
}
}
// Ошибка хеширования не имеет права превратиться в пустой password_hash.
func TestResetRefusesWhenPasswordCannotBeHashed(t *testing.T) {
newAdminDB(t)
if err := resetAdminCredentials(realLookup, "operator-1", "recovery-password-1", 1700000000000); err != nil {
t.Fatalf("первичный сброс не выполнен: %v", err)
}
// HashPassword отклоняет пароль короче шести символов.
err := resetAdminCredentials(realLookup, "operator-2", "abc", 1700000001000)
if err == nil {
t.Fatal("непригодный пароль обязан останавливать сброс")
}
admin, getErr := dao.GetAdminUser("1 = 1")
if getErr != nil {
t.Fatalf("учётная запись не читается: %v", getErr)
}
if !util.IsBcryptHash(*admin.PasswordHash) {
t.Errorf("в password_hash оказалась не-bcrypt строка: %q", *admin.PasswordHash)
}
if !util.VerifyPassword("recovery-password-1", *admin.PasswordHash) {
t.Error("прежний пароль перестал действовать после неудачного сброса")
}
}