from __future__ import annotations import sqlite3 from dataclasses import dataclass from datetime import datetime import httpx from backend.bootstrap.application import create_application from backend.bootstrap.settings import Settings from backend.features.accounts.auth import CSRF_COOKIE, SESSION_COOKIE, SmartAccessPrincipal from backend.features.accounts.service import add_months from tests.support import run_scenario ADMIN_PASSWORD = "Admin-pass-123!" USER_PASSWORD = "User-pass-123!" @dataclass(frozen=True) class BrowserSession: session: str csrf: str def current_session(client: httpx.AsyncClient) -> BrowserSession: return BrowserSession( session=client.cookies.get(SESSION_COOKIE), csrf=client.cookies.get(CSRF_COOKIE), ) def use_session(client: httpx.AsyncClient, session: BrowserSession) -> None: client.cookies.clear() client.cookies.set(SESSION_COOKIE, session.session) client.cookies.set(CSRF_COOKIE, session.csrf) def csrf_headers(session: BrowserSession) -> dict[str, str]: return {"X-CSRF-Token": session.csrf} async def register( client: httpx.AsyncClient, username: str, password: str ) -> tuple[httpx.Response, BrowserSession]: response = await client.post( "/api/auth/register", json={"username": username, "password": password} ) return response, current_session(client) def test_first_account_is_admin_and_second_is_regular_user(tmp_path) -> None: application = create_application(Settings.for_test(tmp_path)) async def scenario(client: httpx.AsyncClient) -> None: admin_response, admin_session = await register(client, "leefer", ADMIN_PASSWORD) assert admin_response.status_code == 201 assert admin_response.json()["account"] == { "id": 1, "username": "leefer", "is_admin": True, "membership_status": "not_open", "membership_active": False, "smart_access": True, "badges": ["admin"], } client.cookies.clear() user_response, user_session = await register(client, "小白用户", USER_PASSWORD) assert user_response.status_code == 201 assert user_response.json()["account"]["is_admin"] is False assert user_response.json()["account"]["smart_access"] is False assert user_response.json()["account"]["badges"] == [] use_session(client, admin_session) assert (await client.get("/api/auth/session")).json()["is_admin"] is True use_session(client, user_session) assert (await client.get("/api/auth/session")).json()["is_admin"] is False with sqlite3.connect(application.state.settings.database_path) as connection: stored = {row[0] for row in connection.execute("SELECT token_hash FROM sessions")} assert admin_session.session not in stored assert user_session.session not in stored assert all(len(value) == 64 for value in stored) run_scenario(application, scenario) def test_csrf_password_change_and_other_session_revocation(tmp_path) -> None: application = create_application(Settings.for_test(tmp_path)) async def scenario(client: httpx.AsyncClient) -> None: _, first_session = await register(client, "secure-user", USER_PASSWORD) second_login = await client.post( "/api/auth/login", json={"username": "secure-user", "password": USER_PASSWORD}, ) assert second_login.status_code == 200 second_session = current_session(client) without_csrf = await client.patch( "/api/account/password", json={ "current_password": USER_PASSWORD, "new_password": "Changed-pass-456!", "confirmation": "Changed-pass-456!", }, ) assert without_csrf.status_code == 403 assert without_csrf.json()["error"]["code"] == "csrf_failed" wrong_current = await client.patch( "/api/account/password", headers=csrf_headers(second_session), json={ "current_password": "Wrong-pass-999!", "new_password": "Changed-pass-456!", "confirmation": "Changed-pass-456!", }, ) assert wrong_current.status_code == 400 assert wrong_current.json()["error"]["code"] == "invalid_current_password" changed = await client.patch( "/api/account/password", headers=csrf_headers(second_session), json={ "current_password": USER_PASSWORD, "new_password": "Changed-pass-456!", "confirmation": "Changed-pass-456!", }, ) assert changed.status_code == 200 use_session(client, first_session) assert (await client.get("/api/auth/session")).status_code == 401 client.cookies.clear() old_login = await client.post( "/api/auth/login", json={"username": "secure-user", "password": USER_PASSWORD}, ) assert old_login.status_code == 401 assert old_login.json()["error"]["message"] == "账号或密码错误。" new_login = await client.post( "/api/auth/login", json={"username": "secure-user", "password": "Changed-pass-456!"}, ) assert new_login.status_code == 200 run_scenario(application, scenario) def test_birth_profile_is_encrypted_and_isolated_by_account(tmp_path) -> None: application = create_application(Settings.for_test(tmp_path)) async def scenario(client: httpx.AsyncClient) -> None: _, first_session = await register(client, "profile-a", USER_PASSWORD) saved = await client.put( "/api/account/profile", headers=csrf_headers(first_session), json={"birth_date": "1990-03-08", "birth_time": "08:30", "gender": "male"}, ) assert saved.status_code == 200 assert saved.json()["configured"] is True assert "仅当前账号可见" in saved.json()["privacy_notice"] client.cookies.clear() _, second_session = await register(client, "profile-b", USER_PASSWORD) assert (await client.get("/api/account/profile")).json()["configured"] is False with sqlite3.connect(application.state.settings.database_path) as connection: encrypted = connection.execute( "SELECT encrypted_payload FROM birth_profiles WHERE user_id = 1" ).fetchone()[0] assert "1990-03-08" not in encrypted assert "08:30" not in encrypted use_session(client, first_session) own_profile = await client.get("/api/account/profile") assert own_profile.json()["birth_date"] == "1990-03-08" deleted = await client.delete("/api/account/profile", headers=csrf_headers(first_session)) assert deleted.status_code == 200 assert (await client.get("/api/account/profile")).json()["configured"] is False use_session(client, second_session) assert (await client.get("/api/account/profile")).json()["configured"] is False run_scenario(application, scenario) def test_incomplete_encrypted_profile_returns_controlled_error(tmp_path) -> None: application = create_application(Settings.for_test(tmp_path)) async def scenario(client: httpx.AsyncClient) -> None: response, _session = await register(client, "broken-profile", USER_PASSWORD) user_id = response.json()["account"]["id"] encrypted = application.state.container.accounts._cipher.encrypt('{"gender":"male"}') with sqlite3.connect(application.state.settings.database_path) as connection: connection.execute( "INSERT INTO birth_profiles VALUES (?,?,datetime('now'),datetime('now'))", (user_id, encrypted), ) result = await client.get("/api/account/profile") assert result.status_code == 503 assert result.json()["error"]["code"] == "profile_unavailable" run_scenario(application, scenario) def test_membership_and_admin_are_independent_dimensions(tmp_path) -> None: application = create_application(Settings.for_test(tmp_path)) async def scenario(client: httpx.AsyncClient) -> None: _, admin_session = await register(client, "admin-user", ADMIN_PASSWORD) client.cookies.clear() user_response, user_session = await register(client, "member-user", USER_PASSWORD) user_id = user_response.json()["account"]["id"] use_session(client, admin_session) activated = await client.patch( f"/api/admin/memberships/{user_id}", headers=csrf_headers(admin_session), json={"action": "activate", "duration": "1_month", "daily_limit": 80}, ) assert activated.status_code == 200 assert activated.json()["membership"]["active"] is True assert activated.json()["membership"]["daily_limit"] == 80 admin_membership = await client.get("/api/account/membership") assert admin_membership.json()["active"] is False assert admin_membership.json()["smart_access"] is True use_session(client, user_session) user_identity = await client.get("/api/auth/session") assert user_identity.json()["badges"] == ["member"] assert user_identity.json()["smart_access"] is True use_session(client, admin_session) permanent = await client.patch( "/api/admin/memberships/1", headers=csrf_headers(admin_session), json={"action": "activate", "duration": "permanent"}, ) assert permanent.status_code == 200 assert permanent.json()["membership"]["is_permanent"] is True assert permanent.json()["membership"]["expires_at"] is None assert permanent.json()["membership"]["remaining_days"] is None identity = await client.get("/api/auth/session") assert identity.json()["badges"] == ["admin", "member"] run_scenario(application, scenario) def test_non_admin_cannot_read_or_write_system_credentials(tmp_path) -> None: application = create_application(Settings.for_test(tmp_path)) async def scenario(client: httpx.AsyncClient) -> None: _, admin_session = await register(client, "system-admin", ADMIN_PASSWORD) client.cookies.clear() _, user_session = await register(client, "system-user", USER_PASSWORD) denied_read = await client.get("/api/admin/system/credentials") assert denied_read.status_code == 403 denied_write = await client.put( "/api/admin/system/credentials/tushare_token", headers=csrf_headers(user_session), json={"value": "user-must-not-save-this"}, ) assert denied_write.status_code == 403 use_session(client, admin_session) saved = await client.put( "/api/admin/system/credentials/tushare_token", headers=csrf_headers(admin_session), json={"value": "real-test-credential-value"}, ) assert saved.status_code == 200 statuses = await client.get("/api/admin/system/credentials") assert statuses.status_code == 200 configured = next(item for item in statuses.json() if item["name"] == "tushare_token") assert configured["configured"] is True assert "value" not in configured assert "real-test-credential-value" not in statuses.text with sqlite3.connect(application.state.settings.database_path) as connection: encrypted = connection.execute( "SELECT encrypted_value FROM system_credentials WHERE name = 'tushare_token'" ).fetchone()[0] assert encrypted != "real-test-credential-value" assert "real-test-credential-value" not in encrypted run_scenario(application, scenario) def test_session_cookies_duplicate_username_and_logout(tmp_path) -> None: application = create_application(Settings.for_test(tmp_path)) async def scenario(client: httpx.AsyncClient) -> None: response, session = await register(client, "CaseUser", USER_PASSWORD) cookies = response.headers.get_list("set-cookie") session_cookie = next(value for value in cookies if value.startswith(f"{SESSION_COOKIE}=")) csrf_cookie = next(value for value in cookies if value.startswith(f"{CSRF_COOKIE}=")) assert "HttpOnly" in session_cookie assert "SameSite=lax" in session_cookie assert "HttpOnly" not in csrf_cookie assert "SameSite=lax" in csrf_cookie client.cookies.clear() duplicate = await client.post( "/api/auth/register", json={"username": "caseuser", "password": USER_PASSWORD}, ) assert duplicate.status_code == 409 assert duplicate.json()["error"]["code"] == "username_taken" use_session(client, session) logged_out = await client.post("/api/auth/logout", headers=csrf_headers(session)) assert logged_out.status_code == 200 assert (await client.get("/api/auth/session")).status_code == 401 run_scenario(application, scenario) def test_active_membership_renews_from_existing_expiry_and_disable_keeps_it(tmp_path) -> None: application = create_application(Settings.for_test(tmp_path)) async def scenario(client: httpx.AsyncClient) -> None: _, admin_session = await register(client, "renew-admin", ADMIN_PASSWORD) client.cookies.clear() user_response, _ = await register(client, "renew-user", USER_PASSWORD) user_id = user_response.json()["account"]["id"] use_session(client, admin_session) first = await client.patch( f"/api/admin/memberships/{user_id}", headers=csrf_headers(admin_session), json={"action": "activate", "duration": "1_month"}, ) first_expiry = datetime.fromisoformat(first.json()["membership"]["expires_at"]) extended = await client.patch( f"/api/admin/memberships/{user_id}", headers=csrf_headers(admin_session), json={"action": "activate", "duration": "3_months"}, ) extended_expiry = datetime.fromisoformat(extended.json()["membership"]["expires_at"]) assert extended_expiry == add_months(first_expiry, 3) disabled = await client.patch( f"/api/admin/memberships/{user_id}", headers=csrf_headers(admin_session), json={"action": "disable"}, ) assert disabled.json()["membership"]["status"] == "disabled" assert disabled.json()["membership"]["active"] is False assert ( datetime.fromisoformat(disabled.json()["membership"]["expires_at"]) == extended_expiry ) run_scenario(application, scenario) def test_smart_access_allows_admin_and_member_but_not_regular_user(tmp_path) -> None: application = create_application(Settings.for_test(tmp_path)) @application.get("/api/test/smart-access") def smart_access_probe(_principal: SmartAccessPrincipal) -> dict[str, bool]: return {"allowed": True} async def scenario(client: httpx.AsyncClient) -> None: _, admin_session = await register(client, "smart-admin", ADMIN_PASSWORD) client.cookies.clear() member_response, member_session = await register( client, "smart-member", USER_PASSWORD ) member_id = member_response.json()["account"]["id"] client.cookies.clear() _, user_session = await register(client, "smart-user", USER_PASSWORD) denied = await client.get("/api/test/smart-access") assert denied.status_code == 403 assert denied.json()["error"]["code"] == "membership_required" use_session(client, admin_session) assert (await client.get("/api/test/smart-access")).status_code == 200 activated = await client.patch( f"/api/admin/memberships/{member_id}", headers=csrf_headers(admin_session), json={"action": "activate", "duration": "1_month"}, ) assert activated.status_code == 200 use_session(client, member_session) assert (await client.get("/api/test/smart-access")).status_code == 200 use_session(client, user_session) assert (await client.get("/api/test/smart-access")).status_code == 403 run_scenario(application, scenario)