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