diff --git a/DOC/instr-ish-rest-api.pdf b/DOC/instr-ish-rest-api.pdf new file mode 100644 index 0000000..0c41705 Binary files /dev/null and b/DOC/instr-ish-rest-api.pdf differ diff --git a/REPORT.md b/REPORT.md index 685d98a..c299e33 100644 --- a/REPORT.md +++ b/REPORT.md @@ -1,6 +1,6 @@ # Bridge-and-Join-s — отчёт о ходе работ -**Дата:** 14.05.2026 +**Дата:** 14.05.2026 (обновлено вечером — добавлен REST-клиент ИШ + эмулятор робота) **Контур:** дев-стенд РЕД ОС 8 (10.10.10.22), bj-server на :8080, lk-emulator на :8083 **Целевая интеграция:** сервис MOEX МОСТ (M2M) через НКО АО НРД @@ -20,16 +20,17 @@ | Контейнеры КриптоПро с флешки (импорт в HDIMAGE) | **80%** | ⚠ Без UI-импорта сертификата из контейнера | | Лента новостей + мониторинг сайта НРД (doc-watcher) | **100%** | ✅ Готово | | Эмулятор робота-автотеста НРД (внутренний mock) | **90%** | ⚠ Сценарий 3333 — частично | -| Реальное подключение к роботу на TEST3 НРД | **0%** | ⏳ Заблокировано на ИШ и сертификате | -| Интеграционный шлюз НРД (ИШ) | **0%** | ⏳ Не скачан, не установлен | +| Реальное подключение к роботу на TEST3 НРД | **30%** | ⚠ REST-клиент ИШ готов, ждём сам ИШ + сертификат | +| REST-клиент ИШ НРД (по DOC/instr-ish-rest-api.pdf) | **100%** | ✅ POST file, GET status, GET list, GET package, упаковщик ZIP, 10/10 тестов | +| Интеграционный шлюз НРД (ИШ) — серверная часть от НРД | **0%** | ⏳ Не скачан, не установлен (заблокировано НРД) | | Сертификат УЦ Московской Биржи для подписи | **0%** | ⏳ Не получен | | Подключение реального ЛК ESIA Finance | **20%** | ⚠ Эмулятор lk-emulator работает, реальный URL не указан | | Контракт с Fansy (ETL) | **30%** | ⚠ Контракт документирован, ETL не реализован стороной Fansy | | Уведомления (e-mail, мессенджеры) | **0%** | ⏳ M3-M4 | | Тесты, CI/CD | **40%** | ⚠ Unit-тесты компонентов, нет E2E против реального НРД | -**Общая готовность системы:** **≈ 65%** (по объёму функциональности) -**Готовность к интеграционному тесту с роботом:** **≈ 80%** (зависит только от внешних факторов — ИШ, сертификат) +**Общая готовность системы:** **≈ 70%** (по объёму функциональности) +**Готовность к интеграционному тесту с роботом:** **≈ 85%** (зависит только от внешних факторов — серверный ИШ от НРД, сертификат) --- @@ -81,6 +82,18 @@ - Help-страница `/admin/help/robot` с полной документацией (коды ошибок M2M01-M2M09, тестовые наборы депозитариев, схема обмена). - Когда подключим реальный ИШ — переключение прозрачное, те же заявки пойдут на реальный TEST3. +### REST-клиент ИШ НРД (готов на нашей стороне) +- По свежей спецификации НРД (`DOC/instr-ish-rest-api.pdf`) реализован Go-клиент в `internal/nsdadapter/igw`: + - `POST /api/package/{channel}/file` — отправка ZIP (Type=archive, File=base64) + - `GET /api/package/status/{id}` — статус: NEW / SENT / ERROR + - `GET /api/package?channel=&type=M2MTD&...` — список входящих от НРД + - `GET /api/package/{id}` — скачать ZIP пакета (поддерживает и raw ZIP, и base64-в-JSON) +- Упаковщик (`pack.go`): `M2MTransferRequest → ZIP (XML + config.xml)` по разделу 2.3 инструкции +- Распаковщик: ZIP → DocXML + winf.xml + .sgn (отсоединённая подпись НРД) +- Парсеры: `ParseDecision`, `ParseResponse` — из XML в Go-структуры через `nsdxml.Unmarshal` +- Покрыто тестами: 10/10 PASS (httptest + zip round-trip + 4xx без ретраев + retry на 5xx) +- Готов к переключению: как только получим живой ИШ от НРД, нужно только указать BaseURL и Channel в `/admin/setup` — код уже всё умеет + ### Безопасность и надёжность - Баннер «🟡 РЕЖИМ ЭМУЛЯЦИИ» отображается на каждой странице админки пока не настроен ИШ или СКЗИ — оператор не сможет случайно принять mock-результат за реальный. - Контекстная навигация после действий (после POST возврат на ту же страницу, не в /admin/setup). @@ -115,6 +128,10 @@ - Сейчас bj-server работает с встроенным эмулятором `lk-emulator` на :8083. - Что нужно: URL продакшен/тест ЛК, Basic-auth учётка. +6. **«Руководство пользователя ПО Интеграционный шлюз НРД»** и **«Руководство по установке и настройке…»** + - Упомянуты как ссылки в `DOC/Инструккия M2M.pdf` стр. 6, но самих файлов у нас нет. + - REST API мы уже реализовали по `instr-ish-rest-api.pdf` — но настройка каналов ЭДО (channel=?), параметры ключевого хранилища ИШ, порт по умолчанию — оттуда. + ### Внутренние задачи (можем делать параллельно) | Задача | Приоритет | Эффект | diff --git a/cmd/bj-server/main.go b/cmd/bj-server/main.go index 633169a..3ee9e08 100644 --- a/cmd/bj-server/main.go +++ b/cmd/bj-server/main.go @@ -110,15 +110,20 @@ func runNSDPoller(ctx context.Context, profileName string) { return case <-t.C: for _, kind := range nsdadapter.IncomingPackageKinds() { - pkgs, err := client.ListIncoming(ctx, profile.Channel, since, string(kind)) + pkgs, err := client.ListIncoming(ctx, igw.ListFilter{ + Channel: profile.Channel, + Date: since, + Type: string(kind), + }) if err != nil { log.Printf("%s: NSD poller ListIncoming(%s, %s): %v", serviceName, profile.Channel, kind, err) continue } for _, p := range pkgs { - log.Printf("%s: NSD входящий пакет %s типа %s (канал %s, получен %s)", - serviceName, p.PackageID, p.PackageType, p.Channel, p.ReceivedAt.Format(time.RFC3339)) - // TODO(M3): парсить тело пакета, передавать в lkgateway.Service.ApplyDecision + log.Printf("%s: NSD входящий пакет id=%d (%s) типа %s, канал %s, state %s", + serviceName, p.ID, p.Name, p.Type, p.Channel, p.State) + // TODO(M3): GetPackage(p.ID) → unpack ZIP → парсить XML → + // передавать в lkgateway.Service.ApplyDecision } } since = time.Now().UTC() diff --git a/internal/nsdadapter/igw/client.go b/internal/nsdadapter/igw/client.go index 9d605b3..5f49e4f 100644 --- a/internal/nsdadapter/igw/client.go +++ b/internal/nsdadapter/igw/client.go @@ -1,6 +1,20 @@ // Package igw — REST-клиент Интеграционного шлюза (ИШ) НРД. -// Тело пакета передаётся base64 в JSON; ИШ сам подписывает и -// упаковывает в ZIP-пакет ЭДО по правилам НРД. +// Документ-источник: DOC/instr-ish-rest-api.pdf (НРД, 2026). +// +// ИШ — серверное ПО НРД, которое: +// - принимает от нас сырой XML/ZIP M2M-документа; +// - сам подписывает его сертификатом УЦ МБ (ключ настроен в ИШ); +// - формирует пакет ЭДО по Правилам ЭДО НРД; +// - отправляет в НРД через Web-сервис ONYX; +// - принимает входящие пакеты M2MTD/M2MER от НРД; +// - проверяет подпись НРД (поле signs.status = VALID/INVALID); +// - выдаёт всё это клиенту через REST API. +// +// REST-эндпоинты (все по 200/JSON): +// POST /api/package/{channel}/file — отправить ZIP, вернёт id +// GET /api/package/status/{id} — статус: NEW | SENT | ERROR +// GET /api/package?channel=&type=... — список входящих +// GET /api/package/{id} — тело пакета (ZIP в base64) package igw import ( @@ -13,6 +27,7 @@ import ( "io" "net/http" "net/url" + "strconv" "strings" "time" ) @@ -53,21 +68,42 @@ func NewClient(baseURL string, opts ...Option) *Client { return c } -// SendPackage отправляет пакет в указанный канал ЭДО. Возвращает -// идентификатор пакета, присвоенный ИШ. -func (c *Client) SendPackage(ctx context.Context, channel, packageType string, body []byte) (string, error) { +// --------------- POST /api/package/{channel}/file --------------- + +// sendBody — тело запроса на отправку пакета. По спецификации НРД +// (instr-ish-rest-api.pdf, раздел 2.5.1) поля Type/File. +type sendBody struct { + Type string `json:"Type"` + File string `json:"File"` +} + +// sendResponse — ответ ИШ на отправку. Спецификация: 200 + JSON с ID. +// В новом документе НРД сам формат JSON не зафиксирован детально, поэтому +// принимаем три популярные формы: {id:..}, {package_id:..}, {ID:..}. +type sendResponse struct { + ID json.Number `json:"id,omitempty"` + PackageID json.Number `json:"package_id,omitempty"` + IDAlt json.Number `json:"ID,omitempty"` +} + +// SendPackage отправляет ZIP-архив (M2MTransferRequest.xml + config.xml) +// в указанный канал ЭДО ИШ. Сигнатура совместима с предыдущей версией — +// packageType остался параметром для backward-compat, но в новом API +// он внутри ZIP'а (в config.xml/), не в HTTP-теле. +// +// Возвращает идентификатор пакета (как строку — может быть числом или +// UUID, зависит от версии ИШ). +func (c *Client) SendPackage(ctx context.Context, channel, packageType string, zipBody []byte) (string, error) { if channel == "" { return "", errors.New("igw: channel пустой") } - if packageType == "" { - return "", errors.New("igw: packageType пустой") + _ = packageType // не используется в новом API, кладётся внутрь ZIP/config.xml + if len(zipBody) == 0 { + return "", errors.New("igw: zipBody пустой") } - payload := struct { - PackageType string `json:"package_type"` - Body string `json:"body"` - }{ - PackageType: packageType, - Body: base64.StdEncoding.EncodeToString(body), + payload := sendBody{ + Type: "archive", + File: base64.StdEncoding.EncodeToString(zipBody), } raw, err := json.Marshal(payload) if err != nil { @@ -80,27 +116,36 @@ func (c *Client) SendPackage(ctx context.Context, channel, packageType string, b } defer resp.Body.Close() - var out struct { - PackageID string `json:"package_id"` - } + var out sendResponse if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return "", fmt.Errorf("igw: decode SendPackage response: %w", err) } - if out.PackageID == "" { - return "", errors.New("igw: пустой package_id в ответе ИШ") + for _, v := range []json.Number{out.ID, out.PackageID, out.IDAlt} { + if s := string(v); s != "" { + return s, nil + } } - return out.PackageID, nil + return "", errors.New("igw: пустой id в ответе ИШ") } -// Status — состояние пакета у ИШ. +// --------------- GET /api/package/status/{id} --------------- + +// Status — состояние отправленного пакета (раздел 2.5.2 инструкции). +// Status может быть NEW (новый), SENT (отправлен), ERROR (ошибка). type Status struct { - PackageID string `json:"package_id"` - State string `json:"state"` - UpdatedAt time.Time `json:"updated_at"` - ErrorCode string `json:"error_code,omitempty"` - ErrorText string `json:"error_text,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Error string `json:"error,omitempty"` } +// Status-константы для удобства. +const ( + StatusNew = "NEW" + StatusSent = "SENT" + StatusError = "ERROR" +) + // GetStatus возвращает текущее состояние пакета по идентификатору. func (c *Client) GetStatus(ctx context.Context, packageID string) (Status, error) { if packageID == "" { @@ -112,41 +157,79 @@ func (c *Client) GetStatus(ctx context.Context, packageID string) (Status, error return Status{}, err } defer resp.Body.Close() - var s Status - if err := json.NewDecoder(resp.Body).Decode(&s); err != nil { + // Дублируем альтернативные имена полей (id|ID, status|state) — на + // случай различий между версиями ИШ. + var raw map[string]json.RawMessage + if err := json.NewDecoder(resp.Body).Decode(&raw); err != nil { return Status{}, fmt.Errorf("igw: decode Status: %w", err) } + s := Status{} + pickStr(raw, []string{"id", "ID", "package_id"}, &s.ID) + pickStr(raw, []string{"name"}, &s.Name) + pickStr(raw, []string{"status", "state"}, &s.Status) + pickStr(raw, []string{"error", "error_text", "error_code"}, &s.Error) return s, nil } -// Package — описание входящего пакета. +// --------------- GET /api/package?channel=&type=... --------------- + +// ListFilter — параметры фильтрации входящих пакетов (раздел 2.6). +type ListFilter struct { + Channel string // обязательный — код канала ЭДО + Date time.Time // опц., YYYY-MM-DD + SinceID int // опц., скип до этого id + Count int // опц., лимит + Type string // опц., "M2MTD" | "M2MER" (без #) + ExcludeErrors bool // опц., исключать пакеты с ошибкой +} + +// Sign — подпись пакета. ИШ сам проверяет и выдаёт результат: VALID/INVALID. +type Sign struct { + Serial string `json:"serial"` + Subject string `json:"subject"` + Description string `json:"description"` + Status string `json:"status"` // "VALID" | "INVALID" +} + +// File — файл внутри пакета. +type File struct { + ID int `json:"id"` + Name string `json:"name"` +} + +// Package — описание входящего пакета по списку (раздел 2.6). type Package struct { - PackageID string `json:"package_id"` - PackageType string `json:"package_type"` - Channel string `json:"channel"` - ReceivedAt time.Time `json:"received_at"` - Body string `json:"body,omitempty"` // base64 + Channel string `json:"channel"` + ID int `json:"id"` + Name string `json:"name"` + Type string `json:"type"` // "M2MTD" | "M2MER" + State string `json:"state"` // "RECEIVED" | "ERROR" | "DELETED" + Files []File `json:"files,omitempty"` + Signs []Sign `json:"signs,omitempty"` } -// DecodeBody возвращает декодированное содержимое пакета. -func (p Package) DecodeBody() ([]byte, error) { - if p.Body == "" { - return nil, nil +// ListIncoming возвращает список входящих пакетов от НРД по фильтрам. +// Если filter.Type не задан — возвращает оба типа M2MTD + M2MER. +func (c *Client) ListIncoming(ctx context.Context, filter ListFilter) ([]Package, error) { + if filter.Channel == "" { + return nil, errors.New("igw: ListFilter.Channel обязателен") } - return base64.StdEncoding.DecodeString(p.Body) -} - -// ListIncoming возвращает список входящих пакетов по фильтрам. -func (c *Client) ListIncoming(ctx context.Context, channel string, since time.Time, packageType string) ([]Package, error) { q := url.Values{} - if channel != "" { - q.Set("channel", channel) + q.Set("channel", filter.Channel) + if !filter.Date.IsZero() { + q.Set("date", filter.Date.UTC().Format("2006-01-02")) } - if !since.IsZero() { - q.Set("date", since.UTC().Format(time.RFC3339)) + if filter.SinceID > 0 { + q.Set("id", strconv.Itoa(filter.SinceID)) } - if packageType != "" { - q.Set("type", packageType) + if filter.Count > 0 { + q.Set("count", strconv.Itoa(filter.Count)) + } + if filter.Type != "" { + q.Set("type", filter.Type) + } + if filter.ExcludeErrors { + q.Set("excludeErrors", "true") } path := "/api/package?" + q.Encode() resp, err := c.doRetry(ctx, http.MethodGet, path, nil, "") @@ -154,15 +237,83 @@ func (c *Client) ListIncoming(ctx context.Context, channel string, since time.Ti return nil, err } defer resp.Body.Close() - var out struct { + + // ИШ может вернуть либо массив, либо {items: [...]}. Поддержим оба. + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("igw: read ListIncoming: %w", err) + } + body = bytes.TrimSpace(body) + if len(body) == 0 { + return nil, nil + } + if body[0] == '[' { + var arr []Package + if err := json.Unmarshal(body, &arr); err != nil { + return nil, fmt.Errorf("igw: decode ListIncoming (array): %w", err) + } + return arr, nil + } + var wrap struct { Items []Package `json:"items"` } - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return nil, fmt.Errorf("igw: decode ListIncoming: %w", err) + if err := json.Unmarshal(body, &wrap); err != nil { + return nil, fmt.Errorf("igw: decode ListIncoming (object): %w", err) } - return out.Items, nil + return wrap.Items, nil } +// --------------- GET /api/package/{id} --------------- + +// GetPackage возвращает содержимое пакета по ID — ZIP-архив с файлами +// документа и отсоединёнными подписями. По спецификации (раздел 2.6.2) +// ИШ отвечает 200 + body = base64-encoded ZIP. Метод декодирует base64 +// и возвращает сырые ZIP-байты. +func (c *Client) GetPackage(ctx context.Context, packageID int) ([]byte, error) { + if packageID <= 0 { + return nil, errors.New("igw: packageID должен быть > 0") + } + path := "/api/package/" + strconv.Itoa(packageID) + resp, err := c.doRetry(ctx, http.MethodGet, path, nil, "") + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("igw: read GetPackage: %w", err) + } + // ИШ возвращает либо чистый ZIP (Content-Type: application/zip), + // либо JSON с base64. Проверим по сигнатуре ZIP (PK\x03\x04). + if len(body) >= 4 && body[0] == 'P' && body[1] == 'K' && + body[2] == 0x03 && body[3] == 0x04 { + return body, nil + } + // Иначе пробуем base64 (с JSON-обёрткой или без). + stripped := bytes.TrimSpace(body) + if len(stripped) > 1 && stripped[0] == '"' && stripped[len(stripped)-1] == '"' { + stripped = stripped[1 : len(stripped)-1] + } + // JSON-объект {"file":"..."} или {"body":"..."} + if len(stripped) > 0 && stripped[0] == '{' { + var obj map[string]string + if err := json.Unmarshal(stripped, &obj); err == nil { + for _, k := range []string{"file", "File", "body", "Body"} { + if v, ok := obj[k]; ok { + return base64.StdEncoding.DecodeString(v) + } + } + } + } + decoded, err := base64.StdEncoding.DecodeString(string(stripped)) + if err == nil && len(decoded) >= 4 && decoded[0] == 'P' && decoded[1] == 'K' { + return decoded, nil + } + return body, nil +} + +// --------------- общая HTTP-логика --------------- + // doRetry выполняет HTTP-запрос с ретраями на сетевые ошибки и 5xx. func (c *Client) doRetry(ctx context.Context, method, path string, body io.Reader, contentType string) (*http.Response, error) { var lastErr error @@ -182,7 +333,7 @@ func (c *Client) doRetry(ctx context.Context, method, path string, body io.Reade if contentType != "" { req.Header.Set("Content-Type", contentType) } - req.Header.Set("Accept", "application/json") + req.Header.Set("Accept", "application/json, */*") resp, err := c.httpClient.Do(req) switch { case err != nil: @@ -207,3 +358,22 @@ func (c *Client) doRetry(ctx context.Context, method, path string, body io.Reade } return nil, fmt.Errorf("igw: исчерпаны ретраи: %w", lastErr) } + +// pickStr заполняет dest первым непустым значением из raw по списку ключей. +func pickStr(raw map[string]json.RawMessage, keys []string, dest *string) { + for _, k := range keys { + if v, ok := raw[k]; ok { + var s string + if err := json.Unmarshal(v, &s); err == nil && s != "" { + *dest = s + return + } + // Может быть числом — превращаем в строку. + var n json.Number + if err := json.Unmarshal(v, &n); err == nil && string(n) != "" { + *dest = string(n) + return + } + } + } +} diff --git a/internal/nsdadapter/igw/client_test.go b/internal/nsdadapter/igw/client_test.go index fb60440..f3622f6 100644 --- a/internal/nsdadapter/igw/client_test.go +++ b/internal/nsdadapter/igw/client_test.go @@ -2,32 +2,53 @@ package igw_test import ( "context" + "encoding/base64" "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "time" "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw" ) +// TestSendPackageHappyPath — отправка ZIP, ИШ возвращает id. +// Сценарий по DOC/instr-ish-rest-api.pdf раздел 2.5.1. func TestSendPackageHappyPath(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/package/TEST3/file" { + if r.URL.Path != "/api/package/CH1/file" { t.Errorf("неожиданный путь %q", r.URL.Path) } + // Проверим что тело — это {Type: "archive", File: base64}. + var body struct { + Type string `json:"Type"` + File string `json:"File"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.Type != "archive" { + t.Errorf("Type = %q, ожидалось archive", body.Type) + } + if body.File == "" { + t.Errorf("File пустой") + } + if _, err := base64.StdEncoding.DecodeString(body.File); err != nil { + t.Errorf("File не base64: %v", err) + } w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]string{"package_id": "pkg-123"}) + _ = json.NewEncoder(w).Encode(map[string]any{"id": 123}) })) defer srv.Close() c := igw.NewClient(srv.URL, igw.WithRetry(0, time.Millisecond)) - id, err := c.SendPackage(context.Background(), "TEST3", "#M2MTR", []byte("")) + id, err := c.SendPackage(context.Background(), "CH1", "#M2MTR", []byte("PK\x03\x04zipbody")) if err != nil { t.Fatal(err) } - if id != "pkg-123" { - t.Errorf("package_id = %q, ожидалось %q", id, "pkg-123") + if id != "123" { + t.Errorf("id = %q, ожидалось 123", id) } } @@ -40,17 +61,17 @@ func TestSendPackageRetryOn500(t *testing.T) { return } w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(map[string]string{"package_id": "pkg-retry"}) + _ = json.NewEncoder(w).Encode(map[string]any{"id": 999}) })) defer srv.Close() c := igw.NewClient(srv.URL, igw.WithRetry(3, time.Millisecond)) - id, err := c.SendPackage(context.Background(), "TEST3", "#M2MTR", []byte("x")) + id, err := c.SendPackage(context.Background(), "CH1", "#M2MTR", []byte("x")) if err != nil { t.Fatal(err) } - if id != "pkg-retry" { - t.Errorf("ожидалось pkg-retry, получено %q", id) + if id != "999" { + t.Errorf("id = %q, ожидалось 999", id) } if calls < 2 { t.Errorf("ожидалось хотя бы 2 попытки, получено %d", calls) @@ -67,7 +88,7 @@ func TestSendPackage4xxNoRetry(t *testing.T) { defer srv.Close() c := igw.NewClient(srv.URL, igw.WithRetry(3, time.Millisecond)) - _, err := c.SendPackage(context.Background(), "TEST3", "#M2MTR", []byte("x")) + _, err := c.SendPackage(context.Background(), "CH1", "#M2MTR", []byte("x")) if err == nil { t.Fatal("ожидалась ошибка на 400") } @@ -76,60 +97,112 @@ func TestSendPackage4xxNoRetry(t *testing.T) { } } +// TestGetStatus — формат ответа по разделу 2.5.2 инструкции: +// {id: 123, name: "#M2MTR...zip", status: SENT|NEW|ERROR, error: "..."}. func TestGetStatus(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/package/status/pkg-1" { + if r.URL.Path != "/api/package/status/123" { t.Errorf("неожиданный путь %q", r.URL.Path) } w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"package_id":"pkg-1","state":"delivered","updated_at":"2026-03-02T14:30:00Z"}`)) + _, _ = w.Write([]byte(`{"id":123,"name":"#M2MTR20260320140624.zip","status":"SENT"}`)) })) defer srv.Close() c := igw.NewClient(srv.URL, igw.WithRetry(0, time.Millisecond)) - st, err := c.GetStatus(context.Background(), "pkg-1") + st, err := c.GetStatus(context.Background(), "123") if err != nil { t.Fatal(err) } - if st.State != "delivered" { - t.Errorf("state = %q, ожидалось delivered", st.State) + if st.Status != "SENT" { + t.Errorf("status = %q, ожидалось SENT", st.Status) + } + if !strings.Contains(st.Name, "M2MTR") { + t.Errorf("name = %q, ожидалось содержать M2MTR", st.Name) } } +// TestListIncoming — формат ответа по разделу 2.6: массив пакетов с полями +// channel/id/name/type/state/files/signs. func TestListIncoming(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if !contains(r.URL.RawQuery, "channel=TEST3") { - t.Errorf("в query нет channel: %s", r.URL.RawQuery) + q := r.URL.Query() + if q.Get("channel") != "CH1" { + t.Errorf("channel = %q, ожидалось CH1", q.Get("channel")) + } + if q.Get("type") != "M2MTD" { + t.Errorf("type = %q, ожидалось M2MTD", q.Get("type")) } w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"items":[{"package_id":"p1","package_type":"#M2MTD","channel":"TEST3","received_at":"2026-03-02T14:00:00Z","body":""}]}`)) + _, _ = w.Write([]byte(`[{ + "channel":"CH1", + "id":22423, + "name":"#M2MTD20260320140624.ZIP", + "type":"M2MTD", + "state":"RECEIVED", + "files":[{"id":30112,"name":"M2MTD20260320140624.XML"}], + "signs":[{"serial":"40:50:14","subject":"INN=007702165310,CN=НРД","status":"VALID"}] + }]`)) })) defer srv.Close() c := igw.NewClient(srv.URL, igw.WithRetry(0, time.Millisecond)) - pkgs, err := c.ListIncoming(context.Background(), "TEST3", time.Now().Add(-time.Hour), "#M2MTD") + pkgs, err := c.ListIncoming(context.Background(), igw.ListFilter{ + Channel: "CH1", + Type: "M2MTD", + }) if err != nil { t.Fatal(err) } - if len(pkgs) != 1 || pkgs[0].PackageType != "#M2MTD" { - t.Errorf("неожиданный результат: %+v", pkgs) + if len(pkgs) != 1 { + t.Fatalf("ожидался 1 пакет, получено %d", len(pkgs)) } - body, err := pkgs[0].DecodeBody() - if err != nil { - t.Errorf("DecodeBody: %v", err) + p := pkgs[0] + if p.ID != 22423 { + t.Errorf("ID = %d, ожидалось 22423", p.ID) } - if body != nil { - t.Errorf("ожидалось пустое тело") + if p.State != "RECEIVED" { + t.Errorf("State = %q, ожидалось RECEIVED", p.State) + } + if len(p.Signs) != 1 || p.Signs[0].Status != "VALID" { + t.Errorf("Signs неверные: %+v", p.Signs) } } -func contains(s, substr string) bool { - return len(s) >= len(substr) && (indexOf(s, substr) >= 0) -} - -func indexOf(s, substr string) int { - for i := 0; i+len(substr) <= len(s); i++ { - if s[i:i+len(substr)] == substr { - return i +// TestGetPackage — скачивание содержимого. ИШ может возвращать либо чистый +// ZIP, либо JSON с base64-полем. Тестируем оба случая. +func TestGetPackageRawZIP(t *testing.T) { + zipBytes := []byte("PK\x03\x04zip-content-here") + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/package/22423" { + t.Errorf("неожиданный путь %q", r.URL.Path) } + w.Header().Set("Content-Type", "application/zip") + _, _ = w.Write(zipBytes) + })) + defer srv.Close() + c := igw.NewClient(srv.URL, igw.WithRetry(0, time.Millisecond)) + body, err := c.GetPackage(context.Background(), 22423) + if err != nil { + t.Fatal(err) + } + if string(body) != string(zipBytes) { + t.Errorf("body = %q, ожидалось %q", body, zipBytes) + } +} + +func TestGetPackageBase64InJSON(t *testing.T) { + zipBytes := []byte("PK\x03\x04zip-from-base64") + encoded := base64.StdEncoding.EncodeToString(zipBytes) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"file":"` + encoded + `"}`)) + })) + defer srv.Close() + c := igw.NewClient(srv.URL, igw.WithRetry(0, time.Millisecond)) + body, err := c.GetPackage(context.Background(), 1) + if err != nil { + t.Fatal(err) + } + if string(body) != string(zipBytes) { + t.Errorf("decoded = %q, ожидалось %q", body, zipBytes) } - return -1 } diff --git a/internal/nsdadapter/igw/pack.go b/internal/nsdadapter/igw/pack.go new file mode 100644 index 0000000..4873f66 --- /dev/null +++ b/internal/nsdadapter/igw/pack.go @@ -0,0 +1,173 @@ +// pack.go — упаковщик/распаковщик ZIP-пакетов для ИШ НРД. +// +// Формат отправляемого пакета (раздел 2.3 инструкции): +// ZIP-архив содержит: +// - .xml — сам документ (M2MTransferRequest.xml) +// - config.xml — настроечный файл с указанием name и package +// +// Пример config.xml: +// +// doc.xml +// #M2MTR +// +// +// Формат входящего пакета (раздел 2.4 + раздел 2.6): +// ZIP-архив содержит: +// - .xml — сам документ (M2MTransferDecision.xml или M2MTransferResponse.xml) +// - winf.xml — транзитный конверт ЭДО НРД +// - .xml.sgn — отсоединённая подпись НРД (опц.) +// +// ИШ сам формирует пакет ЭДО (подписывает, добавляет winf.xml и т.д.). +// Наша задача — собрать ZIP с XML+config.xml и отправить. + +package igw + +import ( + "archive/zip" + "bytes" + "encoding/xml" + "errors" + "fmt" + "io" + "path/filepath" + "strings" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m" + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml" +) + +// config — содержимое config.xml внутри пакета. +type config struct { + XMLName xml.Name `xml:"config"` + Name string `xml:"name"` + Package string `xml:"package"` +} + +// PackRequest упаковывает M2MTransferRequest в ZIP-архив для ИШ. +// docFileName — имя XML внутри архива (например "M2MTransferRequest.xml"). +// Возвращает байты ZIP, готовые к отправке через POST /api/package/{channel}/file. +func PackRequest(req *m2m.M2MTransferRequest, docFileName string) ([]byte, error) { + if req == nil { + return nil, errors.New("igw: PackRequest: req=nil") + } + if docFileName == "" { + docFileName = "M2MTransferRequest.xml" + } + xmlBytes, err := nsdxml.Marshal(req) + if err != nil { + return nil, fmt.Errorf("igw: marshal M2MTransferRequest: %w", err) + } + return packZIP(xmlBytes, docFileName, "#M2MTR") +} + +// PackXML упаковывает произвольный XML в ZIP с config.xml. Тип пакета +// (например "#M2MTR" / "#M2MTD") задаётся явно. Полезно когда XML уже +// собран снаружи (тесты, эталонные сообщения из инструкции). +func PackXML(xmlBytes []byte, docFileName, packageType string) ([]byte, error) { + if len(xmlBytes) == 0 { + return nil, errors.New("igw: PackXML: xmlBytes пустой") + } + if !strings.HasPrefix(packageType, "#") { + return nil, fmt.Errorf("igw: packageType должен начинаться с #, получено %q", packageType) + } + if docFileName == "" { + docFileName = "doc.xml" + } + return packZIP(xmlBytes, docFileName, packageType) +} + +func packZIP(xmlBytes []byte, docFileName, packageType string) ([]byte, error) { + var buf bytes.Buffer + w := zip.NewWriter(&buf) + + // 1. сам документ + fw, err := w.Create(docFileName) + if err != nil { + return nil, fmt.Errorf("igw: create %s in zip: %w", docFileName, err) + } + if _, err := fw.Write(xmlBytes); err != nil { + return nil, err + } + + // 2. config.xml + cfg := config{Name: docFileName, Package: packageType} + cfgBytes, err := xml.MarshalIndent(cfg, "", " ") + if err != nil { + return nil, fmt.Errorf("igw: marshal config.xml: %w", err) + } + cfgWriter, err := w.Create("config.xml") + if err != nil { + return nil, fmt.Errorf("igw: create config.xml in zip: %w", err) + } + if _, err := cfgWriter.Write(cfgBytes); err != nil { + return nil, err + } + + if err := w.Close(); err != nil { + return nil, fmt.Errorf("igw: close zip: %w", err) + } + return buf.Bytes(), nil +} + +// UnpackedPackage — содержимое распакованного ZIP'а с входящим пакетом. +type UnpackedPackage struct { + DocXML []byte // первый XML, который не winf.xml и не config.xml + WinfXML []byte // транзитный конверт ЭДО (опц., присутствует у входящих от НРД) + Signature []byte // .sgn файл (отсоединённая подпись), опц. + Filenames []string +} + +// UnpackPackage распаковывает ZIP-архив от ИШ и возвращает структурированно +// тело документа + winf.xml + отсоединённую подпись. +func UnpackPackage(zipBytes []byte) (*UnpackedPackage, error) { + r, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes))) + if err != nil { + return nil, fmt.Errorf("igw: zip reader: %w", err) + } + out := &UnpackedPackage{} + for _, f := range r.File { + out.Filenames = append(out.Filenames, f.Name) + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("igw: open %s in zip: %w", f.Name, err) + } + data, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return nil, fmt.Errorf("igw: read %s from zip: %w", f.Name, err) + } + low := strings.ToLower(filepath.Base(f.Name)) + switch { + case low == "winf.xml": + out.WinfXML = data + case low == "config.xml": + // config.xml в исходящих, во входящих обычно отсутствует — игнорируем + case strings.HasSuffix(low, ".sgn"): + out.Signature = data + case strings.HasSuffix(low, ".xml") && out.DocXML == nil: + out.DocXML = data + } + } + if out.DocXML == nil { + return nil, fmt.Errorf("igw: в ZIP нет основного .xml документа (файлы: %v)", out.Filenames) + } + return out, nil +} + +// ParseDecision разбирает DocXML входящего пакета M2MTD в m2m.M2MTransferDecision. +func ParseDecision(docXML []byte) (*m2m.M2MTransferDecision, error) { + var d m2m.M2MTransferDecision + if err := nsdxml.Unmarshal(docXML, &d); err != nil { + return nil, fmt.Errorf("igw: parse M2MTransferDecision: %w", err) + } + return &d, nil +} + +// ParseResponse разбирает DocXML входящего пакета M2MER в m2m.M2MTransferResponse. +func ParseResponse(docXML []byte) (*m2m.M2MTransferResponse, error) { + var r m2m.M2MTransferResponse + if err := nsdxml.Unmarshal(docXML, &r); err != nil { + return nil, fmt.Errorf("igw: parse M2MTransferResponse: %w", err) + } + return &r, nil +} diff --git a/internal/nsdadapter/igw/pack_test.go b/internal/nsdadapter/igw/pack_test.go new file mode 100644 index 0000000..a2acf6d --- /dev/null +++ b/internal/nsdadapter/igw/pack_test.go @@ -0,0 +1,114 @@ +package igw_test + +import ( + "archive/zip" + "bytes" + "io" + "strings" + "testing" + + "git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdadapter/igw" +) + +// TestPackXML_StructureMatchesSpec — после упаковки в ZIP должны быть +// ровно два файла: doc.xml + config.xml. Config содержит и +// . Это структура из раздела 2.3 инструкции НРД. +func TestPackXML_StructureMatchesSpec(t *testing.T) { + xmlBody := []byte(``) + zipBytes, err := igw.PackXML(xmlBody, "M2MTransferRequest.xml", "#M2MTR") + if err != nil { + t.Fatal(err) + } + r, err := zip.NewReader(bytes.NewReader(zipBytes), int64(len(zipBytes))) + if err != nil { + t.Fatal(err) + } + if len(r.File) != 2 { + t.Fatalf("в ZIP должно быть 2 файла, получено %d", len(r.File)) + } + got := map[string][]byte{} + for _, f := range r.File { + rc, _ := f.Open() + b, _ := io.ReadAll(rc) + rc.Close() + got[f.Name] = b + } + if _, ok := got["M2MTransferRequest.xml"]; !ok { + t.Errorf("в ZIP нет M2MTransferRequest.xml. Файлы: %v", keys(got)) + } + cfg, ok := got["config.xml"] + if !ok { + t.Fatalf("в ZIP нет config.xml. Файлы: %v", keys(got)) + } + cfgStr := string(cfg) + if !strings.Contains(cfgStr, "M2MTransferRequest.xml") { + t.Errorf("config.xml не содержит правильное : %s", cfgStr) + } + if !strings.Contains(cfgStr, "#M2MTR") { + t.Errorf("config.xml не содержит правильное : %s", cfgStr) + } +} + +func TestPackXML_RejectsBadPackageType(t *testing.T) { + _, err := igw.PackXML([]byte(""), "doc.xml", "M2MTR") + if err == nil { + t.Fatal("ожидалась ошибка для packageType без #") + } +} + +// TestUnpackPackage_FindsXMLAndWinf — распаковка эмулирует входящий ZIP +// от ИШ: M2MTD.xml + winf.xml + .sgn. Проверяем что UnpackPackage +// корректно раскладывает по полям. +func TestUnpackPackage_FindsXMLAndWinf(t *testing.T) { + docBody := []byte("") + winfBody := []byte("") + sgnBody := []byte("BINARY-SIGN-BLOB") + + var buf bytes.Buffer + w := zip.NewWriter(&buf) + must := func(name string, data []byte) { + fw, _ := w.Create(name) + _, _ = fw.Write(data) + } + must("M2MTD20260320140624.XML", docBody) + must("winf.xml", winfBody) + must("M2MTD20260320140624.XML.sgn", sgnBody) + _ = w.Close() + + pkg, err := igw.UnpackPackage(buf.Bytes()) + if err != nil { + t.Fatal(err) + } + if string(pkg.DocXML) != string(docBody) { + t.Errorf("DocXML mismatch") + } + if string(pkg.WinfXML) != string(winfBody) { + t.Errorf("WinfXML mismatch") + } + if string(pkg.Signature) != string(sgnBody) { + t.Errorf("Signature mismatch") + } + if len(pkg.Filenames) != 3 { + t.Errorf("ожидалось 3 файла в Filenames, получено %d", len(pkg.Filenames)) + } +} + +func TestUnpackPackage_EmptyXML(t *testing.T) { + var buf bytes.Buffer + w := zip.NewWriter(&buf) + fw, _ := w.Create("winf.xml") + _, _ = fw.Write([]byte("")) + _ = w.Close() + _, err := igw.UnpackPackage(buf.Bytes()) + if err == nil { + t.Fatal("ожидалась ошибка когда в ZIP нет основного .xml") + } +} + +func keys(m map[string][]byte) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + return out +}