0ef75e05e8
После реальной установки КриптоПро CSP добавлены следующие функциональности: cryptocli/client.go: - FindCertificates() — перечисляет CKO_CERTIFICATE объекты на всех подключенных слотах через PKCS#11, парсит X.509, извлекает CN, ИНН (OID 1.2.643.3.131.1.1), серийник, срок действия. Для каждого сертификата проверяет наличие парного приватного ключа (CKO_PRIVATE_KEY с тем же CKA_ID). - Тип Certificate с полями: SubjectCN, IssuerCN, INN, Serial, NotBefore, NotAfter, DER, HasPrivateKey, TokenLabel, SlotID. internal/lkgateway/setup.go: - handler importCertificate (POST /admin/setup/crypto/import-cert, multipart). Принимает .pfx/.p12 (с PIN) или .cer/.crt. Запускает certmgr -inst -pfx или -inst с выбором хранилища (uMy/mroot/uRoot). - listCertsForUI() — вспомогательный метод renderSetup для подгрузки актуального списка сертификатов с подключенных токенов при каждом открытии страницы. internal/lkgateway/web/templates/admin_setup.html: - секция «Сертификаты на токенах» с таблицей (Кому/Кем выдан/ИНН/срок/ токен/есть-ли-приватный-ключ). - форма «Импорт сертификата (.pfx/.cer/.crt)» с полями для PIN и выбора хранилища. - блок «Интеграционный шлюз НРД»: добавлен JS автозаполнения URL ONYX и контейнера по выбору профиля (guest/test3/prod × gost/rsa) — значения из DOC/Ссылки для доступа в тестовые контуры.pdf. internal/lkgateway/web/templates/admin_help_systems.html: - секция «Интеграционный шлюз НРД и контуры тестирования» дополнена полной таблицей URL-ов сервисов GUEST/TEST3 (ONYX, Agate, DCS, Единый кабинет, Корпоративные действия). IP gost.nsd.ru для настройки межсетевого экрана. - новая секция «Сертификаты УЦ НРД (для проверки квитанций)» с пошаговой инструкцией: куда импортировать корневой сертификат УЦ НРД, куда промежуточные, куда наши сертификаты из стороннего УЦ. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
588 lines
21 KiB
Go
588 lines
21 KiB
Go
package lkgateway
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"net/url"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5"
|
||
|
||
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/cryptocli"
|
||
)
|
||
|
||
// crypto-сертификаты на текущих токенах (для отображения на странице).
|
||
func (h *setupHandlers) listCertsForUI() []cryptocli.Certificate {
|
||
s := h.rc.Snapshot()
|
||
if s.Crypto.Provider == "" || s.Crypto.Provider == "stub" || s.Crypto.JCPPath == "" {
|
||
return nil
|
||
}
|
||
cli := cryptocli.New(cryptocli.Config{
|
||
Provider: cryptocli.Provider(s.Crypto.Provider),
|
||
ModulePath: s.Crypto.JCPPath,
|
||
})
|
||
defer cli.Close()
|
||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||
defer cancel()
|
||
certs, _ := cli.FindCertificates(ctx)
|
||
return certs
|
||
}
|
||
|
||
// setupHandlers — обработчики /admin/setup/*.
|
||
type setupHandlers struct {
|
||
rc *RuntimeConfig
|
||
tpl *adminTemplates
|
||
svc *Service
|
||
}
|
||
|
||
// adminTemplates — обёртка, чтобы передать набор шаблонов в setup.
|
||
type adminTemplates struct {
|
||
a *admin
|
||
}
|
||
|
||
// registerSetup вешает /admin/setup и /admin/setup/* (POST) на mux.
|
||
func registerSetup(mux *http.ServeMux, a *admin, rc *RuntimeConfig, svc *Service) {
|
||
h := &setupHandlers{rc: rc, tpl: &adminTemplates{a: a}, svc: svc}
|
||
mux.HandleFunc("/admin/setup", func(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodGet {
|
||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
h.renderSetup(w, r, "")
|
||
})
|
||
mux.HandleFunc("/admin/setup/postgres", h.savePostgres)
|
||
mux.HandleFunc("/admin/setup/crypto", h.saveCrypto)
|
||
mux.HandleFunc("/admin/setup/crypto/check", h.checkCrypto)
|
||
mux.HandleFunc("/admin/setup/crypto/activate", h.activateLicense)
|
||
mux.HandleFunc("/admin/setup/crypto/install", h.installCryptoPro)
|
||
mux.HandleFunc("/admin/setup/crypto/import-cert", h.importCertificate)
|
||
mux.HandleFunc("/admin/setup/nsd", h.saveNSD)
|
||
mux.HandleFunc("/admin/setup/lk", h.saveLK)
|
||
mux.HandleFunc("/admin/setup/test-run", h.testRun)
|
||
}
|
||
|
||
// installCryptoPro — POST /admin/setup/crypto/install (multipart).
|
||
// Принимает tar или tar.gz архив с дистрибутивом КриптоПро CSP (как
|
||
// linux-amd64.tgz с cryptopro.ru), распаковывает в /tmp/bj-cryptopro,
|
||
// находит все .rpm файлы и устанавливает через sudo rpm -i.
|
||
// На РЕД ОС / ALT / ROSA это даёт рабочий /opt/cprocsp/.
|
||
func (h *setupHandlers) installCryptoPro(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
// Архив КриптоПро ~50-100 МБ — поднимем лимит до 256 МБ.
|
||
if err := r.ParseMultipartForm(256 << 20); err != nil {
|
||
setupFlash(w, r, "Установка: ошибка чтения формы: "+err.Error())
|
||
return
|
||
}
|
||
file, header, err := r.FormFile("dist")
|
||
if err != nil {
|
||
setupFlash(w, r, "Установка: выберите файл архива дистрибутива (.tar/.tgz/.tar.gz/.rpm)")
|
||
return
|
||
}
|
||
defer file.Close()
|
||
|
||
dir := "/tmp/bj-cryptopro"
|
||
_ = os.RemoveAll(dir)
|
||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||
setupFlash(w, r, "Установка: не получилось создать "+dir+": "+err.Error())
|
||
return
|
||
}
|
||
dst := filepath.Join(dir, filepath.Base(header.Filename))
|
||
out, err := os.Create(dst)
|
||
if err != nil {
|
||
setupFlash(w, r, "Установка: не получилось создать "+dst+": "+err.Error())
|
||
return
|
||
}
|
||
if _, err := io.Copy(out, file); err != nil {
|
||
out.Close()
|
||
setupFlash(w, r, "Установка: ошибка записи файла: "+err.Error())
|
||
return
|
||
}
|
||
out.Close()
|
||
|
||
// Распаковка (если .tar/.tgz/.tar.gz).
|
||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute)
|
||
defer cancel()
|
||
lower := strings.ToLower(dst)
|
||
switch {
|
||
case strings.HasSuffix(lower, ".rpm"):
|
||
// Один rpm — установим напрямую без распаковки.
|
||
case strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz"):
|
||
if untar, err := runCmdInDir(ctx, dir, "tar", "-xzf", dst); err != nil {
|
||
setupFlash(w, r, "Установка: распаковка .tgz упала: "+err.Error()+" / вывод: "+untar)
|
||
return
|
||
}
|
||
case strings.HasSuffix(lower, ".tar"):
|
||
if untar, err := runCmdInDir(ctx, dir, "tar", "-xf", dst); err != nil {
|
||
setupFlash(w, r, "Установка: распаковка .tar упала: "+err.Error()+" / вывод: "+untar)
|
||
return
|
||
}
|
||
default:
|
||
setupFlash(w, r, "Установка: неизвестный формат файла, нужен .tar/.tgz/.tar.gz/.rpm")
|
||
return
|
||
}
|
||
|
||
// Найти все .rpm в директории.
|
||
var rpms []string
|
||
_ = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
|
||
if err == nil && !info.IsDir() && strings.HasSuffix(p, ".rpm") {
|
||
rpms = append(rpms, p)
|
||
}
|
||
return nil
|
||
})
|
||
if len(rpms) == 0 {
|
||
setupFlash(w, r, "Установка: после распаковки .rpm файлы не найдены в "+dir)
|
||
return
|
||
}
|
||
|
||
// sudo rpm -i <все rpm>. На РЕД ОС иногда нужен --nosignature --nodeps.
|
||
args := append([]string{"rpm", "-Uvh", "--replacepkgs", "--nosignature"}, rpms...)
|
||
output, err := runCmd(ctx, "sudo", args...)
|
||
if err != nil {
|
||
setupFlash(w, r, "Установка: rpm -i упал: "+err.Error()+" / вывод: "+strings.TrimSpace(output))
|
||
return
|
||
}
|
||
setupFlash(w, r, "КриптоПро CSP установлен. Файлов rpm: "+fmt.Sprint(len(rpms))+". Теперь введите серийник и нажмите «Активировать лицензию». Вывод rpm: "+strings.TrimSpace(output))
|
||
}
|
||
|
||
// importCertificate — POST /admin/setup/crypto/import-cert (multipart).
|
||
// Принимает .pfx (PKCS#12 — приватный ключ + сертификат + опц. PIN) или
|
||
// .cer/.crt (только публичный сертификат). Импортирует через certmgr
|
||
// КриптоПро. Сертификат добавляется в хранилище uMy (либо mroot для
|
||
// корневых).
|
||
func (h *setupHandlers) importCertificate(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
if err := r.ParseMultipartForm(64 << 20); err != nil {
|
||
setupFlash(w, r, "Импорт сертификата: ошибка чтения формы: "+err.Error())
|
||
return
|
||
}
|
||
file, header, err := r.FormFile("cert")
|
||
if err != nil {
|
||
setupFlash(w, r, "Импорт сертификата: выберите файл .pfx/.cer/.crt")
|
||
return
|
||
}
|
||
defer file.Close()
|
||
pin := strings.TrimSpace(r.FormValue("pin"))
|
||
store := strings.TrimSpace(r.FormValue("store")) // "uMy" по умолчанию, "mroot" для корневых
|
||
|
||
dir := "/tmp/bj-certs"
|
||
_ = os.MkdirAll(dir, 0o755)
|
||
safeName := filepath.Base(header.Filename)
|
||
dst := filepath.Join(dir, safeName)
|
||
out, err := os.Create(dst)
|
||
if err != nil {
|
||
setupFlash(w, r, "Импорт сертификата: не получилось создать "+dst+": "+err.Error())
|
||
return
|
||
}
|
||
if _, err := io.Copy(out, file); err != nil {
|
||
out.Close()
|
||
setupFlash(w, r, "Импорт сертификата: ошибка записи: "+err.Error())
|
||
return
|
||
}
|
||
out.Close()
|
||
|
||
certmgr := "/opt/cprocsp/bin/amd64/certmgr"
|
||
if _, err := os.Stat(certmgr); err != nil {
|
||
setupFlash(w, r, "Импорт сертификата: certmgr не найден. Сначала установите КриптоПро CSP.")
|
||
return
|
||
}
|
||
if store == "" {
|
||
store = "uMy"
|
||
}
|
||
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
|
||
defer cancel()
|
||
|
||
lower := strings.ToLower(safeName)
|
||
var cmdOut string
|
||
switch {
|
||
case strings.HasSuffix(lower, ".pfx") || strings.HasSuffix(lower, ".p12"):
|
||
// PKCS#12 — импорт через certmgr -inst с PIN
|
||
args := []string{"-inst", "-pfx", "-file", dst, "-store", store}
|
||
if pin != "" {
|
||
args = append(args, "-pin", pin)
|
||
}
|
||
cmdOut, err = runCmd(ctx, certmgr, args...)
|
||
case strings.HasSuffix(lower, ".cer") || strings.HasSuffix(lower, ".crt"):
|
||
// Голый сертификат — импорт в хранилище без приватного ключа
|
||
cmdOut, err = runCmd(ctx, certmgr, "-inst", "-file", dst, "-store", store)
|
||
default:
|
||
setupFlash(w, r, "Импорт сертификата: неизвестное расширение, нужен .pfx/.p12/.cer/.crt")
|
||
return
|
||
}
|
||
if err != nil {
|
||
setupFlash(w, r, "Импорт сертификата: certmgr упал: "+err.Error()+" / вывод: "+strings.TrimSpace(cmdOut))
|
||
return
|
||
}
|
||
setupFlash(w, r, "Сертификат «"+safeName+"» импортирован в хранилище "+store+". Вывод certmgr: "+strings.TrimSpace(cmdOut))
|
||
}
|
||
|
||
// runCmdInDir выполняет команду в указанной рабочей директории.
|
||
func runCmdInDir(ctx context.Context, dir, name string, args ...string) (string, error) {
|
||
cmd := exec.CommandContext(ctx, name, args...)
|
||
cmd.Dir = dir
|
||
out, err := cmd.CombinedOutput()
|
||
return string(out), err
|
||
}
|
||
|
||
// runCmd выполняет команду и возвращает stdout+stderr строкой.
|
||
func runCmd(ctx context.Context, name string, args ...string) (string, error) {
|
||
cmd := exec.CommandContext(ctx, name, args...)
|
||
out, err := cmd.CombinedOutput()
|
||
return string(out), err
|
||
}
|
||
|
||
// activateLicense — POST /admin/setup/crypto/activate. Принимает серийный
|
||
// номер из формы, вызывает cpconfig -license -set, возвращает результат
|
||
// во flash. Если КриптоПро CSP не установлен — даёт ссылку на инструкцию.
|
||
func (h *setupHandlers) activateLicense(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
serial := strings.TrimSpace(r.FormValue("license_key"))
|
||
if serial == "" {
|
||
setupFlash(w, r, "Активация лицензии: введите серийный номер в поле выше")
|
||
return
|
||
}
|
||
cpconfig := "/opt/cprocsp/sbin/amd64/cpconfig"
|
||
if _, err := os.Stat(cpconfig); err != nil {
|
||
setupFlash(w, r, "КриптоПро CSP не установлен ("+cpconfig+" не найден). Раздел /admin/help/cryptopro — команды установки и копирования дистрибутива на ВМ.")
|
||
return
|
||
}
|
||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||
defer cancel()
|
||
out, err := runCmd(ctx, cpconfig, "-license", "-set", serial)
|
||
if err != nil {
|
||
setupFlash(w, r, "Активация лицензии не прошла: "+err.Error()+" / вывод: "+strings.TrimSpace(out))
|
||
return
|
||
}
|
||
cur := h.rc.Snapshot().Crypto
|
||
cur.LicenseKey = serial
|
||
if err := h.rc.UpdateCrypto(cur); err != nil {
|
||
log.Printf("activateLicense: UpdateCrypto: %v", err)
|
||
}
|
||
setupFlash(w, r, "Лицензия КриптоПро активирована. Вывод cpconfig: "+strings.TrimSpace(out))
|
||
}
|
||
|
||
// checkCrypto — POST /admin/setup/crypto/check. Запускает Health()
|
||
// текущего провайдера PKCS#11 без изменения настроек.
|
||
func (h *setupHandlers) checkCrypto(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
s := h.rc.Snapshot()
|
||
cli := cryptocli.New(cryptocli.Config{
|
||
Provider: cryptocli.Provider(s.Crypto.Provider),
|
||
ModulePath: s.Crypto.JCPPath, // унаследовано — теперь путь к PKCS#11 .so
|
||
})
|
||
defer cli.Close()
|
||
info, err := cli.Health(r.Context())
|
||
if err != nil {
|
||
setupFlash(w, r, "СКЗИ: проверка не прошла — "+err.Error())
|
||
return
|
||
}
|
||
msg := fmt.Sprintf("СКЗИ %s: %s", info.Provider, info.Message)
|
||
if info.CryptokiVersion != "" {
|
||
msg += fmt.Sprintf(" (PKCS#11 v%s, %s)", info.CryptokiVersion, info.ManufacturerID)
|
||
}
|
||
if len(info.Tokens) > 0 {
|
||
msg += ". Токены:"
|
||
for _, t := range info.Tokens {
|
||
msg += fmt.Sprintf(" «%s» (%s);", t.Label, t.Model)
|
||
}
|
||
}
|
||
setupFlash(w, r, msg)
|
||
}
|
||
|
||
// SetupData — данные для шаблона admin_setup.html.
|
||
type SetupData struct {
|
||
page
|
||
Settings Settings
|
||
Readiness []Readiness
|
||
ReadyCount int
|
||
TotalCount int
|
||
Certificates []cryptocli.Certificate
|
||
Flash string
|
||
Error string
|
||
}
|
||
|
||
func (h *setupHandlers) renderSetup(w http.ResponseWriter, _ *http.Request, flash string) {
|
||
s := h.rc.Snapshot()
|
||
r := h.rc.ReadinessSummary()
|
||
ready := 0
|
||
for _, x := range r {
|
||
if x.Configured {
|
||
ready++
|
||
}
|
||
}
|
||
data := SetupData{
|
||
page: nowPage("Настройка", "setup"),
|
||
Settings: s,
|
||
Readiness: r,
|
||
ReadyCount: ready,
|
||
TotalCount: len(r),
|
||
Certificates: h.listCertsForUI(),
|
||
Flash: flash,
|
||
}
|
||
if errVal := errMsgFromQuery(_q(w)); errVal != "" {
|
||
data.Error = errVal
|
||
}
|
||
render(w, h.tpl.a.setup, data)
|
||
}
|
||
|
||
func (h *setupHandlers) savePostgres(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
dsn := strings.TrimSpace(r.FormValue("dsn"))
|
||
if dsn != "" {
|
||
// Лёгкая проверка: попытка sql.Open и Ping (без драйвера дальше
|
||
// просто ошибка — это нормально, важно показать что DSN сохранён).
|
||
if err := tryPingPostgres(dsn); err != nil {
|
||
setupFlash(w, r, "postgres: тест соединения упал: "+err.Error())
|
||
return
|
||
}
|
||
}
|
||
if err := h.rc.UpdatePostgres(PostgresSettings{DSN: dsn}); err != nil {
|
||
setupFlash(w, r, "postgres: ошибка сохранения: "+err.Error())
|
||
return
|
||
}
|
||
setupFlash(w, r, "PostgreSQL настройки сохранены")
|
||
}
|
||
|
||
func (h *setupHandlers) saveCrypto(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
s := CryptoSettings{
|
||
Provider: strings.TrimSpace(r.FormValue("provider")),
|
||
SocketPath: strings.TrimSpace(r.FormValue("socket_path")),
|
||
JCPPath: strings.TrimSpace(r.FormValue("jcp_path")),
|
||
LicenseKey: strings.TrimSpace(r.FormValue("license_key")),
|
||
}
|
||
if s.Provider == "" {
|
||
s.Provider = "stub"
|
||
}
|
||
if s.SocketPath == "" {
|
||
s.SocketPath = "/run/bj/crypto.sock"
|
||
}
|
||
// Если указан JCP-путь — проверим что файл существует.
|
||
if s.JCPPath != "" {
|
||
if _, err := os.Stat(s.JCPPath); err != nil {
|
||
setupFlash(w, r, "crypto: jcp_path не найден: "+err.Error())
|
||
return
|
||
}
|
||
}
|
||
if err := h.rc.UpdateCrypto(s); err != nil {
|
||
setupFlash(w, r, "crypto: ошибка сохранения: "+err.Error())
|
||
return
|
||
}
|
||
setupFlash(w, r, "Криптография: настройки сохранены ("+s.Provider+")")
|
||
}
|
||
|
||
func (h *setupHandlers) saveNSD(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
s := NSDSettings{
|
||
Profile: strings.TrimSpace(r.FormValue("profile")),
|
||
IGWBaseURL: strings.TrimSpace(r.FormValue("igw_base_url")),
|
||
KeyContainer: strings.TrimSpace(r.FormValue("key_container")),
|
||
}
|
||
if s.IGWBaseURL != "" {
|
||
if _, err := url.ParseRequestURI(s.IGWBaseURL); err != nil {
|
||
setupFlash(w, r, "nsd: невалидный URL: "+err.Error())
|
||
return
|
||
}
|
||
if err := tryHTTPHealth(s.IGWBaseURL + "/healthz"); err != nil {
|
||
setupFlash(w, r, "nsd: ИШ не отвечает на /healthz: "+err.Error())
|
||
return
|
||
}
|
||
}
|
||
if err := h.rc.UpdateNSD(s); err != nil {
|
||
setupFlash(w, r, "nsd: ошибка сохранения: "+err.Error())
|
||
return
|
||
}
|
||
setupFlash(w, r, "nsd-adapter: настройки сохранены")
|
||
}
|
||
|
||
func (h *setupHandlers) saveLK(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
s := LKSettings{CallbackURL: strings.TrimSpace(r.FormValue("callback_url"))}
|
||
if s.CallbackURL != "" {
|
||
if _, err := url.ParseRequestURI(s.CallbackURL); err != nil {
|
||
setupFlash(w, r, "lk: невалидный URL: "+err.Error())
|
||
return
|
||
}
|
||
if err := tryHTTPHealth(s.CallbackURL + "/healthz"); err != nil {
|
||
setupFlash(w, r, "lk: callback URL не отвечает на /healthz: "+err.Error())
|
||
return
|
||
}
|
||
}
|
||
if err := h.rc.UpdateLK(s); err != nil {
|
||
setupFlash(w, r, "lk: ошибка сохранения: "+err.Error())
|
||
return
|
||
}
|
||
if s.CallbackURL != "" {
|
||
h.svc.callbackURL = s.CallbackURL
|
||
}
|
||
setupFlash(w, r, "Callback в ЛК сохранён и применён")
|
||
}
|
||
|
||
// testRun запускает тестовую заявку с предустановленными данными,
|
||
// ждёт изменения статуса до confirmed/rejected/timed_out и сохраняет
|
||
// результат в RuntimeConfig.LastTest.
|
||
func (h *setupHandlers) testRun(w http.ResponseWriter, r *http.Request) {
|
||
if r.Method != http.MethodPost {
|
||
http.Error(w, "method", http.StatusMethodNotAllowed)
|
||
return
|
||
}
|
||
go h.runTestClaim()
|
||
setupFlash(w, r, "Тестовая заявка запущена, обновите страницу через 5 секунд")
|
||
}
|
||
|
||
// runTestClaim делает CreateClaim + ждёт финального состояния через GetClaim.
|
||
func (h *setupHandlers) runTestClaim() {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
|
||
whole := uint64(1500)
|
||
req := CreateClaimRequest{
|
||
Investor: Investor{
|
||
ID: "11111111-1111-1111-1111-111111111111",
|
||
LastName: "Иванов", FirstName: "Иван", MiddleName: "Иванович",
|
||
Document: Document{DocumentType: "21", Series: "4512", Number: "654321"},
|
||
},
|
||
TransferringDepositoryINN: "0702345678",
|
||
ReceivingDepositoryINN: "0710987654",
|
||
CostInfo: CostInfo{No: &struct{}{}},
|
||
IIAAgreement: &IIAAgreement{
|
||
AgreementType: "T03", AgreementNumber: "ИИС78/2024",
|
||
AgreementDate: "2026-01-15", BrokerINN: "0707083893",
|
||
},
|
||
Securities: []ClaimSec{
|
||
{
|
||
SecurityCode: "MM0766162534",
|
||
SecurityDetails: SecurityDetails{ISIN: "RU0007661625"},
|
||
Quantity: Quantity{Whole: whole},
|
||
SettlementAccounts: []SettlementAccount{
|
||
{
|
||
SettlementRequisitesINN: "7702070139",
|
||
SettlementLocation: SettlementLocation{
|
||
DeponentCode: "DP789456", AccountID: "31MC0021900000F01", SectionID: "P001",
|
||
},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
SignedDocument: "dGVzdC1zaWduYXR1cmU=",
|
||
SignatureFormat: "XMLDSig-GOST",
|
||
}
|
||
|
||
res := TestRunResult{StartedAt: time.Now().UTC()}
|
||
created, err := h.svc.CreateClaim(ctx, req)
|
||
if err != nil {
|
||
res.FinishedAt = time.Now().UTC()
|
||
res.OK = false
|
||
res.Message = "CreateClaim упал: " + err.Error()
|
||
_ = h.rc.RecordTestRun(res)
|
||
return
|
||
}
|
||
res.ClaimID = created.ID
|
||
|
||
// Опрашиваем статус каждые 200ms до перехода в финал.
|
||
deadline := time.Now().Add(25 * time.Second)
|
||
for time.Now().Before(deadline) {
|
||
view, err := h.svc.GetClaim(ctx, created.ID)
|
||
if err != nil {
|
||
res.FinishedAt = time.Now().UTC()
|
||
res.OK = false
|
||
res.FinalStatus = "lookup_failed"
|
||
res.Message = err.Error()
|
||
_ = h.rc.RecordTestRun(res)
|
||
return
|
||
}
|
||
switch view.Status {
|
||
case "confirmed", "awaiting_sub16", "done":
|
||
res.FinishedAt = time.Now().UTC()
|
||
res.OK = true
|
||
res.FinalStatus = view.Status
|
||
res.Message = "Заявка подтверждена принимающей стороной (mock или реальный НРД)."
|
||
_ = h.rc.RecordTestRun(res)
|
||
return
|
||
case "rejected", "timed_out":
|
||
res.FinishedAt = time.Now().UTC()
|
||
res.OK = false
|
||
res.FinalStatus = view.Status
|
||
res.Message = "Заявка не прошла: статус " + view.Status
|
||
_ = h.rc.RecordTestRun(res)
|
||
return
|
||
}
|
||
time.Sleep(200 * time.Millisecond)
|
||
}
|
||
res.FinishedAt = time.Now().UTC()
|
||
res.OK = false
|
||
res.FinalStatus = "timeout_waiting"
|
||
res.Message = "Не дождались финального статуса за 25 сек (mock-задержка обычно 3 сек; проверьте лог lk-gateway)"
|
||
_ = h.rc.RecordTestRun(res)
|
||
}
|
||
|
||
// tryPingPostgres делает короткое подключение через pgx и Ping.
|
||
func tryPingPostgres(dsn string) error {
|
||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||
defer cancel()
|
||
conn, err := pgx.Connect(ctx, dsn)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer conn.Close(ctx)
|
||
return conn.Ping(ctx)
|
||
}
|
||
|
||
// tryHTTPHealth делает GET и ждёт 2xx.
|
||
func tryHTTPHealth(u string) error {
|
||
c := &http.Client{Timeout: 3 * time.Second}
|
||
resp, err := c.Get(u)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer resp.Body.Close()
|
||
if resp.StatusCode >= 400 {
|
||
return fmt.Errorf("HTTP %d", resp.StatusCode)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// setupFlash шлёт 303 на /admin/setup с flash-сообщением в query.
|
||
func setupFlash(w http.ResponseWriter, r *http.Request, msg string) {
|
||
http.Redirect(w, r, "/admin/setup?flash="+url.QueryEscape(msg), http.StatusSeeOther)
|
||
}
|
||
|
||
// _q извлекает Request из ResponseWriter trick — здесь не нужно
|
||
// (всегда работаем через chain).
|
||
func _q(_ http.ResponseWriter) string { return "" }
|
||
func errMsgFromQuery(_ string) string { return "" }
|
||
|
||
// guard — заглушка для совместимости с возможным расширением.
|
||
var _ = errors.New
|