Files
Bridge-and-Join-s/internal/m2m/messages_test.go
zuevav 9737c787f9 feat: живой цикл M2M с НРД + мастер установки ключа на флешку
Инфраструктура M2M (живой обмен с НРД через ИШ):
- обработка M2MTransferResponse: ERROR(M2Mxx) → заявка Отклонена, сохранение
  ответа; INFO → ждём Decision; идемпотентность поллера
- fallback-корреляция ответов с нулевым GUID (M2M14/M2M17) по FIFO
- сырой XML ответа НРД в карточке заявки (для пересылки в ТП)
- тестовый пакет роботу приведён к эталону m2m_robot_samples (CostInfo=Yes,
  4 бумаги, IsolationStatus, DocumentSeries=сценарий); override паспорта
- редирект из теста сразу в карточку заявки

Мастер установки ключа Валидаты на флешку (admin/setup/keywizard):
- пошаговый: загрузка .7z+пароль → выбор флешки → запись → справочник
  сертификатов (CRL) → перезапуск+проверка ИШ → готово
- привилегированный воркер (bj-keymedia) в host-namespace через файл-обмен,
  bj-server остаётся в песочнице
- сохранение структуры профиля архива (spr<N>), перечисление съёмных USB

Прочее:
- пакет-доказательство для ТП НРД + форма регистрации участника M2M
- эталонные образцы робота (DOC/m2m_robot_samples)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 00:03:21 +03:00

