88 lines
3.6 KiB
Python
88 lines
3.6 KiB
Python
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()
|