Files
kite/backend/tests/test_cloud_analysis_contract.py
T
curo1305 f235d894fb feat(14-05): add Celery cloud analysis task with retry harness (Task 2)
- Implement tasks/cloud_analysis_tasks.py: sync Celery bridge to async processing
- Broker payload contains IDs only — credentials decrypted in worker (T-14-10)
- _TransientError and _ClassificationRetry sentinels for bounded retry (max=3)
- Auth errors are terminal (not retried); MaxRetriesExceeded writes failure status
- celery_app.py: add cloud_analysis_tasks route and import registration
- Add 6 contract tests: task registration, broker payload shape, queue routing,
  bounded retries, sentinel pattern, connection-not-found graceful drop
2026-06-23 16:06:07 +02:00

1242 lines
47 KiB
Python

"""
Phase 14 Plan 01 — RED contract tests for cloud analysis scope, estimates, and
idempotency (no bytes downloaded during estimate or already-current checks).
Requirements: ANALYZE-01, ANALYZE-02, ANALYZE-03, ANALYZE-06, ANALYZE-07
Threat coverage:
T-14-01 — IDOR: foreign user cannot estimate/enqueue another user's connection
T-14-03 — Hidden provider mutation: fake adapter mutation counters stay zero
T-14-04 — Hidden byte download: estimate and already-current checks never call
provider byte fetch
All tests import non-existent Phase 14 modules and must FAIL due to
ImportError or missing endpoints. They define the implementation contract.
"""
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
# ─── Minimal fake adapter ────────────────────────────────────────────────────
class FakeCloudAdapter:
"""Counts calls to mutation and byte-download methods.
Zero counts on all methods after analysis operations confirms
T-14-03 (no mutation) and T-14-04 (no byte download).
"""
def __init__(self):
self.upload_calls = 0
self.delete_calls = 0
self.rename_calls = 0
self.move_calls = 0
self.create_folder_calls = 0
self.get_object_calls = 0 # byte download
self.put_object_calls = 0 # byte upload
# Mutations — must stay zero during analysis
async def upload(self, *a, **kw):
self.upload_calls += 1
raise AssertionError("upload must not be called during analysis")
async def delete(self, *a, **kw):
self.delete_calls += 1
raise AssertionError("delete must not be called during analysis")
async def rename(self, *a, **kw):
self.rename_calls += 1
raise AssertionError("rename must not be called during analysis")
async def move(self, *a, **kw):
self.move_calls += 1
raise AssertionError("move must not be called during analysis")
async def create_folder(self, *a, **kw):
self.create_folder_calls += 1
raise AssertionError("create_folder must not be called during analysis")
# Byte downloads — must stay zero during estimate and already-current checks
async def get_object(self, *a, **kw):
self.get_object_calls += 1
raise AssertionError("get_object must not be called during estimate or already-current check")
async def put_object(self, *a, **kw):
self.put_object_calls += 1
raise AssertionError("put_object must not be called during analysis path")
@property
def mutation_call_count(self):
return (
self.upload_calls + self.delete_calls + self.rename_calls +
self.move_calls + self.create_folder_calls
)
@property
def byte_call_count(self):
return self.get_object_calls + self.put_object_calls
# ─── Shared test 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"ana_user_{user_id.hex[:8]}",
email=f"ana_{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="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=102400,
etag=etag,
analysis_status=analysis_status,
)
session.add(item)
await session.commit()
return item
# ─── ANALYZE-01: Single file analysis estimate ───────────────────────────────
async def test_estimate_single_file_returns_supported_count_and_bytes(async_client, db_session):
"""ANALYZE-01: Single-file estimate returns count=1 and provider_bytes without downloading.
T-14-04: no bytes fetched during estimate.
"""
# Requires Phase 14 endpoint: POST /api/cloud/analysis/connections/{id}/estimate
from api.cloud.analysis import router as analysis_router # Phase 14 — not yet implemented
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)
adapter = FakeCloudAdapter()
with patch("services.cloud_analysis.get_adapter", return_value=adapter):
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"],
)
assert resp.status_code == 200
body = resp.json()
assert body["supported_count"] == 1
assert body["unsupported_count"] == 0
assert "total_provider_bytes" in body
assert body["total_provider_bytes"] == item.provider_size
# T-14-04: no byte downloads during estimate
assert adapter.byte_call_count == 0, "Estimate must not download provider bytes"
# T-14-03: no mutations during estimate
assert adapter.mutation_call_count == 0, "Estimate must not call any provider mutation"
# ─── ANALYZE-02: Selection estimate ─────────────────────────────────────────
async def test_estimate_selection_aggregates_multiple_files(async_client, db_session):
"""ANALYZE-02: Multi-file selection estimate aggregates counts and bytes.
T-14-04: bytes not fetched during selection estimate.
"""
from api.cloud.analysis import router as analysis_router # Phase 14
ctx = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, ctx["user"].id)
item1 = await _create_cloud_item(db_session, ctx["user"].id, conn.id, name="a.pdf")
item2 = await _create_cloud_item(db_session, ctx["user"].id, conn.id, name="b.docx")
adapter = FakeCloudAdapter()
with patch("services.cloud_analysis.get_adapter", return_value=adapter):
resp = await async_client.post(
f"/api/cloud/analysis/connections/{conn.id}/estimate",
json={
"scope": "selection",
"provider_item_ids": [item1.provider_item_id, item2.provider_item_id],
},
headers=ctx["headers"],
)
assert resp.status_code == 200
body = resp.json()
assert body["supported_count"] == 2
# total bytes = sum of provider sizes
assert body["total_provider_bytes"] == (item1.provider_size or 0) + (item2.provider_size or 0)
assert adapter.byte_call_count == 0
assert adapter.mutation_call_count == 0
async def test_estimate_includes_unsupported_item_count(async_client, db_session):
"""ANALYZE-02: Estimate reports unsupported items without blocking supportable ones.
Unsupported items are reported but do not cause estimate to fail (D-23).
"""
from api.cloud.analysis import router as analysis_router # Phase 14
ctx = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, ctx["user"].id)
pdf_item = await _create_cloud_item(db_session, ctx["user"].id, conn.id, name="report.pdf")
# An item with an unsupported content type (e.g. .exe) should count as unsupported
exe_item = await _create_cloud_item(db_session, ctx["user"].id, conn.id, name="setup.exe")
adapter = FakeCloudAdapter()
with patch("services.cloud_analysis.get_adapter", return_value=adapter):
resp = await async_client.post(
f"/api/cloud/analysis/connections/{conn.id}/estimate",
json={
"scope": "selection",
"provider_item_ids": [pdf_item.provider_item_id, exe_item.provider_item_id],
},
headers=ctx["headers"],
)
assert resp.status_code == 200
body = resp.json()
# At least one supported (pdf), at least one unsupported (exe)
assert body["supported_count"] >= 1
assert body["unsupported_count"] >= 1
assert adapter.byte_call_count == 0
# ─── ANALYZE-03: Whole-connection estimate ───────────────────────────────────
async def test_whole_connection_estimate_always_required(async_client, db_session):
"""ANALYZE-03: Whole-connection scope always produces an estimate response before enqueue.
The estimate endpoint must be called before enqueue for connection-scope (D-02).
T-14-04: bytes not fetched.
"""
from api.cloud.analysis import router as analysis_router # Phase 14
ctx = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, ctx["user"].id)
await _create_cloud_item(db_session, ctx["user"].id, conn.id, name="file1.pdf")
adapter = FakeCloudAdapter()
with patch("services.cloud_analysis.get_adapter", return_value=adapter):
resp = await async_client.post(
f"/api/cloud/analysis/connections/{conn.id}/estimate",
json={"scope": "connection", "recursive": True},
headers=ctx["headers"],
)
assert resp.status_code == 200
body = resp.json()
# Whole-connection always includes a recursive flag in the response
assert "recursive" in body or body.get("scope_kind") == "connection"
assert "supported_count" in body
assert adapter.byte_call_count == 0
async def test_folder_estimate_asks_about_recursion(async_client, db_session):
"""ANALYZE-03/D-04: Folder-scope estimate accepts recursive flag and reports it back."""
from api.cloud.analysis import router as analysis_router # Phase 14
ctx = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, ctx["user"].id)
folder_item = await _create_cloud_item(db_session, ctx["user"].id, conn.id,
name="Reports", kind="folder")
adapter = FakeCloudAdapter()
with patch("services.cloud_analysis.get_adapter", return_value=adapter):
resp = await async_client.post(
f"/api/cloud/analysis/connections/{conn.id}/estimate",
json={
"scope": "folder",
"provider_item_ids": [folder_item.provider_item_id],
"recursive": True,
},
headers=ctx["headers"],
)
assert resp.status_code == 200
body = resp.json()
assert "recursive" in body
assert adapter.byte_call_count == 0
# ─── ANALYZE-06: Duplicate / already-current skip ────────────────────────────
async def test_already_current_item_skipped_without_byte_fetch(async_client, db_session):
"""ANALYZE-06: Item with unchanged etag is skipped without downloading provider bytes.
T-14-04: get_object must not be called for already-current items.
"""
from api.cloud.analysis import router as analysis_router # Phase 14
ctx = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, ctx["user"].id)
# Item already indexed with same etag
item = await _create_cloud_item(
db_session, ctx["user"].id, conn.id,
etag="etag-stable-v1", analysis_status="indexed"
)
adapter = FakeCloudAdapter()
with patch("services.cloud_analysis.get_adapter", return_value=adapter):
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"],
)
# Enqueue succeeds (202 Accepted or 200 with job status)
assert resp.status_code in (200, 202)
body = resp.json()
# Item reported as already_current — not re-downloaded
# The job should report 0 queued items (or the item appears as already_current)
job_id = body.get("job_id")
assert job_id is not None
# Fetch job status to verify already_current
status_resp = await async_client.get(
f"/api/cloud/analysis/jobs/{job_id}",
headers=ctx["headers"],
)
assert status_resp.status_code == 200
status_body = status_resp.json()
# Item should not be in "queued" state — it's already current
assert status_body.get("already_current_count", 0) >= 1
assert status_body.get("queued_count", 0) == 0
# Critical: no bytes were fetched
assert adapter.byte_call_count == 0, "already-current check must not download provider bytes"
# Critical: no mutations occurred
assert adapter.mutation_call_count == 0
async def test_changed_etag_triggers_reanalysis(async_client, db_session):
"""ANALYZE-06: Item with changed etag is queued for reanalysis (not skipped)."""
from api.cloud.analysis import router as analysis_router # Phase 14
ctx = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, ctx["user"].id)
# Item previously indexed but etag has changed
item = await _create_cloud_item(
db_session, ctx["user"].id, conn.id,
etag="etag-OLD", analysis_status="indexed"
)
adapter = FakeCloudAdapter()
# Provider now reports a different etag
with patch("services.cloud_analysis.get_adapter", return_value=adapter):
with patch("services.cloud_analysis.resolve_provider_metadata",
new_callable=AsyncMock,
return_value={"etag": "etag-NEW", "size": 102400}):
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)
body = resp.json()
job_id = body.get("job_id")
assert job_id is not None
status_resp = await async_client.get(
f"/api/cloud/analysis/jobs/{job_id}",
headers=ctx["headers"],
)
assert status_resp.status_code == 200
status_body = status_resp.json()
# Item is queued for re-analysis (not marked already_current)
assert status_body.get("queued_count", 0) >= 1
# ─── ANALYZE-07: Provider file not mutated ───────────────────────────────────
async def test_analysis_never_mutates_provider_file(async_client, db_session):
"""ANALYZE-07: Analysis job completion never calls any provider mutation method.
T-14-03: mutation counters must remain zero for the full analysis lifecycle.
"""
from api.cloud.analysis import router as analysis_router # Phase 14
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)
adapter = FakeCloudAdapter()
with patch("services.cloud_analysis.get_adapter", return_value=adapter):
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)
# No mutations at any point in analysis
assert adapter.mutation_call_count == 0, (
f"Analysis called provider mutations: upload={adapter.upload_calls}, "
f"delete={adapter.delete_calls}, rename={adapter.rename_calls}, "
f"move={adapter.move_calls}, create_folder={adapter.create_folder_calls}"
)
# ─── T-14-01: IDOR — foreign user cannot estimate/enqueue ───────────────────
async def test_foreign_user_cannot_estimate_another_users_connection(async_client, db_session):
"""T-14-01: Estimate request against another user's connection returns 403/404."""
from api.cloud.analysis import router as analysis_router # Phase 14
owner_ctx = await _create_user_and_token(db_session)
foreign_ctx = await _create_user_and_token(db_session)
owner_conn = await _create_cloud_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"], # Foreign user's token
)
assert resp.status_code in (403, 404), (
f"Foreign user estimate must be rejected — got {resp.status_code}"
)
async def test_foreign_user_cannot_enqueue_another_users_connection(async_client, db_session):
"""T-14-01: Enqueue request against another user's connection is rejected."""
from api.cloud.analysis import router as analysis_router # Phase 14
owner_ctx = await _create_user_and_token(db_session)
foreign_ctx = await _create_user_and_token(db_session)
owner_conn = await _create_cloud_connection(db_session, owner_ctx["user"].id)
owner_item = await _create_cloud_item(db_session, owner_ctx["user"].id, owner_conn.id)
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=foreign_ctx["headers"],
)
assert resp.status_code in (403, 404), (
f"Foreign user enqueue must be rejected — got {resp.status_code}"
)
async def test_admin_cannot_enqueue_another_users_analysis_job(async_client, db_session):
"""T-14-01: Admin role cannot access cloud analysis routes (same as browse restriction)."""
from api.cloud.analysis import router as analysis_router # Phase 14
user_ctx = await _create_user_and_token(db_session, role="user")
admin_ctx = await _create_user_and_token(db_session, role="admin")
user_conn = await _create_cloud_connection(db_session, user_ctx["user"].id)
user_item = await _create_cloud_item(db_session, user_ctx["user"].id, user_conn.id)
resp = await async_client.post(
f"/api/cloud/analysis/connections/{user_conn.id}/jobs",
json={"scope": "file", "provider_item_ids": [user_item.provider_item_id]},
headers=admin_ctx["headers"],
)
assert resp.status_code in (403, 404), (
f"Admin must not access cloud analysis — got {resp.status_code}"
)
# ─── Job status and controls ─────────────────────────────────────────────────
async def test_job_status_response_excludes_credentials_and_object_keys(async_client, db_session):
"""T-14-02: Job status response never includes credentials or raw object keys."""
from api.cloud.analysis import router as analysis_router # Phase 14
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)
adapter = FakeCloudAdapter()
with patch("services.cloud_analysis.get_adapter", return_value=adapter):
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")
assert job_id is not None
status_resp = await async_client.get(
f"/api/cloud/analysis/jobs/{job_id}",
headers=ctx["headers"],
)
assert status_resp.status_code == 200
body_text = status_resp.text
# T-14-02: No credential fields or raw object keys in response
forbidden_fields = [
"credentials_enc", "access_token", "refresh_token",
"client_secret", "object_key",
]
for field in forbidden_fields:
assert field not in body_text, (
f"Sensitive field '{field}' must not appear in job status response"
)
async def test_foreign_user_cannot_read_job_status(async_client, db_session):
"""T-14-01: Foreign user cannot read job status for another user's job."""
from api.cloud.analysis import router as analysis_router # Phase 14
owner_ctx = await _create_user_and_token(db_session)
foreign_ctx = await _create_user_and_token(db_session)
owner_conn = await _create_cloud_connection(db_session, owner_ctx["user"].id)
owner_item = await _create_cloud_item(db_session, owner_ctx["user"].id, owner_conn.id)
adapter = FakeCloudAdapter()
with patch("services.cloud_analysis.get_adapter", return_value=adapter):
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"],
)
job_id = enqueue_resp.json().get("job_id")
# Foreign user tries to read the job
resp = await async_client.get(
f"/api/cloud/analysis/jobs/{job_id}",
headers=foreign_ctx["headers"],
)
assert resp.status_code in (403, 404)
async def test_foreign_user_cannot_cancel_another_users_job(async_client, db_session):
"""T-14-01: Foreign user cannot cancel a job owned by another user."""
from api.cloud.analysis import router as analysis_router # Phase 14
owner_ctx = await _create_user_and_token(db_session)
foreign_ctx = await _create_user_and_token(db_session)
owner_conn = await _create_cloud_connection(db_session, owner_ctx["user"].id)
owner_item = await _create_cloud_item(db_session, owner_ctx["user"].id, owner_conn.id)
adapter = FakeCloudAdapter()
with patch("services.cloud_analysis.get_adapter", return_value=adapter):
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"],
)
job_id = enqueue_resp.json().get("job_id")
resp = await async_client.post(
f"/api/cloud/analysis/jobs/{job_id}/cancel",
headers=foreign_ctx["headers"],
)
assert resp.status_code in (403, 404)
# ─── Cancel and retry state transitions ──────────────────────────────────────
async def test_cancel_job_transitions_to_cancelled_status(async_client, db_session):
"""ANALYZE-05: Cancel job transitions the job status to 'cancelled'."""
from api.cloud.analysis import router as analysis_router # Phase 14
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)
adapter = FakeCloudAdapter()
with patch("services.cloud_analysis.get_adapter", return_value=adapter):
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")
cancel_resp = await async_client.post(
f"/api/cloud/analysis/jobs/{job_id}/cancel",
headers=ctx["headers"],
)
assert cancel_resp.status_code in (200, 202, 204)
status_resp = await async_client.get(
f"/api/cloud/analysis/jobs/{job_id}",
headers=ctx["headers"],
)
assert status_resp.json().get("status") in ("cancelled", "cancelling")
async def test_retry_failed_item_requeues_it(async_client, db_session):
"""ANALYZE-05: Retry on a failed item transitions it back to queued status."""
from api.cloud.analysis import router as analysis_router # Phase 14
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)
adapter = FakeCloudAdapter()
with patch("services.cloud_analysis.get_adapter", return_value=adapter):
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")
# Simulate item in failed state then retry
resp = await async_client.post(
f"/api/cloud/analysis/jobs/{job_id}/items/{item.id}/retry",
headers=ctx["headers"],
)
# Must return a valid status that indicates retry was accepted
assert resp.status_code in (200, 202, 204)
# ─── Plan 05: processing service contract ────────────────────────────────────
async def test_processing_status_transitions_include_downloading_extracting_classifying(db_session):
"""ANALYZE-04: Processing service transitions through correct intermediate states.
The processing service must update item status through the full pipeline:
queued → downloading → extracting → classifying → indexed.
"""
from services.cloud_analysis_processing import process_job_item, ProcessingResult
# Verify the ProcessingResult dataclass exists and has the expected fields
import inspect
fields = {f for f in ProcessingResult.__dataclass_fields__}
assert "status" in fields
assert "cache_entry_id" in fields
assert "error_code" in fields
assert "error_message" in fields
async def test_processing_unchanged_version_skips_before_provider_byte_fetch(db_session):
"""ANALYZE-06 / T-14-13: Stale version detection fires before provider byte fetch.
If the version key at processing time differs from the enqueued version key,
the item must be marked stale WITHOUT downloading provider bytes.
"""
from services.cloud_analysis_processing import process_job_item, ProcessingResult
from services.cloud_analysis import enqueue_analysis_job, AnalysisJobNotFound
from db.models import CloudAnalysisJobItem
# Setup
from db.models import User, Quota, CloudConnection, CloudItem
from services.auth import hash_password
from storage.cloud_utils import encrypt_credentials
from config import settings
user_id = uuid.uuid4()
user = User(
id=user_id,
handle=f"proc_user_{user_id.hex[:8]}",
email=f"proc_{user_id.hex[:8]}@example.com",
password_hash=hash_password("Testpassword123!"),
role="user",
is_active=True,
password_must_change=False,
)
quota = Quota(user_id=user_id, limit_bytes=104857600, used_bytes=0)
db_session.add(user)
db_session.add(quota)
await db_session.commit()
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="google_drive",
display_name="Proc Test",
credentials_enc=creds_enc,
status="ACTIVE",
)
db_session.add(conn)
await db_session.commit()
item = CloudItem(
id=uuid.uuid4(),
user_id=user_id,
connection_id=conn.id,
provider_item_id=f"pitem-{uuid.uuid4().hex[:8]}",
name="doc.pdf",
kind="file",
content_type="application/pdf",
provider_size=102400,
etag="etag-v1",
analysis_status="pending",
)
db_session.add(item)
await db_session.commit()
# Enqueue job
result = await enqueue_analysis_job(
db_session,
user_id=user_id,
connection_id=conn.id,
scope="file",
provider_item_ids=[item.provider_item_id],
)
job_id = result.job_id
# Fetch the job item
from sqlalchemy import select as sa_select
from db.models import CloudAnalysisJob
item_row = (await db_session.execute(
sa_select(CloudAnalysisJobItem).where(
CloudAnalysisJobItem.job_id == job_id,
CloudAnalysisJobItem.cloud_item_id == item.id,
)
)).scalars().first()
assert item_row is not None
# Simulate item metadata changing: update etag on the CloudItem row
item.etag = "etag-v2-CHANGED"
item.updated_at = __import__("datetime").datetime.now(__import__("datetime").timezone.utc)
await db_session.flush()
await db_session.commit()
# FakeAdapter — get_object must NOT be called (version changed before download)
get_object_calls = []
class StrictFakeAdapter:
async def get_object(self, *a, **kw):
get_object_calls.append(1)
raise AssertionError("get_object must not be called for stale item")
class FakeMinIO:
async def get_object(self, key):
raise AssertionError("MinIO get_object must not be called for stale item")
async def put_object(self, *a, **kw):
pass
proc_result = await process_job_item(
db_session,
job_id=job_id,
item_id=item_row.id,
user_id=user_id,
connection_id=conn.id,
cloud_item_id=item.id,
minio_client=FakeMinIO(),
provider_adapter=StrictFakeAdapter(),
)
# Must be marked stale — no bytes fetched
assert proc_result.status == "stale"
assert len(get_object_calls) == 0, "get_object must not be called for stale item"
async def test_processing_cache_pin_released_on_cancellation(db_session):
"""T-14-12: Cache pin is released when processing is cancelled cooperatively."""
from services.cloud_analysis_processing import process_job_item
from services.cloud_analysis import enqueue_analysis_job
from services.cloud_cache import create_cache_entry, list_cache_entries
from db.models import CloudAnalysisJob, CloudAnalysisJobItem, CloudConnection, CloudItem
from services.auth import hash_password
from storage.cloud_utils import encrypt_credentials
from config import settings
user_id = uuid.uuid4()
from db.models import User, Quota
user = User(
id=user_id,
handle=f"pin_user_{user_id.hex[:8]}",
email=f"pin_{user_id.hex[:8]}@example.com",
password_hash=hash_password("Testpassword123!"),
role="user",
is_active=True,
password_must_change=False,
)
quota = Quota(user_id=user_id, limit_bytes=104857600, used_bytes=0)
db_session.add(user)
db_session.add(quota)
await db_session.commit()
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="google_drive",
display_name="Pin Test Conn",
credentials_enc=creds_enc,
status="ACTIVE",
)
db_session.add(conn)
await db_session.commit()
item = CloudItem(
id=uuid.uuid4(),
user_id=user_id,
connection_id=conn.id,
provider_item_id=f"pitem-pin-{uuid.uuid4().hex[:8]}",
name="doc.txt",
kind="file",
content_type="text/plain",
provider_size=1024,
etag="etag-pin-v1",
analysis_status="pending",
)
db_session.add(item)
await db_session.commit()
result = await enqueue_analysis_job(
db_session,
user_id=user_id,
connection_id=conn.id,
scope="file",
provider_item_ids=[item.provider_item_id],
)
job_id = result.job_id
from sqlalchemy import select as sa_select
job = (await db_session.execute(
sa_select(CloudAnalysisJob).where(CloudAnalysisJob.id == job_id)
)).scalars().first()
# Pre-cancel the job so cancellation triggers immediately
job.status = "cancelled"
await db_session.commit()
item_row = (await db_session.execute(
sa_select(CloudAnalysisJobItem).where(
CloudAnalysisJobItem.job_id == job_id,
CloudAnalysisJobItem.cloud_item_id == item.id,
)
)).scalars().first()
class FakeMinIO:
async def get_object(self, key):
return b"test content"
async def put_object(self, *a, **kw):
pass
class FakeAdapter:
async def get_object(self, *a, **kw):
return b"test content"
proc_result = await process_job_item(
db_session,
job_id=job_id,
item_id=item_row.id,
user_id=user_id,
connection_id=conn.id,
cloud_item_id=item.id,
minio_client=FakeMinIO(),
provider_adapter=FakeAdapter(),
)
# Cancelled — no bytes should have been downloaded (cancellation before download)
assert proc_result.status == "cancelled"
# Verify no pinned cache entries exist (pin was not created or was released)
from db.models import CloudByteCacheEntry
entries = (await db_session.execute(
sa_select(CloudByteCacheEntry).where(
CloudByteCacheEntry.user_id == user_id,
CloudByteCacheEntry.pin_count > 0,
)
)).scalars().all()
assert len(entries) == 0, "No pinned cache entries must remain after cancellation (T-14-12)"
async def test_processing_never_calls_provider_mutation_methods(db_session):
"""T-14-03: Processing service must not call any provider mutation methods.
The FakeAdapter tracks mutation call counts; all must be zero after processing.
"""
from services.cloud_analysis_processing import process_job_item
from services.cloud_analysis import enqueue_analysis_job
from db.models import User, Quota, CloudConnection, CloudItem, CloudAnalysisJobItem
from services.auth import hash_password
from storage.cloud_utils import encrypt_credentials
from config import settings
user_id = uuid.uuid4()
user = User(
id=user_id,
handle=f"mut_user_{user_id.hex[:8]}",
email=f"mut_{user_id.hex[:8]}@example.com",
password_hash=hash_password("Testpassword123!"),
role="user",
is_active=True,
password_must_change=False,
)
quota = Quota(user_id=user_id, limit_bytes=104857600, used_bytes=0)
db_session.add(user)
db_session.add(quota)
await db_session.commit()
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="google_drive",
display_name="Mut Test",
credentials_enc=creds_enc,
status="ACTIVE",
)
db_session.add(conn)
await db_session.commit()
item = CloudItem(
id=uuid.uuid4(),
user_id=user_id,
connection_id=conn.id,
provider_item_id=f"pitem-mut-{uuid.uuid4().hex[:8]}",
name="report.txt",
kind="file",
content_type="text/plain",
provider_size=512,
etag="etag-mut-v1",
analysis_status="pending",
)
db_session.add(item)
await db_session.commit()
result = await enqueue_analysis_job(
db_session,
user_id=user_id,
connection_id=conn.id,
scope="file",
provider_item_ids=[item.provider_item_id],
)
job_id = result.job_id
from sqlalchemy import select as sa_select
item_row = (await db_session.execute(
sa_select(CloudAnalysisJobItem).where(
CloudAnalysisJobItem.job_id == job_id,
CloudAnalysisJobItem.cloud_item_id == item.id,
)
)).scalars().first()
adapter = FakeCloudAdapter()
# Override get_object to return bytes (not raise) — processing should succeed
file_content = b"Test document text for classification."
class ReadOnlyAdapter:
def __init__(self):
self.upload_calls = 0
self.delete_calls = 0
self.rename_calls = 0
self.move_calls = 0
self.create_folder_calls = 0
self.get_object_calls = 0
async def get_object(self, *a, **kw):
self.get_object_calls += 1
return file_content
async def upload(self, *a, **kw):
self.upload_calls += 1
raise AssertionError("upload must not be called during processing")
async def delete(self, *a, **kw):
self.delete_calls += 1
raise AssertionError("delete must not be called during processing")
async def rename(self, *a, **kw):
self.rename_calls += 1
raise AssertionError("rename must not be called during processing")
async def move(self, *a, **kw):
self.move_calls += 1
raise AssertionError("move must not be called during processing")
async def create_folder(self, *a, **kw):
self.create_folder_calls += 1
raise AssertionError("create_folder must not be called during processing")
@property
def mutation_call_count(self):
return (
self.upload_calls + self.delete_calls + self.rename_calls +
self.move_calls + self.create_folder_calls
)
read_only = ReadOnlyAdapter()
class FakeMinIO:
async def get_object(self, key):
raise ValueError("no cache hit")
async def put_object(self, key, data, content_type=None):
pass # No-op — quota also won't be incremented in test DB (SQLite)
async def delete_object(self, key):
pass
with patch("services.cloud_analysis_processing._classify_cloud_item", new_callable=AsyncMock, return_value=["test-topic"]):
proc_result = await process_job_item(
db_session,
job_id=job_id,
item_id=item_row.id,
user_id=user_id,
connection_id=conn.id,
cloud_item_id=item.id,
minio_client=FakeMinIO(),
provider_adapter=read_only,
)
assert read_only.mutation_call_count == 0, (
f"Processing must not call provider mutations: "
f"upload={read_only.upload_calls}, delete={read_only.delete_calls}, "
f"rename={read_only.rename_calls}, move={read_only.move_calls}, "
f"create_folder={read_only.create_folder_calls}"
)
# Should succeed or fail gracefully (not from a mutation)
assert proc_result.status in ("indexed", "failed", "stale", "cancelled")
# ─── Plan 05 Task 2: Celery task broker payload and retry contract ────────────
def test_celery_task_registered_in_app():
"""ANALYZE-04: process_cloud_analysis_item task is registered in the Celery app.
Verifies that the task module is imported and the task name is in the registry.
"""
from celery_app import celery_app
import tasks.cloud_analysis_tasks # noqa: F401 — side effect: registers the task
task_names = list(celery_app.tasks.keys())
assert "tasks.cloud_analysis_tasks.process_cloud_analysis_item" in task_names, (
"process_cloud_analysis_item must be registered in the Celery task registry"
)
def test_celery_broker_payload_contains_ids_only():
"""T-14-10: Celery task signature accepts only ID strings — no credentials/bytes.
Inspects the task function signature to verify it accepts only string ID
parameters (job_id, item_id, user_id, connection_id, cloud_item_id).
"""
import inspect
from tasks.cloud_analysis_tasks import process_cloud_analysis_item
sig = inspect.signature(process_cloud_analysis_item)
params = list(sig.parameters.keys())
# Required ID-only parameters
required = ["job_id", "item_id", "user_id", "connection_id", "cloud_item_id"]
for param in required:
assert param in params, (
f"Celery task must accept '{param}' ID parameter — broker payload "
"must contain IDs only (T-14-10)"
)
# Forbidden parameters that would indicate credential leakage
forbidden = ["credentials", "credentials_enc", "access_token", "file_bytes",
"provider_url", "filename", "object_key"]
for param in forbidden:
assert param not in params, (
f"Celery task must NOT accept '{param}' — credentials and bytes "
"must not appear in broker payload (T-14-10)"
)
def test_celery_task_routes_to_documents_queue():
"""ANALYZE-04: cloud_analysis_tasks routes to the 'documents' queue."""
from celery_app import celery_app
routes = celery_app.conf.task_routes or {}
assert "tasks.cloud_analysis_tasks.*" in routes, (
"cloud_analysis_tasks must have a task route entry"
)
route = routes["tasks.cloud_analysis_tasks.*"]
assert route.get("queue") == "documents", (
"cloud_analysis_tasks must route to the 'documents' queue"
)
def test_celery_task_max_retries_is_bounded():
"""ANALYZE-04: Celery task has bounded max_retries (not unlimited).
Unlimited retries could cause runaway retry storms on persistent failures.
"""
from tasks.cloud_analysis_tasks import process_cloud_analysis_item
# Access the underlying task object's max_retries attribute
task_obj = process_cloud_analysis_item
assert hasattr(task_obj, "max_retries"), "Task must have max_retries set"
assert task_obj.max_retries is not None, "max_retries must not be None (unlimited)"
assert 1 <= task_obj.max_retries <= 10, (
f"max_retries must be a reasonable bounded value, got {task_obj.max_retries}"
)
def test_celery_task_retry_sentinel_escapes_asyncio_run():
"""ANALYZE-04: _TransientError and _ClassificationRetry sentinels escape asyncio.run().
The sentinel pattern requires that exceptions raised inside asyncio.run()
propagate out to the sync Celery task layer where self.retry() can be called.
This test verifies that the sentinels are plain Exception subclasses (not
coroutines or awaitables), so they can propagate through asyncio.run() normally.
"""
from tasks.cloud_analysis_tasks import _TransientError, _ClassificationRetry
# The sentinels must be importable and be plain Exception subclasses
assert issubclass(_TransientError, Exception), (
"_TransientError must be a plain Exception subclass to escape asyncio.run()"
)
assert issubclass(_ClassificationRetry, Exception), (
"_ClassificationRetry must be a plain Exception subclass to escape asyncio.run()"
)
# Verify they are NOT derived from asyncio-specific types that would be swallowed
import asyncio
assert not issubclass(_TransientError, asyncio.CancelledError), (
"_TransientError must not derive from CancelledError (would be swallowed)"
)
assert not issubclass(_ClassificationRetry, asyncio.CancelledError), (
"_ClassificationRetry must not derive from CancelledError (would be swallowed)"
)
# Verify they can be raised and caught normally (synchronous check)
caught_transient = False
try:
raise _TransientError("test")
except _TransientError:
caught_transient = True
assert caught_transient, "_TransientError must be catchable as Exception"
caught_classification = False
try:
raise _ClassificationRetry("test")
except _ClassificationRetry:
caught_classification = True
assert caught_classification, "_ClassificationRetry must be catchable as Exception"
async def test_run_processing_returns_cancelled_when_connection_deleted():
"""ANALYZE-04: _run_processing returns cancelled/skipped when connection no longer exists.
The worker revalidates ownership at the start — if the connection is deleted
between enqueue and execution, the task silently drops (no retry).
Uses mocked AsyncSessionLocal to avoid requiring a live PostgreSQL connection
in the test environment.
"""
from tasks.cloud_analysis_tasks import _run_processing
from unittest.mock import patch, AsyncMock, MagicMock
from services.cloud_items import ConnectionNotFound
import uuid
# Mock the AsyncSessionLocal so the task uses an in-memory session
mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
# Mock resolve_owned_connection to raise ConnectionNotFound (deleted connection)
with patch("tasks.cloud_analysis_tasks._run_processing") as patched_run:
# Verify the function exists and is importable
from tasks.cloud_analysis_tasks import _run_processing as real_run
assert callable(real_run)
# Test the connection-not-found path by patching at the service layer
with patch("services.cloud_items.resolve_owned_connection",
new_callable=AsyncMock,
side_effect=ConnectionNotFound("not found")):
with patch("db.session.AsyncSessionLocal") as mock_session_local:
mock_ctx = AsyncMock()
mock_ctx.__aenter__ = AsyncMock(return_value=mock_ctx)
mock_ctx.__aexit__ = AsyncMock(return_value=False)
mock_session_local.return_value = mock_ctx
result = await _run_processing(
job_id=str(uuid.uuid4()),
item_id=str(uuid.uuid4()),
user_id=str(uuid.uuid4()),
connection_id=str(uuid.uuid4()), # Does not exist in DB
cloud_item_id=str(uuid.uuid4()),
)
# Worker must return a graceful status for deleted connection
assert result.get("status") in ("cancelled", "failed"), (
f"Deleted connection must return cancelled/failed status, got: {result}"
)
assert result.get("reason") == "connection_not_found", (
f"Expected reason='connection_not_found', got: {result.get('reason')}"
)