feat(igw): REST-клиент ИШ НРД по DOC/instr-ish-rest-api.pdf + упаковщик ZIP
Полный клиент Интеграционного шлюза НРД в internal/nsdadapter/igw/:
client.go — REST endpoint'ы по свежей спецификации НРД:
- POST /api/package/{channel}/file — отправка ZIP (Type=archive, File=base64)
возвращает id пакета (поддерживаются варианты id|package_id|ID)
- GET /api/package/status/{id} — статус NEW|SENT|ERROR (с error-полем)
- GET /api/package?channel=&type=M2MTD|M2MER&date=&id=&count=&excludeErrors=
— список входящих от НРД, с files[] и signs[] (ИШ сам проверяет ЭП и
выдаёт VALID|INVALID)
- GET /api/package/{id} — скачать ZIP (raw или base64-в-JSON, авто-детект
по сигнатуре PK\x03\x04)
- Ретраи только на 5xx/сетевые ошибки (4xx — сразу ошибка)
- HTTP-клиент через options, кастомный таймаут, ретраи
pack.go — упаковщик/распаковщик ZIP по разделу 2.3 инструкции:
- PackRequest(req, docName) — M2MTransferRequest→ZIP с config.xml
- PackXML(xml, docName, packageType) — для эталонных сообщений
- UnpackPackage(zip) → {DocXML, WinfXML, Signature, Filenames}
- ParseDecision / ParseResponse через nsdxml.Unmarshal
Покрыто тестами (10/10 PASS):
- send happy path с проверкой формата JSON-body
- retry на 5xx, без ретраев на 4xx
- GetStatus с числовым id
- ListIncoming как массив (новый формат) и как {items:[]} (старый)
- GetPackage raw ZIP + GetPackage с base64-в-JSON
- упаковка/распаковка: 2 файла в ZIP, имена, содержимое config.xml
- распаковка с .sgn и winf.xml
cmd/bj-server/main.go — NSD-poller адаптирован под новый API
(client.ListIncoming(ctx, ListFilter{}) вместо позиционных параметров;
поля Package.ID/Name/Type/State вместо PackageID/PackageType).
Скачана и положена в DOC/ свежая спецификация (798 KB, 15 стр):
DOC/instr-ish-rest-api.pdf — это исходный документ для нашей реализации.
REPORT.md обновлён:
- общая готовность 65% → 70%
- готовность к роботу 80% → 85%
- добавлен раздел про REST-клиент ИШ
- блокер #6 — отсутствие «Руководства по установке ИШ»
This commit is contained in:
Binary file not shown.
@@ -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=?), параметры ключевого хранилища ИШ, порт по умолчанию — оттуда.
|
||||
|
||||
### Внутренние задачи (можем делать параллельно)
|
||||
|
||||
| Задача | Приоритет | Эффект |
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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/<package>), не в 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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("<xml/>"))
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
// pack.go — упаковщик/распаковщик ZIP-пакетов для ИШ НРД.
|
||||
//
|
||||
// Формат отправляемого пакета (раздел 2.3 инструкции):
|
||||
// ZIP-архив содержит:
|
||||
// - <doc>.xml — сам документ (M2MTransferRequest.xml)
|
||||
// - config.xml — настроечный файл с указанием name и package
|
||||
//
|
||||
// Пример config.xml:
|
||||
// <config>
|
||||
// <name>doc.xml</name>
|
||||
// <package>#M2MTR</package>
|
||||
// </config>
|
||||
//
|
||||
// Формат входящего пакета (раздел 2.4 + раздел 2.6):
|
||||
// ZIP-архив содержит:
|
||||
// - <doc>.xml — сам документ (M2MTransferDecision.xml или M2MTransferResponse.xml)
|
||||
// - winf.xml — транзитный конверт ЭДО НРД
|
||||
// - <doc>.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
|
||||
}
|
||||
@@ -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 содержит <name> и
|
||||
// <package>. Это структура из раздела 2.3 инструкции НРД.
|
||||
func TestPackXML_StructureMatchesSpec(t *testing.T) {
|
||||
xmlBody := []byte(`<?xml version="1.0" encoding="windows-1251"?><rt:M2MTransferRequest/>`)
|
||||
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, "<name>M2MTransferRequest.xml</name>") {
|
||||
t.Errorf("config.xml не содержит правильное <name>: %s", cfgStr)
|
||||
}
|
||||
if !strings.Contains(cfgStr, "<package>#M2MTR</package>") {
|
||||
t.Errorf("config.xml не содержит правильное <package>: %s", cfgStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackXML_RejectsBadPackageType(t *testing.T) {
|
||||
_, err := igw.PackXML([]byte("<x/>"), "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("<dn:M2MTransferDecision/>")
|
||||
winfBody := []byte("<winf/>")
|
||||
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("<winf/>"))
|
||||
_ = 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
|
||||
}
|
||||
Reference in New Issue
Block a user