feat(13-05): add typed upload route mechanics tests (Task 2)
- test_upload_success_returns_typed_uploaded_body: kind/provider_item_id/name/size - test_upload_provider_retryable_error_returns_offline_kind: typed offline body - test_upload_provider_conflict_returns_conflict_kind: provider-detected collision - test_upload_reauth_result_is_typed_and_not_500: CONN-02 credential failure path - test_upload_queue_control_semantics_are_backend_authored: all 4 queue fields - test_upload_keep_both_name_format: route can use keep_both_name() from service - test_upload_foreign_user_blocked: IDOR protection on upload route - 7 new tests pass; full suite 741 passed
This commit is contained in:
@@ -778,3 +778,270 @@ async def test_unsupported_operation_returns_typed_kind(async_client, db_session
|
||||
assert body["kind"] in ("folder", "unsupported_operation"), (
|
||||
f"Unexpected kind value: {body.get('kind')!r}"
|
||||
)
|
||||
|
||||
|
||||
# ── Phase 13 Plan 05 Task 2: Upload route typed mechanics ─────────────────────
|
||||
|
||||
|
||||
async def test_upload_success_returns_typed_uploaded_body(async_client, db_session):
|
||||
"""POST upload with no conflict returns {kind: 'uploaded', provider_item_id, name, size}.
|
||||
|
||||
Task 2 behavior 1: The upload route must return a stable typed 'uploaded' body so
|
||||
the queue can advance to the next file without Vue-side inference.
|
||||
Mock adapter returns the canonical uploaded result.
|
||||
"""
|
||||
from storage.cloud_base import MUT_KIND_UPLOADED, MUT_REASON_CREATED
|
||||
|
||||
auth = await _create_user_and_token(db_session)
|
||||
conn = await _create_cloud_connection(db_session, auth["user"].id)
|
||||
|
||||
import uuid as _uuid2
|
||||
mock_adapter = _make_mock_mutable_adapter(
|
||||
upload_result={
|
||||
"kind": MUT_KIND_UPLOADED,
|
||||
"reason": MUT_REASON_CREATED,
|
||||
"provider_item_id": str(_uuid2.uuid4()),
|
||||
"name": "report.pdf",
|
||||
"parent_ref": "root",
|
||||
"size": 1024,
|
||||
}
|
||||
)
|
||||
|
||||
files = {"file": ("report.pdf", b"%PDF-1.4 test", "application/pdf")}
|
||||
data = {"parent_ref": "root_no_conflict", "filename": "report.pdf"}
|
||||
|
||||
with patch("storage.cloud_backend_factory.build_mutable_cloud_adapter", return_value=mock_adapter):
|
||||
resp = await async_client.post(
|
||||
f"/api/cloud/connections/{conn.id}/items/upload",
|
||||
headers=auth["headers"],
|
||||
files=files,
|
||||
data=data,
|
||||
)
|
||||
|
||||
assert resp.status_code == 200, (
|
||||
f"Expected 200 for upload success, got {resp.status_code}: {resp.text}"
|
||||
)
|
||||
body = resp.json()
|
||||
assert body.get("kind") == "uploaded", (
|
||||
f"Expected kind='uploaded', got {body.get('kind')!r}"
|
||||
)
|
||||
assert "provider_item_id" in body, "Response must include provider_item_id"
|
||||
assert "name" in body, "Response must include name"
|
||||
assert "size" in body, "Response must include size"
|
||||
|
||||
|
||||
async def test_upload_provider_retryable_error_returns_offline_kind(async_client, db_session):
|
||||
"""POST upload on provider 503 returns typed {kind: 'offline', reason: 'provider_offline'}.
|
||||
|
||||
Task 2 behavior 1: A transient provider error must pause the upload queue
|
||||
(D-04). The route must expose the typed 'offline' result so Vue can present
|
||||
the 'Retry / Skip / Cancel all' dialog — never a bare 500.
|
||||
"""
|
||||
from storage.cloud_base import MUT_KIND_OFFLINE, MUT_REASON_PROVIDER_OFFLINE
|
||||
|
||||
auth = await _create_user_and_token(db_session)
|
||||
conn = await _create_cloud_connection(db_session, auth["user"].id)
|
||||
|
||||
mock_adapter = _make_mock_mutable_adapter(
|
||||
upload_result={
|
||||
"kind": MUT_KIND_OFFLINE,
|
||||
"reason": MUT_REASON_PROVIDER_OFFLINE,
|
||||
}
|
||||
)
|
||||
|
||||
files = {"file": ("report.pdf", b"data", "application/pdf")}
|
||||
data = {"parent_ref": "root", "filename": "retryable_file.pdf"}
|
||||
|
||||
with patch("storage.cloud_backend_factory.build_mutable_cloud_adapter", return_value=mock_adapter):
|
||||
resp = await async_client.post(
|
||||
f"/api/cloud/connections/{conn.id}/items/upload",
|
||||
headers=auth["headers"],
|
||||
files=files,
|
||||
data=data,
|
||||
)
|
||||
|
||||
# Route must not raise a bare 500 — must return typed error
|
||||
assert resp.status_code != 500, (
|
||||
f"Retryable error must not produce a 500 (D-04), got {resp.status_code}: {resp.text}"
|
||||
)
|
||||
body = resp.json()
|
||||
assert "kind" in body, "Retryable error response must include 'kind' field (D-04)"
|
||||
# kind must be a recognized non-success result
|
||||
assert body.get("kind") in ("offline", "error", "reauth_required"), (
|
||||
f"Expected typed error kind, got {body.get('kind')!r}"
|
||||
)
|
||||
|
||||
|
||||
async def test_upload_provider_conflict_returns_conflict_kind(async_client, db_session):
|
||||
"""POST upload when provider detects conflict returns typed {kind: 'conflict'} body.
|
||||
|
||||
Task 2 behavior 1: If the fast-path cache check misses but the provider
|
||||
returns a conflict (e.g. OneDrive 409 on createUploadSession), the route must
|
||||
surface the typed conflict body — not a raw 409 error — so the queue can show
|
||||
the conflict dialog.
|
||||
"""
|
||||
from storage.cloud_base import MUT_KIND_CONFLICT, MUT_REASON_NAME_COLLISION
|
||||
|
||||
auth = await _create_user_and_token(db_session)
|
||||
conn = await _create_cloud_connection(db_session, auth["user"].id)
|
||||
|
||||
mock_adapter = _make_mock_mutable_adapter(
|
||||
upload_result={
|
||||
"kind": MUT_KIND_CONFLICT,
|
||||
"reason": MUT_REASON_NAME_COLLISION,
|
||||
"existing_name": "document.pdf",
|
||||
}
|
||||
)
|
||||
|
||||
# No cached CloudItem — so fast path won't block
|
||||
files = {"file": ("document.pdf", b"data", "application/pdf")}
|
||||
data = {"parent_ref": "folder_no_cache", "filename": "document.pdf"}
|
||||
|
||||
with patch("storage.cloud_backend_factory.build_mutable_cloud_adapter", return_value=mock_adapter):
|
||||
resp = await async_client.post(
|
||||
f"/api/cloud/connections/{conn.id}/items/upload",
|
||||
headers=auth["headers"],
|
||||
files=files,
|
||||
data=data,
|
||||
)
|
||||
|
||||
assert resp.status_code in (200, 409), (
|
||||
f"Conflict must produce 200 or 409, not {resp.status_code}"
|
||||
)
|
||||
body = resp.json()
|
||||
assert body.get("kind") == "conflict", (
|
||||
f"Expected kind='conflict', got {body.get('kind')!r}"
|
||||
)
|
||||
assert body.get("reason") == "name_collision", (
|
||||
f"Expected reason='name_collision', got {body.get('reason')!r}"
|
||||
)
|
||||
|
||||
|
||||
async def test_upload_reauth_result_is_typed_and_not_500(async_client, db_session):
|
||||
"""POST upload when provider requires reauth returns typed {kind: 'reauth_required'}.
|
||||
|
||||
Task 2 behavior 2: CONN-02 — refreshed credentials surface upward. The route
|
||||
must return typed reauth body so the queue knows to prompt reconnect.
|
||||
"""
|
||||
from storage.cloud_base import MUT_KIND_REAUTH, MUT_REASON_TOKEN_EXPIRED
|
||||
|
||||
auth = await _create_user_and_token(db_session)
|
||||
conn = await _create_cloud_connection(db_session, auth["user"].id)
|
||||
|
||||
mock_adapter = _make_mock_mutable_adapter(
|
||||
upload_result={
|
||||
"kind": MUT_KIND_REAUTH,
|
||||
"reason": MUT_REASON_TOKEN_EXPIRED,
|
||||
}
|
||||
)
|
||||
|
||||
files = {"file": ("report.pdf", b"data", "application/pdf")}
|
||||
data = {"parent_ref": "root", "filename": "auth_report.pdf"}
|
||||
|
||||
with patch("storage.cloud_backend_factory.build_mutable_cloud_adapter", return_value=mock_adapter):
|
||||
resp = await async_client.post(
|
||||
f"/api/cloud/connections/{conn.id}/items/upload",
|
||||
headers=auth["headers"],
|
||||
files=files,
|
||||
data=data,
|
||||
)
|
||||
|
||||
assert resp.status_code != 500, (
|
||||
f"Reauth result must not produce 500, got {resp.status_code}"
|
||||
)
|
||||
body = resp.json()
|
||||
assert "kind" in body, "Reauth response must include 'kind'"
|
||||
# Route may map reauth to a 4xx with typed body
|
||||
assert body.get("kind") in ("reauth_required", "error"), (
|
||||
f"Unexpected kind for reauth: {body.get('kind')!r}"
|
||||
)
|
||||
|
||||
|
||||
async def test_upload_queue_control_semantics_are_backend_authored(async_client, db_session):
|
||||
"""Upload response body contains all fields the queue needs to decide next action.
|
||||
|
||||
Task 2 behavior 3: The queue-control fields (kind, reason, existing_name) must
|
||||
come from the backend response. Vue must never infer queue state from HTTP status
|
||||
alone — the body must have enough typed data.
|
||||
|
||||
Success: body has kind, provider_item_id, name, size.
|
||||
Conflict: body has kind, reason, existing_name.
|
||||
"""
|
||||
from storage.cloud_base import MUT_KIND_UPLOADED, MUT_REASON_CREATED
|
||||
|
||||
auth = await _create_user_and_token(db_session)
|
||||
conn = await _create_cloud_connection(db_session, auth["user"].id)
|
||||
|
||||
import uuid as _uuid2
|
||||
mock_adapter = _make_mock_mutable_adapter(
|
||||
upload_result={
|
||||
"kind": MUT_KIND_UPLOADED,
|
||||
"reason": MUT_REASON_CREATED,
|
||||
"provider_item_id": str(_uuid2.uuid4()),
|
||||
"name": "presentation.pdf",
|
||||
"parent_ref": "root",
|
||||
"size": 512,
|
||||
}
|
||||
)
|
||||
|
||||
files = {"file": ("presentation.pdf", b"pptx", "application/pdf")}
|
||||
data = {"parent_ref": "root_unique_2", "filename": "presentation.pdf"}
|
||||
|
||||
with patch("storage.cloud_backend_factory.build_mutable_cloud_adapter", return_value=mock_adapter):
|
||||
resp = await async_client.post(
|
||||
f"/api/cloud/connections/{conn.id}/items/upload",
|
||||
headers=auth["headers"],
|
||||
files=files,
|
||||
data=data,
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
|
||||
# All queue-control fields must be present in success response
|
||||
required_fields = {"kind", "provider_item_id", "name", "size"}
|
||||
missing = required_fields - set(body.keys())
|
||||
assert not missing, (
|
||||
f"Upload success body missing queue-control fields: {missing} (Task 2 behavior 3)"
|
||||
)
|
||||
assert body["kind"] == "uploaded", f"Expected kind='uploaded', got {body['kind']!r}"
|
||||
|
||||
|
||||
async def test_upload_keep_both_name_format(async_client, db_session):
|
||||
"""keep_both_name() produces counter-before-extension format for collision resolution.
|
||||
|
||||
Task 2: The service utility must be accessible from the route context for
|
||||
queue-side rename. The format must match 'Report (1).pdf' not 'Report.pdf (1)'.
|
||||
"""
|
||||
from services.cloud_operations import keep_both_name
|
||||
|
||||
# Verify the canonical keep-both naming format used by the upload queue
|
||||
assert keep_both_name("Report.pdf", 1) == "Report (1).pdf", (
|
||||
"keep_both_name must insert counter BEFORE extension (D-03)"
|
||||
)
|
||||
assert keep_both_name("archive.tar.gz", 1) == "archive (1).tar.gz", (
|
||||
"keep_both_name must handle compound extensions"
|
||||
)
|
||||
assert keep_both_name("Report.pdf", 3) == "Report (3).pdf", (
|
||||
"keep_both_name must use the passed counter value"
|
||||
)
|
||||
|
||||
|
||||
async def test_upload_foreign_user_blocked(async_client, db_session):
|
||||
"""User2 cannot upload to User1's connection — IDOR protection (T-13-01)."""
|
||||
auth1 = await _create_user_and_token(db_session)
|
||||
auth2 = await _create_user_and_token(db_session)
|
||||
conn = await _create_cloud_connection(db_session, auth1["user"].id)
|
||||
|
||||
files = {"file": ("test.pdf", b"data", "application/pdf")}
|
||||
data = {"parent_ref": "root"}
|
||||
|
||||
resp = await async_client.post(
|
||||
f"/api/cloud/connections/{conn.id}/items/upload",
|
||||
headers=auth2["headers"],
|
||||
files=files,
|
||||
data=data,
|
||||
)
|
||||
assert resp.status_code == 404, (
|
||||
f"Expected 404 IDOR block for foreign upload, got {resp.status_code}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user