test(13-01): add red API and audit contracts for reconnect, content, and mutation flows
- Create test_cloud_mutations.py: IDOR, admin block, credential secrecy, and typed kind/reason body coverage for open, preview, upload, create-folder, rename, move, and delete endpoint contracts (D-02 through D-11, D-18) - Create test_cloud_reconnect.py: reconnect patch-in-place (CONN-01), encrypted credential persistence (CONN-02), response secrecy (CONN-03), health endpoint, test action, cache-preservation on reconnect (D-14), transient-outage data preservation (D-15), and disconnect metadata cleanup (D-16) - Create test_cloud_audit.py: metadata-only audit rows for every successful cloud operation, false-overwrite prevention (T-13-05), admin audit log credential exclusion (T-13-02) - All tests fail against current codebase — Phase 13 routes do not exist yet (expected RED)
This commit is contained in:
@@ -0,0 +1,549 @@
|
||||
"""
|
||||
Phase 13 Plan 01 — TDD RED: Metadata-only audit trail contracts for cloud operations.
|
||||
|
||||
Covers T-13-02 and T-13-05 — audit secrecy and accuracy:
|
||||
- Successful cloud operations must write audit rows with metadata-only payloads.
|
||||
- No provider URL, access_token, refresh_token, credentials_enc, or provider-
|
||||
owned bytes may appear in any audit row's metadata_ JSONB column.
|
||||
- Audit rows must accurately reflect the operation performed (no false overwrite events).
|
||||
- Audit event types must be well-defined and consistently named across operations.
|
||||
- Admin audit log viewer must never expose cloud credentials through audit entries.
|
||||
|
||||
Covered operations:
|
||||
reconnect, test, open, preview, upload (success + conflict), create_folder,
|
||||
rename (success + stale), move (success + invalid), delete.
|
||||
|
||||
All tests FAIL against the current codebase because Phase 13 audit writes
|
||||
do not yet exist.
|
||||
|
||||
Requirements: CLOUD-02 through CLOUD-07, CLOUD-09
|
||||
Threats: T-13-02 (content integrity), T-13-05 (audit trail accuracy)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid as _uuid
|
||||
from typing import Optional
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
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"aud_user_{user_id.hex[:8]}",
|
||||
email=f"aud_{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 audit test fixtures."""
|
||||
from db.models import CloudConnection
|
||||
from storage.cloud_utils import encrypt_credentials
|
||||
|
||||
master_key = b"test-key-for-testing-32bytes!!"
|
||||
creds_enc = encrypt_credentials(
|
||||
master_key,
|
||||
str(user_id),
|
||||
{"access_token": "ya29.audit_tok", "refresh_token": "1//audit_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
|
||||
|
||||
|
||||
async def _get_recent_audit_rows(session, user_id, event_type: Optional[str] = None, limit: int = 10):
|
||||
"""Fetch recent AuditLog rows for a user, optionally filtered by event_type."""
|
||||
from db.models import AuditLog
|
||||
from sqlalchemy import desc
|
||||
|
||||
q = select(AuditLog).where(AuditLog.user_id == user_id).order_by(desc(AuditLog.id)).limit(limit)
|
||||
if event_type:
|
||||
q = q.where(AuditLog.event_type == event_type)
|
||||
result = await session.execute(q)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
def _assert_metadata_no_secrets(metadata: dict, context: str):
|
||||
"""Assert that an audit metadata dict contains no credential or secret fields."""
|
||||
if metadata is None:
|
||||
return
|
||||
forbidden_keys = {
|
||||
"access_token", "refresh_token", "credentials_enc",
|
||||
"client_secret", "client_id", "password",
|
||||
}
|
||||
for key in forbidden_keys:
|
||||
assert key not in metadata, (
|
||||
f"Audit metadata must not contain '{key}' ({context}) — T-13-02"
|
||||
)
|
||||
|
||||
# Also check nested values as strings
|
||||
metadata_str = str(metadata)
|
||||
for token_prefix in ("ya29.", "1//", "Bearer "):
|
||||
assert token_prefix not in metadata_str, (
|
||||
f"Audit metadata must not contain raw token value starting with '{token_prefix}' "
|
||||
f"({context}) — T-13-02"
|
||||
)
|
||||
|
||||
|
||||
# ── T-13-05: Reconnect audit row ──────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_reconnect_writes_metadata_only_audit_row(async_client, db_session):
|
||||
"""POST reconnect writes an audit row with event_type='cloud.reconnect'.
|
||||
|
||||
T-13-05: Successful reconnect must be audited. The metadata_ column must
|
||||
contain only the connection_id and provider — no tokens or credentials.
|
||||
|
||||
FAILS: Phase 13 audit write does not exist yet.
|
||||
"""
|
||||
auth = await _create_user_and_token(db_session)
|
||||
conn = await _create_cloud_connection(db_session, auth["user"].id, status="AUTH_FAILED")
|
||||
|
||||
resp = await async_client.post(
|
||||
f"/api/cloud/connections/{conn.id}/reconnect",
|
||||
headers=auth["headers"],
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code in (200, 202), (
|
||||
f"Expected 200/202 for reconnect, got {resp.status_code}: {resp.text}"
|
||||
)
|
||||
|
||||
# Exactly one reconnect audit row must exist for this user
|
||||
rows = await _get_recent_audit_rows(db_session, auth["user"].id, event_type="cloud.reconnect")
|
||||
assert len(rows) >= 1, (
|
||||
"Reconnect must write an audit row with event_type='cloud.reconnect' (T-13-05)"
|
||||
)
|
||||
|
||||
row = rows[0]
|
||||
assert row.resource_id == conn.id, (
|
||||
"Audit row resource_id must be the connection UUID"
|
||||
)
|
||||
_assert_metadata_no_secrets(row.metadata_, "cloud.reconnect audit")
|
||||
|
||||
# Required metadata fields: connection_id and provider at minimum
|
||||
assert row.metadata_ is not None, "Reconnect audit row must have metadata_"
|
||||
assert "provider" in row.metadata_, (
|
||||
"Reconnect audit metadata must include 'provider' field"
|
||||
)
|
||||
|
||||
|
||||
# ── T-13-05: Open file audit row ──────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_open_file_writes_metadata_only_audit_row(async_client, db_session):
|
||||
"""GET /items/{id}/open writes an audit row with event_type='cloud.file_opened'.
|
||||
|
||||
T-13-05: File open must be audited with metadata only — no bytes, no provider
|
||||
URL, no tokens. Metadata must include connection_id, item_id (DocuVault UUID),
|
||||
and provider_item_id (opaque).
|
||||
|
||||
FAILS: Phase 13 audit write 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"],
|
||||
)
|
||||
# Route may not exist yet (404 expected), but audit test structure is defined
|
||||
assert resp.status_code in (200, 404), (
|
||||
f"Unexpected status: {resp.status_code}"
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
rows = await _get_recent_audit_rows(
|
||||
db_session, auth["user"].id, event_type="cloud.file_opened"
|
||||
)
|
||||
assert len(rows) >= 1, (
|
||||
"File open must write an audit row with event_type='cloud.file_opened'"
|
||||
)
|
||||
_assert_metadata_no_secrets(rows[0].metadata_, "cloud.file_opened audit")
|
||||
|
||||
|
||||
# ── T-13-05: Upload audit row ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_upload_success_writes_metadata_only_audit_row(async_client, db_session):
|
||||
"""POST upload writes audit row 'cloud.file_uploaded' with metadata only.
|
||||
|
||||
T-13-05: Upload must be audited. Metadata_ must include filename, size_bytes,
|
||||
connection_id, and provider_item_id — never the raw file bytes or provider token.
|
||||
|
||||
FAILS: Phase 13 audit write does not exist yet.
|
||||
"""
|
||||
auth = await _create_user_and_token(db_session)
|
||||
conn = await _create_cloud_connection(db_session, auth["user"].id)
|
||||
|
||||
files = {"file": ("report.pdf", b"%PDF-1.4 fake content", "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, 201, 404, 409), (
|
||||
f"Unexpected status: {resp.status_code}"
|
||||
)
|
||||
if resp.status_code in (200, 201):
|
||||
rows = await _get_recent_audit_rows(
|
||||
db_session, auth["user"].id, event_type="cloud.file_uploaded"
|
||||
)
|
||||
assert len(rows) >= 1, (
|
||||
"Successful upload must write audit row 'cloud.file_uploaded' (T-13-05)"
|
||||
)
|
||||
row = rows[0]
|
||||
_assert_metadata_no_secrets(row.metadata_, "cloud.file_uploaded audit")
|
||||
|
||||
# Must include metadata about what was uploaded
|
||||
assert row.metadata_ is not None
|
||||
assert "filename" in row.metadata_ or "name" in row.metadata_, (
|
||||
"Upload audit row must include filename or name in metadata_"
|
||||
)
|
||||
# Must NOT include raw file bytes
|
||||
assert "file_bytes" not in str(row.metadata_), (
|
||||
"Upload audit metadata must never include raw file bytes (T-13-02)"
|
||||
)
|
||||
|
||||
|
||||
async def test_upload_conflict_does_not_write_false_overwrite_audit(async_client, db_session):
|
||||
"""Upload conflict does NOT write a false 'cloud.file_uploaded' audit row.
|
||||
|
||||
T-13-05: Audit trail accuracy. A conflict that was not resolved must not
|
||||
produce a 'cloud.file_uploaded' event — that would be a false audit record.
|
||||
|
||||
FAILS: Phase 13 audit write does not exist yet.
|
||||
"""
|
||||
from db.models import AuditLog
|
||||
|
||||
auth = await _create_user_and_token(db_session)
|
||||
conn = await _create_cloud_connection(db_session, auth["user"].id)
|
||||
|
||||
files = {"file": ("duplicate.pdf", b"%PDF-1.4 duplicate", "application/pdf")}
|
||||
data = {"parent_ref": "root", "filename": "duplicate.pdf"}
|
||||
|
||||
# Count audit rows before the upload
|
||||
before = await _get_recent_audit_rows(
|
||||
db_session, auth["user"].id, event_type="cloud.file_uploaded"
|
||||
)
|
||||
before_count = len(before)
|
||||
|
||||
resp = await async_client.post(
|
||||
f"/api/cloud/connections/{conn.id}/items/upload",
|
||||
headers=auth["headers"],
|
||||
files=files,
|
||||
data=data,
|
||||
)
|
||||
# Conflict response expected
|
||||
if resp.status_code == 409 or (resp.status_code == 200 and resp.json().get("kind") == "conflict"):
|
||||
after = await _get_recent_audit_rows(
|
||||
db_session, auth["user"].id, event_type="cloud.file_uploaded"
|
||||
)
|
||||
assert len(after) == before_count, (
|
||||
"A conflict that was not resolved must not write a false 'cloud.file_uploaded' "
|
||||
"audit event (T-13-05)"
|
||||
)
|
||||
|
||||
|
||||
# ── T-13-05: Create folder audit row ─────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_create_folder_writes_metadata_only_audit_row(async_client, db_session):
|
||||
"""POST create-folder writes audit row 'cloud.folder_created' with metadata only.
|
||||
|
||||
T-13-05: Folder creation must be audited. Metadata must include connection_id,
|
||||
name, parent_ref, and provider_item_id — never provider tokens.
|
||||
|
||||
FAILS: Phase 13 audit write does not exist yet.
|
||||
"""
|
||||
auth = await _create_user_and_token(db_session)
|
||||
conn = await _create_cloud_connection(db_session, auth["user"].id)
|
||||
|
||||
payload = {"parent_ref": None, "name": "My New Folder"}
|
||||
resp = await async_client.post(
|
||||
f"/api/cloud/connections/{conn.id}/folders",
|
||||
headers=auth["headers"],
|
||||
json=payload,
|
||||
)
|
||||
assert resp.status_code in (201, 404), (
|
||||
f"Unexpected status: {resp.status_code}"
|
||||
)
|
||||
if resp.status_code == 201:
|
||||
rows = await _get_recent_audit_rows(
|
||||
db_session, auth["user"].id, event_type="cloud.folder_created"
|
||||
)
|
||||
assert len(rows) >= 1, (
|
||||
"Create folder must write audit row 'cloud.folder_created' (T-13-05)"
|
||||
)
|
||||
_assert_metadata_no_secrets(rows[0].metadata_, "cloud.folder_created audit")
|
||||
assert rows[0].metadata_ is not None
|
||||
assert "name" in rows[0].metadata_, (
|
||||
"Folder creation audit metadata must include 'name'"
|
||||
)
|
||||
|
||||
|
||||
# ── T-13-05: Rename audit row ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_rename_success_writes_audit_row(async_client, db_session):
|
||||
"""Successful rename writes audit row 'cloud.item_renamed' (T-13-05).
|
||||
|
||||
Metadata must include connection_id, old_name, new_name — never token values.
|
||||
|
||||
FAILS: Phase 13 audit write does not exist yet.
|
||||
"""
|
||||
auth = await _create_user_and_token(db_session)
|
||||
conn = await _create_cloud_connection(db_session, auth["user"].id)
|
||||
|
||||
payload = {"new_name": "Updated Report.pdf", "etag": "v1"}
|
||||
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, 404), (
|
||||
f"Unexpected status: {resp.status_code}"
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
rows = await _get_recent_audit_rows(
|
||||
db_session, auth["user"].id, event_type="cloud.item_renamed"
|
||||
)
|
||||
assert len(rows) >= 1, (
|
||||
"Successful rename must write audit row 'cloud.item_renamed' (T-13-05)"
|
||||
)
|
||||
_assert_metadata_no_secrets(rows[0].metadata_, "cloud.item_renamed audit")
|
||||
assert rows[0].metadata_ is not None
|
||||
assert "new_name" in rows[0].metadata_, (
|
||||
"Rename audit metadata must include 'new_name'"
|
||||
)
|
||||
|
||||
|
||||
async def test_rename_stale_does_not_write_false_rename_audit(async_client, db_session):
|
||||
"""Stale rename must NOT write a false 'cloud.item_renamed' audit event (T-13-05).
|
||||
|
||||
If the rename was rejected due to stale metadata, no renamed event should be logged.
|
||||
|
||||
FAILS: Phase 13 audit write does not exist yet.
|
||||
"""
|
||||
auth = await _create_user_and_token(db_session)
|
||||
conn = await _create_cloud_connection(db_session, auth["user"].id)
|
||||
|
||||
before = await _get_recent_audit_rows(
|
||||
db_session, auth["user"].id, event_type="cloud.item_renamed"
|
||||
)
|
||||
before_count = len(before)
|
||||
|
||||
payload = {"new_name": "Report.pdf", "etag": "stale-etag-xyz"}
|
||||
resp = await async_client.patch(
|
||||
f"/api/cloud/connections/{conn.id}/items/fake_item_id/rename",
|
||||
headers=auth["headers"],
|
||||
json=payload,
|
||||
)
|
||||
if resp.status_code == 409 or (
|
||||
resp.status_code == 200 and resp.json().get("kind") == "stale"
|
||||
):
|
||||
after = await _get_recent_audit_rows(
|
||||
db_session, auth["user"].id, event_type="cloud.item_renamed"
|
||||
)
|
||||
assert len(after) == before_count, (
|
||||
"A rejected (stale) rename must not write a false 'cloud.item_renamed' audit event"
|
||||
)
|
||||
|
||||
|
||||
# ── T-13-05: Move audit row ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_move_success_writes_audit_row(async_client, db_session):
|
||||
"""Successful move writes audit row 'cloud.item_moved' with metadata only (T-13-05).
|
||||
|
||||
Metadata must include connection_id, old_parent_ref, new_parent_ref — no tokens.
|
||||
|
||||
FAILS: Phase 13 audit write 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": "dest_folder_ref", "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 (200, 404), (
|
||||
f"Unexpected status: {resp.status_code}"
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
rows = await _get_recent_audit_rows(
|
||||
db_session, auth["user"].id, event_type="cloud.item_moved"
|
||||
)
|
||||
assert len(rows) >= 1, (
|
||||
"Successful move must write audit row 'cloud.item_moved' (T-13-05)"
|
||||
)
|
||||
_assert_metadata_no_secrets(rows[0].metadata_, "cloud.item_moved audit")
|
||||
|
||||
|
||||
# ── T-13-05: Delete audit row ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_delete_success_writes_audit_row(async_client, db_session):
|
||||
"""Successful delete writes audit row 'cloud.item_deleted' with metadata only (T-13-05).
|
||||
|
||||
Metadata must include connection_id, item_id, delete_kind ('trashed' or 'permanent').
|
||||
|
||||
FAILS: Phase 13 audit write 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.delete(
|
||||
f"/api/cloud/connections/{conn.id}/items/fake_file_id",
|
||||
headers=auth["headers"],
|
||||
)
|
||||
assert resp.status_code in (200, 204, 404), (
|
||||
f"Unexpected status: {resp.status_code}"
|
||||
)
|
||||
if resp.status_code in (200, 204):
|
||||
rows = await _get_recent_audit_rows(
|
||||
db_session, auth["user"].id, event_type="cloud.item_deleted"
|
||||
)
|
||||
assert len(rows) >= 1, (
|
||||
"Successful delete must write audit row 'cloud.item_deleted' (T-13-05)"
|
||||
)
|
||||
_assert_metadata_no_secrets(rows[0].metadata_, "cloud.item_deleted audit")
|
||||
if rows[0].metadata_:
|
||||
assert "delete_kind" in rows[0].metadata_ or "reason" in rows[0].metadata_, (
|
||||
"Delete audit metadata must indicate 'trashed' vs 'permanent' (D-11)"
|
||||
)
|
||||
|
||||
|
||||
# ── T-13-02: Admin audit log must not expose cloud credentials ─────────────────
|
||||
|
||||
|
||||
async def test_admin_audit_log_never_exposes_cloud_credentials(async_client, db_session):
|
||||
"""Admin audit log viewer must not expose any cloud credentials in metadata_.
|
||||
|
||||
T-13-02: Even if an audit row was written (legitimately or by a bug), the
|
||||
admin audit log viewer endpoint must not surface access_token, refresh_token,
|
||||
credentials_enc, or client_secret in any returned audit item.
|
||||
|
||||
Seeds a crafted audit row that contains sensitive fields in metadata_ to
|
||||
verify the API scrubs or rejects it.
|
||||
"""
|
||||
from services.audit import write_audit_log
|
||||
|
||||
auth_admin = await _create_user_and_token(db_session, role="admin")
|
||||
auth_user = await _create_user_and_token(db_session, role="user")
|
||||
|
||||
# Deliberately inject a "bad" audit row that contains sensitive fields
|
||||
# (simulating a bug in a Phase 13 implementation that leaks tokens)
|
||||
await write_audit_log(
|
||||
session=db_session,
|
||||
event_type="cloud.reconnect",
|
||||
user_id=auth_user["user"].id,
|
||||
actor_id=auth_user["user"].id,
|
||||
resource_id=None,
|
||||
ip_address=None,
|
||||
metadata_={
|
||||
"provider": "google_drive",
|
||||
"access_token": "ya29.SHOULD_NOT_APPEAR", # should be scrubbed
|
||||
"refresh_token": "1//SHOULD_NOT_APPEAR", # should be scrubbed
|
||||
},
|
||||
)
|
||||
await db_session.commit()
|
||||
|
||||
resp = await async_client.get(
|
||||
"/api/admin/audit-log",
|
||||
headers=auth_admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, f"Expected 200, got {resp.status_code}"
|
||||
|
||||
body_text = resp.text
|
||||
for forbidden in ("ya29.SHOULD_NOT_APPEAR", "1//SHOULD_NOT_APPEAR", "access_token", "refresh_token"):
|
||||
assert forbidden not in body_text, (
|
||||
f"Admin audit log must not expose '{forbidden}' in response (T-13-02)"
|
||||
)
|
||||
|
||||
|
||||
# ── T-13-02: Audit metadata must never contain provider bytes ─────────────────
|
||||
|
||||
|
||||
async def test_audit_metadata_never_contains_binary_content(async_client, db_session):
|
||||
"""Audit rows for open/preview must never include raw provider bytes.
|
||||
|
||||
T-13-02: Provider-owned bytes must never enter audit payloads, logs, or
|
||||
broker payloads. This test asserts that post-operation audit metadata
|
||||
cannot contain binary content (base64-encoded or raw).
|
||||
|
||||
FAILS: Phase 13 audit write does not exist yet.
|
||||
"""
|
||||
import base64
|
||||
from db.models import AuditLog
|
||||
|
||||
auth = await _create_user_and_token(db_session)
|
||||
conn = await _create_cloud_connection(db_session, auth["user"].id)
|
||||
|
||||
# Make an open request
|
||||
resp = await async_client.get(
|
||||
f"/api/cloud/connections/{conn.id}/items/fake_item_id/open",
|
||||
headers=auth["headers"],
|
||||
)
|
||||
# Regardless of status, check any audit rows written
|
||||
rows = await _get_recent_audit_rows(db_session, auth["user"].id)
|
||||
for row in rows:
|
||||
if row.metadata_ is None:
|
||||
continue
|
||||
meta_str = str(row.metadata_)
|
||||
# Check for base64-encoded binary content (> 200 chars of base64 alphabet)
|
||||
import re
|
||||
b64_like = re.findall(r"[A-Za-z0-9+/=]{200,}", meta_str)
|
||||
assert len(b64_like) == 0, (
|
||||
f"Audit row {row.id} appears to contain base64-encoded binary content — "
|
||||
f"raw bytes must never enter audit payloads (T-13-02)"
|
||||
)
|
||||
@@ -0,0 +1,653 @@
|
||||
"""
|
||||
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."""
|
||||
from db.models import CloudConnection
|
||||
from storage.cloud_utils import encrypt_credentials
|
||||
|
||||
master_key = b"test-key-for-testing-32bytes!!"
|
||||
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
|
||||
|
||||
|
||||
# ── 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.
|
||||
|
||||
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)
|
||||
|
||||
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.
|
||||
|
||||
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)
|
||||
|
||||
files = {"file": ("doc.txt", b"hello world", "text/plain")}
|
||||
data = {"parent_ref": "root", "filename": "doc.txt"}
|
||||
|
||||
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.
|
||||
|
||||
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 = {"parent_ref": None, "name": "New Folder"}
|
||||
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.
|
||||
|
||||
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 = {"parent_ref": None, "name": "Projects"}
|
||||
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()
|
||||
# Name must not be the exact colliding name (auto-renamed)
|
||||
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.
|
||||
|
||||
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 = {"new_name": "Updated Report.pdf", "etag": "v1"}
|
||||
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.
|
||||
|
||||
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 = {"new_name": "Report.pdf", "etag": "stale-etag-abc"}
|
||||
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.
|
||||
|
||||
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": "dest_folder_ref",
|
||||
"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 == 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'}.
|
||||
|
||||
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.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).
|
||||
|
||||
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.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 typed kind='offline' body.
|
||||
|
||||
D-15: Transient provider unreachability must not destroy data. The response
|
||||
must carry {kind: 'offline', reason: 'provider_unreachable'} so the frontend
|
||||
can show actionable retry UI.
|
||||
|
||||
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, status="ACTIVE")
|
||||
|
||||
payload = {"new_name": "Report.pdf", "etag": "v1"}
|
||||
resp = await async_client.patch(
|
||||
f"/api/cloud/connections/{conn.id}/items/fake_item_id/rename",
|
||||
headers=auth["headers"],
|
||||
json=payload,
|
||||
)
|
||||
# Test that the route exists and understands offline semantics
|
||||
# (this verifies that when provider is unreachable, kind='offline' is returned)
|
||||
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'}.
|
||||
|
||||
FAILS: Phase 13 route does not exist yet.
|
||||
"""
|
||||
auth = await _create_user_and_token(db_session)
|
||||
# Create a connection with expired/invalid credentials
|
||||
conn = await _create_cloud_connection(db_session, auth["user"].id, status="AUTH_FAILED")
|
||||
|
||||
payload = {"new_name": "Report.pdf", "etag": "v1"}
|
||||
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.
|
||||
|
||||
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, provider="webdav")
|
||||
|
||||
payload = {"parent_ref": None, "name": "NewFolder"}
|
||||
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}"
|
||||
)
|
||||
@@ -0,0 +1,561 @@
|
||||
"""
|
||||
Phase 13 Plan 01 — TDD RED: Reconnect, health, cache invalidation, and credential-refresh persistence contracts.
|
||||
|
||||
Covers D-12 through D-16 and CONN-01 through CONN-03:
|
||||
- D-12: Connection health visible in cloud browser and Settings (compact + full).
|
||||
- D-13: Automatic health re-evaluation after credential-related failures.
|
||||
Explicit Test action available. No probing on every folder navigation.
|
||||
- D-14: Successful reconnect keeps stale metadata visible, invalidates provider/
|
||||
listing/capability caches, immediately refreshes current folder.
|
||||
- D-15: Transient outage: preserve credentials and cached metadata, show
|
||||
actionable warning, allow retry/reconnect; never delete data on timeout.
|
||||
- D-16: Explicit disconnect confirmation: removes credentials and connection-scoped
|
||||
cloud metadata; leaves provider files untouched.
|
||||
- CONN-01: reconnect patches the existing CloudConnection row in-place (no new row).
|
||||
- CONN-02: refreshed access_token and refresh_token are encrypted and persisted.
|
||||
- CONN-03: a reconnect response must never expose raw credentials or provider URLs.
|
||||
|
||||
All tests FAIL against the current codebase because Phase 13 reconnect and
|
||||
health routes do not yet exist.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid as _uuid
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
|
||||
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"rc_user_{user_id.hex[:8]}",
|
||||
email=f"rc_{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 reconnect test fixtures."""
|
||||
from db.models import CloudConnection
|
||||
from storage.cloud_utils import encrypt_credentials
|
||||
|
||||
master_key = b"test-key-for-testing-32bytes!!"
|
||||
creds_enc = encrypt_credentials(
|
||||
master_key,
|
||||
str(user_id),
|
||||
{"access_token": "old_tok", "refresh_token": "old_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
|
||||
|
||||
|
||||
# ── CONN-01: Reconnect patches the existing row — no new row created ──────────
|
||||
|
||||
|
||||
async def test_reconnect_patches_existing_row(async_client, db_session):
|
||||
"""POST /api/cloud/connections/{id}/reconnect patches the existing CloudConnection row.
|
||||
|
||||
CONN-01: reconnect must update the existing row in-place. Creating a new row
|
||||
would break item identity: all CloudItems reference the connection by UUID.
|
||||
A new row would orphan every cached item.
|
||||
|
||||
FAILS: Phase 13 route does not exist yet.
|
||||
"""
|
||||
from db.models import CloudConnection
|
||||
|
||||
auth = await _create_user_and_token(db_session)
|
||||
conn = await _create_cloud_connection(db_session, auth["user"].id, status="AUTH_FAILED")
|
||||
|
||||
original_conn_id = conn.id
|
||||
initial_count = (
|
||||
await db_session.execute(
|
||||
select(CloudConnection).where(CloudConnection.user_id == auth["user"].id)
|
||||
)
|
||||
).scalars().all()
|
||||
|
||||
payload = {"provider": "google_drive"}
|
||||
resp = await async_client.post(
|
||||
f"/api/cloud/connections/{conn.id}/reconnect",
|
||||
headers=auth["headers"],
|
||||
json=payload,
|
||||
)
|
||||
assert resp.status_code in (200, 202), (
|
||||
f"Expected 200/202 for reconnect, got {resp.status_code}: {resp.text}"
|
||||
)
|
||||
|
||||
# The row count must not increase — no new connection row created
|
||||
after_count = (
|
||||
await db_session.execute(
|
||||
select(CloudConnection).where(CloudConnection.user_id == auth["user"].id)
|
||||
)
|
||||
).scalars().all()
|
||||
assert len(after_count) == len(initial_count), (
|
||||
"Reconnect must patch the existing row — no new CloudConnection row should be created (CONN-01)"
|
||||
)
|
||||
|
||||
# The UUID must be unchanged
|
||||
await db_session.refresh(conn)
|
||||
assert conn.id == original_conn_id, (
|
||||
"CloudConnection UUID must not change on reconnect (CONN-01)"
|
||||
)
|
||||
|
||||
|
||||
async def test_reconnect_foreign_user_blocked(async_client, db_session):
|
||||
"""User2 cannot reconnect 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.post(
|
||||
f"/api/cloud/connections/{conn.id}/reconnect",
|
||||
headers=auth2["headers"],
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code == 404, (
|
||||
f"Expected 404 IDOR block, got {resp.status_code}"
|
||||
)
|
||||
|
||||
|
||||
# ── CONN-02: Refreshed credentials are encrypted and persisted ─────────────────
|
||||
|
||||
|
||||
async def test_reconnect_persists_refreshed_credentials(async_client, db_session):
|
||||
"""Successful reconnect encrypts and persists refreshed access_token and refresh_token.
|
||||
|
||||
CONN-02: After a successful reconnect (e.g. OneDrive token refresh), the new
|
||||
credentials must be encrypted (same HKDF AES-256-GCM pattern) and stored in
|
||||
credentials_enc. The old credentials_enc must be replaced.
|
||||
|
||||
FAILS: Phase 13 route does not exist yet.
|
||||
"""
|
||||
from db.models import CloudConnection
|
||||
from storage.cloud_utils import decrypt_credentials
|
||||
|
||||
auth = await _create_user_and_token(db_session)
|
||||
conn = await _create_cloud_connection(db_session, auth["user"].id, status="AUTH_FAILED")
|
||||
|
||||
old_creds_enc = conn.credentials_enc
|
||||
|
||||
resp = await async_client.post(
|
||||
f"/api/cloud/connections/{conn.id}/reconnect",
|
||||
headers=auth["headers"],
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code in (200, 202), (
|
||||
f"Expected 200/202 for reconnect, got {resp.status_code}: {resp.text}"
|
||||
)
|
||||
|
||||
await db_session.refresh(conn)
|
||||
new_creds_enc = conn.credentials_enc
|
||||
|
||||
# Credentials_enc must be updated (refreshed token stored)
|
||||
assert new_creds_enc != old_creds_enc, (
|
||||
"Reconnect must update credentials_enc with refreshed token (CONN-02)"
|
||||
)
|
||||
|
||||
|
||||
async def test_reconnect_refreshed_credentials_are_encrypted(async_client, db_session):
|
||||
"""The credentials_enc stored after reconnect must be encrypted — no plaintext tokens.
|
||||
|
||||
CONN-02: The ciphertext must not contain plaintext field names like 'access_token'
|
||||
or raw token values.
|
||||
|
||||
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, status="AUTH_FAILED")
|
||||
|
||||
resp = await async_client.post(
|
||||
f"/api/cloud/connections/{conn.id}/reconnect",
|
||||
headers=auth["headers"],
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code in (200, 202), (
|
||||
f"Expected 200/202 for reconnect, got {resp.status_code}"
|
||||
)
|
||||
|
||||
await db_session.refresh(conn)
|
||||
creds_enc = conn.credentials_enc
|
||||
# Encrypted blob must not contain plaintext credential keys
|
||||
assert "access_token" not in creds_enc, (
|
||||
"credentials_enc must not contain plaintext 'access_token' after reconnect"
|
||||
)
|
||||
assert "refresh_token" not in creds_enc, (
|
||||
"credentials_enc must not contain plaintext 'refresh_token' after reconnect"
|
||||
)
|
||||
|
||||
|
||||
# ── CONN-03: Reconnect response excludes raw credentials ─────────────────────
|
||||
|
||||
|
||||
async def test_reconnect_response_excludes_credentials(async_client, db_session):
|
||||
"""Reconnect response must not contain raw credentials, provider URLs, or tokens.
|
||||
|
||||
CONN-03: The reconnect response must be credential-free. No access_token,
|
||||
refresh_token, client_secret, or provider-specific URL should appear.
|
||||
|
||||
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.post(
|
||||
f"/api/cloud/connections/{conn.id}/reconnect",
|
||||
headers=auth["headers"],
|
||||
json={},
|
||||
)
|
||||
for forbidden in (
|
||||
"access_token", "refresh_token", "credentials_enc",
|
||||
"client_secret", "client_id",
|
||||
):
|
||||
assert forbidden not in resp.text, (
|
||||
f"Reconnect response must not expose '{forbidden}' (CONN-03)"
|
||||
)
|
||||
|
||||
|
||||
# ── D-12: Connection health endpoint (compact + full) ─────────────────────────
|
||||
|
||||
|
||||
async def test_connection_health_endpoint_returns_status(async_client, db_session):
|
||||
"""GET /api/cloud/connections/{id}/health returns connection health status.
|
||||
|
||||
D-12: Connection health must be available explicitly (not inferred from browse).
|
||||
Response must include a 'status' field: 'healthy' | 'degraded' | 'auth_failed' | 'offline'.
|
||||
|
||||
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}/health",
|
||||
headers=auth["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, (
|
||||
f"Expected 200 for health check, got {resp.status_code}: {resp.text}"
|
||||
)
|
||||
body = resp.json()
|
||||
assert "status" in body, "Health response must include 'status' field (D-12)"
|
||||
assert body["status"] in ("healthy", "degraded", "auth_failed", "offline"), (
|
||||
f"Health status must be a known value, got {body.get('status')!r}"
|
||||
)
|
||||
|
||||
|
||||
async def test_connection_health_foreign_user_blocked(async_client, db_session):
|
||||
"""User2 cannot check health of User1's connection — IDOR protection.
|
||||
|
||||
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}/health",
|
||||
headers=auth2["headers"],
|
||||
)
|
||||
assert resp.status_code == 404, (
|
||||
f"Expected 404 IDOR block, got {resp.status_code}"
|
||||
)
|
||||
|
||||
|
||||
async def test_connection_health_excludes_credentials(async_client, db_session):
|
||||
"""Health endpoint response must not expose any credentials or tokens (T-13-02).
|
||||
|
||||
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}/health",
|
||||
headers=auth["headers"],
|
||||
)
|
||||
for forbidden in ("access_token", "refresh_token", "credentials_enc", "client_secret"):
|
||||
assert forbidden not in resp.text, (
|
||||
f"Health response must not expose '{forbidden}' (T-13-02)"
|
||||
)
|
||||
|
||||
|
||||
# ── D-13: Explicit Test action and no probe on navigation ─────────────────────
|
||||
|
||||
|
||||
async def test_connection_test_action_available(async_client, db_session):
|
||||
"""POST /api/cloud/connections/{id}/test triggers explicit connection test.
|
||||
|
||||
D-13: Explicit Test action is available. It must run a health probe and
|
||||
update the connection status row without modifying provider content.
|
||||
|
||||
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.post(
|
||||
f"/api/cloud/connections/{conn.id}/test",
|
||||
headers=auth["headers"],
|
||||
)
|
||||
assert resp.status_code in (200, 202), (
|
||||
f"Expected 200/202 for connection test, got {resp.status_code}: {resp.text}"
|
||||
)
|
||||
body = resp.json()
|
||||
assert "status" in body, "Test response must include 'status' field (D-13)"
|
||||
|
||||
|
||||
# ── D-14: Reconnect preserves stale metadata and invalidates caches ───────────
|
||||
|
||||
|
||||
async def test_reconnect_preserves_cached_metadata_as_stale(async_client, db_session):
|
||||
"""Successful reconnect marks cached items as stale, not deleted.
|
||||
|
||||
D-14: After reconnect, cached metadata remains visible to the user as stale
|
||||
while DocuVault revalidates. The provider listing is re-fetched immediately.
|
||||
CloudItems must NOT be deleted on reconnect.
|
||||
|
||||
FAILS: Phase 13 route does not exist yet.
|
||||
"""
|
||||
from db.models import CloudItem, CloudFolderState
|
||||
|
||||
auth = await _create_user_and_token(db_session)
|
||||
conn = await _create_cloud_connection(db_session, auth["user"].id, status="AUTH_FAILED")
|
||||
|
||||
# Seed a cached cloud item
|
||||
item = CloudItem(
|
||||
id=_uuid.uuid4(),
|
||||
user_id=auth["user"].id,
|
||||
connection_id=conn.id,
|
||||
provider_item_id="gdrive_file_abc",
|
||||
name="Important Report.pdf",
|
||||
kind="file",
|
||||
)
|
||||
db_session.add(item)
|
||||
await db_session.commit()
|
||||
|
||||
resp = await async_client.post(
|
||||
f"/api/cloud/connections/{conn.id}/reconnect",
|
||||
headers=auth["headers"],
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code in (200, 202), (
|
||||
f"Expected 200/202 for reconnect, got {resp.status_code}"
|
||||
)
|
||||
|
||||
# The cached item must still exist after reconnect
|
||||
result = await db_session.execute(
|
||||
select(CloudItem).where(
|
||||
CloudItem.id == item.id,
|
||||
CloudItem.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
surviving_item = result.scalar_one_or_none()
|
||||
assert surviving_item is not None, (
|
||||
"Cached cloud items must NOT be deleted on reconnect — they become stale (D-14)"
|
||||
)
|
||||
|
||||
|
||||
# ── D-15: Transient outage preserves credentials and cached metadata ──────────
|
||||
|
||||
|
||||
async def test_transient_outage_preserves_credentials(async_client, db_session):
|
||||
"""A transient provider timeout must not delete credentials or cached metadata.
|
||||
|
||||
D-15: Timeout or temporarily unreachable provider = unhealthy state only.
|
||||
Credentials must remain in credentials_enc. CloudItems must not be deleted.
|
||||
|
||||
FAILS: Phase 13 route does not exist yet.
|
||||
"""
|
||||
from db.models import CloudItem
|
||||
|
||||
auth = await _create_user_and_token(db_session)
|
||||
conn = await _create_cloud_connection(db_session, auth["user"].id, status="ACTIVE")
|
||||
|
||||
old_creds_enc = conn.credentials_enc
|
||||
|
||||
# Seed a cached item
|
||||
item = CloudItem(
|
||||
id=_uuid.uuid4(),
|
||||
user_id=auth["user"].id,
|
||||
connection_id=conn.id,
|
||||
provider_item_id="gdrive_file_xyz",
|
||||
name="Budget.xlsx",
|
||||
kind="file",
|
||||
)
|
||||
db_session.add(item)
|
||||
await db_session.commit()
|
||||
|
||||
# Simulate a folder browse that encounters a transient timeout
|
||||
# The browse endpoint must gracefully handle timeout, not destroy data
|
||||
resp = await async_client.get(
|
||||
f"/api/cloud/connections/{conn.id}/items",
|
||||
headers=auth["headers"],
|
||||
)
|
||||
# Even a failed/degraded browse must not destroy credentials
|
||||
await db_session.refresh(conn)
|
||||
assert conn.credentials_enc == old_creds_enc, (
|
||||
"Transient provider outage must not erase credentials_enc (D-15)"
|
||||
)
|
||||
|
||||
# Cached items must survive
|
||||
result = await db_session.execute(
|
||||
select(CloudItem).where(
|
||||
CloudItem.id == item.id,
|
||||
CloudItem.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
surviving = result.scalar_one_or_none()
|
||||
assert surviving is not None, (
|
||||
"Transient provider outage must not delete cached metadata (D-15)"
|
||||
)
|
||||
|
||||
|
||||
# ── D-16: Disconnect removes credentials and connection-scoped metadata ────────
|
||||
|
||||
|
||||
async def test_disconnect_removes_credentials_enc(async_client, db_session):
|
||||
"""DELETE /api/cloud/connections/{id} removes credentials_enc from the database.
|
||||
|
||||
D-16: Explicit user-initiated disconnect requires confirmation, removes credentials
|
||||
and connection-scoped cloud metadata, and leaves provider files untouched.
|
||||
|
||||
FAILS: Phase 13 route does not exist yet — current disconnect tested in test_cloud.py.
|
||||
This test asserts the Phase 13 enhanced disconnect semantics.
|
||||
"""
|
||||
from db.models import CloudConnection
|
||||
|
||||
auth = await _create_user_and_token(db_session)
|
||||
conn = await _create_cloud_connection(db_session, auth["user"].id)
|
||||
|
||||
resp = await async_client.delete(
|
||||
f"/api/cloud/connections/{conn.id}",
|
||||
headers=auth["headers"],
|
||||
)
|
||||
assert resp.status_code in (200, 204), (
|
||||
f"Expected 200/204 for disconnect, got {resp.status_code}: {resp.text}"
|
||||
)
|
||||
|
||||
# Connection row must be deleted or credentials must be cleared
|
||||
result = await db_session.execute(
|
||||
select(CloudConnection).where(CloudConnection.id == conn.id)
|
||||
)
|
||||
conn_after = result.scalar_one_or_none()
|
||||
if conn_after is not None:
|
||||
# If row persists, credentials_enc must be nulled out
|
||||
assert not conn_after.credentials_enc, (
|
||||
"Disconnect must remove credentials_enc (D-16)"
|
||||
)
|
||||
|
||||
|
||||
async def test_disconnect_removes_connection_scoped_cloud_items(async_client, db_session):
|
||||
"""Disconnect removes all connection-scoped CloudItems — no orphaned metadata.
|
||||
|
||||
D-16: All connection-scoped metadata must be cleaned up on explicit disconnect.
|
||||
Phase 13 has no byte cache, so this is metadata-only cleanup.
|
||||
|
||||
FAILS: Phase 13 route does not exist yet.
|
||||
"""
|
||||
from db.models import CloudItem
|
||||
|
||||
auth = await _create_user_and_token(db_session)
|
||||
conn = await _create_cloud_connection(db_session, auth["user"].id)
|
||||
|
||||
# Seed connection-scoped cloud items
|
||||
for i in range(3):
|
||||
item = CloudItem(
|
||||
id=_uuid.uuid4(),
|
||||
user_id=auth["user"].id,
|
||||
connection_id=conn.id,
|
||||
provider_item_id=f"item_{i}",
|
||||
name=f"File {i}.txt",
|
||||
kind="file",
|
||||
)
|
||||
db_session.add(item)
|
||||
await db_session.commit()
|
||||
|
||||
resp = await async_client.delete(
|
||||
f"/api/cloud/connections/{conn.id}",
|
||||
headers=auth["headers"],
|
||||
)
|
||||
assert resp.status_code in (200, 204), (
|
||||
f"Expected 200/204 for disconnect, got {resp.status_code}"
|
||||
)
|
||||
|
||||
# All connection-scoped items must be removed (CASCADE or explicit cleanup)
|
||||
result = await db_session.execute(
|
||||
select(CloudItem).where(
|
||||
CloudItem.connection_id == conn.id,
|
||||
CloudItem.deleted_at.is_(None),
|
||||
)
|
||||
)
|
||||
orphaned = result.scalars().all()
|
||||
assert len(orphaned) == 0, (
|
||||
f"Disconnect must remove all connection-scoped CloudItems — "
|
||||
f"found {len(orphaned)} orphaned items (D-16)"
|
||||
)
|
||||
|
||||
|
||||
async def test_disconnect_foreign_user_blocked(async_client, db_session):
|
||||
"""User2 cannot disconnect 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.delete(
|
||||
f"/api/cloud/connections/{conn.id}",
|
||||
headers=auth2["headers"],
|
||||
)
|
||||
assert resp.status_code == 404, (
|
||||
f"Expected 404 IDOR block, got {resp.status_code}"
|
||||
)
|
||||
Reference in New Issue
Block a user