From 6ba334ef948ad9b91e5813624a1a9e5a39076be6 Mon Sep 17 00:00:00 2001 From: Mgeeeek Date: Wed, 13 May 2026 21:01:30 +0530 Subject: [PATCH] feat(server): admin-gate GET/POST /configure --- server/main.py | 6 +-- server/tests/conftest.py | 35 ++++++++++++++- server/tests/test_admin_gating.py | 75 +++++++++++++++++++++++++++++++ 3 files changed, 111 insertions(+), 5 deletions(-) create mode 100644 server/tests/test_admin_gating.py diff --git a/server/main.py b/server/main.py index 07d300d00..f1b912beb 100644 --- a/server/main.py +++ b/server/main.py @@ -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) diff --git a/server/tests/conftest.py b/server/tests/conftest.py index 166317031..4c6794604 100644 --- a/server/tests/conftest.py +++ b/server/tests/conftest.py @@ -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() diff --git a/server/tests/test_admin_gating.py b/server/tests/test_admin_gating.py new file mode 100644 index 000000000..2df25d194 --- /dev/null +++ b/server/tests/test_admin_gating.py @@ -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