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:
@@ -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}"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user