- 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.
506 lines
19 KiB
Python
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}"
|
|
)
|