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>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
b528b51881
commit
f67559c7f0
@@ -0,0 +1,450 @@
|
||||
"""
|
||||
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}"
|
||||
)
|
||||
Reference in New Issue
Block a user