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