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{ "mode": {"emulate"}, "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.Quik.RealWrite || s.MNPEmu.StepDelaySec != 7 || !s.MNPEmu.FailRegister || !s.MNPEmu.FailDeposit || s.MNPEmu.FailSign { t.Errorf("после сохранения МНП: emulate=%v real=%v emu=%+v", s.Quik.Emulate, s.Quik.RealWrite, 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 не должен добавлять новость") } } // Режимы МНП взаимоисключающие: хендлер выставляет Emulate/RealWrite по выбранному режиму. func TestEmulatorsQuikModes(t *testing.T) { devModeForced = true defer func() { devModeForced = false }() rc, err := NewRuntimeConfig(filepath.Join(t.TempDir(), "setup.json")) if err != nil { t.Fatal(err) } mux := http.NewServeMux() registerEmulators(mux, nil, rc) post := func(form url.Values) *httptest.ResponseRecorder { req := httptest.NewRequest(http.MethodPost, "/admin/emulators/quik", 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(url.Values{"mode": {"real"}, "real_client_code": {"BR859"}}) if rec.Code != http.StatusOK { t.Fatalf("real: %d %s", rec.Code, rec.Body.String()) } if s := rc.Snapshot().Quik; !s.RealWrite || s.Emulate || s.RealClientCode != "BR859" { t.Fatalf("режим real: %+v", s) } // Внутренняя имитация — наоборот, код сохраняется (инертен без флага). rec = post(url.Values{"mode": {"emulate"}, "real_client_code": {"BR859"}}) if rec.Code != http.StatusOK { t.Fatalf("emulate: %d %s", rec.Code, rec.Body.String()) } if s := rc.Snapshot().Quik; !s.Emulate || s.RealWrite { t.Fatalf("режим emulate: %+v", s) } // real без кода — 400. if rec := post(url.Values{"mode": {"real"}}); rec.Code != http.StatusBadRequest { t.Errorf("real без кода: %d, ожидали 400", rec.Code) } // Код с недопустимыми символами — 400 (валидация UpdateQuik). if rec := post(url.Values{"mode": {"real"}, "real_client_code": {"БР 859!"}}); rec.Code != http.StatusBadRequest { t.Errorf("плохой код: %d, ожидали 400", rec.Code) } } // Подмена кода клиента QUIK в режиме реальной записи — после обычной логики (ClientCodeMap не мешает). func TestQuikClientCodeRealWrite(t *testing.T) { s := QuikMNPSettings{ClientCodeMap: map[string]string{"БР-7-7": "BR777"}} if c := quikClientCodeFromPort(s, "БР-859 Баранкин"); c != "BR859" { t.Errorf("обычный режим: %q", c) } if c := quikClientCodeFromPort(s, "БР-7-7 Погонщиков"); c != "BR777" { t.Errorf("ClientCodeMap: %q", c) } s.RealWrite, s.RealClientCode = true, "BR859" if c := quikClientCodeFromPort(s, "БР-7-7 Погонщиков"); c != "BR859" { t.Errorf("подмена имеет приоритет над ClientCodeMap: %q", c) } if c := quikClientCodeFromPort(s, "БР-859 Баранкин"); c != "BR859" { t.Errorf("подмена: %q", c) } // Флаг включён, но код не задан — обычная логика. s.RealClientCode = "" if c := quikClientCodeFromPort(s, "БР-7-7 Погонщиков"); c != "BR777" { t.Errorf("real без кода: %q", c) } } // Цикл МНП активен в режиме реальной записи только в dev и с указанным кодом. func TestQuikActiveRealWrite(t *testing.T) { if quikActive(QuikMNPSettings{RealWrite: true, RealClientCode: "BR859"}) { t.Error("вне dev-режима цикл стоит") } devModeForced = true defer func() { devModeForced = false }() if !quikActive(QuikMNPSettings{RealWrite: true, RealClientCode: "BR859"}) { t.Error("dev + код: цикл должен работать") } if quikActive(QuikMNPSettings{RealWrite: true}) { t.Error("dev без кода: цикл стоит") } // Имитация имеет приоритет над real-записью. if !quikActive(QuikMNPSettings{Emulate: true, RealWrite: true, RealClientCode: "BR859"}) { t.Error("emulate + real: работает имитация (цикл активен)") } } // Авто-сброс при окончании dev-режима гасит и реальную запись на подставного клиента. func TestDisableEmulatorsIfDevOffRealWrite(t *testing.T) { rc := rcWithExpiredDev(t) q := rc.Snapshot().Quik q.RealWrite, q.RealClientCode = true, "BR859" if err := rc.UpdateQuik(q); err != nil { t.Fatal(err) } disableEmulatorsIfDevOff(rc) if s := rc.Snapshot().Quik; s.RealWrite { t.Fatal("RealWrite не сброшен") } found := false for _, n := range rc.Snapshot().News.Items { if strings.Contains(n.Title, "Эмуляторы отключены") { found = true if !strings.Contains(n.Body, "подставного клиента BR859") || !strings.Contains(n.Body, "прекращены") { t.Errorf("новость без упоминания реальных поручений: %s", n.Body) } } } if !found { t.Error("новость не добавлена") } }