""" Share API tests — Phase 6.1, Plan 06.1-01. Promotes all 7 xfail stubs to real tests covering SHARE-01 through SHARE-05. """ from __future__ import annotations import uuid as _uuid import pytest import pytest_asyncio pytestmark = pytest.mark.asyncio # --------------------------------------------------------------------------- # Helper: create an uploaded Document row directly via ORM # --------------------------------------------------------------------------- async def _make_doc(db_session, owner_user) -> str: """Create an uploaded Document row owned by owner_user and return its str UUID.""" from db.models import Document doc_id = _uuid.uuid4() doc = Document( id=doc_id, user_id=owner_user["user"].id, filename="test.txt", content_type="text/plain", size_bytes=1000, storage_backend="minio", status="uploaded", object_key=f"{owner_user['user'].id}/{doc_id}/{_uuid.uuid4()}.txt", ) db_session.add(doc) await db_session.commit() return str(doc_id) # --------------------------------------------------------------------------- # SHARE-01: Share a document # --------------------------------------------------------------------------- async def test_share_success(async_client, auth_user, second_auth_user, db_session): """POST /api/shares grants share; recipient can see doc via GET /api/shares/received.""" doc_id = await _make_doc(db_session, auth_user) resp = await async_client.post( "/api/shares", json={ "document_id": doc_id, "recipient_handle": second_auth_user["user"].handle, }, headers=auth_user["headers"], ) assert resp.status_code == 201, resp.text body = resp.json() assert "id" in body assert body["document_id"] == doc_id assert body["recipient_id"] == str(second_auth_user["user"].id) # Recipient can see the document in their received list received = await async_client.get( "/api/shares/received", headers=second_auth_user["headers"], ) assert received.status_code == 200 items = received.json()["items"] doc_ids = [item["id"] for item in items] assert doc_id in doc_ids async def test_share_handle_not_found(async_client, auth_user, db_session): """POST /api/shares with unknown handle returns 404.""" doc_id = await _make_doc(db_session, auth_user) resp = await async_client.post( "/api/shares", json={ "document_id": doc_id, "recipient_handle": "nonexistent_handle_xyz", }, headers=auth_user["headers"], ) assert resp.status_code == 404 # --------------------------------------------------------------------------- # SHARE-02: List shared-with-me # --------------------------------------------------------------------------- async def test_shared_with_me(async_client, auth_user, second_auth_user, db_session): """GET /api/shares/received lists docs shared with current user (T-04-04-03).""" doc_id = await _make_doc(db_session, auth_user) # Grant share share_resp = await async_client.post( "/api/shares", json={ "document_id": doc_id, "recipient_handle": second_auth_user["user"].handle, }, headers=auth_user["headers"], ) assert share_resp.status_code == 201 # Recipient fetches shared-with-me received = await async_client.get( "/api/shares/received", headers=second_auth_user["headers"], ) assert received.status_code == 200 items = received.json()["items"] assert len(items) >= 1 # Find the specific item matching = [item for item in items if item["id"] == doc_id] assert len(matching) == 1, f"doc {doc_id} not in received items: {items}" item = matching[0] # Required metadata fields present for field in ("id", "filename", "content_type", "size_bytes", "created_at", "owner_handle"): assert field in item, f"Expected field '{field}' in item" # T-04-04-03: extracted_text must NEVER be in any item for received_item in items: assert "extracted_text" not in received_item, ( "extracted_text must not be returned in shared-with-me responses (T-04-04-03)" ) # owner_handle should match the sharer assert item["owner_handle"] == auth_user["user"].handle # --------------------------------------------------------------------------- # SHARE-03: Quota isolation # --------------------------------------------------------------------------- async def test_share_no_quota_impact(async_client, auth_user, second_auth_user, db_session): """Share does not increment recipient's quota used_bytes (T-04-04-04).""" doc_id = await _make_doc(db_session, auth_user) # Grant share share_resp = await async_client.post( "/api/shares", json={ "document_id": doc_id, "recipient_handle": second_auth_user["user"].handle, }, headers=auth_user["headers"], ) assert share_resp.status_code == 201 # Check recipient quota — must still be 0 used_bytes quota_resp = await async_client.get( "/api/auth/me/quota", headers=second_auth_user["headers"], ) assert quota_resp.status_code == 200 quota = quota_resp.json() assert quota["used_bytes"] == 0, ( f"Recipient quota used_bytes should be 0 after share, got {quota['used_bytes']}" ) # --------------------------------------------------------------------------- # SHARE-04: Revoke share # --------------------------------------------------------------------------- async def test_revoke_share(async_client, auth_user, second_auth_user, db_session): """DELETE /api/shares/{id} removes share; GET /api/shares/received no longer lists the doc.""" doc_id = await _make_doc(db_session, auth_user) # Grant share share_resp = await async_client.post( "/api/shares", json={ "document_id": doc_id, "recipient_handle": second_auth_user["user"].handle, }, headers=auth_user["headers"], ) assert share_resp.status_code == 201 share_id = share_resp.json()["id"] # Revoke share (owner revokes) revoke_resp = await async_client.delete( f"/api/shares/{share_id}", headers=auth_user["headers"], ) assert revoke_resp.status_code == 204 # Recipient no longer sees the document in received received = await async_client.get( "/api/shares/received", headers=second_auth_user["headers"], ) assert received.status_code == 200 items = received.json()["items"] doc_ids = [item["id"] for item in items] assert doc_id not in doc_ids, f"doc {doc_id} should have been revoked but still appears" async def test_share_revoke_wrong_owner_404(async_client, auth_user, second_auth_user, db_session): """DELETE /api/shares/{id} by non-owner returns 404 (IDOR protection — T-04-04-02).""" doc_id = await _make_doc(db_session, auth_user) # Grant share share_resp = await async_client.post( "/api/shares", json={ "document_id": doc_id, "recipient_handle": second_auth_user["user"].handle, }, headers=auth_user["headers"], ) assert share_resp.status_code == 201 share_id = share_resp.json()["id"] # Recipient attempts to revoke — must be rejected with 404 (not 403) revoke_resp = await async_client.delete( f"/api/shares/{share_id}", headers=second_auth_user["headers"], ) assert revoke_resp.status_code == 404, ( f"Recipient should get 404 when attempting to revoke a share they don't own, " f"got {revoke_resp.status_code}" ) # --------------------------------------------------------------------------- # SHARE-05: Duplicate share # --------------------------------------------------------------------------- async def test_share_duplicate(async_client, auth_user, second_auth_user, db_session): """POST /api/shares same doc+recipient twice returns 409.""" doc_id = await _make_doc(db_session, auth_user) payload = { "document_id": doc_id, "recipient_handle": second_auth_user["user"].handle, } # First share — should succeed first_resp = await async_client.post( "/api/shares", json=payload, headers=auth_user["headers"], ) assert first_resp.status_code == 201 # Second share with same doc+recipient — should return 409 second_resp = await async_client.post( "/api/shares", json=payload, headers=auth_user["headers"], ) assert second_resp.status_code == 409, ( f"Duplicate share should return 409, got {second_resp.status_code}" ) # --------------------------------------------------------------------------- # SHARE-03: View-only default permission # --------------------------------------------------------------------------- async def test_share_default_permission_view(async_client, auth_user, second_auth_user, db_session): """Shares default to permission='view'; owner's share list confirms this (SHARE-03).""" doc_id = await _make_doc(db_session, auth_user) # Create share — POST response must include permission=view resp = await async_client.post( "/api/shares", json={ "document_id": doc_id, "recipient_handle": second_auth_user["user"].handle, }, headers=auth_user["headers"], ) assert resp.status_code == 201 body = resp.json() assert body["permission"] == "view", ( f"Expected permission='view' in POST /api/shares response, got {body.get('permission')!r}" ) # GET owner's share list for this doc also reports permission=view list_resp = await async_client.get( "/api/shares", params={"document_id": doc_id}, headers=auth_user["headers"], ) assert list_resp.status_code == 200 items = list_resp.json()["items"] assert len(items) == 1 assert items[0]["permission"] == "view", ( f"Expected permission='view' in share list, got {items[0].get('permission')!r}" ) # --------------------------------------------------------------------------- # SHARE-05: Shared indicator in owner's document list # --------------------------------------------------------------------------- async def test_share_indicator_in_owner_list(async_client, auth_user, second_auth_user, db_session): """Owner's document list shows is_shared=True after sharing the document (SHARE-05).""" doc_id = await _make_doc(db_session, auth_user) # Before sharing: is_shared must be False pre_resp = await async_client.get("/api/documents", headers=auth_user["headers"]) assert pre_resp.status_code == 200 pre_items = pre_resp.json()["items"] pre_match = [item for item in pre_items if item["id"] == doc_id] assert len(pre_match) == 1 assert pre_match[0]["is_shared"] is False, ( f"Expected is_shared=False before sharing, got {pre_match[0].get('is_shared')!r}" ) # Share the document share_resp = await async_client.post( "/api/shares", json={ "document_id": doc_id, "recipient_handle": second_auth_user["user"].handle, }, headers=auth_user["headers"], ) assert share_resp.status_code == 201 # After sharing: is_shared must be True in owner's document list post_resp = await async_client.get("/api/documents", headers=auth_user["headers"]) assert post_resp.status_code == 200 post_items = post_resp.json()["items"] post_match = [item for item in post_items if item["id"] == doc_id] assert len(post_match) == 1 assert post_match[0]["is_shared"] is True, ( f"Expected is_shared=True after sharing, got {post_match[0].get('is_shared')!r}" ) # --------------------------------------------------------------------------- # Phase 6.2 Wave 0 xfail stubs — SHARE-03 permission field # --------------------------------------------------------------------------- async def test_share_create_with_permission(async_client, auth_user, second_auth_user, db_session): """POST /api/shares respects permission field from request body (SHARE-03, D-08, D-10)""" pytest.xfail("Phase 6.2 — not implemented yet") async def test_share_patch_permission(async_client, auth_user, second_auth_user, db_session): """PATCH /api/shares/{id} changes permission to edit (SHARE-03, D-09)""" pytest.xfail("Phase 6.2 — not implemented yet") async def test_share_patch_idor(async_client, auth_user, second_auth_user, db_session): """PATCH /api/shares/{id} by non-owner returns 404 — IDOR protection (SHARE-03, D-09, T-IDOR)""" pytest.xfail("Phase 6.2 — not implemented yet")