Files
Bridge-and-Join-s/internal/lkgateway/onboarding_test.go
T
fontvielle b7360b93b2 Мастер: «Обновление» — первый пункт, к которому можно вернуться
Новый шаг 14 «Обновление» в отдельной первой главе мастера. Секция
«Обновления» вынесена из admin_setup.html в {{define "updateSection"}}
(part_fancy.html) и переиспользуется и в настройках, и в шаге мастера —
один источник правды. В мастере рендерится обычным блоком (без
settings-section/active), заголовок h1 скрыт (есть свой h2 шага).

Шаг необязательный (не блокирует путь), зелёный пока нет версии новее;
при наличии обновления — предупреждающая точка. Добавлена пятая глава,
поэтому wizChaptersLine расширен до «Пять глав».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XG3zNMMf2LMoDpXo22mwMX
2026-10-19 17:22:29 +03:00

831 lines
44 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") // перенаправление — по умолчанию, без настройки
h := httpsRedirect(rc, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusTeapot) }))
httpsUp.Store(false) // HTTPS не поднялся — не перенаправляем, иначе в bj не войти
if rec := httptest.NewRecorder(); func() int {
req := httptest.NewRequest(http.MethodGet, "http://10.10.10.6:8080/admin/setup", nil)
req.RemoteAddr = "10.10.10.5:5000"
h.ServeHTTP(rec, req)
return rec.Code
}() != http.StatusTeapot {
t.Fatal("без работающего HTTPS перенаправлять нельзя")
}
httpsUp.Store(true)
defer httpsUp.Store(false)
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"))
}
}
_ = rc.UpdateHTTPS(HTTPSSettings{RedirectOff: true}) // выключено в настройках
req := httptest.NewRequest(http.MethodGet, "http://10.10.10.6:8080/admin/setup", nil)
req.RemoteAddr = "10.10.10.5:5000"
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusTeapot {
t.Errorf("выключенное перенаправление: %d", rec.Code)
}
}
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{14, 1, 12, 4, 7, 8, 9, 13, 10} // обновление — первый пункт; без лицензии нет учёта DEPO, QUIK и модулей; база и Валидата — сделаны; учётные записи — перед проверкой
if fmt.Sprint(path) != fmt.Sprint(want) {
t.Fatalf("путь %v, ожидался %v", path, want)
}
if len(chapters) != 5 || !chapters[2].Current || chapters[2].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[2].Title != "Организация" {
t.Fatalf("без учёта DEPO и QUIK глава «Организация», а не %q", chapters[2].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) != 3 || items[0].Title != "Обновление bj" || items[0].State != "wait" || items[1].State != "ok" || !strings.Contains(items[2].Title, "Интеграционный шлюз") {
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": "Проверка", "14": "sec-update"} {
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)
}
// шаг «Ключ подписи»: ключ найден на подготовленной флешке — используется как есть
h.keyWiz = newKeyWizardState()
h.keyWiz.StagingID, h.keyWiz.VDK, h.keyWiz.MediaDevice, h.keyWiz.MediaProfile = "media-test", "CKJCI7AYNIOSNLXK.vdk", "/dev/sdb1", "PrUser1046"
h.keyWiz.Steps[0].Title, h.keyWiz.Steps[1].Title = "Поиск ключа на флешке", "Подключение флешки к шлюзу как есть — на неё ничего не записывается"
h.keyWiz.set(0, "ok", "Ключ найден на флешке USB2FlashStorage: CKJCI7AYNIOSNLXK.vdk, справочник spr1046, профиль PrUser1046")
wk := httptest.NewRecorder()
h.renderWizard(wk, httptest.NewRequest(http.MethodGet, "/admin/wizard?step=8", nil))
_ = os.WriteFile(filepath.Join(dir, "wizard-08-media.html"), wk.Body.Bytes(), 0o644)
// путь «новая флешка»: архив распакован, ждём флешку
h.keyWiz = newKeyWizardState()
h.keyWiz.StagingID, h.keyWiz.VDK = "test-archive", "CKJCI7AYNIOSNLXK.vdk"
h.keyWiz.set(0, "ok", "Ключ CKJCI7AYNIOSNLXK.vdk, справочник spr1046")
wn := httptest.NewRecorder()
h.renderWizard(wn, httptest.NewRequest(http.MethodGet, "/admin/wizard?step=8", nil))
_ = os.WriteFile(filepath.Join(dir, "wizard-08-new.html"), wn.Body.Bytes(), 0o644)
h.keyWiz = nil
// шаг QUIK после проверки подключения — выбор «Как bj работает с QUIK»
q := rc.Snapshot().Quik
q.Server, q.User, q.Verified = "91.209.122.88", "BROKER_88", true
_ = rc.UpdateQuik(q)
w := httptest.NewRecorder()
h.renderWizard(w, httptest.NewRequest(http.MethodGet, "/admin/wizard?step=5", nil))
_ = os.WriteFile(filepath.Join(dir, "wizard-05-verified.html"), 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)
}
}
// Письмо в ARQA — коротко: организация просит подключить модуль, выдать логин и пароль и разрешить подключение
// с нашего внешнего адреса; о bj и внутреннем устройстве ни слова. Внешний адрес неизвестен — место для него.
func TestArqaLetter(t *testing.T) {
publicIPState.Lock()
publicIPState.ip, publicIPState.at = "195.239.71.22", time.Now()
publicIPState.Unlock()
defer func() {
publicIPState.Lock()
publicIPState.ip, publicIPState.at = "", time.Time{}
publicIPState.Unlock()
}()
var s Settings
s.NSD.OrgName, s.NSD.OrgINN, s.NSD.OrgCode = "ООО \"ИК \"Фонтвьель\"", "7703807489", "MC0413600000"
l, ok := letterFor(s, "arqa")
if !ok {
t.Fatal("нет письма arqa")
}
for _, bad := range []string{"Bridge", "bj", "InstrEu", "sp_NewInstrSec", "M2MDepoRecipients", "10.10."} {
if strings.Contains(l.Body, bad) {
t.Fatalf("в письме лишнее %q:\n%s", bad, l.Body)
}
}
for _, want := range []string{"7703807489", "просит", "логин и пароль", "с нашего адреса 195.239.71.22 [убедитесь, что это постоянный внешний адрес"} {
if !strings.Contains(l.Body, want) {
t.Fatalf("в письме нет %q:\n%s", want, l.Body)
}
}
publicIPState.Lock()
publicIPState.ip = ""
publicIPState.Unlock()
if l, _ := letterFor(s, "arqa"); !strings.Contains(l.Body, "[постоянный внешний IP-адрес вашего сервера") {
t.Fatalf("внешний адрес неизвестен — место для него:\n%s", l.Body)
}
}
// Мастер, QUIK: один выбор вместо набора флажков; тонкая настройка не трогается. Ошибка сертификата — подсказка.
func TestQuikModeAndCertHint(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 }()
q := rc.Snapshot().Quik
q.PollTerminal, q.SecOut, q.ClientCodeMap = true, "RUROUTM2M", map[string]string{"БР-1": "BR1"}
if err := rc.UpdateQuik(q); err != nil {
t.Fatal(err)
}
h := &setupHandlers{rc: rc}
post := func(form url.Values) string {
r := httptest.NewRequest(http.MethodPost, "/admin/setup/quik/mode", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.Header.Set("X-BJ-Ajax", "1")
w := httptest.NewRecorder()
h.saveQuikMode(w, r)
return w.Body.String()
}
if msg := post(url.Values{"mode": {"auto"}, "account": {"L01-00000F00"}}); !strings.Contains(msg, "чтение и запись") {
t.Fatalf("ответ: %q", msg)
}
s := rc.Snapshot().Quik
if !s.WritesEnabled || !s.AutoOut || !s.AutoIn || s.Account != "L01-00000F00" || !s.PollTerminal || s.SecOut != "RUROUTM2M" || s.ClientCodeMap["БР-1"] != "BR1" {
t.Fatalf("автоматически: %+v", s)
}
post(url.Values{"mode": {"check"}})
if s := rc.Snapshot().Quik; s.WritesEnabled || s.AutoOut || s.AutoIn || s.Account != "L01-00000F00" {
t.Fatalf("только сверка (счёт не стирается): %+v", s)
}
if !quikCertError(errors.New("TLS Handshake failed: tls: failed to verify certificate: x509: cannot validate certificate for 91.209.122.88 because it doesn't contain any IP SANs")) ||
quikCertError(errors.New("mssql: login error: Login failed for user 'BROKER_88'")) {
t.Fatal("ошибка сертификата распознаётся, ошибка логина — нет")
}
}
// Ход установки ИШ для «Подготовки модулей»: идёт — пока установщик обновляет файл; молчит 20 минут — прервалась.
func TestIgateInstallStatus(t *testing.T) {
old := igateInstallStatusPath
igateInstallStatusPath = filepath.Join(t.TempDir(), "st.json")
defer func() { igateInstallStatusPath = old }()
if st := igateInstallStatus(); st.Busy || st.Stage != "" {
t.Fatalf("нет файла: %+v", st)
}
write := func(stage string, age time.Duration) {
_ = os.WriteFile(igateInstallStatusPath, []byte(`{"stage":"`+stage+`","msg":"шаг"}`), 0o644)
at := time.Now().Add(-age)
_ = os.Chtimes(igateInstallStatusPath, at, at)
}
write("package", time.Minute)
if st := igateInstallStatus(); !st.Busy {
t.Fatalf("идёт: %+v", st)
}
write("package", 25*time.Minute)
if st := igateInstallStatus(); st.Busy || st.Stage != "error" || !strings.Contains(st.Msg, "прервалась") {
t.Fatalf("прервалась: %+v", st)
}
write("done", time.Hour)
if st := igateInstallStatus(); st.Busy || st.Stage != "done" {
t.Fatalf("готово: %+v", st)
}
}
// Установка ключа из мастера возвращает на шаг «Ключ подписи» (с выбранным контуром), со страницы — на страницу.
func TestKeyWizBack(t *testing.T) {
for form, want := range map[string]string{
"ret=wizard&want=prod": "/admin/wizard?step=8&contour=prod&",
"ret=wizard": "/admin/wizard?step=8&",
"": "/admin/setup/keywizard?",
"want=evil": "/admin/setup/keywizard?",
} {
r := httptest.NewRequest(http.MethodPost, "/admin/setup/keywizard/reset", strings.NewReader(form))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if got := keyWizBack(r); got != want {
t.Errorf("%q: %q, ожидалось %q", form, got, want)
}
}
}
// Итог установщика ключа: мастер не ставит галочки, если справочник не собран, профиль ИШ не создан
// или ключ не подписывает (раньше — «Готово» при ошибке пересборки).
func TestKeyInstallProblem(t *testing.T) {
for _, c := range []struct {
res map[string]any
want string
}{
{map[string]any{"rebuild": "ok", "igate": "connected", "keycheck": "ok"}, ""},
{map[string]any{"rebuild": "as-is", "igate": "pending-channel", "keycheck": "ok"}, ""},
{map[string]any{"rebuild": "fail: crestor: Ошибка загрузки ключа ЭП", "igate": "skip"}, "справочник"},
{map[string]any{"rebuild": "ok", "igate": "skip"}, "профиль ИШ"},
{map[string]any{"rebuild": "ok", "igate": "connected", "keycheck": "fail: E0D5", "keydiag": "флешка в /var/lib/igate/keymedia: не смонтирована; ключи, видимые ИШ: нет"}, "не смонтирована"},
} {
got := keyInstallProblem(c.res)
if (c.want == "") != (got == "") || !strings.Contains(got, c.want) {
t.Errorf("%v: %q, ожидалось %q", c.res, got, c.want)
}
}
}