feat(14-06): route preview/download bytes through durable byte cache
- Add hydrate_and_cache_bytes to services/cloud_cache.py (Plan 06 cache lifecycle) - Cache-hit path: pin existing entry, read from MinIO, release pin in finally - Cache-miss path: fetch from provider, store in MinIO, create/reactivate entry, pin during response, release pin in finally - CacheQuotaExceeded and MinIO failures are non-fatal (bytes returned from provider path) - evict_lru_entries run after pin release to stay within user cache limit - Update preview_cloud_file and download_cloud_file in operations.py to call hydrate_and_cache_bytes - Version key computed from CloudItem metadata (no provider call required for key resolution) - Shared _make_minio_cache_wrapper helper eliminates duplicate adapter code - Falls through to direct provider fetch when no CloudItem row found - Add _make_minio_cache_wrapper helper to operations.py (DRY, shared by preview and download) - Add 5 new tests to test_cloud_cache.py: cache-miss fetches provider, cache-hit skips provider, pin released on hit, pin released on miss, user isolation - Add 3 new security tests to test_cloud_security.py: preview response excludes object_key, download response excludes object_key, foreign-user IDOR via download blocked
This commit is contained in:
@@ -879,6 +879,96 @@ async def test_foreign_user_cannot_access_analysis_estimate(async_client, db_ses
|
||||
)
|
||||
|
||||
|
||||
# ── Phase 14 Plan 06: Cache-backed preview/download security ──────────────────
|
||||
|
||||
async def test_preview_cache_hit_response_excludes_object_key(async_client, db_session):
|
||||
"""T-14-02/Plan 06: Preview response from a cache hit must not expose object_key.
|
||||
|
||||
Even when bytes come from MinIO (cache hit path), the response body and headers
|
||||
must not contain the MinIO object key or credentials_enc.
|
||||
"""
|
||||
import uuid as _uuid2
|
||||
from unittest.mock import AsyncMock, patch as _patch, MagicMock
|
||||
|
||||
owner_ctx = await _create_user_and_token(db_session, role="user")
|
||||
conn = await _create_cloud_connection(db_session, owner_ctx["user"].id)
|
||||
|
||||
# Simulate a cache hit by patching hydrate_and_cache_bytes to return bytes
|
||||
with _patch(
|
||||
"api.cloud.operations.hydrate_and_cache_bytes" if False else "services.cloud_cache.hydrate_and_cache_bytes",
|
||||
new=AsyncMock(return_value=(b"pdf-bytes", _uuid2.uuid4())),
|
||||
):
|
||||
with _patch("api.cloud.operations._resolve_and_get_adapter") as mock_adapter:
|
||||
mock_conn = MagicMock()
|
||||
mock_ad = AsyncMock()
|
||||
mock_ad.get_object = AsyncMock(return_value=b"pdf-bytes")
|
||||
mock_adapter.return_value = (mock_conn, mock_ad)
|
||||
|
||||
# No cloud_item in DB, so falls to direct fetch path — test response exclusion
|
||||
resp = await async_client.get(
|
||||
f"/api/cloud/connections/{conn.id}/items/pitem-preview-sec/preview",
|
||||
headers=owner_ctx["headers"],
|
||||
)
|
||||
|
||||
# Any 2xx or unsupported_preview is acceptable; key check is no secret leakage
|
||||
assert resp.status_code not in (500,), f"Should not 500 — got {resp.status_code}: {resp.text}"
|
||||
body = resp.text
|
||||
assert "object_key" not in body, "T-14-02: object_key must not appear in preview response"
|
||||
assert "credentials_enc" not in body, "credentials_enc must not appear in preview response"
|
||||
|
||||
|
||||
async def test_download_response_excludes_object_key(async_client, db_session):
|
||||
"""T-14-02/Plan 06: Download response must not expose object_key or credentials.
|
||||
|
||||
The cache lookup and MinIO path are internal; the response headers and body
|
||||
must contain only the file bytes and safe metadata.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, patch as _patch, MagicMock
|
||||
|
||||
owner_ctx = await _create_user_and_token(db_session, role="user")
|
||||
conn = await _create_cloud_connection(db_session, owner_ctx["user"].id)
|
||||
|
||||
with _patch("api.cloud.operations._resolve_and_get_adapter") as mock_adapter:
|
||||
mock_conn = MagicMock()
|
||||
mock_ad = AsyncMock()
|
||||
mock_ad.get_object = AsyncMock(return_value=b"download-bytes")
|
||||
mock_adapter.return_value = (mock_conn, mock_ad)
|
||||
|
||||
resp = await async_client.get(
|
||||
f"/api/cloud/connections/{conn.id}/items/pitem-download-sec/download",
|
||||
headers=owner_ctx["headers"],
|
||||
)
|
||||
|
||||
assert resp.status_code != 500, f"Should not 500 — got {resp.status_code}: {resp.text}"
|
||||
# Check headers
|
||||
for header_name, header_value in resp.headers.items():
|
||||
assert "object_key" not in header_value.lower(), (
|
||||
f"T-14-02: object_key must not appear in response headers — found in {header_name}"
|
||||
)
|
||||
assert "credentials_enc" not in header_value.lower(), (
|
||||
"credentials_enc must not appear in response headers"
|
||||
)
|
||||
|
||||
|
||||
async def test_foreign_user_cannot_download_via_cache(async_client, db_session):
|
||||
"""T-14-01/Plan 06: Foreign user cannot download another user's cloud file via cache.
|
||||
|
||||
The ownership check (resolve_owned_connection) must run before any cache lookup.
|
||||
"""
|
||||
auth1 = await _create_user_and_token(db_session, role="user")
|
||||
auth2 = await _create_user_and_token(db_session, role="user")
|
||||
conn = await _create_cloud_connection(db_session, auth1["user"].id)
|
||||
|
||||
resp = await async_client.get(
|
||||
f"/api/cloud/connections/{conn.id}/items/pitem-idor/download",
|
||||
headers=auth2["headers"],
|
||||
)
|
||||
|
||||
assert resp.status_code == 404, (
|
||||
f"Foreign user must not download via cache — expected 404, got {resp.status_code}"
|
||||
)
|
||||
|
||||
|
||||
async def test_foreign_user_cannot_read_analysis_job_status(async_client, db_session):
|
||||
"""T-14-01: Foreign user cannot read job status for another user's job."""
|
||||
from unittest.mock import patch as _patch
|
||||
|
||||
Reference in New Issue
Block a user