test(12.1-02): add failing tests for truthful freshness gate (RED)

- test_incomplete_listing_never_marks_folder_fresh
- test_incomplete_listing_retains_cached_rows_and_last_success
- test_complete_empty_listing_is_authoritative_and_fresh
- test_partial_items_upsert_without_deleting_unseen_children
- test_apply_listing_returns_warning_for_complete_false
- test_sync_browse_returns_warning_for_complete_false
- test_worker_returns_warning_for_complete_false
- security: browse_complete_false_never_sets_fresh, no_raw_provider_error, owner_scoped_on_incomplete
This commit is contained in:
curo1305
2026-06-22 08:26:35 +02:00
parent a6e8a597e7
commit fe08afd740
3 changed files with 457 additions and 0 deletions
+228
View File
@@ -625,3 +625,231 @@ async def test_refresh_cloud_folder_no_byte_calls(db_session: AsyncSession):
db_session, user_id=uid, connection_id=cid, parent_ref=None, listing=listing
)
mock_get_object.assert_not_called()
# ── Plan 02: Truthful freshness / listing-gate semantics ──────────────────────
@pytest.mark.asyncio
async def test_incomplete_listing_never_marks_folder_fresh(db_session: AsyncSession):
"""apply_listing_and_finalize must NOT set refresh_state=fresh when listing.complete=False.
RED: cloud_items.py currently sets refresh_state="fresh" unconditionally after
reconcile_cloud_listing. This test must fail until the gate is in place.
"""
from services.cloud_items import apply_listing_and_finalize
uid = await _make_user(db_session)
cid = await _make_connection(db_session, uid)
# Seed an existing successful refresh
await update_folder_state(
db_session,
user_id=uid,
connection_id=cid,
parent_ref="",
refresh_state="fresh",
last_refreshed_at=datetime(2026, 6, 1, 12, 0, 0, tzinfo=timezone.utc),
)
await db_session.flush()
incomplete_listing = CloudListing(items=(), complete=False)
result = await apply_listing_and_finalize(
db_session,
user_id=uid,
connection_id=cid,
parent_ref=None,
listing=incomplete_listing,
)
from sqlalchemy import select
fs = (await db_session.execute(
select(CloudFolderState).where(
CloudFolderState.connection_id == cid,
CloudFolderState.parent_ref == "",
)
)).scalars().first()
assert fs is not None
assert fs.refresh_state != "fresh", (
"incomplete listing (complete=False) must never set refresh_state=fresh"
)
@pytest.mark.asyncio
async def test_incomplete_listing_retains_cached_rows_and_last_success(db_session: AsyncSession):
"""Incomplete refresh must retain cached items AND preserve previous last_refreshed_at.
T-12.1-07: cached metadata survives ambiguous/incomplete provider responses.
T-12.1-08: last known-good timestamp is not overwritten on incomplete refresh.
"""
from services.cloud_items import apply_listing_and_finalize
uid = await _make_user(db_session)
cid = await _make_connection(db_session, uid)
prior_ts = datetime(2026, 6, 1, 12, 0, 0, tzinfo=timezone.utc)
# Seed durable items from a prior successful refresh
resource = _cloud_resource(cid, uid, provider_item_id="keeper-001", name="keep.pdf")
await upsert_cloud_item(db_session, user_id=uid, resource=resource)
await update_folder_state(
db_session,
user_id=uid,
connection_id=cid,
parent_ref="",
refresh_state="fresh",
last_refreshed_at=prior_ts,
)
await db_session.flush()
# Incomplete listing with no items — must not delete keeper-001
incomplete_listing = CloudListing(items=(), complete=False)
await apply_listing_and_finalize(
db_session,
user_id=uid,
connection_id=cid,
parent_ref=None,
listing=incomplete_listing,
)
# Cached items must survive
from sqlalchemy import select
result = await db_session.execute(
select(CloudItem).where(
CloudItem.provider_item_id == "keeper-001",
CloudItem.deleted_at.is_(None),
)
)
assert result.scalars().first() is not None, "incomplete listing must not delete cached items"
# Prior successful timestamp must be preserved
fs = (await db_session.execute(
select(CloudFolderState).where(
CloudFolderState.connection_id == cid,
CloudFolderState.parent_ref == "",
)
)).scalars().first()
assert fs is not None
assert fs.last_refreshed_at == prior_ts, (
f"incomplete listing must not overwrite last_refreshed_at: {fs.last_refreshed_at} != {prior_ts}"
)
@pytest.mark.asyncio
async def test_complete_empty_listing_is_authoritative_and_fresh(db_session: AsyncSession):
"""A provider-confirmed empty listing (complete=True, items=[]) is authoritative and fresh.
T-12.1-07: reconcile deletion runs only when complete=True.
A complete empty listing soft-deletes all unseen children AND marks folder fresh.
"""
from services.cloud_items import apply_listing_and_finalize
uid = await _make_user(db_session)
cid = await _make_connection(db_session, uid)
# Pre-existing item that the provider no longer reports
gone = _cloud_resource(cid, uid, provider_item_id="gone-002", name="deleted.pdf")
await upsert_cloud_item(db_session, user_id=uid, resource=gone)
complete_empty_listing = CloudListing(items=(), complete=True)
result = await apply_listing_and_finalize(
db_session,
user_id=uid,
connection_id=cid,
parent_ref=None,
listing=complete_empty_listing,
)
from sqlalchemy import select
# Soft-deletion must have occurred
gone_row = (await db_session.execute(
select(CloudItem).where(CloudItem.provider_item_id == "gone-002")
)).scalars().first()
assert gone_row is not None
assert gone_row.deleted_at is not None, "complete empty listing must soft-delete unseen items"
# Folder must be marked fresh
fs = (await db_session.execute(
select(CloudFolderState).where(
CloudFolderState.connection_id == cid,
CloudFolderState.parent_ref == "",
)
)).scalars().first()
assert fs is not None
assert fs.refresh_state == "fresh", "complete empty listing must mark folder fresh"
@pytest.mark.asyncio
async def test_partial_items_upsert_without_deleting_unseen_children(db_session: AsyncSession):
"""Incomplete listing upserts seen items but leaves unseen children intact.
T-12.1-07: soft-deletion of missing children requires complete=True.
Partially-seen items (e.g. first page only) must not delete un-paged items.
"""
from services.cloud_items import apply_listing_and_finalize
uid = await _make_user(db_session)
cid = await _make_connection(db_session, uid)
# Two pre-existing items
seen = _cloud_resource(cid, uid, provider_item_id="seen-001", name="seen.pdf")
unseen = _cloud_resource(cid, uid, provider_item_id="unseen-002", name="hidden.pdf")
await upsert_cloud_item(db_session, user_id=uid, resource=seen)
await upsert_cloud_item(db_session, user_id=uid, resource=unseen)
# Incomplete listing: only "seen-001" came back in partial page
partial_listing = CloudListing(items=[seen], complete=False)
await apply_listing_and_finalize(
db_session,
user_id=uid,
connection_id=cid,
parent_ref=None,
listing=partial_listing,
)
from sqlalchemy import select
unseen_row = (await db_session.execute(
select(CloudItem).where(
CloudItem.provider_item_id == "unseen-002",
CloudItem.deleted_at.is_(None),
)
)).scalars().first()
assert unseen_row is not None, "unseen items must survive when listing is incomplete"
@pytest.mark.asyncio
async def test_apply_listing_returns_warning_for_complete_false(db_session: AsyncSession):
"""apply_listing_and_finalize returns a result with warning when listing.complete=False.
T-12.1-06: false fresh state must never be returned to the API caller.
The result must carry a controlled warning code (incomplete_listing) and no
raw provider exception text.
"""
from services.cloud_items import apply_listing_and_finalize
uid = await _make_user(db_session)
cid = await _make_connection(db_session, uid)
incomplete_listing = CloudListing(items=(), complete=False)
result = await apply_listing_and_finalize(
db_session,
user_id=uid,
connection_id=cid,
parent_ref=None,
listing=incomplete_listing,
)
# The result must signal warning, not fresh
assert result.is_fresh is False, "incomplete listing must not report is_fresh=True"
assert result.warning_code == "incomplete_listing", (
f"expected warning_code='incomplete_listing', got {result.warning_code!r}"
)
# Warning message must be a stable controlled string (no traceback, no URLs, no credentials)
assert result.warning_message is not None
assert len(result.warning_message) > 0
dangerous_patterns = ["traceback", "http://", "https://", "password", "token", "credentials"]
for pattern in dangerous_patterns:
assert pattern not in result.warning_message.lower(), (
f"warning_message must not contain sensitive content: {pattern!r}"
)