from datetime import datetime, timedelta from flask import Flask import pytest from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool from werkzeug.security import generate_password_hash import auth import services.permission_service as permission_service_module from database.permission_models import UserPermission from database.user_models import LoginHistory, User from services.auth_identity_service import ( build_auth_identity_runtime_readback, required_roles_for_request, resolve_auth_identity_mode, resolve_client_ip, ) from services.user_service import UserService from services.permission_service import PermissionService @pytest.fixture def identity_store(): engine = create_engine( "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) User.__table__.create(engine) LoginHistory.__table__.create(engine) UserPermission.__table__.create(engine) return sessionmaker(bind=engine) @pytest.fixture def identity_user(identity_store): db_session = identity_store() now = datetime.now() user = User( username="operator-admin", password_hash=generate_password_hash("DatabasePass9!", method="pbkdf2:sha256"), role="admin", display_name="Operator Admin", is_active=True, password_changed_at=now, created_at=now, updated_at=now, ) db_session.add(user) db_session.commit() user_id = user.id db_session.close() return user_id @pytest.fixture def identity_app(monkeypatch, identity_store, identity_user): monkeypatch.setenv("MOMO_AUTH_IDENTITY_MODE", "hybrid") monkeypatch.setenv("MOMO_TRUSTED_PROXY_CIDRS", "127.0.0.1/32,::1/128") monkeypatch.setattr(auth, "LOGIN_PASSWORD", "LegacyPass9!") monkeypatch.setattr(auth, "get_identity_session", identity_store) app = Flask(__name__, template_folder=str(auth.os.path.join(auth.os.path.dirname(__file__), "..", "templates"))) app.config.update( TESTING=True, SECRET_KEY="identity-test-secret", SESSION_COOKIE_SECURE=False, ) app.jinja_env.globals["csrf_token"] = lambda: "test-csrf" app.add_url_rule( "/", endpoint="dashboard.index", view_func=lambda: "dashboard", ) @app.route("/protected") @auth.login_required def protected(): return "protected" auth.init_auth_routes(app) return app def test_proxy_headers_are_ignored_from_untrusted_peer(): assert resolve_client_ip( "203.0.113.9", "198.51.100.7", trusted_proxy_cidrs="127.0.0.1/32", ) == "203.0.113.9" assert resolve_client_ip( "127.0.0.1", "198.51.100.7, 127.0.0.1", trusted_proxy_cidrs="127.0.0.1/32", ) == "198.51.100.7" def test_auth_mode_and_role_matrix_fail_closed(): assert resolve_auth_identity_mode("auto") == "auto" with pytest.raises(ValueError): resolve_auth_identity_mode("invalid") assert required_roles_for_request("GET", "vendor.list", "vendor") == ( "admin", "manager", "user", ) assert required_roles_for_request("POST", "vendor.update", "vendor") == ( "admin", "manager", ) assert required_roles_for_request("GET", "cicd.status", "cicd") == ("admin",) def test_lockout_is_durable_across_database_sessions(identity_store): for _ in range(5): db_session = identity_store() assert UserService(db_session).record_login( None, "operator-admin", "198.51.100.20", "pytest", LoginHistory.STATUS_FAILED, "invalid_credentials", ) is not None db_session.close() db_session = identity_store() service = UserService(db_session) locked, remaining, count = service.get_lockout_status( "operator-admin", "198.51.100.20", max_attempts=5, reset_seconds=1800, lockout_seconds=300, ) assert locked is True assert remaining > 0 assert count == 5 service.record_login( None, "operator-admin", "198.51.100.20", "pytest", LoginHistory.STATUS_SUCCESS, "auth_source=database", ) locked, remaining, count = service.get_lockout_status( "operator-admin", "198.51.100.20", ) assert (locked, remaining, count) == (False, 0, 0) db_session.close() def test_database_identity_login_writes_audit_and_versioned_session( identity_app, identity_store, identity_user, ): client = identity_app.test_client() response = client.post( "/login", data={"username": "operator-admin", "password": "DatabasePass9!"}, ) assert response.status_code == 302 assert response.headers["Location"].endswith("/") with client.session_transaction() as current_session: assert current_session["user_id"] == identity_user assert current_session["role"] == "admin" assert current_session["auth_source"] == "database" assert current_session["identity_version"] db_session = identity_store() event = db_session.query(LoginHistory).order_by(LoginHistory.id.desc()).first() assert event.status == LoginHistory.STATUS_SUCCESS assert event.failure_reason == "auth_source=database" db_session.close() def test_identity_change_revokes_existing_session( identity_app, identity_store, identity_user, ): client = identity_app.test_client() assert client.post( "/login", data={"username": "operator-admin", "password": "DatabasePass9!"}, ).status_code == 302 db_session = identity_store() user = db_session.query(User).filter(User.id == identity_user).one() user.updated_at = datetime.now() + timedelta(seconds=1) db_session.commit() db_session.close() response = client.get("/protected") assert response.status_code == 302 assert response.headers["Location"].endswith("/login") with client.session_transaction() as current_session: assert "logged_in" not in current_session db_session = identity_store() statuses = [row.status for row in db_session.query(LoginHistory).all()] assert LoginHistory.STATUS_REVOKED in statuses db_session.close() def test_legacy_hybrid_login_audits_real_peer_not_spoofed_header( identity_app, identity_store, ): client = identity_app.test_client() response = client.post( "/login", data={"username": "", "password": "LegacyPass9!"}, headers={"X-Forwarded-For": "198.51.100.88"}, environ_base={"REMOTE_ADDR": "203.0.113.44"}, ) assert response.status_code == 302 with client.session_transaction() as current_session: assert current_session["auth_source"] == "legacy_shared_password" assert current_session["client_ip"] == "203.0.113.44" db_session = identity_store() event = db_session.query(LoginHistory).order_by(LoginHistory.id.desc()).first() assert event.ip_address == "203.0.113.44" db_session.close() def test_database_mode_refuses_legacy_authority( monkeypatch, identity_app, ): monkeypatch.setenv("MOMO_AUTH_IDENTITY_MODE", "database") client = identity_app.test_client() response = client.post( "/login", data={"username": "", "password": "LegacyPass9!"}, ) assert response.status_code == 401 with client.session_transaction() as current_session: assert "logged_in" not in current_session def test_auto_mode_retires_legacy_session_after_durable_admin_canary( monkeypatch, identity_app, identity_store, identity_user, ): monkeypatch.setenv("MOMO_AUTH_IDENTITY_MODE", "auto") client = identity_app.test_client() assert client.post( "/login", data={"username": "", "password": "LegacyPass9!"}, ).status_code == 302 db_session = identity_store() service = UserService(db_session) for _ in range(2): assert service.record_login( identity_user, "operator-admin", "127.0.0.1", "pytest", LoginHistory.STATUS_SUCCESS, "auth_source=database", ) is not None assert service.get_auth_auto_cutover_state()["ready"] is True db_session.close() response = client.get("/protected") assert response.status_code == 302 assert response.headers["Location"].endswith("/login") with client.session_transaction() as current_session: assert "logged_in" not in current_session def test_unversioned_cookie_is_rejected_outside_test_mode(monkeypatch, identity_store): monkeypatch.setenv("MOMO_AUTH_IDENTITY_MODE", "hybrid") monkeypatch.setattr(auth, "get_identity_session", identity_store) app = Flask(__name__) app.config.update(SECRET_KEY="production-shape-secret", TESTING=False) @app.route("/login", endpoint="login") def login_stub(): return "login" @app.route("/protected-production-shape") @auth.login_required def protected_production_shape(): return "protected" client = app.test_client() with client.session_transaction() as current_session: current_session["logged_in"] = True response = client.get("/protected-production-shape") assert response.status_code == 302 assert response.headers["Location"].endswith("/login") with client.session_transaction() as current_session: assert "logged_in" not in current_session def test_runtime_readback_separates_canary_from_closed(identity_store, identity_user): db_session = identity_store() report = build_auth_identity_runtime_readback(db_session, mode="hybrid") assert report["status"] == "canary_ready" assert report["canary_ready"] is True assert report["runtime_closed"] is False assert report["inventory"]["active_admin_count"] == 1 assert report["checks"]["legacy_shared_password_retired"] is False closed = build_auth_identity_runtime_readback(db_session, mode="database") assert closed["status"] == "completed" assert closed["runtime_closed"] is True assert closed["checks"]["legacy_shared_password_retired"] is True db_session.close() def test_auto_runtime_readback_closes_after_admin_success_threshold( identity_store, identity_user, ): db_session = identity_store() initial = build_auth_identity_runtime_readback(db_session, mode="auto") assert initial["configured_auth_identity_mode"] == "auto" assert initial["auth_identity_mode"] == "hybrid" assert initial["runtime_closed"] is False service = UserService(db_session) for _ in range(2): service.record_login( identity_user, "operator-admin", "127.0.0.1", "pytest", LoginHistory.STATUS_SUCCESS, "auth_source=database", ) promoted = build_auth_identity_runtime_readback(db_session, mode="auto") assert promoted["configured_auth_identity_mode"] == "auto" assert promoted["auth_identity_mode"] == "database" assert promoted["auto_cutover"]["manual_review_required"] is False assert promoted["auto_cutover"]["database_admin_successes"] == 2 assert promoted["runtime_closed"] is True db_session.close() def test_identity_mutations_write_no_secret_audits( identity_store, identity_user, ): audit_context = { "actor_id": identity_user, "ip_address": "198.51.100.40", "user_agent": "pytest-identity-audit", } db_session = identity_store() service = UserService(db_session) user, error = service.create_user( "audit-target", "CreatePass9!", role="admin", created_by=identity_user, audit_context=audit_context, ) assert error is None success, error = service.update_user( user.id, audit_context=audit_context, role="user", display_name="Audit Target", ) assert (success, error) == (True, None) success, error = service.change_password( user.id, "CreatePass9!", "ChangedPass8!", audit_context=audit_context, ) assert (success, error) == (True, None) success, error = service.reset_password( user.id, "ResetPass7!", admin_id=identity_user, audit_context=audit_context, ) assert (success, error) == (True, None) success, error = service.delete_user(user.id, audit_context=audit_context) assert (success, error) == (True, None) events = db_session.query(LoginHistory).filter( LoginHistory.user_id == user.id, ).order_by(LoginHistory.id).all() assert [event.status for event in events] == [ LoginHistory.STATUS_USER_CREATED, LoginHistory.STATUS_IDENTITY_CHANGED, LoginHistory.STATUS_PASSWORD_CHANGED, LoginHistory.STATUS_PASSWORD_CHANGED, LoginHistory.STATUS_USER_DISABLED, ] serialized = " ".join( f"{event.failure_reason or ''} {event.user_agent or ''}" for event in events ) assert "CreatePass9!" not in serialized assert "ChangedPass8!" not in serialized assert "ResetPass7!" not in serialized assert all(event.ip_address == "198.51.100.40" for event in events) assert service.get_identity_inventory()["identity_mutation_events"] == 5 db_session.close() def test_identity_mutation_and_audit_roll_back_together( monkeypatch, identity_store, identity_user, ): db_session = identity_store() def fail_commit(): raise RuntimeError("forced commit failure") monkeypatch.setattr(db_session, "commit", fail_commit) user, error = UserService(db_session).create_user( "rollback-target", "RollbackPass9!", role="admin", created_by=identity_user, audit_context={"actor_id": identity_user}, ) assert user is None assert "forced commit failure" in error db_session.close() verification_session = identity_store() assert verification_session.query(User).filter_by(username="rollback-target").count() == 0 assert verification_session.query(LoginHistory).filter_by( username_attempted="rollback-target", ).count() == 0 verification_session.close() def test_permission_mutation_and_audit_share_one_transaction( monkeypatch, identity_store, identity_user, ): seed_session = identity_store() target = User( username="permission-target", password_hash=generate_password_hash("PermissionPass9!", method="pbkdf2:sha256"), role="user", display_name="Permission Target", is_active=True, password_changed_at=datetime.now(), created_at=datetime.now(), ) seed_session.add(target) seed_session.commit() target_id = target.id seed_session.close() monkeypatch.setattr(permission_service_module, "get_session", identity_store) success, message = PermissionService.set_user_permissions( target_id, ["dashboard.view", "dashboard.view"], granted_by=identity_user, audit_context={ "actor_id": identity_user, "ip_address": "198.51.100.41", "user_agent": "pytest-permission-audit", }, ) assert success is True assert "1 項權限" in message verification_session = identity_store() assert verification_session.query(UserPermission).filter_by(user_id=target_id).count() == 1 event = verification_session.query(LoginHistory).filter_by( user_id=target_id, status=LoginHistory.STATUS_PERMISSION_CHANGED, ).one() assert event.failure_reason == f"actor_id={identity_user};change=replace;count=1" assert event.ip_address == "198.51.100.41" assert event.user_agent == "pytest-permission-audit" verification_session.close() def test_permission_mutation_and_audit_roll_back_on_commit_failure( monkeypatch, identity_store, identity_user, ): seed_session = identity_store() target = User( username="permission-rollback", password_hash=generate_password_hash("PermissionPass9!", method="pbkdf2:sha256"), role="user", display_name="Permission Rollback", is_active=True, password_changed_at=datetime.now(), created_at=datetime.now(), ) seed_session.add(target) seed_session.commit() target_id = target.id seed_session.close() failing_session = identity_store() def fail_commit(): raise RuntimeError("forced permission commit failure") monkeypatch.setattr(failing_session, "commit", fail_commit) monkeypatch.setattr(permission_service_module, "get_session", lambda: failing_session) success, message = PermissionService.set_user_permissions( target_id, ["dashboard.view"], granted_by=identity_user, audit_context={"actor_id": identity_user}, ) assert success is False assert "forced permission commit failure" in message verification_session = identity_store() assert verification_session.query(UserPermission).filter_by(user_id=target_id).count() == 0 assert verification_session.query(LoginHistory).filter_by( user_id=target_id, status=LoginHistory.STATUS_PERMISSION_CHANGED, ).count() == 0 verification_session.close()