359 lines
16 KiB
Python
359 lines
16 KiB
Python
# #region Test.AuthService [C:3] [TYPE Module] [SEMANTICS test,auth,service]
|
|
# @BRIEF Verify AuthService contracts — authenticate_user, create_session, provision_adfs_user.
|
|
# @RELATION BINDS_TO -> [Services.AuthServiceClass]
|
|
# @TEST_EDGE: user_not_found -> authenticate_user returns None
|
|
# @TEST_EDGE: user_inactive -> authenticate_user returns None
|
|
# @TEST_EDGE: wrong_password -> authenticate_user returns None
|
|
# @TEST_EDGE: adfs_new_user -> provision_adfs_user creates User and commits
|
|
# @TEST_EDGE: adfs_existing_user -> provision_adfs_user syncs roles without creating
|
|
# @TEST_EDGE: adfs_no_upn_fallback_email -> username derived from email when upn absent
|
|
|
|
from pathlib import Path
|
|
import sys
|
|
from contextlib import contextmanager
|
|
from unittest.mock import MagicMock, call, patch
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
|
|
|
import pytest
|
|
|
|
|
|
# ── Helpers (C1 — anchor pair only) ──
|
|
|
|
# #region Test.AuthService.MakeUser
|
|
def _make_user(**kwargs):
|
|
defaults = {
|
|
"id": "user-001",
|
|
"username": "alice",
|
|
"email": "alice@example.com",
|
|
"password_hash": "$2b$12$FAKEHASH",
|
|
"is_active": True,
|
|
"roles": [],
|
|
"last_login": None,
|
|
}
|
|
defaults.update(kwargs)
|
|
user = MagicMock()
|
|
for k, v in defaults.items():
|
|
setattr(user, k, v)
|
|
return user
|
|
# #endregion Test.AuthService.MakeUser
|
|
|
|
# #region Test.AuthService.MakeRole
|
|
def _make_role(name: str):
|
|
role = MagicMock()
|
|
role.name = name
|
|
return role
|
|
# #endregion Test.AuthService.MakeRole
|
|
|
|
# #region Test.AuthService.NoopBeliefScope
|
|
@contextmanager
|
|
def _noop_belief_scope(_id):
|
|
yield
|
|
# #endregion Test.AuthService.NoopBeliefScope
|
|
|
|
|
|
# ── Fixtures ──
|
|
|
|
@pytest.fixture()
|
|
def mock_db():
|
|
db = MagicMock()
|
|
db.commit = MagicMock()
|
|
db.refresh = MagicMock()
|
|
db.add = MagicMock()
|
|
return db
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════
|
|
# authenticate_user
|
|
# ════════════════════════════════════════════════════════════════
|
|
|
|
class TestAuthenticateUser:
|
|
"""authenticate_user — credential validation and account-state gating."""
|
|
|
|
# #region Test.AuthService.TestAuthenticateUserSuccess
|
|
# @BRIEF Active user with correct password returns User and updates last_login.
|
|
@patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope)
|
|
@patch("src.services.auth_service.verify_password", return_value=True)
|
|
@patch("src.services.auth_service.AuthRepository")
|
|
def test_authenticate_user_success(self, MockRepo, _vp, _bs, mock_db):
|
|
from src.services.auth_service import AuthService
|
|
|
|
expected_user = _make_user(username="alice", is_active=True)
|
|
instance = MockRepo.return_value
|
|
instance.get_user_by_username.return_value = expected_user
|
|
|
|
svc = AuthService(mock_db)
|
|
result = svc.authenticate_user("alice", "correct-password")
|
|
|
|
assert result is expected_user
|
|
assert result.last_login is not None
|
|
mock_db.commit.assert_called_once()
|
|
mock_db.refresh.assert_called_once_with(expected_user)
|
|
# #endregion Test.AuthService.TestAuthenticateUserSuccess
|
|
|
|
# #region Test.AuthService.TestAuthenticateUserNotFound
|
|
# @BRIEF Returns None when username does not exist in the database.
|
|
@patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope)
|
|
@patch("src.services.auth_service.verify_password")
|
|
@patch("src.services.auth_service.AuthRepository")
|
|
def test_authenticate_user_not_found(self, MockRepo, mock_vp, _bs, mock_db):
|
|
from src.services.auth_service import AuthService
|
|
|
|
MockRepo.return_value.get_user_by_username.return_value = None
|
|
|
|
svc = AuthService(mock_db)
|
|
result = svc.authenticate_user("ghost", "pw")
|
|
|
|
assert result is None
|
|
mock_vp.assert_not_called()
|
|
# #endregion Test.AuthService.TestAuthenticateUserNotFound
|
|
|
|
# #region Test.AuthService.TestAuthenticateUserInactive
|
|
# @BRIEF Returns None when user exists but is_active is False.
|
|
@patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope)
|
|
@patch("src.services.auth_service.verify_password")
|
|
@patch("src.services.auth_service.AuthRepository")
|
|
def test_authenticate_user_inactive(self, MockRepo, mock_vp, _bs, mock_db):
|
|
from src.services.auth_service import AuthService
|
|
|
|
inactive_user = _make_user(is_active=False)
|
|
MockRepo.return_value.get_user_by_username.return_value = inactive_user
|
|
|
|
svc = AuthService(mock_db)
|
|
result = svc.authenticate_user("alice", "pw")
|
|
|
|
assert result is None
|
|
mock_vp.assert_not_called()
|
|
# #endregion Test.AuthService.TestAuthenticateUserInactive
|
|
|
|
# #region Test.AuthService.TestAuthenticateUserWrongPassword
|
|
# @BRIEF Returns None when password hash verification fails.
|
|
@patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope)
|
|
@patch("src.services.auth_service.verify_password", return_value=False)
|
|
@patch("src.services.auth_service.AuthRepository")
|
|
def test_authenticate_user_wrong_password(self, MockRepo, _vp, _bs, mock_db):
|
|
from src.services.auth_service import AuthService
|
|
|
|
active_user = _make_user(is_active=True)
|
|
MockRepo.return_value.get_user_by_username.return_value = active_user
|
|
|
|
svc = AuthService(mock_db)
|
|
result = svc.authenticate_user("alice", "wrong-password")
|
|
|
|
assert result is None
|
|
mock_db.commit.assert_not_called()
|
|
# #endregion Test.AuthService.TestAuthenticateUserWrongPassword
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════
|
|
# create_session
|
|
# ════════════════════════════════════════════════════════════════
|
|
|
|
class TestCreateSession:
|
|
"""create_session — JWT issuance with role-based scopes."""
|
|
|
|
# #region Test.AuthService.TestCreateSessionReturnsBearerToken
|
|
# @BRIEF Returns dict with access_token and token_type='bearer'; creates logical session row.
|
|
@patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope)
|
|
@patch("src.services.auth_service.create_access_token", return_value="jwt-token-abc")
|
|
@patch("src.services.auth_service.AuthRepository")
|
|
def test_create_session_returns_bearer_token(self, MockRepo, mock_jwt, _bs, mock_db):
|
|
from src.services.auth_service import AuthService
|
|
|
|
role_admin = _make_role("admin")
|
|
role_editor = _make_role("editor")
|
|
user = _make_user(username="alice", roles=[role_admin, role_editor])
|
|
|
|
svc = AuthService(mock_db)
|
|
session = svc.create_session(user)
|
|
|
|
assert session == {"access_token": "jwt-token-abc", "token_type": "bearer"}
|
|
# Token carries the logical session id
|
|
_, kwargs = mock_jwt.call_args
|
|
assert "session_id" in kwargs
|
|
assert kwargs["session_id"]
|
|
assert kwargs["expires_delta"] is not None
|
|
# SessionActivity row created and committed
|
|
added = mock_db.add.call_args[0][0]
|
|
assert added.sid == kwargs["session_id"]
|
|
assert added.user_id == "user-001"
|
|
assert added.is_revoked is False
|
|
mock_db.commit.assert_called_once()
|
|
# #endregion Test.AuthService.TestCreateSessionReturnsBearerToken
|
|
|
|
# #region Test.AuthService.TestCreateSessionNoRoles
|
|
# @BRIEF User with no roles produces empty scopes list.
|
|
@patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope)
|
|
@patch("src.services.auth_service.create_access_token", return_value="jwt-empty")
|
|
@patch("src.services.auth_service.AuthRepository")
|
|
def test_create_session_no_roles(self, MockRepo, mock_jwt, _bs, mock_db):
|
|
from src.services.auth_service import AuthService
|
|
|
|
user = _make_user(username="bob", roles=[])
|
|
|
|
svc = AuthService(mock_db)
|
|
session = svc.create_session(user)
|
|
|
|
assert session["token_type"] == "bearer"
|
|
mock_jwt.assert_called_once_with(
|
|
data={"sub": "bob", "scopes": []},
|
|
expires_delta=mock_jwt.call_args.kwargs["expires_delta"],
|
|
session_id=mock_jwt.call_args.kwargs["session_id"],
|
|
)
|
|
# #endregion Test.AuthService.TestCreateSessionNoRoles
|
|
|
|
# #region Test.AuthService.TestRenewSession
|
|
# @BRIEF renew_session issues a replacement token with the same sid and rolls expires_at.
|
|
@patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope)
|
|
@patch("src.services.auth_service.create_access_token", return_value="jwt-renewed")
|
|
def test_renew_session_same_sid(self, mock_jwt, _bs, mock_db):
|
|
from src.services.auth_service import AuthService
|
|
|
|
role_admin = _make_role("admin")
|
|
user = _make_user(username="alice", roles=[role_admin])
|
|
activity = MagicMock()
|
|
activity.expires_at = None
|
|
mock_db.query.return_value.filter.return_value.first.return_value = activity
|
|
|
|
svc = AuthService(mock_db)
|
|
session = svc.renew_session(user, "session-xyz")
|
|
|
|
assert session == {"access_token": "jwt-renewed", "token_type": "bearer"}
|
|
_, kwargs = mock_jwt.call_args
|
|
assert kwargs["session_id"] == "session-xyz"
|
|
assert kwargs["data"] == {"sub": "alice", "scopes": ["admin"]}
|
|
assert activity.expires_at is not None
|
|
mock_db.commit.assert_called_once()
|
|
# #endregion Test.AuthService.TestRenewSession
|
|
|
|
# #region Test.AuthService.TestRevokeSession
|
|
# @BRIEF revoke_session marks the logical session revoked and commits.
|
|
@patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope)
|
|
def test_revoke_session_marks_revoked(self, _bs, mock_db):
|
|
from src.services.auth_service import AuthService
|
|
|
|
activity = MagicMock()
|
|
activity.is_revoked = False
|
|
mock_db.query.return_value.filter.return_value.first.return_value = activity
|
|
|
|
svc = AuthService(mock_db)
|
|
svc.revoke_session("session-xyz")
|
|
|
|
assert activity.is_revoked is True
|
|
mock_db.commit.assert_called_once()
|
|
# #endregion Test.AuthService.TestRevokeSession
|
|
|
|
# #region Test.AuthService.TestRevokeSessionAlreadyRevoked
|
|
# @BRIEF revoke_session is idempotent for already-revoked sessions.
|
|
@patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope)
|
|
def test_revoke_session_idempotent(self, _bs, mock_db):
|
|
from src.services.auth_service import AuthService
|
|
|
|
activity = MagicMock()
|
|
activity.is_revoked = True
|
|
mock_db.query.return_value.filter.return_value.first.return_value = activity
|
|
|
|
svc = AuthService(mock_db)
|
|
svc.revoke_session("session-xyz")
|
|
|
|
mock_db.commit.assert_not_called()
|
|
# #endregion Test.AuthService.TestRevokeSessionAlreadyRevoked
|
|
|
|
|
|
# ════════════════════════════════════════════════════════════════
|
|
# provision_adfs_user
|
|
# ════════════════════════════════════════════════════════════════
|
|
|
|
class TestProvisionAdfsUser:
|
|
"""provision_adfs_user — JIT provisioning and role synchronization."""
|
|
|
|
# #region Test.AuthService.TestProvisionNewAdfsUser
|
|
# @BRIEF Creates new User when username not found, sets ADFS fields, commits.
|
|
@patch("src.services.auth_service.log_security_event")
|
|
@patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope)
|
|
@patch("src.services.auth_service.AuthRepository")
|
|
def test_provision_new_adfs_user(self, MockRepo, _bs, mock_log, mock_db):
|
|
from src.services.auth_service import AuthService
|
|
|
|
MockRepo.return_value.get_user_by_username.return_value = None
|
|
mapped_role = _make_role("ad-users")
|
|
MockRepo.return_value.get_roles_by_ad_groups.return_value = [mapped_role]
|
|
|
|
svc = AuthService(mock_db)
|
|
user_info = {"upn": "carol@corp.com", "email": "carol@corp.com", "name": "Carol", "groups": ["AD-Users"]}
|
|
result = svc.provision_adfs_user(user_info)
|
|
|
|
mock_db.add.assert_called_once()
|
|
new_user = mock_db.add.call_args[0][0]
|
|
assert new_user.username == "carol@corp.com"
|
|
assert new_user.auth_source == "ADFS"
|
|
assert new_user.is_ad_user is True
|
|
assert new_user.is_active is True
|
|
assert new_user.roles == [mapped_role]
|
|
mock_db.commit.assert_called_once()
|
|
mock_db.refresh.assert_called_once()
|
|
mock_log.assert_called_once_with("USER_PROVISIONED", "carol@corp.com", {"source": "ADFS"})
|
|
# #endregion Test.AuthService.TestProvisionNewAdfsUser
|
|
|
|
# #region Test.AuthService.TestProvisionExistingAdfsUser
|
|
# @BRIEF Existing user gets roles synced without creating a new record.
|
|
@patch("src.services.auth_service.log_security_event")
|
|
@patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope)
|
|
@patch("src.services.auth_service.AuthRepository")
|
|
def test_provision_existing_adfs_user(self, MockRepo, _bs, mock_log, mock_db):
|
|
from src.services.auth_service import AuthService
|
|
|
|
existing = _make_user(username="dave@corp.com")
|
|
MockRepo.return_value.get_user_by_username.return_value = existing
|
|
role1 = _make_role("analysts")
|
|
MockRepo.return_value.get_roles_by_ad_groups.return_value = [role1]
|
|
|
|
svc = AuthService(mock_db)
|
|
result = svc.provision_adfs_user({"upn": "dave@corp.com", "groups": ["AD-Analysts"]})
|
|
|
|
mock_db.add.assert_not_called()
|
|
mock_log.assert_not_called()
|
|
assert existing.roles == [role1]
|
|
assert existing.last_login is not None
|
|
mock_db.commit.assert_called_once()
|
|
# #endregion Test.AuthService.TestProvisionExistingAdfsUser
|
|
|
|
# #region Test.AuthService.TestProvisionAdfsUserEmailFallback
|
|
# @BRIEF Uses 'email' claim as username when 'upn' is absent.
|
|
@patch("src.services.auth_service.log_security_event")
|
|
@patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope)
|
|
@patch("src.services.auth_service.AuthRepository")
|
|
def test_provision_adfs_user_email_fallback(self, MockRepo, _bs, mock_log, mock_db):
|
|
from src.services.auth_service import AuthService
|
|
|
|
MockRepo.return_value.get_user_by_username.return_value = None
|
|
MockRepo.return_value.get_roles_by_ad_groups.return_value = []
|
|
|
|
svc = AuthService(mock_db)
|
|
result = svc.provision_adfs_user({"email": "eve@corp.com", "groups": []})
|
|
|
|
new_user = mock_db.add.call_args[0][0]
|
|
assert new_user.username == "eve@corp.com"
|
|
# #endregion Test.AuthService.TestProvisionAdfsUserEmailFallback
|
|
|
|
# #region Test.AuthService.TestProvisionAdfsUserNoGroups
|
|
# @BRIEF Empty groups list results in empty roles and no error.
|
|
@patch("src.services.auth_service.log_security_event")
|
|
@patch("src.services.auth_service.belief_scope", side_effect=_noop_belief_scope)
|
|
@patch("src.services.auth_service.AuthRepository")
|
|
def test_provision_adfs_user_no_groups(self, MockRepo, _bs, mock_log, mock_db):
|
|
from src.services.auth_service import AuthService
|
|
|
|
existing = _make_user(username="frank@corp.com")
|
|
MockRepo.return_value.get_user_by_username.return_value = existing
|
|
MockRepo.return_value.get_roles_by_ad_groups.return_value = []
|
|
|
|
svc = AuthService(mock_db)
|
|
result = svc.provision_adfs_user({"upn": "frank@corp.com"})
|
|
|
|
assert existing.roles == []
|
|
mock_db.commit.assert_called_once()
|
|
# #endregion Test.AuthService.TestProvisionAdfsUserNoGroups
|
|
|
|
# #endregion Test.AuthService
|