Step 13 - completed
This commit is contained in:
@@ -1,48 +1,30 @@
|
||||
"""Tests for the storage layer: encryption, BlobStore, and VectorStore."""
|
||||
"""Tests for the storage layer: encryption, BlobStore, VectorStore, and storage routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import boto3
|
||||
import pytest
|
||||
from botocore.exceptions import ClientError
|
||||
from moto import mock_aws
|
||||
|
||||
from app.storage.encryption import reject_if_tampered, verify_checksum
|
||||
from app.storage.blob_store import BlobStore
|
||||
from app.storage.vector_store import VectorStore, _blob_to_vector
|
||||
from app.schemas import VectorItem, VectorSearchResult
|
||||
from tests.conftest import auth_header, S3_TEST_BUCKET
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
_BLOB = b"encrypted-payload-opaque-to-server"
|
||||
_CHECKSUM = hashlib.sha256(_BLOB).hexdigest()
|
||||
_BUCKET = "test-bucket"
|
||||
_BUCKET = S3_TEST_BUCKET
|
||||
_REGION = "us-east-1"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def s3_bucket():
|
||||
"""Create a mocked S3 bucket and expose its name."""
|
||||
with mock_aws():
|
||||
os.environ.setdefault("AWS_ACCESS_KEY_ID", "testing")
|
||||
os.environ.setdefault("AWS_SECRET_ACCESS_KEY", "testing")
|
||||
os.environ.setdefault("AWS_DEFAULT_REGION", _REGION)
|
||||
client = boto3.client("s3", region_name=_REGION)
|
||||
client.create_bucket(Bucket=_BUCKET)
|
||||
with patch("app.storage.blob_store.settings") as mock_settings:
|
||||
mock_settings.S3_BUCKET = _BUCKET
|
||||
mock_settings.S3_REGION = _REGION
|
||||
mock_settings.AWS_ACCESS_KEY_ID = "testing"
|
||||
mock_settings.AWS_SECRET_ACCESS_KEY = "testing"
|
||||
yield _BUCKET
|
||||
|
||||
|
||||
def _pinecone_mock():
|
||||
"""Return a mock Pinecone index with realistic return shapes."""
|
||||
mock_index = MagicMock()
|
||||
@@ -383,3 +365,198 @@ class TestVectorStoreQdrant:
|
||||
await store.delete("u1", ["v1"])
|
||||
call_kwargs = mock_client.delete.call_args[1]
|
||||
assert call_kwargs["collection_name"] == "adiuva_vectors"
|
||||
|
||||
|
||||
# ── TestStorageRoutes (integration) ───────────────────────────────────
|
||||
|
||||
|
||||
class TestStorageRoutes:
|
||||
"""Integration tests for POST/GET/PUT/DELETE /api/v1/storage/records.
|
||||
|
||||
Pydantic v2 converts JSON string → bytes via ``str.encode('utf-8')``.
|
||||
So "hello" in JSON becomes ``b"hello"`` on the server. We use plain
|
||||
ASCII strings as blob values and compute checksums accordingly.
|
||||
"""
|
||||
|
||||
_BLOB_STR = "encrypted-payload-opaque-to-server"
|
||||
_BLOB_BYTES = _BLOB_STR.encode()
|
||||
_BLOB_CHECKSUM = hashlib.sha256(_BLOB_BYTES).hexdigest()
|
||||
|
||||
@classmethod
|
||||
def _create_payload(cls, blob_str: str | None = None) -> dict:
|
||||
blob_str = blob_str or cls._BLOB_STR
|
||||
checksum = hashlib.sha256(blob_str.encode()).hexdigest()
|
||||
return {
|
||||
"table": "tasks",
|
||||
"blob": blob_str,
|
||||
"checksum": checksum,
|
||||
}
|
||||
|
||||
def _create_record(self, client, tier="power", blob_str=None):
|
||||
payload = self._create_payload(blob_str)
|
||||
return client.post(
|
||||
"/api/v1/storage/records",
|
||||
json=payload,
|
||||
headers=auth_header(tier),
|
||||
)
|
||||
|
||||
# ── Create ────────────────────────────────────────────────────────
|
||||
|
||||
def test_create_record(self, client, s3_bucket) -> None:
|
||||
resp = self._create_record(client)
|
||||
assert resp.status_code == 201
|
||||
data = resp.json()
|
||||
assert "id" in data
|
||||
assert "created_at" in data
|
||||
|
||||
def test_create_record_bad_checksum(self, client, s3_bucket) -> None:
|
||||
payload = {
|
||||
"table": "tasks",
|
||||
"blob": self._BLOB_STR,
|
||||
"checksum": "0" * 64,
|
||||
}
|
||||
resp = client.post(
|
||||
"/api/v1/storage/records",
|
||||
json=payload,
|
||||
headers=auth_header("power"),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_create_record_free_tier_blocked(self, client, s3_bucket) -> None:
|
||||
"""Free tier has cloud_storage_gb=0 → 402."""
|
||||
resp = self._create_record(client, tier="free")
|
||||
assert resp.status_code == 402
|
||||
|
||||
def test_create_record_pro_tier_allowed(self, client, s3_bucket) -> None:
|
||||
"""Pro tier has cloud_storage_gb=5 → succeeds for small blob."""
|
||||
resp = self._create_record(client, tier="pro")
|
||||
assert resp.status_code == 201
|
||||
|
||||
# ── List ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_list_records(self, client, s3_bucket) -> None:
|
||||
self._create_record(client)
|
||||
self._create_record(client, blob_str="second-blob")
|
||||
resp = client.get(
|
||||
"/api/v1/storage/records",
|
||||
headers=auth_header("power"),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 2
|
||||
# Each entry has metadata, no blob bytes
|
||||
for item in data:
|
||||
assert "id" in item
|
||||
assert "table" in item
|
||||
assert "checksum" in item
|
||||
assert "blob" not in item
|
||||
|
||||
def test_list_records_filter_by_table(self, client, s3_bucket) -> None:
|
||||
self._create_record(client)
|
||||
# Create in a different table
|
||||
note_blob = "note-blob"
|
||||
payload = {
|
||||
"table": "notes",
|
||||
"blob": note_blob,
|
||||
"checksum": hashlib.sha256(note_blob.encode()).hexdigest(),
|
||||
}
|
||||
client.post(
|
||||
"/api/v1/storage/records",
|
||||
json=payload,
|
||||
headers=auth_header("power"),
|
||||
)
|
||||
resp = client.get(
|
||||
"/api/v1/storage/records?table=notes",
|
||||
headers=auth_header("power"),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert len(data) == 1
|
||||
assert data[0]["table"] == "notes"
|
||||
|
||||
def test_list_records_isolated_per_user(self, client, s3_bucket) -> None:
|
||||
"""One user's records should not appear in another user's list."""
|
||||
self._create_record(client, tier="power")
|
||||
resp = client.get(
|
||||
"/api/v1/storage/records",
|
||||
headers=auth_header("team"),
|
||||
)
|
||||
assert resp.json() == []
|
||||
|
||||
# ── Download ──────────────────────────────────────────────────────
|
||||
|
||||
def test_download_record(self, client, s3_bucket) -> None:
|
||||
create_resp = self._create_record(client)
|
||||
record_id = create_resp.json()["id"]
|
||||
resp = client.get(
|
||||
f"/api/v1/storage/records/{record_id}",
|
||||
headers=auth_header("power"),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == self._BLOB_BYTES
|
||||
assert resp.headers["X-Checksum"] == self._BLOB_CHECKSUM
|
||||
|
||||
def test_download_record_not_found(self, client, s3_bucket) -> None:
|
||||
resp = client.get(
|
||||
"/api/v1/storage/records/nonexistent-id",
|
||||
headers=auth_header("power"),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
# ── Update ────────────────────────────────────────────────────────
|
||||
|
||||
def test_update_record(self, client, s3_bucket) -> None:
|
||||
create_resp = self._create_record(client)
|
||||
record_id = create_resp.json()["id"]
|
||||
new_blob_str = "updated-encrypted-payload"
|
||||
new_checksum = hashlib.sha256(new_blob_str.encode()).hexdigest()
|
||||
resp = client.put(
|
||||
f"/api/v1/storage/records/{record_id}",
|
||||
json={"blob": new_blob_str, "checksum": new_checksum},
|
||||
headers=auth_header("power"),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True}
|
||||
|
||||
# Verify download returns the updated blob
|
||||
dl = client.get(
|
||||
f"/api/v1/storage/records/{record_id}",
|
||||
headers=auth_header("power"),
|
||||
)
|
||||
assert dl.content == new_blob_str.encode()
|
||||
|
||||
def test_update_record_bad_checksum(self, client, s3_bucket) -> None:
|
||||
create_resp = self._create_record(client)
|
||||
record_id = create_resp.json()["id"]
|
||||
resp = client.put(
|
||||
f"/api/v1/storage/records/{record_id}",
|
||||
json={"blob": "some-data", "checksum": "0" * 64},
|
||||
headers=auth_header("power"),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
# ── Delete ────────────────────────────────────────────────────────
|
||||
|
||||
def test_delete_record(self, client, s3_bucket) -> None:
|
||||
create_resp = self._create_record(client)
|
||||
record_id = create_resp.json()["id"]
|
||||
resp = client.delete(
|
||||
f"/api/v1/storage/records/{record_id}",
|
||||
headers=auth_header("power"),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"ok": True}
|
||||
|
||||
# Subsequent GET should return 404
|
||||
dl = client.get(
|
||||
f"/api/v1/storage/records/{record_id}",
|
||||
headers=auth_header("power"),
|
||||
)
|
||||
assert dl.status_code == 404
|
||||
|
||||
def test_delete_record_not_found(self, client, s3_bucket) -> None:
|
||||
resp = client.delete(
|
||||
"/api/v1/storage/records/nonexistent",
|
||||
headers=auth_header("power"),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
Reference in New Issue
Block a user