You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

337 lines
12 KiB

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/")