90aacf9ef4
- ключ, подключённый мастером ключа, засчитывается сразу и в мастере, и на главной (сверка справочника шлюза отстаёт) - точка в заголовке шага — то же «выполнено», что в колонке (с «Настроить позже») - выход из мастера — «Выйти из мастера», не «Настроить позже» - шаг «Обновление» — сразу после «Лицензии»; на шаге 1 — указание на него, без второй кнопки - «Далее»/«Назад» со шага вне пути (сделан установщиком) — соседи по порядку глав - последний шаг при несделанном обязательном — «Вернуться к шагу …», не «Перейти к работе» Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q7eREaYwUt1i4fNLZcGnjn
835 lines
44 KiB
Go
835 lines
44 KiB
Go
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{1, 14, 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) != 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)
|
||
}
|
||
// шаг «База bj» сделан установщиком и в пути его нет (открыт по ссылке) — соседи по порядку глав, не начало
|
||
if p, n := wizNeighbours(path, 2); p != 14 || n != 12 {
|
||
t.Fatalf("соседи шага 2 вне пути: %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 глава «Организация», а не %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) != 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)
|
||
}
|
||
}
|
||
}
|