Files
HY2XS_flamy/apps/service/cron_scheduler_test.go
T
founder b99be7d514 fix(v1): разблокировать сборку, починить жизненный цикл cron и закрыть каналы утечки
Сборка не собиралась: два контракта приёмки роняли её на корректном коде.

verify_api_namespace_contract искал возвращение legacy-пространства имён
через grep по '/hui' и находил router_test.go, который ПЕРЕЧИСЛЯЕТ этот
префикс, чтобы доказать отсутствие маршрута, и сам versions.sh, где строка
стоит в тексте проверки. Падение приходило шестым шагом из четырнадцати, до
резолва Hysteria. За ним прятался второй такой же: проверка транзакционности
импорта пиров брала файл от начала applyPeerImportEntry и до конца, захватывая
объявленные ниже ExistPeerName и UpdatePeerLastConnectionAt.

Обе проверки теперь смотрят на код, а не на упоминания: добавлены помощники
code_without_comments и code_mentions_in, а отсутствие legacy-маршрута
доказывает тест на таблице маршрутов собранного роутера.

Планировщик стал собственностью процесса. InitCron вызывался из runServer и
на каждом вызове создавал новый cron.New(), не сохраняя ссылку; cron.Stop()
не вызывался нигде. Смена RESET_TRAFFIC_CRON выполняла StopServer(), точка
входа крутила for { runServer() } — и каждая правка добавляла целый
дублирующий набор джоб, а старое расписание сброса продолжало работать.
Фиксированные джобы регистрируются один раз, расписание переносится на месте
по EntryID, HTTP-сервер не трогается. Добавлено штатное завершение по SIGTERM.

Выражение проверяется до записи в базу тем же парсером (cron.ParseStandard),
которым его разбирает планировщик: раньше невалидная строка сохранялась, API
отвечал успехом, а сброс трафика молча исчезал.

updateConfigs стал атомарным: полная проверка партии, одна транзакция,
применение к рантайму. Прежний тест ставил запрещённый ключ первым и не
смотрел в базу — поймать частичное применение он был неспособен.

Удалены четыре ключа таблицы config без единого потребителя: HYSTERIA2_ENABLE,
HYSTERIA2_CONFIG (второй источник истины, читался первым), HYSTERIA2_TRAFFIC_TIME
и HYSTERIA2_CONFIG_REMARK. Имя профиля в share URI выводится из имени пира.

Безопасность:
- bootstrap-пароль администратора больше не генерируется и не пишется в журнал,
  который отдаётся кнопкой выгрузки; отсутствие env — отказ старта;
- собственный журнал админки санитизируется наравне с чужим;
- golang-jwt/jwt v3 -> v5: GO-2025-3553 не имеет исправленной версии в v3 и
  достижима с неаутентифицированного запроса; набор алгоритмов подписи
  зафиксирован через WithValidMethods;
- удалён вход по несолёному SHA-224 из предыдущего поколения;
- убран modulo bias в util.RandomString — единственном генераторе секретов;
- пир установщика защищён во всех путях записи, а не только в импорте;
- удалена латентная паника в service.GetToken и недостижимая ветка GetAdminInfo,
  проверявшая меньше, чем middleware.

Toolchain: Go 1.21.13 -> 1.26.7, Node 20.19.0 (EOL) -> 24.20.0. На прежнем
графе govulncheck находил 21 вызываемую уязвимость, 17 из них в stdlib,
попадающей в production-бинарь. Сейчас — ноль. Добавлен обязательный шаг
проверки зависимостей (govulncheck + pnpm audit) с записью результата в
metadata пакета.
2026-08-29 21:37:50 +05:00

