from __future__ import annotations from pathlib import Path import sys import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from dashboard import authentication ADMIN_ID = "test-admin" ADMIN_PASSWORD = "test-password" def test_load_admin_credentials_uses_only_required_environment_variables(): credentials = authentication.load_admin_credentials( { "JARCA_DASHBOARD_ADMIN_ID": ADMIN_ID, "JARCA_DASHBOARD_ADMIN_PASSWORD": ADMIN_PASSWORD, "auth.admin_id": "ignored-streamlit-id", "auth.admin_password": "ignored-streamlit-password", } ) assert credentials.admin_id == ADMIN_ID assert credentials.password == ADMIN_PASSWORD @pytest.mark.parametrize( "environ", [ {}, {"JARCA_DASHBOARD_ADMIN_ID": ADMIN_ID}, {"JARCA_DASHBOARD_ADMIN_PASSWORD": ADMIN_PASSWORD}, {"JARCA_DASHBOARD_ADMIN_ID": "", "JARCA_DASHBOARD_ADMIN_PASSWORD": ADMIN_PASSWORD}, {"JARCA_DASHBOARD_ADMIN_ID": ADMIN_ID, "JARCA_DASHBOARD_ADMIN_PASSWORD": ""}, {"JARCA_DASHBOARD_ADMIN_ID": 123, "JARCA_DASHBOARD_ADMIN_PASSWORD": ADMIN_PASSWORD}, {"JARCA_DASHBOARD_ADMIN_ID": ADMIN_ID, "JARCA_DASHBOARD_ADMIN_PASSWORD": object()}, ], ) def test_load_admin_credentials_fails_closed_for_missing_empty_or_nonstring_values(environ): with pytest.raises(authentication.AuthenticationConfigurationError) as exc_info: authentication.load_admin_credentials(environ) assert str(exc_info.value) == authentication.CONFIGURATION_ERROR_MESSAGE assert ADMIN_ID not in str(exc_info.value) assert ADMIN_PASSWORD not in str(exc_info.value) def test_load_admin_credentials_fails_closed_when_environment_cannot_be_read(): class UnreadableEnvironment: def get(self, key): raise OSError("secret backend unavailable") with pytest.raises(authentication.AuthenticationConfigurationError) as exc_info: authentication.load_admin_credentials(UnreadableEnvironment()) assert str(exc_info.value) == authentication.CONFIGURATION_ERROR_MESSAGE assert "secret backend" not in str(exc_info.value) def test_verify_credentials_uses_constant_time_comparison_for_both_fields(monkeypatch): credentials = authentication.AdminCredentials(ADMIN_ID, ADMIN_PASSWORD) comparisons: list[tuple[str, str]] = [] def record_compare(left: str, right: str) -> bool: comparisons.append((left, right)) return left == right monkeypatch.setattr(authentication.hmac, "compare_digest", record_compare) assert authentication.verify_credentials(credentials, ADMIN_ID, ADMIN_PASSWORD) is True assert comparisons == [(ADMIN_ID, ADMIN_ID), (ADMIN_PASSWORD, ADMIN_PASSWORD)] comparisons.clear() assert authentication.verify_credentials(credentials, "wrong-id", "wrong-password") is False assert comparisons == [("wrong-id", ADMIN_ID), ("wrong-password", ADMIN_PASSWORD)] def test_failed_login_delay_waits_only_for_remaining_minimum_duration(): sleeps: list[float] = [] authentication.enforce_failed_login_delay( started_at=10.0, minimum_seconds=1.0, monotonic=lambda: 10.25, sleep=sleeps.append, ) assert sleeps == [pytest.approx(0.75)] def test_failed_login_delay_does_not_sleep_after_minimum_duration(): sleeps: list[float] = [] authentication.enforce_failed_login_delay( started_at=10.0, minimum_seconds=1.0, monotonic=lambda: 11.25, sleep=sleeps.append, ) assert sleeps == [] def test_authentication_correlation_id_is_noncredential_session_state(): session_state: dict[str, object] = {} correlation_id = authentication.get_auth_correlation_id(session_state, token_hex=lambda size: "safe-correlation") assert correlation_id == "safe-correlation" assert session_state == {authentication.AUTH_CORRELATION_SESSION_KEY: "safe-correlation"} def test_authentication_event_log_contains_only_event_and_correlation(monkeypatch): calls: list[tuple[str, tuple[object, ...]]] = [] monkeypatch.setattr(authentication.logger, "info", lambda message, *args: calls.append((message, args))) authentication.log_auth_event("login_failed", "safe-correlation") assert calls == [("dashboard_auth event=%s correlation_id=%s", ("login_failed", "safe-correlation"))] assert ADMIN_ID not in repr(calls) assert ADMIN_PASSWORD not in repr(calls)