feat(server): admin-gate GET/POST /configure
This commit is contained in:
+3
-3
@@ -13,7 +13,7 @@ from slowapi import _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from auth import ADMIN_API_KEY, AUTH_DISABLED, JWT_SECRET, verify_auth
|
||||
from auth import ADMIN_API_KEY, AUTH_DISABLED, JWT_SECRET, require_admin, verify_auth
|
||||
from errors import (
|
||||
UpstreamError,
|
||||
install_request_id_logging,
|
||||
@@ -301,7 +301,7 @@ async def log_requests(request: Request, call_next):
|
||||
|
||||
|
||||
@app.get("/configure", summary="Get current Mem0 configuration")
|
||||
def get_config(_auth=Depends(verify_auth)):
|
||||
def get_config(_admin=Depends(require_admin)):
|
||||
return _redact_config(get_current_config())
|
||||
|
||||
|
||||
@@ -311,7 +311,7 @@ def list_bundled_providers(_auth=Depends(verify_auth)):
|
||||
|
||||
|
||||
@app.post("/configure", summary="Configure Mem0")
|
||||
def set_config(config: Dict[str, Any], _auth=Depends(verify_auth)):
|
||||
def set_config(config: Dict[str, Any], _admin=Depends(require_admin)):
|
||||
"""Set memory configuration."""
|
||||
_validate_bundled_providers(config)
|
||||
update_config(config)
|
||||
|
||||
@@ -7,6 +7,10 @@ Strategy:
|
||||
- The FastAPI app is imported lazily after env vars are set so module-level
|
||||
constants in server.auth (JWT_SECRET, AUTH_DISABLED, ADMIN_API_KEY) bind
|
||||
to the test values, not whatever the host shell has.
|
||||
- SQLAlchemy's generic Uuid type decorator rejects string values on SQLite
|
||||
(it calls value.hex on bind); production uses Postgres which has native
|
||||
UUID handling that accepts strings transparently. Patch the bind processor
|
||||
to coerce str -> UUID for the test session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,6 +26,7 @@ from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
from sqlalchemy.sql import sqltypes
|
||||
|
||||
# Make server/ importable as a top-level package (matches uvicorn's CWD).
|
||||
SERVER_DIR = Path(__file__).resolve().parent.parent
|
||||
@@ -33,6 +38,28 @@ os.environ.setdefault("OPENAI_API_KEY", "test-key")
|
||||
os.environ.setdefault("POSTGRES_HOST", "localhost") # silence db url builder
|
||||
|
||||
|
||||
# JWT 'sub' is always a string per spec, so _resolve_user_from_jwt looks up
|
||||
# User by string PK. SQLite's generic Uuid type-decorator fails on strings;
|
||||
# Postgres' native UUID accepts them. Coerce at the bind layer once at import.
|
||||
_orig_uuid_bind_processor = sqltypes.Uuid.bind_processor
|
||||
|
||||
|
||||
def _patched_uuid_bind_processor(self, dialect):
|
||||
proc = _orig_uuid_bind_processor(self, dialect)
|
||||
if proc is None:
|
||||
return proc
|
||||
|
||||
def wrapper(value):
|
||||
if isinstance(value, str):
|
||||
value = uuid.UUID(value)
|
||||
return proc(value)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
sqltypes.Uuid.bind_processor = _patched_uuid_bind_processor
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_engine():
|
||||
"""Fresh in-memory SQLite engine per test (no cross-test pollution).
|
||||
@@ -86,14 +113,18 @@ def client(test_session_factory, mock_memory, monkeypatch):
|
||||
|
||||
# Neutralize the real Memory.from_config() call that initialize_state()
|
||||
# makes during main.py import — pgvector and the history sqlite path are
|
||||
# both unavailable in tests.
|
||||
# both unavailable in tests. update_config takes the same path on POST /configure.
|
||||
monkeypatch.setattr(server_state, "initialize_state", lambda *_args, **_kwargs: None)
|
||||
monkeypatch.setattr(server_state, "get_memory_instance", lambda: mock_memory)
|
||||
monkeypatch.setattr(server_state, "update_config", lambda updates: {"config": updates})
|
||||
|
||||
from main import app # noqa: E402
|
||||
|
||||
# Also patch the binding inside main.py since it imported get_memory_instance by name.
|
||||
# Also patch the bindings inside main.py since it imported by name.
|
||||
monkeypatch.setattr("main.get_memory_instance", lambda: mock_memory)
|
||||
monkeypatch.setattr("main.update_config", lambda updates: {"config": updates})
|
||||
# Async request-log persistence uses the real SessionLocal (Postgres) — noop it.
|
||||
monkeypatch.setattr("main._persist_request_log", lambda *_args, **_kwargs: None)
|
||||
|
||||
def _override_get_db():
|
||||
db = test_session_factory()
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Integration tests for admin-gated routes.
|
||||
|
||||
Covers, for each affected route, the regression matrix:
|
||||
- admin JWT -> success
|
||||
- ADMIN_API_KEY -> success (no DB user required)
|
||||
- AUTH_DISABLED -> success
|
||||
- member JWT -> 403
|
||||
- no auth -> 401
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
# --- GET /configure ---
|
||||
|
||||
|
||||
def test_get_configure_admin_jwt(client, auth_admin_header):
|
||||
response = client.get("/configure", headers=auth_admin_header)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_get_configure_admin_api_key(client, admin_api_key_env):
|
||||
response = client.get("/configure", headers=admin_api_key_env)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_get_configure_auth_disabled(client, auth_disabled_env):
|
||||
response = client.get("/configure")
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_get_configure_member_forbidden(client, auth_member_header):
|
||||
response = client.get("/configure", headers=auth_member_header)
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_get_configure_no_auth_unauthorized(client):
|
||||
response = client.get("/configure")
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
# --- POST /configure ---
|
||||
|
||||
|
||||
def _valid_config():
|
||||
return {
|
||||
"vector_store": {"provider": "pgvector", "config": {"host": "h"}},
|
||||
"llm": {"provider": "openai", "config": {"api_key": "x", "model": "m"}},
|
||||
"embedder": {"provider": "openai", "config": {"api_key": "x", "model": "e"}},
|
||||
}
|
||||
|
||||
|
||||
def test_post_configure_admin_jwt(client, auth_admin_header):
|
||||
response = client.post("/configure", headers=auth_admin_header, json=_valid_config())
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_post_configure_admin_api_key(client, admin_api_key_env):
|
||||
response = client.post("/configure", headers=admin_api_key_env, json=_valid_config())
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_post_configure_auth_disabled(client, auth_disabled_env):
|
||||
response = client.post("/configure", json=_valid_config())
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def test_post_configure_member_forbidden(client, auth_member_header):
|
||||
response = client.post("/configure", headers=auth_member_header, json=_valid_config())
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_post_configure_no_auth_unauthorized(client):
|
||||
response = client.post("/configure", json=_valid_config())
|
||||
assert response.status_code == 401
|
||||
Reference in New Issue
Block a user