rebuild(stage-15): complete governance and handoff
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import pytest
|
||||
|
||||
from backend.database import MIGRATIONS, Database, MigrationRunner
|
||||
from tools.database import main
|
||||
|
||||
|
||||
@@ -14,6 +15,19 @@ def test_status_uses_configured_data_directory(tmp_path, monkeypatch, capsys) ->
|
||||
assert (tmp_path / "xiaobai.db").exists()
|
||||
|
||||
|
||||
def test_status_reads_an_existing_schema_without_mutating_history(
|
||||
tmp_path, monkeypatch, capsys
|
||||
) -> None:
|
||||
database_path = tmp_path / "existing.db"
|
||||
MigrationRunner(Database(database_path)).upgrade(MIGRATIONS)
|
||||
monkeypatch.setenv("APP_ENV", "test")
|
||||
monkeypatch.setenv("APP_DATABASE_PATH", str(database_path))
|
||||
|
||||
assert main(["status"]) == 0
|
||||
|
||||
assert capsys.readouterr().out.strip() == "available=true schema_version=10"
|
||||
|
||||
|
||||
def test_downgrade_requires_explicit_confirmation(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setenv("APP_ENV", "test")
|
||||
monkeypatch.setenv("APP_DATA_DIR", str(tmp_path))
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
import asyncio
|
||||
from dataclasses import replace
|
||||
|
||||
import httpx
|
||||
from cryptography.fernet import Fernet
|
||||
from fastapi import Query
|
||||
|
||||
from backend.bootstrap.application import create_application
|
||||
@@ -17,6 +22,8 @@ def test_health_reports_runtime_environment(tmp_path) -> None:
|
||||
"components": {"process": "ok", "database": "ok"},
|
||||
}
|
||||
assert len(response.headers["X-Request-ID"]) == 32
|
||||
assert response.headers["X-Content-Type-Options"] == "nosniff"
|
||||
assert response.headers["X-Frame-Options"] == "DENY"
|
||||
|
||||
|
||||
def test_unknown_failure_uses_safe_error_contract(tmp_path) -> None:
|
||||
@@ -81,13 +88,38 @@ def test_production_frontend_serves_spa_without_capturing_api_404(tmp_path) -> N
|
||||
settings.frontend_dist_directory.joinpath("index.html").write_text(
|
||||
"<main>application</main>", encoding="utf-8"
|
||||
)
|
||||
settings.frontend_dist_directory.joinpath("asset.js").write_text(
|
||||
settings.frontend_dist_directory.joinpath("assets").mkdir()
|
||||
settings.frontend_dist_directory.joinpath("assets", "asset.js").write_text(
|
||||
"window.ready=true", encoding="utf-8"
|
||||
)
|
||||
application = create_application(settings)
|
||||
|
||||
assert request(application, "/review/history").text == "<main>application</main>"
|
||||
assert request(application, "/asset.js").text == "window.ready=true"
|
||||
asset_response = request(application, "/assets/asset.js")
|
||||
assert asset_response.text == "window.ready=true"
|
||||
assert asset_response.headers["Cache-Control"] == "public,max-age=31536000,immutable"
|
||||
api_response = request(application, "/api/unknown")
|
||||
assert api_response.status_code == 404
|
||||
assert api_response.json()["error"]["code"] == "not_found"
|
||||
|
||||
|
||||
def test_production_security_headers_are_strict_on_https(tmp_path) -> None:
|
||||
settings = Settings.for_test(tmp_path)
|
||||
settings = replace(
|
||||
settings,
|
||||
environment="production",
|
||||
encryption_key=Fernet.generate_key().decode("ascii"),
|
||||
)
|
||||
application = create_application(settings)
|
||||
|
||||
async def get():
|
||||
transport = httpx.ASGITransport(app=application)
|
||||
async with application.router.lifespan_context(application):
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://testserver"
|
||||
) as client:
|
||||
return await client.get("/api/health")
|
||||
|
||||
response = asyncio.run(get())
|
||||
assert "default-src 'self'" in response.headers["Content-Security-Policy"]
|
||||
assert response.headers["Strict-Transport-Security"].startswith("max-age=31536000")
|
||||
|
||||
@@ -21,7 +21,7 @@ from backend.data.policy import DataPolicyError, DataSourcePolicy
|
||||
from backend.data.providers.ifind import IfindProvider
|
||||
from backend.data.providers.tushare import TushareProvider
|
||||
from backend.data.repository import MarketRepository
|
||||
from backend.data.sentiment import calculate_sentiment
|
||||
from backend.data.sentiment import _phase, calculate_sentiment
|
||||
from backend.database.connection import Database
|
||||
from backend.database.migrations import MIGRATIONS, MigrationRunner
|
||||
from backend.features.market.snapshot import build_snapshot
|
||||
@@ -403,6 +403,25 @@ def test_sentiment_has_all_weighted_components_and_extreme_risk_cap() -> None:
|
||||
}
|
||||
|
||||
|
||||
def test_sentiment_phase_transitions_require_confirmed_recovery_and_fermentation() -> None:
|
||||
phase, _reason = _phase(
|
||||
{"phase": "冰点"}, 28, 3, 40, 45, 50, "修复", False
|
||||
)
|
||||
assert phase == "冰点"
|
||||
|
||||
phase, _reason = _phase(
|
||||
{"phase": "修复", "fermentation_signal_count": 0},
|
||||
55,
|
||||
8,
|
||||
60,
|
||||
55,
|
||||
60,
|
||||
"发酵",
|
||||
False,
|
||||
)
|
||||
assert phase == "修复"
|
||||
|
||||
|
||||
def test_incomplete_daily_snapshot_is_rejected_without_overwriting(tmp_path) -> None:
|
||||
database = Database(tmp_path / "sync.db")
|
||||
MigrationRunner(database).upgrade(MIGRATIONS)
|
||||
|
||||
Reference in New Issue
Block a user