198 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"testing"
"hy2xs-admin/dao"
"hy2xs-admin/model/constant"
)
// Число фиксированных джоб планировщика: CronHandleAccount,
// CollectMetricsSnapshot, CleanupStatsRetention. Записано числом намеренно —
// тест обязан заметить и появление новой джобы, и потерю существующей.
const fixedCronJobCount = 3
func startTestCron(t *testing.T, resetTrafficExpression string) {
t.Helper()
newTestDB(t)
if err := dao.UpdateConfig(
[]string{constant.ResetTrafficCron},
map[string]interface{}{"value": resetTrafficExpression},
); err != nil {
t.Fatalf("не удалось записать %s: %v", constant.ResetTrafficCron, err)
}
if err := InitCron(); err != nil {
t.Fatalf("InitCron: %v", err)
}
t.Cleanup(StopCron)
}
// Главная регрессия P0: смена расписания не имеет права добавлять джобы.
//
// Раньше применение новой настройки шло через StopServer() и повторный
// runServer, а InitCron на каждом вызове создавал новый cron.New() и нигде его
// не сохранял. Старый планировщик никто не останавливал, поэтому после каждой
// правки на процессе оставался ЦЕЛЫЙ дублирующий набор джоб, а старое
// расписание сброса трафика продолжало срабатывать.
func TestRescheduleResetTrafficDoesNotAccumulateJobs(t *testing.T) {
startTestCron(t, "@monthly")
initial := CronEntryCount()
if initial != fixedCronJobCount+1 {
t.Fatalf("после старта ожидалось %d записей, получено %d", fixedCronJobCount+1, initial)
}
for _, expression := range []string{"@weekly", "@monthly", "0 3 * * *", "@daily"} {
if err := RescheduleResetTraffic(expression); err != nil {
t.Fatalf("RescheduleResetTraffic(%q): %v", expression, err)
}
if count := CronEntryCount(); count != initial {
t.Fatalf("после смены расписания на %q записей стало %d, ожидалось %d", expression, count, initial)
}
}
}
// Пустое выражение означает «автоматический сброс выключен», а не «оставить
// как было»: поле в панели clearable, и оператор имеет право его очистить.
func TestRescheduleResetTrafficWithEmptyExpressionDisablesJob(t *testing.T) {
startTestCron(t, "@monthly")
if !ResetTrafficScheduled() {
t.Fatal("после старта с непустым расписанием джоба сброса не запланирована")
}
if err := RescheduleResetTraffic(" "); err != nil {
t.Fatalf("RescheduleResetTraffic(пусто): %v", err)
}
if ResetTrafficScheduled() {
t.Fatal("пустое выражение не сняло джобу сброса трафика")
}
if count := CronEntryCount(); count != fixedCronJobCount {
t.Fatalf("после отключения сброса ожидалось %d записей, получено %d", fixedCronJobCount, count)
}
// Включение обратно должно работать без перезапуска процесса.
if err := RescheduleResetTraffic("@weekly"); err != nil {
t.Fatalf("повторное включение: %v", err)
}
if !ResetTrafficScheduled() {
t.Fatal("джоба сброса не вернулась после повторного включения")
}
}
// Невалидное выражение не имеет права ни изменить планировщик, ни пройти
// молча: раньше ошибка AddFunc только логировалась, и сброс трафика тихо
// исчезал.
func TestRescheduleResetTrafficRejectsInvalidExpression(t *testing.T) {
startTestCron(t, "@monthly")
before := CronEntryCount()
if err := RescheduleResetTraffic("какая-нибудь ерунда"); err == nil {
t.Fatal("невалидное выражение принято")
}
if after := CronEntryCount(); after != before {
t.Fatalf("отклонённое выражение изменило планировщик: было %d записей, стало %d", before, after)
}
if !ResetTrafficScheduled() {
t.Fatal("отклонённое выражение сняло действующую джобу")
}
}
// Валидация обязана пользоваться ТЕМ ЖЕ парсером, что и планировщик. Набор
// проверяет обе стороны: то, что cron умеет, должно приниматься, а то, что не
// умеет, — отклоняться до записи в базу.
func TestValidateResetTrafficCronMatchesSchedulerParser(t *testing.T) {
valid := []string{
"",
" ",
"@monthly",
"@weekly",
"@daily",
"@hourly",
"@every 1h30m",
"0 0 * * *",
"*/15 * * * *",
"0 3 1 * 1",
}
for _, expression := range valid {
if err := ValidateResetTrafficCron(expression); err != nil {
t.Errorf("валидное выражение %q отклонено: %v", expression, err)
}
}
invalid := []string{
"какая-нибудь ерунда",
"@yearly extra",
"* * * *",
"* * * * * *",
"99 * * * *",
"@every",
"@every банан",
"- - - - -",
}
for _, expression := range invalid {
if err := ValidateResetTrafficCron(expression); err == nil {
t.Errorf("невалидное выражение %q принято", expression)
}
}
}
// Невалидное значение в базе не имеет права уронить старт: на панели висит
// /internal/hysteria/auth, и отказ старта из-за строки расписания положил бы
// подключения пользователей.
func TestInitCronSurvivesInvalidStoredExpression(t *testing.T) {
startTestCron(t, "совершенно невалидная строка")
if ResetTrafficScheduled() {
t.Fatal("невалидное выражение из базы было запланировано")
}
if count := CronEntryCount(); count != fixedCronJobCount {
t.Fatalf("фиксированные джобы не поднялись: ожидалось %d записей, получено %d", fixedCronJobCount, count)
}
// Оператор обязан иметь возможность починить настройку через API, без
// перезапуска сервиса.
if err := RescheduleResetTraffic("@weekly"); err != nil {
t.Fatalf("исправление расписания: %v", err)
}
if !ResetTrafficScheduled() {
t.Fatal("исправленное расписание не применилось")
}
}
// Планировщик принадлежит процессу: второй InitCron без остановки первого —
// это ровно тот сценарий, который и создавал дубликаты.
func TestInitCronRefusesSecondScheduler(t *testing.T) {
startTestCron(t, "@monthly")
if err := InitCron(); err == nil {
t.Fatal("второй планировщик поднялся поверх работающего")
}
if count := CronEntryCount(); count != fixedCronJobCount+1 {
t.Fatalf("отклонённый повторный старт изменил планировщик: %d записей", count)
}
}
func TestStopCronIsIdempotent(t *testing.T) {
startTestCron(t, "@monthly")
StopCron()
if count := CronEntryCount(); count != 0 {
t.Fatalf("после остановки осталось %d записей", count)
}
if ResetTrafficScheduled() {
t.Fatal("после остановки джоба сброса числится запланированной")
}
// Второй вызов не должен паниковать: StopCron стоит в defer runServer и
// может быть вызван повторно на пути обработки ошибки.
StopCron()
if err := RescheduleResetTraffic("@weekly"); err == nil {
t.Fatal("перепланирование на остановленном планировщике не отклонено")
}
}