Files
Bridge-and-Join-s/internal/lkgateway/onboarding_test.go
T
zuevav c1f77df340 Мастер, «Организация»: одна кнопка «Сохранить»; в строке справочника — своя запись
- Организация и депозитарные реквизиты в НРД сохраняются одной кнопкой внизу шага
  (поля реквизитов — в общей форме org-form; saveOrg сохраняет и их, если они
  пришли; в настройках формы прежние). Повтор «Из DEPO: … подставлено» в мастере
  убран — то же в зелёной плашке.
- «Найти себя в справочнике участников M2M» не пустая: по коду и ИНН из полей
  показывается своя запись и «✓ Есть в справочнике участников M2M»; нет в
  справочнике — предупреждение.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
2026-10-06 16:27:44 +03:00

647 lines
34 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"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/license"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/release"
)
func TestAccPrefix(t *testing.T) {
for in, want := range map[string]string{"ВТНКЦ": "ВТНКЦ", " БР ": "БР", "AB_1": "AB_1", "a'b": "", "x%": "", "": ""} {
if got := cleanAccPrefix(in); got != want {
t.Errorf("cleanAccPrefix(%q)=%q, want %q", in, got, want)
}
}
if q := accSQL("LIKE N'{ACC}%'"); !strings.Contains(q, "N'ВТНКЦ%'") {
t.Errorf("accSQL по умолчанию: %s", q)
}
}
func TestDoneNSDUnknownProfile(t *testing.T) {
var s Settings
s.NSD.IGWBaseURL, s.NSD.Profile = "http://localhost:8090", "prod"
if doneNSD(s) {
t.Fatal("профиль prod неизвестен bj — шаг не должен считаться выполненным")
}
s.NSD.Profile = "prod-gost"
if !doneNSD(s) {
t.Fatal("prod-gost — известный профиль")
}
}
func TestStoreCertKindAndExpiry(t *testing.T) {
bundle := map[string]bool{}
own := NSDStoreCert{Kind: "x509", Subject: "O=Предприятие 1046,CN=Сотрудник 1046,INN=078000001046", SubjectCN: "Сотрудник 1046", IssuerCN: "Тестовый УЦ", Serial: "AA01"}
nsd := NSDStoreCert{Kind: "x509", Subject: "O=НКО АО НРД,CN=Петров,INN=007702165310", SubjectCN: "Петров", IssuerCN: "Тестовый УЦ", Serial: "BB02"}
ca := NSDStoreCert{Kind: "x509", SubjectCN: "Тестовый УЦ", SelfSigned: true}
for c, want := range map[*NSDStoreCert]string{&own: "own", &nsd: "counterpart", &ca: "ca"} {
if got := storeCertKind(*c, bundle); got != want {
t.Errorf("%s: %s, want %s", c.SubjectCN, got, want)
}
}
if subjectINN(nsd.Subject) != "7702165310" {
t.Errorf("subjectINN: %q", subjectINN(nsd.Subject))
}
// уведомления: за 20 дней — одно (отметка 30), повторно не шлётся; за 5 дней — следующее (7)
dir := t.TempDir()
certExpiry.file = filepath.Join(dir, "cert-expiry.json")
rc, err := NewRuntimeConfig(filepath.Join(dir, "setup.json"))
if err != nil {
t.Fatal(err)
}
own.NotAfter = time.Now().AddDate(0, 0, 20).Format("02.01.2006")
st := rc.Snapshot().NSDCerts
st.LastStore = []NSDStoreCert{own, nsd, ca}
_ = rc.UpdateNSDCerts(st)
count := func() int { return len(rc.Snapshot().News.Items) }
certExpiryTick(rc)
n1 := count()
certExpiryTick(rc)
if n1 != 1 || count() != 1 {
t.Fatalf("за 20 дней: ожидалось одно уведомление, было %d, затем %d", n1, count())
}
own.NotAfter = time.Now().AddDate(0, 0, 5).Format("02.01.2006")
st.LastStore = []NSDStoreCert{own}
_ = rc.UpdateNSDCerts(st)
certExpiryTick(rc)
if count() != 2 {
t.Fatalf("за 5 дней: ожидалось второе уведомление, всего %d", count())
}
raw, _ := os.ReadFile(certExpiry.file)
if !strings.Contains(string(raw), "7") {
t.Fatalf("отметка 7 не сохранена: %s", raw)
}
}
func TestSafeReturnPathKeepsStep(t *testing.T) {
for in, want := range map[string]string{
"/admin/wizard?step=4": "/admin/wizard?step=4",
"/admin/wizard?step=3&sub=done&flash=x": "/admin/wizard?step=3&sub=done",
"/admin/setup#sec-org": "/admin/setup",
"/admin/wizard?step=4%0d%0aX": "/admin/wizard",
"//evil.example/admin": "",
"/admin/x?a=<script>": "/admin/x",
} {
if got := safeReturnPath(in); got != want {
t.Errorf("safeReturnPath(%q)=%q, want %q", in, got, want)
}
}
}
func TestFirstRunRestoreNeedsCode(t *testing.T) {
if !authExempt("/first-run/backup/list") || authExempt("/first-runx") {
t.Fatal("пути восстановления при первичной настройке должны быть без входа, но не шире")
}
mux := http.NewServeMux()
registerFirstRunRestore(mux)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/first-run/backup/restore", strings.NewReader("code=1234&name=2026-10-05_1445")))
if !strings.Contains(rec.Body.String(), `"ok":false`) {
t.Fatalf("без ожидающей первичной настройки восстановление не должно запускаться: %s", rec.Body.String())
}
}
func TestHTTPSSelfSignedAndRedirect(t *testing.T) {
old := tlsDir
tlsDir = t.TempDir()
defer func() { tlsDir = old }()
if err := ensureSelfSigned(false); err != nil {
t.Fatal(err)
}
if cn, until, err := tlsCertInfo(); err != nil || cn == "" || certDaysLeft(until) < 1000 {
t.Fatalf("сертификат: %q %v %v", cn, until, err)
}
if _, err := loadTLSCert(nil); err != nil {
t.Fatal(err)
}
rc, _ := NewRuntimeConfig(t.TempDir() + "/setup.json")
_ = rc.UpdateHTTPS(HTTPSSettings{Redirect: true})
h := httpsRedirect(rc, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) }))
cases := []struct {
path, remote string
want int
}{
{"/admin/setup", "10.10.10.5:5000", http.StatusFound},
{"/api/v1/lk/x", "10.10.10.5:5000", http.StatusTeapot},
{"/healthz", "10.10.10.5:5000", http.StatusTeapot},
{"/admin/setup", "127.0.0.1:5000", http.StatusTeapot},
}
for _, c := range cases {
req := httptest.NewRequest(http.MethodGet, "http://10.10.10.6:8080"+c.path, nil)
req.RemoteAddr = c.remote
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != c.want {
t.Errorf("%s с %s: %d, ожидалось %d", c.path, c.remote, rec.Code, c.want)
}
if c.want == http.StatusFound && rec.Header().Get("Location") != "https://10.10.10.6:8443/admin/setup" {
t.Errorf("перенаправление: %s", rec.Header().Get("Location"))
}
}
}
func TestReceiverUnknownRejected(t *testing.T) {
if !handbookLoaded() {
t.Skip("справочник участников не вшит")
}
svc := &Service{defaultSender: "MC0413600000", defaultReceiver: "MC0010300000"}
if _, err := svc.BuildRequest(context.Background(), CreateClaimRequest{ReceivingDepositoryINN: "0000000001"}); err == nil || !errors.Is(err, errReceiverUnknown) {
t.Fatalf("неизвестный депозитарий получателя должен давать errReceiverUnknown, а не чужой код: %v", err)
}
}
func TestBuildRequestNeedsOrganization(t *testing.T) {
svc := &Service{}
if _, err := svc.BuildRequest(context.Background(), CreateClaimRequest{}); err == nil || !strings.Contains(err.Error(), "организация не задана") {
t.Fatalf("без кода отправителя перевод не должен формироваться: %v", err)
}
}
func TestKeyArchiveContour(t *testing.T) {
dir := t.TempDir()
raw, err := nsdCertsFS.ReadFile("nsd-certs/nsd-test-gost-petrov-20240924.cer")
if err != nil {
t.Skip(err)
}
_ = os.MkdirAll(filepath.Join(dir, "spr1"), 0o755)
_ = os.WriteFile(filepath.Join(dir, "spr1", "user.cer"), raw, 0o644)
if c, iss := keyArchiveContour(dir); c != "test3" || iss == "" {
t.Fatalf("тестовый сертификат: %q %q", c, iss)
}
raw, _ = nsdCertsFS.ReadFile("nsd-certs/nsd-prod-gost-shlyappo-20260821.cer")
dir2 := t.TempDir()
_ = os.WriteFile(filepath.Join(dir2, "user.cer"), raw, 0o644)
if c, _ := keyArchiveContour(dir2); c != "prod" {
t.Fatalf("боевой сертификат: %q", c)
}
}
func TestWizardChaptersWithoutLicense(t *testing.T) {
rc, _ := NewRuntimeConfig(t.TempDir() + "/setup.json")
done := map[int]bool{2: true, 6: true}
path, chapters := wizPath(rc, done, 4)
want := []int{1, 12, 4, 7, 8, 9, 10} // без лицензии нет учёта DEPO, QUIK и модулей; база и Валидата — сделаны
if fmt.Sprint(path) != fmt.Sprint(want) {
t.Fatalf("путь %v, ожидался %v", path, want)
}
if len(chapters) != 4 || !chapters[1].Current || chapters[1].Steps[0].N != 4 {
t.Fatalf("главы: %+v", chapters)
}
if p, n := wizNeighbours(path, 4); p != 12 || n != 7 {
t.Fatalf("соседи шага 4: %d %d", p, n)
}
if d := wizDefaultStep(path, map[int]bool{1: true, 4: false}); d != 4 {
t.Fatalf("по умолчанию: %d", d)
}
all := map[int]bool{}
for _, n := range path {
all[n] = true
}
if d := wizDefaultStep(path, all); d != 10 {
t.Fatalf("всё сделано — проверка, а не %d", d)
}
if len(wizModuleViews(rc, rc.Snapshot())) != 0 {
t.Fatal("без лицензии модулей в мастере быть не должно")
}
if chapters[1].Title != "Организация" {
t.Fatalf("без учёта DEPO и QUIK глава 2 — «Организация», а не %q", chapters[1].Title)
}
if l := wizChaptersLine(chapters); l != "Четыре главы: сервер, организация, НРД, запуск." {
t.Fatalf("подзаголовок: %q", l)
}
}
func TestLettersFill(t *testing.T) {
var s Settings
s.NSD.OrgName, s.NSD.OrgINN, s.NSD.OrgCode = "АО «Тест»", "7700000001", "MC0000000001"
s.NSD.DeponentCode, s.NSD.AccountID, s.NSD.SectionID = "MC0000000001", "HL000000001C", "36MC0000000001F00"
s.Fancy.Server = "sql.example"
for _, k := range []string{"nsd-test", "nsd-prod", "arqa", "dba"} {
l, ok := letterFor(s, k)
if !ok || !strings.Contains(l.Body, "АО «Тест»") || l.Subject == "" {
t.Errorf("%s: %+v", k, l)
}
}
l, _ := letterFor(s, "nsd-test")
if !strings.Contains(l.Body, "HL000000001C") || !strings.Contains(l.Body, "ГОСТ") {
t.Error("в письме НРД нет реквизитов")
}
s.Fancy.DBType = "postgres"
if l, _ := letterFor(s, "dba"); strings.Contains(l.Body, "SQL Server Agent") || !strings.Contains(l.Body, "postgres") {
t.Error("письмо администратору PostgreSQL не должно говорить о SQL Agent")
}
if _, ok := letterFor(s, "nope"); ok {
t.Error("неизвестное письмо")
}
}
func TestRuPlural(t *testing.T) {
for n, want := range map[int]string{1: "участник", 2: "участника", 4: "участника", 5: "участников", 11: "участников", 12: "участников", 21: "участник", 162: "участника", 111: "участников", 0: "участников"} {
if got := ruPlural(n, "участник", "участника", "участников"); got != want {
t.Errorf("%d: %q, ожидалось %q", n, got, want)
}
}
}
// Без лицензии QUIK МНП маршрута «ошибки QUIK» в форме нет, а сохранение формы не стирает его прежнее значение.
func TestNotifyKindsByLicense(t *testing.T) {
rc, _ := NewRuntimeConfig(t.TempDir() + "/setup.json")
for _, k := range visibleNotifyKinds(rc) {
if k.Key == "quik" {
t.Fatal("без лицензии QUIK МНП маршрута QUIK быть не должно")
}
}
if len(visibleNotifyKinds(rc)) != len(notifyKinds)-1 {
t.Fatalf("типы: %+v", visibleNotifyKinds(rc))
}
h := &setupHandlers{rc: rc}
form := url.Values{"enabled": {"on"}, "emails": {"depo@example.ru"}, "route_incoming_depo": {"1"}}
req := httptest.NewRequest(http.MethodPost, "/admin/setup/incoming-notify", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
h.saveIncomingNotify(httptest.NewRecorder(), req)
r := rc.Snapshot().Incoming.Routes
if r["incoming"] != "depo" || r["quik"] != defaultNotifyRoutes["quik"] {
t.Fatalf("маршруты после сохранения: %+v", r)
}
}
// Модули ставятся сразу, когда состав лицензии изменился (план или признаки), а не при каждом продлении.
func TestLicenseScopeChanged(t *testing.T) {
a := &license.License{Plan: "pro", Features: []string{"updates", "quik-mnp"}}
same := &license.License{Plan: "pro", Features: []string{"quik-mnp", "updates"}}
more := &license.License{Plan: "pro", Features: []string{"updates", "quik-mnp", "npr"}}
other := &license.License{Plan: "enterprise", Features: []string{"updates", "quik-mnp"}}
if licenseScopeChanged(a, same) {
t.Fatal("тот же состав в другом порядке — не изменение")
}
if !licenseScopeChanged(a, more) || !licenseScopeChanged(a, other) || !licenseScopeChanged(nil, a) {
t.Fatal("новый модуль или план — изменение")
}
}
// «Подготовка модулей»: страница собирается, переход — только на свои страницы, без лицензии — одно ядро.
func TestPrepareScreen(t *testing.T) {
a, err := newAdmin()
if err != nil {
t.Fatal(err)
}
var b strings.Builder
d := PrepareData{page: page{Title: "Подготовка модулей", Active: "wizard"}, Tenant: "ТЕСТ ZETIT", Plan: "pro", Until: "активна до 04.12.2026", Next: "/admin/wizard", Flash: "Администратор создан, ключ продукта принят."}
if err := a.prepare.ExecuteTemplate(&b, "layout", d); err != nil {
t.Fatal(err)
}
for _, want := range []string{"Подготовка модулей", "ТЕСТ ZETIT", "/admin/setup/prepare/status", "Перейти к настройке"} {
if !strings.Contains(b.String(), want) {
t.Fatalf("нет %q", want)
}
}
const def = "/admin/wizard?step=12" // после ключа и модулей — «Время и сеть»
for in, want := range map[string]string{"": def, "/admin/setup?x=1": "/admin/setup?x=1", "https://evil": def, "//evil/admin/": def, "/admin/wizard\r\nX: y": def} {
if got := safeNext(in); got != want {
t.Errorf("safeNext(%q) = %q, ожидалось %q", in, got, want)
}
}
rc, _ := NewRuntimeConfig(t.TempDir() + "/setup.json")
if items := prepareItems(rc, false, false, ""); len(items) != 2 || items[0].Title != "Обновление bj" || items[0].State != "wait" || items[1].State != "ok" {
t.Fatalf("без лицензии — обновление (ещё не проверялось) и ядро: %+v", items)
}
// выпуск, который подготовка уже ставила, после перезапуска bj не ставится по кругу
oldTried := prepareTriedFile
prepareTriedFile = t.TempDir() + "/tried"
defer func() { prepareTriedFile = oldTried }()
if prepareTried("2026.10.06.1") || !prepareTried("2026.10.06.1") || prepareTried("2026.10.06.2") {
t.Fatal("prepareTried: первый раз — ставить, второй раз тот же выпуск — нет, новый выпуск — ставить")
}
}
// Модуль из комплекта образа берётся без скачивания, только если это тот же файл, что в выпуске.
func TestFromLocalBundle(t *testing.T) {
dir, staging := t.TempDir(), t.TempDir()
old := localBundleDir
localBundleDir = dir
defer func() { localBundleDir = old }()
if err := os.WriteFile(filepath.Join(dir, "crypto-service.jar"), []byte("jar-2026.09.25.14"), 0o644); err != nil {
t.Fatal(err)
}
sum, _, _ := release.HashFile(filepath.Join(dir, "crypto-service.jar"))
if !fromLocalBundle(release.Artifact{Name: "crypto-service.jar", File: "crypto-service.jar", SHA256: sum}, staging) {
t.Fatal("тот же файл — должен взяться из образа")
}
if b, _ := os.ReadFile(filepath.Join(staging, "crypto-service.jar")); string(b) != "jar-2026.09.25.14" {
t.Fatalf("скопировано: %q", b)
}
if fromLocalBundle(release.Artifact{Name: "crypto-service.jar", File: "crypto-service.jar", SHA256: strings.Repeat("0", 64)}, t.TempDir()) {
t.Fatal("другой выпуск — качать с сервера, а не брать из образа")
}
}
// Один скрипт подготовки базы DEPO: в названной базе (сам не ищет, но проверяет, что это DEPO) создаёт логин,
// таблицы, права и (по выбору) задание.
func TestDepoSetupScript(t *testing.T) {
f := FancyMSSQLSettings{}
s := depoSetupScript(f, "DEPO_DATA", "bj_user", "Pass1word", true)
for _, want := range []string{"DECLARE @db sysname = N'DEPO_DATA';", "RAISERROR(N'Базы %s на этом сервере нет.", "OBJECT_ID(QUOTENAME(@db) + N'.dbo.OD_FACES', N'U') IS NULL", "DECLARE @pass nvarchar(128) = N'Pass1word';", "OD_FACES", "CREATE LOGIN [bj_user] WITH PASSWORD = N''' + REPLACE(@pass, N'''', N'''''')", "FTV_M2M_EVENTS", "db_datareader", "sp_add_job @job_name = N'bj2depo'", "@database_name = @db", "SQLAgentReaderRole"} {
if !strings.Contains(s, want) {
t.Fatalf("в скрипте нет %q", want)
}
}
if strings.Contains(s, "BJ_DEPO_DATA") || strings.Contains(s, "\nGO") || strings.Contains(s, "sys.databases") || strings.Contains(s, "%!") {
t.Fatal("в скрипте — имя нашей базы, GO (переменные @db между пакетами не живут), поиск базы по серверу или сбой подстановки")
}
// каждая строка EXEC(@in + N'…') — закрытый литерал: чётное число кавычек
for _, line := range strings.Split(s, "\n") {
if strings.HasPrefix(line, "EXEC(") && strings.HasSuffix(line, "');") && strings.Count(line, "'")%2 != 0 {
t.Fatalf("нечётные кавычки: %s", line)
}
}
// EXEC(...) в T-SQL склеивает только литералы и переменные: вызов функции внутри (REPLACE, QUOTENAME…) —
// ошибка 102 «Неправильный синтаксис», и весь скрипт не выполняется (так было в .06.4–.06.6)
for _, sc := range []string{s, depoSetupScript(f, "DEPO_X", "bj_user", "Pass1word", false)} {
for _, line := range strings.Split(sc, "\n") {
if ln := strings.TrimSpace(line); strings.HasPrefix(ln, "EXEC(") && strings.Contains(tsqlOutsideLiterals(ln[len("EXEC("):]), "(") {
t.Fatalf("в EXEC(...) вызов функции — SQL Server не примет: %s", ln)
}
}
}
// задание — по базе: своё имя, и до любых изменений — стоп, если имя занято другой базой или эту базу
// уже обрабатывает другое задание
named := depoSetupScript(FancyMSSQLSettings{DepoJobName: "bj2depo_DEPO_DATA"}, "DEPO_DATA", "bj_user", "Pass1word", true)
for _, want := range []string{"sp_add_job @job_name = N'bj2depo_DEPO_DATA'", "WHERE j.name = N'bj2depo_DEPO_DATA' AND s.database_name <> @db",
"s.command LIKE N'%FTV_M2M_JOB%' AND j.name <> N'bj2depo_DEPO_DATA'", "уже запускает задание %s"} {
if !strings.Contains(named, want) {
t.Fatalf("задание по базе: в скрипте нет %q", want)
}
}
if strings.Index(named, "s.database_name <> @db") > strings.Index(named, "-- 1. Логин bj") {
t.Fatal("проверка задания должна стоять до создания логина")
}
if depoJobName(FancyMSSQLSettings{}) != "bj2depo" || depoJobNameFor("BJ2_DEPO_DATA") != "bj2depo_BJ2_DEPO_DATA" {
t.Fatal("имя задания: прежние установки — bj2depo, новые — bj2depo_<база>")
}
bj := depoSetupScript(f, "DEPO_X", "bj_user", "Pass1word", false)
if strings.Contains(bj, "sp_add_job") || !strings.Contains(bj, "GRANT EXECUTE ON dbo.FTV_M2M_JOB") || !strings.Contains(bj, "N'DEPO_X'") {
t.Fatal("обработка силами bj: без задания, с правом на процедуру и указанной базой")
}
pg := depoSetupScript(FancyMSSQLSettings{DBType: "postgres"}, "depo", "bj_user", "p'w", false)
if !strings.Contains(pg, `\c "depo"`) || !strings.Contains(pg, "PASSWORD 'p''w'") || !strings.Contains(pg, "RAISE EXCEPTION 'В базе % нет учётных таблиц DEPO") {
t.Fatal("PostgreSQL: подключение к базе и экранирование пароля")
}
if p := depoGenPassword(); len(p) != 24 || !strings.ContainsAny(p, "ABCDEFGHJKLMNPQRSTUVWXYZ") || !strings.ContainsAny(p, "23456789") {
t.Fatalf("пароль: %q", p)
}
}
// Шаги мастера отрисовываются без ошибок шаблона: лицензия, учёт DEPO одним шагом, время и сеть, проверка.
func TestWizardStepsRender(t *testing.T) {
a, err := newAdmin()
if err != nil {
t.Fatal(err)
}
priv := testLicenseKey(t)
rc := testRC(t)
now := time.Now().UTC()
tok := signTestLicense(t, priv, &license.License{Schema: 1, ID: "lic-1", Tenant: "Тест", Product: "bj-server", Plan: license.PlanPro,
IssuedAt: now, ExpiresAt: now.Add(30 * 24 * time.Hour), ContractUntil: now.AddDate(1, 0, 0), Features: []string{"updates", "quik-mnp"}})
if err := rc.SaveLicense(LicenseSettings{Key: tok}); err != nil {
t.Fatal(err)
}
h := &setupHandlers{rc: rc, tpl: &adminTemplates{a: a}}
for step, want := range map[string]string{"1": "QUIK: неторговые поручения", "3": "wiz-depo", "5": "Сохранить и проверить", "12": "wiz-tz-suggest", "10": "Проверка"} {
w := httptest.NewRecorder()
h.renderWizard(w, httptest.NewRequest(http.MethodGet, "/admin/wizard?step="+step, nil))
body := w.Body.String()
if d := os.Getenv("BJ_DUMP_PAGES"); d != "" {
_ = os.WriteFile(filepath.Join(d, "wizard-"+step+".html"), []byte(body), 0o644)
}
if w.Code != http.StatusOK || !strings.Contains(body, want) {
i := strings.Index(body, "<main")
t.Fatalf("шаг %s: код %d, нет %q\n%s", step, w.Code, want, body[max(i, 0):min(len(body), max(i, 0)+1500)])
}
if step == "3" && (strings.Contains(body, "BJ_DEPO_DATA") || !strings.Contains(body, "depo_job")) {
t.Fatal("шаг DEPO: имя нашей базы в форме или нет выбора, где работает задание")
}
if strings.Contains(body, "wiz-restore") {
t.Fatal("восстановление из копии — только на первой странице, не в мастере")
}
}
}
// Снимки страниц первичной настройки для проверки глазами: BJ_DUMP_PAGES=<каталог> go test -run TestDumpOnboardingPages.
func TestDumpOnboardingPages(t *testing.T) {
dir := os.Getenv("BJ_DUMP_PAGES")
if dir == "" {
t.Skip("BJ_DUMP_PAGES не задан")
}
priv := testLicenseKey(t)
a, err := newAdmin()
if err != nil {
t.Fatal(err)
}
rc := testRC(t)
now := time.Now().UTC()
tok := signTestLicense(t, priv, &license.License{Schema: 1, ID: "lic-1", Tenant: "АО «Пример Инвест»", Product: "bj-server", Plan: license.PlanEnterprise,
IssuedAt: now, ExpiresAt: now.Add(365 * 24 * time.Hour), ContractUntil: now.AddDate(1, 0, 0), Features: []string{"updates", "quik-mnp", "web-cabinet", "npr", "quik-export"}})
if err := rc.SaveLicense(LicenseSettings{Key: tok}); err != nil {
t.Fatal(err)
}
h := &setupHandlers{rc: rc, tpl: &adminTemplates{a: a}}
for step := 1; step <= wizardSteps; step++ {
w := httptest.NewRecorder()
h.renderWizard(w, httptest.NewRequest(http.MethodGet, fmt.Sprintf("/admin/wizard?step=%d", step), nil))
_ = os.WriteFile(filepath.Join(dir, fmt.Sprintf("wizard-%02d.html", step)), w.Body.Bytes(), 0o644)
}
var b strings.Builder
d := PrepareData{page: page{Title: "Подготовка модулей", Active: "wizard"}, Tenant: "АО «Пример Инвест»", Plan: "enterprise", Until: "поддержка до 06.10.2027", Next: "/admin/wizard?step=12", Flash: "Администратор создан."}
if err := a.prepare.ExecuteTemplate(&b, "layout", d); err != nil {
t.Fatal(err)
}
_ = os.WriteFile(filepath.Join(dir, "prepare.html"), []byte(b.String()), 0o644)
_ = os.WriteFile(filepath.Join(dir, "depo-script.sql"), []byte(depoSetupScript(FancyMSSQLSettings{}, "DEPO_DATA", "bj_user", "Xk7pQ2mN9vR4tL8wZ3hB6cJd", true)), 0o644)
}
// Первая страница (администратор) — для снимка в TestDumpOnboardingPages.
func TestDumpFirstRunPage(t *testing.T) {
dir := os.Getenv("BJ_DUMP_PAGES")
if dir == "" {
t.Skip("BJ_DUMP_PAGES не задан")
}
var b strings.Builder
if err := firstRunTpl.Execute(&b, firstRunData{NeedKey: true}); err != nil {
t.Fatal(err)
}
_ = os.WriteFile(filepath.Join(dir, "first-run.html"), []byte(b.String()), 0o644)
}
// «Получить скрипт»: скрипт показывается на странице с базой и паролем с экрана и при показе ничего не
// сохраняет; файлом — логин и пароль запоминаются черновиком, рабочие (с ними ходят службы bj) не меняются
// до проверки входа: неверный пароль от служб блокирует логин на сервере. Короткий пароль не принимается.
func TestDepoScriptView(t *testing.T) {
priv := testLicenseKey(t)
rc := testRC(t)
now := time.Now().UTC()
tok := signTestLicense(t, priv, &license.License{Schema: 1, ID: "lic-1", Tenant: "Тест", Product: "bj-server", Plan: license.PlanPro,
IssuedAt: now, ExpiresAt: now.Add(30 * 24 * time.Hour), ContractUntil: now.AddDate(1, 0, 0), Features: []string{"updates", "quik-mnp"}})
if err := rc.SaveLicense(LicenseSettings{Key: tok}); err != nil {
t.Fatal(err)
}
old := globalRC
globalRC = rc
defer func() { globalRC = old }()
h := &setupHandlers{rc: rc}
post := func(form url.Values) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodPost, "/admin/setup/wizard-depo/script.sql", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.wizardDepoScript(w, r)
return w
}
form := url.Values{"server": {"10.0.0.5"}, "port": {"1433"}, "db_type": {"mssql"}, "depo_database": {"DEPO_X"},
"mode": {"script"}, "bj_user": {"bj_user"}, "bj_password": {"Secret123x"}, "depo_job": {"agent"}, "view": {"1"}}
w := post(form)
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "DECLARE @db sysname = N'DEPO_X';") || !strings.Contains(w.Body.String(), "N'Secret123x';") {
t.Fatalf("показ скрипта: %d %s", w.Code, w.Body.String()[:min(300, w.Body.Len())])
}
if f := rc.Snapshot().Fancy; f.Server != "" || f.User != "" || f.DepoNewUser != "" {
t.Fatalf("показ скрипта не должен ничего сохранять: %q %q %q", f.Server, f.User, f.DepoNewUser)
}
// рабочий логин уже есть (другой bj на том же сервере или прежняя настройка) — файл его не трогает
cur := rc.Snapshot().Fancy
cur.User, cur.Password = "bj_user", "Working1x"
if err := rc.UpdateFancy(cur); err != nil {
t.Fatal(err)
}
form.Del("view")
if w := post(form); w.Code != http.StatusOK || !strings.Contains(w.Header().Get("Content-Disposition"), "attachment") {
t.Fatalf("скрипт файлом: %d %s", w.Code, w.Body.String())
}
if f := rc.Snapshot().Fancy; f.User != "bj_user" || f.Password != "Working1x" || f.DepoNewUser != "bj_user" || f.DepoNewPass != "Secret123x" || f.Server != "10.0.0.5" || f.DepoTablesMode != "script" {
t.Fatalf("файл: рабочие логин/пароль должны остаться, новые — черновиком: %q/%q, черновик %q", f.User, f.Password, f.DepoNewUser)
}
if f := rc.Snapshot().Fancy; f.DepoJobName != "bj2depo_DEPO_X" {
t.Fatalf("имя задания по умолчанию — по базе: %q", f.DepoJobName)
}
form.Set("view", "1")
form.Set("depo_job_name", "bj2depo test;")
if w := post(form); w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "имя задания") {
t.Fatalf("недопустимое имя задания: %d %s", w.Code, w.Body.String())
}
form.Set("depo_job_name", "bj2depo")
if w := post(form); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "sp_add_job @job_name = N'bj2depo'") {
t.Fatalf("имя задания с экрана: %d", w.Code)
}
form.Del("view")
form.Set("view", "1")
form.Set("bj_password", "short")
if w := post(form); w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "8 знаков") {
t.Fatalf("короткий пароль: %d %s", w.Code, w.Body.String())
}
// «Проверить подключение», а войти не удалось — рабочими остаются прежние логин и пароль
chk := url.Values{"server": {"127.0.0.1"}, "port": {"1"}, "db_type": {"mssql"}, "depo_database": {"DEPO_X"},
"mode": {"script"}, "bj_user": {"bj_new"}, "bj_password": {"Newpass12"}, "depo_job": {"bj"}}
r := httptest.NewRequest(http.MethodPost, "/admin/setup/wizard-depo/setup", strings.NewReader(chk.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
cw := httptest.NewRecorder()
h.wizardDepoSetup(cw, r)
if loc := cw.Header().Get("Location"); cw.Code != http.StatusSeeOther || !strings.Contains(loc, "flash=") {
t.Fatalf("проверка без сервера: %d %s", cw.Code, loc)
}
if f := rc.Snapshot().Fancy; f.User != "bj_user" || f.Password != "Working1x" || f.DepoNewUser != "bj_new" || f.DepoNewPass != "Newpass12" {
t.Fatalf("вход не удался — рабочие логин/пароль не должны меняться: %q/%q, черновик %q", f.User, f.Password, f.DepoNewUser)
}
// с экрана (stream=1) — ход по шагам строками JSON, последняя — итог; адрес ошибки — на тот же экран
chk.Set("stream", "1")
r = httptest.NewRequest(http.MethodPost, "/admin/setup/wizard-depo/setup", strings.NewReader(chk.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
cw = httptest.NewRecorder()
h.wizardDepoSetup(cw, r)
lines := strings.Split(strings.TrimSpace(cw.Body.String()), "\n")
if !strings.Contains(cw.Header().Get("Content-Type"), "ndjson") || len(lines) < 2 ||
!strings.Contains(lines[0], `"step":"login"`) || !strings.Contains(lines[0], `"state":"run"`) ||
!strings.Contains(lines[len(lines)-1], `"done":true`) || !strings.Contains(lines[len(lines)-1], `"ok":false`) {
t.Fatalf("ход проверки: %s", cw.Body.String())
}
// экран пересобирает скрипт на ходу — и до того, как вписан адрес сервера
form.Set("bj_password", "Other123x")
form.Set("server", "")
if w := post(form); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "N'Other123x';") {
t.Fatalf("скрипт без адреса сервера: %d %s", w.Code, w.Body.String())
}
// имя базы обязательно (bj по серверу не ищет) — без него вместо скрипта понятная причина
form.Set("depo_database", "")
if w := post(form); w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "имя базы") {
t.Fatalf("SQL Server без базы: %d %s", w.Code, w.Body.String())
}
form.Set("db_type", "postgres")
if w := post(form); w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "имя базы") {
t.Fatalf("PostgreSQL без базы: %d %s", w.Code, w.Body.String())
}
form.Set("depo_database", "depo")
if w := post(form); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "PostgreSQL") {
t.Fatalf("PostgreSQL: %d %s", w.Code, w.Body.String())
}
}
// tsqlOutsideLiterals — текст T-SQL без строковых литералов ('…', внутри ” — экранированная кавычка).
func tsqlOutsideLiterals(s string) string {
var b strings.Builder
for i := 0; i < len(s); i++ {
if s[i] != '\'' {
b.WriteByte(s[i])
continue
}
for i++; i < len(s); i++ {
if s[i] == '\'' {
if i+1 < len(s) && s[i+1] == '\'' {
i++
continue
}
break
}
}
}
return b.String()
}
// Шаг «Организация» мастера: одна кнопка «Сохранить» — организация и депозитарные реквизиты в НРД одной формой;
// без полей реквизитов (настройки) — только организация, реквизиты не трогаются.
func TestSaveOrgWithRequisites(t *testing.T) {
rc := testRC(t)
h := &setupHandlers{rc: rc}
post := func(form url.Values) *httptest.ResponseRecorder {
r := httptest.NewRequest(http.MethodPost, "/admin/setup/org", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.saveOrg(w, r)
return w
}
w := post(url.Values{"org_code": {"MC0413600000"}, "org_inn": {"7703807489"}, "org_name": {"ООО \"ИК \"Фонтвьель\""},
"deponent_code": {"MC0413600000"}, "account_id": {"HL171004001C"}, "section_id": {"36MC0413600000F00"}, "return_to": {"/admin/wizard?step=4"}})
if loc := w.Header().Get("Location"); !strings.HasPrefix(loc, "/admin/wizard?step=4") {
t.Fatalf("после сохранения — на тот же шаг: %q", loc)
}
s := rc.Snapshot().NSD
if s.OrgCode != "MC0413600000" || s.OrgINN != "7703807489" || s.AccountID != "HL171004001C" || s.SectionID != "36MC0413600000F00" || s.DeponentCode != "MC0413600000" {
t.Fatalf("организация и реквизиты одной кнопкой: %+v", s)
}
post(url.Values{"org_code": {"MC0413600000"}, "org_inn": {"7703807489"}, "org_name": {"Фонтвьель"}})
if s := rc.Snapshot().NSD; s.AccountID != "HL171004001C" || s.OrgName != "Фонтвьель" {
t.Fatalf("форма без реквизитов не должна их стирать: %+v", s)
}
post(url.Values{"org_code": {"MC0413600000"}, "deponent_code": {"MC0413600000"}, "account_id": {"HL 17"}, "section_id": {""}})
if s := rc.Snapshot().NSD; s.AccountID != "HL171004001C" {
t.Fatalf("неверный счёт — ничего не сохраняется: %+v", s)
}
}