diff --git a/internal/lkgateway/depo_setup.go b/internal/lkgateway/depo_setup.go index 7180a9c..4d37dd1 100644 --- a/internal/lkgateway/depo_setup.go +++ b/internal/lkgateway/depo_setup.go @@ -99,11 +99,14 @@ END PRINT N'База DEPO: ' + @db; DECLARE @in nvarchar(300) = N'USE ' + QUOTENAME(@db) + N'; '; --- 1. Логин bj +-- 1. Логин bj (если такой логин на сервере уже есть — ему ставится пароль из @pass) +-- (EXEC(...) склеивает только строки и переменные — команда собирается в @sql заранее) +DECLARE @sql nvarchar(max); IF SUSER_ID(N'%[1]s') IS NULL - EXEC(N'CREATE LOGIN [%[1]s] WITH PASSWORD = N''' + REPLACE(@pass, N'''', N'''''') + N''', CHECK_POLICY = ON, DEFAULT_DATABASE = ' + QUOTENAME(@db)) + SET @sql = N'CREATE LOGIN [%[1]s] WITH PASSWORD = N''' + REPLACE(@pass, N'''', N'''''') + N''', CHECK_POLICY = ON, DEFAULT_DATABASE = ' + QUOTENAME(@db); ELSE - EXEC(N'ALTER LOGIN [%[1]s] WITH PASSWORD = N''' + REPLACE(@pass, N'''', N'''''') + N''''); + SET @sql = N'ALTER LOGIN [%[1]s] WITH PASSWORD = N''' + REPLACE(@pass, N'''', N'''''') + N''''; +EXEC(@sql); -- 2. Таблицы обмена FTV_M2M* (только недостающие таблицы, колонки и индексы — данные не меняются) `, login, job, sqlStr(db), sqlStr(pass)) diff --git a/internal/lkgateway/depo_setup_live_test.go b/internal/lkgateway/depo_setup_live_test.go new file mode 100644 index 0000000..306ffb4 --- /dev/null +++ b/internal/lkgateway/depo_setup_live_test.go @@ -0,0 +1,67 @@ +package lkgateway + +import ( + "context" + "os" + "testing" + "time" +) + +// Синтаксис скрипта подготовки DEPO на настоящем SQL Server — без выполнения (SET PARSEONLY ON: сервер только +// разбирает текст). BJ_DEPO_PARSE_CFG=<копия setup.json с подключением к DEPO> go test -run TestDepoSetupScriptParseLive. +// Для надёжности база в скрипте — заведомо несуществующая: даже без PARSEONLY скрипт остановился бы до изменений. +func TestDepoSetupScriptParseLive(t *testing.T) { + cfg := os.Getenv("BJ_DEPO_PARSE_CFG") + if cfg == "" { + t.Skip("BJ_DEPO_PARSE_CFG не задан") + } + rc, err := NewRuntimeConfig(cfg) + if err != nil { + t.Fatal(err) + } + f := rc.Snapshot().Fancy + if f.IsPostgres() || f.Server == "" { + t.Skip("в настройках нет SQL Server DEPO") + } + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + db, err := fancyOpen(f, "") + if err != nil { + t.Fatal(err) + } + defer db.Close() + conn, err := db.Conn(ctx) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + if _, err := conn.ExecContext(ctx, "SET PARSEONLY ON"); err != nil { + t.Fatal(err) + } + // режим действует: ошибка, поднятая командой, не возникает — значит, ничего не выполняется + if _, err := conn.ExecContext(ctx, "RAISERROR(N'parseonly-probe', 16, 1)"); err != nil { + t.Fatalf("SET PARSEONLY не действует — дальше не идём: %s", quikErrorDetail(err)) + } + // и ловит ровно ту ошибку, что была в выпусках .06.4–.06.6: вызов функции внутри EXEC(...) + if _, err := conn.ExecContext(ctx, "DECLARE @p nvarchar(10) = N'a'; EXEC(N'SELECT ' + REPLACE(@p, N'a', N'1'))"); err == nil { + t.Fatal("разбор не заметил функцию внутри EXEC(...) — проверка ничего не стоит") + } + const nodb, login, pass = "BJ_PARSE_CHECK_NO_SUCH_DB", "bj_parse_check", "Xk7pQ2mN'9vR4tL8w" + for _, agent := range []bool{true, false} { + if _, err := conn.ExecContext(ctx, depoSetupScript(f, nodb, login, pass, agent)); err != nil { + t.Fatalf("скрипт (задание Agent: %v): %s", agent, quikErrorDetail(err)) + } + } + // команды, которые скрипт собирает и выполняет через EXEC, — по отдельности + stmts := []string{ + "CREATE LOGIN [" + login + "] WITH PASSWORD = N'" + sqlStr(pass) + "', CHECK_POLICY = ON, DEFAULT_DATABASE = [" + nodb + "]", + "ALTER LOGIN [" + login + "] WITH PASSWORD = N'" + sqlStr(pass) + "'", + } + stmts = append(stmts, depoTablesDDL(false)...) + stmts = append(stmts, depoTablesGrantSQL(false, login)...) + for _, q := range stmts { + if _, err := conn.ExecContext(ctx, q); err != nil { + t.Fatalf("%s → %s", depoStmtHead(q), quikErrorDetail(err)) + } + } +} diff --git a/internal/lkgateway/onboarding_test.go b/internal/lkgateway/onboarding_test.go index 3e9ec26..c13bd17 100644 --- a/internal/lkgateway/onboarding_test.go +++ b/internal/lkgateway/onboarding_test.go @@ -366,6 +366,15 @@ func TestDepoSetupScript(t *testing.T) { 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) + } + } + } 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: без задания, с правом на процедуру и указанной базой") @@ -460,8 +469,9 @@ func TestDumpFirstRunPage(t *testing.T) { _ = os.WriteFile(filepath.Join(dir, "first-run.html"), []byte(b.String()), 0o644) } -// «Отдать скрипт администратору»: скрипт показывается на странице с базой и паролем с экрана, пароль -// запоминается (с ним bj потом проверяет подключение); короткий пароль не принимается. +// «Получить скрипт»: скрипт показывается на странице с базой и паролем с экрана и при показе ничего не +// сохраняет; файлом — логин и пароль запоминаются черновиком, рабочие (с ними ходят службы bj) не меняются +// до проверки входа: неверный пароль от служб блокирует логин на сервере. Короткий пароль не принимается. func TestDepoScriptView(t *testing.T) { priv := testLicenseKey(t) rc := testRC(t) @@ -488,22 +498,46 @@ func TestDepoScriptView(t *testing.T) { 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.User != "bj_user" || f.Password != "Secret123x" || f.DepoTablesMode != "script" { - t.Fatalf("логин и пароль bj не запомнились: %+v", f.User) + 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) + } + 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) + } + // экран пересобирает скрипт на ходу — и до того, как вписан адрес сервера 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()) } - if f := rc.Snapshot().Fancy; f.Server != "10.0.0.5" || f.Password != "Secret123x" { - t.Fatalf("без адреса сервера настройки не должны меняться: %q", f.Server) - } // имя базы обязательно (bj по серверу не ищет) — без него вместо скрипта понятная причина form.Set("depo_database", "") if w := post(form); w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "имя базы") { @@ -518,3 +552,24 @@ func TestDepoScriptView(t *testing.T) { 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() +} diff --git a/internal/lkgateway/runtimeconfig.go b/internal/lkgateway/runtimeconfig.go index 49a9d2b..390044a 100644 --- a/internal/lkgateway/runtimeconfig.go +++ b/internal/lkgateway/runtimeconfig.go @@ -211,6 +211,11 @@ type FancyMSSQLSettings struct { DepoTablesCheck string `json:"depo_tables_check"` DepoTablesCheckOK bool `json:"depo_tables_check_ok"` DepoTablesCheckAt time.Time `json:"depo_tables_check_at"` + // Логин и пароль bj из выданного скрипта, ещё не проверенные входом. В работу (User/Password) — только + // после успешного входа: службы bj ходят в DEPO с рабочими, и неверный пароль от них за минуты блокирует + // логин на сервере (CHECK_POLICY) — для всех, кто им пользуется. + DepoNewUser string `json:"depo_new_user,omitempty"` + DepoNewPass string `json:"depo_new_password,omitempty"` } // IsPostgres сообщает, выбран ли PostgreSQL (иначе — MSSQL по умолчанию). diff --git a/internal/lkgateway/setup.go b/internal/lkgateway/setup.go index 92f52b5..01a3b90 100644 --- a/internal/lkgateway/setup.go +++ b/internal/lkgateway/setup.go @@ -324,7 +324,9 @@ func (h *setupHandlers) renderWizard(w http.ResponseWriter, r *http.Request) { if step == 3 { d.Sub = depoWizardSub(r.URL.Query().Get("sub")) d.DepoUser, d.DepoPass = depoSetupLogin(s.Fancy), s.Fancy.Password - if s.Fancy.User != d.DepoUser || d.DepoPass == "" { + if s.Fancy.DepoNewUser != "" { // скрипт уже выдан, вход ещё не проверен — те же логин и пароль, что в нём + d.DepoUser, d.DepoPass = s.Fancy.DepoNewUser, s.Fancy.DepoNewPass + } else if s.Fancy.User != d.DepoUser || d.DepoPass == "" { d.DepoPass = depoGenPassword() } d.DepoMode = "auto" diff --git a/internal/lkgateway/web/templates/admin_wizard.html b/internal/lkgateway/web/templates/admin_wizard.html index c81940e..0a850be 100644 --- a/internal/lkgateway/web/templates/admin_wizard.html +++ b/internal/lkgateway/web/templates/admin_wizard.html @@ -73,6 +73,7 @@ .dq-opt { color:var(--muted); font-weight:400; } .dq-note { color:var(--muted); font-size:13px; margin:8px 0 0; } .dq-panel { margin-top:18px; } +.dq-panel + .dq-panel { margin-top:14px; } .wiz-card .dq-go { margin-top:16px; border-radius:999px; padding:10px 22px; font-size:14.5px; font-weight:600; } .wiz-card .dq-mono { font-family:var(--mono); } .dq-sql { margin-top:14px; } @@ -203,19 +204,26 @@
bj создаст свой логин, таблицы обмена и задание обработки, выдаст права и проверит подключение.
-Скрипт создаст этот логин — с ним bj будет входить в базу DEPO. Пароль придумал bj, можно вписать свой.
+С этим логином bj будет входить в базу DEPO — его создаст подготовка. Пароль придумал bj, можно вписать свой. Если такой логин на сервере уже есть, его пароль будет заменён — когда этим логином пользуется другая программа, впишите другое имя.
+bj проверит, что это база DEPO, создаст свой логин, таблицы обмена и задание обработки, выдаст права и проверит подключение.
+ +