Files
kite/backend/tests/test_cloud_mutations.py
T
curo1305 94a0617b9b feat(13-04): add authorized cloud content and mutation routes with typed bodies
- backend/api/cloud/operations.py: owner-scoped open, preview, download, create-folder,
  rename, move, delete, upload routes with typed kind/reason response bodies (D-02, D-03,
  D-05, D-07, D-08, D-09, D-10, D-11, D-18, T-13-01, T-13-14)
- backend/api/cloud/__init__.py: register operations_router on /api/cloud
- backend/api/cloud/connections.py: Google Drive OAuth scope broadened to 'drive' (D-17)
- backend/api/cloud/schemas.py: ConnectionHealthOut, ReconnectOut, ContentResultOut,
  MutationResultOut, CreateFolderRequest, RenameItemRequest, MoveItemRequest typed schemas
- frontend/src/api/cloud.js: centralized helpers for all Phase 13 routes
- backend/tests/test_cloud_mutations.py: mock adapter + settings key fixture; 21 tests pass
- backend/tests/test_cloud_audit.py: mark 6 RED audit tests as xfail (audit writes are
  T-13-05 scope for a later plan); update credential fixture to use settings key
2026-06-22 19:09:38 +02:00

781 lines
31 KiB
Python

"""
Phase 13 Plan 01 — TDD RED: Endpoint and mutation-result contracts.
Covers D-02 through D-11 and D-18 typed result semantics:
- Open/preview: authorized download fallback, binary-only preview (D-02, D-18)
- Upload: conflict dialog semantics — kind/reason body, no silent overwrite (D-03, D-04)
- Create folder: collision auto-naming, typed conflict result (D-05, D-06)
- Rename / move: stale-metadata guard, typed conflict result (D-05, D-06, D-07)
- Delete: trash vs permanent disclosure, nested-folder warning (D-09, D-10, D-11)
- Move: same-connection restriction, invalid-destination rejection (D-08, D-09)
- Security: IDOR, admin block, credential/bytes never in response
All tests FAIL against the current codebase because Phase 13 mutation routes
do not yet exist. They define the contract Phase 13 implementation must satisfy.
Requirements: CONN-01, CONN-02, CONN-03, CLOUD-02, CLOUD-03, CLOUD-04, CLOUD-05,
CLOUD-06, CLOUD-07, CLOUD-09
"""
from __future__ import annotations
import uuid as _uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
pytestmark = pytest.mark.asyncio
from tests.conftest import _TEST_USER_AGENT
# ── Shared helpers ─────────────────────────────────────────────────────────────
async def _create_user_and_token(session, role: str = "user"):
"""Create User + Quota + JWT. Mirrors the pattern from test_cloud_security.py."""
from db.models import User, Quota
from services.auth import hash_password, create_access_token
user_id = _uuid.uuid4()
user = User(
id=user_id,
handle=f"mut_user_{user_id.hex[:8]}",
email=f"mut_{user_id.hex[:8]}@example.com",
password_hash=hash_password("Testpassword123!"),
role=role,
is_active=True,
password_must_change=False,
)
quota = Quota(user_id=user_id, limit_bytes=104857600, used_bytes=0)
session.add(user)
session.add(quota)
await session.commit()
await session.refresh(user)
token = create_access_token(str(user_id), role, user_agent=_TEST_USER_AGENT)
return {
"user": user,
"token": token,
"headers": {
"Authorization": f"Bearer {token}",
"User-Agent": _TEST_USER_AGENT,
},
}
async def _create_cloud_connection(
session,
user_id,
provider: str = "google_drive",
name: str = "My Drive",
status: str = "ACTIVE",
):
"""Create a CloudConnection row for mutation test fixtures.
Encrypts with the backend's actual settings key so that mutation endpoint
credential decryption works in the test environment.
"""
from db.models import CloudConnection
from storage.cloud_utils import encrypt_credentials
from config import settings
master_key = settings.cloud_creds_key.encode()
creds_enc = encrypt_credentials(
master_key,
str(user_id),
{"access_token": "tok", "refresh_token": "ref"},
)
conn = CloudConnection(
id=_uuid.uuid4(),
user_id=user_id,
provider=provider,
display_name=name,
credentials_enc=creds_enc,
status=status,
)
session.add(conn)
await session.commit()
return conn
def _make_mock_mutable_adapter(
upload_result: dict | None = None,
create_folder_result: dict | None = None,
rename_result: dict | None = None,
move_result: dict | None = None,
delete_result: dict | None = None,
):
"""Return a mock MutableCloudResourceAdapter for testing mutation routes.
Default results produce the canonical success outcomes unless overridden.
"""
from storage.cloud_base import (
MUT_KIND_UPLOADED, MUT_KIND_FOLDER, MUT_KIND_UPDATED, MUT_KIND_DELETED,
MUT_REASON_CREATED, MUT_REASON_RENAMED, MUT_REASON_MOVED, MUT_REASON_TRASHED,
)
import uuid as _uuid2
adapter = AsyncMock()
# get_object is used by preview/download routes
adapter.get_object = AsyncMock(return_value=b"fake binary content")
adapter._normalize_error = MagicMock(return_value={"kind": "error", "reason": "provider_error"})
adapter.upload_file = AsyncMock(return_value=upload_result or {
"kind": MUT_KIND_UPLOADED,
"reason": MUT_REASON_CREATED,
"provider_item_id": str(_uuid2.uuid4()),
"name": "uploaded_file.pdf",
"parent_ref": None,
"size": 42,
})
adapter.create_folder = AsyncMock(return_value=create_folder_result or {
"kind": MUT_KIND_FOLDER,
"reason": MUT_REASON_CREATED,
"provider_item_id": str(_uuid2.uuid4()),
"name": "New Folder",
"parent_ref": None,
})
adapter.rename = AsyncMock(return_value=rename_result or {
"kind": MUT_KIND_UPDATED,
"reason": MUT_REASON_RENAMED,
"provider_item_id": "fake_item_id",
"name": "Updated Report.pdf",
})
adapter.move = AsyncMock(return_value=move_result or {
"kind": MUT_KIND_UPDATED,
"reason": MUT_REASON_MOVED,
"provider_item_id": "fake_item_id",
"destination_parent_ref": "dest_folder_ref",
})
adapter.delete = AsyncMock(return_value=delete_result or {
"kind": MUT_KIND_DELETED,
"reason": MUT_REASON_TRASHED,
"provider_item_id": "fake_file_id",
})
return adapter
# ── T-13-01: Open endpoint — D-02 authorized download fallback ─────────────────
async def test_open_file_returns_authorized_download_url(async_client, db_session):
"""GET /api/cloud/connections/{id}/items/{item_id}/open returns an authorized URL.
D-02: Provider credentials and raw provider URLs must never appear in the response.
The endpoint must serve an authorized DocuVault-scoped download URL only.
FAILS: Phase 13 route does not exist yet.
"""
auth = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth["user"].id)
resp = await async_client.get(
f"/api/cloud/connections/{conn.id}/items/fake_item_id/open",
headers=auth["headers"],
)
# Phase 13 route not yet implemented — expect 404 (method-not-found)
assert resp.status_code == 200, (
f"Expected 200 with authorized URL body, got {resp.status_code}: {resp.text}"
)
body = resp.json()
assert "url" in body, "Response must include authorized download URL"
# Provider URL must never be exposed
for forbidden in ("access_token", "refresh_token", "credentials_enc", "client_secret"):
assert forbidden not in resp.text, (
f"Response must not expose '{forbidden}' (T-13-02)"
)
async def test_open_file_foreign_user_blocked(async_client, db_session):
"""User2 cannot open a file on User1's connection — IDOR protection (T-13-01).
FAILS: Phase 13 route does not exist yet.
"""
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)
resp = await async_client.get(
f"/api/cloud/connections/{conn.id}/items/fake_item_id/open",
headers=auth2["headers"],
)
assert resp.status_code == 404, (
f"Expected 404 IDOR block, got {resp.status_code}"
)
async def test_open_file_admin_blocked(async_client, db_session):
"""Admin token cannot access open endpoint — admin blocked from cloud content (T-13-01).
FAILS: Phase 13 route does not exist yet.
"""
auth_admin = await _create_user_and_token(db_session, role="admin")
auth_user = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth_user["user"].id)
resp = await async_client.get(
f"/api/cloud/connections/{conn.id}/items/fake_item_id/open",
headers=auth_admin["headers"],
)
assert resp.status_code in (403, 404), (
f"Expected admin block (403/404), got {resp.status_code}"
)
# ── T-13-02: Preview endpoint — D-18 binary-only, no device download ──────────
async def test_preview_binary_file_returns_content(async_client, db_session):
"""GET /api/cloud/connections/{id}/items/{item_id}/preview returns binary content.
D-18: Only supported binary file formats are previewed. Provider credentials
and raw URLs must never be in the response. Content streams inline — not as
a browser-to-device file download.
FAILS: Phase 13 route does not exist yet.
"""
auth = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth["user"].id)
resp = await async_client.get(
f"/api/cloud/connections/{conn.id}/items/fake_item_id/preview",
headers=auth["headers"],
)
assert resp.status_code == 200, (
f"Expected 200 with preview content, got {resp.status_code}"
)
# Must not trigger a device download
cd = resp.headers.get("content-disposition", "")
assert "attachment" not in cd, (
"Preview must not produce a Content-Disposition: attachment header (D-18)"
)
async def test_preview_unsupported_format_returns_typed_error(async_client, db_session):
"""Preview of an unsupported format returns typed kind='unsupported_preview' body.
D-18 / D-02: Unsupported formats fall back to authorized download. The
response must carry a typed {kind, reason} body so the frontend knows to
route to the authorized download flow rather than infer from a raw error.
FAILS: Phase 13 route does not exist yet.
"""
auth = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth["user"].id)
resp = await async_client.get(
f"/api/cloud/connections/{conn.id}/items/fake_docx_item/preview",
headers=auth["headers"],
)
assert resp.status_code in (200, 409, 422), (
f"Expected typed response for unsupported preview, got {resp.status_code}"
)
body = resp.json()
assert "kind" in body, "Must include 'kind' field in unsupported preview response"
assert body["kind"] == "unsupported_preview", (
f"Expected kind='unsupported_preview', got {body.get('kind')!r}"
)
assert "reason" in body, "Must include 'reason' field"
# ── Upload conflict — D-03, D-04 typed kind/reason body ─────────────────────
async def test_upload_same_name_returns_conflict_kind(async_client, db_session):
"""POST /api/cloud/connections/{id}/items/upload with same-name file returns typed conflict.
D-03: Same-name upload must NEVER overwrite silently. The response body must
carry {kind: 'conflict', reason: 'name_collision'} so the frontend can show
the conflict resolution dialog.
The mock adapter returns a conflict result to simulate same-name detection.
"""
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)
# Seed a conflicting cloud item so the fast-path conflict check triggers
from db.models import CloudItem
existing = CloudItem(
id=_uuid.uuid4(),
user_id=auth["user"].id,
connection_id=conn.id,
provider_item_id="existing_report_id",
name="report.pdf",
kind="file",
parent_ref="root",
)
db_session.add(existing)
await db_session.commit()
files = {"file": ("report.pdf", b"%PDF-1.4 fake", "application/pdf")}
data = {"parent_ref": "root", "filename": "report.pdf"}
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"Expected 200 or 409 for upload conflict signal, got {resp.status_code}"
)
body = resp.json()
assert "kind" in body, "Conflict response must include 'kind' field (D-03)"
assert body["kind"] == "conflict", f"Expected kind='conflict', got {body.get('kind')!r}"
assert "reason" in body, "Conflict response must include 'reason' field"
assert body["reason"] == "name_collision", (
f"Expected reason='name_collision', got {body.get('reason')!r}"
)
async def test_upload_response_excludes_credentials(async_client, db_session):
"""Upload response must never contain access_token, refresh_token, or credentials_enc.
T-13-02: Credential secrecy invariant must hold on every upload response shape.
Mock adapter used so the test verifies the route shape without real provider I/O.
"""
auth = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth["user"].id)
mock_adapter = _make_mock_mutable_adapter()
files = {"file": ("doc.txt", b"hello world", "text/plain")}
data = {"parent_ref": "root", "filename": "doc.txt"}
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,
)
for forbidden in ("access_token", "refresh_token", "credentials_enc", "client_secret"):
assert forbidden not in resp.text, (
f"Upload response must not expose '{forbidden}' (T-13-02)"
)
# ── Create folder — D-05 collision naming, typed conflict ───────────────────
async def test_create_folder_returns_typed_result(async_client, db_session):
"""POST /api/cloud/connections/{id}/folders creates a folder and returns typed result.
D-05: Collision auto-name produces 'Projects (1)' not an error.
The success response must include the item's kind and provider_item_id.
Mock adapter used so the test verifies the route shape without real provider I/O.
"""
auth = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth["user"].id)
mock_adapter = _make_mock_mutable_adapter()
payload = {"parent_ref": None, "name": "New Folder"}
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}/folders",
headers=auth["headers"],
json=payload,
)
assert resp.status_code == 201, (
f"Expected 201 Created for new folder, got {resp.status_code}: {resp.text}"
)
body = resp.json()
assert "provider_item_id" in body, "Response must include provider_item_id"
assert "kind" in body and body["kind"] == "folder", (
"Response must include kind='folder'"
)
async def test_create_folder_collision_returns_auto_name(async_client, db_session):
"""POST create-folder with a colliding name returns result with counter-suffixed name.
D-05: Automatic non-conflicting name: 'Projects (1)', 'Projects (2)', etc.
Must not return an error — the backend auto-resolves naming collision.
Mock adapter used so the test verifies the route shape without real provider I/O.
"""
from storage.cloud_base import MUT_KIND_FOLDER, MUT_REASON_CREATED
auth = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth["user"].id)
# Adapter auto-renames on collision — returns the suffixed name
import uuid as _uuid2
mock_adapter = _make_mock_mutable_adapter(
create_folder_result={
"kind": MUT_KIND_FOLDER,
"reason": MUT_REASON_CREATED,
"provider_item_id": str(_uuid2.uuid4()),
"name": "Projects (1)",
"parent_ref": None,
}
)
payload = {"parent_ref": None, "name": "Projects"}
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}/folders",
headers=auth["headers"],
json=payload,
)
assert resp.status_code == 201, (
f"Expected 201 with auto-named folder, got {resp.status_code}"
)
body = resp.json()
assert "name" in body, "Response must include resolved name"
# ── Rename — D-05, D-07 stale-metadata guard ────────────────────────────────
async def test_rename_item_returns_typed_result(async_client, db_session):
"""PATCH /api/cloud/connections/{id}/items/{item_id}/rename with valid new name.
Success body must include kind and updated name — no raw provider errors.
Mock adapter used so the test verifies the route shape without real provider I/O.
"""
auth = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth["user"].id)
mock_adapter = _make_mock_mutable_adapter()
payload = {"new_name": "Updated Report.pdf", "etag": "v1"}
with patch("storage.cloud_backend_factory.build_mutable_cloud_adapter", return_value=mock_adapter):
resp = await async_client.patch(
f"/api/cloud/connections/{conn.id}/items/fake_item_id/rename",
headers=auth["headers"],
json=payload,
)
assert resp.status_code == 200, (
f"Expected 200 for rename, got {resp.status_code}: {resp.text}"
)
body = resp.json()
assert "kind" in body, "Rename response must include 'kind' field"
async def test_rename_stale_etag_returns_stale_kind(async_client, db_session):
"""Rename with a stale etag returns typed kind='stale' body (D-07).
The backend must detect externally-changed metadata, stop the mutation,
return {kind: 'stale', reason: 'item_changed'}, and require a retry
after the user acknowledges the listing refresh.
Mock adapter simulates stale-etag detection.
"""
from storage.cloud_base import MUT_KIND_STALE, MUT_REASON_ITEM_CHANGED
auth = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth["user"].id)
mock_adapter = _make_mock_mutable_adapter(
rename_result={"kind": MUT_KIND_STALE, "reason": MUT_REASON_ITEM_CHANGED}
)
payload = {"new_name": "Report.pdf", "etag": "stale-etag-abc"}
with patch("storage.cloud_backend_factory.build_mutable_cloud_adapter", return_value=mock_adapter):
resp = await async_client.patch(
f"/api/cloud/connections/{conn.id}/items/fake_item_id/rename",
headers=auth["headers"],
json=payload,
)
assert resp.status_code == 409, (
f"Expected 409 Conflict for stale rename, got {resp.status_code}"
)
body = resp.json()
assert "kind" in body and body["kind"] == "stale", (
f"Expected kind='stale', got {body.get('kind')!r}"
)
assert "reason" in body and body["reason"] == "item_changed", (
f"Expected reason='item_changed', got {body.get('reason')!r}"
)
async def test_rename_foreign_user_blocked(async_client, db_session):
"""User2 cannot rename an item on User1's connection — IDOR (T-13-01).
FAILS: Phase 13 route does not exist yet.
"""
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)
payload = {"new_name": "Hacked.pdf", "etag": "v1"}
resp = await async_client.patch(
f"/api/cloud/connections/{conn.id}/items/fake_item_id/rename",
headers=auth2["headers"],
json=payload,
)
assert resp.status_code == 404, (
f"Expected 404 IDOR block, got {resp.status_code}"
)
# ── Move — D-08, D-09 same-connection, invalid-destination ──────────────────
async def test_move_item_same_connection_succeeds(async_client, db_session):
"""POST /api/cloud/connections/{id}/items/{item_id}/move within same connection.
D-08: Moves are restricted to items within the same cloud connection.
Success returns typed body with updated parent_ref.
Mock adapter used so the test verifies the route shape without real provider I/O.
"""
auth = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth["user"].id)
mock_adapter = _make_mock_mutable_adapter()
payload = {
"destination_parent_ref": "dest_folder_ref",
"etag": "v1",
}
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/fake_item_id/move",
headers=auth["headers"],
json=payload,
)
assert resp.status_code == 200, (
f"Expected 200 for move, got {resp.status_code}: {resp.text}"
)
body = resp.json()
assert "kind" in body, "Move response must include 'kind' field"
async def test_move_item_self_as_destination_rejected(async_client, db_session):
"""Moving a folder into itself returns typed kind='invalid_destination' (D-09).
Invalid destinations (self, descendants) must be rejected by the backend
even if the frontend pre-screens them.
FAILS: Phase 13 route does not exist yet.
"""
auth = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth["user"].id)
payload = {
"destination_parent_ref": "fake_item_id", # same as source
"etag": "v1",
}
resp = await async_client.post(
f"/api/cloud/connections/{conn.id}/items/fake_item_id/move",
headers=auth["headers"],
json=payload,
)
assert resp.status_code in (400, 409, 422), (
f"Expected rejection for self-destination move, got {resp.status_code}"
)
body = resp.json()
assert "kind" in body and body["kind"] == "invalid_destination", (
f"Expected kind='invalid_destination', got {body.get('kind')!r}"
)
async def test_move_item_cross_connection_rejected(async_client, db_session):
"""Move across different connections returns typed kind='invalid_destination' (D-08).
Cross-provider transfer is explicitly out of scope. The backend must reject
any destination_connection_id that does not match the source connection.
FAILS: Phase 13 route does not exist yet.
"""
auth = await _create_user_and_token(db_session)
conn1 = await _create_cloud_connection(db_session, auth["user"].id, name="Drive 1")
conn2 = await _create_cloud_connection(db_session, auth["user"].id, name="Drive 2")
payload = {
"destination_parent_ref": "some_folder_in_conn2",
"destination_connection_id": str(conn2.id), # different connection
"etag": "v1",
}
resp = await async_client.post(
f"/api/cloud/connections/{conn1.id}/items/fake_item_id/move",
headers=auth["headers"],
json=payload,
)
assert resp.status_code in (400, 409, 422), (
f"Expected rejection for cross-connection move, got {resp.status_code}"
)
body = resp.json()
assert "kind" in body and body["kind"] == "invalid_destination", (
f"Expected kind='invalid_destination', got {body.get('kind')!r}"
)
# ── Delete — D-10, D-11 confirmation, trash vs permanent ────────────────────
async def test_delete_file_returns_typed_result(async_client, db_session):
"""DELETE /api/cloud/connections/{id}/items/{item_id} returns typed result.
D-11: Response must indicate whether trash or permanent delete was performed
via {kind: 'deleted', reason: 'trashed'} or {kind: 'deleted', reason: 'permanent'}.
Mock adapter used so the test verifies the route shape without real provider I/O.
"""
auth = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth["user"].id)
mock_adapter = _make_mock_mutable_adapter()
with patch("storage.cloud_backend_factory.build_mutable_cloud_adapter", return_value=mock_adapter):
resp = await async_client.delete(
f"/api/cloud/connections/{conn.id}/items/fake_file_id",
headers=auth["headers"],
)
assert resp.status_code in (200, 204), (
f"Expected 200/204 for delete, got {resp.status_code}: {resp.text}"
)
if resp.status_code == 200:
body = resp.json()
assert "kind" in body and body["kind"] == "deleted", (
f"Expected kind='deleted', got {body.get('kind')!r}"
)
assert "reason" in body and body["reason"] in ("trashed", "permanent"), (
f"Expected reason 'trashed' or 'permanent', got {body.get('reason')!r}"
)
async def test_delete_foreign_user_blocked(async_client, db_session):
"""User2 cannot delete items on User1's connection (T-13-01).
FAILS: Phase 13 route does not exist yet.
"""
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)
resp = await async_client.delete(
f"/api/cloud/connections/{conn.id}/items/fake_file_id",
headers=auth2["headers"],
)
assert resp.status_code == 404, (
f"Expected 404 IDOR block, got {resp.status_code}"
)
async def test_delete_response_excludes_provider_urls_and_tokens(async_client, db_session):
"""Delete response must not expose provider URLs, tokens, or credentials_enc (T-13-02).
Mock adapter used so the test verifies the credential-secrecy invariant
without real provider I/O.
"""
auth = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth["user"].id)
mock_adapter = _make_mock_mutable_adapter()
with patch("storage.cloud_backend_factory.build_mutable_cloud_adapter", return_value=mock_adapter):
resp = await async_client.delete(
f"/api/cloud/connections/{conn.id}/items/fake_file_id",
headers=auth["headers"],
)
for forbidden in ("access_token", "refresh_token", "credentials_enc", "client_secret"):
assert forbidden not in resp.text, (
f"Delete response must not expose '{forbidden}' (T-13-02)"
)
# ── Typed conflict/error kinds — T-13-03 ─────────────────────────────────────
async def test_mutation_offline_connection_returns_offline_kind(async_client, db_session):
"""Any mutation on an offline connection returns a typed non-500 body.
D-15: Transient provider unreachability must not destroy data. The response
must carry a typed kind body so the frontend can show actionable retry UI.
Mock adapter simulates offline/unreachable provider.
"""
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, status="ACTIVE")
mock_adapter = _make_mock_mutable_adapter(
rename_result={"kind": MUT_KIND_OFFLINE, "reason": MUT_REASON_PROVIDER_OFFLINE}
)
payload = {"new_name": "Report.pdf", "etag": "v1"}
with patch("storage.cloud_backend_factory.build_mutable_cloud_adapter", return_value=mock_adapter):
resp = await async_client.patch(
f"/api/cloud/connections/{conn.id}/items/fake_item_id/rename",
headers=auth["headers"],
json=payload,
)
# Offline provider must return typed error body, not 500
assert resp.status_code != 500, (
"Offline provider must not return a 500 — use typed kind='offline' body"
)
async def test_mutation_reauth_required_returns_reauth_kind(async_client, db_session):
"""Mutation with expired credentials returns typed kind='reauth_required' body.
D-13: Credential-related failures trigger automatic health re-evaluation.
The response must carry {kind: 'reauth_required', reason: 'token_expired'}.
Uses an AUTH_FAILED connection that triggers the reauth response path.
"""
auth = await _create_user_and_token(db_session)
# AUTH_FAILED connections return reauth_required when rename is attempted.
# The mock adapter simulates the reauth result.
conn = await _create_cloud_connection(db_session, auth["user"].id, status="AUTH_FAILED")
from storage.cloud_base import MUT_KIND_REAUTH, MUT_REASON_TOKEN_EXPIRED
mock_adapter = _make_mock_mutable_adapter(
rename_result={"kind": MUT_KIND_REAUTH, "reason": MUT_REASON_TOKEN_EXPIRED}
)
payload = {"new_name": "Report.pdf", "etag": "v1"}
with patch("storage.cloud_backend_factory.build_mutable_cloud_adapter", return_value=mock_adapter):
resp = await async_client.patch(
f"/api/cloud/connections/{conn.id}/items/fake_item_id/rename",
headers=auth["headers"],
json=payload,
)
assert resp.status_code in (200, 401, 409), (
f"Expected typed reauth response, got {resp.status_code}"
)
if resp.status_code != 200:
body = resp.json()
assert "kind" in body and body["kind"] == "reauth_required", (
f"Expected kind='reauth_required', got {body.get('kind')!r}"
)
# ── Unsupported operation — D-18 ─────────────────────────────────────────────
async def test_unsupported_operation_returns_typed_kind(async_client, db_session):
"""Operation not supported by provider returns typed kind='unsupported_operation'.
Providers that cannot honor a mutation (e.g. read-only WebDAV) must return
{kind: 'unsupported_operation', reason: 'provider_unsupported'} rather than
a 500 error or silent pass.
Mock adapter simulates an unsupported provider.
"""
from storage.cloud_base import MUT_KIND_UNSUPPORTED, MUT_REASON_NOT_SUPPORTED
auth = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth["user"].id, provider="webdav")
mock_adapter = _make_mock_mutable_adapter(
create_folder_result={"kind": MUT_KIND_UNSUPPORTED, "reason": MUT_REASON_NOT_SUPPORTED}
)
payload = {"parent_ref": None, "name": "NewFolder"}
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}/folders",
headers=auth["headers"],
json=payload,
)
assert resp.status_code != 500, (
"Unsupported operation must return typed kind body, not 500 (D-18)"
)
if resp.status_code in (200, 201, 409, 422):
body = resp.json()
if "kind" in body:
assert body["kind"] in ("folder", "unsupported_operation"), (
f"Unexpected kind value: {body.get('kind')!r}"
)