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:
curo1305
2026-05-31 15:04:53 +02:00
parent 7e62868fea
commit ea231853e9
2 changed files with 143 additions and 5 deletions
+51 -2
View File
@@ -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} ─────────────────────────────────────────────
+92 -3
View File
@@ -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}"
)