rebuild(stage-3): establish accounts permissions and secure settings
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
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_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)
|
||||
Reference in New Issue
Block a user