diff --git a/backend/tests/test_documents.py b/backend/tests/test_documents.py index 7a44cf7..16c4822 100644 --- a/backend/tests/test_documents.py +++ b/backend/tests/test_documents.py @@ -339,29 +339,257 @@ async def test_documents_require_auth(async_client): # --------------------------------------------------------------------------- -# Wave 0 xfail stubs for Phase 4 DOC-02 proxy / content-stream tests +# Phase 4 DOC-02 proxy / content-stream tests # --------------------------------------------------------------------------- -@pytest.mark.xfail(strict=False) -async def test_content_stream_200(async_client, auth_user): +async def test_content_stream_200(async_client, auth_user, db_session, monkeypatch): """GET /api/documents/{id}/content returns 200 with correct Content-Type and Content-Disposition: inline.""" - pytest.xfail("not implemented yet") + import uuid as _uuid + from unittest.mock import AsyncMock + from db.models import Document + from storage.minio_backend import MinIOBackend + + file_bytes = b"Hello, PDF content!" + monkeypatch.setattr(MinIOBackend, "get_object", AsyncMock(return_value=file_bytes), raising=False) + + doc_id = _uuid.uuid4() + doc = Document( + id=doc_id, + user_id=auth_user["user"].id, + filename="test.pdf", + content_type="application/pdf", + size_bytes=len(file_bytes), + storage_backend="minio", + status="uploaded", + object_key=f"{auth_user['user'].id}/{doc_id}/{_uuid.uuid4()}.pdf", + ) + db_session.add(doc) + await db_session.commit() + + resp = await async_client.get( + f"/api/documents/{doc_id}/content", + headers=auth_user["headers"], + ) + assert resp.status_code == 200 + assert resp.content == file_bytes + assert resp.headers["content-type"].startswith("application/pdf") + assert "inline" in resp.headers.get("content-disposition", "") -@pytest.mark.xfail(strict=False) -async def test_content_stream_206_range(async_client, auth_user): +async def test_content_stream_206_range(async_client, auth_user, db_session, monkeypatch): """GET /api/documents/{id}/content with Range header returns 206 and Content-Range header.""" - pytest.xfail("not implemented yet") + import uuid as _uuid + from unittest.mock import AsyncMock + from db.models import Document + from storage.minio_backend import MinIOBackend + + file_bytes = b"0123456789ABCDEF" # 16 bytes + monkeypatch.setattr(MinIOBackend, "get_object", AsyncMock(return_value=file_bytes), raising=False) + + doc_id = _uuid.uuid4() + doc = Document( + id=doc_id, + user_id=auth_user["user"].id, + filename="test.pdf", + content_type="application/pdf", + size_bytes=len(file_bytes), + storage_backend="minio", + status="uploaded", + object_key=f"{auth_user['user'].id}/{doc_id}/{_uuid.uuid4()}.pdf", + ) + db_session.add(doc) + await db_session.commit() + + resp = await async_client.get( + f"/api/documents/{doc_id}/content", + headers={**auth_user["headers"], "Range": "bytes=0-7"}, + ) + assert resp.status_code == 206 + assert resp.content == b"01234567" + assert "content-range" in resp.headers + assert resp.headers["content-range"] == "bytes 0-7/16" -@pytest.mark.xfail(strict=False) -async def test_content_stream_admin_403(async_client, admin_user): +async def test_content_stream_admin_403(async_client, admin_user, db_session, monkeypatch): """GET /api/documents/{id}/content with admin JWT returns 403.""" - pytest.xfail("not implemented yet") + import uuid as _uuid + from unittest.mock import AsyncMock + from db.models import Document + from storage.minio_backend import MinIOBackend + + file_bytes = b"admin should not see this" + monkeypatch.setattr(MinIOBackend, "get_object", AsyncMock(return_value=file_bytes), raising=False) + + doc_id = _uuid.uuid4() + doc = Document( + id=doc_id, + user_id=admin_user["user"].id, + filename="test.pdf", + content_type="application/pdf", + size_bytes=len(file_bytes), + storage_backend="minio", + status="uploaded", + object_key=f"{admin_user['user'].id}/{doc_id}/{_uuid.uuid4()}.pdf", + ) + db_session.add(doc) + await db_session.commit() + + resp = await async_client.get( + f"/api/documents/{doc_id}/content", + headers=admin_user["headers"], + ) + assert resp.status_code == 403 -@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") +async def test_content_stream_no_presigned_url(async_client, auth_user, db_session, monkeypatch): + """GET /api/documents/{id}/content response does not call presigned_get_url.""" + import uuid as _uuid + from unittest.mock import AsyncMock, MagicMock + from db.models import Document + from storage.minio_backend import MinIOBackend + + file_bytes = b"document content" + get_object_mock = AsyncMock(return_value=file_bytes) + presigned_mock = AsyncMock(return_value="http://minio/presigned?X-Amz-Signature=FAKE") + monkeypatch.setattr(MinIOBackend, "get_object", get_object_mock, raising=False) + monkeypatch.setattr(MinIOBackend, "presigned_get_url", presigned_mock, raising=False) + + doc_id = _uuid.uuid4() + doc = Document( + id=doc_id, + user_id=auth_user["user"].id, + filename="test.pdf", + content_type="application/pdf", + size_bytes=len(file_bytes), + storage_backend="minio", + status="uploaded", + object_key=f"{auth_user['user'].id}/{doc_id}/{_uuid.uuid4()}.pdf", + ) + db_session.add(doc) + await db_session.commit() + + resp = await async_client.get( + f"/api/documents/{doc_id}/content", + headers=auth_user["headers"], + ) + assert resp.status_code == 200 + # presigned_get_url must NEVER be called + presigned_mock.assert_not_called() + # get_object must be called (direct fetch) + get_object_mock.assert_called_once() + + +async def test_content_stream_share_recipient_200(async_client, auth_user, admin_user, db_session, monkeypatch): + """Share recipient can access document content via GET /api/documents/{id}/content.""" + import uuid as _uuid + from unittest.mock import AsyncMock + from db.models import Document, Share + from storage.minio_backend import MinIOBackend + + file_bytes = b"shared document content" + monkeypatch.setattr(MinIOBackend, "get_object", AsyncMock(return_value=file_bytes), raising=False) + + # Create a regular user as recipient (use auth_user as recipient, admin_user as owner) + # But we need two regular users; use auth_user as owner and create a second regular user + import uuid as _uuid2 + from db.models import User, Quota + from services.auth import hash_password, create_access_token + + recipient_id = _uuid2.uuid4() + recipient = User( + id=recipient_id, + handle=f"recipient_{recipient_id.hex[:8]}", + email=f"recipient_{recipient_id.hex[:8]}@example.com", + password_hash=hash_password("Testpassword123!"), + role="user", + is_active=True, + password_must_change=False, + ) + recipient_quota = Quota(user_id=recipient_id, limit_bytes=104857600, used_bytes=0) + db_session.add(recipient) + db_session.add(recipient_quota) + await db_session.flush() + + doc_id = _uuid.uuid4() + doc = Document( + id=doc_id, + user_id=auth_user["user"].id, + filename="shared.pdf", + content_type="application/pdf", + size_bytes=len(file_bytes), + storage_backend="minio", + status="uploaded", + object_key=f"{auth_user['user'].id}/{doc_id}/{_uuid.uuid4()}.pdf", + ) + db_session.add(doc) + await db_session.flush() + + share = Share( + document_id=doc_id, + owner_id=auth_user["user"].id, + recipient_id=recipient_id, + permission="view", + ) + db_session.add(share) + await db_session.commit() + + recipient_token = create_access_token(str(recipient_id), "user") + recipient_headers = {"Authorization": f"Bearer {recipient_token}"} + + resp = await async_client.get( + f"/api/documents/{doc_id}/content", + headers=recipient_headers, + ) + assert resp.status_code == 200 + assert resp.content == file_bytes + + +async def test_content_stream_not_found(async_client, auth_user): + """GET /api/documents/{id}/content returns 404 for unknown document ID.""" + import uuid as _uuid + resp = await async_client.get( + f"/api/documents/{_uuid.uuid4()}/content", + headers=auth_user["headers"], + ) + assert resp.status_code == 404 + + +async def test_content_stream_invalid_id(async_client, auth_user): + """GET /api/documents/{id}/content returns 404 for invalid UUID.""" + resp = await async_client.get( + "/api/documents/not-a-uuid/content", + headers=auth_user["headers"], + ) + assert resp.status_code == 404 + + +async def test_parse_range_416(async_client, auth_user, db_session, monkeypatch): + """GET /api/documents/{id}/content with invalid Range returns 416.""" + import uuid as _uuid + from unittest.mock import AsyncMock + from db.models import Document + from storage.minio_backend import MinIOBackend + + file_bytes = b"short" + monkeypatch.setattr(MinIOBackend, "get_object", AsyncMock(return_value=file_bytes), raising=False) + + doc_id = _uuid.uuid4() + doc = Document( + id=doc_id, + user_id=auth_user["user"].id, + filename="test.pdf", + content_type="application/pdf", + size_bytes=len(file_bytes), + storage_backend="minio", + status="uploaded", + object_key=f"{auth_user['user'].id}/{doc_id}/{_uuid.uuid4()}.pdf", + ) + db_session.add(doc) + await db_session.commit() + + resp = await async_client.get( + f"/api/documents/{doc_id}/content", + headers={**auth_user["headers"], "Range": "bytes=100-200"}, + ) + assert resp.status_code == 416