arm-core/api/tests/test_api_files_upload.py
Andrey Epifancev be0d1b410c feat(files): загрузка файлов организации — S3, stored_file, recognition_job в outbox, дедуп, аудит
POST /api/v1/legal-entities/{id}/files (DOCUMENT_EDIT, видимость ЮЛ → 404): тип по расширению
со сверкой сигнатуры (domain/file_types.py), лимит UPLOAD_MAX_BYTES → FILE_TOO_LARGE, дедуп по
sha256 в пределах ЮЛ (кроме SUPERSEDED) с рекомендательной блокировкой (ЮЛ, sha256) до конца
транзакции запроса, s3_object → put_object → stored_file(UPLOADED) → recognition_job(queued) →
current_recognition_job_id, аудит FILE_UPLOAD. Каждый файл — SAVEPOINT: частичный успех
(201/422), отказ S3 на файле — STORAGE_UNAVAILABLE только для него; без S3 — 503.
safe_filename/CONTENT_TYPES/read_limited вынесены в common/filenames.py (импорт справочников
их переиспользует). Схемы UploadResult/FileOut/JobBrief/DocumentBrief, PrimaryFilesService и
зависимость для Task 9–10. tasks.md 2.1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
2026-10-05 21:50:29 +04:00

534 lines
22 KiB
Python
Raw 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.

