feat(13-09): move stale-guard, descendant safety, reconciliation, delete disclosure, and metadata-only auditing

Move endpoint (D-07, D-08, D-09, T-13-27, T-13-28, T-13-29):
- Descendant destination detection via cloud_items ancestor-chain walk (D-09)
- Stale provider result marks source folder non-fresh, returns typed stale body (D-07)
- Success: upsert_cloud_item with new parent_ref before returning
- Success: update_folder_state for both source and destination folders
- Success: write_audit_log 'cloud.item_moved' with metadata-only payload

Delete endpoint (D-10, D-11, T-13-29):
- Pre-fetch item kind for D-10 folder-vs-file disclosure
- Success: update_folder_state for parent folder before returning
- Success: write_audit_log 'cloud.item_deleted' with delete_kind metadata
- Response carries is_folder/item_kind for frontend disclosure
- Failed deletes never emit audit rows

Promote xfail markers in test_cloud_audit.py for move and delete audit rows
This commit is contained in:
curo1305
2026-06-22 20:02:09 +02:00
parent af7d45f3c4
commit 508d27643b
2 changed files with 281 additions and 49 deletions
+62 -45
View File
@@ -438,77 +438,94 @@ async def test_rename_stale_does_not_write_false_rename_audit(async_client, db_s
# ── T-13-05: Move audit row ───────────────────────────────────────────────────
@pytest.mark.xfail(
reason="Phase 13 audit write not implemented yet — RED test from plan 01 (T-13-05)",
strict=True,
)
async def test_move_success_writes_audit_row(async_client, db_session):
"""Successful move writes audit row 'cloud.item_moved' with metadata only (T-13-05).
Metadata must include connection_id, old_parent_ref, new_parent_ref — no tokens.
FAILS: Phase 13 audit write does not exist yet.
Plan 09 GREEN: Move audit write implemented.
"""
from storage.cloud_base import MUT_KIND_UPDATED, MUT_REASON_MOVED
from unittest.mock import patch, AsyncMock, MagicMock
auth = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth["user"].id)
payload = {"destination_parent_ref": "dest_folder_ref", "etag": "v1"}
resp = await async_client.post(
f"/api/cloud/connections/{conn.id}/items/fake_item_id/move",
headers=auth["headers"],
json=payload,
)
# 401 means credential decrypt failure in test env; audit check skipped in that case.
assert resp.status_code in (200, 401, 404), (
f"Unexpected status: {resp.status_code}"
)
if resp.status_code == 200:
rows = await _get_recent_audit_rows(
db_session, auth["user"].id, event_type="cloud.item_moved"
mock_adapter = AsyncMock()
mock_adapter.get_object = AsyncMock(return_value=b"content")
mock_adapter._normalize_error = MagicMock(return_value={"kind": "error", "reason": "provider_error"})
mock_adapter.move = AsyncMock(return_value={
"kind": MUT_KIND_UPDATED,
"reason": MUT_REASON_MOVED,
"provider_item_id": "fake_item_id",
"destination_parent_ref": "dest_folder_ref",
})
payload = {"destination_parent_ref": "dest_folder_ref_audit", "etag": "v1"}
with patch("storage.cloud_backend_factory.build_mutable_cloud_adapter", return_value=mock_adapter):
resp = await async_client.post(
f"/api/cloud/connections/{conn.id}/items/fake_item_id/move",
headers=auth["headers"],
json=payload,
)
assert len(rows) >= 1, (
"Successful move must write audit row 'cloud.item_moved' (T-13-05)"
)
_assert_metadata_no_secrets(rows[0].metadata_, "cloud.item_moved audit")
assert resp.status_code == 200, (
f"Expected 200 for move, got {resp.status_code}: {resp.text}"
)
rows = await _get_recent_audit_rows(
db_session, auth["user"].id, event_type="cloud.item_moved"
)
assert len(rows) >= 1, (
"Successful move must write audit row 'cloud.item_moved' (T-13-05)"
)
_assert_metadata_no_secrets(rows[0].metadata_, "cloud.item_moved audit")
# ── T-13-05: Delete audit row ─────────────────────────────────────────────────
@pytest.mark.xfail(
reason="Phase 13 audit write not implemented yet — RED test from plan 01 (T-13-05)",
strict=True,
)
async def test_delete_success_writes_audit_row(async_client, db_session):
"""Successful delete writes audit row 'cloud.item_deleted' with metadata only (T-13-05).
Metadata must include connection_id, item_id, delete_kind ('trashed' or 'permanent').
FAILS: Phase 13 audit write does not exist yet.
Plan 09 GREEN: Delete audit write implemented.
"""
from storage.cloud_base import MUT_KIND_DELETED, MUT_REASON_TRASHED
from unittest.mock import patch, AsyncMock, MagicMock
auth = await _create_user_and_token(db_session)
conn = await _create_cloud_connection(db_session, auth["user"].id)
resp = await async_client.delete(
f"/api/cloud/connections/{conn.id}/items/fake_file_id",
headers=auth["headers"],
)
# 401 means credential decrypt failure in test env; audit check skipped in that case.
assert resp.status_code in (200, 204, 401, 404), (
f"Unexpected status: {resp.status_code}"
)
if resp.status_code in (200, 204):
rows = await _get_recent_audit_rows(
db_session, auth["user"].id, event_type="cloud.item_deleted"
mock_adapter = AsyncMock()
mock_adapter.get_object = AsyncMock(return_value=b"content")
mock_adapter._normalize_error = MagicMock(return_value={"kind": "error", "reason": "provider_error"})
mock_adapter.delete = AsyncMock(return_value={
"kind": MUT_KIND_DELETED,
"reason": MUT_REASON_TRASHED,
"provider_item_id": "audit_delete_file_id",
})
with patch("storage.cloud_backend_factory.build_mutable_cloud_adapter", return_value=mock_adapter):
resp = await async_client.delete(
f"/api/cloud/connections/{conn.id}/items/audit_delete_file_id",
headers=auth["headers"],
)
assert len(rows) >= 1, (
"Successful delete must write audit row 'cloud.item_deleted' (T-13-05)"
assert resp.status_code in (200, 204), (
f"Expected 200/204 for delete, got {resp.status_code}: {resp.text}"
)
rows = await _get_recent_audit_rows(
db_session, auth["user"].id, event_type="cloud.item_deleted"
)
assert len(rows) >= 1, (
"Successful delete must write audit row 'cloud.item_deleted' (T-13-05)"
)
_assert_metadata_no_secrets(rows[0].metadata_, "cloud.item_deleted audit")
if rows[0].metadata_:
assert "delete_kind" in rows[0].metadata_ or "reason" in rows[0].metadata_, (
"Delete audit metadata must indicate 'trashed' vs 'permanent' (D-11)"
)
_assert_metadata_no_secrets(rows[0].metadata_, "cloud.item_deleted audit")
if rows[0].metadata_:
assert "delete_kind" in rows[0].metadata_ or "reason" in rows[0].metadata_, (
"Delete audit metadata must indicate 'trashed' vs 'permanent' (D-11)"
)
# ── T-13-02: Admin audit log must not expose cloud credentials ─────────────────