Files
Bridge-and-Join-s/internal/lkgateway/emulators_test.go
T
zuevav c5784d9b62 feat(admin): авто-сброс эмуляторов НРД/МНП при окончании режима разработки
Если оператор временно получил dev-режим, включил эмулятор и забыл — при
окончании dev-режима флаги LK.EmulateNSD и QuikMNP.Emulate сбрасываются
насовсем (а не просто перестают действовать): при следующей выдаче dev-режима
эмуляторы молча не оживут.

Проверка disableEmulatorsIfDevOff вызывается при старте сервера (после
продления лицензии) и на каждом проходе цикла продления — ловит истечение
dev и по времени, без продления. При сбросе — запись в ленту событий:
«Эмуляторы отключены: режим разработки закончился» с перечнем выключенного;
если был включён эмулятор МНП при WritesEnabled=false — предупреждение, что
цикл МНП остановлен и для боевой работы нужно включить запись.
2026-10-08 00:09:10 +03:00

304 lines
12 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 lkgateway
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
)
// Настройки эмуляторов: валидация и обратная совместимость (старый setup.json без полей = дефолты).
func TestEmulatorSettingsValidation(t *testing.T) {
rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json"))
if err != nil {
t.Fatal(err)
}
for _, s := range []NSDEmuSettings{
{DelaySec: -1}, {DelaySec: 301},
{Outcome: "explode"}, {RejectCode: "99"}, {RejectCode: "M2M07"},
} {
if err := rc.UpdateNSDEmu(s); err == nil {
t.Errorf("UpdateNSDEmu(%+v): ожидалась ошибка", s)
}
}
if err := rc.UpdateNSDEmu(NSDEmuSettings{DelaySec: 45, Outcome: "reject", RejectCode: "18"}); err != nil {
t.Fatal(err)
}
for _, s := range []MNPEmuSettings{{StepDelaySec: -1}, {StepDelaySec: 61}} {
if err := rc.UpdateMNPEmu(s); err == nil {
t.Errorf("UpdateMNPEmu(%+v): ожидалась ошибка", s)
}
}
if err := rc.UpdateMNPEmu(MNPEmuSettings{StepDelaySec: 5, FailRegister: true}); err != nil {
t.Fatal(err)
}
// Перечитываем файл: настройки переживают перезапуск.
rc2, err := NewRuntimeConfig(rc.path)
if err != nil {
t.Fatal(err)
}
s := rc2.Snapshot()
if s.NSDEmu.DelaySec != 45 || s.NSDEmu.Outcome != "reject" || s.NSDEmu.RejectCode != "18" {
t.Errorf("NSDEmu после перезагрузки: %+v", s.NSDEmu)
}
if !s.MNPEmu.FailRegister || s.MNPEmu.StepDelaySec != 5 {
t.Errorf("MNPEmu после перезагрузки: %+v", s.MNPEmu)
}
// Дефолты для нулевых полей (старый setup.json).
d := NSDEmuSettings{}.Defaults()
if d.DelaySec != 30 || d.Outcome != "confirm" || d.RejectCode != "07" {
t.Errorf("дефолты NSDEmu: %+v", d)
}
}
// Страница «Эмуляторы» — только в режиме разработки: вне него 404 (гейт в authMiddleware).
func TestEmulatorsDevGate(t *testing.T) {
for _, p := range []string{"/admin/emulators", "/admin/emulators/nsd", "/admin/emulators/quik"} {
if !devOnlyPath(p) || requiredPerm(p, http.MethodGet) != "dev" {
t.Errorf("%s должен быть служебным", p)
}
}
rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json"))
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
registerEmulators(mux, nil, rc)
h := authMiddleware(rc, mux)
for _, m := range []string{http.MethodGet, http.MethodPost} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(m, "/admin/emulators", nil))
if rec.Code != http.StatusNotFound {
t.Errorf("%s вне режима разработки: %d, ожидали 404", m, rec.Code)
}
}
}
// Сохранение со страницы «Эмуляторы»: ajax-POST пишет настройки и флаги.
func TestEmulatorsSave(t *testing.T) {
devModeForced = true
defer func() { devModeForced = false }()
rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json"))
if err != nil {
t.Fatal(err)
}
a, err := newAdmin()
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
registerEmulators(mux, a, rc)
post := func(path string, form url.Values) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("X-BJ-Ajax", "1")
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec
}
rec := post("/admin/emulators/nsd", url.Values{
"emulate": {"1"}, "delay_sec": {"45"}, "outcome": {"reject"}, "reject_code": {"18"},
})
if rec.Code != http.StatusOK {
t.Fatalf("nsd: %d %s", rec.Code, rec.Body.String())
}
s := rc.Snapshot()
if !s.LK.EmulateNSD || s.NSDEmu.DelaySec != 45 || s.NSDEmu.Outcome != "reject" || s.NSDEmu.RejectCode != "18" {
t.Errorf("после сохранения НРД: emulate=%v emu=%+v", s.LK.EmulateNSD, s.NSDEmu)
}
rec = post("/admin/emulators/quik", url.Values{
"emulate": {"1"}, "step_delay_sec": {"7"}, "fail_register": {"1"}, "fail_deposit": {"1"},
})
if rec.Code != http.StatusOK {
t.Fatalf("quik: %d %s", rec.Code, rec.Body.String())
}
s = rc.Snapshot()
if !s.Quik.Emulate || s.MNPEmu.StepDelaySec != 7 || !s.MNPEmu.FailRegister || !s.MNPEmu.FailDeposit || s.MNPEmu.FailSign {
t.Errorf("после сохранения МНП: emulate=%v emu=%+v", s.Quik.Emulate, s.MNPEmu)
}
// Валидация на хендлере: плохой код отказа — 400, настройки не тронуты.
rec = post("/admin/emulators/nsd", url.Values{"delay_sec": {"10"}, "outcome": {"reject"}, "reject_code": {"42"}})
if rec.Code != http.StatusBadRequest {
t.Errorf("плохой код отказа: %d, ожидали 400", rec.Code)
}
rec = post("/admin/emulators/quik", url.Values{"step_delay_sec": {"600"}})
if rec.Code != http.StatusBadRequest {
t.Errorf("задержка 600: %d, ожидали 400", rec.Code)
}
// Страница собирается и показывает обе карточки.
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/admin/emulators", nil))
if rec.Code != http.StatusOK {
t.Fatalf("GET: %d", rec.Code)
}
for _, want := range []string{"Эмулятор НРД", "Эмулятор МНП QUIK", "в НРД ничего не отправляется", "M2M18"} {
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("на странице нет %q", want)
}
}
}
// Эмулированный отказ МНП: шаг завершается как боевой отказ — QuikStep OK=false, Err заполнена,
// состояние связки не продвигается; повторный вызов без отказа проходит.
func TestMNPEmuFailRegister(t *testing.T) {
rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json"))
if err != nil {
t.Fatal(err)
}
q := rc.Snapshot().Quik
q.Emulate = true
if err := rc.UpdateQuik(q); err != nil {
t.Fatal(err)
}
if err := rc.UpdateMNPEmu(MNPEmuSettings{FailRegister: true}); err != nil {
t.Fatal(err)
}
l, err := QuikOutRegister(context.Background(), rc, "emu-fail-test-guid", "CL1", "", "RU0009029540", 10, "7702070139", "", false)
if err == nil {
t.Fatal("ожидался отказ регистрации")
}
if l == nil || l.State != "error" || !strings.Contains(l.Err, "имитация отказа") {
t.Fatalf("связка после отказа: %+v", l)
}
if st := l.LastStep(); st == nil || st.OK || st.Action != "register" {
t.Fatalf("шаг: %+v", st)
}
// Сняли отказ — повтор (как следующий проход цикла) проходит.
if err := rc.UpdateMNPEmu(MNPEmuSettings{}); err != nil {
t.Fatal(err)
}
l, err = QuikOutRegister(context.Background(), rc, "emu-fail-test-guid", "CL1", "", "RU0009029540", 10, "7702070139", "", false)
if err != nil || l.State != "registered" {
t.Fatalf("повтор без отказа: %v, %+v", err, l)
}
}
// Задержка шага эмулятора МНП учитывает контекст: отмена/таймаут прерывают ожидание.
func TestMNPEmuStepDelayCtx(t *testing.T) {
rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json"))
if err != nil {
t.Fatal(err)
}
q := rc.Snapshot().Quik
q.Emulate = true
if err := rc.UpdateQuik(q); err != nil {
t.Fatal(err)
}
if err := rc.UpdateMNPEmu(MNPEmuSettings{StepDelaySec: 60}); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
defer cancel()
start := time.Now()
if _, err := QuikOutRegister(ctx, rc, "emu-delay-test-guid", "CL1", "", "RU0009029540", 5, "", "", false); err == nil {
t.Fatal("ожидался обрыв по контексту")
}
if time.Since(start) > 5*time.Second {
t.Fatal("задержка не прервалась по контексту")
}
}
// rcWithExpiredDev — действующая лицензия с истёкшим dev_until: devMode уже false.
func rcWithExpiredDev(t *testing.T) *RuntimeConfig {
t.Helper()
priv := testLicenseKey(t)
rc := testRC(t)
past := time.Now().UTC().Add(-time.Hour)
tok := signTestLicense(t, priv, &license.License{Schema: 1, ID: "lic-dev", Tenant: "Тест", Product: "bj-server", Plan: license.PlanPro,
IssuedAt: past.Add(-24 * time.Hour), ExpiresAt: time.Now().UTC().Add(60 * 24 * time.Hour), DevUntil: &past})
if err := rc.SaveLicense(LicenseSettings{Key: tok}); err != nil {
t.Fatal(err)
}
if devMode(rc) {
t.Fatal("devMode с истёкшим dev_until должен быть выключен")
}
return rc
}
// dev-режим закончился: включённые эмуляторы сбрасываются из настроек, про это — новость.
func TestDisableEmulatorsIfDevOff(t *testing.T) {
rc := rcWithExpiredDev(t)
s := rc.Snapshot()
s.LK.EmulateNSD = true
if err := rc.UpdateLK(s.LK); err != nil {
t.Fatal(err)
}
s.Quik.Emulate = true
if err := rc.UpdateQuik(s.Quik); err != nil {
t.Fatal(err)
}
disableEmulatorsIfDevOff(rc)
if s := rc.Snapshot(); s.LK.EmulateNSD || s.Quik.Emulate {
t.Fatalf("флаги не сброшены: EmulateNSD=%v Quik.Emulate=%v", s.LK.EmulateNSD, s.Quik.Emulate)
}
// setup.json на диске тоже без флагов — при следующем dev-режиме эмуляторы не оживут.
rc2, err := NewRuntimeConfig(rc.path)
if err != nil {
t.Fatal(err)
}
if s := rc2.Snapshot(); s.LK.EmulateNSD || s.Quik.Emulate {
t.Fatal("в setup.json флаги остались включёнными")
}
found := false
for _, n := range rc.Snapshot().News.Items {
if strings.Contains(n.Title, "Эмуляторы отключены") {
found = true
if !strings.Contains(n.Body, "эмулятор НРД") || !strings.Contains(n.Body, "эмулятор МНП") {
t.Errorf("в новости не перечислено, что выключено: %s", n.Body)
}
// WritesEnabled=false — новость предупреждает об остановленном цикле МНП.
if !strings.Contains(n.Body, "Цикл M2M в МНП остановлен") {
t.Errorf("нет предупреждения об остановленном цикле МНП: %s", n.Body)
}
}
}
if !found {
t.Error("новость об отключении эмуляторов не добавлена")
}
}
// dev-режим действует: авто-сброс ничего не трогает.
func TestDisableEmulatorsIfDevOffDevOn(t *testing.T) {
devModeForced = true
defer func() { devModeForced = false }()
rc := testRC(t)
s := rc.Snapshot()
s.LK.EmulateNSD = true
s.Quik.Emulate = true
if err := rc.UpdateLK(s.LK); err != nil {
t.Fatal(err)
}
if err := rc.UpdateQuik(s.Quik); err != nil {
t.Fatal(err)
}
disableEmulatorsIfDevOff(rc)
if s := rc.Snapshot(); !s.LK.EmulateNSD || !s.Quik.Emulate {
t.Fatal("при действующем dev-режиме флаги не должны сбрасываться")
}
}
// dev выключен, но флаги уже сняты: no-op — настройки не переписываются, новости нет.
func TestDisableEmulatorsIfDevOffNoOp(t *testing.T) {
rc := rcWithExpiredDev(t)
before := rc.Snapshot().UpdatedAt
disableEmulatorsIfDevOff(rc)
after := rc.Snapshot()
if !after.UpdatedAt.Equal(before) {
t.Error("no-op не должен переписывать setup.json")
}
if len(after.News.Items) != 0 {
t.Error("no-op не должен добавлять новость")
}
}