"""Regression tests for DB outage runtime fallback policy.""" from __future__ import annotations import unittest import time from contextlib import contextmanager from types import SimpleNamespace from unittest.mock import AsyncMock, patch from fastapi import HTTPException from . import auth_sessions, db, 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 from .services import notifications @contextmanager def environment(value: str): previous = settings.environment settings.environment = value try: yield finally: settings.environment = previous class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase): def test_gateway_shared_secret_rejects_short_or_placeholder_values(self) -> None: for value in ("too-short", "replace-with-a-random-token-of-32-chars"): with self.subTest(value=value), self.assertRaises(ValueError) as caught: Settings(engine_gateway_shared_secret=value) self.assertIn("ENGINE_GATEWAY_SHARED_SECRET", str(caught.exception)) def test_gateway_shared_secret_is_masked_in_settings_repr(self) -> None: secret = "runtime-gateway-secret-" + ("s" * 32) cfg = Settings(engine_gateway_shared_secret=secret) self.assertEqual( cfg.engine_gateway_shared_secret.get_secret_value(), secret, ) self.assertNotIn(secret, repr(cfg)) 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_recent_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_prod_blocks_runtime_schema_bootstrap_ddl_when_schema_incomplete( self, ) -> None: class IncompleteConn: async def fetchrow(self, *args, **kwargs): return { "has_user_columns": False, "has_persona_triggers": False, "has_persona_voice_map": False, "has_auth_session": False, "has_preferences": False, "has_engine_config": False, "has_session_columns": False, "has_state_columns": False, "has_turn_voice_metadata_columns": False, "has_session_review_worksheet_columns": False, "has_stage_defs": False, "has_admin_health_event": False, "has_admin_health_daily_rollup": False, "has_admin_health_daily_rollup_columns": False, "has_support_ticket": False, "has_support_ticket_duplicate_columns": False, "has_learner_prepost_measure": False, "has_admin_health_event_policies": False, "has_admin_health_daily_rollup_policies": False, "has_support_ticket_policies": False, "has_learner_prepost_measure_policies": False, "has_session_write_policies": False, "removed_old_session_policy": False, "has_turn_write_policies": False, "removed_old_turn_policy": False, } async def execute(self, *args, **kwargs): raise AssertionError("prod startup must not run owner DDL") class IncompleteAcquire: async def __aenter__(self): return IncompleteConn() async def __aexit__(self, exc_type, exc, tb): return None class IncompletePool: def acquire(self): return IncompleteAcquire() with ( environment("prod"), patch.object(auth_sessions, "get_pool", return_value=IncompletePool()), ): with self.assertRaises(RuntimeError) as caught: await auth_sessions.ensure_runtime_tables() self.assertIn("runtime DB schema is incomplete", str(caught.exception)) async def test_prod_blocks_review_schema_bootstrap_ddl_when_schema_incomplete( self, ) -> None: class IncompleteConn: async def fetchrow(self, *args, **kwargs): return {"ready": False} async def execute(self, *args, **kwargs): raise AssertionError("prod startup must not run review schema DDL") class IncompleteAcquire: async def __aenter__(self): return IncompleteConn() async def __aexit__(self, exc_type, exc, tb): return None with ( environment("prod"), patch.object(session_persistence, "get_pool", return_value=object()), patch.object( session_persistence, "acquire", return_value=IncompleteAcquire() ), ): with self.assertRaises(RuntimeError) as caught: await session_persistence.ensure_review_tables() self.assertIn( "review/evaluation runtime DB schema is incomplete", str(caught.exception) ) async def test_prod_blocks_notification_schema_bootstrap_ddl_when_schema_incomplete( self, ) -> None: class IncompleteConn: async def fetchrow(self, *args, **kwargs): return {"ready": False} async def execute(self, *args, **kwargs): raise AssertionError( "prod startup must not run notification schema DDL" ) class IncompleteAcquire: async def __aenter__(self): return IncompleteConn() async def __aexit__(self, exc_type, exc, tb): return None with ( environment("prod"), patch.object(notifications, "get_pool", return_value=object()), patch.object(notifications, "acquire", return_value=IncompleteAcquire()), ): with self.assertRaises(RuntimeError) as caught: await notifications.ensure_notification_tables() self.assertIn( "notification runtime DB schema is incomplete", str(caught.exception) ) async def test_runtime_readiness_requires_all_turn_voice_metadata_columns( self, ) -> None: class VoiceMetadataDriftConn: def __init__(self) -> None: self.query = "" async def fetchrow(self, query: str, *args, **kwargs): self.query = query return { "has_user_columns": True, "has_persona_triggers": True, "has_persona_voice_map": True, "has_auth_session": True, "has_auth_identity_alias": True, "has_auth_session_login_email": True, "has_auth_identity_alias_select_policy": True, "has_preferences": True, "has_engine_config": True, "has_session_columns": True, "has_state_columns": True, "has_turn_voice_metadata_columns": False, "has_session_review_worksheet_columns": True, "has_stage_defs": True, "has_admin_health_event": True, "has_admin_health_daily_rollup": True, "has_admin_health_daily_rollup_columns": True, "has_support_ticket": True, "has_support_ticket_duplicate_columns": True, "has_learner_prepost_measure": True, "has_admin_health_event_policies": True, "has_admin_health_daily_rollup_policies": True, "has_support_ticket_policies": True, "has_learner_prepost_measure_policies": True, "has_session_write_policies": True, "removed_old_session_policy": True, "has_turn_write_policies": True, "removed_old_turn_policy": True, } conn = VoiceMetadataDriftConn() ready = await auth_sessions._runtime_tables_ready(conn) self.assertFalse(ready) for column in ( "audio_ref", "silence_ms", "speech_rate", "barge_in", "provider_events", ): self.assertIn(column, conn.query) self.assertIn("HAVING count(*) = 5", conn.query) async def test_healthcheck_requires_safety_events_schema(self) -> None: class MissingSafetyEventsConn: def __init__(self) -> None: self.query = "" async def fetchrow(self, query: str, *args, **kwargs): self.query = query return { "has_user": True, "has_auth_session": True, "has_preferences": True, "has_engine_config": True, "has_admin_health_event": True, "has_admin_health_daily_rollup": True, "has_support_ticket": True, "has_notification_event": True, "has_notification_delivery": True, "has_sessions": True, "has_turns": True, "has_safety_events": False, "has_session_review_status": True, "has_turn_voice_metadata_columns": True, "has_safety_event_columns": False, "has_session_review_worksheet_columns": True, } class MissingSafetyEventsAcquire: def __init__(self, conn: MissingSafetyEventsConn) -> None: self.conn = conn async def __aenter__(self) -> MissingSafetyEventsConn: return self.conn async def __aexit__(self, exc_type, exc, tb) -> None: return None class MissingSafetyEventsPool: def __init__(self, conn: MissingSafetyEventsConn) -> None: self.conn = conn def acquire(self): return MissingSafetyEventsAcquire(self.conn) conn = MissingSafetyEventsConn() with patch.object(db, "get_pool", return_value=MissingSafetyEventsPool(conn)): healthy = await db.healthcheck() self.assertFalse(healthy) self.assertIn("to_regclass('app.safety_events')", conn.query) self.assertIn("has_safety_event_columns", conn.query) async def test_staging_uses_env_engine_config_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()), ): config = await admin_routes._current_engine_config() self.assertFalse(config.durable) self.assertEqual(config.source, "runtime_default") self.assertEqual(config.engine_mode, settings.engine_mode) self.assertEqual(config.engine_url, settings.engine_url) async def test_staging_blocks_admin_engine_config_when_db_unavailable(self) -> None: class BrokenConfigPool: def acquire(self): raise RuntimeError("db unavailable") with ( environment("staging"), patch.object(admin_routes, "get_pool", return_value=BrokenConfigPool()), ): 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) async def test_dev_admin_usage_falls_back_to_runtime_store(self) -> None: principal = Principal( user_id="00000000-0000-0000-0000-000000000007", role=Role.ADMIN, cohort_ids=[], email="admin@twentyoz.kr", display_name="Admin", ) now = time.time() fake_session = SimpleNamespace( turns=[ SimpleNamespace( speaker="client", created_at=now, llm_provider="claude_cli", model="gateway-default", tokens_in=11, tokens_out=13, cost_usd=0.0042, ), SimpleNamespace( speaker="client", created_at=now, llm_provider=None, model=None, tokens_in=None, tokens_out=None, cost_usd=None, ), SimpleNamespace( speaker="counselor", created_at=now, llm_provider="ignored", model="ignored", tokens_in=100, tokens_out=100, cost_usd=9.0, ), ] ) with ( environment("dev"), patch.object( admin_routes, "_usage_from_database", AsyncMock(side_effect=RuntimeError("db unavailable")), ), patch.object(admin_routes.store, "list", return_value=[fake_session]), patch.object(admin_routes.settings, "admin_usage_budget_usd", 0.005), ): usage = await admin_routes.admin_usage(principal, window_days=7) self.assertEqual(usage.source, "server_session_registry") self.assertFalse(usage.durable) self.assertEqual(usage.total_turns, 2) self.assertEqual(usage.metered_turns, 1) self.assertEqual(usage.tokens_in, 11) self.assertEqual(usage.tokens_out, 13) self.assertAlmostEqual(usage.cost_usd, 0.0042) self.assertEqual(usage.budget.status, "warn") self.assertAlmostEqual(usage.budget.limit_usd, 0.005) self.assertAlmostEqual(usage.budget.used_ratio, 0.84) self.assertEqual(usage.by_provider[0].provider, "claude_cli") self.assertEqual(usage.by_provider[0].turns, 1) async def test_prod_admin_usage_rejects_runtime_store_fallback(self) -> None: principal = Principal( user_id="00000000-0000-0000-0000-000000000008", role=Role.ADMIN, cohort_ids=[], email="admin@twentyoz.kr", display_name="Admin", ) with ( environment("prod"), patch.object( admin_routes, "_usage_from_database", AsyncMock(side_effect=RuntimeError("db unavailable")), ), ): with self.assertRaises(HTTPException) as caught: await admin_routes.admin_usage(principal, window_days=7) self.assertEqual(caught.exception.status_code, 503) self.assertIn("runtime fallback is disabled in prod", caught.exception.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, voice_poc_sample_tts_enabled=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)) self.assertIn("VIGNETTE_VOICE_POC_SAMPLE_TTS", 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"], voice_poc_sample_tts_enabled=False, ) self.assertEqual(cfg.environment, "staging") self.assertEqual(cfg.cors_origins, ["https://vignette.chanpaca.net"]) def test_non_dev_accepts_vnet_frontend_origin_map(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", frontend_origin_map={ "api-vignette.chanpaca.net": "https://vignette.chanpaca.net", "api-vnet.18ka.net": "https://vnet.18ka.net", }, cors_origins=["https://vignette.chanpaca.net", "https://vnet.18ka.net"], voice_poc_sample_tts_enabled=False, ) self.assertEqual( cfg.frontend_origin_map["api-vnet.18ka.net"], "https://vnet.18ka.net" ) self.assertIn("https://vnet.18ka.net", cfg.cors_origins) def test_non_dev_rejects_local_frontend_origin_map(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", frontend_origin_map={"api.example.test": "http://localhost:9999"}, cors_origins=["https://vignette.chanpaca.net"], ) self.assertIn("FRONTEND_ORIGIN_MAP", str(caught.exception)) 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", ], voice_poc_sample_tts_enabled=False, ) 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()