Files
kite/backend/tests/test_cloud_analysis_contract.py
T
curo1305andClaude Sonnet 4.6 f67559c7f0 test(14-01): add RED contract tests for cloud analysis and byte cache (backend)
- test_cloud_analysis_contract.py: estimate (single/selection/connection/folder),
  already-current skip without byte fetch, changed-etag requeue, no-mutation
  invariant, IDOR/admin-negative access (T-14-01..04), cancel/retry transitions
- test_cloud_cache.py: CloudByteCacheEntry schema, LRU eviction with pin
  protection, quota increment/decrement seam (T-14-05), owner isolation
  (CACHE-05), object-key secrecy (T-14-02), version-key precedence helper
- test_cloud_analysis_api.py: route registration gates, job status shape,
  simplified/detailed progress labels (D-06), skip/cancel controls, scan
  quota seam (D-13), failure_behavior field acceptance, unauthenticated block

Requirements: ANALYZE-01..07, CACHE-03..05
All tests fail against missing Phase 14 modules — no syntax/import errors in tests themselves

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 14:56:29 +02:00

661 lines
26 KiB
Python

"""
Phase 14 Plan 01 — RED contract tests for cloud analysis scope, estimates, and
idempotency (no bytes downloaded during estimate or already-current checks).
Requirements: ANALYZE-01, ANALYZE-02, ANALYZE-03, ANALYZE-06, ANALYZE-07
Threat coverage:
T-14-01 — IDOR: foreign user cannot estimate/enqueue another user's connection
T-14-03 — Hidden provider mutation: fake adapter mutation counters stay zero
T-14-04 — Hidden byte download: estimate and already-current checks never call
provider byte fetch
All tests import non-existent Phase 14 modules and must FAIL due to
ImportError or missing endpoints. They define the implementation contract.
"""
from __future__ import annotations
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
pytestmark = pytest.mark.asyncio
from tests.conftest import _TEST_USER_AGENT
# ─── Minimal fake adapter ────────────────────────────────────────────────────
class FakeCloudAdapter:
"""Counts calls to mutation and byte-download methods.
Zero counts on all methods after analysis operations confirms
T-14-03 (no mutation) and T-14-04 (no byte download).
"""
def __init__(self):
self.upload_calls = 0
self.delete_calls = 0
self.rename_calls = 0
self.move_calls = 0
self.create_folder_calls = 0
self.get_object_calls = 0 # byte download
self.put_object_calls = 0 # byte upload
# Mutations — must stay zero during analysis
async def upload(self, *a, **kw):
self.upload_calls += 1
raise AssertionError("upload must not be called during analysis")
async def delete(self, *a, **kw):
self.delete_calls += 1
raise AssertionError("delete must not be called during analysis")
async def rename(self, *a, **kw):
self.rename_calls += 1
raise AssertionError("rename must not be called during analysis")
async def move(self, *a, **kw):
self.move_calls += 1
raise AssertionError("move must not be called during analysis")
async def create_folder(self, *a, **kw):
self.create_folder_calls += 1
raise AssertionError("create_folder must not be called during analysis")
# Byte downloads — must stay zero during estimate and already-current checks
async def get_object(self, *a, **kw):
self.get_object_calls += 1
raise AssertionError("get_object must not be called during estimate or already-current check")
async def put_object(self, *a, **kw):
self.put_object_calls += 1
raise AssertionError("put_object must not be called during analysis path")
@property
def mutation_call_count(self):
return (
self.upload_calls + self.delete_calls + self.rename_calls +
self.move_calls + self.create_folder_calls
)
@property
def byte_call_count(self):
return self.get_object_calls + self.put_object_calls
# ─── Shared test helpers ─────────────────────────────────────────────────────
async def _create_user_and_token(session, role: str = "user"):
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"ana_user_{user_id.hex[:8]}",
email=f"ana_{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="google_drive", status="ACTIVE"):
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="Test Connection",
credentials_enc=creds_enc,
status=status,
)
session.add(conn)
await session.commit()
return conn
async def _create_cloud_item(session, user_id, connection_id, *, name="doc.pdf",
kind="file", etag="etag-v1", analysis_status="pending"):
from db.models import CloudItem
item = CloudItem(
id=uuid.uuid4(),
user_id=user_id,
connection_id=connection_id,
provider_item_id=f"pitem-{uuid.uuid4().hex[:8]}",
name=name,
kind=kind,
content_type="application/pdf",
provider_size=102400,
etag=etag,
analysis_status=analysis_status,
)
session.add(item)
await session.commit()
return item
# ─── ANALYZE-01: Single file analysis estimate ───────────────────────────────
async def test_estimate_single_file_returns_supported_count_and_bytes(async_client, db_session):
"""ANALYZE-01: Single-file estimate returns count=1 and provider_bytes without downloading.
T-14-04: no bytes fetched during estimate.
"""
# Requires Phase 14 endpoint: POST /api/cloud/analysis/connections/{id}/estimate
from api.cloud.analysis import router as analysis_router # Phase 14 — not yet implemented
ctx = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, ctx["user"].id)
item = await _create_cloud_item(db_session, ctx["user"].id, conn.id)
adapter = FakeCloudAdapter()
with patch("services.cloud_analysis.get_adapter", return_value=adapter):
resp = await async_client.post(
f"/api/cloud/analysis/connections/{conn.id}/estimate",
json={"scope": "file", "provider_item_ids": [item.provider_item_id]},
headers=ctx["headers"],
)
assert resp.status_code == 200
body = resp.json()
assert body["supported_count"] == 1
assert body["unsupported_count"] == 0
assert "total_provider_bytes" in body
assert body["total_provider_bytes"] == item.provider_size
# T-14-04: no byte downloads during estimate
assert adapter.byte_call_count == 0, "Estimate must not download provider bytes"
# T-14-03: no mutations during estimate
assert adapter.mutation_call_count == 0, "Estimate must not call any provider mutation"
# ─── ANALYZE-02: Selection estimate ─────────────────────────────────────────
async def test_estimate_selection_aggregates_multiple_files(async_client, db_session):
"""ANALYZE-02: Multi-file selection estimate aggregates counts and bytes.
T-14-04: bytes not fetched during selection estimate.
"""
from api.cloud.analysis import router as analysis_router # Phase 14
ctx = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, ctx["user"].id)
item1 = await _create_cloud_item(db_session, ctx["user"].id, conn.id, name="a.pdf")
item2 = await _create_cloud_item(db_session, ctx["user"].id, conn.id, name="b.docx")
adapter = FakeCloudAdapter()
with patch("services.cloud_analysis.get_adapter", return_value=adapter):
resp = await async_client.post(
f"/api/cloud/analysis/connections/{conn.id}/estimate",
json={
"scope": "selection",
"provider_item_ids": [item1.provider_item_id, item2.provider_item_id],
},
headers=ctx["headers"],
)
assert resp.status_code == 200
body = resp.json()
assert body["supported_count"] == 2
# total bytes = sum of provider sizes
assert body["total_provider_bytes"] == (item1.provider_size or 0) + (item2.provider_size or 0)
assert adapter.byte_call_count == 0
assert adapter.mutation_call_count == 0
async def test_estimate_includes_unsupported_item_count(async_client, db_session):
"""ANALYZE-02: Estimate reports unsupported items without blocking supportable ones.
Unsupported items are reported but do not cause estimate to fail (D-23).
"""
from api.cloud.analysis import router as analysis_router # Phase 14
ctx = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, ctx["user"].id)
pdf_item = await _create_cloud_item(db_session, ctx["user"].id, conn.id, name="report.pdf")
# An item with an unsupported content type (e.g. .exe) should count as unsupported
exe_item = await _create_cloud_item(db_session, ctx["user"].id, conn.id, name="setup.exe")
adapter = FakeCloudAdapter()
with patch("services.cloud_analysis.get_adapter", return_value=adapter):
resp = await async_client.post(
f"/api/cloud/analysis/connections/{conn.id}/estimate",
json={
"scope": "selection",
"provider_item_ids": [pdf_item.provider_item_id, exe_item.provider_item_id],
},
headers=ctx["headers"],
)
assert resp.status_code == 200
body = resp.json()
# At least one supported (pdf), at least one unsupported (exe)
assert body["supported_count"] >= 1
assert body["unsupported_count"] >= 1
assert adapter.byte_call_count == 0
# ─── ANALYZE-03: Whole-connection estimate ───────────────────────────────────
async def test_whole_connection_estimate_always_required(async_client, db_session):
"""ANALYZE-03: Whole-connection scope always produces an estimate response before enqueue.
The estimate endpoint must be called before enqueue for connection-scope (D-02).
T-14-04: bytes not fetched.
"""
from api.cloud.analysis import router as analysis_router # Phase 14
ctx = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, ctx["user"].id)
await _create_cloud_item(db_session, ctx["user"].id, conn.id, name="file1.pdf")
adapter = FakeCloudAdapter()
with patch("services.cloud_analysis.get_adapter", return_value=adapter):
resp = await async_client.post(
f"/api/cloud/analysis/connections/{conn.id}/estimate",
json={"scope": "connection", "recursive": True},
headers=ctx["headers"],
)
assert resp.status_code == 200
body = resp.json()
# Whole-connection always includes a recursive flag in the response
assert "recursive" in body or body.get("scope_kind") == "connection"
assert "supported_count" in body
assert adapter.byte_call_count == 0
async def test_folder_estimate_asks_about_recursion(async_client, db_session):
"""ANALYZE-03/D-04: Folder-scope estimate accepts recursive flag and reports it back."""
from api.cloud.analysis import router as analysis_router # Phase 14
ctx = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, ctx["user"].id)
folder_item = await _create_cloud_item(db_session, ctx["user"].id, conn.id,
name="Reports", kind="folder")
adapter = FakeCloudAdapter()
with patch("services.cloud_analysis.get_adapter", return_value=adapter):
resp = await async_client.post(
f"/api/cloud/analysis/connections/{conn.id}/estimate",
json={
"scope": "folder",
"provider_item_ids": [folder_item.provider_item_id],
"recursive": True,
},
headers=ctx["headers"],
)
assert resp.status_code == 200
body = resp.json()
assert "recursive" in body
assert adapter.byte_call_count == 0
# ─── ANALYZE-06: Duplicate / already-current skip ────────────────────────────
async def test_already_current_item_skipped_without_byte_fetch(async_client, db_session):
"""ANALYZE-06: Item with unchanged etag is skipped without downloading provider bytes.
T-14-04: get_object must not be called for already-current items.
"""
from api.cloud.analysis import router as analysis_router # Phase 14
ctx = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, ctx["user"].id)
# Item already indexed with same etag
item = await _create_cloud_item(
db_session, ctx["user"].id, conn.id,
etag="etag-stable-v1", analysis_status="indexed"
)
adapter = FakeCloudAdapter()
with patch("services.cloud_analysis.get_adapter", return_value=adapter):
resp = await async_client.post(
f"/api/cloud/analysis/connections/{conn.id}/jobs",
json={
"scope": "file",
"provider_item_ids": [item.provider_item_id],
},
headers=ctx["headers"],
)
# Enqueue succeeds (202 Accepted or 200 with job status)
assert resp.status_code in (200, 202)
body = resp.json()
# Item reported as already_current — not re-downloaded
# The job should report 0 queued items (or the item appears as already_current)
job_id = body.get("job_id")
assert job_id is not None
# Fetch job status to verify already_current
status_resp = await async_client.get(
f"/api/cloud/analysis/jobs/{job_id}",
headers=ctx["headers"],
)
assert status_resp.status_code == 200
status_body = status_resp.json()
# Item should not be in "queued" state — it's already current
assert status_body.get("already_current_count", 0) >= 1
assert status_body.get("queued_count", 0) == 0
# Critical: no bytes were fetched
assert adapter.byte_call_count == 0, "already-current check must not download provider bytes"
# Critical: no mutations occurred
assert adapter.mutation_call_count == 0
async def test_changed_etag_triggers_reanalysis(async_client, db_session):
"""ANALYZE-06: Item with changed etag is queued for reanalysis (not skipped)."""
from api.cloud.analysis import router as analysis_router # Phase 14
ctx = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, ctx["user"].id)
# Item previously indexed but etag has changed
item = await _create_cloud_item(
db_session, ctx["user"].id, conn.id,
etag="etag-OLD", analysis_status="indexed"
)
adapter = FakeCloudAdapter()
# Provider now reports a different etag
with patch("services.cloud_analysis.get_adapter", return_value=adapter):
with patch("services.cloud_analysis.resolve_provider_metadata",
new_callable=AsyncMock,
return_value={"etag": "etag-NEW", "size": 102400}):
resp = await async_client.post(
f"/api/cloud/analysis/connections/{conn.id}/jobs",
json={"scope": "file", "provider_item_ids": [item.provider_item_id]},
headers=ctx["headers"],
)
assert resp.status_code in (200, 202)
body = resp.json()
job_id = body.get("job_id")
assert job_id is not None
status_resp = await async_client.get(
f"/api/cloud/analysis/jobs/{job_id}",
headers=ctx["headers"],
)
assert status_resp.status_code == 200
status_body = status_resp.json()
# Item is queued for re-analysis (not marked already_current)
assert status_body.get("queued_count", 0) >= 1
# ─── ANALYZE-07: Provider file not mutated ───────────────────────────────────
async def test_analysis_never_mutates_provider_file(async_client, db_session):
"""ANALYZE-07: Analysis job completion never calls any provider mutation method.
T-14-03: mutation counters must remain zero for the full analysis lifecycle.
"""
from api.cloud.analysis import router as analysis_router # Phase 14
ctx = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, ctx["user"].id)
item = await _create_cloud_item(db_session, ctx["user"].id, conn.id)
adapter = FakeCloudAdapter()
with patch("services.cloud_analysis.get_adapter", return_value=adapter):
resp = await async_client.post(
f"/api/cloud/analysis/connections/{conn.id}/jobs",
json={"scope": "file", "provider_item_ids": [item.provider_item_id]},
headers=ctx["headers"],
)
assert resp.status_code in (200, 202)
# No mutations at any point in analysis
assert adapter.mutation_call_count == 0, (
f"Analysis called provider mutations: upload={adapter.upload_calls}, "
f"delete={adapter.delete_calls}, rename={adapter.rename_calls}, "
f"move={adapter.move_calls}, create_folder={adapter.create_folder_calls}"
)
# ─── T-14-01: IDOR — foreign user cannot estimate/enqueue ───────────────────
async def test_foreign_user_cannot_estimate_another_users_connection(async_client, db_session):
"""T-14-01: Estimate request against another user's connection returns 403/404."""
from api.cloud.analysis import router as analysis_router # Phase 14
owner_ctx = await _create_user_and_token(db_session)
foreign_ctx = await _create_user_and_token(db_session)
owner_conn = await _create_cloud_connection(db_session, owner_ctx["user"].id)
resp = await async_client.post(
f"/api/cloud/analysis/connections/{owner_conn.id}/estimate",
json={"scope": "connection", "recursive": True},
headers=foreign_ctx["headers"], # Foreign user's token
)
assert resp.status_code in (403, 404), (
f"Foreign user estimate must be rejected — got {resp.status_code}"
)
async def test_foreign_user_cannot_enqueue_another_users_connection(async_client, db_session):
"""T-14-01: Enqueue request against another user's connection is rejected."""
from api.cloud.analysis import router as analysis_router # Phase 14
owner_ctx = await _create_user_and_token(db_session)
foreign_ctx = await _create_user_and_token(db_session)
owner_conn = await _create_cloud_connection(db_session, owner_ctx["user"].id)
owner_item = await _create_cloud_item(db_session, owner_ctx["user"].id, owner_conn.id)
resp = await async_client.post(
f"/api/cloud/analysis/connections/{owner_conn.id}/jobs",
json={"scope": "file", "provider_item_ids": [owner_item.provider_item_id]},
headers=foreign_ctx["headers"],
)
assert resp.status_code in (403, 404), (
f"Foreign user enqueue must be rejected — got {resp.status_code}"
)
async def test_admin_cannot_enqueue_another_users_analysis_job(async_client, db_session):
"""T-14-01: Admin role cannot access cloud analysis routes (same as browse restriction)."""
from api.cloud.analysis import router as analysis_router # Phase 14
user_ctx = await _create_user_and_token(db_session, role="user")
admin_ctx = await _create_user_and_token(db_session, role="admin")
user_conn = await _create_cloud_connection(db_session, user_ctx["user"].id)
user_item = await _create_cloud_item(db_session, user_ctx["user"].id, user_conn.id)
resp = await async_client.post(
f"/api/cloud/analysis/connections/{user_conn.id}/jobs",
json={"scope": "file", "provider_item_ids": [user_item.provider_item_id]},
headers=admin_ctx["headers"],
)
assert resp.status_code in (403, 404), (
f"Admin must not access cloud analysis — got {resp.status_code}"
)
# ─── Job status and controls ─────────────────────────────────────────────────
async def test_job_status_response_excludes_credentials_and_object_keys(async_client, db_session):
"""T-14-02: Job status response never includes credentials or raw object keys."""
from api.cloud.analysis import router as analysis_router # Phase 14
ctx = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, ctx["user"].id)
item = await _create_cloud_item(db_session, ctx["user"].id, conn.id)
adapter = FakeCloudAdapter()
with patch("services.cloud_analysis.get_adapter", return_value=adapter):
enqueue_resp = await async_client.post(
f"/api/cloud/analysis/connections/{conn.id}/jobs",
json={"scope": "file", "provider_item_ids": [item.provider_item_id]},
headers=ctx["headers"],
)
assert enqueue_resp.status_code in (200, 202)
job_id = enqueue_resp.json().get("job_id")
assert job_id is not None
status_resp = await async_client.get(
f"/api/cloud/analysis/jobs/{job_id}",
headers=ctx["headers"],
)
assert status_resp.status_code == 200
body_text = status_resp.text
# T-14-02: No credential fields or raw object keys in response
forbidden_fields = [
"credentials_enc", "access_token", "refresh_token",
"client_secret", "object_key",
]
for field in forbidden_fields:
assert field not in body_text, (
f"Sensitive field '{field}' must not appear in job status response"
)
async def test_foreign_user_cannot_read_job_status(async_client, db_session):
"""T-14-01: Foreign user cannot read job status for another user's job."""
from api.cloud.analysis import router as analysis_router # Phase 14
owner_ctx = await _create_user_and_token(db_session)
foreign_ctx = await _create_user_and_token(db_session)
owner_conn = await _create_cloud_connection(db_session, owner_ctx["user"].id)
owner_item = await _create_cloud_item(db_session, owner_ctx["user"].id, owner_conn.id)
adapter = FakeCloudAdapter()
with patch("services.cloud_analysis.get_adapter", return_value=adapter):
enqueue_resp = await async_client.post(
f"/api/cloud/analysis/connections/{owner_conn.id}/jobs",
json={"scope": "file", "provider_item_ids": [owner_item.provider_item_id]},
headers=owner_ctx["headers"],
)
job_id = enqueue_resp.json().get("job_id")
# Foreign user tries to read the job
resp = await async_client.get(
f"/api/cloud/analysis/jobs/{job_id}",
headers=foreign_ctx["headers"],
)
assert resp.status_code in (403, 404)
async def test_foreign_user_cannot_cancel_another_users_job(async_client, db_session):
"""T-14-01: Foreign user cannot cancel a job owned by another user."""
from api.cloud.analysis import router as analysis_router # Phase 14
owner_ctx = await _create_user_and_token(db_session)
foreign_ctx = await _create_user_and_token(db_session)
owner_conn = await _create_cloud_connection(db_session, owner_ctx["user"].id)
owner_item = await _create_cloud_item(db_session, owner_ctx["user"].id, owner_conn.id)
adapter = FakeCloudAdapter()
with patch("services.cloud_analysis.get_adapter", return_value=adapter):
enqueue_resp = await async_client.post(
f"/api/cloud/analysis/connections/{owner_conn.id}/jobs",
json={"scope": "file", "provider_item_ids": [owner_item.provider_item_id]},
headers=owner_ctx["headers"],
)
job_id = enqueue_resp.json().get("job_id")
resp = await async_client.post(
f"/api/cloud/analysis/jobs/{job_id}/cancel",
headers=foreign_ctx["headers"],
)
assert resp.status_code in (403, 404)
# ─── Cancel and retry state transitions ──────────────────────────────────────
async def test_cancel_job_transitions_to_cancelled_status(async_client, db_session):
"""ANALYZE-05: Cancel job transitions the job status to 'cancelled'."""
from api.cloud.analysis import router as analysis_router # Phase 14
ctx = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, ctx["user"].id)
item = await _create_cloud_item(db_session, ctx["user"].id, conn.id)
adapter = FakeCloudAdapter()
with patch("services.cloud_analysis.get_adapter", return_value=adapter):
enqueue_resp = await async_client.post(
f"/api/cloud/analysis/connections/{conn.id}/jobs",
json={"scope": "file", "provider_item_ids": [item.provider_item_id]},
headers=ctx["headers"],
)
job_id = enqueue_resp.json().get("job_id")
cancel_resp = await async_client.post(
f"/api/cloud/analysis/jobs/{job_id}/cancel",
headers=ctx["headers"],
)
assert cancel_resp.status_code in (200, 202, 204)
status_resp = await async_client.get(
f"/api/cloud/analysis/jobs/{job_id}",
headers=ctx["headers"],
)
assert status_resp.json().get("status") in ("cancelled", "cancelling")
async def test_retry_failed_item_requeues_it(async_client, db_session):
"""ANALYZE-05: Retry on a failed item transitions it back to queued status."""
from api.cloud.analysis import router as analysis_router # Phase 14
ctx = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, ctx["user"].id)
item = await _create_cloud_item(db_session, ctx["user"].id, conn.id)
adapter = FakeCloudAdapter()
with patch("services.cloud_analysis.get_adapter", return_value=adapter):
enqueue_resp = await async_client.post(
f"/api/cloud/analysis/connections/{conn.id}/jobs",
json={"scope": "file", "provider_item_ids": [item.provider_item_id]},
headers=ctx["headers"],
)
job_id = enqueue_resp.json().get("job_id")
# Simulate item in failed state then retry
resp = await async_client.post(
f"/api/cloud/analysis/jobs/{job_id}/items/{item.id}/retry",
headers=ctx["headers"],
)
# Must return a valid status that indicates retry was accepted
assert resp.status_code in (200, 202, 204)