feat(server): admin-gate GET/POST /configure

This commit is contained in:
Mgeeeek
2026-05-13 21:01:30 +05:30
parent 8bd7b913c6
commit 6ba334ef94
3 changed files with 111 additions and 5 deletions
+3 -3
View File
@@ -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)
+33 -2
View File
@@ -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()
+75
View File
@@ -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