feat(14-04): add analysis API routes and typed schemas

- POST /analysis/connections/{id}/estimate: scope estimate without bytes (T-14-04)
- POST /analysis/connections/{id}/jobs: enqueue job, returns job_id (ANALYZE-01)
- GET /analysis/jobs, GET /analysis/jobs/{id}: job status list and detail
- POST /analysis/jobs/{id}/cancel: batch cancel (queued items immediate; running cooperative)
- POST /analysis/jobs/{id}/items/{item_id}/cancel|skip|retry: per-item controls (ANALYZE-05)
- AnalysisEstimateRequest/Out, AnalysisEnqueueRequest/Out, AnalysisJobOut: typed schemas
- AnalysisJobOut: both simple (waiting/working/done/skipped/failed) and per-stage counts
- All routes use get_regular_user — admin blocked (T-14-01)
- Response schemas exclude credentials_enc, object_key, raw provider URLs (T-14-02)
- 14/14 test_cloud_analysis_api.py pass; 7 new analysis security tests pass (33/33 total)
This commit is contained in:
curo1305
2026-06-23 15:50:37 +02:00
parent 3f26cd2059
commit ba48d625dd
3 changed files with 845 additions and 10 deletions
+202
View File
@@ -704,3 +704,205 @@ async def test_cache_settings_limit_above_tier_cap_returns_422(async_client, db_
assert resp.status_code == 422, (
f"Cache limit above tier cap must return 422, got {resp.status_code}"
)
# ── Phase 14: Analysis job API security (T-14-01, T-14-02, T-14-03) ────────────
async def _create_analysis_connection(session, user_id, provider="google_drive"):
"""Helper: create a cloud connection for analysis security tests."""
from db.models import CloudConnection
from storage.cloud_utils import encrypt_credentials
from config import settings as app_settings
master_key = app_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="Security Test Connection",
credentials_enc=creds_enc,
status="ACTIVE",
)
session.add(conn)
await session.commit()
return conn
async def _create_analysis_item(session, user_id, connection_id, name="doc.pdf"):
"""Helper: create a cloud item for analysis security tests."""
from db.models import CloudItem
item = CloudItem(
id=_uuid.uuid4(),
user_id=user_id,
connection_id=connection_id,
provider_item_id=f"sec-pitem-{_uuid.uuid4().hex[:8]}",
name=name,
kind="file",
content_type="application/pdf",
provider_size=102400,
etag="etag-sec-v1",
analysis_status="pending",
)
session.add(item)
await session.commit()
return item
async def test_analysis_estimate_admin_blocked(async_client, db_session):
"""T-14-01: Admin cannot access analysis estimate endpoint."""
admin_ctx = await _create_user_and_token(db_session, role="admin")
user_ctx = await _create_user_and_token(db_session, role="user")
conn = await _create_analysis_connection(db_session, user_ctx["user"].id)
resp = await async_client.post(
f"/api/cloud/analysis/connections/{conn.id}/estimate",
json={"scope": "connection", "recursive": True},
headers=admin_ctx["headers"],
)
assert resp.status_code in (403, 404), (
f"Admin must not access analysis estimate — got {resp.status_code}"
)
async def test_analysis_enqueue_admin_blocked(async_client, db_session):
"""T-14-01: Admin cannot enqueue analysis jobs."""
admin_ctx = await _create_user_and_token(db_session, role="admin")
user_ctx = await _create_user_and_token(db_session, role="user")
conn = await _create_analysis_connection(db_session, user_ctx["user"].id)
resp = await async_client.post(
f"/api/cloud/analysis/connections/{conn.id}/jobs",
json={"scope": "connection", "recursive": True},
headers=admin_ctx["headers"],
)
assert resp.status_code in (403, 404), (
f"Admin must not enqueue analysis jobs — got {resp.status_code}"
)
async def test_analysis_estimate_unauthenticated_blocked(async_client, db_session):
"""Analysis estimate requires authentication."""
conn_id = str(_uuid.uuid4())
resp = await async_client.post(
f"/api/cloud/analysis/connections/{conn_id}/estimate",
json={"scope": "connection", "recursive": True},
)
assert resp.status_code in (401, 403), (
f"Unauthenticated analysis estimate must be rejected — got {resp.status_code}"
)
async def test_analysis_enqueue_response_excludes_credentials(async_client, db_session):
"""T-14-02: Enqueue response must not contain credentials or object_key."""
from unittest.mock import patch as _patch
user_ctx = await _create_user_and_token(db_session, role="user")
conn = await _create_analysis_connection(db_session, user_ctx["user"].id)
item = await _create_analysis_item(db_session, user_ctx["user"].id, conn.id)
with _patch("services.cloud_analysis.get_adapter", return_value=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=user_ctx["headers"],
)
assert resp.status_code in (200, 202), (
f"Enqueue must succeed — got {resp.status_code}: {resp.text}"
)
body_text = resp.text
# T-14-02: No credential or internal storage fields in response
for field in ("credentials_enc", "access_token", "refresh_token", "object_key"):
assert field not in body_text, (
f"T-14-02: Sensitive field '{field}' must not appear in enqueue response"
)
async def test_analysis_job_status_excludes_credentials(async_client, db_session):
"""T-14-02: Job status response must not contain credentials or object_key."""
from unittest.mock import patch as _patch
user_ctx = await _create_user_and_token(db_session, role="user")
conn = await _create_analysis_connection(db_session, user_ctx["user"].id)
item = await _create_analysis_item(db_session, user_ctx["user"].id, conn.id)
with _patch("services.cloud_analysis.get_adapter", return_value=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=user_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=user_ctx["headers"],
)
assert status_resp.status_code == 200
body_text = status_resp.text
for field in ("credentials_enc", "access_token", "refresh_token", "object_key"):
assert field not in body_text, (
f"T-14-02: Sensitive field '{field}' must not appear in job status response"
)
async def test_foreign_user_cannot_access_analysis_estimate(async_client, db_session):
"""T-14-01: Foreign user cannot estimate analysis scope for another user's connection."""
owner_ctx = await _create_user_and_token(db_session, role="user")
foreign_ctx = await _create_user_and_token(db_session, role="user")
owner_conn = await _create_analysis_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"],
)
assert resp.status_code in (403, 404), (
f"Foreign user must not estimate owner's connection — got {resp.status_code}"
)
async def test_foreign_user_cannot_read_analysis_job_status(async_client, db_session):
"""T-14-01: Foreign user cannot read job status for another user's job."""
from unittest.mock import patch as _patch
owner_ctx = await _create_user_and_token(db_session, role="user")
foreign_ctx = await _create_user_and_token(db_session, role="user")
owner_conn = await _create_analysis_connection(db_session, owner_ctx["user"].id)
owner_item = await _create_analysis_item(db_session, owner_ctx["user"].id, owner_conn.id)
with _patch("services.cloud_analysis.get_adapter", return_value=MagicMock()):
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"],
)
assert enqueue_resp.status_code in (200, 202)
job_id = enqueue_resp.json().get("job_id")
resp = await async_client.get(
f"/api/cloud/analysis/jobs/{job_id}",
headers=foreign_ctx["headers"],
)
assert resp.status_code in (403, 404), (
f"Foreign user must not read owner's job — got {resp.status_code}"
)