Files
microfish/backend/tests/test_settings_service.py
Kunthawat Greethong 8b84378fe1 feat: SaaS foundation for CrowdSight
Elevate MiroFish/CrowdSight from single-container dev to a SaaS foundation:

- Local memory backend (Zep-compatible): memory services/models, local graph
  builder + updater, AgentActivity seam, import-boundary isolation; Zep stays
  default, local is opt-in behind MEMORY_BACKEND. Semantic parity not yet proven.
- Durable product persistence: projects/simulations/reports schema (migration
  0007) + tenant/owner-scoped ProductRepository + dual-write + scoped_project
  read-first + ArtifactStore abstraction; durable JobQueue + worker.py.
- SaaS hardening: durable RateLimiter (wired to login), UsageService (LLM
  accounting), redacted AuditService, idempotency, CORS allowlist, safe API
  errors, single-use PasswordResetService + endpoints (covers invite-pending).
- Exactly 3 roles (super_admin/admin/user) with tenant authz policy.
- Admin UI: GET/POST/PATCH /api/admin/users + GET/PUT /api/admin/settings
  (super-admin only, encrypted/masked); AdminView.vue + SettingsView.vue with
  admin/super-admin route guards, th/en i18n.
- Production deploy topology: multi-stage Dockerfile (frontend build + gunicorn
  wsgi + nginx SPA-proxy + supervisord worker), backend/wsgi.py, gunicorn dep.

Backend 197 passed; frontend 10 tests + build green. ruff unavailable (gap).
No commit of credentials; secrets handled via env/.env.example.
Deferred: Zep semantic A/B parity, object storage cutover, mobile QA, EasyPanel
container build of deploy topology.
2026-08-31 13:05:21 +07:00

118 lines
4.0 KiB
Python

"""TDD gate: versioned, redacted platform LLM settings.
LLM settings are currently environment-only. This gate proves a durable,
versioned settings store that (a) keeps the API key encrypted (never plaintext
in the record or logs), (b) exposes only masked values to the API surface, and
(c) snapshots an effective settings version for reproducible jobs.
"""
import pytest
from sqlalchemy import create_engine
from app.config import Config
from app.db import Base, create_session_factory
from app.services.settings_service import SettingsService
@pytest.fixture()
def session_factory(monkeypatch):
monkeypatch.setattr(Config, "SECRET_KEY", "test-encryption-secret-key")
engine = create_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
factory = create_session_factory(engine)
try:
yield factory
finally:
engine.dispose()
def test_settings_service_saves_and_reads_masked(session_factory):
session = session_factory()
try:
svc = SettingsService(session)
version = svc.save_settings(
{"model": "gpt-4o", "base_url": "https://api.example.com/v1"},
api_key="sk-secret-123",
updated_by="user-1",
)
assert version
active = svc.active_settings()
assert active["settings"]["model"] == "gpt-4o"
# API key must be masked, never plaintext.
assert active["api_key"] != "sk-secret-123"
assert "sk-secret-123" not in repr(active)
# Version metadata present.
assert active["version"]
assert active["updated_by"] == "user-1"
finally:
session.close()
def test_settings_service_never_stores_plaintext_api_key(session_factory):
session = session_factory()
try:
svc = SettingsService(session)
svc.save_settings({"model": "m"}, api_key="sk-plain-abc", updated_by="user-1")
row = svc._latest_row()
secret_ref = getattr(row, "secret_ref", None) or ""
blob = repr(row.settings) + " " + str(secret_ref)
assert "sk-plain-abc" not in blob
finally:
session.close()
def test_settings_service_snapshots_effective_settings_for_job(session_factory):
session = session_factory()
try:
svc = SettingsService(session)
version = svc.save_settings(
{"model": "gpt-4o"}, api_key="sk-secret-9", updated_by="user-1"
)
snapshot = svc.snapshot_for_job()
assert snapshot["version"] == version
assert snapshot["settings"]["model"] == "gpt-4o"
# The job snapshot must NOT contain the plaintext api key.
assert "sk-secret-9" not in repr(snapshot)
# It carries a settings version, not the secret.
assert snapshot["settings_version"] == version
finally:
session.close()
def test_platform_settings_migration_round_trip(tmp_path, monkeypatch):
from pathlib import Path
from alembic import command
from alembic.config import Config as AlembicConfig
from sqlalchemy import inspect
from app.db import create_database_engine
database_url = f"sqlite+pysqlite:///{tmp_path / 'settings-roundtrip.db'}"
monkeypatch.setenv("DATABASE_URL", database_url)
alembic_config = AlembicConfig(str(Path(__file__).resolve().parents[1] / "alembic.ini"))
alembic_config.set_main_option("sqlalchemy.url", database_url)
command.upgrade(alembic_config, "head")
engine = create_database_engine(database_url)
try:
inspector = inspect(engine)
assert "platform_settings" in set(inspector.get_table_names())
cols = {c["name"] for c in inspector.get_columns("platform_settings")}
assert {"version", "settings", "secret_ref", "active"}.issubset(cols)
finally:
engine.dispose()
command.downgrade(alembic_config, "0007_product_resources")
engine = create_database_engine(database_url)
try:
assert "platform_settings" not in set(inspect(engine).get_table_names())
finally:
engine.dispose()
command.upgrade(alembic_config, "head")
command.check(alembic_config)