Учёт DEPO: скрипт снова выполняется; логин bj виден в обоих способах; новые логин и пароль — только после проверки входа

- EXEC(...) в T-SQL склеивает только строки и переменные: REPLACE/QUOTENAME внутри
  давали ошибку 102, и SQL Server отклонял весь скрипт (.06.4–.06.6). Команда входа
  собирается в @sql заранее; тест ловит вызовы функций внутри EXEC; живая проверка
  синтаксиса на SQL Server без выполнения (SET PARSEONLY) — TestDepoSetupScriptParseLive.
- «Логин bj / Пароль bj» и в способе «Дать логин администратора»: на общем сервере
  bj_user может быть занят другим bj — подготовка сменила бы ему пароль. Предупреждение.
- Показ скрипта на странице ничего не сохраняет. Логин и пароль из выданного скрипта
  и из «Проверить подключение» хранятся черновиком (DepoNewUser/DepoNewPass) и
  становятся рабочими только после успешного входа: службы bj с неверным паролем
  за минуты блокировали логин на сервере (CHECK_POLICY) — у всех, кто им пользуется.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FsEKyJrYF69Cj9ZXzFLRi
This commit is contained in:
zuevav
2026-10-06 15:35:36 +03:00
parent 13c252ae3d
commit 7af6cdf2f6
7 changed files with 190 additions and 27 deletions
+6 -3
View File
@@ -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))
@@ -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))
}
}
}
+63 -8
View File
@@ -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()
}
+5
View File
@@ -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 по умолчанию).
+3 -1
View File
@@ -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"
@@ -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 @@
<div class="dq-panel" id="wiz-depo-auto">
<div class="dq-grid dq-2">
<label>Логин администратора<input type="text" name="admin_user" autocomplete="off" spellcheck="false" placeholder="{{if $pg}}postgres{{else}}sa{{end}}"></label>
<label>Пароль<input type="password" name="admin_password" autocomplete="new-password"></label>
<label>Логин администратора SQL<input type="text" name="admin_user" autocomplete="off" spellcheck="false" placeholder="{{if $pg}}postgres{{else}}sa{{end}}"></label>
<label>Пароль администратора<input type="password" name="admin_password" autocomplete="new-password"></label>
</div>
<p class="dq-note">bj создаст свой логин, таблицы обмена<span data-ms> и задание обработки</span>, выдаст права и проверит подключение.</p>
<button type="submit" class="btn dq-go">Подготовить</button>
</div>
<div class="dq-panel" id="wiz-depo-script">
{{/* логин bj — в обоих способах: на общем сервере SQL bj_user может быть уже занят другим bj */}}
<div class="dq-panel" id="wiz-depo-login">
<div class="dq-grid dq-2">
<label>Логин bj<input type="text" name="bj_user" value="{{.DepoUser}}" autocomplete="off" spellcheck="false"></label>
<label>Пароль bj<input type="text" name="bj_password" value="{{.DepoPass}}" autocomplete="off" spellcheck="false" class="dq-mono"></label>
</div>
<p class="dq-note">Скрипт создаст этот логин — с ним bj будет входить в базу DEPO. Пароль придумал bj, можно вписать свой.</p>
<p class="dq-note">С этим логином bj будет входить в базу DEPO — его создаст подготовка. Пароль придумал bj, можно вписать свой. <b>Если такой логин на сервере уже есть, его пароль будет заменён</b> — когда этим логином пользуется другая программа, впишите другое имя.</p>
</div>
<div class="dq-panel" id="wiz-depo-autogo">
<p class="dq-note">bj проверит, что это база DEPO, создаст свой логин, таблицы обмена<span data-ms> и задание обработки</span>, выдаст права и проверит подключение.</p>
<button type="submit" class="btn dq-go">Подготовить</button>
</div>
<div class="dq-panel" id="wiz-depo-script">
<div class="dq-sql">
<div class="dq-sql-head copy-bar">
<span class="dq-sql-st" id="wiz-depo-st">Скрипт собирается из ответов выше и обновляется сам.</span>
@@ -246,7 +254,8 @@
f.classList.toggle('is-pg', pg);
if(pg&&f.port.value==='1433') f.port.value='5432'; else if(!pg&&f.port.value==='5432') f.port.value='1433';
f.admin_user.placeholder=pg?'postgres':'sa';
['auto','script','existing'].forEach(function(k){ document.getElementById('wiz-depo-'+k).hidden=m!==k; });
var show={auto:['auto','login','autogo'], script:['login','script'], existing:['existing']}[m]||[];
['auto','login','autogo','script','existing'].forEach(function(k){ document.getElementById('wiz-depo-'+k).hidden=show.indexOf(k)<0; });
}
function load(){ // скрипт — из текущих ответов; устаревший ответ (пока печатали дальше) не показываем
if(f.mode.value!=='script') return;
+30 -8
View File
@@ -253,9 +253,10 @@ func depoEnsureLogin(s *FancyMSSQLSettings) {
}
}
// depoScriptCreds — логин и пароль bj с экрана «Отдать скрипт администратору» (поля видны и меняются;
// пустой пароль — bj придумает сам). Возвращает текст ошибки.
func depoScriptCreds(r *http.Request, s *FancyMSSQLSettings) string {
// depoBjCreds — логин и пароль bj с экрана подготовки. Видны в обоих способах — «логин администратора» и
// «скрипт»: на общем сервере SQL логин bj_user может быть уже занят другим bj (подготовка сменила бы ему
// пароль) — тогда вписывают другое имя. Пустой пароль — bj придумает сам. Возвращает текст ошибки.
func depoBjCreds(r *http.Request, s *FancyMSSQLSettings) string {
if u := strings.TrimSpace(r.FormValue("bj_user")); u != "" {
if !depoLoginRe.MatchString(u) {
return "логин bj — латинские буквы, цифры, точка, дефис и _"
@@ -289,7 +290,7 @@ func (h *setupHandlers) wizardDepoScript(w http.ResponseWriter, r *http.Request)
}
s, db, agent, bad := h.depoSetupForm(r)
if bad == "" {
bad = depoScriptCreds(r, &s)
bad = depoBjCreds(r, &s)
}
if bad != "" {
fail(bad)
@@ -297,8 +298,10 @@ func (h *setupHandlers) wizardDepoScript(w http.ResponseWriter, r *http.Request)
}
depoEnsureLogin(&s)
s.DepoTablesMode = "script"
if s.Server != "" { // скрипт собирается и до адреса сервера (экран обновляет его на ходу) — без адреса не сохраняем
if err := h.rc.UpdateFancy(s); err != nil {
// показ на странице (экран пересобирает скрипт на ходу) ничего не сохраняет; файл — уходит администратору:
// запомнить логин и пароль из него, но только черновиком — в работу они пойдут после проверки входа
if !view && s.Server != "" {
if err := h.rc.UpdateFancy(depoDraft(s, h.rc.Snapshot().Fancy)); err != nil {
fail("не сохранилось: " + err.Error())
return
}
@@ -318,12 +321,22 @@ func (h *setupHandlers) wizardDepoScript(w http.ResponseWriter, r *http.Request)
_, _ = w.Write([]byte(body))
}
// depoDraft — настройки экрана с логином и паролем bj черновиком: рабочими остаются прежние (saved), новые —
// в DepoNewUser/DepoNewPass до успешного входа.
func depoDraft(s, saved FancyMSSQLSettings) FancyMSSQLSettings {
d := s
d.User, d.Password = saved.User, saved.Password
d.DepoNewUser, d.DepoNewPass = s.User, s.Password
return d
}
// wizardDepoSetup — POST /admin/setup/wizard-depo/setup: подготовить базу (mode=auto — bj сам логином
// администратора), проверить после скрипта (mode=script) или с готовым логином (mode=existing).
func (h *setupHandlers) wizardDepoSetup(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || !requireDepo(w, r) {
return
}
saved := h.rc.Snapshot().Fancy
s, db, agent, bad := h.depoSetupForm(r)
if bad == "" && s.Server == "" {
bad = "укажите адрес сервера DEPO"
@@ -342,6 +355,10 @@ func (h *setupHandlers) wizardDepoSetup(w http.ResponseWriter, r *http.Request)
wizDepoGo(w, r, "setup", "введите логин и пароль администратора SQL (они используются один раз и не сохраняются)", false)
return
}
if bad := depoBjCreds(r, &s); bad != "" {
wizDepoGo(w, r, "setup", bad, false)
return
}
depoEnsureLogin(&s)
if err := depoSetupRun(ctx, s, db, au, r.FormValue("admin_password"), s.User, s.Password, agent); err != nil {
wizDepoGo(w, r, "setup", "база не подготовлена — "+err.Error(), false)
@@ -363,7 +380,7 @@ func (h *setupHandlers) wizardDepoSetup(w http.ResponseWriter, r *http.Request)
s.User, s.Password = u, r.FormValue("password")
}
default: // script — администратор выполнил скрипт: логин и пароль bj — с экрана (они же в настройках)
if bad := depoScriptCreds(r, &s); bad != "" {
if bad := depoBjCreds(r, &s); bad != "" {
wizDepoGo(w, r, "setup", bad, false)
return
}
@@ -374,7 +391,7 @@ func (h *setupHandlers) wizardDepoSetup(w http.ResponseWriter, r *http.Request)
}
}
s.DepoDatabase = db
if err := h.rc.UpdateFancy(s); err != nil {
if err := h.rc.UpdateFancy(depoDraft(s, saved)); err != nil {
wizDepoGo(w, r, "setup", "не сохранилось: "+err.Error(), false)
return
}
@@ -382,6 +399,11 @@ func (h *setupHandlers) wizardDepoSetup(w http.ResponseWriter, r *http.Request)
wizDepoGo(w, r, "setup", note+"войти логином "+s.User+" не удалось — "+quikErrorDetail(err)+". Выполнен ли скрипт на сервере "+s.Server+"?", false)
return
}
s.DepoNewUser, s.DepoNewPass = "", "" // вход проверен — логин и пароль в работу
if err := h.rc.UpdateFancy(s); err != nil {
wizDepoGo(w, r, "setup", "не сохранилось: "+err.Error(), false)
return
}
if err := depoCheckIsDepo(ctx, s, db); err != nil { // имя базы задал клиент — та ли это база
wizDepoGo(w, r, "setup", note+err.Error(), false)
return