from __future__ import annotations from datetime import datetime, timedelta, timezone import hashlib import sqlite3 import unittest from bank_importer import auth from bank_importer.db import connect, migrate, utc_now class AuthTestCase(unittest.TestCase): def setUp(self) -> None: self.connection = connect(":memory:") self.addCleanup(self.connection.close) migrate(self.connection) now = utc_now() with self.connection: self.connection.execute( "INSERT INTO companies (name, created_at, updated_at) VALUES ('甲公司', ?, ?)", (now, now), ) self.company_id = int(self.connection.execute("SELECT id FROM companies").fetchone()["id"]) def create_company_user(self, username: str = "cashier-a", password: str = "Init1234") -> int: return auth.create_user( self.connection, username, password, "company", company_id=self.company_id ) def create_admin(self, username: str = "group-admin", password: str = "Admin1234") -> int: return auth.create_user(self.connection, username, password, "admin") class PasswordHashTests(unittest.TestCase): def test_hash_format_and_verify_roundtrip(self) -> None: stored = auth.hash_password("Secret123") scheme, iterations, salt_hex, hash_hex = stored.split("$") self.assertEqual("pbkdf2_sha256", scheme) self.assertEqual(str(auth.PBKDF2_ITERATIONS), iterations) self.assertEqual(32, len(salt_hex)) self.assertEqual(64, len(hash_hex)) self.assertTrue(auth.verify_password("Secret123", stored)) def test_wrong_password_is_rejected(self) -> None: stored = auth.hash_password("Secret123") self.assertFalse(auth.verify_password("Secret124", stored)) def test_same_password_gets_different_salts(self) -> None: self.assertNotEqual(auth.hash_password("Secret123"), auth.hash_password("Secret123")) def test_malformed_stored_hash_is_rejected(self) -> None: for stored in ("", "plain", "pbkdf2_sha256$x$y$z", "bcrypt$1$aa$bb"): with self.subTest(stored=stored): self.assertFalse(auth.verify_password("Secret123", stored)) class PasswordPolicyTests(unittest.TestCase): def test_policy_rejects_short_username_like_and_single_charset(self) -> None: self.assertIsNotNone(auth.validate_password_policy("Ab1", "cashier")) self.assertIsNotNone(auth.validate_password_policy("Cashier99", "cashier99")) self.assertIsNotNone(auth.validate_password_policy("abcdefgh", "cashier")) self.assertIsNotNone(auth.validate_password_policy("12345678", "cashier")) self.assertIsNone(auth.validate_password_policy("GoodPass1", "cashier")) def test_initial_password_generator_guarantees_charset(self) -> None: for _ in range(50): password = auth.generate_initial_password() self.assertEqual(12, len(password)) self.assertTrue(any(char.isupper() for char in password)) self.assertTrue(any(char.islower() for char in password)) self.assertTrue(any(char.isdigit() for char in password)) self.assertIsNone(auth.validate_password_policy(auth.generate_initial_password(), "x")) def test_initial_password_exclude_never_equals_username(self) -> None: for _ in range(50): password = auth.generate_initial_password(exclude="Cashier99") self.assertNotEqual(password.lower(), "cashier99") class CreateUserTests(AuthTestCase): def test_company_role_requires_company(self) -> None: with self.assertRaises(ValueError): auth.create_user(self.connection, "cashier-x", "Init1234", "company") def test_company_must_exist(self) -> None: with self.assertRaises(ValueError): auth.create_user(self.connection, "cashier-x", "Init1234", "company", company_id=999) def test_admin_cannot_bind_company(self) -> None: with self.assertRaises(ValueError): auth.create_user( self.connection, "admin-x", "Init1234", "admin", company_id=self.company_id ) def test_invalid_role_rejected(self) -> None: with self.assertRaises(ValueError): auth.create_user(self.connection, "nobody", "Init1234", "superuser") def test_duplicate_username_rejected(self) -> None: self.create_company_user() with self.assertRaises(ValueError): self.create_company_user() def test_new_company_user_must_change_password_by_default(self) -> None: user_id = self.create_company_user() user = self.connection.execute( "SELECT must_change_password, status FROM users WHERE id = ?", (user_id,) ).fetchone() self.assertEqual(1, user["must_change_password"]) self.assertEqual("active", user["status"]) class AuthenticateTests(AuthTestCase): def test_success_returns_user_and_records_attempt(self) -> None: self.create_company_user() user, reason = auth.authenticate(self.connection, "cashier-a", "Init1234", "127.0.0.1") self.assertIsNotNone(user) self.assertIsNone(reason) attempt = self.connection.execute("SELECT success FROM login_attempts").fetchone() self.assertEqual(1, attempt["success"]) entry = self.connection.execute( "SELECT action FROM audit_log WHERE action = 'login_success'" ).fetchone() self.assertIsNotNone(entry) def test_bad_credentials_do_not_leak_which_part_failed(self) -> None: self.create_company_user() for username, password in (("cashier-a", "Wrong999"), ("no-such-user", "Init1234")): with self.subTest(username=username): user, reason = auth.authenticate(self.connection, username, password, "127.0.0.1") self.assertIsNone(user) self.assertEqual("bad_credentials", reason) def test_disabled_user_gets_disabled_reason(self) -> None: user_id = self.create_company_user() with self.connection: self.connection.execute( "UPDATE users SET status = 'disabled', updated_at = ? WHERE id = ?", (utc_now(), user_id), ) user, reason = auth.authenticate(self.connection, "cashier-a", "Init1234", "127.0.0.1") self.assertIsNone(user) self.assertEqual("disabled", reason) def test_five_failures_within_window_trigger_rate_limit(self) -> None: self.create_company_user() for _ in range(auth.RATE_LIMIT_MAX_FAILURES): user, reason = auth.authenticate(self.connection, "cashier-a", "Wrong999", "10.0.0.1") self.assertEqual("bad_credentials", reason) user, reason = auth.authenticate(self.connection, "cashier-a", "Init1234", "10.0.0.1") self.assertIsNone(user) self.assertEqual("rate_limited", reason) # A different IP is not limited. user, reason = auth.authenticate(self.connection, "cashier-a", "Init1234", "10.0.0.2") self.assertIsNotNone(user) def test_failures_outside_window_do_not_count(self) -> None: self.create_company_user() old = (datetime.now(timezone.utc) - timedelta(minutes=30)).isoformat() with self.connection: for _ in range(auth.RATE_LIMIT_MAX_FAILURES): self.connection.execute( "INSERT INTO login_attempts (username, ip, success, created_at) VALUES (?, ?, 0, ?)", ("cashier-a", "10.0.0.9", old), ) user, reason = auth.authenticate(self.connection, "cashier-a", "Init1234", "10.0.0.9") self.assertIsNotNone(user) self.assertIsNone(reason) class SessionTests(AuthTestCase): def test_create_and_resolve_roundtrip(self) -> None: user_id = self.create_company_user() token = auth.create_session(self.connection, user_id) user = auth.resolve_session(self.connection, token) self.assertIsNotNone(user) self.assertEqual(user_id, user["id"]) # Only the digest is stored, never the raw token. row = self.connection.execute("SELECT token_hash FROM sessions").fetchone() self.assertEqual(hashlib.sha256(token.encode()).hexdigest(), row["token_hash"]) self.assertNotIn(token, row["token_hash"]) def test_expired_session_is_rejected(self) -> None: user_id = self.create_company_user() token = auth.create_session(self.connection, user_id) past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat() with self.connection: self.connection.execute( "UPDATE sessions SET expires_at = ? WHERE token_hash = ?", (past, hashlib.sha256(token.encode()).hexdigest()), ) self.assertIsNone(auth.resolve_session(self.connection, token)) def test_revoked_session_is_rejected(self) -> None: user_id = self.create_company_user() token = auth.create_session(self.connection, user_id) auth.revoke_session(self.connection, token) self.assertIsNone(auth.resolve_session(self.connection, token)) def test_revoke_user_sessions_kills_all_sessions(self) -> None: user_id = self.create_company_user() first = auth.create_session(self.connection, user_id) second = auth.create_session(self.connection, user_id) auth.revoke_user_sessions(self.connection, user_id) self.assertIsNone(auth.resolve_session(self.connection, first)) self.assertIsNone(auth.resolve_session(self.connection, second)) def test_disabled_user_session_is_rejected(self) -> None: user_id = self.create_company_user() token = auth.create_session(self.connection, user_id) with self.connection: self.connection.execute( "UPDATE users SET status = 'disabled', updated_at = ? WHERE id = ?", (utc_now(), user_id), ) self.assertIsNone(auth.resolve_session(self.connection, token)) class ChangePasswordTests(AuthTestCase): def test_change_password_clears_flag_and_updates_hash(self) -> None: user_id = self.create_company_user() error = auth.change_password(self.connection, user_id, "Init1234", "NewPass99") self.assertIsNone(error) user = self.connection.execute( "SELECT password_hash, must_change_password FROM users WHERE id = ?", (user_id,) ).fetchone() self.assertEqual(0, user["must_change_password"]) self.assertTrue(auth.verify_password("NewPass99", user["password_hash"])) self.assertFalse(auth.verify_password("Init1234", user["password_hash"])) entry = self.connection.execute( "SELECT detail FROM audit_log WHERE action = 'password_change'" ).fetchone() self.assertIsNotNone(entry) self.assertNotIn("NewPass99", entry["detail"] or "") def test_wrong_old_password_rejected(self) -> None: user_id = self.create_company_user() error = auth.change_password(self.connection, user_id, "Wrong999", "NewPass99") self.assertIsNotNone(error) user = self.connection.execute( "SELECT must_change_password FROM users WHERE id = ?", (user_id,) ).fetchone() self.assertEqual(1, user["must_change_password"]) def test_policy_violation_rejected(self) -> None: user_id = self.create_company_user() error = auth.change_password(self.connection, user_id, "Init1234", "short") self.assertIsNotNone(error) if __name__ == "__main__": unittest.main()