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("прежний пароль перестал действовать после неудачного сброса") } }