Files
kite/backend/tests/test_cloud_detail_parity.py
curo1305 a76854e003 test(14.1-01): RED contract tests for cloud detail parity + force re-analyze
- backend/tests/test_cloud_detail_parity.py: RED tests asserting GET
  /api/cloud/connections/{id}/items/{item_id}/detail returns extracted_text,
  analysis_status, semantic_index_status, topics, provider metadata, and
  excludes credentials_enc/object_key; owner gets 200, foreign user gets 404,
  admin gets 403 via get_regular_user; stale items retain prior data; detail
  endpoint must not call hydrate_and_cache_bytes (D-05, D-07, D-18, T-14.1-01/02)
- backend/tests/test_cloud_reanalyze_force.py: RED tests asserting force=True
  on AnalysisEnqueueRequest bypasses already_current for explicit re-analyze;
  default enqueue still skips unchanged indexed items; single-item retry with
  no active job creates a new one; force flag is owner-scoped and admin-blocked
  (D-11, D-12, ANALYZE-05, ANALYZE-06, ANALYZE-07, T-14.1-02)
- frontend/src/views/__tests__/CloudDetailParity.test.js: RED tests asserting
  cloud-file-detail named route exists in router; paired local+cloud route
  parity; DocumentView does not contain Re-classify (D-09 regression); route
  prerequisite for navigation (D-01, D-19)
- frontend/src/components/storage/__tests__/StorageBrowser.parity.test.js: RED
  tests asserting cloud rows render TopicBadge and analysis-status indicator in
  same slot as local rows; Analyze/Re-analyze/Retry action slot by state; no
  Re-classify copy in cloud rows (D-06, D-08, D-09, D-10)

All four files collect cleanly. 11 backend failures and 7 frontend failures are
tied exclusively to the missing detail endpoint, force flag, route, and Re-analyze
copy — not to fixture or infrastructure errors.
2026-06-26 21:47:44 +02:00

506 lines
19 KiB
Python

