| """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 | |