- 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>
451 lines
18 KiB
Python
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}"
|
|
)
|