Files
arc53--docsgpt/tests/core/test_registry_user_layer.py
wehub-resource-sync fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

506 lines
19 KiB
Python

"""Tests for the BYOM per-user layer on ModelRegistry.
Covers: per-user lookups don't leak across users, lookups without
user_id stay built-in only, get_all_models / get_enabled_models /
model_exists all consult the user layer when given user_id, and the
explicit invalidate_user clears the cache.
"""
from __future__ import annotations
from contextlib import contextmanager
from unittest.mock import MagicMock, patch
import pytest
from application.core.model_registry import ModelRegistry
from application.core.model_settings import ModelProvider
from application.storage.db.repositories.user_custom_models import (
UserCustomModelsRepository,
)
@pytest.fixture(autouse=True)
def _reset_registry():
ModelRegistry.reset()
yield
ModelRegistry.reset()
def _make_settings(**overrides):
s = MagicMock()
s.OPENAI_BASE_URL = None
s.OPENAI_API_KEY = None
s.OPENAI_API_BASE = None
s.ANTHROPIC_API_KEY = None
s.GOOGLE_API_KEY = None
s.GROQ_API_KEY = None
s.OPEN_ROUTER_API_KEY = None
s.NOVITA_API_KEY = None
s.HUGGINGFACE_API_KEY = None
s.LLM_PROVIDER = ""
s.LLM_NAME = None
s.API_KEY = None
s.MODELS_CONFIG_DIR = None
for k, v in overrides.items():
setattr(s, k, v)
return s
@contextmanager
def _yield(conn):
yield conn
@pytest.mark.unit
class TestPerUserLayer:
def test_user_models_isolated_per_user(self, pg_conn):
"""Alice's BYOM model must not appear in Bob's lookups."""
repo = UserCustomModelsRepository(pg_conn)
alice_model = repo.create(
user_id="alice",
upstream_model_id="alice-mistral",
display_name="Alice Mistral",
base_url="https://api.mistral.ai/v1",
api_key_plaintext="sk-alice",
)
s = _make_settings()
with patch("application.core.settings.settings", s), patch(
"application.storage.db.session.db_readonly",
lambda: _yield(pg_conn),
):
reg = ModelRegistry()
assert reg.get_model(alice_model["id"], user_id="alice") is not None
assert reg.get_model(alice_model["id"], user_id="bob") is None
# And without a user_id at all, the per-user layer is invisible
assert reg.get_model(alice_model["id"]) is None
def test_get_all_models_includes_user_models(self, pg_conn):
repo = UserCustomModelsRepository(pg_conn)
created = repo.create(
user_id="user-1",
upstream_model_id="mistral-large-latest",
display_name="My Mistral",
base_url="https://api.mistral.ai/v1",
api_key_plaintext="sk-test",
)
s = _make_settings()
with patch("application.core.settings.settings", s), patch(
"application.storage.db.session.db_readonly",
lambda: _yield(pg_conn),
):
reg = ModelRegistry()
ids_no_user = {m.id for m in reg.get_all_models()}
ids_with_user = {
m.id for m in reg.get_all_models(user_id="user-1")
}
assert created["id"] not in ids_no_user
assert created["id"] in ids_with_user
def test_user_models_carry_decrypted_api_key_and_upstream_id(self, pg_conn):
repo = UserCustomModelsRepository(pg_conn)
created = repo.create(
user_id="user-1",
upstream_model_id="mistral-large-latest",
display_name="My Mistral",
base_url="https://api.mistral.ai/v1",
api_key_plaintext="sk-test-XYZ",
)
s = _make_settings()
with patch("application.core.settings.settings", s), patch(
"application.storage.db.session.db_readonly",
lambda: _yield(pg_conn),
):
reg = ModelRegistry()
m = reg.get_model(created["id"], user_id="user-1")
assert m is not None
assert m.provider == ModelProvider.OPENAI_COMPATIBLE
assert m.upstream_model_id == "mistral-large-latest"
assert m.api_key == "sk-test-XYZ"
assert m.base_url == "https://api.mistral.ai/v1"
assert m.source == "user"
# The wire format never leaks the api_key
d = m.to_dict()
assert "api_key" not in d
for v in d.values():
assert v != "sk-test-XYZ"
def test_invalidate_user_clears_cache(self, pg_conn):
repo = UserCustomModelsRepository(pg_conn)
created = repo.create(
user_id="user-1",
upstream_model_id="x",
display_name="X",
base_url="https://api.mistral.ai/v1",
api_key_plaintext="k",
)
s = _make_settings()
# Stub Redis so invalidate_user can publish its version bump
# without hitting a real broker. The P1 fix calls ``incr`` on
# invalidate; here we just need it not to raise.
fake_redis = MagicMock()
with patch("application.core.settings.settings", s), patch(
"application.storage.db.session.db_readonly",
lambda: _yield(pg_conn),
), patch(
"application.cache.get_redis_instance", return_value=fake_redis
):
reg = ModelRegistry()
assert reg.get_model(created["id"], user_id="user-1") is not None
# Cache populated
assert "user-1" in reg._user_models
ModelRegistry.invalidate_user("user-1")
assert "user-1" not in reg._user_models
# Re-lookup repopulates
reg.get_model(created["id"], user_id="user-1")
assert "user-1" in reg._user_models
@pytest.mark.unit
class TestLLMCreatorDispatchUsesUpstreamModelId:
def test_llmcreator_sends_upstream_id_not_uuid(self, pg_conn):
"""End-to-end: LLMCreator with a BYOM uuid must construct the
OpenAILLM with the user's upstream model name (e.g.
``mistral-large-latest``), not the registry uuid."""
repo = UserCustomModelsRepository(pg_conn)
created = repo.create(
user_id="user-1",
upstream_model_id="mistral-large-latest",
display_name="My Mistral",
base_url="https://api.mistral.ai/v1",
api_key_plaintext="sk-mistral-real",
)
captured = {}
class _FakeLLM:
def __init__(self, api_key, user_api_key, *args, **kwargs):
captured["api_key"] = api_key
captured["base_url"] = kwargs.get("base_url")
captured["model_id"] = kwargs.get("model_id")
s = _make_settings()
with patch("application.core.settings.settings", s), patch(
"application.storage.db.session.db_readonly",
lambda: _yield(pg_conn),
):
ModelRegistry()
from application.llm.providers import PROVIDERS_BY_NAME
with patch.object(
PROVIDERS_BY_NAME["openai_compatible"], "llm_class", _FakeLLM
):
from application.llm.llm_creator import LLMCreator
LLMCreator.create_llm(
type="openai_compatible",
api_key="caller-passed-WRONG",
user_api_key=None,
decoded_token={"sub": "user-1"},
model_id=created["id"],
)
assert captured["api_key"] == "sk-mistral-real"
assert captured["base_url"] == "https://api.mistral.ai/v1"
assert captured["model_id"] == "mistral-large-latest" # NOT the uuid!
def test_llmcreator_forwards_byom_capabilities(self, pg_conn):
"""LLMCreator must thread the registry-resolved ``capabilities``
into the LLM. Without it the OpenAILLM hard-codes ``True`` for
tools/structured output and advertises image attachments
unconditionally, leaking unsupported features to BYOMs that
disabled them."""
repo = UserCustomModelsRepository(pg_conn)
created = repo.create(
user_id="user-2",
upstream_model_id="my-text-only-model",
display_name="Text-only BYOM",
base_url="https://api.mistral.ai/v1",
api_key_plaintext="sk-real",
capabilities={
"supports_tools": False,
"supports_structured_output": False,
"attachments": [],
"context_window": 8192,
},
)
captured = {}
class _FakeLLM:
def __init__(self, api_key, user_api_key, *args, **kwargs):
captured["capabilities"] = kwargs.get("capabilities")
s = _make_settings()
with patch("application.core.settings.settings", s), patch(
"application.storage.db.session.db_readonly",
lambda: _yield(pg_conn),
):
ModelRegistry()
from application.llm.providers import PROVIDERS_BY_NAME
with patch.object(
PROVIDERS_BY_NAME["openai_compatible"], "llm_class", _FakeLLM
):
from application.llm.llm_creator import LLMCreator
LLMCreator.create_llm(
type="openai_compatible",
api_key="ignored",
user_api_key=None,
decoded_token={"sub": "user-2"},
model_id=created["id"],
)
caps = captured["capabilities"]
assert caps is not None
assert caps.supports_tools is False
assert caps.supports_structured_output is False
assert caps.supported_attachment_types == []
def test_byom_image_alias_expands_to_mime_types(self, pg_conn):
"""A BYOM stored with ``attachments: ["image"]`` (the alias the
UI sends) must surface as concrete MIME types on the registry
record, matching the built-in YAML expansion. Without this,
handlers/base.prepare_messages compares ``image/png`` against
the bare alias and filters every image upload as unsupported.
"""
repo = UserCustomModelsRepository(pg_conn)
created = repo.create(
user_id="user-1",
upstream_model_id="my-vision-model",
display_name="Vision BYOM",
base_url="https://api.mistral.ai/v1",
api_key_plaintext="sk-real",
capabilities={"attachments": ["image"]},
)
s = _make_settings()
with patch("application.core.settings.settings", s), patch(
"application.storage.db.session.db_readonly",
lambda: _yield(pg_conn),
):
reg = ModelRegistry()
model = reg.get_model(created["id"], user_id="user-1")
assert model is not None
types = model.capabilities.supported_attachment_types
assert "image" not in types, (
"alias must be expanded, not stored verbatim"
)
assert any(t.startswith("image/") for t in types)
# Must include at least the common web image types so any image
# an end user uploads has a chance to match.
assert "image/png" in types
assert "image/jpeg" in types
def test_byom_unknown_alias_is_skipped_at_runtime(self, pg_conn):
"""Operator alias-map edits could orphan a stored alias. The
registry must drop the unknown entry rather than the entire
layer (which would hide every BYOM the user has).
"""
repo = UserCustomModelsRepository(pg_conn)
created = repo.create(
user_id="user-1",
upstream_model_id="m",
display_name="M",
base_url="https://api.mistral.ai/v1",
api_key_plaintext="k",
# Bypass the route validation: write a bad alias straight
# to the row to simulate the post-edit orphan case.
capabilities={"attachments": ["image", "not-a-real-alias"]},
)
s = _make_settings()
with patch("application.core.settings.settings", s), patch(
"application.storage.db.session.db_readonly",
lambda: _yield(pg_conn),
):
reg = ModelRegistry()
model = reg.get_model(created["id"], user_id="user-1")
assert model is not None
types = model.capabilities.supported_attachment_types
assert "not-a-real-alias" not in types
assert any(t.startswith("image/") for t in types)
@pytest.mark.unit
class TestCrossProcessInvalidation:
"""The BYOM cache lives per-process. Without the P1 fix, a CRUD on
web-1 would leave the decrypted record (with old api_key/base_url)
cached forever in web-2 / Celery. These tests pin down that:
* ``invalidate_user`` publishes a version bump to Redis
* peers reload when the version they saw at load time is stale
* the local TTL bounds staleness even when Redis is unreachable
* unchanged version + expired TTL extends the entry without a
DB read (the common-case fast path)
"""
def test_invalidate_user_publishes_redis_version_bump(self, pg_conn):
repo = UserCustomModelsRepository(pg_conn)
repo.create(
user_id="user-1",
upstream_model_id="m1",
display_name="M1",
base_url="https://api.mistral.ai/v1",
api_key_plaintext="k",
)
fake_redis = MagicMock()
s = _make_settings()
with patch("application.core.settings.settings", s), patch(
"application.storage.db.session.db_readonly",
lambda: _yield(pg_conn),
), patch(
"application.cache.get_redis_instance", return_value=fake_redis
):
ModelRegistry().get_model("anything", user_id="user-1")
ModelRegistry.invalidate_user("user-1")
fake_redis.incr.assert_called_once_with("byom:registry_version:user-1")
def test_peer_reloads_when_redis_version_changes(self, pg_conn):
"""Two-process simulation. Peer loads at version=0; another
process's CRUD bumps the version and updates Postgres; peer's
next post-TTL access sees the version mismatch and reloads,
picking up the rotated key it never invalidated locally."""
from application.core import model_registry as registry_mod
repo = UserCustomModelsRepository(pg_conn)
created = repo.create(
user_id="user-1",
upstream_model_id="m-orig",
display_name="orig",
base_url="https://api.mistral.ai/v1",
api_key_plaintext="orig-key",
)
state = {"version": 0}
class _FakeRedis:
def get(self, key):
if key == "byom:registry_version:user-1":
return str(state["version"]).encode()
return None
def incr(self, key):
if key == "byom:registry_version:user-1":
state["version"] += 1
s = _make_settings()
# Force TTL to 0 so any subsequent access takes the post-TTL
# path without waiting 60s.
with patch("application.core.settings.settings", s), patch(
"application.storage.db.session.db_readonly",
lambda: _yield(pg_conn),
), patch(
"application.cache.get_redis_instance", return_value=_FakeRedis()
), patch.object(
registry_mod, "_USER_CACHE_TTL_SECONDS", 0.0
):
reg = ModelRegistry()
assert (
reg.get_model(created["id"], user_id="user-1").api_key
== "orig-key"
)
# Another process's CRUD: bump Redis counter + mutate the
# row. Note we deliberately do NOT call ``invalidate_user``
# in this process — that's the whole point of the test.
state["version"] += 1
repo.update(
created["id"],
"user-1",
{"api_key_plaintext": "rotated-key"},
)
assert (
reg.get_model(created["id"], user_id="user-1").api_key
== "rotated-key"
)
def test_ttl_bounds_staleness_when_redis_unavailable(self, pg_conn):
"""Redis down → fall back to TTL-only invalidation. After the
TTL elapses, peers reload regardless."""
from application.core import model_registry as registry_mod
repo = UserCustomModelsRepository(pg_conn)
created = repo.create(
user_id="user-1",
upstream_model_id="m",
display_name="m",
base_url="https://api.mistral.ai/v1",
api_key_plaintext="orig",
)
s = _make_settings()
with patch("application.core.settings.settings", s), patch(
"application.storage.db.session.db_readonly",
lambda: _yield(pg_conn),
), patch(
"application.cache.get_redis_instance", return_value=None
), patch.object(
registry_mod, "_USER_CACHE_TTL_SECONDS", 0.0
):
reg = ModelRegistry()
first = reg.get_model(created["id"], user_id="user-1")
assert first.api_key == "orig"
repo.update(
created["id"],
"user-1",
{"api_key_plaintext": "rotated"},
)
second = reg.get_model(created["id"], user_id="user-1")
assert second.api_key == "rotated"
def test_unchanged_version_extends_ttl_without_db_read(self, pg_conn):
"""Hot path: TTL expires but Redis says no invalidation
happened — extend the entry without re-reading Postgres."""
from application.core import model_registry as registry_mod
repo = UserCustomModelsRepository(pg_conn)
created = repo.create(
user_id="user-1",
upstream_model_id="m",
display_name="m",
base_url="https://api.mistral.ai/v1",
api_key_plaintext="k",
)
fake_redis = MagicMock()
fake_redis.get.return_value = b"7" # constant version
db_open_count = {"n": 0}
@contextmanager
def _counting_db_readonly():
db_open_count["n"] += 1
yield pg_conn
s = _make_settings()
with patch("application.core.settings.settings", s), patch(
"application.storage.db.session.db_readonly",
_counting_db_readonly,
), patch(
"application.cache.get_redis_instance", return_value=fake_redis
), patch.object(
registry_mod, "_USER_CACHE_TTL_SECONDS", 0.0
):
reg = ModelRegistry()
reg.get_model(created["id"], user_id="user-1")
first_open = db_open_count["n"]
# TTL has expired, but Redis returns the same version we
# captured at load time → no DB reload.
reg.get_model(created["id"], user_id="user-1")
reg.get_model(created["id"], user_id="user-1")
assert db_open_count["n"] == first_open