diff --git a/backend/api/shares.py b/backend/api/shares.py index 7b87639..6067135 100644 --- a/backend/api/shares.py +++ b/backend/api/shares.py @@ -19,7 +19,7 @@ import uuid from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request, status -from pydantic import BaseModel +from pydantic import BaseModel, field_validator from sqlalchemy import select from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession @@ -38,6 +38,25 @@ router = APIRouter(prefix="/api/shares", tags=["shares"]) class ShareCreate(BaseModel): document_id: str recipient_handle: str + permission: str = "view" + + @field_validator("permission") + @classmethod + def validate_permission(cls, v: str) -> str: + if v not in {"view", "edit"}: + raise ValueError("permission must be 'view' or 'edit'") + return v + + +class SharePermissionPatch(BaseModel): + permission: str + + @field_validator("permission") + @classmethod + def validate_permission(cls, v: str) -> str: + if v not in {"view", "edit"}: + raise ValueError("permission must be 'view' or 'edit'") + return v # ── Helpers ─────────────────────────────────────────────────────────────────── @@ -94,7 +113,7 @@ async def grant_share( document_id=uid, owner_id=current_user.id, recipient_id=recipient.id, - permission="view", + permission=body.permission, ) session.add(share) @@ -221,6 +240,36 @@ async def list_shared_with_me( return {"items": items} +# ── PATCH /api/shares/{share_id} ───────────────────────────────────────────── + + +@router.patch("/{share_id}", status_code=200) +async def update_share_permission( + share_id: str, + body: SharePermissionPatch, + session: AsyncSession = Depends(get_db), + current_user: User = Depends(get_regular_user), +) -> dict: + """Update the permission on an existing share (SHARE-03, D-09). + + T-06.2-02-01 IDOR protection: 404 on owner mismatch — mirrors revoke_share exactly. + T-06.2-02-02: SharePermissionPatch validator prevents arbitrary string passthrough. + """ + try: + sid = uuid.UUID(share_id) + except ValueError: + raise HTTPException(status_code=404, detail="Share not found") + + share = await session.get(Share, sid) + if share is None or share.owner_id != current_user.id: + raise HTTPException(status_code=404, detail="Share not found") + + share.permission = body.permission + await session.commit() + + return {"id": str(share.id), "permission": share.permission} + + # ── DELETE /api/shares/{share_id} ───────────────────────────────────────────── diff --git a/backend/tests/test_shares.py b/backend/tests/test_shares.py index f33c4d5..b988045 100644 --- a/backend/tests/test_shares.py +++ b/backend/tests/test_shares.py @@ -352,14 +352,103 @@ async def test_share_indicator_in_owner_list(async_client, auth_user, second_aut 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") + doc_id = await _make_doc(db_session, auth_user) + + # POST with explicit permission="edit" — must be stored and returned + resp = await async_client.post( + "/api/shares", + json={ + "document_id": doc_id, + "recipient_handle": second_auth_user["user"].handle, + "permission": "edit", + }, + headers=auth_user["headers"], + ) + assert resp.status_code == 201, resp.text + body = resp.json() + assert body["permission"] == "edit", ( + f"Expected permission='edit' in POST /api/shares response, got {body.get('permission')!r}" + ) + + # POST without permission field defaults to "view" + # Use a third document to avoid duplicate share constraint + doc_id2 = await _make_doc(db_session, second_auth_user) + resp2 = await async_client.post( + "/api/shares", + json={ + "document_id": doc_id2, + "recipient_handle": auth_user["user"].handle, + }, + headers=second_auth_user["headers"], + ) + assert resp2.status_code == 201, resp2.text + body2 = resp2.json() + assert body2["permission"] == "view", ( + f"Expected default permission='view', got {body2.get('permission')!r}" + ) 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") + doc_id = await _make_doc(db_session, auth_user) + + # Create a share with default permission (view) + 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_resp.text + share_id = share_resp.json()["id"] + + # PATCH to "edit" + patch_resp = await async_client.patch( + f"/api/shares/{share_id}", + json={"permission": "edit"}, + headers=auth_user["headers"], + ) + assert patch_resp.status_code == 200, patch_resp.text + assert patch_resp.json()["permission"] == "edit", ( + f"Expected permission='edit' after PATCH, got {patch_resp.json().get('permission')!r}" + ) + + # PATCH back to "view" + patch_resp2 = await async_client.patch( + f"/api/shares/{share_id}", + json={"permission": "view"}, + headers=auth_user["headers"], + ) + assert patch_resp2.status_code == 200, patch_resp2.text + assert patch_resp2.json()["permission"] == "view", ( + f"Expected permission='view' after second PATCH, got {patch_resp2.json().get('permission')!r}" + ) 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") + doc_id = await _make_doc(db_session, auth_user) + + # Create share owned by auth_user + 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_resp.text + share_id = share_resp.json()["id"] + + # second_auth_user attempts to PATCH a share they do not own — must be 404 (not 403) + patch_resp = await async_client.patch( + f"/api/shares/{share_id}", + json={"permission": "edit"}, + headers=second_auth_user["headers"], + ) + assert patch_resp.status_code == 404, ( + f"Non-owner should get 404 on PATCH (IDOR protection), got {patch_resp.status_code}" + )