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