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