Files
kite/backend/tests/test_cloud_analysis_api.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

451 lines
18 KiB
Python

"""
Phase 14 Plan 01 — RED API integration tests for cloud analysis endpoints.
Requirements: ANALYZE-01, ANALYZE-04, ANALYZE-05, CACHE-05
Threat coverage:
T-14-01 — IDOR: all analysis and job-status endpoints are owner-scoped
T-14-02 — No credentials/object keys in any API response
T-14-03 — No provider mutations triggered by analysis routes
These tests use the async_client fixture (real FastAPI + SQLite in-memory).
They MUST FAIL due to missing route registrations until Phase 14 implementation
adds the /api/cloud/analysis/* router.
"""
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
# ─── 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"api_user_{user_id.hex[:8]}",
email=f"api_{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="API 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=204800,
etag=etag,
analysis_status=analysis_status,
)
session.add(item)
await session.commit()
return item
# ─── Route registration check ─────────────────────────────────────────────────
async def test_analysis_estimate_route_exists(async_client, db_session):
"""Phase 14 router must be registered — estimate route returns non-404/405.
Until the analysis router is added to main.py, this returns 404. That is
the expected RED failure.
"""
ctx = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, ctx["user"].id)
resp = await async_client.post(
f"/api/cloud/analysis/connections/{conn.id}/estimate",
json={"scope": "connection", "recursive": True},
headers=ctx["headers"],
)
# Phase 14 RED: route not yet registered, must return 404
# After implementation: route must exist and return 200
assert resp.status_code != 405, (
"Method not allowed suggests the router is registered but the route pattern is wrong"
)
# This assertion FAILS (404) until the router is wired in — expected RED behavior
assert resp.status_code == 200, (
f"Analysis estimate route not yet registered (Phase 14 RED — expected). "
f"Got {resp.status_code}"
)
async def test_analysis_jobs_route_exists(async_client, db_session):
"""Phase 14 router must be registered — job enqueue route returns non-404/405."""
ctx = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, ctx["user"].id)
resp = await async_client.post(
f"/api/cloud/analysis/connections/{conn.id}/jobs",
json={"scope": "connection", "recursive": True},
headers=ctx["headers"],
)
assert resp.status_code != 405
# RED: expected to fail until implementation
assert resp.status_code in (200, 202), (
f"Analysis job enqueue route not yet registered (Phase 14 RED). Got {resp.status_code}"
)
async def test_job_status_route_exists(async_client, db_session):
"""Phase 14: GET /api/cloud/analysis/jobs/{id} route must exist."""
ctx = await _create_user_and_token(db_session)
fake_job_id = str(uuid.uuid4())
resp = await async_client.get(
f"/api/cloud/analysis/jobs/{fake_job_id}",
headers=ctx["headers"],
)
# RED: returns 404 for route until implementation; then 404 for unknown job is ok
# After implementation: must return either 200 (job found) or 404 (not found) — not 405
assert resp.status_code != 405, (
"Method not allowed suggests the job-status route pattern is wrong"
)
async def test_job_cancel_route_exists(async_client, db_session):
"""Phase 14: POST /api/cloud/analysis/jobs/{id}/cancel route must exist."""
ctx = await _create_user_and_token(db_session)
fake_job_id = str(uuid.uuid4())
resp = await async_client.post(
f"/api/cloud/analysis/jobs/{fake_job_id}/cancel",
headers=ctx["headers"],
)
assert resp.status_code != 405, "Method not allowed on cancel route"
# ─── ANALYZE-04: Progress detail labels ──────────────────────────────────────
async def test_job_status_includes_simplified_labels_by_default(async_client, db_session):
"""ANALYZE-04/D-06: Job status defaults to simplified progress labels.
Simplified: waiting, working, done, skipped, failed (not raw internal states).
"""
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)
with patch("services.cloud_analysis.get_adapter", new_callable=MagicMock):
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")
status_resp = await async_client.get(
f"/api/cloud/analysis/jobs/{job_id}",
headers=ctx["headers"],
)
assert status_resp.status_code == 200
body = status_resp.json()
# Default response must include aggregate counts using simplified vocabulary
simplified_states = {
"waiting_count", "working_count", "done_count", "skipped_count", "failed_count",
# or equivalently: queued_count, running_count, indexed_count, ...
"total_count",
}
# At least one of these must be present
assert any(k in body for k in simplified_states | {"status", "total_count"}), (
f"Job status must include aggregate counts. Got keys: {list(body.keys())}"
)
async def test_job_status_detailed_mode_includes_per_stage_counts(async_client, db_session):
"""ANALYZE-04/D-06: With detailed setting, job status includes per-stage counts.
Detailed: queued, downloading, extracting, classifying, indexed, cancelled, failed.
"""
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)
with patch("services.cloud_analysis.get_adapter", new_callable=MagicMock):
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")
# Request detailed progress (via query param or header per implementation design)
status_resp = await async_client.get(
f"/api/cloud/analysis/jobs/{job_id}?detail=true",
headers=ctx["headers"],
)
assert status_resp.status_code == 200
body = status_resp.json()
# Detailed mode must expose per-stage counts
detailed_states = {
"queued_count", "downloading_count", "extracting_count",
"classifying_count", "indexed_count", "cancelled_count", "failed_count",
}
assert any(k in body for k in detailed_states), (
f"Detailed mode must include per-stage counts. Got keys: {list(body.keys())}"
)
# ─── ANALYZE-05: Controls — cancel job items ─────────────────────────────────
async def test_skip_item_transitions_it_to_skipped(async_client, db_session):
"""ANALYZE-05: Skip control on a queued item transitions it to skipped status."""
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)
with patch("services.cloud_analysis.get_adapter", new_callable=MagicMock):
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")
resp = await async_client.post(
f"/api/cloud/analysis/jobs/{job_id}/items/{item.id}/skip",
headers=ctx["headers"],
)
assert resp.status_code in (200, 202, 204)
async def test_unauthenticated_request_blocked(async_client, db_session):
"""Security: unauthenticated requests to analysis endpoints return 401."""
conn_id = str(uuid.uuid4())
resp = await async_client.post(
f"/api/cloud/analysis/connections/{conn_id}/estimate",
json={"scope": "connection", "recursive": True},
# No Authorization header
)
# Analysis routes must require authentication (401 or 403 depending on middleware)
# 404 is acceptable during RED phase when route is not yet registered
assert resp.status_code in (401, 403, 404), (
f"Unauthenticated request to analysis route must be blocked — got {resp.status_code}"
)
# ─── Response schema shape ────────────────────────────────────────────────────
async def test_estimate_response_schema_shape(async_client, db_session):
"""ANALYZE-03/D-03: Estimate response includes required fields.
D-03 fields: supported_count, total_provider_bytes, unsupported_count,
recursive (for folder/connection scope).
"""
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)
with patch("services.cloud_analysis.get_adapter", new_callable=MagicMock):
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"],
)
# RED: will fail until routes are registered
assert resp.status_code == 200, (
f"Estimate route not yet registered (Phase 14 RED). Got {resp.status_code}"
)
body = resp.json()
required_fields = {"supported_count", "unsupported_count", "total_provider_bytes"}
missing = required_fields - set(body.keys())
assert not missing, f"Estimate response missing required fields: {missing}"
# No sensitive fields in response
sensitive = {"credentials_enc", "object_key", "access_token", "refresh_token"}
leaked = sensitive & set(body.keys())
assert not leaked, f"Estimate response contains sensitive fields: {leaked}"
async def test_enqueue_response_includes_job_id(async_client, db_session):
"""ANALYZE-01: Enqueue response must include a stable job_id for status polling."""
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)
with patch("services.cloud_analysis.get_adapter", new_callable=MagicMock):
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), (
f"Enqueue route not yet registered (Phase 14 RED). Got {resp.status_code}"
)
body = resp.json()
assert "job_id" in body, "Enqueue response must include job_id"
# job_id must be a valid UUID string
try:
uuid.UUID(body["job_id"])
except (ValueError, AttributeError):
pytest.fail(f"job_id must be a valid UUID — got: {body.get('job_id')!r}")
async def test_enqueue_response_excludes_credentials(async_client, db_session):
"""T-14-02: Enqueue response must not contain credentials or raw object keys."""
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)
with patch("services.cloud_analysis.get_adapter", new_callable=MagicMock):
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"],
)
if resp.status_code in (200, 202):
body_text = resp.text
sensitive_fields = [
"credentials_enc", "access_token", "refresh_token",
"client_secret", "object_key",
]
for field in sensitive_fields:
assert field not in body_text, (
f"Enqueue response contains sensitive field '{field}' (T-14-02)"
)
# ─── Scan quota seam ─────────────────────────────────────────────────────────
async def test_scan_quota_seam_module_exists():
"""ANALYZE-13/D-13: Phase 14 must expose a scan quota seam distinct from storage quota.
This test checks that the scan quota helper exists as a callable with the
correct signature, even if tier limits are not enforced in Phase 14.
"""
from services.cloud_analysis import check_scan_quota # Phase 14 — not yet implemented
import inspect
sig = inspect.signature(check_scan_quota)
params = list(sig.parameters.keys())
# Must accept session and user_id at minimum
assert "session" in params or "user_id" in params, (
"check_scan_quota must accept session and/or user_id"
)
async def test_failure_behavior_setting_accepted_by_enqueue(async_client, db_session):
"""ANALYZE-05/D-11: Enqueue must accept failure_behavior field.
Accepted values: 'pause_batch' (default) and 'continue_item'.
"""
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)
with patch("services.cloud_analysis.get_adapter", new_callable=MagicMock):
resp = await async_client.post(
f"/api/cloud/analysis/connections/{conn.id}/jobs",
json={
"scope": "file",
"provider_item_ids": [item.provider_item_id],
"failure_behavior": "continue_item",
},
headers=ctx["headers"],
)
# Must accept 'continue_item' without 422/400 validation error
assert resp.status_code not in (400, 422), (
f"Enqueue must accept failure_behavior='continue_item'. Got {resp.status_code}: {resp.text}"
)
async def test_invalid_failure_behavior_rejected(async_client, db_session):
"""ANALYZE-05: Unknown failure_behavior values must be rejected."""
ctx = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, ctx["user"].id)
resp = await async_client.post(
f"/api/cloud/analysis/connections/{conn.id}/jobs",
json={
"scope": "file",
"provider_item_ids": [],
"failure_behavior": "explode_everything", # Invalid
},
headers=ctx["headers"],
)
# Route may 404 (RED phase) or 422 (validation error after implementation)
# Must NOT be 200/202 with an invalid value accepted
assert resp.status_code not in (200, 202), (
f"Invalid failure_behavior must not be accepted — got {resp.status_code}"
)