jarvis / tests /test_config.py
Jonathan Haas
Add LLM memory quality eval gate and promote OpenAI-agent readiness updates
1265f9a
Raw
History Blame Contribute Delete
33.2 kB
"""Tests for jarvis.config."""
import pytest
class TestConfig:
def test_loads_from_env(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "sk-test-123")
monkeypatch.setenv("ELEVENLABS_API_KEY", "el-test-456")
monkeypatch.setenv("HASS_URL", "http://ha.local:8123")
monkeypatch.setenv("HASS_TOKEN", "ha-token")
from jarvis.config import Config
c = Config()
assert c.openai_api_key == "sk-test-123"
assert c.elevenlabs_api_key == "el-test-456"
assert c.hass_url == "http://ha.local:8123"
assert c.has_home_assistant is True
def test_missing_openai_key_raises(self, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
from jarvis.config import Config
with pytest.raises(RuntimeError, match="OPENAI_API_KEY"):
Config()
def test_whitespace_only_openai_key_raises(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", " ")
from jarvis.config import Config
with pytest.raises(RuntimeError, match="OPENAI_API_KEY"):
Config()
def test_required_env_value_is_trimmed(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", " sk-trimmed ")
from jarvis.config import Config
c = Config()
assert c.openai_api_key == "sk-trimmed"
def test_defaults(self, config):
assert config.sample_rate == 16000
assert config.vad_threshold == 0.5
assert config.whisper_model == "base.en"
assert config.yolo_model == "yolov8n-face.pt"
assert config.openai_router_model == config.openai_model
assert config.router_timeout_sec == 2.0
assert config.policy_router_min_confidence == 0.55
assert config.interruption_router_timeout_sec == 1.5
assert config.interruption_resume_min_confidence == 0.6
assert config.semantic_turn_enabled is True
assert config.semantic_turn_router_timeout_sec == 0.8
assert config.semantic_turn_min_confidence == 0.6
assert config.semantic_turn_extension_sec == 0.6
assert config.semantic_turn_max_transcript_chars == 220
assert config.motion_enabled is True
assert config.home_enabled is True
assert config.safe_mode_enabled is False
assert config.expansion_state_path.endswith("expansion-state.json")
assert config.release_channel_config_path.endswith("release-channels.json")
assert config.notes_capture_dir.endswith("notes")
assert config.quality_report_dir.endswith("quality-reports")
def test_release_and_expansion_paths_load_from_env(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("EXPANSION_STATE_PATH", "/tmp/jarvis-expansion.json")
monkeypatch.setenv("RELEASE_CHANNEL_CONFIG_PATH", "custom/release.json")
monkeypatch.setenv("NOTES_CAPTURE_DIR", "/tmp/jarvis-notes")
monkeypatch.setenv("QUALITY_REPORT_DIR", "/tmp/jarvis-quality")
monkeypatch.setenv("NOTION_API_TOKEN", "notion-token")
monkeypatch.setenv("NOTION_DATABASE_ID", "db-123")
from jarvis.config import Config
c = Config()
assert c.expansion_state_path == "/tmp/jarvis-expansion.json"
assert c.release_channel_config_path == "custom/release.json"
assert c.notes_capture_dir == "/tmp/jarvis-notes"
assert c.quality_report_dir == "/tmp/jarvis-quality"
assert c.notion_api_token == "notion-token"
assert c.notion_database_id == "db-123"
def test_invalid_sample_rate_raises(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
from jarvis.config import Config
with pytest.raises(ValueError, match="16000"):
Config(sample_rate=44100)
def test_has_home_assistant_false_when_missing(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.delenv("HASS_URL", raising=False)
monkeypatch.delenv("HASS_TOKEN", raising=False)
from jarvis.config import Config
c = Config()
assert c.has_home_assistant is False
def test_invalid_vad_threshold_raises(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
from jarvis.config import Config
with pytest.raises(ValueError, match="vad_threshold"):
Config(vad_threshold=1.5)
def test_invalid_face_track_fps_raises(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
from jarvis.config import Config
with pytest.raises(ValueError, match="face_track_fps"):
Config(face_track_fps=0)
def test_invalid_memory_weights_raise(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
from jarvis.config import Config
with pytest.raises(ValueError, match="memory_hybrid_weight"):
Config(memory_hybrid_weight=-0.1)
def test_invalid_memory_embedding_settings_raise(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
from jarvis.config import Config
with pytest.raises(ValueError, match="memory_embedding_vector_weight"):
Config(memory_embedding_vector_weight=1.5)
with pytest.raises(ValueError, match="memory_embedding_min_similarity"):
Config(memory_embedding_min_similarity=-0.1)
with pytest.raises(ValueError, match="memory_embedding_timeout_sec"):
Config(memory_embedding_timeout_sec=0.0)
with pytest.raises(ValueError, match="memory_conflict_resolution_timeout_sec"):
Config(memory_conflict_resolution_timeout_sec=0.0)
def test_backchannel_style_normalizes(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
from jarvis.config import Config
c = Config(backchannel_style="LOUD")
assert c.backchannel_style == "balanced"
def test_persona_style_normalizes(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
from jarvis.config import Config
c = Config(persona_style="chatty")
assert c.persona_style == "composed"
def test_persona_style_allows_jarvis_and_aliases(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
from jarvis.config import Config
assert Config(persona_style="jarvis").persona_style == "jarvis"
assert Config(persona_style="witty").persona_style == "jarvis"
def test_wake_mode_normalizes(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
from jarvis.config import Config
c = Config(wake_mode="invalid-mode")
assert c.wake_mode == "always_listening"
def test_wake_calibration_profile_normalizes(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
from jarvis.config import Config
c = Config(wake_calibration_profile="street-noise")
assert c.wake_calibration_profile == "default"
def test_voice_timeout_profile_normalizes(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
from jarvis.config import Config
c = Config(voice_timeout_profile="dynamic")
assert c.voice_timeout_profile == "normal"
def test_home_permission_profile_normalizes(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
from jarvis.config import Config
c = Config(home_permission_profile="execute-all")
assert c.home_permission_profile == "control"
def test_home_conversation_permission_profile_normalizes(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
from jarvis.config import Config
c = Config(home_conversation_permission_profile="execute-all")
assert c.home_conversation_permission_profile == "readonly"
def test_todoist_permission_profile_normalizes(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
from jarvis.config import Config
c = Config(todoist_permission_profile="write-all")
assert c.todoist_permission_profile == "control"
def test_notification_permission_profile_normalizes(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
from jarvis.config import Config
c = Config(notification_permission_profile="enabled")
assert c.notification_permission_profile == "allow"
def test_nudge_policy_normalizes(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
from jarvis.config import Config
c = Config(nudge_policy="force")
assert c.nudge_policy == "adaptive"
def test_email_permission_profile_normalizes(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
from jarvis.config import Config
c = Config(email_permission_profile="send-all")
assert c.email_permission_profile == "readonly"
def test_weather_units_normalizes(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
from jarvis.config import Config
c = Config(weather_units="kelvin")
assert c.weather_units == "metric"
def test_operator_auth_mode_normalizes(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
from jarvis.config import Config
c = Config(operator_auth_mode="browser")
assert c.operator_auth_mode == "token"
def test_invalid_audit_retention_raises(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
from jarvis.config import Config
with pytest.raises(ValueError, match="audit_log_max_bytes"):
Config(audit_log_max_bytes=0)
with pytest.raises(ValueError, match="audit_log_backups"):
Config(audit_log_backups=0)
def test_startup_warnings_include_invalid_optional_env_values(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("DOA_TIMEOUT", "nan")
monkeypatch.setenv("DOA_CHANGE_THRESHOLD", "inf")
monkeypatch.setenv("MEMORY_SEARCH_LIMIT", "oops")
monkeypatch.setenv("TODOIST_TIMEOUT_SEC", "0")
monkeypatch.setenv("PUSHOVER_TIMEOUT_SEC", "-5")
monkeypatch.setenv("WEATHER_TIMEOUT_SEC", "0")
monkeypatch.setenv("WEBHOOK_TIMEOUT_SEC", "-3")
monkeypatch.setenv("TURN_TIMEOUT_ACT_SEC", "0")
monkeypatch.setenv("INTERRUPTION_ROUTER_TIMEOUT_SEC", "0")
monkeypatch.setenv("INTERRUPTION_RESUME_MIN_CONFIDENCE", "nan")
monkeypatch.setenv("SEMANTIC_TURN_ROUTER_TIMEOUT_SEC", "0")
monkeypatch.setenv("SEMANTIC_TURN_MIN_CONFIDENCE", "nan")
monkeypatch.setenv("SEMANTIC_TURN_EXTENSION_SEC", "0")
monkeypatch.setenv("SEMANTIC_TURN_MAX_TRANSCRIPT_CHARS", "oops")
monkeypatch.setenv("BACKCHANNEL_STYLE", "LOUD")
monkeypatch.setenv("PERSONA_STYLE", "chatty")
monkeypatch.setenv("HOME_ENABLED", "maybe")
monkeypatch.setenv("SAFE_MODE_ENABLED", "sometimes")
monkeypatch.setenv("HOME_REQUIRE_CONFIRM_EXECUTE", "sometimes")
monkeypatch.setenv("HOME_CONVERSATION_ENABLED", "perhaps")
monkeypatch.setenv("PLAN_PREVIEW_REQUIRE_ACK", "later")
monkeypatch.setenv("SEMANTIC_TURN_ENABLED", "later")
monkeypatch.setenv("MEMORY_PII_GUARDRAILS_ENABLED", "unknown")
monkeypatch.setenv("HOME_PERMISSION_PROFILE", "execute-all")
monkeypatch.setenv("HOME_CONVERSATION_PERMISSION_PROFILE", "execute-all")
monkeypatch.setenv("TODOIST_PERMISSION_PROFILE", "write-all")
monkeypatch.setenv("NOTIFICATION_PERMISSION_PROFILE", "enabled")
monkeypatch.setenv("NUDGE_POLICY", "force")
monkeypatch.setenv("NUDGE_QUIET_HOURS_START", "25:00")
monkeypatch.setenv("NUDGE_QUIET_HOURS_END", "bad")
monkeypatch.setenv("EMAIL_PERMISSION_PROFILE", "send-all")
monkeypatch.setenv("EMAIL_TIMEOUT_SEC", "0")
monkeypatch.setenv("WEATHER_UNITS", "kelvin")
monkeypatch.setenv("OPERATOR_AUTH_MODE", "browser")
from jarvis.config import Config
c = Config()
text = "\n".join(c.startup_warnings)
assert "DOA_TIMEOUT invalid" in text
assert "DOA_CHANGE_THRESHOLD invalid" in text
assert "MEMORY_SEARCH_LIMIT invalid" in text
assert "TODOIST_TIMEOUT_SEC invalid" in text
assert "PUSHOVER_TIMEOUT_SEC invalid" in text
assert "WEATHER_TIMEOUT_SEC invalid" in text
assert "WEBHOOK_TIMEOUT_SEC invalid" in text
assert "TURN_TIMEOUT_ACT_SEC invalid" in text
assert "INTERRUPTION_ROUTER_TIMEOUT_SEC invalid" in text
assert "INTERRUPTION_RESUME_MIN_CONFIDENCE invalid" in text
assert "SEMANTIC_TURN_ROUTER_TIMEOUT_SEC invalid" in text
assert "SEMANTIC_TURN_MIN_CONFIDENCE invalid" in text
assert "SEMANTIC_TURN_EXTENSION_SEC invalid" in text
assert "SEMANTIC_TURN_MAX_TRANSCRIPT_CHARS invalid" in text
assert "BACKCHANNEL_STYLE invalid" in text
assert "PERSONA_STYLE invalid" in text
assert "HOME_ENABLED invalid boolean" in text
assert "SAFE_MODE_ENABLED invalid boolean" in text
assert "HOME_REQUIRE_CONFIRM_EXECUTE invalid boolean" in text
assert "HOME_CONVERSATION_ENABLED invalid boolean" in text
assert "PLAN_PREVIEW_REQUIRE_ACK invalid boolean" in text
assert "SEMANTIC_TURN_ENABLED invalid boolean" in text
assert "MEMORY_PII_GUARDRAILS_ENABLED invalid boolean" in text
assert "HOME_PERMISSION_PROFILE invalid" in text
assert "HOME_CONVERSATION_PERMISSION_PROFILE invalid" in text
assert "TODOIST_PERMISSION_PROFILE invalid" in text
assert "NOTIFICATION_PERMISSION_PROFILE invalid" in text
assert "NUDGE_POLICY invalid" in text
assert "NUDGE_QUIET_HOURS_START invalid" in text
assert "NUDGE_QUIET_HOURS_END invalid" in text
assert "EMAIL_PERMISSION_PROFILE invalid" in text
assert "EMAIL_TIMEOUT_SEC invalid" in text
assert "WEATHER_UNITS invalid" in text
assert "OPERATOR_AUTH_MODE invalid" in text
def test_invalid_bool_env_falls_back_to_default_behavior(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("HOME_ENABLED", "invalid")
monkeypatch.setenv("MEMORY_ENABLED", "invalid")
monkeypatch.setenv("HOME_CONVERSATION_ENABLED", "invalid")
monkeypatch.setenv("MEMORY_PII_GUARDRAILS_ENABLED", "invalid")
from jarvis.config import Config
c = Config()
assert c.home_enabled is True
assert c.memory_enabled is True
assert c.home_require_confirm_execute is False
assert c.home_conversation_enabled is False
assert c.memory_pii_guardrails_enabled is True
def test_home_require_confirm_execute_env_true(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("HOME_REQUIRE_CONFIRM_EXECUTE", "true")
from jarvis.config import Config
c = Config()
assert c.home_require_confirm_execute is True
def test_safe_mode_enabled_env_true(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("SAFE_MODE_ENABLED", "true")
from jarvis.config import Config
c = Config()
assert c.safe_mode_enabled is True
assert any("SAFE_MODE_ENABLED=true" in warning for warning in c.startup_warnings)
def test_home_conversation_enabled_env_true(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("HOME_CONVERSATION_ENABLED", "true")
from jarvis.config import Config
c = Config()
assert c.home_conversation_enabled is True
def test_plan_preview_require_ack_env_true(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("PLAN_PREVIEW_REQUIRE_ACK", "true")
from jarvis.config import Config
c = Config()
assert c.plan_preview_require_ack is True
def test_non_finite_float_env_values_fall_back_to_defaults(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("DOA_TIMEOUT", "nan")
monkeypatch.setenv("DOA_CHANGE_THRESHOLD", "inf")
from jarvis.config import Config
c = Config()
assert c.doa_timeout == 1.0
assert c.doa_change_threshold == 0.04
def test_timeout_env_values_fall_back_to_defaults_when_invalid(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("TODOIST_TIMEOUT_SEC", "0")
monkeypatch.setenv("PUSHOVER_TIMEOUT_SEC", "-5")
monkeypatch.setenv("EMAIL_TIMEOUT_SEC", "0")
monkeypatch.setenv("WEATHER_TIMEOUT_SEC", "0")
monkeypatch.setenv("WEBHOOK_TIMEOUT_SEC", "-1")
monkeypatch.setenv("TURN_TIMEOUT_ACT_SEC", "0")
monkeypatch.setenv("ROUTER_TIMEOUT_SEC", "0")
monkeypatch.setenv("INTERRUPTION_ROUTER_TIMEOUT_SEC", "0")
monkeypatch.setenv("SEMANTIC_TURN_ROUTER_TIMEOUT_SEC", "0")
monkeypatch.setenv("SEMANTIC_TURN_EXTENSION_SEC", "0")
from jarvis.config import Config
c = Config()
assert c.todoist_timeout_sec == 10.0
assert c.pushover_timeout_sec == 10.0
assert c.email_timeout_sec == 10.0
assert c.weather_timeout_sec == 8.0
assert c.webhook_timeout_sec == 8.0
assert c.turn_timeout_act_sec == 30.0
assert c.router_timeout_sec == 2.0
assert c.interruption_router_timeout_sec == 1.5
assert c.semantic_turn_router_timeout_sec == 0.8
assert c.semantic_turn_extension_sec == 0.6
def test_timeout_env_values_can_be_set(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("TODOIST_TIMEOUT_SEC", "7.5")
monkeypatch.setenv("PUSHOVER_TIMEOUT_SEC", "12")
monkeypatch.setenv("EMAIL_TIMEOUT_SEC", "11")
monkeypatch.setenv("WEATHER_TIMEOUT_SEC", "4.5")
monkeypatch.setenv("WEBHOOK_TIMEOUT_SEC", "9")
monkeypatch.setenv("TURN_TIMEOUT_ACT_SEC", "6.5")
monkeypatch.setenv("ROUTER_TIMEOUT_SEC", "1.75")
monkeypatch.setenv("INTERRUPTION_ROUTER_TIMEOUT_SEC", "0.9")
monkeypatch.setenv("SEMANTIC_TURN_ROUTER_TIMEOUT_SEC", "0.65")
monkeypatch.setenv("SEMANTIC_TURN_MIN_CONFIDENCE", "0.72")
monkeypatch.setenv("SEMANTIC_TURN_EXTENSION_SEC", "1.1")
monkeypatch.setenv("SEMANTIC_TURN_MAX_TRANSCRIPT_CHARS", "180")
from jarvis.config import Config
c = Config()
assert c.todoist_timeout_sec == 7.5
assert c.pushover_timeout_sec == 12.0
assert c.email_timeout_sec == 11.0
assert c.weather_timeout_sec == 4.5
assert c.webhook_timeout_sec == 9.0
assert c.turn_timeout_act_sec == 6.5
assert c.router_timeout_sec == 1.75
assert c.interruption_router_timeout_sec == 0.9
assert c.semantic_turn_router_timeout_sec == 0.65
assert c.semantic_turn_min_confidence == 0.72
assert c.semantic_turn_extension_sec == 1.1
assert c.semantic_turn_max_transcript_chars == 180
def test_policy_router_min_confidence_bounds_are_validated(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
from jarvis.config import Config
with pytest.raises(ValueError, match="policy_router_min_confidence"):
Config(policy_router_min_confidence=-0.1)
with pytest.raises(ValueError, match="policy_router_min_confidence"):
Config(policy_router_min_confidence=1.1)
def test_interruption_resume_min_confidence_bounds_are_validated(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
from jarvis.config import Config
with pytest.raises(ValueError, match="interruption_resume_min_confidence"):
Config(interruption_resume_min_confidence=-0.1)
with pytest.raises(ValueError, match="interruption_resume_min_confidence"):
Config(interruption_resume_min_confidence=1.1)
def test_semantic_turn_min_confidence_bounds_are_validated(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
from jarvis.config import Config
with pytest.raises(ValueError, match="semantic_turn_min_confidence"):
Config(semantic_turn_min_confidence=-0.1)
with pytest.raises(ValueError, match="semantic_turn_min_confidence"):
Config(semantic_turn_min_confidence=1.1)
def test_retention_env_values_fall_back_to_defaults_when_invalid(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("MEMORY_RETENTION_DAYS", "-1")
monkeypatch.setenv("AUDIT_RETENTION_DAYS", "-5")
from jarvis.config import Config
c = Config()
assert c.memory_retention_days == 0.0
assert c.audit_retention_days == 0.0
text = "\n".join(c.startup_warnings)
assert "MEMORY_RETENTION_DAYS invalid" in text
assert "AUDIT_RETENTION_DAYS invalid" in text
def test_startup_warnings_flag_short_tokens_and_insecure_webhooks(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("HASS_URL", "http://ha.local:8123")
monkeypatch.setenv("HASS_TOKEN", "short")
monkeypatch.setenv("TODOIST_API_TOKEN", "tiny")
monkeypatch.setenv("PUSHOVER_API_TOKEN", "small")
monkeypatch.setenv("PUSHOVER_USER_KEY", "user")
monkeypatch.setenv("WEBHOOK_AUTH_TOKEN", "token")
monkeypatch.setenv("SLACK_WEBHOOK_URL", "http://slack.test/hook")
monkeypatch.setenv("DISCORD_WEBHOOK_URL", "http://discord.test/hook")
from jarvis.config import Config
c = Config()
text = "\n".join(c.startup_warnings)
assert "HASS_TOKEN appears unusually short" in text
assert "TODOIST_API_TOKEN appears unusually short" in text
assert "PUSHOVER_API_TOKEN appears unusually short" in text
assert "WEBHOOK_AUTH_TOKEN is set while WEBHOOK_ALLOWLIST is empty" in text
assert "SLACK_WEBHOOK_URL should use https." in text
assert "DISCORD_WEBHOOK_URL should use https." in text
def test_startup_warning_for_partial_home_assistant_config(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("HASS_URL", "http://ha.local:8123")
monkeypatch.delenv("HASS_TOKEN", raising=False)
from jarvis.config import Config
c = Config()
text = "\n".join(c.startup_warnings)
assert "home assistant config incomplete" in text.lower()
def test_startup_warning_for_partial_todoist_config(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("TODOIST_PROJECT_ID", "inbox")
monkeypatch.delenv("TODOIST_API_TOKEN", raising=False)
from jarvis.config import Config
c = Config()
text = "\n".join(c.startup_warnings)
assert "todoist config incomplete" in text.lower()
def test_startup_warning_for_partial_pushover_config(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("PUSHOVER_API_TOKEN", "app-token")
monkeypatch.delenv("PUSHOVER_USER_KEY", raising=False)
from jarvis.config import Config
c = Config()
text = "\n".join(c.startup_warnings)
assert "pushover config incomplete" in text.lower()
def test_startup_warning_for_partial_email_config(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("EMAIL_SMTP_HOST", "smtp.example.com")
monkeypatch.delenv("EMAIL_FROM", raising=False)
monkeypatch.delenv("EMAIL_DEFAULT_TO", raising=False)
from jarvis.config import Config
c = Config()
text = "\n".join(c.startup_warnings)
assert "email config incomplete" in text.lower()
def test_startup_warning_for_partial_quiet_window_config(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("NUDGE_QUIET_HOURS_START", "22:00")
monkeypatch.setenv("NUDGE_QUIET_HOURS_END", "")
from jarvis.config import Config
c = Config()
text = "\n".join(c.startup_warnings)
assert "quiet-window config incomplete" in text.lower()
def test_startup_warning_for_permissive_profiles_without_credentials(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("HOME_PERMISSION_PROFILE", "control")
monkeypatch.setenv("HOME_CONVERSATION_PERMISSION_PROFILE", "control")
monkeypatch.setenv("TODOIST_PERMISSION_PROFILE", "control")
monkeypatch.setenv("NOTIFICATION_PERMISSION_PROFILE", "allow")
monkeypatch.delenv("HASS_URL", raising=False)
monkeypatch.delenv("HASS_TOKEN", raising=False)
monkeypatch.delenv("TODOIST_API_TOKEN", raising=False)
monkeypatch.delenv("PUSHOVER_API_TOKEN", raising=False)
monkeypatch.delenv("PUSHOVER_USER_KEY", raising=False)
from jarvis.config import Config
c = Config()
text = "\n".join(c.startup_warnings).lower()
assert "home_permission_profile=control set while hass_url/hass_token are empty" in text
assert "home_conversation_permission_profile=control set while hass_url/hass_token are empty" in text
assert "todoist_permission_profile=control set while todoist_api_token is empty" in text
assert "notification_permission_profile=allow set while pushover credentials are empty" in text
def test_identity_profile_parsing_and_normalization(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("IDENTITY_ENFORCEMENT_ENABLED", "true")
monkeypatch.setenv("IDENTITY_DEFAULT_USER", " OWNER ")
monkeypatch.setenv("IDENTITY_DEFAULT_PROFILE", "trusted")
monkeypatch.setenv("IDENTITY_USER_PROFILES", "alice=readonly,bob=DENY,charlie=unknown")
monkeypatch.setenv("IDENTITY_TRUSTED_USERS", "owner,alice,owner")
monkeypatch.setenv("IDENTITY_REQUIRE_APPROVAL", "true")
monkeypatch.setenv("IDENTITY_APPROVAL_CODE", "strong-secret")
from jarvis.config import Config
c = Config()
assert c.identity_enforcement_enabled is True
assert c.identity_default_user == "owner"
assert c.identity_default_profile == "trusted"
assert c.identity_user_profiles["alice"] == "readonly"
assert c.identity_user_profiles["bob"] == "deny"
assert c.identity_user_profiles["charlie"] == "control"
assert c.identity_trusted_users == ["alice", "owner"]
assert c.identity_require_approval is True
assert c.identity_approval_code == "strong-secret"
def test_identity_startup_warnings_for_invalid_profiles_and_missing_approval_path(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("IDENTITY_ENFORCEMENT_ENABLED", "true")
monkeypatch.setenv("IDENTITY_REQUIRE_APPROVAL", "true")
monkeypatch.setenv("IDENTITY_USER_PROFILES", "badentry,=readonly,user=super")
monkeypatch.setenv("IDENTITY_DEFAULT_PROFILE", "super")
monkeypatch.setenv("IDENTITY_APPROVAL_CODE", "short")
monkeypatch.delenv("IDENTITY_TRUSTED_USERS", raising=False)
from jarvis.config import Config
c = Config()
text = "\n".join(c.startup_warnings)
assert "IDENTITY_DEFAULT_PROFILE invalid" in text
assert "IDENTITY_USER_PROFILES has invalid entry" in text
assert "IDENTITY_USER_PROFILES has invalid profile" in text
assert "IDENTITY_APPROVAL_CODE appears unusually short" in text
def test_identity_require_approval_warning_when_no_code_or_trusted_users(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("IDENTITY_ENFORCEMENT_ENABLED", "true")
monkeypatch.setenv("IDENTITY_REQUIRE_APPROVAL", "true")
monkeypatch.delenv("IDENTITY_APPROVAL_CODE", raising=False)
monkeypatch.delenv("IDENTITY_TRUSTED_USERS", raising=False)
from jarvis.config import Config
c = Config()
text = "\n".join(c.startup_warnings)
assert "IDENTITY_REQUIRE_APPROVAL is enabled without IDENTITY_APPROVAL_CODE or IDENTITY_TRUSTED_USERS." in text
def test_plan_preview_warning_when_identity_enforcement_disabled(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("PLAN_PREVIEW_REQUIRE_ACK", "true")
monkeypatch.setenv("IDENTITY_ENFORCEMENT_ENABLED", "false")
from jarvis.config import Config
c = Config()
text = "\n".join(c.startup_warnings)
assert "PLAN_PREVIEW_REQUIRE_ACK enabled while IDENTITY_ENFORCEMENT_ENABLED=false" in text
def test_model_secondary_mode_normalizes(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
from jarvis.config import Config
c = Config(model_secondary_mode="unknown")
assert c.model_secondary_mode == "offline_stub"
def test_startup_warning_for_signature_requirement_without_key(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("SKILLS_REQUIRE_SIGNATURE", "true")
monkeypatch.delenv("SKILLS_SIGNATURE_KEY", raising=False)
from jarvis.config import Config
c = Config()
text = "\n".join(c.startup_warnings)
assert "SKILLS_REQUIRE_SIGNATURE enabled without SKILLS_SIGNATURE_KEY" in text
def test_startup_warning_for_encryption_without_data_key(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("MEMORY_ENCRYPTION_ENABLED", "true")
monkeypatch.delenv("JARVIS_DATA_KEY", raising=False)
from jarvis.config import Config
c = Config()
text = "\n".join(c.startup_warnings)
assert "Encryption enabled without JARVIS_DATA_KEY" in text
def test_startup_warning_for_inbound_webhook_without_auth(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("WEBHOOK_INBOUND_ENABLED", "true")
monkeypatch.delenv("WEBHOOK_INBOUND_TOKEN", raising=False)
monkeypatch.delenv("WEBHOOK_AUTH_TOKEN", raising=False)
from jarvis.config import Config
c = Config()
text = "\n".join(c.startup_warnings)
assert "WEBHOOK_INBOUND_ENABLED=true without WEBHOOK_INBOUND_TOKEN/WEBHOOK_AUTH_TOKEN" in text
def test_startup_warning_for_non_loopback_operator_host(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("OPERATOR_SERVER_ENABLED", "true")
monkeypatch.setenv("OPERATOR_SERVER_HOST", "0.0.0.0")
monkeypatch.delenv("OPERATOR_AUTH_TOKEN", raising=False)
from jarvis.config import Config
c = Config()
text = "\n".join(c.startup_warnings)
assert "OPERATOR_SERVER_HOST is non-loopback" in text
assert "OPERATOR_AUTH_TOKEN should be set when OPERATOR_SERVER_HOST is non-loopback." in text
def test_startup_warning_for_short_operator_auth_token(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("OPERATOR_AUTH_TOKEN", "short")
from jarvis.config import Config
c = Config()
text = "\n".join(c.startup_warnings)
assert "OPERATOR_AUTH_TOKEN appears unusually short" in text
def test_startup_warning_for_operator_auth_mode_off_risk(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("OPERATOR_AUTH_MODE", "off")
monkeypatch.setenv("OPERATOR_SERVER_HOST", "0.0.0.0")
from jarvis.config import Config
c = Config()
text = "\n".join(c.startup_warnings)
assert "OPERATOR_AUTH_MODE=off (risk: high)" in text
assert "OPERATOR_AUTH_MODE=off on non-loopback OPERATOR_SERVER_HOST (risk: critical)." in text
def test_startup_warning_for_operator_auth_session_without_token(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("OPERATOR_AUTH_MODE", "session")
monkeypatch.delenv("OPERATOR_AUTH_TOKEN", raising=False)
from jarvis.config import Config
c = Config()
text = "\n".join(c.startup_warnings)
assert "OPERATOR_AUTH_MODE=session without OPERATOR_AUTH_TOKEN (risk: high)." in text
def test_startup_warning_when_inbound_enabled_but_operator_disabled(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
monkeypatch.setenv("OPERATOR_SERVER_ENABLED", "false")
monkeypatch.setenv("WEBHOOK_INBOUND_ENABLED", "true")
from jarvis.config import Config
c = Config()
text = "\n".join(c.startup_warnings)
assert "WEBHOOK_INBOUND_ENABLED=true while OPERATOR_SERVER_ENABLED=false" in text