diff --git a/app/pipeline/attachments.py b/app/pipeline/attachments.py new file mode 100644 index 0000000..ad9b4be --- /dev/null +++ b/app/pipeline/attachments.py @@ -0,0 +1,198 @@ +"""Сбор XML-файлов из вложений интерфейса. + +Пользователь прикрепляет через file_uploader сразу несколько объектов: +отдельные .xml, архивы с выгрузками, или и то и другое вперемешку. Этот +модуль приводит их к плоскому списку XML-файлов на диске и отдельно +возвращает список вложений, которые обработать не удалось, — чтобы одно +битое вложение не отменяло весь запуск. + +Формат архива определяется по содержимому (zipfile/tarfile), а не по +расширению: выгрузки нередко приезжают с именем вида "Файлы" без суффикса +или с суффиксом, не совпадающим с фактическим форматом. +""" + +from __future__ import annotations + +import tarfile +import zipfile +from collections.abc import Sequence +from dataclasses import dataclass, field +from pathlib import Path + +XML_SUFFIX = ".xml" +# Форматы, для которых нужен внешний бинарник или сторонняя зависимость, +# поэтому распознаём их только чтобы выдать понятную причину отказа. +UNSUPPORTED_ARCHIVE_SUFFIXES = frozenset({".rar", ".7z"}) + + +class AttachmentError(Exception): + """Вложение не удалось разобрать: не архив, битый архив, опасные пути.""" + + +@dataclass(frozen=True) +class CollectedXml: + """XML-файл, готовый к разбору, и вложение, из которого он получен.""" + + file_name: str + path: Path + source: str + + +@dataclass(frozen=True) +class AttachmentProblem: + source: str + reason: str + # Для дубликатов — имя самого файла; для нечитаемых вложений пусто, + # потому что до отдельных файлов дело не доходит. + file_name: str = "" + + +@dataclass +class CollectResult: + files: list[CollectedXml] = field(default_factory=list) + problems: list[AttachmentProblem] = field(default_factory=list) + duplicates: list[AttachmentProblem] = field(default_factory=list) + archives: int = 0 + loose_xml_files: int = 0 + + +def collect_xml_attachments( + attachments: Sequence[Path], + extract_root: Path, +) -> CollectResult: + """Разворачивает вложения в плоский список XML-файлов. + + Дубликаты по имени файла отбрасываются: имя XML-выгрузки уникально + (в нём есть дата и порядковый номер), поэтому повторное имя означает, + что одна и та же выгрузка прикреплена дважды, а её повторный разбор + удвоил бы строки отчёта. + """ + result = CollectResult() + seen: dict[str, str] = {} + + for index, attachment in enumerate(attachments): + source = attachment.name + if attachment.suffix.lower() == XML_SUFFIX: + result.loose_xml_files += 1 + _add_file(result, seen, file_name=source, path=attachment, source=source) + continue + + # Каждое вложение распаковываем в свой каталог: имена файлов внутри + # разных архивов могут совпадать. + target_dir = extract_root / str(index) + try: + target_dir.mkdir(parents=True, exist_ok=True) + extract_archive(attachment, target_dir) + except AttachmentError as exc: + result.problems.append(AttachmentProblem(source=source, reason=str(exc))) + continue + + result.archives += 1 + for xml_path in sorted(target_dir.rglob("*")): + if xml_path.is_file() and xml_path.suffix.lower() == XML_SUFFIX: + _add_file( + result, + seen, + file_name=xml_path.name, + path=xml_path, + source=source, + ) + + return result + + +def extract_archive(archive_path: Path, target_dir: Path) -> None: + """Распаковывает zip или tar в target_dir, не выходя за его пределы.""" + if zipfile.is_zipfile(archive_path): + _extract_zip(archive_path, target_dir) + return + if tarfile.is_tarfile(archive_path): + _extract_tar(archive_path, target_dir) + return + + suffix = archive_path.suffix.lower() + if suffix in UNSUPPORTED_ARCHIVE_SUFFIXES: + raise AttachmentError( + f"формат {suffix} не поддерживается — прикрепите .zip или .tar" + ) + raise AttachmentError("вложение не является ни XML-файлом, ни архивом .zip/.tar") + + +def _extract_zip(archive_path: Path, target_dir: Path) -> None: + try: + with zipfile.ZipFile(archive_path) as archive: + for member in archive.infolist(): + if member.is_dir(): + continue + destination = _safe_destination(target_dir, member.filename) + destination.parent.mkdir(parents=True, exist_ok=True) + with archive.open(member) as source, destination.open("wb") as target: + target.write(source.read()) + except (zipfile.BadZipFile, OSError) as exc: + raise AttachmentError(f"не удалось распаковать zip-архив: {exc}") from exc + + +def _extract_tar(archive_path: Path, target_dir: Path) -> None: + # Своя распаковка вместо extractall: параметр filter="data", который + # отсекает опасных членов архива, появился только в Python 3.11.4, а + # окружение бывает и старше. + try: + with tarfile.open(archive_path) as archive: + for member in archive.getmembers(): + if member.isdir(): + continue + if not member.isfile(): + # Симлинки и устройства из архива нам не нужны и опасны: + # ссылка может указать за пределы каталога распаковки. + continue + destination = _safe_destination(target_dir, member.name) + destination.parent.mkdir(parents=True, exist_ok=True) + extracted = archive.extractfile(member) + if extracted is None: + continue + with extracted, destination.open("wb") as target: + target.write(extracted.read()) + except (tarfile.TarError, OSError) as exc: + raise AttachmentError(f"не удалось распаковать tar-архив: {exc}") from exc + + +def _safe_destination(target_dir: Path, member_name: str) -> Path: + """Защита от путей вида "../..": член архива обязан лежать внутри каталога.""" + base = target_dir.resolve() + # PurePath не трогает разделители Windows, а в архивах они бывают оба. + relative_parts = [ + part + for part in member_name.replace("\\", "/").split("/") + if part and part not in {".", ".."} + ] + if not relative_parts: + raise AttachmentError(f"архив содержит недопустимый путь: {member_name!r}") + destination = base.joinpath(*relative_parts) + if not destination.is_relative_to(base): + raise AttachmentError(f"архив содержит недопустимый путь: {member_name!r}") + return destination + + +def _add_file( + result: CollectResult, + seen: dict[str, str], + *, + file_name: str, + path: Path, + source: str, +) -> None: + previous_source = seen.get(file_name) + if previous_source is not None: + result.duplicates.append( + AttachmentProblem( + source=source, + file_name=file_name, + reason=( + f"Повторно прикреплённый файл — уже получен из вложения " + f"«{previous_source}», пропущен." + ), + ) + ) + return + seen[file_name] = source + result.files.append(CollectedXml(file_name=file_name, path=path, source=source)) diff --git a/app/pipeline/protocol.py b/app/pipeline/protocol.py index be49c75..5306b20 100644 --- a/app/pipeline/protocol.py +++ b/app/pipeline/protocol.py @@ -19,6 +19,9 @@ class ProtocolRow: rows_written: int launcher: str processed_at: datetime + # Имя вложения, из которого получен файл. Пусто, если файл прикреплён + # отдельным .xml — тогда имя вложения совпадает с именем файла. + source: str = "" def write_protocol(rows: Iterable[ProtocolRow], destination: Path) -> None: @@ -30,6 +33,7 @@ def write_protocol(rows: Iterable[ProtocolRow], destination: Path) -> None: columns = [ "Имя файла", + "Вложение", "Статус", "Комментарий", "Операций", @@ -41,6 +45,7 @@ def write_protocol(rows: Iterable[ProtocolRow], destination: Path) -> None: for row_index, row in enumerate(row_list, start=2): values = [ row.file_name, + row.source or row.file_name, row.status, row.message, row.operations_total, diff --git a/app/pipeline/service.py b/app/pipeline/service.py index 6a203c9..4784737 100644 --- a/app/pipeline/service.py +++ b/app/pipeline/service.py @@ -2,7 +2,7 @@ from __future__ import annotations import tempfile import time -import zipfile +from collections.abc import Sequence from dataclasses import dataclass, field from datetime import datetime from pathlib import Path @@ -14,6 +14,7 @@ from ..opencloud_access import ( resolve_personal_drive_id, upload_local_file_to_opencloud, ) +from .attachments import collect_xml_attachments from .config import ( DEFAULT_OUTPUT_DIR_NAME, build_date_parts, @@ -42,20 +43,25 @@ class BatchResult: def run_batch( - zip_path: Path, + input_paths: Sequence[Path], launcher: str, opencloud_username: str | None, opencloud_access_token: str | None, ) -> BatchResult: - """Прикреплённый через интерфейс .zip с XML -> отчёт/протокол/лог -> OpenCloud. + """Прикреплённые XML и архивы -> отчёт/протокол/лог -> OpenCloud. - Входные файлы приходят как есть — через вложение (attach) в интерфейсе + Входные файлы приходят как есть — через вложения (attach) в интерфейсе (Streamlit file_uploader), без SMB и без выделенной входной папки в - OpenCloud. Результат грузится в личное пространство пользователя - (Out/год/месяц/день/номер_запуска). Файлы с некорректным именем/битым + OpenCloud. Вложений может быть несколько, и это могут быть как отдельные + .xml, так и архивы, вперемешку: их разворачивает в плоский список + collect_xml_attachments. Вложение, которое не удалось разобрать, попадает + в протокол отдельной строкой и не отменяет обработку остальных. + + Результат грузится в личное пространство пользователя + (Out/год/месяц/день/номер_запуска). Файлы с некорректным именем или битым XML просто попадают в протокол со статусом "error" — никуда не - перемещаются (перемещать их и некуда, это временная распакованная - копия вложения). + перемещаются (перемещать их и некуда, это временная распакованная копия + вложения). """ started_at = time.perf_counter() if not opencloud_username: @@ -84,42 +90,79 @@ def run_batch( ], ) + if not input_paths: + return BatchResult( + status="error", + processed=0, + partial=0, + errors=1, + report_path="", + protocol_path="", + log_path="", + messages=["Не прикреплено ни одного файла."], + ) + protocol_rows: list[ProtocolRow] = [] - log_lines: list[str] = [f"{_now()} | batch start: {zip_path.name}"] + attachment_failures: list[str] = [] + attachment_names = ", ".join(path.name for path in input_paths) + log_lines: list[str] = [f"{_now()} | batch start: {attachment_names}"] with tempfile.TemporaryDirectory() as extract_dir: extract_dir_path = Path(extract_dir) - try: - with zipfile.ZipFile(zip_path) as archive: - archive.extractall(extract_dir_path) - except zipfile.BadZipFile as exc: - return BatchResult( - status="error", - processed=0, - partial=0, - errors=1, - report_path="", - protocol_path="", - log_path="", - messages=[f"Не удалось распаковать архив: {exc}"], + collected = collect_xml_attachments(input_paths, extract_dir_path) + log_lines.append( + f"{_now()} | вложений: {len(input_paths)} " + f"(архивов: {collected.archives}, " + f"отдельных XML: {collected.loose_xml_files}), " + f"найдено XML-файлов: {len(collected.files)}" + ) + for problem in collected.problems: + protocol_rows.append( + ProtocolRow( + file_name=problem.source, + source=problem.source, + status="error", + message=f"Вложение не обработано: {problem.reason}", + operations_total=0, + rows_written=0, + launcher=launcher, + processed_at=datetime.now(), + ) + ) + attachment_failures.append(f"{problem.source} — {problem.reason}") + log_lines.append( + f"{_now()} | attachment failed: {problem.source}. {problem.reason}" + ) + for duplicate in collected.duplicates: + protocol_rows.append( + ProtocolRow( + file_name=duplicate.file_name or duplicate.source, + source=duplicate.source, + status="skip", + message=duplicate.reason, + operations_total=0, + rows_written=0, + launcher=launcher, + processed_at=datetime.now(), + ) + ) + log_lines.append( + f"{_now()} | duplicate skipped: {duplicate.file_name}. " + f"{duplicate.reason}" ) - xml_paths = sorted(extract_dir_path.rglob("*.xml")) - log_lines.append( - f"{_now()} | распаковано, найдено XML-файлов: {len(xml_paths)}" - ) - report_writer = StreamingReportWriter() - for xml_path in xml_paths: - file_name = xml_path.name + for collected_xml in collected.files: + file_name = collected_xml.file_name try: - content = xml_path.read_bytes() + content = collected_xml.path.read_bytes() except Exception as exc: # noqa: BLE001 protocol_rows.append( ProtocolRow( file_name=file_name, + source=collected_xml.source, status="error", - message=f"Ошибка чтения файла из архива: {exc}", + message=f"Ошибка чтения файла из вложения: {exc}", operations_total=0, rows_written=0, launcher=launcher, @@ -148,6 +191,7 @@ def run_batch( protocol_rows.append( ProtocolRow( file_name=file_name, + source=collected_xml.source, status=status, message=message, operations_total=parse_result.total_operations, @@ -163,7 +207,7 @@ def run_batch( ProtocolRow( file_name="-", status=EMPTY_BATCH_STATUS, - message="В архиве не найдено XML-файлов.", + message="Во вложениях не найдено XML-файлов.", operations_total=0, rows_written=0, launcher=launcher, @@ -238,11 +282,16 @@ def run_batch( if errors == 0 and partial == 0 else ("partial" if partial > 0 else "error") ) + skipped = sum(1 for row in protocol_rows if row.status == "skip") messages = [ _build_result_message( status=status, processed=processed, partial=partial, errors=errors ) ] + if skipped: + messages.append(f"Пропущено повторно прикреплённых файлов: {skipped}.") + if attachment_failures: + messages.append("Не обработаны вложения: " + "; ".join(attachment_failures)) duration_seconds = round(time.perf_counter() - started_at, 2) messages.append(f"Время выполнения: {_format_duration(duration_seconds)}.") messages.extend(log_lines[-5:]) diff --git a/main.py b/main.py index a340ce4..82826bd 100644 --- a/main.py +++ b/main.py @@ -29,25 +29,29 @@ def main() -> None: _render_token_popover() st.caption( - "Прикрепите .zip с XML-файлами — отчёт, протокол и лог " - "загрузятся в Out вашего личного пространства OpenCloud." + "Прикрепите XML-файлы и/или архивы с ними (можно несколько сразу, " + "вперемешку) — отчёт, протокол и лог загрузятся в Out вашего " + "личного пространства OpenCloud." ) - uploaded_zip = st.file_uploader("Архив с XML-файлами (.zip)", type=["zip"]) + uploaded_files = st.file_uploader( + "XML-файлы и архивы", + type=["xml", "zip", "tar", "gz", "tgz", "bz2", "xz"], + accept_multiple_files=True, + ) if st.button( "Запустить обработку xml в excel", type="primary", use_container_width=True, - disabled=uploaded_zip is None, + disabled=not uploaded_files, ): with tempfile.TemporaryDirectory() as tmp_dir: - zip_path = Path(tmp_dir) / uploaded_zip.name - zip_path.write_bytes(uploaded_zip.getvalue()) + input_paths = _save_attachments(uploaded_files, Path(tmp_dir)) with st.spinner("Выполняется обработка..."): launcher_name = resolve_opencloud_username() or resolve_user_id() batch_result = run_batch( - zip_path=zip_path, + input_paths=input_paths, launcher=launcher_name, opencloud_username=resolve_opencloud_username(), opencloud_access_token=resolve_opencloud_access_token(), @@ -78,6 +82,23 @@ def main() -> None: st.rerun() +def _save_attachments(uploaded_files, target_dir: Path) -> list[Path]: + """Складывает вложения на диск, каждое в свой подкаталог. + + Подкаталог по индексу нужен потому, что имена вложений могут совпадать + (например, два архива "Файлы.zip" из разных папок), и одно перезаписало + бы другое. + """ + paths: list[Path] = [] + for index, uploaded in enumerate(uploaded_files): + holder = target_dir / str(index) + holder.mkdir(parents=True, exist_ok=True) + path = holder / uploaded.name + path.write_bytes(uploaded.getvalue()) + paths.append(path) + return paths + + def _render_token_popover() -> None: """Самообслуживание: пользователь сам привязывает свой App Token OpenCloud к своей учётке (по preferred_username из Keycloak), без diff --git a/scripts/local_zip_test_app.py b/scripts/local_zip_test_app.py index 0754f35..9ac9f7c 100644 --- a/scripts/local_zip_test_app.py +++ b/scripts/local_zip_test_app.py @@ -29,9 +29,9 @@ from app.pipeline.service import run_batch load_dotenv() st.set_page_config(page_title="СФМ — локальный тест", page_icon="📦") -st.title("Локальный тест: zip → обработка → OpenCloud") +st.title("Локальный тест: вложения → обработка → OpenCloud") st.caption( - "Прикрепляете .zip с XML-файлами, он распаковывается и обрабатывается " + "Прикрепляете XML-файлы и/или архивы с ними, они распаковываются и обрабатываются " "тем же кодом парсинга, что и прод (app/pipeline/parser.py), а отчёт, " "протокол и лог грузятся в OpenCloud (app/opencloud_access.py). Та же " "логика, что и в main.py, только username/token вводятся руками." @@ -54,22 +54,31 @@ access_token = st.text_input( ) launcher = st.text_input("Запустил (для протокола)", value="local-test") -uploaded_zip = st.file_uploader("Архив с XML-файлами (.zip)", type=["zip"]) +uploaded_files = st.file_uploader( + "XML-файлы и архивы", + type=["xml", "zip", "tar", "gz", "tgz", "bz2", "xz"], + accept_multiple_files=True, +) if st.button("Начать обработку", type="primary", use_container_width=True): if not username: st.error("Укажите username.") elif not access_token: st.error("Укажите App Token.") - elif uploaded_zip is None: - st.error("Прикрепите .zip-архив.") + elif not uploaded_files: + st.error("Прикрепите XML-файлы или архивы.") else: with tempfile.TemporaryDirectory() as tmp_dir: - zip_path = Path(tmp_dir) / uploaded_zip.name - zip_path.write_bytes(uploaded_zip.getvalue()) + input_paths = [] + for index, uploaded in enumerate(uploaded_files): + holder = Path(tmp_dir) / str(index) + holder.mkdir(parents=True, exist_ok=True) + attachment_path = holder / uploaded.name + attachment_path.write_bytes(uploaded.getvalue()) + input_paths.append(attachment_path) with st.spinner("Распаковываем, обрабатываем, грузим в OpenCloud..."): result = run_batch( - zip_path=zip_path, + input_paths=input_paths, launcher=launcher or "local-test", opencloud_username=username, opencloud_access_token=access_token, diff --git a/tests/smoke/test_batch_smoke.py b/tests/smoke/test_batch_smoke.py index cb5cc70..f3563af 100644 --- a/tests/smoke/test_batch_smoke.py +++ b/tests/smoke/test_batch_smoke.py @@ -1,5 +1,6 @@ from __future__ import annotations +import tarfile import zipfile from pathlib import Path @@ -70,7 +71,7 @@ def test_run_batch_uses_run_number_from_opencloud(tmp_path: Path, monkeypatch) - archive.writestr("SKO115FZ_01_123456789_20260616_X00001.xml", _valid_xml()) result = run_batch( - zip_path=zip_path, + input_paths=[zip_path], launcher="tester", opencloud_username="test-user", opencloud_access_token="token-123", @@ -89,7 +90,7 @@ def test_run_batch_success(tmp_path: Path, monkeypatch) -> None: archive.writestr("SKO115FZ_01_123456789_20260616_X00001.xml", _valid_xml()) result = run_batch( - zip_path=zip_path, + input_paths=[zip_path], launcher="tester", opencloud_username="test-user", opencloud_access_token="token-123", @@ -111,7 +112,7 @@ def test_run_batch_bad_file_name_is_error(tmp_path: Path, monkeypatch) -> None: archive.writestr("bad_name.xml", _valid_xml()) result = run_batch( - zip_path=zip_path, + input_paths=[zip_path], launcher="tester", opencloud_username="test-user", opencloud_access_token="token-123", @@ -130,7 +131,7 @@ def test_run_batch_empty_archive(tmp_path: Path, monkeypatch) -> None: archive.writestr("readme.txt", "no xml here") result = run_batch( - zip_path=zip_path, + input_paths=[zip_path], launcher="tester", opencloud_username="test-user", opencloud_access_token="token-123", @@ -146,7 +147,7 @@ def test_run_batch_missing_token(tmp_path: Path) -> None: archive.writestr("SKO115FZ_01_123456789_20260616_X00001.xml", _valid_xml()) result = run_batch( - zip_path=zip_path, + input_paths=[zip_path], launcher="tester", opencloud_username="test-user", opencloud_access_token="", @@ -162,7 +163,7 @@ def test_run_batch_missing_username(tmp_path: Path) -> None: archive.writestr("SKO115FZ_01_123456789_20260616_X00001.xml", _valid_xml()) result = run_batch( - zip_path=zip_path, + input_paths=[zip_path], launcher="tester", opencloud_username="", opencloud_access_token="token-123", @@ -172,16 +173,146 @@ def test_run_batch_missing_username(tmp_path: Path) -> None: assert not result.report_path -def test_run_batch_bad_zip_file(tmp_path: Path) -> None: +def test_run_batch_bad_zip_file(tmp_path: Path, monkeypatch) -> None: + _patch_opencloud(monkeypatch) + zip_path = tmp_path / "not_a_zip.zip" zip_path.write_bytes(b"this is not a zip file") result = run_batch( - zip_path=zip_path, + input_paths=[zip_path], launcher="tester", opencloud_username="test-user", opencloud_access_token="token-123", ) assert result.status == "error" - assert any("распаковать" in message for message in result.messages) + assert result.errors == 1 + assert any("not_a_zip.zip" in message for message in result.messages) + + +def _write_zip(path: Path, members: dict[str, str]) -> Path: + with zipfile.ZipFile(path, "w") as archive: + for name, content in members.items(): + archive.writestr(name, content) + return path + + +def _write_xml(path: Path, content: str | None = None) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content or _valid_xml(), encoding="utf-8") + return path + + +def test_run_batch_accepts_several_archives_and_loose_xml( + tmp_path: Path, monkeypatch +) -> None: + """Основной сценарий правки: вложений несколько, среди них и архивы, + и отдельные .xml.""" + _patch_opencloud(monkeypatch) + + first = _write_zip( + tmp_path / "day1.zip", + {"SKO115FZ_01_123456789_20260616_X00001.xml": _valid_xml()}, + ) + second = _write_zip( + tmp_path / "day2.zip", + { + "nested/SKO115FZ_01_123456789_20260616_X00002.xml": _valid_xml(), + "nested/deeper/SKO115FZ_01_123456789_20260616_X00003.xml": _valid_xml(), + }, + ) + loose = _write_xml(tmp_path / "SKO115FZ_01_123456789_20260616_X00004.xml") + + result = run_batch( + input_paths=[first, second, loose], + launcher="tester", + opencloud_username="test-user", + opencloud_access_token="token-123", + ) + + assert result.status == "success" + assert result.processed == 4 + assert result.errors == 0 + + +def test_run_batch_keeps_going_when_one_attachment_is_broken( + tmp_path: Path, monkeypatch +) -> None: + """Одно нечитаемое вложение не отменяет обработку остальных.""" + _patch_opencloud(monkeypatch) + + good = _write_zip( + tmp_path / "good.zip", + {"SKO115FZ_01_123456789_20260616_X00001.xml": _valid_xml()}, + ) + broken = tmp_path / "broken.zip" + broken.write_bytes(b"not an archive at all") + + result = run_batch( + input_paths=[good, broken], + launcher="tester", + opencloud_username="test-user", + opencloud_access_token="token-123", + ) + + assert result.processed == 1 + assert result.errors == 1 + assert result.status == "error" + assert result.report_path + + +def test_run_batch_skips_duplicate_file_names_across_attachments( + tmp_path: Path, monkeypatch +) -> None: + """Один и тот же файл, прикреплённый дважды, не должен удваивать отчёт.""" + _patch_opencloud(monkeypatch) + + name = "SKO115FZ_01_123456789_20260616_X00001.xml" + first = _write_zip(tmp_path / "a.zip", {name: _valid_xml()}) + second = _write_zip(tmp_path / "b.zip", {name: _valid_xml()}) + + result = run_batch( + input_paths=[first, second], + launcher="tester", + opencloud_username="test-user", + opencloud_access_token="token-123", + ) + + assert result.processed == 1 + assert result.errors == 0 + assert any("Пропущено повторно" in message for message in result.messages) + + +def test_run_batch_accepts_tar_gz_archive(tmp_path: Path, monkeypatch) -> None: + _patch_opencloud(monkeypatch) + + xml_path = _write_xml( + tmp_path / "src" / "SKO115FZ_01_123456789_20260616_X00001.xml" + ) + archive_path = tmp_path / "batch.tar.gz" + with tarfile.open(archive_path, "w:gz") as archive: + archive.add(xml_path, arcname=xml_path.name) + + result = run_batch( + input_paths=[archive_path], + launcher="tester", + opencloud_username="test-user", + opencloud_access_token="token-123", + ) + + assert result.status == "success" + assert result.processed == 1 + + +def test_run_batch_without_attachments_is_error(tmp_path: Path) -> None: + _ = tmp_path + result = run_batch( + input_paths=[], + launcher="tester", + opencloud_username="test-user", + opencloud_access_token="token-123", + ) + + assert result.status == "error" + assert not result.report_path diff --git a/tests/unit/test_attachments.py b/tests/unit/test_attachments.py new file mode 100644 index 0000000..f915afe --- /dev/null +++ b/tests/unit/test_attachments.py @@ -0,0 +1,182 @@ +from __future__ import annotations + +import tarfile +import zipfile +from pathlib import Path + +import pytest + +from app.pipeline.attachments import ( + AttachmentError, + collect_xml_attachments, + extract_archive, +) + +XML_BODY = "<СообщОперКО/>" + + +def _zip(path: Path, members: dict[str, str]) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + with zipfile.ZipFile(path, "w") as archive: + for name, content in members.items(): + archive.writestr(name, content) + return path + + +def _xml(path: Path) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(XML_BODY, encoding="utf-8") + return path + + +def test_collects_loose_xml_and_archives_together(tmp_path: Path) -> None: + loose = _xml(tmp_path / "loose" / "a.xml") + archive = _zip(tmp_path / "batch.zip", {"b.xml": XML_BODY, "notes.txt": "skip me"}) + extract_root = tmp_path / "extract" + extract_root.mkdir() + + result = collect_xml_attachments([loose, archive], extract_root) + + assert [item.file_name for item in result.files] == ["a.xml", "b.xml"] + assert result.loose_xml_files == 1 + assert result.archives == 1 + assert result.problems == [] + # У каждого файла видно, из какого вложения он пришёл. + assert {item.source for item in result.files} == {"a.xml", "batch.zip"} + + +def test_finds_xml_in_nested_folders(tmp_path: Path) -> None: + archive = _zip( + tmp_path / "batch.zip", + {"one/two/three/deep.xml": XML_BODY, "one/shallow.xml": XML_BODY}, + ) + extract_root = tmp_path / "extract" + extract_root.mkdir() + + result = collect_xml_attachments([archive], extract_root) + + assert sorted(item.file_name for item in result.files) == [ + "deep.xml", + "shallow.xml", + ] + + +def test_same_file_name_in_two_archives_is_collected_once(tmp_path: Path) -> None: + first = _zip(tmp_path / "first.zip", {"same.xml": XML_BODY}) + second = _zip(tmp_path / "second.zip", {"same.xml": XML_BODY}) + extract_root = tmp_path / "extract" + extract_root.mkdir() + + result = collect_xml_attachments([first, second], extract_root) + + assert [item.file_name for item in result.files] == ["same.xml"] + assert len(result.duplicates) == 1 + assert "first.zip" in result.duplicates[0].reason + assert result.duplicates[0].source == "second.zip" + # Имя самого файла нужно протоколу: строка «пропущен» должна стоять + # под тем же именем, что и обработанная. + assert result.duplicates[0].file_name == "same.xml" + + +def test_archives_with_equal_names_do_not_overwrite_each_other(tmp_path: Path) -> None: + """Два вложения могут называться одинаково — распаковываться они должны + в разные каталоги, иначе одно затрёт другое.""" + first = _zip(tmp_path / "a" / "Файлы.zip", {"one.xml": XML_BODY}) + second = _zip(tmp_path / "b" / "Файлы.zip", {"two.xml": XML_BODY}) + extract_root = tmp_path / "extract" + extract_root.mkdir() + + result = collect_xml_attachments([first, second], extract_root) + + assert sorted(item.file_name for item in result.files) == ["one.xml", "two.xml"] + assert result.problems == [] + + +def test_tar_gz_archive_is_supported(tmp_path: Path) -> None: + xml_path = _xml(tmp_path / "src" / "in_tar.xml") + archive = tmp_path / "batch.tar.gz" + with tarfile.open(archive, "w:gz") as tar: + tar.add(xml_path, arcname=xml_path.name) + extract_root = tmp_path / "extract" + extract_root.mkdir() + + result = collect_xml_attachments([archive], extract_root) + + assert [item.file_name for item in result.files] == ["in_tar.xml"] + assert result.archives == 1 + + +def test_format_is_detected_by_content_not_extension(tmp_path: Path) -> None: + """Выгрузки приезжают и с неверным суффиксом — формат определяем по + содержимому.""" + archive = _zip(tmp_path / "actually_a_zip.bin", {"x.xml": XML_BODY}) + extract_root = tmp_path / "extract" + extract_root.mkdir() + + result = collect_xml_attachments([archive], extract_root) + + assert [item.file_name for item in result.files] == ["x.xml"] + + +def test_broken_attachment_is_reported_without_stopping_the_rest( + tmp_path: Path, +) -> None: + good = _zip(tmp_path / "good.zip", {"ok.xml": XML_BODY}) + broken = tmp_path / "broken.zip" + broken.write_bytes(b"definitely not an archive") + extract_root = tmp_path / "extract" + extract_root.mkdir() + + result = collect_xml_attachments([broken, good], extract_root) + + assert [item.file_name for item in result.files] == ["ok.xml"] + assert len(result.problems) == 1 + assert result.problems[0].source == "broken.zip" + + +def test_unsupported_archive_format_explains_itself(tmp_path: Path) -> None: + archive = tmp_path / "batch.7z" + archive.write_bytes(b"7z\xbc\xaf\x27\x1c not really") + extract_root = tmp_path / "extract" + extract_root.mkdir() + + result = collect_xml_attachments([archive], extract_root) + + assert result.files == [] + assert ".7z" in result.problems[0].reason + + +def test_zip_with_path_traversal_member_is_rejected(tmp_path: Path) -> None: + """Защита от zip slip: член архива не должен попадать за пределы каталога + распаковки.""" + archive = tmp_path / "evil.zip" + with zipfile.ZipFile(archive, "w") as zip_file: + zip_file.writestr("../../escaped.xml", XML_BODY) + extract_root = tmp_path / "extract" + extract_root.mkdir() + + result = collect_xml_attachments([archive], extract_root) + + assert not (tmp_path.parent / "escaped.xml").exists() + # Имя очищается до безопасного, файл остаётся внутри каталога распаковки. + for item in result.files: + assert extract_root.resolve() in item.path.resolve().parents + + +def test_extract_archive_raises_for_plain_file(tmp_path: Path) -> None: + plain = tmp_path / "notes.txt" + plain.write_text("just text", encoding="utf-8") + target = tmp_path / "out" + target.mkdir() + + with pytest.raises(AttachmentError): + extract_archive(plain, target) + + +def test_empty_attachment_list_gives_empty_result(tmp_path: Path) -> None: + result = collect_xml_attachments([], tmp_path) + + assert result.files == [] + assert result.problems == [] + assert result.archives == 0 + assert result.loose_xml_files == 0