Add state-corp exchange package sender
All checks were successful
All checks were successful
This commit is contained in:
1
src/apps/exchange/management/__init__.py
Normal file
1
src/apps/exchange/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Management package for exchange app."""
|
||||
1
src/apps/exchange/management/commands/__init__.py
Normal file
1
src/apps/exchange/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Exchange management commands."""
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Export encrypted package for manual delivery to state-corp."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from apps.core.management.commands.base import BaseAppCommand
|
||||
from apps.exchange.state_corp_services import StateCorpExchangeService
|
||||
|
||||
|
||||
class Command(BaseAppCommand):
|
||||
"""Build package and save it to disk."""
|
||||
|
||||
help = "Экспортирует зашифрованный пакет обмена для state-corp в .zip файл"
|
||||
|
||||
def add_arguments(self, parser) -> None:
|
||||
super().add_arguments(parser)
|
||||
parser.add_argument("output_path", type=str, help="Куда сохранить .zip пакет")
|
||||
parser.add_argument(
|
||||
"--package-id", type=str, default="", help="Явный package_id"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--organization-inn",
|
||||
action="append",
|
||||
dest="organization_inns",
|
||||
default=[],
|
||||
help="Ограничить экспорт конкретными ИНН. Параметр можно повторять.",
|
||||
)
|
||||
|
||||
def execute_command(self, *args, **options) -> str:
|
||||
package = StateCorpExchangeService.build_package(
|
||||
package_id=options["package_id"] or None,
|
||||
organization_inns=options["organization_inns"],
|
||||
)
|
||||
output_path = StateCorpExchangeService.save_package(
|
||||
package=package,
|
||||
output_path=options["output_path"],
|
||||
)
|
||||
rendered = json.dumps(
|
||||
{
|
||||
"package_id": package.package_id,
|
||||
"archive_name": package.archive_name,
|
||||
"output_path": str(output_path),
|
||||
"payload_counts": package.payload_counts,
|
||||
"produced_at": package.produced_at,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
self.log_success(rendered)
|
||||
return rendered
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Push encrypted package to state-corp exchange endpoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from apps.core.management.commands.base import BaseAppCommand
|
||||
from apps.exchange.state_corp_services import StateCorpExchangeService
|
||||
|
||||
|
||||
class Command(BaseAppCommand):
|
||||
"""Build and send package to state-corp."""
|
||||
|
||||
help = "Собирает и отправляет пакет обмена в state-corp dev endpoint"
|
||||
|
||||
def add_arguments(self, parser) -> None:
|
||||
super().add_arguments(parser)
|
||||
parser.add_argument(
|
||||
"--package-id", type=str, default="", help="Явный package_id"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--target-url",
|
||||
type=str,
|
||||
default="",
|
||||
help="Override для STATE_CORP_EXCHANGE_URL",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--token",
|
||||
type=str,
|
||||
default="",
|
||||
help="Override для STATE_CORP_EXCHANGE_TOKEN",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--save-package",
|
||||
type=str,
|
||||
default="",
|
||||
help="Дополнительно сохранить сформированный пакет в файл",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--organization-inn",
|
||||
action="append",
|
||||
dest="organization_inns",
|
||||
default=[],
|
||||
help="Ограничить экспорт конкретными ИНН. Параметр можно повторять.",
|
||||
)
|
||||
|
||||
def execute_command(self, *args, **options) -> str:
|
||||
package = StateCorpExchangeService.build_package(
|
||||
package_id=options["package_id"] or None,
|
||||
organization_inns=options["organization_inns"],
|
||||
)
|
||||
saved_path = None
|
||||
if options["save_package"]:
|
||||
saved_path = StateCorpExchangeService.save_package(
|
||||
package=package,
|
||||
output_path=options["save_package"],
|
||||
)
|
||||
result = StateCorpExchangeService.send_package(
|
||||
package=package,
|
||||
target_url=options["target_url"] or None,
|
||||
token=options["token"] or None,
|
||||
)
|
||||
rendered = json.dumps(
|
||||
{
|
||||
**result,
|
||||
"payload_counts": package.payload_counts,
|
||||
"saved_path": str(saved_path) if saved_path else None,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
)
|
||||
self.log_success(rendered)
|
||||
return rendered
|
||||
388
src/apps/exchange/state_corp_services.py
Normal file
388
src/apps/exchange/state_corp_services.py
Normal file
@@ -0,0 +1,388 @@
|
||||
"""Build and deliver exchange packages for state-corp-backend."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import secrets
|
||||
import struct
|
||||
import zlib
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
from zipfile import ZIP_DEFLATED, ZipFile
|
||||
|
||||
import requests
|
||||
from apps.parsers.models import (
|
||||
IndustrialProductRecord,
|
||||
InspectionRecord,
|
||||
ProcurementRecord,
|
||||
)
|
||||
from apps.registers.models import Organization
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
class StateCorpExchangeError(ValueError):
|
||||
"""Raised when building or sending state-corp exchange packages fails."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StateCorpExchangePackage:
|
||||
"""Built archive ready for upload or manual delivery."""
|
||||
|
||||
package_id: str
|
||||
archive_name: str
|
||||
bin_name: str
|
||||
archive_bytes: bytes
|
||||
payload_counts: dict[str, int]
|
||||
produced_at: str
|
||||
|
||||
|
||||
class StateCorpExchangeService:
|
||||
"""Create and send packages compatible with state-corp exchange import."""
|
||||
|
||||
MAGIC = b"EXCH"
|
||||
AAD = b"state-corp-exchange-v1"
|
||||
PAYLOAD_FORMAT = "state-corp-exchange-payload"
|
||||
BIN_FORMAT = "state-corp-exchange-bin"
|
||||
|
||||
@classmethod
|
||||
def build_package(
|
||||
cls,
|
||||
*,
|
||||
package_id: str | None = None,
|
||||
source_system: str = "mostovik",
|
||||
organization_inns: list[str] | None = None,
|
||||
) -> StateCorpExchangePackage:
|
||||
"""Build encrypted archive for state-corp import."""
|
||||
produced_at = timezone.now()
|
||||
normalized_inns = cls._normalize_inn_list(organization_inns)
|
||||
organizations = cls._get_organizations(normalized_inns)
|
||||
allowed_inns = {str(item.mn_inn) for item in organizations}
|
||||
data = {
|
||||
"organizations": cls._serialize_organizations(organizations),
|
||||
"industrial_products": cls._serialize_industrial_products(allowed_inns),
|
||||
"prosecutor_checks": cls._serialize_prosecutor_checks(allowed_inns),
|
||||
"public_procurements": cls._serialize_public_procurements(allowed_inns),
|
||||
"arbitration_cases": [],
|
||||
}
|
||||
payload_counts = {key: len(value) for key, value in data.items()}
|
||||
package_id = package_id or cls._build_package_id()
|
||||
payload = {
|
||||
"format": cls.PAYLOAD_FORMAT,
|
||||
"schema_version": 1,
|
||||
"manifest": {
|
||||
"package_id": package_id,
|
||||
"source_system": source_system,
|
||||
"produced_at": produced_at.isoformat(),
|
||||
"sections": [key for key, items in data.items() if items],
|
||||
},
|
||||
"data": data,
|
||||
}
|
||||
|
||||
archive_name, bin_name, archive_bytes = cls._render_archive(
|
||||
package_id=package_id,
|
||||
payload=payload,
|
||||
produced_at=produced_at,
|
||||
)
|
||||
return StateCorpExchangePackage(
|
||||
package_id=package_id,
|
||||
archive_name=archive_name,
|
||||
bin_name=bin_name,
|
||||
archive_bytes=archive_bytes,
|
||||
payload_counts=payload_counts,
|
||||
produced_at=produced_at.isoformat(),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def save_package(
|
||||
cls,
|
||||
*,
|
||||
package: StateCorpExchangePackage,
|
||||
output_path: str | Path,
|
||||
) -> Path:
|
||||
"""Persist package to disk for manual transfer."""
|
||||
target_path = Path(output_path).expanduser().resolve()
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
target_path.write_bytes(package.archive_bytes)
|
||||
return target_path
|
||||
|
||||
@classmethod
|
||||
def send_package(
|
||||
cls,
|
||||
*,
|
||||
package: StateCorpExchangePackage,
|
||||
target_url: str | None = None,
|
||||
token: str | None = None,
|
||||
timeout_seconds: int | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Send package to state-corp dev endpoint."""
|
||||
resolved_url = str(target_url or settings.STATE_CORP_EXCHANGE_URL).strip()
|
||||
resolved_token = str(token or settings.STATE_CORP_EXCHANGE_TOKEN).strip()
|
||||
timeout_seconds = timeout_seconds or int(
|
||||
getattr(settings, "STATE_CORP_EXCHANGE_TIMEOUT_SECONDS", 60)
|
||||
)
|
||||
|
||||
if not resolved_url:
|
||||
raise StateCorpExchangeError("STATE_CORP_EXCHANGE_URL не настроен")
|
||||
if not resolved_token:
|
||||
raise StateCorpExchangeError("STATE_CORP_EXCHANGE_TOKEN не настроен")
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
resolved_url,
|
||||
files={
|
||||
"file": (
|
||||
package.archive_name,
|
||||
package.archive_bytes,
|
||||
"application/zip",
|
||||
)
|
||||
},
|
||||
headers={"X-Exchange-Token": resolved_token},
|
||||
timeout=timeout_seconds,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise StateCorpExchangeError(
|
||||
f"Не удалось отправить пакет в state-corp: {exc}"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
body = response.json()
|
||||
except ValueError:
|
||||
body = {"raw": response.text}
|
||||
|
||||
if response.status_code >= 400:
|
||||
raise StateCorpExchangeError(
|
||||
f"state-corp вернул HTTP {response.status_code}: {body}"
|
||||
)
|
||||
|
||||
return {
|
||||
"status_code": response.status_code,
|
||||
"response": body,
|
||||
"package_id": package.package_id,
|
||||
"archive_name": package.archive_name,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _build_package_id(cls) -> str:
|
||||
return f"mostovik-{timezone.now():%Y%m%d%H%M%S}-{uuid4().hex[:8]}"
|
||||
|
||||
@classmethod
|
||||
def _render_archive(
|
||||
cls,
|
||||
*,
|
||||
package_id: str,
|
||||
payload: dict[str, Any],
|
||||
produced_at,
|
||||
) -> tuple[str, str, bytes]:
|
||||
token = str(settings.STATE_CORP_EXCHANGE_TOKEN or "").strip()
|
||||
if not token:
|
||||
raise StateCorpExchangeError("STATE_CORP_EXCHANGE_TOKEN не настроен")
|
||||
|
||||
payload_bytes = json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
compressed_payload = zlib.compress(payload_bytes, level=9)
|
||||
nonce = secrets.token_bytes(12)
|
||||
encrypted_payload = AESGCM(
|
||||
hashlib.sha256(token.encode("utf-8")).digest()
|
||||
).encrypt(
|
||||
nonce,
|
||||
compressed_payload,
|
||||
cls.AAD,
|
||||
)
|
||||
header = {
|
||||
"format": cls.BIN_FORMAT,
|
||||
"version": 1,
|
||||
"key_id": settings.STATE_CORP_EXCHANGE_KEY_ID,
|
||||
"nonce": cls._b64url(nonce),
|
||||
"aad": cls._b64url(cls.AAD),
|
||||
"package_id": package_id,
|
||||
"schema_version": 1,
|
||||
"plaintext_sha256": hashlib.sha256(payload_bytes).hexdigest(),
|
||||
"compressed_sha256": hashlib.sha256(compressed_payload).hexdigest(),
|
||||
"ciphertext_sha256": hashlib.sha256(encrypted_payload).hexdigest(),
|
||||
}
|
||||
header_bytes = json.dumps(
|
||||
header,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
bin_bytes = (
|
||||
cls.MAGIC
|
||||
+ bytes([1])
|
||||
+ struct.pack(">I", len(header_bytes))
|
||||
+ header_bytes
|
||||
+ encrypted_payload
|
||||
)
|
||||
|
||||
timestamp = produced_at.strftime("%Y%m%d_%H%M%S")
|
||||
archive_name = f"state_corp_exchange_{timestamp}.zip"
|
||||
bin_name = f"state_corp_exchange_{timestamp}.bin"
|
||||
|
||||
archive_bytes = BytesIO()
|
||||
with ZipFile(archive_bytes, "w", compression=ZIP_DEFLATED) as archive:
|
||||
archive.writestr(bin_name, bin_bytes)
|
||||
archive.writestr(
|
||||
f"{bin_name}.sha256",
|
||||
f"{hashlib.sha256(bin_bytes).hexdigest()} {bin_name}\n",
|
||||
)
|
||||
|
||||
return archive_name, bin_name, archive_bytes.getvalue()
|
||||
|
||||
@classmethod
|
||||
def _get_organizations(cls, organization_inns: list[str]) -> list[Organization]:
|
||||
queryset = Organization.objects.all().order_by("id")
|
||||
if organization_inns:
|
||||
queryset = queryset.filter(
|
||||
mn_inn__in=[int(item) for item in organization_inns]
|
||||
)
|
||||
return list(queryset)
|
||||
|
||||
@classmethod
|
||||
def _serialize_organizations(
|
||||
cls,
|
||||
organizations: list[Organization],
|
||||
) -> list[dict[str, str]]:
|
||||
return [
|
||||
{
|
||||
"inn": str(item.mn_inn),
|
||||
"name": item.pn_name,
|
||||
"ogrn": str(item.mn_ogrn),
|
||||
"kpp": str(item.in_kpp or ""),
|
||||
"okpo": item.mn_okpo,
|
||||
}
|
||||
for item in organizations
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _serialize_industrial_products(
|
||||
cls,
|
||||
allowed_inns: set[str],
|
||||
) -> list[dict[str, str]]:
|
||||
queryset = IndustrialProductRecord.objects.filter(
|
||||
inn__in=allowed_inns
|
||||
).order_by("id")
|
||||
return [
|
||||
{
|
||||
"organization_inn": cls._digits(record.inn),
|
||||
"product_name": record.product_name,
|
||||
"product_class": record.product_model
|
||||
or record.regulatory_document
|
||||
or "Промышленная продукция",
|
||||
"okpd2_code": record.okpd2_code,
|
||||
"tnved_code": record.tnved_code,
|
||||
"registry_number": record.registry_number,
|
||||
}
|
||||
for record in queryset
|
||||
]
|
||||
|
||||
@classmethod
|
||||
def _serialize_prosecutor_checks(
|
||||
cls,
|
||||
allowed_inns: set[str],
|
||||
) -> list[dict[str, str]]:
|
||||
items: list[dict[str, str]] = []
|
||||
queryset = InspectionRecord.objects.filter(inn__in=allowed_inns).order_by("id")
|
||||
for record in queryset:
|
||||
start_date = cls._coerce_date(
|
||||
record.start_date_normalized, record.start_date
|
||||
)
|
||||
if start_date is None:
|
||||
continue
|
||||
items.append(
|
||||
{
|
||||
"organization_inn": cls._digits(record.inn),
|
||||
"registration_number": record.registration_number,
|
||||
"law_type": record.legal_basis
|
||||
or ("248-ФЗ" if record.is_federal_law_248 else "294-ФЗ"),
|
||||
"control_authority": record.control_authority,
|
||||
"prosecutor_office": "",
|
||||
"start_date": start_date.isoformat(),
|
||||
"status": record.status,
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
@classmethod
|
||||
def _serialize_public_procurements(
|
||||
cls,
|
||||
allowed_inns: set[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
items: list[dict[str, Any]] = []
|
||||
queryset = ProcurementRecord.objects.filter(
|
||||
customer_inn__in=allowed_inns
|
||||
).order_by("id")
|
||||
for record in queryset:
|
||||
contract_date = cls._coerce_date(
|
||||
record.publish_date_normalized,
|
||||
record.publish_date,
|
||||
)
|
||||
if contract_date is None:
|
||||
continue
|
||||
execution_end_date = cls._coerce_date(
|
||||
record.end_date_normalized,
|
||||
record.end_date,
|
||||
)
|
||||
items.append(
|
||||
{
|
||||
"organization_inn": cls._digits(record.customer_inn),
|
||||
"purchase_number": record.purchase_number,
|
||||
"law_type": record.law_type,
|
||||
"status": record.status,
|
||||
"contract_amount": cls._serialize_decimal(record.max_price_amount),
|
||||
"contract_date": contract_date.isoformat(),
|
||||
"execution_start_date": contract_date.isoformat(),
|
||||
"execution_end_date": (
|
||||
execution_end_date.isoformat() if execution_end_date else None
|
||||
),
|
||||
"purchase_name": record.purchase_name,
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
@staticmethod
|
||||
def _normalize_inn_list(organization_inns: list[str] | None) -> list[str]:
|
||||
normalized_items: list[str] = []
|
||||
for item in organization_inns or []:
|
||||
normalized = "".join(char for char in str(item).strip() if char.isdigit())
|
||||
if normalized:
|
||||
normalized_items.append(normalized)
|
||||
return normalized_items
|
||||
|
||||
@staticmethod
|
||||
def _digits(value: str | int) -> str:
|
||||
return "".join(char for char in str(value).strip() if char.isdigit())
|
||||
|
||||
@staticmethod
|
||||
def _b64url(data: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(data).decode("ascii").rstrip("=")
|
||||
|
||||
@classmethod
|
||||
def _coerce_date(
|
||||
cls, normalized: date | None, raw_value: str | None
|
||||
) -> date | None:
|
||||
if normalized is not None:
|
||||
return normalized
|
||||
raw_text = str(raw_value or "").strip()
|
||||
if not raw_text:
|
||||
return None
|
||||
try:
|
||||
return date.fromisoformat(raw_text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _serialize_decimal(value: Decimal | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return format(value, "f")
|
||||
@@ -217,6 +217,14 @@ PROXY_TOOLS_SYNC_INTERVAL_SECONDS = int(
|
||||
)
|
||||
BACKUP_ENCRYPTION_KEY = os.getenv("BACKUP_ENCRYPTION_KEY", "")
|
||||
BACKUP_KEY_ID = os.getenv("BACKUP_KEY_ID", "default")
|
||||
STATE_CORP_EXCHANGE_URL = os.getenv("STATE_CORP_EXCHANGE_URL", "").strip()
|
||||
STATE_CORP_EXCHANGE_TOKEN = os.getenv("STATE_CORP_EXCHANGE_TOKEN", "").strip()
|
||||
STATE_CORP_EXCHANGE_KEY_ID = os.getenv(
|
||||
"STATE_CORP_EXCHANGE_KEY_ID", "state-corp-shared-token"
|
||||
).strip()
|
||||
STATE_CORP_EXCHANGE_TIMEOUT_SECONDS = int(
|
||||
os.getenv("STATE_CORP_EXCHANGE_TIMEOUT_SECONDS", "60")
|
||||
)
|
||||
BACKUP_EXPORT_DIRECTORY = os.getenv(
|
||||
"BACKUP_EXPORT_DIRECTORY",
|
||||
str(PROJECT_ROOT / "media" / "backups"),
|
||||
|
||||
@@ -10,6 +10,14 @@ SECRET_KEY = "django-insecure-development-key-mostovik-2024"
|
||||
DEBUG = True
|
||||
ALLOWED_HOSTS = ["*"]
|
||||
OPENAPI_USE_ENGLISH_TAGS = True
|
||||
STATE_CORP_EXCHANGE_URL = os.getenv(
|
||||
"STATE_CORP_EXCHANGE_URL",
|
||||
"http://127.0.0.1:8001/api/v1/exchange/packages/upload/",
|
||||
)
|
||||
STATE_CORP_EXCHANGE_TOKEN = os.getenv(
|
||||
"STATE_CORP_EXCHANGE_TOKEN",
|
||||
"state-corp-dev-exchange-token-v1",
|
||||
)
|
||||
|
||||
# JWT
|
||||
SIMPLE_JWT["SIGNING_KEY"] = SECRET_KEY
|
||||
|
||||
@@ -7,6 +7,8 @@ from .base import *
|
||||
SECRET_KEY = "django-insecure-test-key-only-for-testing"
|
||||
DEBUG = True
|
||||
STARTUP_CHECKS_ENABLED = False
|
||||
STATE_CORP_EXCHANGE_URL = "http://state-corp.test/api/v1/exchange/packages/upload/"
|
||||
STATE_CORP_EXCHANGE_TOKEN = "state-corp-test-exchange-token"
|
||||
|
||||
# JWT
|
||||
SIMPLE_JWT["SIGNING_KEY"] = SECRET_KEY
|
||||
|
||||
146
tests/apps/exchange/test_state_corp_services.py
Normal file
146
tests/apps/exchange/test_state_corp_services.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""Tests for state-corp package build and delivery from mostovik."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import struct
|
||||
import zlib
|
||||
from io import BytesIO
|
||||
from unittest.mock import Mock, patch
|
||||
from zipfile import ZipFile
|
||||
|
||||
from apps.exchange.state_corp_services import StateCorpExchangeService
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from django.test import TestCase, override_settings
|
||||
|
||||
from tests.apps.parsers.factories import (
|
||||
IndustrialProductRecordFactory,
|
||||
InspectionRecordFactory,
|
||||
ProcurementRecordFactory,
|
||||
)
|
||||
from tests.apps.registers.factories import OrganizationFactory
|
||||
|
||||
|
||||
def _b64url_decode(value: str) -> bytes:
|
||||
normalized = value + ("=" * (-len(value) % 4))
|
||||
return base64.urlsafe_b64decode(normalized.encode("ascii"))
|
||||
|
||||
|
||||
TEST_STATE_CORP_TOKEN = "state-corp-test-exchange-token" # noqa: S105
|
||||
|
||||
|
||||
@override_settings(
|
||||
STATE_CORP_EXCHANGE_TOKEN=TEST_STATE_CORP_TOKEN,
|
||||
STATE_CORP_EXCHANGE_URL="http://state-corp.test/api/v1/exchange/packages/upload/",
|
||||
)
|
||||
class StateCorpExchangeServiceTest(TestCase):
|
||||
"""Verify package compatibility with state-corp receiver contract."""
|
||||
|
||||
def test_build_package_contains_expected_payload(self):
|
||||
organization = OrganizationFactory.create(
|
||||
mn_inn=7707083893,
|
||||
mn_ogrn=1027700132195,
|
||||
pn_name="АО Альфа",
|
||||
in_kpp=770701001,
|
||||
mn_okpo="12345678",
|
||||
)
|
||||
IndustrialProductRecordFactory.create(
|
||||
inn=str(organization.mn_inn),
|
||||
ogrn=str(organization.mn_ogrn),
|
||||
full_organisation_name=organization.pn_name,
|
||||
registry_number="prod-001",
|
||||
product_name="Система связи М-1",
|
||||
product_model="Связь",
|
||||
registry_organization=organization,
|
||||
)
|
||||
InspectionRecordFactory.create(
|
||||
inn=str(organization.mn_inn),
|
||||
ogrn=str(organization.mn_ogrn),
|
||||
organisation_name=organization.pn_name,
|
||||
registration_number="insp-001",
|
||||
control_authority="Минпромторг",
|
||||
legal_basis="294-ФЗ",
|
||||
start_date="2026-03-10",
|
||||
start_date_normalized="2026-03-10",
|
||||
status="active",
|
||||
registry_organization=organization,
|
||||
)
|
||||
ProcurementRecordFactory.create(
|
||||
customer_inn=str(organization.mn_inn),
|
||||
customer_ogrn=str(organization.mn_ogrn),
|
||||
customer_name=organization.pn_name,
|
||||
purchase_number="purchase-001",
|
||||
purchase_name="Поставка оборудования",
|
||||
law_type="223-ФЗ",
|
||||
status="executing",
|
||||
publish_date="2026-02-15",
|
||||
publish_date_normalized="2026-02-15",
|
||||
end_date="2026-11-30",
|
||||
end_date_normalized="2026-11-30",
|
||||
max_price_amount="4500000.75",
|
||||
registry_organization=organization,
|
||||
)
|
||||
|
||||
package = StateCorpExchangeService.build_package()
|
||||
self.assertEqual(package.payload_counts["organizations"], 1)
|
||||
self.assertEqual(package.payload_counts["industrial_products"], 1)
|
||||
self.assertEqual(package.payload_counts["prosecutor_checks"], 1)
|
||||
self.assertEqual(package.payload_counts["public_procurements"], 1)
|
||||
|
||||
with ZipFile(BytesIO(package.archive_bytes)) as archive:
|
||||
bin_names = [name for name in archive.namelist() if name.endswith(".bin")]
|
||||
self.assertEqual(len(bin_names), 1)
|
||||
bin_bytes = archive.read(bin_names[0])
|
||||
|
||||
header_size = struct.unpack(">I", bin_bytes[5:9])[0]
|
||||
header_end = 9 + header_size
|
||||
header = json.loads(bin_bytes[9:header_end].decode("utf-8"))
|
||||
encrypted_payload = bin_bytes[header_end:]
|
||||
raw_key = hashlib.sha256(TEST_STATE_CORP_TOKEN.encode("utf-8")).digest()
|
||||
compressed_payload = AESGCM(raw_key).decrypt(
|
||||
_b64url_decode(header["nonce"]),
|
||||
encrypted_payload,
|
||||
_b64url_decode(header["aad"]),
|
||||
)
|
||||
payload = json.loads(zlib.decompress(compressed_payload).decode("utf-8"))
|
||||
|
||||
self.assertEqual(payload["format"], StateCorpExchangeService.PAYLOAD_FORMAT)
|
||||
self.assertEqual(payload["manifest"]["source_system"], "mostovik")
|
||||
self.assertEqual(payload["data"]["organizations"][0]["inn"], "7707083893")
|
||||
self.assertEqual(
|
||||
payload["data"]["industrial_products"][0]["registry_number"],
|
||||
"prod-001",
|
||||
)
|
||||
self.assertEqual(
|
||||
payload["data"]["prosecutor_checks"][0]["registration_number"],
|
||||
"insp-001",
|
||||
)
|
||||
self.assertEqual(
|
||||
payload["data"]["public_procurements"][0]["purchase_number"],
|
||||
"purchase-001",
|
||||
)
|
||||
self.assertEqual(payload["data"]["arbitration_cases"], [])
|
||||
|
||||
@patch("apps.exchange.state_corp_services.requests.post")
|
||||
def test_send_package_posts_multipart_archive(self, post_mock):
|
||||
response_mock = Mock(status_code=201)
|
||||
response_mock.json.return_value = {
|
||||
"message": "ok",
|
||||
"result": {"duplicate": False},
|
||||
}
|
||||
post_mock.return_value = response_mock
|
||||
|
||||
package = StateCorpExchangeService.build_package()
|
||||
result = StateCorpExchangeService.send_package(package=package)
|
||||
|
||||
self.assertEqual(result["status_code"], 201)
|
||||
self.assertEqual(result["response"]["result"]["duplicate"], False)
|
||||
post_mock.assert_called_once()
|
||||
_, kwargs = post_mock.call_args
|
||||
self.assertEqual(
|
||||
kwargs["headers"]["X-Exchange-Token"],
|
||||
TEST_STATE_CORP_TOKEN,
|
||||
)
|
||||
self.assertEqual(kwargs["files"]["file"][0], package.archive_name)
|
||||
Reference in New Issue
Block a user