harden memory edits and uploads

This commit is contained in:
2026-06-11 11:06:35 +08:00
parent 7155704b73
commit 8afb460883
7 changed files with 469 additions and 72 deletions

View File

@ -17,15 +17,22 @@ class FakeEverOSClient:
self,
search_results: list[dict[str, Any]] | None = None,
health_error: Exception | None = None,
add_failures: int = 0,
flush_failures: int = 0,
) -> None:
self.add_calls: list[dict[str, Any]] = []
self.flush_calls: list[dict[str, str]] = []
self.search_calls: list[dict[str, Any]] = []
self.search_results = search_results or []
self.health_error = health_error
self.add_failures = add_failures
self.flush_failures = flush_failures
async def add_memory(self, payload: dict[str, Any]) -> dict[str, Any]:
self.add_calls.append(payload)
if self.add_failures > 0:
self.add_failures -= 1
raise RuntimeError("temporary add failure")
return {"request_id": "add", "data": {"status": "accumulated"}}
async def flush_memory(
@ -37,6 +44,9 @@ class FakeEverOSClient:
self.flush_calls.append(
{"session_id": session_id, "app_id": app_id, "project_id": project_id}
)
if self.flush_failures > 0:
self.flush_failures -= 1
raise RuntimeError("temporary flush failure")
return {"request_id": "flush", "data": {"status": "extracted"}}
async def search_memory(self, payload: dict[str, Any]) -> dict[str, Any]:
@ -73,6 +83,33 @@ def app_client(
return httpx.AsyncClient(transport=transport, base_url="http://test")
def create_test_resource(
repo: MemoryRepository,
*,
resource_id: str,
user_id: str,
uri: str = "file:///private/a.txt",
) -> None:
repo.create_resource(
id=resource_id,
user_id=user_id,
app_id="default",
project_id="default",
session_id=f"resource:{user_id}:{resource_id}",
original_filename="a.txt",
mime_type="text/plain",
content_type="text",
uri=uri,
uri_public=False,
sha256=f"sha-{resource_id}",
size_bytes=1,
title=None,
description=None,
status="extracted",
error_message=None,
)
async def create_user(client: httpx.AsyncClient, user_id: str = "u_123") -> str:
response = await client.post("/users", json={"user_id": user_id})
assert response.status_code == 200, response.text
@ -82,6 +119,18 @@ async def create_user(client: httpx.AsyncClient, user_id: str = "u_123") -> str:
return body["user_key"]
def test_create_app_uses_configured_everos_timeout(config: GatewayConfig) -> None:
config = GatewayConfig(
everos_base_url=config.everos_base_url,
database_path=config.database_path,
storage_dir=config.storage_dir,
everos_timeout_seconds=7.5,
)
app = create_app(config=config)
assert app.state.everos_client.timeout == 7.5
@pytest.mark.asyncio
async def test_health_reports_api_and_everos_ok(
config: GatewayConfig,
@ -179,6 +228,102 @@ async def test_upload_resource_creates_record_and_calls_everos(
]
@pytest.mark.asyncio
async def test_upload_retries_transient_everos_failure(
config: GatewayConfig,
) -> None:
config = GatewayConfig(
everos_base_url=config.everos_base_url,
database_path=config.database_path,
storage_dir=config.storage_dir,
everos_ingest_attempts=2,
everos_retry_delay_seconds=0,
)
everos = FakeEverOSClient(add_failures=1, flush_failures=1)
async with app_client(config, everos) as client:
user_key = await create_user(client)
response = await client.post(
"/resources",
data={"user_id": "u_123", "user_key": user_key},
files={"file": ("retry.txt", b"retry me", "text/plain")},
)
assert response.status_code == 200, response.text
assert response.json()["status"] == "extracted"
assert len(everos.add_calls) == 2
assert len(everos.flush_calls) == 2
@pytest.mark.asyncio
async def test_upload_duplicate_resource_is_idempotent_for_same_user(
config: GatewayConfig,
) -> None:
everos = FakeEverOSClient()
async with app_client(config, everos) as client:
user_key = await create_user(client)
first = await client.post(
"/resources",
data={"user_id": "u_123", "user_key": user_key},
files={"file": ("same.txt", b"same bytes", "text/plain")},
)
second = await client.post(
"/resources",
data={"user_id": "u_123", "user_key": user_key},
files={"file": ("same.txt", b"same bytes", "text/plain")},
)
assert first.status_code == 200, first.text
assert second.status_code == 200, second.text
assert second.json()["resource_id"] == first.json()["resource_id"]
assert len(everos.add_calls) == 1
assert len(everos.flush_calls) == 1
@pytest.mark.asyncio
async def test_upload_rejects_file_larger_than_configured_limit(
config: GatewayConfig,
repo: MemoryRepository,
) -> None:
config = GatewayConfig(
everos_base_url=config.everos_base_url,
database_path=config.database_path,
storage_dir=config.storage_dir,
max_upload_bytes=4,
)
everos = FakeEverOSClient()
async with app_client(config, everos) as client:
user_key = await create_user(client)
response = await client.post(
"/resources",
data={"user_id": "u_123", "user_key": user_key},
files={"file": ("big.txt", b"too large", "text/plain")},
)
assert response.status_code == 413, response.text
assert repo.list_resources("u_123") == []
assert not any(config.storage_dir.rglob("*"))
assert everos.add_calls == []
@pytest.mark.asyncio
async def test_upload_rejects_unsupported_mime_type(
config: GatewayConfig,
repo: MemoryRepository,
) -> None:
everos = FakeEverOSClient()
async with app_client(config, everos) as client:
user_key = await create_user(client)
response = await client.post(
"/resources",
data={"user_id": "u_123", "user_key": user_key},
files={"file": ("payload.bin", b"\x00\x01", "application/octet-stream")},
)
assert response.status_code == 415, response.text
assert repo.list_resources("u_123") == []
assert everos.add_calls == []
@pytest.mark.asyncio
async def test_resource_detail_does_not_leak_internal_uri(
config: GatewayConfig,
@ -298,6 +443,10 @@ async def test_deleted_resource_is_excluded_from_resource_scope_search(
assert search_response.status_code == 200
assert everos.search_calls == []
assert search_response.json()["results"] == []
repo = MemoryRepository(config.database_path)
deleted = repo.get_resource(resource_id)
assert deleted is not None
assert not Path(deleted["uri"].removeprefix("file://")).exists()
@pytest.mark.asyncio
@ -305,24 +454,7 @@ async def test_tombstone_filters_search_results(
config: GatewayConfig,
repo: MemoryRepository,
) -> None:
repo.create_resource(
id="r_1",
user_id="u_123",
app_id="default",
project_id="default",
session_id="resource:u_123:r_1",
original_filename="a.txt",
mime_type="text/plain",
content_type="text",
uri="file:///private/a.txt",
uri_public=False,
sha256="abc",
size_bytes=1,
title=None,
description=None,
status="extracted",
error_message=None,
)
create_test_resource(repo, resource_id="r_1", user_id="u_123")
repo.add_tombstone(
user_id="u_123",
memory_id="mem_deleted",
@ -358,24 +490,7 @@ async def test_override_replaces_search_result_text(
config: GatewayConfig,
repo: MemoryRepository,
) -> None:
repo.create_resource(
id="r_1",
user_id="u_123",
app_id="default",
project_id="default",
session_id="resource:u_123:r_1",
original_filename="a.txt",
mime_type="text/plain",
content_type="text",
uri="file:///private/a.txt",
uri_public=False,
sha256="abc",
size_bytes=1,
title=None,
description=None,
status="extracted",
error_message=None,
)
create_test_resource(repo, resource_id="r_1", user_id="u_123")
everos = FakeEverOSClient(
[{"id": "mem_1", "session_id": "resource:u_123:r_1", "episode": "old text"}]
)
@ -407,6 +522,52 @@ async def test_override_replaces_search_result_text(
assert result["raw"]["episode"] == "old text"
@pytest.mark.asyncio
async def test_memory_override_rejects_session_owned_by_another_user(
config: GatewayConfig,
repo: MemoryRepository,
) -> None:
create_test_resource(repo, resource_id="r_bob", user_id="bob")
everos = FakeEverOSClient()
async with app_client(config, everos) as client:
user_key = await create_user(client, "alice")
response = await client.patch(
"/memories/mem_1",
json={
"user_id": "alice",
"user_key": user_key,
"session_id": "resource:bob:r_bob",
"override_text": "nope",
},
)
assert response.status_code == 403, response.text
assert repo.get_active_overrides("alice") == []
@pytest.mark.asyncio
async def test_memory_delete_requires_owned_session(
config: GatewayConfig,
repo: MemoryRepository,
) -> None:
everos = FakeEverOSClient()
async with app_client(config, everos) as client:
user_key = await create_user(client, "u_123")
response = await client.request(
"DELETE",
"/memories/mem_1",
json={
"user_id": "u_123",
"user_key": user_key,
"session_id": "resource:u_123:r_missing",
"reason": "manual delete",
},
)
assert response.status_code == 403, response.text
assert repo.get_tombstones("u_123") == []
@pytest.mark.asyncio
async def test_list_resources_returns_only_not_deleted(
config: GatewayConfig,
@ -445,6 +606,7 @@ async def test_delete_memory_writes_tombstone(
config: GatewayConfig,
repo: MemoryRepository,
) -> None:
create_test_resource(repo, resource_id="r_1", user_id="u_123")
everos = FakeEverOSClient()
async with app_client(config, everos) as client:
user_key = await create_user(client)