rebuild(stage-3): establish accounts permissions and secure settings

This commit is contained in:
leefer
2026-07-30 01:33:19 +08:00
parent 2ff35eb6df
commit f69972c3c0
31 changed files with 2742 additions and 18 deletions
+1
View File
@@ -0,0 +1 @@
"""Tests for the rebuilt application."""
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
import httpx
from fastapi import FastAPI
type Scenario[Result] = Callable[[httpx.AsyncClient], Awaitable[Result]]
def run_scenario[Result](application: FastAPI, scenario: Scenario[Result]) -> Result:
async def run() -> Result:
transport = httpx.ASGITransport(app=application, raise_app_exceptions=False)
async with application.router.lifespan_context(application):
async with httpx.AsyncClient(
transport=transport, base_url="http://testserver"
) as client:
return await scenario(client)
return asyncio.run(run())
def request(application: FastAPI, path: str) -> httpx.Response:
async def get(client: httpx.AsyncClient) -> httpx.Response:
return await client.get(path)
return run_scenario(application, get)
+377
View File
@@ -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)
+43
View File
@@ -0,0 +1,43 @@
import pytest
from cryptography.fernet import Fernet
from backend.bootstrap.settings import ConfigurationError, Settings
from backend.security import SecretCipher, load_or_create_cipher
def test_development_cipher_persists_in_ignored_data_directory(tmp_path) -> None:
settings = Settings.for_test(tmp_path)
first = load_or_create_cipher(settings)
encrypted = first.encrypt("private profile")
second = load_or_create_cipher(settings)
assert second.decrypt(encrypted) == "private profile"
assert (tmp_path / ".encryption.key").exists()
assert "private profile" not in encrypted
def test_explicit_encryption_key_is_reusable(tmp_path) -> None:
key = Fernet.generate_key().decode("ascii")
settings = Settings.for_test(tmp_path)
configured = Settings(
environment=settings.environment,
debug=settings.debug,
data_directory=settings.data_directory,
database_path=settings.database_path,
log_file=settings.log_file,
log_level=settings.log_level,
host=settings.host,
port=settings.port,
encryption_key=key,
)
first = load_or_create_cipher(configured).encrypt("secret")
second = SecretCipher.from_key(key).decrypt(first)
assert second == "secret"
def test_invalid_encryption_key_is_rejected() -> None:
with pytest.raises(ConfigurationError):
SecretCipher.from_key("invalid")
+1 -15
View File
@@ -1,23 +1,9 @@
import asyncio
import httpx
from fastapi import Query
from backend.bootstrap.application import create_application
from backend.bootstrap.settings import Settings
from backend.http.errors import AppError
def request(application, path: str) -> httpx.Response:
async def run() -> httpx.Response:
transport = httpx.ASGITransport(app=application, raise_app_exceptions=False)
async with application.router.lifespan_context(application):
async with httpx.AsyncClient(
transport=transport, base_url="http://testserver"
) as client:
return await client.get(path)
return asyncio.run(run())
from tests.support import request
def test_health_reports_runtime_environment(tmp_path) -> None:
+22 -1
View File
@@ -4,7 +4,7 @@ import sqlite3
import pytest
from backend.database import Database, Migration, MigrationError, MigrationRunner
from backend.database import MIGRATIONS, Database, Migration, MigrationError, MigrationRunner
from backend.database.repositories import DatabaseStatusRepository
@@ -104,3 +104,24 @@ def test_non_contiguous_database_history_is_rejected(tmp_path) -> None:
with pytest.raises(MigrationError, match="not contiguous"):
runner.upgrade((first, second))
def test_real_account_schema_can_upgrade_and_rollback(tmp_path) -> None:
database = Database(tmp_path / "app.db")
runner = MigrationRunner(database)
assert runner.upgrade(MIGRATIONS) == (1, 2)
assert {
"users",
"memberships",
"sessions",
"birth_profiles",
"llm_usage_daily",
"system_credentials",
"llm_models",
"llm_configuration",
} <= table_names(database)
assert runner.downgrade(MIGRATIONS, target_version=0) == (2, 1)
assert "users" not in table_names(database)
assert "llm_models" not in table_names(database)
+159
View File
@@ -0,0 +1,159 @@
from __future__ import annotations
import sqlite3
import httpx
from backend.bootstrap.application import create_application
from backend.bootstrap.settings import Settings
from tests.support import run_scenario
from tests.test_accounts import (
ADMIN_PASSWORD,
USER_PASSWORD,
csrf_headers,
register,
use_session,
)
def model_payload(index: int, api_key: str | None = None) -> dict[str, str]:
return {
"display_name": f"模型 {index}",
"base_url": f"https://model-{index}.example.com/v1",
"model_identifier": f"model-{index}",
"api_key": api_key or f"secret-key-{index}",
}
def test_model_pool_is_admin_only_and_never_exposes_keys(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path))
async def scenario(client: httpx.AsyncClient) -> None:
_, admin_session = await register(client, "model-admin", ADMIN_PASSWORD)
client.cookies.clear()
_, user_session = await register(client, "model-user", USER_PASSWORD)
denied_read = await client.get("/api/admin/models")
assert denied_read.status_code == 403
denied_write = await client.post(
"/api/admin/models",
headers=csrf_headers(user_session),
json=model_payload(1),
)
assert denied_write.status_code == 403
use_session(client, admin_session)
created = await client.post(
"/api/admin/models",
headers=csrf_headers(admin_session),
json=model_payload(1, "private-alpha-key"),
)
assert created.status_code == 201
assert created.json()["is_primary"] is True
assert created.json()["is_fallback"] is False
assert created.json()["has_api_key"] is True
assert "api_key" not in created.json()
assert "private-alpha-key" not in created.text
with sqlite3.connect(application.state.settings.database_path) as connection:
encrypted_before = connection.execute(
"SELECT encrypted_api_key FROM llm_models WHERE id = 1"
).fetchone()[0]
assert encrypted_before != "private-alpha-key"
assert "private-alpha-key" not in encrypted_before
updated_payload = model_payload(1)
updated_payload.pop("api_key")
updated_payload["display_name"] = "主模型"
updated = await client.put(
"/api/admin/models/1",
headers=csrf_headers(admin_session),
json=updated_payload,
)
assert updated.status_code == 200
assert updated.json()["display_name"] == "主模型"
with sqlite3.connect(application.state.settings.database_path) as connection:
encrypted_after = connection.execute(
"SELECT encrypted_api_key FROM llm_models WHERE id = 1"
).fetchone()[0]
assert encrypted_after == encrypted_before
run_scenario(application, scenario)
def test_model_selection_deletion_guards_and_runtime_config(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path))
async def scenario(client: httpx.AsyncClient) -> None:
_, admin_session = await register(client, "selection-admin", ADMIN_PASSWORD)
for index in (1, 2, 3):
response = await client.post(
"/api/admin/models",
headers=csrf_headers(admin_session),
json=model_payload(index),
)
assert response.status_code == 201
duplicate_roles = await client.put(
"/api/admin/models/selection",
headers=csrf_headers(admin_session),
json={"primary_model_id": 2, "fallback_model_id": 2},
)
assert duplicate_roles.status_code == 400
assert duplicate_roles.json()["error"]["code"] == "duplicate_model_role"
selected = await client.put(
"/api/admin/models/selection",
headers=csrf_headers(admin_session),
json={"primary_model_id": 2, "fallback_model_id": 1},
)
assert selected.status_code == 200
models = (await client.get("/api/admin/models")).json()
assert next(item for item in models if item["id"] == 2)["is_primary"] is True
assert next(item for item in models if item["id"] == 1)["is_fallback"] is True
selected_delete = await client.delete(
"/api/admin/models/1", headers=csrf_headers(admin_session)
)
assert selected_delete.status_code == 409
assert selected_delete.json()["error"]["code"] == "model_in_use"
unselected_delete = await client.delete(
"/api/admin/models/3", headers=csrf_headers(admin_session)
)
assert unselected_delete.status_code == 200
runtime = application.state.container.model_pool.runtime_config()
assert runtime.primary.id == 2
assert runtime.fallback is not None
assert runtime.fallback.id == 1
assert (
application.state.container.model_pool.decrypt_api_key(runtime.primary)
== "secret-key-2"
)
run_scenario(application, scenario)
def test_model_pool_rejects_twenty_first_model(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path))
async def scenario(client: httpx.AsyncClient) -> None:
_, admin_session = await register(client, "capacity-admin", ADMIN_PASSWORD)
for index in range(1, 21):
response = await client.post(
"/api/admin/models",
headers=csrf_headers(admin_session),
json=model_payload(index),
)
assert response.status_code == 201
rejected = await client.post(
"/api/admin/models",
headers=csrf_headers(admin_session),
json=model_payload(21),
)
assert rejected.status_code == 409
assert rejected.json()["error"]["code"] == "model_pool_full"
run_scenario(application, scenario)
+20
View File
@@ -0,0 +1,20 @@
import pytest
from backend.security import PasswordHasher, PasswordPolicyError
@pytest.mark.parametrize("password", ["onlyletters", "12345678", "short1!"])
def test_password_policy_rejects_weak_values(password: str) -> None:
with pytest.raises(PasswordPolicyError):
PasswordHasher().hash(password)
def test_password_hash_uses_independent_salts_and_verifies() -> None:
hasher = PasswordHasher()
first = hasher.hash("Valid-password-123!")
second = hasher.hash("Valid-password-123!")
assert first != second
assert "Valid-password-123!" not in first
assert hasher.verify("Valid-password-123!", first)
assert not hasher.verify("Wrong-password-123!", first)
+8
View File
@@ -32,3 +32,11 @@ def test_test_settings_do_not_create_log_files(tmp_path: Path) -> None:
assert settings.log_file is None
assert settings.database_path.parent == tmp_path
def test_production_requires_encryption_key(monkeypatch) -> None:
monkeypatch.setenv("APP_ENV", "production")
monkeypatch.delenv("APP_ENCRYPTION_KEY", raising=False)
with pytest.raises(ConfigurationError, match="APP_ENCRYPTION_KEY"):
Settings.from_environment()