feat(06.2-02): backend — ShareCreate.permission field + PATCH /{share_id} endpoint
- Add permission field (default "view") with field_validator to ShareCreate
- Add SharePermissionPatch model with same validator
- Wire body.permission into grant_share() Share constructor
- Add PATCH /{share_id} endpoint with IDOR protection (T-06.2-02-01)
- Promote 3 xfail stubs to real tests (create_with_permission, patch_permission, patch_idor)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+51
-2
@@ -19,7 +19,7 @@ import uuid
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
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 import select
|
||||||
from sqlalchemy.exc import IntegrityError
|
from sqlalchemy.exc import IntegrityError
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -38,6 +38,25 @@ router = APIRouter(prefix="/api/shares", tags=["shares"])
|
|||||||
class ShareCreate(BaseModel):
|
class ShareCreate(BaseModel):
|
||||||
document_id: str
|
document_id: str
|
||||||
recipient_handle: 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 ───────────────────────────────────────────────────────────────────
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
@@ -94,7 +113,7 @@ async def grant_share(
|
|||||||
document_id=uid,
|
document_id=uid,
|
||||||
owner_id=current_user.id,
|
owner_id=current_user.id,
|
||||||
recipient_id=recipient.id,
|
recipient_id=recipient.id,
|
||||||
permission="view",
|
permission=body.permission,
|
||||||
)
|
)
|
||||||
session.add(share)
|
session.add(share)
|
||||||
|
|
||||||
@@ -221,6 +240,36 @@ async def list_shared_with_me(
|
|||||||
return {"items": items}
|
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} ─────────────────────────────────────────────
|
# ── DELETE /api/shares/{share_id} ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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):
|
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)"""
|
"""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):
|
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)"""
|
"""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):
|
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)"""
|
"""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}"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user