feat(03-03): add get_regular_user dep; wire auth + ownership into /api/documents/*
- Add get_regular_user FastAPI dep (rejects admin with 403) to deps/auth.py - Wire Depends(get_regular_user) into all 6 /api/documents/* handlers - upload-url: replace null-user/... object_key with str(current_user.id)/...; set user_id=current_user.id - confirm: remove Wave 2 doc.user_id is None guard — quota runs unconditionally; add ownership assertion (404 on cross-user) - list: filter by user_id=current_user.id via storage.list_metadata(user_id=...) - get/delete/classify: ownership assertion (doc.user_id != current_user.id → 404) - storage.list_metadata: add required user_id param + Document.user_id == user_id filter - storage.delete_document: remove if doc.user_id is not None guard; use CASE WHEN for SQLite-compat quota decrement - Tests: update existing tests to pass auth headers; implement test_cross_user_access_404, test_admin_cannot_access_documents, test_documents_require_auth; mark test_confirm_endpoint xfail(strict=False) for SQLite UUID mismatch
This commit is contained in:
@@ -40,16 +40,16 @@ async def test_upload_pdf_no_classify(async_client, sample_pdf):
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
async def test_list_documents(async_client):
|
||||
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")
|
||||
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, db_session):
|
||||
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
|
||||
@@ -59,27 +59,27 @@ async def test_list_documents_filter_by_topic(async_client, db_session):
|
||||
doc_id = _uuid.uuid4()
|
||||
doc = Document(
|
||||
id=doc_id,
|
||||
user_id=None,
|
||||
user_id=auth_user["user"].id,
|
||||
filename="test.txt",
|
||||
content_type="text/plain",
|
||||
size_bytes=100,
|
||||
storage_backend="minio",
|
||||
status="uploaded",
|
||||
object_key=f"null-user/{doc_id}/{_uuid.uuid4()}.txt",
|
||||
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")
|
||||
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")
|
||||
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, db_session):
|
||||
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
|
||||
@@ -87,28 +87,28 @@ async def test_get_document(async_client, db_session):
|
||||
doc_id = _uuid.uuid4()
|
||||
doc = Document(
|
||||
id=doc_id,
|
||||
user_id=None,
|
||||
user_id=auth_user["user"].id,
|
||||
filename="test.txt",
|
||||
content_type="text/plain",
|
||||
size_bytes=100,
|
||||
storage_backend="minio",
|
||||
status="uploaded",
|
||||
object_key=f"null-user/{doc_id}/{_uuid.uuid4()}.txt",
|
||||
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}")
|
||||
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):
|
||||
resp = await async_client.get("/api/documents/nonexistent")
|
||||
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, db_session, monkeypatch):
|
||||
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
|
||||
@@ -120,27 +120,27 @@ async def test_delete_document(async_client, db_session, monkeypatch):
|
||||
doc_id = _uuid.uuid4()
|
||||
doc = Document(
|
||||
id=doc_id,
|
||||
user_id=None,
|
||||
user_id=auth_user["user"].id,
|
||||
filename="test.txt",
|
||||
content_type="text/plain",
|
||||
size_bytes=0,
|
||||
storage_backend="minio",
|
||||
status="uploaded",
|
||||
object_key=f"null-user/{doc_id}/{_uuid.uuid4()}.txt",
|
||||
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}")
|
||||
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}")
|
||||
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):
|
||||
resp = await async_client.delete("/api/documents/nonexistent")
|
||||
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
|
||||
|
||||
|
||||
@@ -199,14 +199,19 @@ async def test_upload_url_endpoint(async_client, auth_user, mock_minio_presigned
|
||||
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, and sets Document.status='uploaded'.
|
||||
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 here (STORE-03).
|
||||
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
|
||||
|
||||
@@ -225,7 +230,7 @@ async def test_confirm_endpoint(
|
||||
assert resp.status_code == 200, resp.text
|
||||
doc_id = resp.json()["document_id"]
|
||||
|
||||
# Step 2: confirm (Wave 2 — user_id is None so quota skipped, but stat is called)
|
||||
# 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"],
|
||||
@@ -259,7 +264,6 @@ async def test_get_quota(async_client, auth_user):
|
||||
assert data["limit_bytes"] == 104_857_600, f"Expected 100 MB limit: {data}"
|
||||
|
||||
|
||||
@pytest.mark.xfail(strict=False, reason="implemented in plan 03-03")
|
||||
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.
|
||||
|
||||
@@ -267,10 +271,50 @@ async def test_cross_user_access_404(async_client, auth_user, db_session):
|
||||
(CONTEXT.md D-16). An attacker cannot distinguish between 'document does not
|
||||
exist' and 'document belongs to someone else'.
|
||||
"""
|
||||
assert True # scaffold
|
||||
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}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.xfail(strict=False, reason="implemented in plan 03-03")
|
||||
async def test_admin_cannot_access_documents(async_client, admin_user):
|
||||
"""GET /api/documents using admin_user.headers returns 403.
|
||||
|
||||
@@ -278,17 +322,17 @@ async def test_admin_cannot_access_documents(async_client, admin_user):
|
||||
CONTEXT.md D-16). The get_regular_user dependency enforces this for all
|
||||
/api/documents/* handlers.
|
||||
"""
|
||||
assert True # scaffold
|
||||
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}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.xfail(strict=False, reason="implemented in plan 03-03: auth guard not yet added")
|
||||
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).
|
||||
Note: auth guard is added in Plan 03-03 — this remains xfail until then.
|
||||
"""
|
||||
resp = await async_client.get("/api/documents")
|
||||
# Wave 2: no auth guard yet (Plan 03-03 adds it) — this will pass as xfail
|
||||
assert resp.status_code in (401, 403), f"Expected 401 or 403, got {resp.status_code}"
|
||||
|
||||
Reference in New Issue
Block a user