Files
Bridge-and-Join-s/internal/lkgateway/emulators_test.go
T
fontvielle fe005c0422 fix(emulators): подмена личности в НРД гаснет по окончании dev-режима
disableEmulatorsIfDevOff сбрасывал только LK.EmulateNSD / Quik.Emulate /
Quik.RealWrite, но не LK.RealNSDTest.Enabled (тест «боевой ЛК → TEST3»).
Из-за этого после истечения dev-режима подмена инвестора оставалась включённой,
а клиент без доступа к dev-вкладкам не мог вернуть стандартный режим.

Теперь сброс LK объединён в одно обновление и гасит и подмену (список паспортов
сохраняется). + тест TestDisableEmulatorsIfDevOffRealNSD.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-12 21:15:49 +03:00

454 lines
18 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{
"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 не должен добавлять новость")
}
}
// Подмена личности в НРД («боевой ЛК → TEST3») тоже гаснет по окончании dev-режима:
// иначе клиент без доступа к dev-вкладкам не вернул бы стандартный режим.
func TestDisableEmulatorsIfDevOffRealNSD(t *testing.T) {
rc := rcWithExpiredDev(t)
s := rc.Snapshot()
s.LK.RealNSDTest.Enabled = true
s.LK.RealNSDTest.Docs = "1110 754503"
if err := rc.UpdateLK(s.LK); err != nil {
t.Fatal(err)
}
disableEmulatorsIfDevOff(rc)
if rc.Snapshot().LK.RealNSDTest.Enabled {
t.Fatal("подмена личности не сброшена")
}
// Паспорта клиентов сохраняем — сбрасываем только включатель.
if rc.Snapshot().LK.RealNSDTest.Docs != "1110 754503" {
t.Errorf("список паспортов не должен затираться: %q", rc.Snapshot().LK.RealNSDTest.Docs)
}
// На диске тоже выключено — при следующем dev-режиме подмена сама не оживёт.
rc2, err := NewRuntimeConfig(rc.path)
if err != nil {
t.Fatal(err)
}
if rc2.Snapshot().LK.RealNSDTest.Enabled {
t.Fatal("в setup.json подмена осталась включённой")
}
found := false
for _, n := range rc.Snapshot().News.Items {
if strings.Contains(n.Title, "Эмуляторы отключены") && strings.Contains(n.Body, "подмена личности") {
found = true
}
}
if !found {
t.Error("в новости не упомянуто отключение подмены личности")
}
}
// Режимы МНП взаимоисключающие: хендлер выставляет 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("новость не добавлена")
}
}