vignette/apps/api/app/test_runtime_policy.py
Yun Chan 085460b5e0 대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·misconduct 반응 + 게이트웨이 격리·RAG 비차단 수정
SSOT 대시보드:
- 한신대 기술분석 PDF(19쪽) 정합성 분석 + 이번 세션 발견 섹션 추가
- 섹션 폴드아웃(접기)·상단 목차(드릴다운)·모두 펼치기/접기 — 내용 보존, 레이아웃만 정리

페르소나 반응 강화('저항·반응 조절' 핵심 차별):
- PersonaCard.triggers(역린) 필드 + CCD 핵심상처 파생 역린 블록
- L0에 무례·모욕·조롱 시 현실적 동맹 균열 반응 지침

버그·성능 수정(라이브/E2E로 포착):
- 게이트웨이 페르소나 격리: --append-system-prompt를 --system-prompt(교체)로 + --exclude-dynamic-system-prompt-sections (내담자 캐릭터 붕괴·개발맥락 누출 차단)
- RAG: 임베더 동기 로드(약 7-13초)를 _warm_rag_caches 백그라운드 warm으로(세션 생성 블로킹 회귀 수정)
- voice TTS RMS 데드힌트 제거, init_state OpennessParams 파라미터객체화
- 한국어 PII(날짜·금액·주소) 마스킹 보강
- 레이아웃 시각 게이트: 폼 컨트롤 값 스크롤 오탐 제외(7/7)

검증: 백엔드 84/84, E2E 42(데스크톱 27·모바일 11·아바타 4), 시각 게이트 7/7
2026-06-27 02:30:46 +09:00

305 lines
12 KiB
Python