229 lines
9.1 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package m2m_test
import (
"encoding/xml"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/m2m"
"git.zetit.ru/zuevav/Bridge-and-Join-s/internal/nsdxml"
)
// validator — общий интерфейс для каскадной валидации в тестах.
type validator interface {
Validate() error
}
// roundTripCase описывает приёмочный кейс round-trip на эталонном файле.
type roundTripCase struct {
path string
mk func() any
}
// roundTripCases — соответствие "файл -> ожидаемый Go-тип сообщения".
var roundTripCases = []roundTripCase{
{filepath.Join("..", "..", "DOC", "Примеры", "M2MTransferRequest.xml"), func() any { return new(m2m.M2MTransferRequest) }},
{filepath.Join("..", "..", "DOC", "Примеры", "M2MTransferDecision.xml"), func() any { return new(m2m.M2MTransferDecision) }},
{filepath.Join("..", "..", "DOC", "Примеры", "M2MTransferResponse.xml"), func() any { return new(m2m.M2MTransferResponse) }},
{filepath.Join("..", "..", "DOC", "Примеры", "M2MTransferHandbook.xml"), func() any { return new(m2m.M2MTransferHandbook) }},
{filepath.Join("..", "..", "DOC", "Примеры", "M2MTransferHandbookRequest.xml"), func() any { return new(m2m.M2MTransferHandbookRequest) }},
{filepath.Join("..", "..", "DOC", "Примеры", "M2MTransferParticipantForm.xml"), func() any { return new(m2m.M2MTransferParticipantForm) }},
{filepath.Join("..", "..", "DOC", "Эталонные сообщения", "M2MTransferRequest_эталон.xml"), func() any { return new(m2m.M2MTransferRequest) }},
{filepath.Join("..", "..", "DOC", "Эталонные сообщения", "M2MTransferDecision_эталон.xml"), func() any { return new(m2m.M2MTransferDecision) }},
}
// clearXMLNameSpace обнуляет поле Space у верхнеуровневого xml.Name (если
// есть), чтобы round-trip-сравнение не зависело от namespace корня входного
// документа.
func clearXMLNameSpace(v any) {
rv := reflect.ValueOf(v)
if rv.Kind() != reflect.Ptr || rv.IsNil() {
return
}
rv = rv.Elem()
if rv.Kind() != reflect.Struct {
return
}
f := rv.FieldByName("XMLName")
if f.IsValid() && f.CanSet() && f.Type() == reflect.TypeOf(xml.Name{}) {
f.Set(reflect.ValueOf(xml.Name{Local: f.Interface().(xml.Name).Local}))
}
}
func TestRoundTrip(t *testing.T) {
for _, c := range roundTripCases {
c := c
t.Run(filepath.Base(c.path), func(t *testing.T) {
b1, err := os.ReadFile(c.path)
if err != nil {
t.Fatalf("read %s: %v", c.path, err)
}
s1 := c.mk()
if err := nsdxml.Unmarshal(b1, s1); err != nil {
t.Fatalf("unmarshal оригинала: %v", err)
}
if v, ok := s1.(validator); ok {
if err := v.Validate(); err != nil {
t.Fatalf("Validate после первого Unmarshal: %v", err)
}
}
b2, err := nsdxml.Marshal(s1)
if err != nil {
t.Fatalf("marshal: %v", err)
}
// Проверяем, что пролог windows-1251 проставлен.
if !strings.HasPrefix(string(nsdxml.DecodeWindows1251(b2)), `<?xml version="1.0" encoding="windows-1251"?>`) {
t.Fatalf("в выходе нет windows-1251 пролога")
}
s2 := c.mk()
if err := nsdxml.Unmarshal(b2, s2); err != nil {
t.Fatalf("unmarshal после Marshal: %v", err)
}
// XMLName.Space — это метаданные пространства имён входного XML,
// а не полезная нагрузка. Часть документов НРД присылает с
// namespace на корне (официальные примеры), часть — без (реальный
// ответ робота МОСТ). Парсер намеренно namespace-agnostic, поэтому
// после re-marshal корневой namespace может отличаться. Для
// приёмочных (receive-only) документов это несущественно — сравниваем
// без учёта XMLName.Space.
clearXMLNameSpace(s1)
clearXMLNameSpace(s2)
if !reflect.DeepEqual(s1, s2) {
t.Errorf("round-trip структуры разошлись:\nS1 = %+v\nS2 = %+v", s1, s2)
}
})
}
}
func TestValidatorsPositive(t *testing.T) {
cases := []struct {
name string
v validator
}{
{"ReferenceId", m2m.ReferenceID("M2M2026030200001")},
{"ISIN", m2m.ISIN("RU0007661625")},
{"OrganizationINN", m2m.OrganizationINN("7702070139")},
{"DeponentCode", m2m.DeponentCode("MC0079200000")},
{"UUID", m2m.UUID("c02a1d5e-c2af-4799-bab4-953f133c5133")},
{"SecurityCode", m2m.SecurityCode("MM0766162534")},
{"IdentityDocSerial", m2m.IdentityDocSerial("4512")},
{"AccountId", m2m.AccountID("31MC0021900000F01")},
{"StatusCode", m2m.StatusInfo},
{"IIAContractType", m2m.IIAContractT03},
{"SecurityClassification", m2m.SecurityBond},
{"SecurityCategory", m2m.CategoryOrdn},
{"IdentityDocumentCode", m2m.DocCode21},
{"IsolationStatus", m2m.IsolationSGDN},
{"Decimal16", m2m.Decimal16("2500.75")},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if err := c.v.Validate(); err != nil {
t.Errorf("ожидалось без ошибок, получено: %v", err)
}
})
}
}
func TestValidatorsNegative(t *testing.T) {
cases := []struct {
name string
v validator
}{
{"ReferenceId короткий", m2m.ReferenceID("M2M123")},
{"ReferenceId без префикса", m2m.ReferenceID("XYZ2026030200001")},
{"ISIN короткий", m2m.ISIN("RU0007")},
{"ISIN с lowercase", m2m.ISIN("ru0007661625")},
{"INN короткий", m2m.OrganizationINN("123")},
{"INN с буквами", m2m.OrganizationINN("770207013A")},
{"DeponentCode пустой", m2m.DeponentCode("")},
{"DeponentCode lowercase", m2m.DeponentCode("mc007920")},
{"DeponentCode слишком длинный", m2m.DeponentCode("AAAAAAAAAAAAA")},
{"UUID без дефисов", m2m.UUID("c02a1d5ec2af4799bab4953f133c5133aaaa")},
{"UUID с не-hex символом", m2m.UUID("c02a1d5e-c2af-4799-babZ-953f133c5133")},
{"UUID короткий", m2m.UUID("c02a1d5e-c2af-4799-bab4-953f133c513")},
{"SecurityCode короткий", m2m.SecurityCode("ABC")},
{"SecurityCode с lowercase", m2m.SecurityCode("mm0766162534")},
{"IdentityDocSerial пустой", m2m.IdentityDocSerial("")},
{"IdentityDocSerial с пробелом", m2m.IdentityDocSerial("45 12")},
{"AccountId пустой", m2m.AccountID("")},
{"AccountId длиннее 50", m2m.AccountID(strings.Repeat("A", 51))},
{"StatusCode unknown", m2m.StatusCode("OK")},
{"IIAContractType T01", m2m.IIAContractType("T01")},
{"SecurityClassification unknown", m2m.SecurityClassification("STCK")},
{"SecurityCategory unknown", m2m.SecurityCategory("XXXX")},
{"IdentityDocumentCode 99", m2m.IdentityDocumentCode("99")},
{"IsolationStatus FOO", m2m.IsolationStatus("FOO")},
{"Decimal16 пустой", m2m.Decimal16("")},
{"Decimal16 с буквами", m2m.Decimal16("12a.5")},
{"Decimal16 слишком много дробных", m2m.Decimal16("1.12345678901234567")},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
err := c.v.Validate()
if err == nil {
t.Errorf("ожидалась ошибка")
return
}
if !errors.Is(err, m2m.ErrInvalid) {
t.Errorf("ожидалась ErrInvalid, получено: %v", err)
}
})
}
}
func TestChoiceValidators(t *testing.T) {
t.Run("CostInfo пустой", func(t *testing.T) {
if err := (m2m.CostInfo{}).Validate(); !errors.Is(err, m2m.ErrChoice) {
t.Errorf("ожидалась ErrChoice, получено: %v", err)
}
})
t.Run("CostInfo оба поля", func(t *testing.T) {
c := m2m.CostInfo{
Yes: &m2m.CostInfoYes{Code: "MC0010300032"},
No: &m2m.CostInfoNo{},
}
if err := c.Validate(); !errors.Is(err, m2m.ErrChoice) {
t.Errorf("ожидалась ErrChoice, получено: %v", err)
}
})
t.Run("Quantity пустой", func(t *testing.T) {
if err := (m2m.Quantity{}).Validate(); !errors.Is(err, m2m.ErrChoice) {
t.Errorf("ожидалась ErrChoice, получено: %v", err)
}
})
t.Run("Quantity оба поля", func(t *testing.T) {
whole := uint64(100)
frac := m2m.Decimal16("1.5")
q := m2m.Quantity{Whole: &whole, Fractional: &frac}
if err := q.Validate(); !errors.Is(err, m2m.ErrChoice) {
t.Errorf("ожидалась ErrChoice, получено: %v", err)
}
})
t.Run("SecurityDetails пустой", func(t *testing.T) {
if err := (m2m.SecurityDetails{}).Validate(); !errors.Is(err, m2m.ErrChoice) {
t.Errorf("ожидалась ErrChoice, получено: %v", err)
}
})
t.Run("IdentificationDetails пустой", func(t *testing.T) {
if err := (m2m.IdentificationDetails{}).Validate(); !errors.Is(err, m2m.ErrChoice) {
t.Errorf("ожидалась ErrChoice, получено: %v", err)
}
})
t.Run("DecisionTransfer пустой", func(t *testing.T) {
if err := (m2m.DecisionTransfer{}).Validate(); !errors.Is(err, m2m.ErrChoice) {
t.Errorf("ожидалась ErrChoice, получено: %v", err)
}
})
}