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"), (
|
assert body["kind"] in ("folder", "unsupported_operation"), (
|
||||||
f"Unexpected kind value: {body.get('kind')!r}"
|
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