from __future__ import annotations import unittest from types import SimpleNamespace from unittest.mock import MagicMock, patch from fastapi import HTTPException from govoplan_access.backend.api.v1 import auth from govoplan_access.backend.security.passwords import ( DUMMY_PASSWORD_HASH, verify_password, ) from govoplan_core.api.v1.schemas import LoginRequest class LoginSecurityTests(unittest.TestCase): @staticmethod def _session_with_account(account: object | None) -> MagicMock: session = MagicMock() session.query.return_value.filter.return_value.one_or_none.return_value = ( account ) return session def test_absent_identity_verifies_a_valid_dummy_hash_before_generic_failure( self, ) -> None: self.assertTrue(verify_password("not-a-user-password", DUMMY_PASSWORD_HASH)) payload = LoginRequest( email="missing@example.test", password="attempted-password" ) with patch.object(auth, "verify_password", return_value=True) as verifier: with self.assertRaises(HTTPException) as raised: auth._resolve_login_user(self._session_with_account(None), payload) verifier.assert_called_once_with(payload.password, DUMMY_PASSWORD_HASH) self.assertEqual(raised.exception.status_code, 401) self.assertEqual(raised.exception.detail, "Invalid login") def test_present_identity_verifies_account_hash_before_same_generic_failure( self, ) -> None: account_hash = "pbkdf2_sha256$260000$account-salt$account-digest" account = SimpleNamespace(password_hash=account_hash) payload = LoginRequest(email="known@example.test", password="wrong-password") with patch.object(auth, "verify_password", return_value=False) as verifier: with self.assertRaises(HTTPException) as raised: auth._resolve_login_user(self._session_with_account(account), payload) # type: ignore[arg-type] verifier.assert_called_once_with(payload.password, account_hash) self.assertEqual(raised.exception.status_code, 401) self.assertEqual(raised.exception.detail, "Invalid login") def test_passwordless_account_cannot_authenticate_with_dummy_password(self) -> None: account = SimpleNamespace(password_hash=None) payload = LoginRequest( email="passwordless@example.test", password="not-a-user-password" ) with self.assertRaises(HTTPException) as raised: auth._resolve_login_user(self._session_with_account(account), payload) # type: ignore[arg-type] self.assertEqual(raised.exception.status_code, 401) self.assertEqual(raised.exception.detail, "Invalid login") def test_account_without_active_membership_uses_same_generic_failure(self) -> None: account = SimpleNamespace( id="account-1", password_hash="pbkdf2_sha256$260000$account-salt$account-digest", ) session = self._session_with_account(account) session.query.return_value.join.return_value.filter.return_value.order_by.return_value.first.return_value = None payload = LoginRequest(email="known@example.test", password="correct-password") with patch.object(auth, "verify_password", return_value=True): with self.assertRaises(HTTPException) as raised: auth._resolve_login_user(session, payload) # type: ignore[arg-type] self.assertEqual(raised.exception.status_code, 401) self.assertEqual(raised.exception.detail, "Invalid login") if __name__ == "__main__": unittest.main()