rebuild(stage-2): establish runtime and persistence foundations

This commit is contained in:
leefer
2026-07-30 01:02:04 +08:00
parent b3ba840d4e
commit d969d2c092
26 changed files with 865 additions and 33 deletions
+1
View File
@@ -19,6 +19,7 @@ test-results/
playwright-report/
node_modules/
next/.venv/
next/data/
next/frontend/dist/
next/frontend/.vite/
next/frontend/coverage/
+22
View File
@@ -23,6 +23,28 @@ npm.cmd run dev
浏览器访问`http://127.0.0.1:5173`。开发服务器将`/api`转发到后端8780端口。
## 运行配置
配置只从进程环境读取,不在源码或浏览器中保存密钥。基础变量:
- `APP_ENV``development``test``production`
- `APP_DATA_DIR`:持久化数据目录;相对路径以`next/`为基准。
- `APP_DATABASE_PATH`:SQLite文件路径,默认位于数据目录。
- `APP_LOG_FILE`:轮转日志路径;单文件10MB,保留3份。
- `APP_LOG_LEVEL``DEBUG``INFO``WARNING``ERROR``CRITICAL`
- `APP_HOST``APP_PORT`:监听地址和端口;迁移开发端口为8780。
- `APP_TIMEZONE`:固定为`Asia/Shanghai`,其他值会拒绝启动。
数据库维护:
```powershell
.\.venv\Scripts\python.exe -m tools.database status
.\.venv\Scripts\python.exe -m tools.database upgrade
.\.venv\Scripts\python.exe -m tools.database downgrade --target 0 --confirm-downgrade
```
回退只允许显式执行;代码回退不会自动回退数据库。
## 最小质量门禁
```powershell
+32
View File
@@ -1,19 +1,51 @@
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from backend.bootstrap.container import build_container
from backend.bootstrap.logging import configure_logging
from backend.bootstrap.settings import Settings
from backend.database.migrations import MIGRATIONS, MigrationRunner
from backend.http.errors import install_error_handlers
from backend.http.request_context import install_request_context
from backend.http.router import api_router
logger = logging.getLogger(__name__)
def create_application(settings: Settings | None = None) -> FastAPI:
runtime = settings or Settings.from_environment()
container = build_container(runtime)
@asynccontextmanager
async def lifespan(_application: FastAPI):
runtime.data_directory.mkdir(parents=True, exist_ok=True)
configure_logging(runtime.log_level, runtime.log_file)
MigrationRunner(container.database).upgrade(MIGRATIONS)
database_status = container.database_status.get()
logger.info(
"Application started",
extra={
"event": "application.started",
"context": {
"environment": runtime.environment,
"schema_version": database_status.schema_version,
},
},
)
yield
application = FastAPI(
title="小白复盘",
version="0.1.0",
docs_url="/api/docs" if runtime.debug else None,
redoc_url=None,
lifespan=lifespan,
)
application.state.settings = runtime
application.state.container = container
install_request_context(application)
install_error_handlers(application)
application.include_router(api_router, prefix="/api")
return application
+23
View File
@@ -0,0 +1,23 @@
from __future__ import annotations
from dataclasses import dataclass
from backend.bootstrap.settings import Settings
from backend.database.connection import Database
from backend.database.repositories.status import DatabaseStatusRepository
@dataclass(frozen=True, slots=True)
class ApplicationContainer:
settings: Settings
database: Database
database_status: DatabaseStatusRepository
def build_container(settings: Settings) -> ApplicationContainer:
database = Database(settings.database_path)
return ApplicationContainer(
settings=settings,
database=database,
database_status=DatabaseStatusRepository(database),
)
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
import json
import logging
import re
from datetime import datetime
from logging.handlers import RotatingFileHandler
from pathlib import Path
from typing import Any
from zoneinfo import ZoneInfo
SENSITIVE_KEYS = (
"api_key",
"authorization",
"cookie",
"password",
"refresh_token",
"secret",
"token",
)
SENSITIVE_VALUE_PATTERN = re.compile(
r"(?i)\b(api[_-]?key|authorization|password|refresh[_-]?token|secret|token)"
r"\s*[:=]\s*[^\s,;]+"
)
BEARER_PATTERN = re.compile(r"(?i)\bbearer\s+[a-z0-9._~+/-]{8,}")
SHANGHAI_TIMEZONE = ZoneInfo("Asia/Shanghai")
def redact(value: Any) -> Any:
if isinstance(value, dict):
return {
str(key): "[REDACTED]" if _is_sensitive(str(key)) else redact(item)
for key, item in value.items()
}
if isinstance(value, (list, tuple)):
return [redact(item) for item in value]
if isinstance(value, str):
safe = SENSITIVE_VALUE_PATTERN.sub(lambda match: f"{match.group(1)}=[REDACTED]", value)
return BEARER_PATTERN.sub("Bearer [REDACTED]", safe)
return value
def _is_sensitive(key: str) -> bool:
normalized = key.lower().replace("-", "_")
return any(marker in normalized for marker in SENSITIVE_KEYS)
class JsonFormatter(logging.Formatter):
def format(self, record: logging.LogRecord) -> str:
payload: dict[str, Any] = {
"timestamp": datetime.now(SHANGHAI_TIMEZONE).isoformat(timespec="milliseconds"),
"level": record.levelname.lower(),
"logger": record.name,
"message": record.getMessage(),
}
for field in ("request_id", "event", "context"):
if hasattr(record, field):
payload[field] = redact(getattr(record, field))
if record.exc_info:
payload["exception"] = record.exc_info[0].__name__
return json.dumps(redact(payload), ensure_ascii=False, separators=(",", ":"))
def configure_logging(level: str, log_file: Path | None) -> None:
handlers: list[logging.Handler] = [logging.StreamHandler()]
if log_file is not None:
log_file.parent.mkdir(parents=True, exist_ok=True)
handlers.append(
RotatingFileHandler(
log_file,
maxBytes=10 * 1024 * 1024,
backupCount=3,
encoding="utf-8",
)
)
formatter = JsonFormatter()
for handler in handlers:
handler.setFormatter(formatter)
logging.basicConfig(level=level, handlers=handlers, force=True)
+66 -1
View File
@@ -4,19 +4,84 @@ import os
from dataclasses import dataclass
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
class ConfigurationError(ValueError):
pass
def _resolve_path(raw_value: str, default: Path) -> Path:
candidate = Path(raw_value).expanduser() if raw_value.strip() else default
if not candidate.is_absolute():
candidate = PROJECT_ROOT / candidate
return candidate.resolve()
def _parse_port(raw_value: str) -> int:
try:
port = int(raw_value)
except ValueError as exc:
raise ConfigurationError("APP_PORT must be an integer") from exc
if not 1 <= port <= 65535:
raise ConfigurationError("APP_PORT must be between 1 and 65535")
return port
@dataclass(frozen=True, slots=True)
class Settings:
environment: str
debug: bool
data_directory: Path
database_path: Path
log_file: Path | None
log_level: str
host: str
port: int
timezone: str = "Asia/Shanghai"
@classmethod
def from_environment(cls) -> Settings:
environment = os.getenv("APP_ENV", "development").strip().lower()
data_directory = Path(os.getenv("APP_DATA_DIR", "data")).resolve()
if environment not in {"development", "test", "production"}:
raise ConfigurationError("APP_ENV must be development, test, or production")
timezone = os.getenv("APP_TIMEZONE", "Asia/Shanghai").strip()
if timezone != "Asia/Shanghai":
raise ConfigurationError("APP_TIMEZONE must be Asia/Shanghai")
data_directory = _resolve_path(os.getenv("APP_DATA_DIR", ""), PROJECT_ROOT / "data")
database_path = _resolve_path(
os.getenv("APP_DATABASE_PATH", ""), data_directory / "xiaobai.db"
)
log_file = _resolve_path(
os.getenv("APP_LOG_FILE", ""),
data_directory / "logs" / "application.log",
)
log_level = os.getenv("APP_LOG_LEVEL", "INFO").strip().upper()
if log_level not in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}:
raise ConfigurationError("APP_LOG_LEVEL is invalid")
return cls(
environment=environment,
debug=environment == "development",
data_directory=data_directory,
database_path=database_path,
log_file=log_file,
log_level=log_level,
host=os.getenv("APP_HOST", "127.0.0.1").strip() or "127.0.0.1",
port=_parse_port(os.getenv("APP_PORT", "8780")),
timezone=timezone,
)
@classmethod
def for_test(cls, data_directory: Path) -> Settings:
root = data_directory.resolve()
return cls(
environment="test",
debug=False,
data_directory=root,
database_path=root / "xiaobai-test.db",
log_file=None,
log_level="CRITICAL",
host="127.0.0.1",
port=8780,
)
+4
View File
@@ -0,0 +1,4 @@
from backend.database.connection import Database
from backend.database.migrations import MIGRATIONS, Migration, MigrationError, MigrationRunner
__all__ = ["MIGRATIONS", "Database", "Migration", "MigrationError", "MigrationRunner"]
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
import sqlite3
from collections.abc import Iterator
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True, slots=True)
class Database:
path: Path
timeout_seconds: float = 20.0
def connect(self) -> sqlite3.Connection:
self.path.parent.mkdir(parents=True, exist_ok=True)
connection = sqlite3.connect(self.path, timeout=self.timeout_seconds)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
connection.execute("PRAGMA journal_mode = WAL")
connection.execute(f"PRAGMA busy_timeout = {int(self.timeout_seconds * 1000)}")
return connection
@contextmanager
def read(self) -> Iterator[sqlite3.Connection]:
connection = self.connect()
try:
yield connection
finally:
connection.close()
@contextmanager
def transaction(self) -> Iterator[sqlite3.Connection]:
connection = self.connect()
try:
connection.execute("BEGIN IMMEDIATE")
yield connection
connection.commit()
except Exception:
connection.rollback()
raise
finally:
connection.close()
@@ -0,0 +1,4 @@
from backend.database.migrations.registry import MIGRATIONS
from backend.database.migrations.runner import Migration, MigrationError, MigrationRunner
__all__ = ["MIGRATIONS", "Migration", "MigrationError", "MigrationRunner"]
@@ -0,0 +1,3 @@
from backend.database.migrations.runner import Migration
MIGRATIONS: tuple[Migration, ...] = ()
+148
View File
@@ -0,0 +1,148 @@
from __future__ import annotations
import hashlib
import sqlite3
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from datetime import UTC, datetime
from backend.database.connection import Database
MigrationAction = Callable[[sqlite3.Connection], None]
class MigrationError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class Migration:
version: int
name: str
signature: str
upgrade: MigrationAction
downgrade: MigrationAction
@property
def checksum(self) -> str:
source = f"{self.version}:{self.name}:{self.signature}"
return hashlib.sha256(source.encode("utf-8")).hexdigest()
class MigrationRunner:
def __init__(self, database: Database) -> None:
self._database = database
def upgrade(self, migrations: Iterable[Migration]) -> tuple[int, ...]:
ordered = self._validate(migrations)
with self._database.transaction() as connection:
self._ensure_ledger(connection)
applied = self._applied(connection)
self._verify_history(ordered, applied)
completed: list[int] = []
for migration in ordered:
if migration.version in applied:
continue
self._run_action(connection, migration, migration.upgrade, "upgrade")
connection.execute(
"""
INSERT INTO schema_migrations (version, name, checksum, applied_at)
VALUES (?, ?, ?, ?)
""",
(
migration.version,
migration.name,
migration.checksum,
datetime.now(UTC).isoformat(timespec="seconds"),
),
)
completed.append(migration.version)
return tuple(completed)
def downgrade(self, migrations: Iterable[Migration], target_version: int) -> tuple[int, ...]:
if target_version < 0:
raise MigrationError("Target version cannot be negative")
ordered = self._validate(migrations)
by_version = {migration.version: migration for migration in ordered}
with self._database.transaction() as connection:
self._ensure_ledger(connection)
applied = self._applied(connection)
self._verify_history(ordered, applied)
pending = sorted(
(version for version in applied if version > target_version), reverse=True
)
rolled_back: list[int] = []
for version in pending:
migration = by_version[version]
self._run_action(connection, migration, migration.downgrade, "downgrade")
connection.execute("DELETE FROM schema_migrations WHERE version = ?", (version,))
rolled_back.append(version)
return tuple(rolled_back)
@staticmethod
def _validate(migrations: Iterable[Migration]) -> tuple[Migration, ...]:
ordered = tuple(sorted(migrations, key=lambda migration: migration.version))
versions = [migration.version for migration in ordered]
if any(version <= 0 for version in versions):
raise MigrationError("Migration versions must be positive integers")
if len(versions) != len(set(versions)):
raise MigrationError("Migration versions must be unique")
return ordered
@staticmethod
def _ensure_ledger(connection: sqlite3.Connection) -> None:
connection.execute(
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
checksum TEXT NOT NULL,
applied_at TEXT NOT NULL
)
"""
)
@staticmethod
def _applied(connection: sqlite3.Connection) -> dict[int, str]:
return {
int(row["version"]): str(row["checksum"])
for row in connection.execute(
"SELECT version, checksum FROM schema_migrations ORDER BY version"
)
}
@staticmethod
def _verify_history(migrations: tuple[Migration, ...], applied: dict[int, str]) -> None:
by_version = {migration.version: migration for migration in migrations}
known_versions = sorted(by_version)
applied_versions = sorted(applied)
unknown = sorted(set(applied) - set(known_versions))
if unknown:
values = ", ".join(f"{version:04d}" for version in unknown)
raise MigrationError(f"Database contains unknown migrations: {values}")
if applied_versions != known_versions[: len(applied_versions)]:
raise MigrationError("Database migration history is not contiguous")
for version in applied_versions:
if applied[version] != by_version[version].checksum:
raise MigrationError(
f"Migration checksum changed: {version:04d} {by_version[version].name}"
)
@staticmethod
def _run_action(
connection: sqlite3.Connection,
migration: Migration,
action: MigrationAction,
direction: str,
) -> None:
savepoint = f"migration_{migration.version:04d}_{direction}"
connection.execute(f"SAVEPOINT {savepoint}")
try:
action(connection)
connection.execute(f"RELEASE SAVEPOINT {savepoint}")
except Exception as exc:
connection.execute(f"ROLLBACK TO SAVEPOINT {savepoint}")
connection.execute(f"RELEASE SAVEPOINT {savepoint}")
raise MigrationError(
f"Migration {direction} failed: {migration.version:04d} {migration.name}"
) from exc
@@ -0,0 +1,3 @@
from backend.database.repositories.status import DatabaseStatus, DatabaseStatusRepository
__all__ = ["DatabaseStatus", "DatabaseStatusRepository"]
@@ -0,0 +1,28 @@
from __future__ import annotations
import sqlite3
from dataclasses import dataclass
from backend.database.connection import Database
@dataclass(frozen=True, slots=True)
class DatabaseStatus:
available: bool
schema_version: int
class DatabaseStatusRepository:
def __init__(self, database: Database) -> None:
self._database = database
def get(self) -> DatabaseStatus:
try:
with self._database.read() as connection:
connection.execute("SELECT 1").fetchone()
row = connection.execute(
"SELECT COALESCE(MAX(version), 0) AS version FROM schema_migrations"
).fetchone()
return DatabaseStatus(available=True, schema_version=int(row["version"]))
except (OSError, sqlite3.Error):
return DatabaseStatus(available=False, schema_version=0)
+73 -12
View File
@@ -1,22 +1,83 @@
from __future__ import annotations
import uuid
import logging
from dataclasses import dataclass
from http import HTTPStatus
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException
logger = logging.getLogger(__name__)
@dataclass(frozen=True, slots=True)
class AppError(Exception):
code: str
message: str
status_code: int = HTTPStatus.BAD_REQUEST
def _request_id(request: Request) -> str:
return str(getattr(request.state, "request_id", "unavailable"))
def _response(request: Request, status_code: int, code: str, message: str) -> JSONResponse:
request_id = _request_id(request)
return JSONResponse(
status_code=status_code,
headers={"X-Request-ID": request_id},
content={
"error": {
"code": code,
"message": message,
"request_id": request_id,
}
},
)
def install_error_handlers(application: FastAPI) -> None:
@application.exception_handler(AppError)
async def application_error(request: Request, error: AppError) -> JSONResponse:
return _response(request, error.status_code, error.code, error.message)
@application.exception_handler(RequestValidationError)
async def validation_error(request: Request, _error: RequestValidationError) -> JSONResponse:
return _response(
request,
HTTPStatus.UNPROCESSABLE_ENTITY,
"invalid_request",
"请求内容不符合要求,请检查后重试。",
)
@application.exception_handler(HTTPException)
async def http_error(request: Request, error: HTTPException) -> JSONResponse:
code = {
HTTPStatus.UNAUTHORIZED: "authentication_required",
HTTPStatus.FORBIDDEN: "access_denied",
HTTPStatus.NOT_FOUND: "not_found",
HTTPStatus.CONFLICT: "conflict",
}.get(error.status_code, "request_failed")
message = {
HTTPStatus.UNAUTHORIZED: "请先登录。",
HTTPStatus.FORBIDDEN: "当前账号无权执行此操作。",
HTTPStatus.NOT_FOUND: "请求的内容不存在。",
HTTPStatus.CONFLICT: "当前状态已发生变化,请刷新后重试。",
}.get(error.status_code, "请求失败,请稍后重试。")
return _response(request, error.status_code, code, message)
@application.exception_handler(Exception)
async def unexpected_error(_request: Request, _error: Exception) -> JSONResponse:
correlation_id = uuid.uuid4().hex
return JSONResponse(
status_code=500,
content={
"error": {
"code": "internal_error",
"message": "服务暂时不可用,请稍后重试。",
"correlation_id": correlation_id,
}
},
async def unexpected_error(request: Request, error: Exception) -> JSONResponse:
logger.exception(
"Unhandled request failure",
exc_info=(type(error), error, error.__traceback__),
extra={"request_id": _request_id(request), "event": "request.failed"},
)
return _response(
request,
HTTPStatus.INTERNAL_SERVER_ERROR,
"internal_error",
"服务暂时不可用,请稍后重试。",
)
+15
View File
@@ -0,0 +1,15 @@
from __future__ import annotations
import uuid
from fastapi import FastAPI, Request
def install_request_context(application: FastAPI) -> None:
@application.middleware("http")
async def request_context(request: Request, call_next):
request_id = uuid.uuid4().hex
request.state.request_id = request_id
response = await call_next(request)
response.headers["X-Request-ID"] = request_id
return response
+7 -1
View File
@@ -7,11 +7,17 @@ router = APIRouter(tags=["health"])
class HealthResponse(BaseModel):
status: str
environment: str
components: dict[str, str]
@router.get("/health", response_model=HealthResponse)
def health(request: Request) -> HealthResponse:
database_status = request.app.state.container.database_status.get()
return HealthResponse(
status="ok",
status="ok" if database_status.available else "degraded",
environment=request.app.state.settings.environment,
components={
"process": "ok",
"database": "ok" if database_status.available else "unavailable",
},
)
+2 -2
View File
@@ -29,7 +29,7 @@ describe("api client", () => {
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
error: { code: "unavailable", message: "暂不可用", correlation_id: "abc" },
error: { code: "unavailable", message: "暂不可用", request_id: "abc" },
}),
{ status: 503, headers: { "Content-Type": "application/json" } },
),
@@ -42,7 +42,7 @@ describe("api client", () => {
message: "暂不可用",
status: 503,
code: "unavailable",
correlationId: "abc",
requestId: "abc",
});
});
});
+3 -3
View File
@@ -2,7 +2,7 @@ type ApiErrorPayload = {
error?: {
code?: string;
message?: string;
correlation_id?: string;
request_id?: string;
};
};
@@ -11,7 +11,7 @@ export class ApiError extends Error {
message: string,
readonly status: number,
readonly code: string,
readonly correlationId?: string,
readonly requestId?: string,
) {
super(message);
}
@@ -29,7 +29,7 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
payload.error?.message ?? "请求失败,请稍后重试。",
response.status,
payload.error?.code ?? "request_failed",
payload.error?.correlation_id,
payload.error?.request_id,
);
}
return payload;
+1
View File
@@ -1,3 +1,4 @@
fastapi==0.141.0
pydantic==2.13.4
tzdata==2026.3
uvicorn==0.52.0
+22
View File
@@ -0,0 +1,22 @@
import pytest
from tools.database import main
def test_status_uses_configured_data_directory(tmp_path, monkeypatch, capsys) -> None:
monkeypatch.setenv("APP_ENV", "test")
monkeypatch.setenv("APP_DATA_DIR", str(tmp_path))
monkeypatch.delenv("APP_DATABASE_PATH", raising=False)
assert main(["status"]) == 0
assert capsys.readouterr().out.strip() == "available=true schema_version=0"
assert (tmp_path / "xiaobai.db").exists()
def test_downgrade_requires_explicit_confirmation(tmp_path, monkeypatch) -> None:
monkeypatch.setenv("APP_ENV", "test")
monkeypatch.setenv("APP_DATA_DIR", str(tmp_path))
with pytest.raises(SystemExit, match="confirm-downgrade"):
main(["downgrade", "--target", "0"])
+67 -14
View File
@@ -1,36 +1,89 @@
from fastapi.testclient import TestClient
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())
def test_health_reports_runtime_environment(tmp_path) -> None:
application = create_application(
Settings(environment="test", debug=False, data_directory=tmp_path)
)
with TestClient(application) as client:
response = client.get("/api/health")
application = create_application(Settings.for_test(tmp_path))
response = request(application, "/api/health")
assert response.status_code == 200
assert response.json() == {"status": "ok", "environment": "test"}
assert response.json() == {
"status": "ok",
"environment": "test",
"components": {"process": "ok", "database": "ok"},
}
assert len(response.headers["X-Request-ID"]) == 32
def test_unknown_failure_uses_safe_error_contract(tmp_path) -> None:
application = create_application(
Settings(environment="test", debug=False, data_directory=tmp_path)
)
application = create_application(Settings.for_test(tmp_path))
@application.get("/api/test/failure")
def fail() -> None:
raise RuntimeError("secret provider details")
with TestClient(application, raise_server_exceptions=False) as client:
response = client.get("/api/test/failure")
response = request(application, "/api/test/failure")
payload = response.json()["error"]
assert response.status_code == 500
assert payload["code"] == "internal_error"
assert payload["message"] == "服务暂时不可用,请稍后重试。"
assert "secret provider details" not in response.text
assert len(payload["correlation_id"]) == 32
assert len(payload["request_id"]) == 32
assert response.headers["X-Request-ID"] == payload["request_id"]
def test_application_error_uses_business_message(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path))
@application.get("/api/test/locked")
def locked() -> None:
raise AppError("membership_required", "该功能仅对会员开放。", 403)
response = request(application, "/api/test/locked")
assert response.status_code == 403
assert response.json()["error"]["code"] == "membership_required"
assert response.json()["error"]["message"] == "该功能仅对会员开放。"
def test_framework_404_uses_the_same_safe_contract(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path))
response = request(application, "/api/does-not-exist")
assert response.status_code == 404
assert response.json()["error"]["code"] == "not_found"
assert response.json()["error"]["message"] == "请求的内容不存在。"
def test_validation_error_does_not_expose_framework_details(tmp_path) -> None:
application = create_application(Settings.for_test(tmp_path))
@application.get("/api/test/validated")
def validated(value: int = Query(ge=1)) -> dict[str, int]:
return {"value": value}
response = request(application, "/api/test/validated?value=wrong")
assert response.status_code == 422
assert response.json()["error"]["code"] == "invalid_request"
assert "integer" not in response.text.lower()
+33
View File
@@ -0,0 +1,33 @@
import json
import logging
from backend.bootstrap.logging import JsonFormatter, redact
def test_nested_sensitive_values_are_redacted() -> None:
payload = {
"account": "leefer",
"api_key": "private-key",
"nested": {"refresh-token": "private-token", "count": 3},
}
assert redact(payload) == {
"account": "leefer",
"api_key": "[REDACTED]",
"nested": {"refresh-token": "[REDACTED]", "count": 3},
}
def test_json_formatter_keeps_context_without_secret_values() -> None:
record = logging.LogRecord(
"test", logging.INFO, __file__, 1, "saved token=plain-secret", (), None
)
record.request_id = "request-1"
record.context = {"token": "hidden", "feature": "health"}
payload = json.loads(JsonFormatter().format(record))
assert payload["timestamp"].endswith("+08:00")
assert payload["request_id"] == "request-1"
assert payload["message"] == "saved token=[REDACTED]"
assert payload["context"] == {"token": "[REDACTED]", "feature": "health"}
+106
View File
@@ -0,0 +1,106 @@
from __future__ import annotations
import sqlite3
import pytest
from backend.database import Database, Migration, MigrationError, MigrationRunner
from backend.database.repositories import DatabaseStatusRepository
def create_example(connection: sqlite3.Connection) -> None:
connection.execute("CREATE TABLE example (id INTEGER PRIMARY KEY, value TEXT NOT NULL)")
def drop_example(connection: sqlite3.Connection) -> None:
connection.execute("DROP TABLE example")
def example_migration(signature: str = "example:v1") -> Migration:
return Migration(1, "create_example", signature, create_example, drop_example)
def table_names(database: Database) -> set[str]:
with database.read() as connection:
return {
str(row["name"])
for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")
}
def test_connection_enables_wal_foreign_keys_and_busy_timeout(tmp_path) -> None:
database = Database(tmp_path / "app.db")
with database.read() as connection:
assert connection.execute("PRAGMA journal_mode").fetchone()[0] == "wal"
assert connection.execute("PRAGMA foreign_keys").fetchone()[0] == 1
assert connection.execute("PRAGMA busy_timeout").fetchone()[0] == 20_000
def test_migration_can_upgrade_idempotently_and_downgrade(tmp_path) -> None:
database = Database(tmp_path / "app.db")
runner = MigrationRunner(database)
migration = example_migration()
assert runner.upgrade((migration,)) == (1,)
assert runner.upgrade((migration,)) == ()
assert "example" in table_names(database)
assert DatabaseStatusRepository(database).get().schema_version == 1
assert runner.downgrade((migration,), target_version=0) == (1,)
assert "example" not in table_names(database)
assert DatabaseStatusRepository(database).get().schema_version == 0
def test_failed_migration_is_atomic_and_not_recorded(tmp_path) -> None:
database = Database(tmp_path / "app.db")
def fail(connection: sqlite3.Connection) -> None:
connection.execute("CREATE TABLE should_rollback (id INTEGER)")
raise RuntimeError("stop")
migration = Migration(1, "failure", "failure:v1", fail, lambda connection: None)
with pytest.raises(MigrationError, match="upgrade failed"):
MigrationRunner(database).upgrade((migration,))
assert "should_rollback" not in table_names(database)
assert DatabaseStatusRepository(database).get().schema_version == 0
def test_applied_migration_checksum_cannot_change(tmp_path) -> None:
database = Database(tmp_path / "app.db")
runner = MigrationRunner(database)
runner.upgrade((example_migration(),))
with pytest.raises(MigrationError, match="checksum changed"):
runner.upgrade((example_migration("example:v2"),))
with pytest.raises(MigrationError, match="checksum changed"):
runner.downgrade((example_migration("example:v2"),), target_version=0)
def test_unknown_database_migration_is_rejected(tmp_path) -> None:
database = Database(tmp_path / "app.db")
runner = MigrationRunner(database)
runner.upgrade((example_migration(),))
with pytest.raises(MigrationError, match="unknown migrations"):
runner.upgrade(())
def test_non_contiguous_database_history_is_rejected(tmp_path) -> None:
database = Database(tmp_path / "app.db")
first = example_migration()
second = Migration(
2,
"second",
"second:v1",
lambda connection: None,
lambda connection: None,
)
runner = MigrationRunner(database)
runner.upgrade((first, second))
with database.transaction() as connection:
connection.execute("DELETE FROM schema_migrations WHERE version = 1")
with pytest.raises(MigrationError, match="not contiguous"):
runner.upgrade((first, second))
+34
View File
@@ -0,0 +1,34 @@
from pathlib import Path
import pytest
from backend.bootstrap.settings import PROJECT_ROOT, ConfigurationError, Settings
def test_relative_data_directory_is_anchored_to_project(monkeypatch) -> None:
monkeypatch.setenv("APP_DATA_DIR", "var/test-data")
monkeypatch.delenv("APP_DATABASE_PATH", raising=False)
settings = Settings.from_environment()
assert settings.data_directory == (PROJECT_ROOT / "var/test-data").resolve()
assert settings.database_path == settings.data_directory / "xiaobai.db"
assert settings.timezone == "Asia/Shanghai"
@pytest.mark.parametrize(
("name", "value"),
[("APP_PORT", "70000"), ("APP_PORT", "wrong"), ("APP_TIMEZONE", "UTC")],
)
def test_invalid_runtime_configuration_fails_fast(monkeypatch, name: str, value: str) -> None:
monkeypatch.setenv(name, value)
with pytest.raises(ConfigurationError):
Settings.from_environment()
def test_test_settings_do_not_create_log_files(tmp_path: Path) -> None:
settings = Settings.for_test(tmp_path)
assert settings.log_file is None
assert settings.database_path.parent == tmp_path
+1
View File
@@ -0,0 +1 @@
"""Maintenance commands for the rebuilt application."""
+45
View File
@@ -0,0 +1,45 @@
from __future__ import annotations
import argparse
from collections.abc import Sequence
from backend.bootstrap.settings import Settings
from backend.database import MIGRATIONS, Database, MigrationRunner
from backend.database.repositories import DatabaseStatusRepository
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Manage the rebuilt application database")
subcommands = parser.add_subparsers(dest="command", required=True)
subcommands.add_parser("status", help="Show database availability and schema version")
subcommands.add_parser("upgrade", help="Apply all pending migrations")
downgrade = subcommands.add_parser("downgrade", help="Roll back to a schema version")
downgrade.add_argument("--target", type=int, required=True)
downgrade.add_argument("--confirm-downgrade", action="store_true")
return parser
def main(arguments: Sequence[str] | None = None) -> int:
parsed = build_parser().parse_args(arguments)
settings = Settings.from_environment()
database = Database(settings.database_path)
runner = MigrationRunner(database)
if parsed.command == "status":
runner.upgrade(())
status = DatabaseStatusRepository(database).get()
print(f"available={str(status.available).lower()} schema_version={status.schema_version}")
return 0
if parsed.command == "upgrade":
applied = runner.upgrade(MIGRATIONS)
print("applied=" + (",".join(f"{version:04d}" for version in applied) or "none"))
return 0
if not parsed.confirm_downgrade:
raise SystemExit("downgrade requires --confirm-downgrade")
rolled_back = runner.downgrade(MIGRATIONS, parsed.target)
print("rolled_back=" + (",".join(f"{version:04d}" for version in rolled_back) or "none"))
return 0
if __name__ == "__main__":
raise SystemExit(main())