File size: 8,967 Bytes
108e227 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 | """Tests for the BasicAuthProvider plugin (username/password, scrypt, signed
tokens).
Loads the plugin module directly (it's a bundled backend plugin, not on the
import path as a package) and exercises the provider behaviour + the
``register(ctx)`` entry point's config/env resolution and skip reasons.
"""
from __future__ import annotations
import secrets
from unittest.mock import MagicMock
import pytest
import plugins.dashboard_auth.basic as basic_plugin
from hermes_cli.dashboard_auth import (
InvalidCredentialsError,
RefreshExpiredError,
assert_protocol_compliance,
)
@pytest.fixture(scope="module")
def basic():
return basic_plugin
@pytest.fixture(autouse=True)
def _clear_basic_env(monkeypatch):
for var in (
"HERMES_DASHBOARD_BASIC_AUTH_USERNAME",
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD",
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD_HASH",
"HERMES_DASHBOARD_BASIC_AUTH_SECRET",
"HERMES_DASHBOARD_BASIC_AUTH_TTL_SECONDS",
):
monkeypatch.delenv(var, raising=False)
# ---------------------------------------------------------------------------
# Hashing
# ---------------------------------------------------------------------------
class TestPasswordHashing:
def test_hash_then_verify_round_trips(self, basic):
h = basic.hash_password("hunter2")
assert h.startswith("scrypt$")
assert basic._verify_password("hunter2", h)
def test_wrong_password_fails(self, basic):
h = basic.hash_password("hunter2")
assert not basic._verify_password("wrong", h)
def test_malformed_hash_returns_false(self, basic):
assert not basic._verify_password("x", "not-a-valid-hash")
assert not basic._verify_password("x", "bcrypt$wrong$scheme")
def test_two_hashes_of_same_password_differ(self, basic):
# Distinct random salts → distinct encoded hashes.
assert basic.hash_password("pw") != basic.hash_password("pw")
# ---------------------------------------------------------------------------
# Provider behaviour
# ---------------------------------------------------------------------------
class TestProvider:
def _make(self, basic, **kw):
h = basic.hash_password("hunter2")
return basic.BasicAuthProvider(
username="admin",
password_hash=h,
secret=secrets.token_bytes(32),
**kw,
)
def test_protocol_compliant(self, basic):
assert assert_protocol_compliance(basic.BasicAuthProvider) is None
def test_supports_password_true(self, basic):
assert basic.BasicAuthProvider.supports_password is True
def test_login_mints_session(self, basic):
p = self._make(basic)
s = p.complete_password_login(username="admin", password="hunter2")
assert s.user_id == "admin"
assert s.provider == "basic"
assert s.access_token and s.refresh_token
def test_bad_credentials_raise(self, basic):
p = self._make(basic)
for u, pw in [("admin", "wrong"), ("ghost", "hunter2"), ("", "")]:
with pytest.raises(InvalidCredentialsError):
p.complete_password_login(username=u, password=pw)
def test_verify_round_trips_and_rejects_tamper(self, basic):
p = self._make(basic)
s = p.complete_password_login(username="admin", password="hunter2")
assert p.verify_session(access_token=s.access_token) is not None
assert p.verify_session(access_token="garbage") is None
def test_access_token_not_accepted_as_refresh(self, basic):
p = self._make(basic)
s = p.complete_password_login(username="admin", password="hunter2")
# A refresh token must not verify as an access token and vice
# versa — the ``kind`` claim is enforced.
assert p.verify_session(access_token=s.refresh_token) is None
with pytest.raises(RefreshExpiredError):
p.refresh_session(refresh_token=s.access_token)
def test_refresh_round_trips(self, basic):
p = self._make(basic)
s = p.complete_password_login(username="admin", password="hunter2")
r = p.refresh_session(refresh_token=s.refresh_token)
assert r.user_id == "admin"
assert p.verify_session(access_token=r.access_token) is not None
def test_cross_secret_token_does_not_verify(self, basic):
p1 = self._make(basic)
p2 = self._make(basic) # different random secret
s = p1.complete_password_login(username="admin", password="hunter2")
assert p2.verify_session(access_token=s.access_token) is None
def test_revoke_is_silent(self, basic):
p = self._make(basic)
p.revoke_session(refresh_token="anything") # must not raise
def test_oauth_methods_raise_not_implemented(self, basic):
p = self._make(basic)
with pytest.raises(NotImplementedError):
p.start_login(redirect_uri="https://x/auth/callback")
with pytest.raises(NotImplementedError):
p.complete_login(
code="c", state="s", code_verifier="v", redirect_uri="r"
)
def test_construction_validates_inputs(self, basic):
good_hash = basic.hash_password("pw")
with pytest.raises(ValueError):
basic.BasicAuthProvider(
username="", password_hash=good_hash, secret=b"x" * 32
)
with pytest.raises(ValueError):
basic.BasicAuthProvider(
username="admin", password_hash="", secret=b"x" * 32
)
with pytest.raises(ValueError):
basic.BasicAuthProvider(
username="admin", password_hash=good_hash, secret=b"short"
)
# ---------------------------------------------------------------------------
# register() entry point — config/env resolution + skip reasons
# ---------------------------------------------------------------------------
class TestRegister:
def test_skips_when_no_username(self, basic, monkeypatch):
monkeypatch.setattr(basic, "_load_config_basic_auth_section", lambda: {})
ctx = MagicMock()
basic.register(ctx)
ctx.register_dashboard_auth_provider.assert_not_called()
assert "username" in basic.LAST_SKIP_REASON
def test_registers_with_env_plaintext_password(self, basic, monkeypatch):
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_USERNAME", "admin")
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "hunter2")
monkeypatch.setattr(basic, "_load_config_basic_auth_section", lambda: {})
ctx = MagicMock()
basic.register(ctx)
ctx.register_dashboard_auth_provider.assert_called_once()
provider = ctx.register_dashboard_auth_provider.call_args.args[0]
assert isinstance(provider, basic.BasicAuthProvider)
# Round-trips: the registered provider authenticates the env creds.
s = provider.complete_password_login(username="admin", password="hunter2")
assert s.user_id == "admin"
assert basic.LAST_SKIP_REASON == ""
def test_env_password_overrides_config(self, basic, monkeypatch):
cfg_hash = basic.hash_password("config-pw")
monkeypatch.setattr(
basic,
"_load_config_basic_auth_section",
lambda: {"username": "admin", "password_hash": cfg_hash},
)
# Env plaintext should win over the config hash.
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "env-pw")
ctx = MagicMock()
basic.register(ctx)
provider = ctx.register_dashboard_auth_provider.call_args.args[0]
# env password works ...
assert provider.complete_password_login(
username="admin", password="env-pw"
)
# ... and the config password no longer does.
with pytest.raises(InvalidCredentialsError):
provider.complete_password_login(username="admin", password="config-pw")
def test_explicit_secret_makes_sessions_portable(self, basic, monkeypatch):
# Two providers built from the SAME explicit secret accept each
# other's tokens (the restart-/multi-worker-survival contract).
shared = secrets.token_bytes(32).hex()
monkeypatch.setattr(basic, "_load_config_basic_auth_section", lambda: {})
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_USERNAME", "admin")
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", "hunter2")
monkeypatch.setenv("HERMES_DASHBOARD_BASIC_AUTH_SECRET", shared)
ctx1, ctx2 = MagicMock(), MagicMock()
basic.register(ctx1)
basic.register(ctx2)
p1 = ctx1.register_dashboard_auth_provider.call_args.args[0]
p2 = ctx2.register_dashboard_auth_provider.call_args.args[0]
s = p1.complete_password_login(username="admin", password="hunter2")
assert p2.verify_session(access_token=s.access_token) is not None
|