"""
Phase 14.1 Plan 01 — RED contract tests for cloud detail endpoint parity.
Tests pin the contract for the cloud detail endpoint before implementation exists.
All tests will fail (404 or missing field assertions) until Plan 02 adds:
- GET /api/cloud/connections/{connection_id}/items/{item_id:path}/detail
- CloudItemDetailOut schema with extracted_text, analysis_status,
semantic_index_status, topics, provider, display_name, parent_ref/location,
modified_at, size, content_type, capabilities, unsupported_analysis_reason
Requirements: CLOUD-02, ANALYZE-01, CACHE-03, CACHE-05
Threats: T-14.1-01 (information disclosure), T-14.1-02 (privilege escalation)
Decisions: D-05, D-07, D-18, D-20
"""
from __future__ import annotations
import uuid as _uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
pytestmark = pytest.mark.asyncio
from tests.conftest import _TEST_USER_AGENT
# ── Target endpoint path (Plan 02 implements this) ────────────────────────────
_DETAIL_PATH_TEMPLATE = "/api/cloud/connections/{connection_id}/items/{item_id}/detail"
# ── Shared helpers — reused from test_cloud_security.py pattern ──────────────
async def _create_user_and_token(session, role: str = "user"):
"""Create User + Quota + JWT. Mirrors the pattern from test_cloud_security.py."""
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"det_user_{user_id.hex[:8]}",
email=f"det_{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: str = "google_drive",
name: str = "Test Drive",
status: str = "ACTIVE",
):
"""Create a CloudConnection owned by user_id."""
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=name,
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: str = "report.pdf",
kind: str = "file",
analysis_status: str = "pending",
extracted_text: str | None = None,
etag: str = "etag-v1",
):
"""Create a CloudItem with optional analysis fields."""
from db.models import CloudItem
item = CloudItem(
id=_uuid.uuid4(),
user_id=user_id,
connection_id=connection_id,
provider_item_id=f"pitem-det-{_uuid.uuid4().hex[:8]}",
name=name,
kind=kind,
content_type="application/pdf",
provider_size=102400,
etag=etag,
analysis_status=analysis_status,
semantic_index_status="none" if analysis_status == "pending" else "indexed",
extracted_text=extracted_text,
)
session.add(item)
await session.commit()
return item
async def _create_analyzed_item_with_topics(
session,
user_id,
connection_id,
topic_names: list[str] | None = None,
):
"""Create a CloudItem in 'indexed' state with CloudItemTopic links."""
from db.models import CloudItem, CloudItemTopic, Topic
from sqlalchemy import select
item = CloudItem(
id=_uuid.uuid4(),
user_id=user_id,
connection_id=connection_id,
provider_item_id=f"pitem-analyzed-{_uuid.uuid4().hex[:8]}",
name="analyzed.pdf",
kind="file",
content_type="application/pdf",
provider_size=204800,
etag="etag-analyzed-v1",
analysis_status="indexed",
semantic_index_status="indexed",
extracted_text="Extracted text from the analyzed document.",
)
session.add(item)
await session.flush()
topic_names = topic_names or ["finance", "contracts"]
for tname in topic_names:
# Find or create the topic
result = await session.execute(
select(Topic).where(Topic.name == tname)
)
topic = result.scalars().first()
if topic is None:
topic = Topic(id=_uuid.uuid4(), name=tname)
session.add(topic)
await session.flush()
link = CloudItemTopic(
cloud_item_id=item.id,
topic_id=topic.id,
)
session.add(link)
await session.commit()
return item
# ── T-14.1-01: Owner GET returns analysis fields ──────────────────────────────
async def test_owner_detail_returns_extracted_text_and_topics(async_client, db_session):
"""CLOUD-02 / D-05: Owner GET of the cloud detail endpoint returns analysis fields.
After analysis completes, the detail response must include:
extracted_text, analysis_status, semantic_index_status, topics (list of names),
provider, display_name, parent_ref, modified_at, size, content_type,
capabilities, and unsupported_analysis_reason.
This test will FAIL until Plan 02 implements the /detail endpoint.
"""
auth = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth["user"].id)
item = await _create_analyzed_item_with_topics(
db_session, auth["user"].id, conn.id, topic_names=["finance", "contracts"]
)
url = _DETAIL_PATH_TEMPLATE.format(
connection_id=conn.id,
item_id=item.provider_item_id,
)
resp = await async_client.get(url, headers=auth["headers"])
# Plan 02 implements this endpoint — currently returns 404 or 405
assert resp.status_code == 200, (
f"Expected 200 from cloud detail endpoint — "
f"got {resp.status_code}: {resp.text}. "
"Plan 02 must add GET /connections/{id}/items/{item_id}/detail."
)
body = resp.json()
# Analysis fields that must be present (D-05)
assert "extracted_text" in body, "Cloud detail must include extracted_text"
assert body["extracted_text"], "extracted_text must be non-empty for indexed item"
assert "analysis_status" in body, "Cloud detail must include analysis_status"
assert body["analysis_status"] == "indexed"
assert "semantic_index_status" in body, "Cloud detail must include semantic_index_status"
# Topics as a list of names
assert "topics" in body, "Cloud detail must include topics list"
topic_names = body["topics"]
assert isinstance(topic_names, list), "topics must be a list"
assert len(topic_names) >= 1, "topics must contain at least one name"
assert "finance" in topic_names or "contracts" in topic_names, (
f"Expected topic names in {topic_names}"
)
# Source metadata fields (D-04)
assert "provider" in body, "Cloud detail must include provider"
assert "display_name" in body, "Cloud detail must include display_name"
assert "size" in body, "Cloud detail must include size"
assert "content_type" in body, "Cloud detail must include content_type"
async def test_owner_detail_returns_capabilities_and_unsupported_reason(async_client, db_session):
"""D-05 / D-16: Cloud detail includes capabilities and unsupported_analysis_reason.
The detail endpoint must expose capability information alongside content so
frontend action slots know whether Analyze/Re-analyze is available.
This test will FAIL until Plan 02 implements the /detail endpoint.
"""
auth = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth["user"].id)
item = await _create_cloud_item(
db_session, auth["user"].id, conn.id, analysis_status="pending"
)
url = _DETAIL_PATH_TEMPLATE.format(
connection_id=conn.id,
item_id=item.provider_item_id,
)
resp = await async_client.get(url, headers=auth["headers"])
assert resp.status_code == 200, (
f"Expected 200 from cloud detail endpoint, got {resp.status_code}: {resp.text}"
)
body = resp.json()
# Capabilities must be present (D-05)
assert "capabilities" in body, (
"Cloud detail must expose capabilities dict so frontend can render action slots"
)
# unsupported_analysis_reason is optional but must be present as a field (D-16)
# It may be None for supported items
assert "unsupported_analysis_reason" in body, (
"Cloud detail must include unsupported_analysis_reason (D-16)"
)
# ── T-14.1-01: Response excludes forbidden credential/key fields ──────────────
async def test_detail_response_excludes_credentials_and_keys(async_client, db_session):
"""T-14.1-01 / D-18: Cloud detail response must not expose forbidden fields.
The full serialized response body must NOT contain:
- credentials_enc (cloud credential ciphertext)
- object_key (MinIO object path — internal cache detail)
- version_key (internal cache version discriminator)
- password (user credential field)
This test will FAIL until Plan 02 implements the /detail endpoint.
"""
auth = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth["user"].id)
item = await _create_analyzed_item_with_topics(
db_session, auth["user"].id, conn.id
)
url = _DETAIL_PATH_TEMPLATE.format(
connection_id=conn.id,
item_id=item.provider_item_id,
)
resp = await async_client.get(url, headers=auth["headers"])
assert resp.status_code == 200, (
f"Expected 200 from cloud detail endpoint, got {resp.status_code}: {resp.text}"
)
# Serialize the full body to string for token presence checks (D-18, T-14.1-01)
body_text = resp.text
# Forbidden tokens — CLAUDE.md allowlist-schema rule
forbidden_tokens = [
"credentials_enc", # cloud credential ciphertext
"object_key", # MinIO internal object path
"version_key", # internal cache version discriminator
]
for token in forbidden_tokens:
assert token not in body_text, (
f"T-14.1-01: Forbidden field '{token}' must not appear in cloud detail response"
)
# Provider URLs must not appear in the body (raw https:// pointing at the provider host)
# A provider URL for google_drive would typically be https://drive.google.com or
# https://www.googleapis.com — the response must use DocuVault-scoped relative URLs only
# (or omit provider URLs entirely).
# We assert no raw http(s) URL pointing to an external provider appears in the body.
# The test checks for the absence of common provider URL substrings that would
# indicate credential-bearing URLs leaking through.
assert "googleapis.com" not in body_text, (
"T-14.1-01: Raw Google API URL must not appear in cloud detail response"
)
assert "graph.microsoft.com" not in body_text, (
"T-14.1-01: Raw Microsoft Graph URL must not appear in cloud detail response"
)
# ── T-14.1-02: IDOR — foreign user gets 404 ──────────────────────────────────
async def test_foreign_user_detail_returns_404(async_client, db_session):
"""T-14.1-02 / D-18: Foreign user GET of another user's cloud item returns 404.
The ownership check (connection_id scoped to current_user.id) must fire before
any content is served. 404 protects against IDOR.
This test will FAIL until Plan 02 implements the /detail endpoint.
"""
auth_owner = await _create_user_and_token(db_session)
auth_foreign = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth_owner["user"].id)
item = await _create_cloud_item(db_session, auth_owner["user"].id, conn.id)
url = _DETAIL_PATH_TEMPLATE.format(
connection_id=conn.id,
item_id=item.provider_item_id,
)
resp = await async_client.get(url, headers=auth_foreign["headers"])
assert resp.status_code == 404, (
f"T-14.1-02: Foreign user must get 404 (IDOR protection) — "
f"got {resp.status_code}: {resp.text}"
)
# ── T-14.1-02: Admin is blocked from cloud detail endpoint ───────────────────
async def test_admin_detail_returns_403(async_client, db_session):
"""T-14.1-02 / D-18: Admin token is blocked from cloud detail by get_regular_user.
The cloud detail endpoint must use get_regular_user, which rejects admin accounts.
Admin accounts must not access user document content.
This test will FAIL until Plan 02 implements the /detail endpoint.
"""
auth_user = await _create_user_and_token(db_session, role="user")
auth_admin = await _create_user_and_token(db_session, role="admin")
conn = await _create_cloud_connection(db_session, auth_user["user"].id)
item = await _create_cloud_item(db_session, auth_user["user"].id, conn.id)
url = _DETAIL_PATH_TEMPLATE.format(
connection_id=conn.id,
item_id=item.provider_item_id,
)
resp = await async_client.get(url, headers=auth_admin["headers"])
assert resp.status_code == 403, (
f"T-14.1-02: Admin must be blocked from cloud detail — "
f"expected 403 from get_regular_user, got {resp.status_code}: {resp.text}"
)
# ── D-07: Stale item returns prior extracted_text and topics ─────────────────
async def test_stale_item_returns_prior_analysis_data(async_client, db_session):
"""D-07: Stale cloud analysis keeps prior extracted text and topics visible.
When analysis_status is 'stale', the detail response must still include
the previously extracted_text and topics — it must not clear them.
This test will FAIL until Plan 02 implements the /detail endpoint.
"""
auth = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth["user"].id)
# Create an item that was indexed but has become stale
item = await _create_analyzed_item_with_topics(
db_session, auth["user"].id, conn.id, topic_names=["legal"]
)
# Update analysis_status to stale (provider changed the file)
from db.models import CloudItem
from sqlalchemy import select as sa_select
result = await db_session.execute(
sa_select(CloudItem).where(CloudItem.id == item.id)
)
item_row = result.scalars().first()
item_row.analysis_status = "stale"
await db_session.commit()
url = _DETAIL_PATH_TEMPLATE.format(
connection_id=conn.id,
item_id=item.provider_item_id,
)
resp = await async_client.get(url, headers=auth["headers"])
assert resp.status_code == 200, (
f"Expected 200 for stale item, got {resp.status_code}: {resp.text}"
)
body = resp.json()
# D-07: stale item must still return prior extracted_text and topics
assert "extracted_text" in body, "Stale item must still include extracted_text"
assert body["extracted_text"], "Stale item must retain prior extracted_text"
assert "topics" in body, "Stale item must still include topics"
assert len(body["topics"]) >= 1, "Stale item must retain prior topic links"
# analysis_status must reflect stale state
assert body.get("analysis_status") == "stale", (
f"Stale item must report analysis_status='stale', got {body.get('analysis_status')}"
)
# ── D-05 / D-07: Pending item returns empty analysis fields ──────────────────
async def test_pending_item_returns_empty_analysis_fields(async_client, db_session):
"""D-02 / D-05: Cloud detail exists before analysis and shows empty-state fields.
A cloud file that has not been analyzed yet must return 200 with:
analysis_status = 'pending', extracted_text = None/empty, topics = [].
This test will FAIL until Plan 02 implements the /detail endpoint.
"""
auth = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth["user"].id)
item = await _create_cloud_item(
db_session, auth["user"].id, conn.id, analysis_status="pending"
)
url = _DETAIL_PATH_TEMPLATE.format(
connection_id=conn.id,
item_id=item.provider_item_id,
)
resp = await async_client.get(url, headers=auth["headers"])
assert resp.status_code == 200, (
f"Expected 200 for pending item (detail exists before analysis), "
f"got {resp.status_code}: {resp.text}"
)
body = resp.json()
# analysis_status should be pending or none
assert body.get("analysis_status") in ("pending", None, ""), (
f"Expected pending analysis_status, got {body.get('analysis_status')}"
)
# topics must be an empty list (not None — frontend expects a list)
topics = body.get("topics", [])
assert isinstance(topics, list), "topics must be a list even for unanalyzed items"
assert topics == [], f"Unanalyzed item must have empty topics list, got {topics}"
# ── D-18: Detail does not hydrate bytes (no get_object call during detail) ────
async def test_detail_does_not_download_bytes(async_client, db_session):
"""D-18: Cloud detail endpoint must not call adapter.get_object or MinIO.
The detail endpoint returns only metadata from cloud_items — it must not
trigger byte download through the cache or provider adapter.
This test will FAIL until Plan 02 implements the /detail endpoint.
"""
auth = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth["user"].id)
item = await _create_analyzed_item_with_topics(db_session, auth["user"].id, conn.id)
get_object_tracker = MagicMock(
side_effect=AssertionError("get_object must not be called from cloud detail")
)
url = _DETAIL_PATH_TEMPLATE.format(
connection_id=conn.id,
item_id=item.provider_item_id,
)
with patch("services.cloud_cache.hydrate_and_cache_bytes", side_effect=AssertionError(
"hydrate_and_cache_bytes must not be called from detail endpoint"
)):
resp = await async_client.get(url, headers=auth["headers"])
# If the endpoint exists, it must not have triggered byte hydration
assert resp.status_code == 200, (
f"Expected 200 from cloud detail (metadata-only), got {resp.status_code}: {resp.text}"
)