"""Regression tests for DB outage runtime fallback policy."""
from __future__ import annotations
import unittest
from contextlib import contextmanager
from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from . import auth_sessions, session_persistence
from .config import Settings, settings
from .deps import Principal, Role
from .routes import admin as admin_routes
from .routes import eval as eval_routes
from .routes import kb as kb_routes
from .routes import users as users_routes
@contextmanager
def environment(value: str):
previous = settings.environment
settings.environment = value
try:
yield
finally:
settings.environment = previous
class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
auth_sessions._sessions.clear()
auth_sessions._users.clear()
auth_sessions._email_index.clear()
auth_sessions._inactive_emails.clear()
users_routes._preferences.clear()
admin_routes._ENGINE_CONFIG = None
async def asyncTearDown(self) -> None:
admin_routes._ENGINE_CONFIG = None
async def test_dev_allows_auth_registry_fallback_when_db_pool_missing(self) -> None:
with environment("dev"):
users, durable = await auth_sessions.list_managed_users()
self.assertFalse(durable)
self.assertEqual(users, [])
async def test_staging_blocks_auth_registry_fallback_when_db_pool_missing(self) -> None:
with environment("staging"):
with self.assertRaises(HTTPException) as caught:
await auth_sessions.list_managed_users()
self.assertEqual(caught.exception.status_code, 503)
self.assertIn("runtime fallback is disabled in staging", caught.exception.detail)
async def test_prod_blocks_browser_session_creation_when_db_pool_missing(self) -> None:
with environment("prod"):
with self.assertRaises(HTTPException) as caught:
await auth_sessions.create_session(
email="learner@hs.ac.kr",
display_name="Learner",
role="learner",
)
self.assertEqual(caught.exception.status_code, 503)
self.assertIn("runtime fallback is disabled in prod", caught.exception.detail)
async def test_staging_blocks_session_store_fallback_when_db_pool_missing(self) -> None:
principal = Principal(
user_id="00000000-0000-0000-0000-000000000001",
role=Role.LEARNER,
cohort_ids=[],
email="learner@hs.ac.kr",
display_name="Learner",
)
with environment("staging"):
with self.assertRaises(HTTPException) as caught:
await session_persistence.list_sessions(principal)
self.assertEqual(caught.exception.status_code, 503)
self.assertIn("runtime fallback is disabled in staging", caught.exception.detail)
async def test_staging_blocks_eval_store_cache_fallback_when_db_pool_missing(self) -> None:
principal = Principal(
user_id="00000000-0000-0000-0000-000000000002",
role=Role.TEACHER,
cohort_ids=[],
email="teacher@hs.ac.kr",
display_name="Teacher",
)
with environment("staging"):
with self.assertRaises(HTTPException) as caught:
await eval_routes.get_session_evaluation("missing-session-id", principal)
self.assertEqual(caught.exception.status_code, 503)
self.assertIn("runtime fallback is disabled in staging", caught.exception.detail)
async def test_staging_kb_search_fails_closed_when_db_pool_missing(self) -> None:
with environment("staging"):
with self.assertRaises(HTTPException) as caught:
await kb_routes.search(kb_routes.KBSearchRequest(query="rapport"))
self.assertEqual(caught.exception.status_code, 503)
self.assertIn("DB not ready", caught.exception.detail)
async def test_staging_blocks_user_preferences_fallback_when_db_pool_missing(self) -> None:
principal = Principal(
user_id="00000000-0000-0000-0000-000000000003",
role=Role.LEARNER,
cohort_ids=[],
email="learner@hs.ac.kr",
display_name="Learner",
)
with environment("staging"):
with self.assertRaises(HTTPException) as caught:
await users_routes.get_preferences(principal)
self.assertEqual(caught.exception.status_code, 503)
self.assertIn("runtime fallback is disabled in staging", caught.exception.detail)
async def test_dev_allows_user_preferences_fallback_when_db_pool_missing(self) -> None:
principal = Principal(
user_id="00000000-0000-0000-0000-000000000004",
role=Role.LEARNER,
cohort_ids=[],
email="learner@hs.ac.kr",
display_name="Learner",
)
with environment("dev"):
prefs = await users_routes.get_preferences(principal)
self.assertEqual(prefs.voice_preset_id, "soft-young-fem")
async def test_staging_blocks_admin_engine_config_default_when_row_missing(self) -> None:
class EmptyConfigConn:
async def fetchrow(self, *args, **kwargs):
return None
class EmptyConfigAcquire:
async def __aenter__(self):
return EmptyConfigConn()
async def __aexit__(self, exc_type, exc, tb):
return None
class EmptyConfigPool:
def acquire(self):
return EmptyConfigAcquire()
with environment("staging"), patch.object(admin_routes, "get_pool", return_value=EmptyConfigPool()):
with self.assertRaises(HTTPException) as caught:
await admin_routes._current_engine_config()
self.assertEqual(caught.exception.status_code, 503)
self.assertIn("runtime fallback is disabled in staging", caught.exception.detail)
async def test_prod_admin_health_marks_db_down_when_persistence_unavailable(self) -> None:
principal = Principal(
user_id="00000000-0000-0000-0000-000000000005",
role=Role.ADMIN,
cohort_ids=[],
email="admin@twentyoz.kr",
display_name="Admin",
)
engine_config = admin_routes.AdminEngineConfigResponse(
engine_mode="claude_cli",
engine_url="http://127.0.0.1:9099",
model="gateway-default",
durable=True,
source="database",
)
with (
environment("prod"),
patch.object(admin_routes, "_current_engine_config", AsyncMock(return_value=engine_config)),
patch.object(admin_routes, "healthcheck", AsyncMock(return_value=False)),
patch.object(admin_routes.engine_client, "health_detail", AsyncMock(return_value={"ok": True})),
patch.object(admin_routes.voice_service, "is_available", return_value=False),
):
health = await admin_routes.admin_health(principal)
db = next(service for service in health.services if service.key == "db")
self.assertEqual(db.status, "down")
self.assertEqual(db.metric, "저장소 중단")
self.assertIn("DB 저장소", db.detail)
async def test_dev_admin_health_labels_db_fallback_as_non_durable_runtime(self) -> None:
principal = Principal(
user_id="00000000-0000-0000-0000-000000000006",
role=Role.ADMIN,
cohort_ids=[],
email="admin@twentyoz.kr",
display_name="Admin",
)
engine_config = admin_routes.AdminEngineConfigResponse(
engine_mode="claude_cli",
engine_url="http://127.0.0.1:9099",
model="gateway-default",
durable=False,
source="runtime_cache",
)
with (
environment("dev"),
patch.object(admin_routes, "_current_engine_config", AsyncMock(return_value=engine_config)),
patch.object(admin_routes, "healthcheck", AsyncMock(return_value=False)),
patch.object(admin_routes.engine_client, "health_detail", AsyncMock(return_value={"ok": True})),
patch.object(admin_routes.voice_service, "is_available", return_value=False),
):
health = await admin_routes.admin_health(principal)
db = next(service for service in health.services if service.key == "db")
self.assertEqual(db.status, "degraded")
self.assertEqual(db.metric, "비영구 런타임 기록")
self.assertIn("비영구 개발 런타임 기록", db.detail)
def test_non_dev_rejects_fixture_runtime_flags(self) -> None:
with self.assertRaises(ValueError) as caught:
Settings(
environment="staging",
auth_dev_login_enabled=True,
auto_seed_personas=True,
allow_seed_persona_fallback=True,
)
self.assertIn("AUTH_DEV_LOGIN_ENABLED", str(caught.exception))
self.assertIn("AUTO_SEED_PERSONAS", str(caught.exception))
self.assertIn("ALLOW_SEED_PERSONA_FALLBACK", str(caught.exception))
def test_non_dev_rejects_missing_public_runtime_config(self) -> None:
with self.assertRaises(ValueError) as caught:
Settings(
environment="prod",
auth_dev_login_enabled=False,
auto_seed_personas=False,
allow_seed_persona_fallback=False,
oauth_google_client_id="",
oauth_google_client_secret="",
session_secret="dev-insecure-change-me",
frontend_base_url="http://localhost:5173",
cors_origins=["http://localhost:5173"],
)
error = str(caught.exception)
self.assertIn("OAUTH_GOOGLE_CLIENT_ID", error)
self.assertIn("OAUTH_GOOGLE_CLIENT_SECRET", error)
self.assertIn("SESSION_SECRET", error)
self.assertIn("FRONTEND_BASE_URL", error)
def test_non_dev_accepts_public_runtime_config(self) -> None:
cfg = Settings(
environment="staging",
auth_dev_login_enabled=False,
auto_seed_personas=False,
allow_seed_persona_fallback=False,
session_secret="staging-secret-change-me",
oauth_google_client_id="google-client-id",
oauth_google_client_secret="google-client-secret",
frontend_base_url="https://vignette.chanpaca.net",
cors_origins=["https://vignette.chanpaca.net"],
)
self.assertEqual(cfg.environment, "staging")
self.assertEqual(cfg.cors_origins, ["https://vignette.chanpaca.net"])
def test_non_dev_accepts_explicit_local_vite_cors_ports(self) -> None:
cfg = Settings(
environment="prod",
auth_dev_login_enabled=False,
auto_seed_personas=False,
allow_seed_persona_fallback=False,
session_secret="prod-secret-change-me",
oauth_google_client_id="google-client-id",
oauth_google_client_secret="google-client-secret",
frontend_base_url="https://vignette.chanpaca.net",
cors_origins=[
"https://vignette.chanpaca.net",
"http://localhost:5170",
"http://127.0.0.1:5180",
],
)
self.assertIn("http://localhost:5170", cfg.cors_origins)
self.assertIn("http://127.0.0.1:5180", cfg.cors_origins)
def test_non_dev_rejects_unscoped_local_cors_ports(self) -> None:
with self.assertRaises(ValueError) as caught:
Settings(
environment="prod",
auth_dev_login_enabled=False,
auto_seed_personas=False,
allow_seed_persona_fallback=False,
session_secret="prod-secret-change-me",
oauth_google_client_id="google-client-id",
oauth_google_client_secret="google-client-secret",
frontend_base_url="https://vignette.chanpaca.net",
cors_origins=["http://localhost:5181"],
)
self.assertIn("CORS_ORIGINS", str(caught.exception))
if __name__ == "__main__":
unittest.main()