Files
memory-gateway/tests/test_gateway.py

859 lines
28 KiB
Python

from __future__ import annotations
import base64
import json
import logging
from pathlib import Path
from typing import Any
import httpx
import pytest
from core.api import create_app
from core.config import GatewayConfig
from core.db import init_db
from core.repository import MemoryRepository
import core.api as api_module
import core.service as service_module
class FakeEverOSClient:
def __init__(
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(
self,
session_id: str,
app_id: str,
project_id: str,
) -> dict[str, Any]:
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]:
self.search_calls.append(payload)
return {"request_id": "search", "data": {"episodes": self.search_results}}
async def health_check(self) -> dict[str, Any]:
if self.health_error is not None:
raise self.health_error
return {"status": "ok"}
@pytest.fixture
def config(tmp_path: Path) -> GatewayConfig:
return GatewayConfig(
everos_base_url="http://everos.test",
database_path=tmp_path / "gateway.sqlite3",
storage_dir=tmp_path / "storage",
)
@pytest.fixture
def repo(config: GatewayConfig) -> MemoryRepository:
init_db(config.database_path)
return MemoryRepository(config.database_path)
def app_client(
config: GatewayConfig,
everos_client: FakeEverOSClient,
) -> httpx.AsyncClient:
app = create_app(config=config, everos_client=everos_client)
transport = httpx.ASGITransport(app=app)
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
body = response.json()
assert body["user_id"] == user_id
assert body["user_key"]
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
def test_create_app_uses_project_name(config: GatewayConfig) -> None:
app = create_app(config=config, everos_client=FakeEverOSClient())
assert app.title == "memory-gateway"
def test_api_log_body_capture_policy_skips_large_and_multipart_requests() -> None:
assert api_module._should_capture_request_body("application/json", 100)
assert not api_module._should_capture_request_body(
"application/json",
api_module.MAX_LOG_BODY_BYTES + 1,
)
assert not api_module._should_capture_request_body(
"multipart/form-data; boundary=test",
100,
)
@pytest.mark.asyncio
async def test_api_logs_request_time_address_input_and_output(
config: GatewayConfig,
caplog: pytest.LogCaptureFixture,
) -> None:
caplog.set_level(logging.INFO, logger="memory_gateway.api")
everos = FakeEverOSClient()
async with app_client(config, everos) as client:
user_key = await create_user(client, "u_log")
response = await client.get(
"/resources",
params={"user_id": "u_log", "user_key": user_key},
)
assert response.status_code == 200, response.text
events = [json.loads(record.message) for record in caplog.records]
create_user_event = next(item for item in events if item["path"] == "/users")
assert create_user_event["output"]["body"]["user_key"] == "[REDACTED]"
event = next(item for item in events if item["path"] == "/resources")
assert event["request_time"]
assert event["method"] == "GET"
assert event["url"] == "http://test/resources?user_id=u_log&user_key=[REDACTED]"
assert event["client"] == "127.0.0.1"
assert event["duration_ms"] >= 0
assert event["input"]["query_params"] == {
"user_id": "u_log",
"user_key": "[REDACTED]",
}
assert event["output"]["status_code"] == 200
assert event["output"]["body"] == {"resources": []}
@pytest.mark.asyncio
async def test_api_logs_do_not_expose_secrets_from_large_json_bodies(
config: GatewayConfig,
caplog: pytest.LogCaptureFixture,
) -> None:
caplog.set_level(logging.INFO, logger="memory_gateway.api")
everos = FakeEverOSClient()
async with app_client(config, everos) as client:
user_key = await create_user(client, "u_large_log")
caplog.clear()
response = await client.post(
"/memories/add",
json={
"user_id": "u_large_log",
"user_key": user_key,
"session_id": "chat:c_large_log",
"messages": [
{
"sender_id": "u_large_log",
"role": "user",
"timestamp": 1234567890123,
"content": "x" * 5000,
}
],
},
)
assert response.status_code == 200, response.text
event = next(
json.loads(record.message)
for record in caplog.records
if json.loads(record.message)["path"] == "/memories/add"
)
assert event["input"]["body"]["truncated"] is True
assert user_key not in json.dumps(event, ensure_ascii=False)
@pytest.mark.asyncio
async def test_health_reports_api_and_everos_ok(
config: GatewayConfig,
) -> None:
everos = FakeEverOSClient()
async with app_client(config, everos) as client:
response = await client.get("/health")
assert response.status_code == 200, response.text
assert response.json() == {
"status": "ok",
"api": {"status": "ok"},
"everos": {
"status": "ok",
"base_url": "http://everos.test",
"data": {"status": "ok"},
},
}
@pytest.mark.asyncio
async def test_health_reports_degraded_when_everos_fails(
config: GatewayConfig,
) -> None:
everos = FakeEverOSClient(health_error=RuntimeError("everos down"))
async with app_client(config, everos) as client:
response = await client.get("/health")
assert response.status_code == 200, response.text
body = response.json()
assert body["status"] == "degraded"
assert body["api"] == {"status": "ok"}
assert body["everos"]["status"] == "unavailable"
assert body["everos"]["base_url"] == "http://everos.test"
assert body["everos"]["error"] == "everos down"
@pytest.mark.asyncio
async def test_create_user_generates_and_persists_user_key(
config: GatewayConfig,
repo: MemoryRepository,
) -> None:
everos = FakeEverOSClient()
async with app_client(config, everos) as client:
user_key = await create_user(client, "u_123")
user = repo.get_user("u_123")
assert user is not None
assert user["user_key"] == user_key
@pytest.mark.asyncio
async def test_upload_resource_creates_record_and_calls_everos(
config: GatewayConfig,
) -> 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, "title": "Contract"},
files={"file": ("contract.txt", b"pay in 30 days", "text/plain")},
)
assert response.status_code == 200, response.text
body = response.json()
resource_id = body["resource_id"]
assert body["session_id"] == f"resource:u_123:{resource_id}"
assert body["uri"] == f"resource://u_123/{resource_id}"
assert body["status"] == "extracted"
repo = MemoryRepository(config.database_path)
resource = repo.get_resource(resource_id)
assert resource is not None
assert resource["status"] == "extracted"
assert resource["original_filename"] == "contract.txt"
assert resource["content_type"] == "text"
assert resource["sha256"]
assert resource["size_bytes"] == len(b"pay in 30 days")
assert not resource["uri"].startswith("resource://")
assert len(everos.add_calls) == 1
add_payload = everos.add_calls[0]
assert add_payload["session_id"] == f"resource:u_123:{resource_id}"
content = add_payload["messages"][0]["content"][0]
assert content["type"] == "text"
assert content["text"] == "pay in 30 days"
assert "uri" not in content
assert content["extras"] == {"resource_id": resource_id, "source": "user_upload"}
assert everos.flush_calls == [
{
"session_id": f"resource:u_123:{resource_id}",
"app_id": "default",
"project_id": "default",
}
]
@pytest.mark.asyncio
async def test_upload_binary_resource_sends_base64_content_to_everos(
config: GatewayConfig,
) -> 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": ("paper.pdf", b"%PDF bytes", "application/pdf")},
)
assert response.status_code == 200, response.text
content = everos.add_calls[0]["messages"][0]["content"][0]
assert content["type"] == "pdf"
assert content["base64"] == base64.b64encode(b"%PDF bytes").decode("ascii")
assert content["ext"] == "pdf"
assert content["name"] == "paper.pdf"
assert "uri" not in content
@pytest.mark.asyncio
async def test_upload_resource_uses_current_timestamp(
config: GatewayConfig,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(
service_module,
"current_timestamp_ms",
lambda: 1234567890123,
raising=False,
)
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": ("timed.txt", b"time me", "text/plain")},
)
assert response.status_code == 200, response.text
assert everos.add_calls[0]["messages"][0]["timestamp"] == 1234567890123
@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,
) -> None:
everos = FakeEverOSClient()
async with app_client(config, everos) as client:
user_key = await create_user(client)
created = await client.post(
"/resources",
data={"user_id": "u_123", "user_key": user_key},
files={"file": ("note.txt", b"hello", "text/plain")},
)
resource_id = created.json()["resource_id"]
detail = await client.get(
f"/resources/{resource_id}",
params={"user_id": "u_123", "user_key": user_key},
)
assert detail.status_code == 200, detail.text
resources = detail.json()["resources"]
assert len(resources) == 1
assert resources[0]["uri"] == f"resource://u_123/{resource_id}"
assert "file://" not in detail.text
@pytest.mark.asyncio
async def test_resource_detail_returns_empty_when_user_has_no_resource(
config: GatewayConfig,
) -> None:
everos = FakeEverOSClient()
async with app_client(config, everos) as client:
user_key = await create_user(client, "u_empty")
response = await client.get(
"/resources/r_missing",
params={"user_id": "u_empty", "user_key": user_key},
)
assert response.status_code == 200, response.text
assert response.json() == {"resources": []}
@pytest.mark.asyncio
async def test_resources_are_isolated_by_user_key(
config: GatewayConfig,
) -> None:
everos = FakeEverOSClient()
async with app_client(config, everos) as client:
alice_key = await create_user(client, "alice")
bob_key = await create_user(client, "bob")
created = await client.post(
"/resources",
data={"user_id": "alice", "user_key": alice_key},
files={"file": ("alice.txt", b"alice-only", "text/plain")},
)
resource_id = created.json()["resource_id"]
bob_detail = await client.get(
f"/resources/{resource_id}",
params={"user_id": "bob", "user_key": bob_key},
)
bob_list = await client.get(
"/resources",
params={"user_id": "bob", "user_key": bob_key},
)
assert bob_detail.status_code == 200, bob_detail.text
assert bob_detail.json() == {"resources": []}
assert bob_list.status_code == 200, bob_list.text
assert bob_list.json() == {"resources": []}
@pytest.mark.asyncio
async def test_resource_api_rejects_invalid_user_key(
config: GatewayConfig,
) -> None:
everos = FakeEverOSClient()
async with app_client(config, everos) as client:
await create_user(client, "u_123")
response = await client.get(
"/resources",
params={"user_id": "u_123", "user_key": "wrong"},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_add_memory_forwards_multimodal_payload_to_everos(
config: GatewayConfig,
) -> None:
everos = FakeEverOSClient()
audio = base64.b64encode(b"wav bytes").decode("ascii")
content = [
{"type": "text", "text": "remember the picture and audio"},
{"type": "audio", "base64": audio, "ext": "wav", "name": "tone.wav"},
{
"type": "image",
"uri": "file:///home/tom/memory-gateway/tests/simple-multimodal-image.png",
"ext": "png",
"name": "simple-multimodal-image.png",
},
]
async with app_client(config, everos) as client:
user_key = await create_user(client)
response = await client.post(
"/memories/add",
json={
"user_id": "u_123",
"user_key": user_key,
"session_id": "chat:c_multimodal",
"app_id": "default",
"project_id": "default",
"messages": [
{
"sender_id": "u_123",
"role": "user",
"timestamp": 1234567890123,
"content": content,
}
],
},
)
assert response.status_code == 200, response.text
assert response.json() == {
"session_id": "chat:c_multimodal",
"everos": {"request_id": "add", "data": {"status": "accumulated"}},
}
assert everos.add_calls == [
{
"session_id": "chat:c_multimodal",
"app_id": "default",
"project_id": "default",
"messages": [
{
"sender_id": "u_123",
"role": "user",
"timestamp": 1234567890123,
"content": content,
}
],
}
]
@pytest.mark.asyncio
async def test_flush_memory_forwards_request_to_everos(
config: GatewayConfig,
) -> None:
everos = FakeEverOSClient()
async with app_client(config, everos) as client:
user_key = await create_user(client)
response = await client.post(
"/memories/flush",
json={
"user_id": "u_123",
"user_key": user_key,
"session_id": "chat:c_multimodal",
"app_id": "default",
"project_id": "default",
},
)
assert response.status_code == 200, response.text
assert response.json() == {
"session_id": "chat:c_multimodal",
"everos": {"request_id": "flush", "data": {"status": "extracted"}},
}
assert everos.flush_calls == [
{
"session_id": "chat:c_multimodal",
"app_id": "default",
"project_id": "default",
}
]
@pytest.mark.asyncio
async def test_deleted_resource_is_excluded_from_resource_scope_search(
config: GatewayConfig,
) -> None:
everos = FakeEverOSClient(
[{"id": "mem_1", "session_id": "resource:u_123:r_live", "episode": "live"}]
)
async with app_client(config, everos) as client:
user_key = await create_user(client)
created = await client.post(
"/resources",
data={"user_id": "u_123", "user_key": user_key},
files={"file": ("note.txt", b"hello", "text/plain")},
)
resource_id = created.json()["resource_id"]
delete_response = await client.delete(
f"/resources/{resource_id}",
params={"user_id": "u_123", "user_key": user_key},
)
search_response = await client.post(
"/memories/search",
json={
"user_id": "u_123",
"user_key": user_key,
"query": "hello",
"scope": ["resources"],
"top_k": 8,
},
)
assert delete_response.status_code == 200
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
async def test_tombstone_filters_search_results(
config: GatewayConfig,
repo: MemoryRepository,
) -> None:
create_test_resource(repo, resource_id="r_1", user_id="u_123")
repo.add_tombstone(
user_id="u_123",
memory_id="mem_deleted",
session_id=None,
reason="user deleted",
)
everos = FakeEverOSClient(
[
{"id": "mem_deleted", "session_id": "resource:u_123:r_1", "episode": "x"},
{"id": "mem_live", "session_id": "resource:u_123:r_1", "episode": "y"},
]
)
async with app_client(config, everos) as client:
user_key = await create_user(client)
response = await client.post(
"/memories/search",
json={
"user_id": "u_123",
"user_key": user_key,
"query": "hello",
"scope": ["resources"],
},
)
assert response.status_code == 200, response.text
results = response.json()["results"]
assert [result["id"] for result in results] == ["mem_live"]
@pytest.mark.asyncio
async def test_override_replaces_search_result_text(
config: GatewayConfig,
repo: MemoryRepository,
) -> 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"}]
)
async with app_client(config, everos) as client:
user_key = await create_user(client)
patch_response = await client.patch(
"/memories/mem_1",
json={
"user_id": "u_123",
"user_key": user_key,
"session_id": "resource:u_123:r_1",
"override_text": "corrected text",
},
)
search_response = await client.post(
"/memories/search",
json={
"user_id": "u_123",
"user_key": user_key,
"query": "hello",
"scope": ["resources"],
},
)
assert patch_response.status_code == 200, patch_response.text
result = search_response.json()["results"][0]
assert result["id"] == "mem_1"
assert result["text"] == "corrected 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,
) -> 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": ("a.txt", b"a", "text/plain")},
)
await client.post(
"/resources",
data={"user_id": "u_123", "user_key": user_key},
files={"file": ("b.txt", b"b", "text/plain")},
)
await client.delete(
f"/resources/{first.json()['resource_id']}",
params={"user_id": "u_123", "user_key": user_key},
)
response = await client.get(
"/resources",
params={"user_id": "u_123", "user_key": user_key},
)
assert response.status_code == 200
items = response.json()["resources"]
assert len(items) == 1
assert items[0]["filename"] == "b.txt"
assert items[0]["uri"].startswith("resource://u_123/")
@pytest.mark.asyncio
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)
response = await client.request(
"DELETE",
"/memories/mem_1",
json={
"user_id": "u_123",
"user_key": user_key,
"session_id": "resource:u_123:r_1",
"reason": "manual delete",
},
)
assert response.status_code == 200, response.text
tombstones = repo.get_tombstones("u_123")
assert len(tombstones) == 1
assert tombstones[0]["memory_id"] == "mem_1"
assert tombstones[0]["session_id"] == "resource:u_123:r_1"