""" 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}" )