Make the governed RAG evidence path executable end to end
Some checks failed
verify / verify (push) Has been cancelled
Some checks failed
verify / verify (push) Has been cancelled
Separate local parsing from model indexing, bind review decisions to immutable manifests, persist vectors behind active profiles, and expose retrieval, chat, evaluation, and document workflows through the React workbench. Constraint: Live Bailian authentication currently fails for all three configured capabilities Rejected: Direct upload-to-embedding flow | bypasses local review and manifest binding Confidence: high Scope-risk: broad Directive: Keep private-data deployment blocked until authentication, RBAC, and separate database roles land Tested: make verify; fresh and replay Docker document smoke; worker recovery smoke; frozen synthetic evaluation; migration 0003-0004 roundtrip Not-tested: Successful live Bailian calls, OCR, real multi-user authorization
This commit is contained in:
395
backend/tests/unit/test_documents_api.py
Normal file
395
backend/tests/unit/test_documents_api.py
Normal file
@@ -0,0 +1,395 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from collections.abc import AsyncIterable
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
|
||||
from app.adapters.local_storage import (
|
||||
LocalStorageError,
|
||||
StorageErrorCode,
|
||||
StoredUpload,
|
||||
)
|
||||
from app.api.v1.documents import (
|
||||
get_document_actor,
|
||||
get_documents_repository,
|
||||
get_upload_storage,
|
||||
router,
|
||||
)
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.demo_identity import (
|
||||
ACCESS_SCOPE_ID,
|
||||
BAILIAN_ACCESS_SCOPE_ID,
|
||||
BAILIAN_KNOWLEDGE_BASE_ID,
|
||||
KNOWLEDGE_BASE_ID,
|
||||
)
|
||||
from app.core.problems import (
|
||||
ApiProblem,
|
||||
api_problem_handler,
|
||||
request_validation_problem_handler,
|
||||
)
|
||||
from app.core.request_context import trace_request
|
||||
from app.persistence.documents import (
|
||||
CompletedUpload,
|
||||
DocumentActor,
|
||||
DocumentDetail,
|
||||
DocumentListPage,
|
||||
DocumentSummary,
|
||||
DocumentUpload,
|
||||
ReviewBundle,
|
||||
SafeJob,
|
||||
)
|
||||
|
||||
NOW = datetime(2026, 7, 13, 8, 0, tzinfo=UTC)
|
||||
TRACE_ID = "10000000-0000-0000-0000-000000000001"
|
||||
UPLOAD_ID = uuid.UUID("20000000-0000-0000-0000-000000000002")
|
||||
STORAGE_KEY = uuid.UUID("30000000-0000-0000-0000-000000000003")
|
||||
DOCUMENT_ID = uuid.UUID("40000000-0000-0000-0000-000000000004")
|
||||
JOB_ID = uuid.UUID("50000000-0000-0000-0000-000000000005")
|
||||
IDEMPOTENCY_KEY = "60000000-0000-0000-0000-000000000006"
|
||||
CONTENT = b"# Synthetic\n\nA governed geological document."
|
||||
CONTENT_SHA = hashlib.sha256(CONTENT).hexdigest()
|
||||
|
||||
|
||||
def _upload(status: str = "CREATED") -> DocumentUpload:
|
||||
completed = status == "COMPLETED"
|
||||
stored = status in {"STORED", "COMPLETED"}
|
||||
return DocumentUpload(
|
||||
id=UPLOAD_ID,
|
||||
filename="synthetic.md",
|
||||
declared_mime_type="text/markdown",
|
||||
expected_size=len(CONTENT),
|
||||
expected_sha256=CONTENT_SHA,
|
||||
storage_key=STORAGE_KEY,
|
||||
actual_size=len(CONTENT) if stored else None,
|
||||
actual_sha256=CONTENT_SHA if stored else None,
|
||||
status=status,
|
||||
document_id=DOCUMENT_ID if completed else None,
|
||||
parse_job_id=JOB_ID if completed else None,
|
||||
created_at=NOW,
|
||||
updated_at=NOW,
|
||||
completed_at=NOW if completed else None,
|
||||
)
|
||||
|
||||
|
||||
def _document() -> DocumentSummary:
|
||||
return DocumentSummary(
|
||||
id=DOCUMENT_ID,
|
||||
filename="synthetic.md",
|
||||
mime_type="text/markdown",
|
||||
raw_sha256=CONTENT_SHA,
|
||||
status="QUARANTINED_LOCAL_REVIEW",
|
||||
active_version_id=None,
|
||||
created_at=NOW,
|
||||
updated_at=NOW,
|
||||
)
|
||||
|
||||
|
||||
def _job() -> SafeJob:
|
||||
return SafeJob(
|
||||
id=JOB_ID,
|
||||
job_type="PARSE_DOCUMENT",
|
||||
stage="PENDING",
|
||||
status="QUEUED",
|
||||
progress=0,
|
||||
attempt=0,
|
||||
max_attempts=3,
|
||||
last_error_code=None,
|
||||
created_at=NOW,
|
||||
updated_at=NOW,
|
||||
finished_at=None,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StubRepository:
|
||||
upload: DocumentUpload = field(default_factory=_upload)
|
||||
created: bool = True
|
||||
create_calls: list[dict[str, object]] = field(default_factory=list)
|
||||
mark_calls: list[dict[str, object]] = field(default_factory=list)
|
||||
|
||||
def create_upload(self, **kwargs: object) -> tuple[DocumentUpload, bool]:
|
||||
self.create_calls.append(kwargs)
|
||||
return self.upload, self.created
|
||||
|
||||
def get_upload(self, actor: DocumentActor, upload_id: uuid.UUID) -> DocumentUpload | None:
|
||||
assert actor.knowledge_base_id == KNOWLEDGE_BASE_ID
|
||||
assert actor.access_scope_id == ACCESS_SCOPE_ID
|
||||
return self.upload if upload_id == UPLOAD_ID else None
|
||||
|
||||
def mark_upload_stored(self, **kwargs: object) -> DocumentUpload:
|
||||
self.mark_calls.append(kwargs)
|
||||
self.upload = replace(
|
||||
self.upload,
|
||||
status="STORED",
|
||||
actual_size=len(CONTENT),
|
||||
actual_sha256=CONTENT_SHA,
|
||||
)
|
||||
return self.upload
|
||||
|
||||
def complete_upload(self, **kwargs: object) -> CompletedUpload:
|
||||
self.upload = _upload("COMPLETED")
|
||||
return CompletedUpload(self.upload, _document(), _job())
|
||||
|
||||
def get_job(self, actor: DocumentActor, job_id: uuid.UUID) -> SafeJob | None:
|
||||
assert actor.access_scope_id == ACCESS_SCOPE_ID
|
||||
return _job() if job_id == JOB_ID else None
|
||||
|
||||
def list_documents(
|
||||
self, actor: DocumentActor, *, cursor: uuid.UUID | None, limit: int
|
||||
) -> DocumentListPage:
|
||||
assert actor.access_scope_id == ACCESS_SCOPE_ID
|
||||
assert cursor is None and limit == 20
|
||||
return DocumentListPage((_document(),), None)
|
||||
|
||||
def get_document(self, actor: DocumentActor, document_id: uuid.UUID) -> DocumentDetail | None:
|
||||
assert actor.access_scope_id == ACCESS_SCOPE_ID
|
||||
if document_id != DOCUMENT_ID:
|
||||
return None
|
||||
return DocumentDetail(_document(), 0, 0, 0, 0)
|
||||
|
||||
def get_review_bundle(
|
||||
self,
|
||||
actor: DocumentActor,
|
||||
document_id: uuid.UUID,
|
||||
*,
|
||||
after_ordinal: int,
|
||||
limit: int,
|
||||
) -> ReviewBundle | None:
|
||||
assert actor.access_scope_id == ACCESS_SCOPE_ID
|
||||
assert after_ordinal == -1 and limit == 50
|
||||
if document_id != DOCUMENT_ID:
|
||||
return None
|
||||
return ReviewBundle(_document(), None, (), (), (), None)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StubStorage:
|
||||
error: StorageErrorCode | None = None
|
||||
received: bytes = b""
|
||||
|
||||
async def store(
|
||||
self,
|
||||
*,
|
||||
storage_key: uuid.UUID,
|
||||
chunks: AsyncIterable[bytes],
|
||||
expected_size: int,
|
||||
expected_sha256: str,
|
||||
) -> StoredUpload:
|
||||
assert storage_key == STORAGE_KEY
|
||||
value = bytearray()
|
||||
async for chunk in chunks:
|
||||
value.extend(chunk)
|
||||
self.received = bytes(value)
|
||||
if self.error is not None:
|
||||
raise LocalStorageError(self.error)
|
||||
assert expected_size == len(CONTENT)
|
||||
assert expected_sha256 == CONTENT_SHA
|
||||
return StoredUpload(STORAGE_KEY, len(CONTENT), CONTENT_SHA)
|
||||
|
||||
|
||||
def _app(repository: StubRepository, storage: StubStorage, tmp_path: Path) -> FastAPI:
|
||||
app = FastAPI()
|
||||
app.middleware("http")(trace_request)
|
||||
app.add_exception_handler(ApiProblem, api_problem_handler) # type: ignore[arg-type]
|
||||
app.add_exception_handler(
|
||||
RequestValidationError,
|
||||
request_validation_problem_handler, # type: ignore[arg-type]
|
||||
)
|
||||
app.include_router(router)
|
||||
app.dependency_overrides[get_documents_repository] = lambda: repository
|
||||
app.dependency_overrides[get_upload_storage] = lambda: storage
|
||||
app.dependency_overrides[get_settings] = lambda: Settings(
|
||||
upload_root=tmp_path / "uploads",
|
||||
max_upload_mb=1,
|
||||
)
|
||||
return app
|
||||
|
||||
|
||||
def _declaration() -> dict[str, object]:
|
||||
return {
|
||||
"filename": "synthetic.md",
|
||||
"declared_mime_type": "text/markdown",
|
||||
"expected_size": len(CONTENT),
|
||||
"expected_sha256": CONTENT_SHA,
|
||||
}
|
||||
|
||||
|
||||
def test_document_namespace_is_server_configured() -> None:
|
||||
offline_actor = get_document_actor(Settings(document_namespace_mode="fake"))
|
||||
bailian_actor = get_document_actor(Settings(document_namespace_mode="bailian"))
|
||||
|
||||
assert offline_actor.knowledge_base_id == KNOWLEDGE_BASE_ID
|
||||
assert offline_actor.access_scope_id == ACCESS_SCOPE_ID
|
||||
assert bailian_actor.knowledge_base_id == BAILIAN_KNOWLEDGE_BASE_ID
|
||||
assert bailian_actor.access_scope_id == BAILIAN_ACCESS_SCOPE_ID
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_is_idempotent_and_scope_is_server_owned(tmp_path: Path) -> None:
|
||||
repository = StubRepository(created=False)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=_app(repository, StubStorage(), tmp_path)),
|
||||
base_url="http://test",
|
||||
) as client:
|
||||
response = await client.post(
|
||||
"/api/v1/document-uploads",
|
||||
headers={"Idempotency-Key": IDEMPOTENCY_KEY, "x-request-id": TRACE_ID},
|
||||
json=_declaration(),
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
assert response.json()["id"] == str(UPLOAD_ID)
|
||||
assert response.json()["replayed"] is True
|
||||
call = repository.create_calls[0]
|
||||
actor = call["actor"]
|
||||
assert isinstance(actor, DocumentActor)
|
||||
assert actor.knowledge_base_id == KNOWLEDGE_BASE_ID
|
||||
assert actor.access_scope_id == ACCESS_SCOPE_ID
|
||||
assert len(str(call["idempotency_key_hash"])) == 64
|
||||
assert IDEMPOTENCY_KEY not in str(call["idempotency_key_hash"])
|
||||
assert "storage_key" not in response.text
|
||||
assert "access_scope" not in response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("field", ["access_scope_id", "knowledge_base_id", "storage_key"])
|
||||
async def test_client_cannot_select_scope_or_storage_fields(field: str, tmp_path: Path) -> None:
|
||||
repository = StubRepository()
|
||||
body = _declaration() | {field: str(uuid.uuid4())}
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=_app(repository, StubStorage(), tmp_path)),
|
||||
base_url="http://test",
|
||||
) as client:
|
||||
response = await client.post(
|
||||
"/api/v1/document-uploads",
|
||||
headers={"Idempotency-Key": IDEMPOTENCY_KEY},
|
||||
json=body,
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.headers["content-type"].startswith("application/problem+json")
|
||||
assert response.json()["code"] == "REQUEST_VALIDATION_FAILED"
|
||||
assert "input" not in response.text
|
||||
assert repository.create_calls == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invalid_idempotency_key_is_problem_json_without_echo(tmp_path: Path) -> None:
|
||||
repository = StubRepository()
|
||||
secret = "sk-" + "A" * 24
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=_app(repository, StubStorage(), tmp_path)),
|
||||
base_url="http://test",
|
||||
) as client:
|
||||
response = await client.post(
|
||||
"/api/v1/document-uploads",
|
||||
headers={"Idempotency-Key": secret},
|
||||
json=_declaration(),
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.headers["content-type"].startswith("application/problem+json")
|
||||
assert response.json()["code"] == "IDEMPOTENCY_KEY_INVALID"
|
||||
assert secret not in response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_content_stream_is_stored_then_short_transaction_marks_it(tmp_path: Path) -> None:
|
||||
repository = StubRepository()
|
||||
storage = StubStorage()
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=_app(repository, storage, tmp_path)),
|
||||
base_url="http://test",
|
||||
) as client:
|
||||
response = await client.put(
|
||||
f"/api/v1/document-uploads/{UPLOAD_ID}/content",
|
||||
headers={"Content-Type": "application/octet-stream", "x-request-id": TRACE_ID},
|
||||
content=CONTENT,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "STORED"
|
||||
assert storage.received == CONTENT
|
||||
assert repository.mark_calls[0]["actual_sha256"] == CONTENT_SHA
|
||||
assert repository.mark_calls[0]["actual_size"] == len(CONTENT)
|
||||
assert "storage_key" not in response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hash_failure_is_sanitized_and_does_not_mark_stored(tmp_path: Path) -> None:
|
||||
repository = StubRepository()
|
||||
storage = StubStorage(error=StorageErrorCode.HASH_MISMATCH)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=_app(repository, storage, tmp_path)),
|
||||
base_url="http://test",
|
||||
) as client:
|
||||
response = await client.put(
|
||||
f"/api/v1/document-uploads/{UPLOAD_ID}/content",
|
||||
headers={"Content-Type": "application/octet-stream"},
|
||||
content=CONTENT,
|
||||
)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert response.json()["code"] == "UPLOAD_HASH_MISMATCH"
|
||||
assert repository.mark_calls == []
|
||||
assert CONTENT.decode() not in response.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_enqueues_parse_job_and_public_status_is_safe(tmp_path: Path) -> None:
|
||||
repository = StubRepository(upload=_upload("STORED"))
|
||||
app = _app(repository, StubStorage(), tmp_path)
|
||||
async with httpx.AsyncClient(
|
||||
transport=httpx.ASGITransport(app=app), base_url="http://test"
|
||||
) as client:
|
||||
completed = await client.post(f"/api/v1/document-uploads/{UPLOAD_ID}/complete")
|
||||
job = await client.get(f"/api/v1/document-jobs/{JOB_ID}")
|
||||
documents = await client.get("/api/v1/documents")
|
||||
detail = await client.get(f"/api/v1/documents/{DOCUMENT_ID}")
|
||||
bundle = await client.get(f"/api/v1/documents/{DOCUMENT_ID}/review-bundle")
|
||||
|
||||
assert completed.status_code == 202
|
||||
assert completed.json()["job"]["job_type"] == "PARSE_DOCUMENT"
|
||||
assert completed.json()["job"]["status"] == "QUEUED"
|
||||
assert job.status_code == 200
|
||||
assert documents.json()["items"][0]["id"] == str(DOCUMENT_ID)
|
||||
assert detail.json()["version_count"] == 0
|
||||
assert bundle.json()["version"] is None
|
||||
for response in (completed, job, documents, detail, bundle):
|
||||
assert "lease_token" not in response.text
|
||||
assert "lease_owner" not in response.text
|
||||
assert "storage_key" not in response.text
|
||||
assert "/data/uploads" not in response.text
|
||||
|
||||
|
||||
def test_openapi_has_stable_operations_and_binary_content_contract(tmp_path: Path) -> None:
|
||||
schema = _app(StubRepository(), StubStorage(), tmp_path).openapi()
|
||||
|
||||
assert schema["paths"]["/api/v1/document-uploads"]["post"]["operationId"] == (
|
||||
"createDocumentUpload"
|
||||
)
|
||||
assert (
|
||||
schema["paths"]["/api/v1/document-uploads/{upload_id}/content"]["put"]["operationId"]
|
||||
== "storeDocumentUploadContent"
|
||||
)
|
||||
assert (
|
||||
"application/octet-stream"
|
||||
in schema["paths"]["/api/v1/document-uploads/{upload_id}/content"]["put"]["requestBody"][
|
||||
"content"
|
||||
]
|
||||
)
|
||||
assert (
|
||||
schema["paths"]["/api/v1/document-uploads/{upload_id}/complete"]["post"]["operationId"]
|
||||
== "completeDocumentUpload"
|
||||
)
|
||||
request_schema = schema["components"]["schemas"]["CreateDocumentUploadRequest"]
|
||||
assert request_schema["additionalProperties"] is False
|
||||
assert "access_scope_id" not in request_schema["properties"]
|
||||
Reference in New Issue
Block a user