132 lines
4.5 KiB
Python
132 lines
4.5 KiB
Python
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[bytes, bytes]] = []
|
|
|
|
def record_compare(left: bytes, right: bytes) -> 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.encode("utf-8"), ADMIN_ID.encode("utf-8")),
|
|
(ADMIN_PASSWORD.encode("utf-8"), ADMIN_PASSWORD.encode("utf-8")),
|
|
]
|
|
|
|
comparisons.clear()
|
|
assert authentication.verify_credentials(credentials, "wrong-id", "wrong-password") is False
|
|
assert comparisons == [
|
|
(b"wrong-id", ADMIN_ID.encode("utf-8")),
|
|
(b"wrong-password", ADMIN_PASSWORD.encode("utf-8")),
|
|
]
|
|
|
|
|
|
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)
|