from __future__ import annotations from collections.abc import Sequence import pytest from app import create_app from app.extensions import db from app.models import Account, AuditLog @pytest.fixture def app(): app = create_app("testing") with app.app_context(): db.create_all() yield app db.session.remove() db.drop_all() @pytest.fixture def client(app): return app.test_client() def create_account(*, username: str, password: str, role: str, status: str = "active", display_name: str | None = None) -> Account: account = Account( username=username, display_name=display_name, role=role, status=status, ) account.set_password(password) db.session.add(account) db.session.commit() return account def login(client, *, username: str, password: str, next_path: str | None = None): target = "/auth/login" if next_path: target = f"{target}?next={next_path}" return client.post( target, data={"username": username, "password": password, "next": next_path or ""}, follow_redirects=False, ) def get_logs(*, action_type: str) -> Sequence[AuditLog]: return db.session.execute( db.select(AuditLog).where(AuditLog.action_type == action_type).order_by(AuditLog.id), ).scalars().all() def test_login_page_renders(client) -> None: response = client.get("/auth/login") page = response.get_data(as_text=True) assert response.status_code == 200 assert "登录" in page assert "HW" in page def test_login_success_redirects_admin_to_main(client, app) -> None: with app.app_context(): create_account(username="admin-user", password="AdminPass123!", role="admin", display_name="管理员") response = login(client, username="admin-user", password="AdminPass123!") assert response.status_code == 302 assert response.headers["Location"].endswith("/") with client.session_transaction() as session_state: assert isinstance(session_state.get("account_id"), int) with app.app_context(): logs = get_logs(action_type="auth_login") assert len(logs) == 1 assert logs[0].actor_username == "admin-user" assert logs[0].request_path == "/auth/login" assert logs[0].after_data_json == { "account": { "id": logs[0].actor_user_id, "username": "admin-user", "display_name": "管理员", "role": "admin", "status": "active", }, "redirect_to": "/", } def test_entry_only_login_redirects_to_entry_page(client, app) -> None: with app.app_context(): create_account(username="entry-user", password="EntryPass123!", role="entry_only") response = login(client, username="entry-user", password="EntryPass123!") assert response.status_code == 302 assert response.headers["Location"].endswith("/quick-entry/") def test_quick_editor_login_redirects_to_entry_page(client, app) -> None: with app.app_context(): create_account(username="quick-editor", password="QuickEdit123!", role="quick_editor") response = login(client, username="quick-editor", password="QuickEdit123!") assert response.status_code == 302 assert response.headers["Location"].endswith("/quick-entry/") def test_login_rejects_wrong_password(client, app) -> None: with app.app_context(): create_account(username="editor-user", password="EditorPass123!", role="editor") response = login(client, username="editor-user", password="WrongPass123!") assert response.status_code == 400 assert "用户名或密码错误。" in response.get_data(as_text=True) with client.session_transaction() as session_state: assert session_state.get("account_id") is None with app.app_context(): logs = get_logs(action_type="auth_login_failed") assert len(logs) == 1 assert logs[0].actor_username == "editor-user" assert logs[0].after_data_json == { "reason": "invalid_credentials", "attempted_username": "editor-user", } def test_login_rejects_disabled_account(client, app) -> None: with app.app_context(): create_account(username="disabled-user", password="DisabledPass123!", role="editor", status="disabled") response = login(client, username="disabled-user", password="DisabledPass123!") assert response.status_code == 403 assert "账号已停用,无法登录。" in response.get_data(as_text=True) with app.app_context(): logs = get_logs(action_type="auth_login_disabled") assert len(logs) == 1 assert logs[0].actor_username == "disabled-user" assert logs[0].after_data_json == { "reason": "account_disabled", "account": { "id": logs[0].actor_user_id, "username": "disabled-user", "display_name": None, "role": "editor", "status": "disabled", }, } def test_protected_page_redirects_to_login_when_logged_out(client) -> None: response = client.get("/", follow_redirects=False) assert response.status_code == 302 assert "/auth/login?next=/" in response.headers["Location"] def test_protected_page_redirect_creates_unauthenticated_audit_log(client, app) -> None: response = client.get("/", follow_redirects=False) assert response.status_code == 302 with app.app_context(): logs = get_logs(action_type="auth_unauthenticated") assert len(logs) == 1 assert logs[0].actor_username == "anonymous" assert logs[0].target_display_name == "/" assert logs[0].after_data_json == { "reason": "login_required", "redirect_to": "/auth/login?next=/", } def test_entry_only_is_redirected_away_from_main_page(client, app) -> None: with app.app_context(): create_account(username="entry-only", password="EntryOnly123!", role="entry_only") login(client, username="entry-only", password="EntryOnly123!") response = client.get("/", follow_redirects=False) assert response.status_code == 302 assert response.headers["Location"].endswith("/quick-entry/") with app.app_context(): logs = get_logs(action_type="auth_forbidden") assert len(logs) == 1 assert logs[0].actor_username == "entry-only" assert logs[0].target_display_name == "/" assert logs[0].after_data_json == { "account": { "id": logs[0].actor_user_id, "username": "entry-only", "display_name": None, "role": "entry_only", "status": "active", }, "required_roles": ["admin", "editor"], "redirect_to": "/quick-entry/", } def test_entry_page_allows_entry_only_role(client, app) -> None: with app.app_context(): create_account(username="entry-ok", password="EntryPass123!", role="entry_only") login(client, username="entry-ok", password="EntryPass123!") response = client.get("/quick-entry/") assert response.status_code == 200 assert "快速录入" in response.get_data(as_text=True) def test_entry_page_allows_quick_editor_role(client, app) -> None: with app.app_context(): create_account(username="quick-editor", password="QuickEdit123!", role="quick_editor") login(client, username="quick-editor", password="QuickEdit123!") response = client.get("/quick-entry/") assert response.status_code == 200 assert "快速录入" in response.get_data(as_text=True) def test_logout_clears_session(client, app) -> None: with app.app_context(): create_account(username="logout-user", password="LogoutPass123!", role="editor") login(client, username="logout-user", password="LogoutPass123!") response = client.post("/auth/logout", follow_redirects=False) assert response.status_code == 302 assert response.headers["Location"].endswith("/auth/login") with client.session_transaction() as session_state: assert session_state.get("account_id") is None with app.app_context(): logs = get_logs(action_type="auth_logout") assert len(logs) == 1 assert logs[0].actor_username == "logout-user" assert logs[0].after_data_json == { "account": { "id": logs[0].actor_user_id, "username": "logout-user", "display_name": None, "role": "editor", "status": "active", }, } def test_login_honors_safe_next_target(client, app) -> None: with app.app_context(): create_account(username="safe-next", password="SafeNext123!", role="admin") response = login(client, username="safe-next", password="SafeNext123!", next_path="/quick-entry/") assert response.status_code == 302 assert response.headers["Location"].endswith("/quick-entry/") def test_login_ignores_external_next_target(client, app) -> None: with app.app_context(): create_account(username="unsafe-next", password="UnsafeNext123!", role="admin") response = login(client, username="unsafe-next", password="UnsafeNext123!", next_path="https://evil.example/phish") assert response.status_code == 302 assert response.headers["Location"].endswith("/") def test_disabled_account_session_is_cleared_on_next_request(client, app) -> None: with app.app_context(): account = create_account(username="later-disabled", password="LaterDisabled123!", role="editor") account_id = account.id login(client, username="later-disabled", password="LaterDisabled123!") with app.app_context(): stored_account = db.session.get(Account, account_id) assert stored_account is not None stored_account.status = "disabled" db.session.commit() response = client.get("/quick-entry/", follow_redirects=False) assert response.status_code == 302 assert "/auth/login?next=/quick-entry/" in response.headers["Location"] with client.session_transaction() as session_state: assert session_state.get("account_id") is None def test_editor_forbidden_from_admin_account_page(client, app) -> None: with app.app_context(): create_account(username="editor-user", password="EditorPass123!", role="editor") login(client, username="editor-user", password="EditorPass123!") response = client.get("/admin/accounts", follow_redirects=False) assert response.status_code == 302 assert response.headers["Location"].endswith("/") def test_entry_only_forbidden_from_admin_audit_page(client, app) -> None: with app.app_context(): create_account(username="entry-user", password="EntryPass123!", role="entry_only") login(client, username="entry-user", password="EntryPass123!") response = client.get("/admin/audit-logs", follow_redirects=False) assert response.status_code == 302 assert response.headers["Location"].endswith("/quick-entry/") def test_quick_editor_is_redirected_away_from_main_page(client, app) -> None: with app.app_context(): create_account(username="quick-editor", password="QuickEdit123!", role="quick_editor") login(client, username="quick-editor", password="QuickEdit123!") response = client.get("/", follow_redirects=False) assert response.status_code == 302 assert response.headers["Location"].endswith("/quick-entry/") def test_quick_editor_forbidden_from_admin_audit_page(client, app) -> None: with app.app_context(): create_account(username="quick-editor", password="QuickEdit123!", role="quick_editor") login(client, username="quick-editor", password="QuickEdit123!") response = client.get("/admin/audit-logs", follow_redirects=False) assert response.status_code == 302 assert response.headers["Location"].endswith("/quick-entry/")