"""Загрузка файлов организации (spec primary-files «Загрузка», «Дедуп»; audit-trail «События файлов и документов»).
Сценарии поверх фикстуры `app` (транзакция теста с SAVEPOINT): запрос — `begin_nested()`, каждый файл — ещё один
SAVEPOINT внутри него. Реальная ОСВ и параллельный дубль идут на `committed_*` (временная БД с зафиксированным seed,
сессия запроса как в проде): публикатор outbox и второй запрос работают в своих транзакциях и видят только
зафиксированное.
"""
import asyncio
import hashlib
import json
import uuid
from collections.abc import Sequence
from datetime import timedelta
from typing import Any
import httpx
import pytest
from fastapi import FastAPI
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from src.api.dependencies.directories import get_app_settings
from src.core.config import get_settings
from src.models.access import AppUser
from src.models.audit_ex import AuditEvent
from src.models.directories import LegalEntity, RegionalOffice
from src.models.enums import (
AuditAction,
AuditSection,
FileStatus,
FileType,
RecognitionPipeline,
RecognitionStatus,
S3BucketRole,
)
from src.models.files import RecognitionJob, S3Object, StoredFile
from src.services.recognition_exchange import publish_pending
from tests.conftest import AuthClientFactory, FakeStorage
from tests.factories import make_company_group, make_legal_entity
from tests.test_recognition_exchange import REQUEST_VALIDATOR, FakeNats
HTTP_CREATED = 201
HTTP_FORBIDDEN = 403
HTTP_NOT_FOUND = 404
HTTP_UNPROCESSABLE = 422
HTTP_UNAVAILABLE = 503
KA_RF = "ka_rf@armka.local" # Ростовский филиал
CLIENT_MANAGER = "client_manager@armka.local" # Краснодарский филиал, право DOCUMENT_EDIT есть
CURATOR_GO = "curator_go@armka.local" # права DOCUMENT_EDIT нет
ROSTOV = "Ростовский региональный филиал"
REAL_OSV = "ОСВ_66 за 2024.xls"
REAL_OSV_PATH = ("osv", "rb", "2024-FY", REAL_OSV)
BIFF8 = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
ZIP = b"PK\x03\x04"
PDF = b"%PDF-1.7\n"
XLS_TYPE = "application/vnd.ms-excel"
XLSX_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
TINY_LIMIT = 10 # байт: «файл 51 МиБ» без 51 МиБ в памяти теста
Upload = tuple[str, bytes]
def _files_url(entity_id: uuid.UUID) -> str:
return f"/legal-entities/{entity_id}/files"
def _xls(tag: str) -> bytes:
"""Байты с сигнатурой BIFF8 и уникальным хвостом (разный sha256 — не дубль)."""
return BIFF8 + tag.encode()
async def _post(client: httpx.AsyncClient, entity_id: uuid.UUID, *uploads: Upload) -> httpx.Response:
files = [("files", (name, content, "application/octet-stream")) for name, content in uploads]
return await client.post(_files_url(entity_id), files=files)
async def _rostov_entity(session: AsyncSession, name: str = "ООО Загрузка") -> LegalEntity:
office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()
group = await make_company_group(session, name=f"ГК {name}", office=office)
return await make_legal_entity(session, name=name, group=group)
async def _user_id(session: AsyncSession, email: str) -> uuid.UUID:
return (await session.execute(select(AppUser.id).where(AppUser.email == email))).scalar_one()
async def _counts(session: AsyncSession) -> dict[str, int]:
return {
model.__tablename__: (await session.execute(select(func.count()).select_from(model))).scalar_one()
for model in (S3Object, StoredFile, RecognitionJob)
} | {
"file_upload": (
await session.execute(
select(func.count()).select_from(AuditEvent).where(AuditEvent.action == AuditAction.FILE_UPLOAD)
)
).scalar_one()
}
async def _upload_events(session: AsyncSession) -> Sequence[AuditEvent]:
stmt = select(AuditEvent).where(AuditEvent.action == AuditAction.FILE_UPLOAD).order_by(AuditEvent.occurred_at)
return (await session.execute(stmt)).scalars().all()
@pytest.fixture
def storage(app: FastAPI) -> FakeStorage:
fake = FakeStorage()
app.state.storage = fake
return fake
class FlakyStorage(FakeStorage):
"""S3, у которого падает запись объектов с заданным суффиксом ключа."""
def __init__(self, failing_suffix: str) -> None:
super().__init__()
self.failing_suffix = failing_suffix
async def put_object(self, obj_name: str, file_data: bytes) -> None:
if obj_name.endswith(self.failing_suffix):
raise ConnectionError("S3 недоступно")
await super().put_object(obj_name, file_data)
class CapturedPublish:
"""Делегат `FakeNats`: запоминает тела опубликованных запросов."""
def __init__(self) -> None:
self.payloads: list[dict[str, Any]] = []
async def publish(self, subject: str, payload: bytes, msg_id: str) -> None: # noqa: ARG002 - протокол издателя
self.payloads.append(json.loads(payload))
# --- «Реальная ОСВ принята и ушла в outbox» ----------------------------------------------------------------------
@pytest.mark.integration
@pytest.mark.fixtures
async def test_real_osv_is_accepted_and_published_from_outbox(
fixtures_dir: Any,
committed_client: AuthClientFactory,
committed_sessionmaker: async_sessionmaker[AsyncSession],
committed_storage: FakeStorage,
) -> None:
content = fixtures_dir.joinpath(*REAL_OSV_PATH).read_bytes()
async with committed_sessionmaker() as session, session.begin():
entity = await _rostov_entity(session, "АО Русский Бисквит")
user_id = await _user_id(session, KA_RF)
client = await committed_client(KA_RF)
response = await _post(client, entity.id, (REAL_OSV, content))
assert response.status_code == HTTP_CREATED, response.text
body = response.json()
assert body["rejected"] == []
(accepted,) = body["accepted"]
file_id, job_id = uuid.UUID(accepted["file_id"]), uuid.UUID(accepted["job_id"])
assert accepted["name"] == REAL_OSV
key = f"uploads/{file_id}/{job_id}/{REAL_OSV}"
assert committed_storage.objects == {key: content}
async with committed_sessionmaker() as session:
file = await session.get(StoredFile, file_id)
job = await session.get(RecognitionJob, job_id)
assert file is not None and job is not None
s3 = await session.get(S3Object, file.s3_object_id)
assert s3 is not None
sha256 = hashlib.sha256(content).hexdigest()
assert (file.status, file.file_type, file.content_hash, file.version_no) == (
FileStatus.UPLOADED,
FileType.XLS,
sha256,
1,
)
assert (file.legal_entity_id, file.uploaded_by, file.original_name) == (entity.id, user_id, REAL_OSV)
assert file.current_recognition_job_id == job.id
assert (job.status, job.pipeline, job.max_attempts, job.file_id) == (
RecognitionStatus.queued,
RecognitionPipeline.tabular,
3,
file_id,
)
assert (s3.bucket_role, s3.key, s3.sha256, s3.size_bytes) == (S3BucketRole.uploads, key, sha256, len(content))
capture = CapturedPublish()
report = await publish_pending(
committed_sessionmaker,
FakeNats(delegate=capture),
ack_timeout=timedelta(seconds=30),
max_republish=3,
)
assert report.published == 1
(request,) = capture.payloads
REQUEST_VALIDATOR.validate(request)
assert request["s3"] == {"bucket": committed_storage.bucket, "key": key}
assert (request["job_id"], request["file_id"], request["file_type"]) == (str(job_id), str(file_id), "XLS")
assert request["original_name"] == REAL_OSV
# --- поля записей, имя файла, аудит ------------------------------------------------------------------------------
@pytest.mark.integration
async def test_hostile_name_gives_flat_key_and_full_records(
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
) -> None:
"""Review Focus 4: кириллица, пробелы, `..` и обратные слэши — ключ S3 без путей, имя без каталогов."""
entity = await _rostov_entity(seeded)
user_id = await _user_id(seeded, KA_RF)
client = await auth_client(KA_RF)
content = _xls("hostile")
response = await _post(client, entity.id, ("../..\\ОСВ 60 (2).xls", content))
assert response.status_code == HTTP_CREATED, response.text
(accepted,) = response.json()["accepted"]
file_id, job_id = uuid.UUID(accepted["file_id"]), uuid.UUID(accepted["job_id"])
assert accepted["name"] == "ОСВ 60 (2).xls"
key = f"uploads/{file_id}/{job_id}/ОСВ 60 (2).xls"
assert list(storage.objects) == [key]
assert ".." not in key
assert "\\" not in key
assert key.count("/") == 3 # noqa: PLR2004 - uploads/{file_id}/{job_id}/{имя}
file = await seeded.get(StoredFile, file_id)
job = await seeded.get(RecognitionJob, job_id)
assert file is not None and job is not None
s3 = await seeded.get(S3Object, file.s3_object_id)
assert s3 is not None
sha256 = hashlib.sha256(content).hexdigest()
assert file.original_name == "ОСВ 60 (2).xls"
assert (file.status, file.version_no, file.content_hash, file.size_bytes) == (
FileStatus.UPLOADED,
1,
sha256,
len(content),
)
assert file.uploaded_by == user_id
assert file.uploaded_at is not None
assert file.current_recognition_job_id == job_id
assert job.request_subject == "recognition.tabular.request"
assert job.schema_version == "1.0"
assert (job.status, job.attempt, job.max_attempts) == (RecognitionStatus.queued, 1, 3)
assert job.requested_at is not None
assert (s3.bucket_role, s3.bucket, s3.key, s3.content_type) == (
S3BucketRole.uploads,
storage.bucket,
key,
XLS_TYPE,
)
assert (s3.sha256, s3.size_bytes, s3.created_by) == (sha256, len(content), f"user:{user_id}")
@pytest.mark.integration
async def test_image_file_goes_to_image_pipeline_with_one_attempt(
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
) -> None:
entity = await _rostov_entity(seeded)
client = await auth_client(KA_RF)
response = await _post(client, entity.id, ("ББ 2024.pdf", PDF + b"scan"))
assert response.status_code == HTTP_CREATED, response.text
job = await seeded.get(RecognitionJob, uuid.UUID(response.json()["accepted"][0]["job_id"]))
assert job is not None
assert (job.pipeline, job.max_attempts, job.request_subject) == (
RecognitionPipeline.image,
1,
"recognition.image.request",
)
assert len(storage.objects) == 1
@pytest.mark.integration
async def test_two_files_write_two_upload_events(
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
) -> None:
"""audit-trail «Загрузка двух файлов»: два `FILE_UPLOAD` с `object_id` каждого файла."""
entity = await _rostov_entity(seeded)
user_id = await _user_id(seeded, KA_RF)
client = await auth_client(KA_RF)
first, second = _xls("first"), ZIP + b"second"
response = await _post(client, entity.id, ("ОСВ 60.xls", first), ("ОСВ 62.xlsx", second))
assert response.status_code == HTTP_CREATED, response.text
accepted = response.json()["accepted"]
assert [item["name"] for item in accepted] == ["ОСВ 60.xls", "ОСВ 62.xlsx"]
events = await _upload_events(seeded)
assert len(events) == len(accepted)
by_object = {event.object_id: event for event in events}
for item, content, file_type in ((accepted[0], first, "XLS"), (accepted[1], second, "XLSX")):
event = by_object[uuid.UUID(item["file_id"])]
assert (event.section, event.object_type, event.user_id) == (
AuditSection.PRIMARY_DOCUMENTS,
"stored_file",
user_id,
)
assert event.after_value == {
"original_name": item["name"],
"content_hash": hashlib.sha256(content).hexdigest(),
"job_id": item["job_id"],
"file_type": file_type,
}
assert len(storage.objects) == len(accepted)
# --- «Смесь принятых и отклонённых», «Расширение не совпадает с содержимым» ---------------------------------------
@pytest.mark.integration
async def test_mixed_request_accepts_valid_and_rejects_others(
app: FastAPI, auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
) -> None:
app.dependency_overrides[get_app_settings] = lambda: get_settings().model_copy(
update={"UPLOAD_MAX_BYTES": TINY_LIMIT}
)
entity = await _rostov_entity(seeded)
client = await auth_client(KA_RF)
before = await _counts(seeded)
xlsx = ZIP + b"ok" # 6 байт — в пределах лимита
big = BIFF8 + b"overflow" # больше лимита — как файл 51 МиБ при 50 МиБ
response = await _post(client, entity.id, ("osv.xlsx", xlsx), ("report.docx", ZIP + b"d"), ("big.xls", big))
assert response.status_code == HTTP_CREATED, response.text
body = response.json()
assert [item["name"] for item in body["accepted"]] == ["osv.xlsx"]
assert {item["name"]: item["code"] for item in body["rejected"]} == {
"report.docx": "UNSUPPORTED_FILE",
"big.xls": "FILE_TOO_LARGE",
}
assert all(item["message"] and item["file_id"] is None for item in body["rejected"])
after = await _counts(seeded)
assert {name: after[name] - before[name] for name in after} == {
"s3_object": 1,
"stored_file": 1,
"recognition_job": 1,
"file_upload": 1,
}
(key,) = storage.objects
assert key.endswith("/osv.xlsx")
s3 = (await seeded.execute(select(S3Object).where(S3Object.key == key))).scalar_one()
assert s3.content_type == XLSX_TYPE
@pytest.mark.integration
async def test_signature_mismatch_is_rejected_with_422(
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
) -> None:
entity = await _rostov_entity(seeded)
client = await auth_client(KA_RF)
before = await _counts(seeded)
response = await _post(client, entity.id, ("osv.xlsx", _xls("biff")))
assert response.status_code == HTTP_UNPROCESSABLE, response.text
assert response.json()["accepted"] == []
(rejected,) = response.json()["rejected"]
assert (rejected["name"], rejected["code"]) == ("osv.xlsx", "UNSUPPORTED_FILE")
assert await _counts(seeded) == before
assert storage.objects == {}
@pytest.mark.integration
async def test_empty_filename_is_unsupported(
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
) -> None:
"""Review Focus 4: имя `..` (после очистки — пустое) — `UNSUPPORTED_FILE`, а не файл без имени."""
entity = await _rostov_entity(seeded)
client = await auth_client(KA_RF)
response = await _post(client, entity.id, ("..", _xls("noname")))
assert response.status_code == HTTP_UNPROCESSABLE, response.text
(rejected,) = response.json()["rejected"]
assert (rejected["name"], rejected["code"]) == ("", "UNSUPPORTED_FILE")
assert storage.objects == {}
# --- «Чужое ЮЛ», права, S3 --------------------------------------------------------------------------------------
@pytest.mark.integration
async def test_foreign_entity_is_404_like_missing(
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
) -> None:
entity = await _rostov_entity(seeded)
client = await auth_client(CLIENT_MANAGER)
before = await _counts(seeded)
foreign = await _post(client, entity.id, ("osv.xls", _xls("foreign")))
missing = await _post(client, uuid.uuid4(), ("osv.xls", _xls("missing")))
assert foreign.status_code == missing.status_code == HTTP_NOT_FOUND, foreign.text
assert foreign.json() == missing.json()
assert foreign.json()["detail"]["code"] == "NOT_FOUND"
assert await _counts(seeded) == before
assert storage.objects == {}
@pytest.mark.integration
async def test_upload_requires_document_edit(
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
) -> None:
entity = await _rostov_entity(seeded)
client = await auth_client(CURATOR_GO)
response = await _post(client, entity.id, ("osv.xls", _xls("curator")))
assert response.status_code == HTTP_FORBIDDEN, response.text
assert "DOCUMENT_EDIT" in response.json()["detail"]
assert storage.objects == {}
@pytest.mark.integration
async def test_without_storage_is_503(auth_client: AuthClientFactory, seeded: AsyncSession) -> None:
entity = await _rostov_entity(seeded) # `app.state.storage` фикстуры `app` — None
client = await auth_client(KA_RF)
before = await _counts(seeded)
response = await _post(client, entity.id, ("osv.xls", _xls("nostorage")))
assert response.status_code == HTTP_UNAVAILABLE, response.text
assert response.json()["detail"]["code"] == "STORAGE_UNAVAILABLE"
assert await _counts(seeded) == before
@pytest.mark.integration
async def test_storage_failure_rejects_only_that_file(
app: FastAPI, auth_client: AuthClientFactory, seeded: AsyncSession
) -> None:
"""Отказ S3 на одном файле откатывает только его SAVEPOINT: принятый файл и его записи остаются."""
storage = FlakyStorage(failing_suffix="/broken.xls")
app.state.storage = storage
entity = await _rostov_entity(seeded)
client = await auth_client(KA_RF)
before = await _counts(seeded)
response = await _post(client, entity.id, ("good.xls", _xls("good")), ("broken.xls", _xls("broken")))
assert response.status_code == HTTP_CREATED, response.text
body = response.json()
assert [item["name"] for item in body["accepted"]] == ["good.xls"]
(rejected,) = body["rejected"]
assert (rejected["name"], rejected["code"]) == ("broken.xls", "STORAGE_UNAVAILABLE")
after = await _counts(seeded)
assert {name: after[name] - before[name] for name in after} == {
"s3_object": 1,
"stored_file": 1,
"recognition_job": 1,
"file_upload": 1,
}
assert [key.rsplit("/", 1)[1] for key in storage.objects] == ["good.xls"]
# --- «Дедуп по содержимому» --------------------------------------------------------------------------------------
@pytest.mark.integration
async def test_duplicate_in_same_entity_is_rejected_other_entity_accepts(
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
) -> None:
entity = await _rostov_entity(seeded)
other = await _rostov_entity(seeded, "ООО Другое")
client = await auth_client(KA_RF)
content = _xls("dup")
first = await _post(client, entity.id, ("ОСВ 60.xls", content))
assert first.status_code == HTTP_CREATED, first.text
first_id = first.json()["accepted"][0]["file_id"]
before = await _counts(seeded)
again = await _post(client, entity.id, ("ОСВ 60 копия.xls", content))
assert again.status_code == HTTP_UNPROCESSABLE, again.text
(rejected,) = again.json()["rejected"]
assert (rejected["code"], rejected["file_id"], rejected["name"]) == ("DUPLICATE_FILE", first_id, "ОСВ 60 копия.xls")
assert await _counts(seeded) == before
assert len(storage.objects) == 1
elsewhere = await _post(client, other.id, ("ОСВ 60.xls", content))
assert elsewhere.status_code == HTTP_CREATED, elsewhere.text
@pytest.mark.integration
async def test_superseded_file_does_not_block_same_content(
auth_client: AuthClientFactory, seeded: AsyncSession, storage: FakeStorage
) -> None:
entity = await _rostov_entity(seeded)
client = await auth_client(KA_RF)
content = _xls("superseded")
first = await _post(client, entity.id, ("ОСВ 60.xls", content))
first_id = uuid.UUID(first.json()["accepted"][0]["file_id"])
await seeded.execute(update(StoredFile).where(StoredFile.id == first_id).values(status=FileStatus.SUPERSEDED))
again = await _post(client, entity.id, ("ОСВ 60.xls", content))
assert again.status_code == HTTP_CREATED, again.text
assert again.json()["accepted"][0]["file_id"] != str(first_id)
assert len(storage.objects) == 2 # noqa: PLR2004 - старый и новый объект
@pytest.mark.integration
async def test_concurrent_duplicates_accept_only_one(
committed_client: AuthClientFactory,
committed_sessionmaker: async_sessionmaker[AsyncSession],
committed_storage: FakeStorage,
) -> None:
"""Двойной клик: два запроса с одним содержимым одновременно — принят один, второй `DUPLICATE_FILE`."""
async with committed_sessionmaker() as session, session.begin():
entity = await _rostov_entity(session)
first_client, second_client = await committed_client(KA_RF), await committed_client(KA_RF)
content = _xls("race")
responses = await asyncio.gather(
_post(first_client, entity.id, ("ОСВ 60.xls", content)),
_post(second_client, entity.id, ("ОСВ 60.xls", content)),
)
assert sorted(response.status_code for response in responses) == [HTTP_CREATED, HTTP_UNPROCESSABLE]
accepted = next(r for r in responses if r.status_code == HTTP_CREATED).json()["accepted"][0]
rejected = next(r for r in responses if r.status_code == HTTP_UNPROCESSABLE).json()["rejected"][0]
assert (rejected["code"], rejected["file_id"]) == ("DUPLICATE_FILE", accepted["file_id"])
async with committed_sessionmaker() as session:
files = (await session.execute(select(func.count()).select_from(StoredFile))).scalar_one()
assert files == 1
assert len(committed_storage.objects) == 1