c8a0443ad2
- test_documents.py: append 4 stubs (content_stream 200, 206, admin_403, no_presigned_url)
- test_audit.py: create new file with 4 stubs (viewer, no_doc_content, user_403, export_csv)
- test_security.py: create new file with 2 stubs (credentials_enc_not_in_response, delete_user_cleans_files)
- All stubs: xfail(strict=False), body is pytest.xfail("not implemented yet")
368 lines
14 KiB
Python
368 lines
14 KiB
Python
"""
|
|
Document API tests — async only (Plan 05 cutover).
|
|
|
|
Legacy sync tests (using the flat-file storage layer) were deleted in Plan 05.
|
|
All tests here use async_client (httpx.AsyncClient + ASGITransport + in-memory SQLite).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.mark.xfail(strict=False, reason="POST /api/documents/upload removed in Plan 03-02 — replaced by upload-url + confirm flow")
|
|
async def test_upload_txt_no_classify(async_client, sample_txt):
|
|
"""Legacy multipart upload test — endpoint removed in Plan 03-02 (D-04).
|
|
|
|
Replaced by test_upload_url_endpoint + test_confirm_endpoint.
|
|
"""
|
|
with open(sample_txt, "rb") as f:
|
|
resp = await async_client.post(
|
|
"/api/documents/upload",
|
|
files={"file": ("sample.txt", f, "text/plain")},
|
|
data={"auto_classify": "false"},
|
|
)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["original_name"] == "sample.txt"
|
|
|
|
|
|
@pytest.mark.xfail(strict=False, reason="POST /api/documents/upload removed in Plan 03-02 — replaced by upload-url + confirm flow")
|
|
async def test_upload_pdf_no_classify(async_client, sample_pdf):
|
|
"""Legacy multipart upload test — endpoint removed in Plan 03-02 (D-04)."""
|
|
with open(sample_pdf, "rb") as f:
|
|
resp = await async_client.post(
|
|
"/api/documents/upload",
|
|
files={"file": ("sample.pdf", f, "application/pdf")},
|
|
data={"auto_classify": "false"},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
|
|
async def test_list_documents(async_client, auth_user):
|
|
"""GET /api/documents returns an empty list when no documents exist."""
|
|
resp = await async_client.get("/api/documents", headers=auth_user["headers"])
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["total"] == 0
|
|
assert data["items"] == []
|
|
|
|
|
|
async def test_list_documents_filter_by_topic(async_client, auth_user, db_session):
|
|
"""GET /api/documents?topic=finance returns only matching documents."""
|
|
import uuid as _uuid
|
|
from db.models import Document
|
|
from services import storage
|
|
|
|
# Create a document directly via ORM (bypasses the upload endpoint)
|
|
doc_id = _uuid.uuid4()
|
|
doc = Document(
|
|
id=doc_id,
|
|
user_id=auth_user["user"].id,
|
|
filename="test.txt",
|
|
content_type="text/plain",
|
|
size_bytes=100,
|
|
storage_backend="minio",
|
|
status="uploaded",
|
|
object_key=f"{auth_user['user'].id}/{doc_id}/{_uuid.uuid4()}.txt",
|
|
)
|
|
db_session.add(doc)
|
|
await db_session.commit()
|
|
|
|
await storage.update_document_topics(db_session, str(doc_id), ["finance"])
|
|
|
|
resp = await async_client.get("/api/documents?topic=finance", headers=auth_user["headers"])
|
|
assert resp.json()["total"] == 1
|
|
|
|
resp2 = await async_client.get("/api/documents?topic=legal", headers=auth_user["headers"])
|
|
assert resp2.json()["total"] == 0
|
|
|
|
|
|
async def test_get_document(async_client, auth_user, db_session):
|
|
"""GET /api/documents/{id} returns metadata for an existing document."""
|
|
import uuid as _uuid
|
|
from db.models import Document
|
|
|
|
doc_id = _uuid.uuid4()
|
|
doc = Document(
|
|
id=doc_id,
|
|
user_id=auth_user["user"].id,
|
|
filename="test.txt",
|
|
content_type="text/plain",
|
|
size_bytes=100,
|
|
storage_backend="minio",
|
|
status="uploaded",
|
|
object_key=f"{auth_user['user'].id}/{doc_id}/{_uuid.uuid4()}.txt",
|
|
)
|
|
db_session.add(doc)
|
|
await db_session.commit()
|
|
|
|
resp = await async_client.get(f"/api/documents/{doc_id}", headers=auth_user["headers"])
|
|
assert resp.status_code == 200
|
|
assert resp.json()["id"] == str(doc_id)
|
|
|
|
|
|
async def test_get_document_not_found(async_client, auth_user):
|
|
resp = await async_client.get("/api/documents/nonexistent", headers=auth_user["headers"])
|
|
assert resp.status_code == 404
|
|
|
|
|
|
async def test_delete_document(async_client, auth_user, db_session, monkeypatch):
|
|
"""DELETE /api/documents/{id} removes the document."""
|
|
import uuid as _uuid
|
|
from db.models import Document
|
|
from unittest.mock import AsyncMock
|
|
|
|
# Mock MinIO delete so we don't need a live MinIO
|
|
monkeypatch.setattr("services.storage._backend", lambda: type("B", (), {"delete_object": AsyncMock()})())
|
|
|
|
doc_id = _uuid.uuid4()
|
|
doc = Document(
|
|
id=doc_id,
|
|
user_id=auth_user["user"].id,
|
|
filename="test.txt",
|
|
content_type="text/plain",
|
|
size_bytes=0,
|
|
storage_backend="minio",
|
|
status="uploaded",
|
|
object_key=f"{auth_user['user'].id}/{doc_id}/{_uuid.uuid4()}.txt",
|
|
)
|
|
db_session.add(doc)
|
|
await db_session.commit()
|
|
|
|
resp = await async_client.delete(f"/api/documents/{doc_id}", headers=auth_user["headers"])
|
|
assert resp.status_code == 200
|
|
assert resp.json()["success"] is True
|
|
|
|
resp2 = await async_client.get(f"/api/documents/{doc_id}", headers=auth_user["headers"])
|
|
assert resp2.status_code == 404
|
|
|
|
|
|
async def test_delete_document_not_found(async_client, auth_user):
|
|
resp = await async_client.delete("/api/documents/nonexistent", headers=auth_user["headers"])
|
|
assert resp.status_code == 404
|
|
|
|
|
|
@pytest.mark.xfail(strict=False, reason="POST /api/documents/upload removed in Plan 03-02 — replaced by upload-url + confirm flow")
|
|
async def test_upload_empty_file(async_client):
|
|
"""Legacy empty file test — endpoint removed in Plan 03-02 (D-04)."""
|
|
resp = await async_client.post(
|
|
"/api/documents/upload",
|
|
files={"file": ("empty.txt", b"", "text/plain")},
|
|
data={"auto_classify": "false"},
|
|
)
|
|
assert resp.status_code == 400
|
|
|
|
|
|
@pytest.mark.xfail(strict=False, reason="POST /api/documents/upload removed in Plan 03-02 — replaced by upload-url + confirm flow")
|
|
async def test_upload_persists_to_postgres_and_minio(async_client, sample_txt):
|
|
"""Legacy upload+persist test — endpoint removed in Plan 03-02 (D-04).
|
|
|
|
Replaced by the upload-url + confirm flow tested in test_upload_url_endpoint
|
|
and test_confirm_endpoint.
|
|
"""
|
|
with open(sample_txt, "rb") as f:
|
|
resp = await async_client.post(
|
|
"/api/documents/upload",
|
|
files={"file": ("sample.txt", f, "text/plain")},
|
|
data={"auto_classify": "false"},
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Wave 0 xfail stubs for Phase 3 document endpoint tests — Plans 03-02 / 03-03
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def test_upload_url_endpoint(async_client, auth_user, mock_minio_presigned):
|
|
"""POST /api/documents/upload-url returns {upload_url, document_id} and creates
|
|
a Document row with status='pending'.
|
|
|
|
D-05: two-step upload flow — step 1 creates the pending Document row and
|
|
returns the presigned PUT URL (15-min TTL). Quota is NOT reserved here.
|
|
"""
|
|
resp = await async_client.post(
|
|
"/api/documents/upload-url",
|
|
json={"filename": "report.pdf", "content_type": "application/pdf"},
|
|
headers=auth_user["headers"],
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
data = resp.json()
|
|
assert "upload_url" in data, f"Missing upload_url: {data}"
|
|
assert "document_id" in data, f"Missing document_id: {data}"
|
|
assert "presigned" in data["upload_url"] or "localhost" in data["upload_url"], (
|
|
f"Expected a presigned URL: {data['upload_url']}"
|
|
)
|
|
# Verify mock was called
|
|
assert mock_minio_presigned.called, "generate_presigned_put_url was not called"
|
|
|
|
|
|
@pytest.mark.xfail(strict=False, reason="SQLite UUID format mismatch in raw SQL quota UPDATE — xpass on PostgreSQL (INTEGRATION=1)")
|
|
async def test_confirm_endpoint(
|
|
async_client, auth_user, mock_minio_presigned, mock_minio_stat, monkeypatch
|
|
):
|
|
"""POST /api/documents/{id}/confirm calls stat_object once, updates Document.size_bytes
|
|
from the stat return value, sets Document.status='uploaded', and runs atomic quota.
|
|
|
|
D-05: step 3 of the presigned upload flow. stat_object provides the authoritative
|
|
file size (D-07). The atomic quota UPDATE runs unconditionally here (STORE-03, Plan 03-03+).
|
|
|
|
SQLite note: The raw SQL quota UPDATE uses :uid in dashed UUID format, which does not
|
|
match SQLite's CHAR(32) undashed storage. This test xfails on SQLite and xpasses on
|
|
PostgreSQL (run with INTEGRATION=1). Same as test_quota.py pattern.
|
|
"""
|
|
from unittest.mock import MagicMock
|
|
|
|
# Patch out the Celery delay call — no Redis in unit test environment
|
|
mock_delay = MagicMock()
|
|
monkeypatch.setattr("api.documents.extract_and_classify.delay", mock_delay)
|
|
|
|
mock_minio_stat.return_value = 2048
|
|
|
|
# Step 1: get upload URL
|
|
resp = await async_client.post(
|
|
"/api/documents/upload-url",
|
|
json={"filename": "doc.txt", "content_type": "text/plain"},
|
|
headers=auth_user["headers"],
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
doc_id = resp.json()["document_id"]
|
|
|
|
# Step 2: confirm — quota runs unconditionally (Plan 03-03+, no Wave 2 guard)
|
|
conf_resp = await async_client.post(
|
|
f"/api/documents/{doc_id}/confirm",
|
|
headers=auth_user["headers"],
|
|
)
|
|
assert conf_resp.status_code == 200, conf_resp.text
|
|
conf_data = conf_resp.json()
|
|
assert conf_data["id"] == doc_id
|
|
assert conf_data["size_bytes"] == 2048
|
|
assert conf_data["status"] == "uploaded"
|
|
# stat_object was called once
|
|
assert mock_minio_stat.called, "stat_object was not called"
|
|
# Celery task was dispatched
|
|
assert mock_delay.called, "extract_and_classify.delay was not called"
|
|
|
|
|
|
async def test_get_quota(async_client, auth_user):
|
|
"""GET /api/auth/me/quota returns {used_bytes: 0, limit_bytes: 104857600}.
|
|
|
|
STORE-04: quota usage bar endpoint. Returns current usage and limit for the
|
|
authenticated user. Newly created users start at used_bytes=0.
|
|
"""
|
|
resp = await async_client.get(
|
|
"/api/auth/me/quota",
|
|
headers=auth_user["headers"],
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
data = resp.json()
|
|
assert "used_bytes" in data, f"Missing used_bytes: {data}"
|
|
assert "limit_bytes" in data, f"Missing limit_bytes: {data}"
|
|
assert data["used_bytes"] == 0, f"Expected 0 used_bytes for new user: {data}"
|
|
assert data["limit_bytes"] == 104_857_600, f"Expected 100 MB limit: {data}"
|
|
|
|
|
|
async def test_cross_user_access_404(async_client, auth_user, db_session):
|
|
"""User B's request for GET /api/documents/{A_doc_id} returns 404.
|
|
|
|
SEC-04: cross-user access returns 404 (not 403) to avoid information leakage
|
|
(CONTEXT.md D-16). An attacker cannot distinguish between 'document does not
|
|
exist' and 'document belongs to someone else'.
|
|
"""
|
|
import uuid as _uuid
|
|
from db.models import Document, User, Quota
|
|
from services.auth import hash_password, create_access_token
|
|
|
|
# Create User A's document directly via ORM
|
|
doc_id = _uuid.uuid4()
|
|
doc = Document(
|
|
id=doc_id,
|
|
user_id=auth_user["user"].id,
|
|
filename="user_a_doc.txt",
|
|
content_type="text/plain",
|
|
size_bytes=100,
|
|
storage_backend="minio",
|
|
status="uploaded",
|
|
object_key=f"{auth_user['user'].id}/{doc_id}/{_uuid.uuid4()}.txt",
|
|
)
|
|
db_session.add(doc)
|
|
|
|
# Create User B
|
|
user_b_id = _uuid.uuid4()
|
|
user_b = User(
|
|
id=user_b_id,
|
|
handle=f"user_b_{user_b_id.hex[:8]}",
|
|
email=f"user_b_{user_b_id.hex[:8]}@example.com",
|
|
password_hash=hash_password("Testpassword123!"),
|
|
role="user",
|
|
is_active=True,
|
|
password_must_change=False,
|
|
)
|
|
quota_b = Quota(user_id=user_b_id, limit_bytes=104857600, used_bytes=0)
|
|
db_session.add(user_b)
|
|
db_session.add(quota_b)
|
|
await db_session.commit()
|
|
|
|
token_b = create_access_token(str(user_b_id), "user")
|
|
headers_b = {"Authorization": f"Bearer {token_b}"}
|
|
|
|
# User B attempts to access User A's document — must get 404 (not 403)
|
|
resp = await async_client.get(f"/api/documents/{doc_id}", headers=headers_b)
|
|
assert resp.status_code == 404, (
|
|
f"Expected 404 for cross-user access, got {resp.status_code}: {resp.text}"
|
|
)
|
|
|
|
|
|
async def test_admin_cannot_access_documents(async_client, admin_user):
|
|
"""GET /api/documents using admin_user.headers returns 403.
|
|
|
|
SEC-04 SC4: admin accounts cannot access document content (CLAUDE.md +
|
|
CONTEXT.md D-16). The get_regular_user dependency enforces this for all
|
|
/api/documents/* handlers.
|
|
"""
|
|
resp = await async_client.get("/api/documents", headers=admin_user["headers"])
|
|
assert resp.status_code == 403, (
|
|
f"Expected 403 for admin on document endpoints, got {resp.status_code}: {resp.text}"
|
|
)
|
|
|
|
|
|
async def test_documents_require_auth(async_client):
|
|
"""Anonymous GET /api/documents (no Authorization header) returns 401 or 403.
|
|
|
|
D-16: all /api/documents/* endpoints require authentication via
|
|
get_current_user (Phase 2 D-07 fulfilled in Phase 3).
|
|
"""
|
|
resp = await async_client.get("/api/documents")
|
|
assert resp.status_code in (401, 403), f"Expected 401 or 403, got {resp.status_code}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Wave 0 xfail stubs for Phase 4 DOC-02 proxy / content-stream tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.xfail(strict=False)
|
|
async def test_content_stream_200(async_client, auth_user):
|
|
"""GET /api/documents/{id}/content returns 200 with correct Content-Type and Content-Disposition: inline."""
|
|
pytest.xfail("not implemented yet")
|
|
|
|
|
|
@pytest.mark.xfail(strict=False)
|
|
async def test_content_stream_206_range(async_client, auth_user):
|
|
"""GET /api/documents/{id}/content with Range header returns 206 and Content-Range header."""
|
|
pytest.xfail("not implemented yet")
|
|
|
|
|
|
@pytest.mark.xfail(strict=False)
|
|
async def test_content_stream_admin_403(async_client, admin_user):
|
|
"""GET /api/documents/{id}/content with admin JWT returns 403."""
|
|
pytest.xfail("not implemented yet")
|
|
|
|
|
|
@pytest.mark.xfail(strict=False)
|
|
async def test_content_stream_no_presigned_url(async_client, auth_user):
|
|
"""GET /api/documents/{id}/content response body does not contain any presigned URL token."""
|
|
pytest.xfail("not implemented yet")
|