cteward-ng/cteward_ng/tests/test_auth.py
2026-06-06 22:21:20 +02:00

223 lines
7.7 KiB
Python

"""Tests for authentication provider (auth.py).
Replaces test/authprovider-authorize.js,
test/authprovider-check_password.js,
test/authprovider-find_botuser.js,
test/authprovider-find_ldapuser.js.
"""
import base64
from unittest.mock import Mock, patch
import pytest
from cteward_ng.auth import (
check_password,
_parse_basic_auth,
find_botuser,
find_ldapuser,
authorize,
)
# ── helpers ───────────────────────────────────────────────────────
def _make_request(headers=None, query_string='', view_args=None):
"""Create a minimal Flask-like request mock."""
req = Mock()
req.headers = headers or {}
req.args = Mock()
req.args.get = Mock(return_value=None)
req.view_args = view_args
return req
def _basic_header(user, passwd):
token = base64.b64encode(f"{user}:{passwd}".encode()).decode()
return {'Authorization': f'Basic {token}'}
# ── check_password ───────────────────────────────────────────────
class TestCheckPassword:
def test_plaintext_match(self):
assert check_password('mypass', 'mypass') is True
def test_plaintext_mismatch(self):
assert check_password('wrong', 'mypass') is False
@patch('cteward_ng.auth.apr_md5_crypt.verify')
def test_apr1_match(self, mock_verify):
mock_verify.return_value = True
assert check_password('secret', '$apr1$salt$hash') is True
@patch('cteward_ng.auth.apr_md5_crypt.verify')
def test_apr1_mismatch(self, mock_verify):
mock_verify.return_value = False
assert check_password('wrong', '$apr1$salt$hash') is False
def test_unsupported_algo(self):
with pytest.raises(ValueError, match="Unsupported"):
check_password('x', '$sha512$hash')
# ── _parse_basic_auth ────────────────────────────────────────────
class TestParseBasicAuth:
def test_valid(self):
req = _make_request(headers=_basic_header('alice', 'pass'))
result = _parse_basic_auth(req)
assert result == ('alice', 'pass')
def test_missing_header(self):
req = _make_request(headers={})
assert _parse_basic_auth(req) is None
def test_not_basic_scheme(self):
req = _make_request(headers={'Authorization': 'Bearer token'})
assert _parse_basic_auth(req) is None
# ── find_botuser ────────────────────────────────────────────────
class TestFindBotuser:
def test_no_auth_header(self):
ctx = {
'request': _make_request(headers={}),
'config': {'auth': {'bots': {}}},
}
find_botuser(ctx)
assert ctx.get('username') is None
def test_known_bot_correct_password(self):
ctx = {
'request': _make_request(headers=_basic_header('testbot', 'secret')),
'config': {'auth': {'bots': {'testbot': 'secret'}}},
}
find_botuser(ctx)
assert ctx['username'] == 'testbot'
def test_known_bot_wrong_password_aborts(self):
ctx = {
'request': _make_request(headers=_basic_header('testbot', 'wrong')),
'config': {'auth': {'bots': {'testbot': 'secret'}}},
}
with pytest.raises(Exception): # Flask abort raises HTTPException
find_botuser(ctx)
def test_unknown_user_passes_through(self):
ctx = {
'request': _make_request(headers=_basic_header('nobody', 'pass')),
'config': {'auth': {'bots': {'testbot': 'secret'}}},
}
find_botuser(ctx)
assert ctx.get('username') is None
def test_already_authenticated_short_circuits(self):
ctx = {
'username': 'already_set',
'request': _make_request(headers=_basic_header('other', 'pass')),
'config': {'auth': {'bots': {}}},
}
find_botuser(ctx)
assert ctx['username'] == 'already_set'
# ── find_ldapuser ───────────────────────────────────────────────
class TestFindLdapuser:
def test_already_authenticated_short_circuits(self):
ctx = {
'username': 'botuser',
'request': _make_request(headers=_basic_header('someone', 'pass')),
'config': {},
}
find_ldapuser(ctx)
assert ctx['username'] == 'botuser'
def test_no_ldap_config_passes_through(self):
ctx = {
'request': _make_request(headers=_basic_header('user', 'pass')),
'config': {},
}
find_ldapuser(ctx)
assert ctx.get('username') is None
@patch('cteward_ng.auth._authenticate_ldap')
def test_ldap_success(self, mock_auth):
mock_auth.return_value = True
ctx = {
'request': _make_request(headers=_basic_header('alice', 'pass')),
'config': {'ldap': {'url': 'ldap://localhost'}},
}
find_ldapuser(ctx)
assert ctx['username'] == 'alice'
@patch('cteward_ng.auth._authenticate_ldap')
def test_ldap_failure_passes_through(self, mock_auth):
mock_auth.return_value = False
ctx = {
'request': _make_request(headers=_basic_header('alice', 'bad')),
'config': {'ldap': {'url': 'ldap://localhost'}},
}
find_ldapuser(ctx)
assert ctx.get('username') is None
# ── authorize (pipeline) ────────────────────────────────────────
class TestAuthorize:
@patch('cteward_ng.auth.find_botuser')
@patch('cteward_ng.auth.find_ldapuser')
@patch('cteward_ng.auth.find_config_flags')
@patch('cteward_ng.auth.find_database_flags')
@patch('cteward_ng.auth.impersonate')
@patch('cteward_ng.auth.effective_permissions')
def test_full_pipeline(
self, mock_eff, mock_imp, mock_dbf, mock_cfg, mock_ldap, mock_bot,
):
"""Smoke-test: all pipeline stages are called in order."""
call_log = []
def capture(name):
def inner(ctx):
call_log.append(name)
ctx['username'] = 'testuser'
ctx['flags'] = ['_anonymous_', '_bot_']
return ctx
return inner
mock_bot.side_effect = capture('bot')
mock_ldap.side_effect = capture('ldap')
mock_cfg.side_effect = capture('cfg')
mock_dbf.side_effect = capture('dbf')
mock_imp.side_effect = capture('imp')
mock_eff.side_effect = capture('eff')
ctx = {
'request': _make_request(headers=_basic_header('testbot', 'pw')),
'config': {'auth': {'bots': {'testbot': 'pw'}, 'flags': {}}},
'permissions': {'_bot_': {'query': None, 'level': 0}},
}
authorize(ctx)
assert call_log == ['bot', 'ldap', 'cfg', 'dbf', 'imp', 'eff']
def test_no_credentials_no_anonymous_aborts(self):
ctx = {
'request': _make_request(headers={}),
'config': {},
'permissions': {'_board_': {'query': None, 'level': 0}},
}
with pytest.raises(Exception):
authorize(ctx)
def test_no_credentials_with_anonymous_succeeds(self):
ctx = {
'request': _make_request(headers={}),
'config': {},
'permissions': {
'_anonymous_': {'query': None, 'level': 3},
},
}
authorize(ctx)
assert ctx['username'] == 'anonymous'