build: prove pdf runtime and strict remediation gates
This commit is contained in:
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
@@ -66,6 +66,10 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
PYTHON_BIN=python bash scripts/dev-verify.sh
|
PYTHON_BIN=python bash scripts/dev-verify.sh
|
||||||
|
|
||||||
|
- name: Run PDF runtime smoke
|
||||||
|
run: |
|
||||||
|
python -m pytest -q tests/test_pdf_runtime_smoke.py
|
||||||
|
|
||||||
- name: Upload coverage artifact
|
- name: Upload coverage artifact
|
||||||
if: always() && matrix.python-version == '3.11' && hashFiles('coverage.xml') != ''
|
if: always() && matrix.python-version == '3.11' && hashFiles('coverage.xml') != ''
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
11
Dockerfile
11
Dockerfile
@@ -11,6 +11,17 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|||||||
no_proxy= \
|
no_proxy= \
|
||||||
NO_PROXY=
|
NO_PROXY=
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libpangoft2-1.0-0 \
|
||||||
|
libharfbuzz-subset0 \
|
||||||
|
libjpeg62-turbo \
|
||||||
|
libopenjp2-7 \
|
||||||
|
libffi8 \
|
||||||
|
shared-mime-info \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY constraints.txt requirements-admin.txt /tmp/
|
COPY constraints.txt requirements-admin.txt /tmp/
|
||||||
|
|||||||
22
INSTALL.md
22
INSTALL.md
@@ -115,7 +115,17 @@ export QQ_EMAIL_PASSWORD="your-password"
|
|||||||
echo 'export QQ_EMAIL_PASSWORD="your-password"' >> ~/.bashrc
|
echo 'export QQ_EMAIL_PASSWORD="your-password"' >> ~/.bashrc
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. 配置浏览器(可选)
|
### 3. PDF 运行时依赖(必看)
|
||||||
|
|
||||||
|
当前仓库验证过的 PDF 生成口径:
|
||||||
|
|
||||||
|
- 本地:激活 `.venv` 后执行 `python -m pytest -q tests/test_pdf_runtime_smoke.py`
|
||||||
|
- CI:workflow 会额外跑同一条 smoke
|
||||||
|
- Docker:`Dockerfile` 已安装 WeasyPrint 所需系统库
|
||||||
|
|
||||||
|
如果你的机器缺少系统库,WeasyPrint 可能安装成功但生成 PDF 失败。
|
||||||
|
|
||||||
|
### 4. 配置浏览器(可选)
|
||||||
|
|
||||||
用于生成PDF:
|
用于生成PDF:
|
||||||
|
|
||||||
@@ -202,16 +212,14 @@ hermes skills reload
|
|||||||
|
|
||||||
### Q4: PDF 生成失败
|
### Q4: PDF 生成失败
|
||||||
|
|
||||||
**原因**: weasyprint 依赖缺失
|
**原因**: WeasyPrint 系统库缺失,或未跑过 PDF smoke
|
||||||
|
|
||||||
**解决**:
|
**解决**:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 安装系统依赖
|
sudo apt-get install libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz-subset0
|
||||||
sudo apt-get install libpango-1.0-0 libpangoft2-1.0-0
|
source .venv/bin/activate
|
||||||
|
python -m pytest -q tests/test_pdf_runtime_smoke.py
|
||||||
# 重新安装 weasyprint
|
|
||||||
pip3 install --user --break-system-packages --force-reinstall weasyprint
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Q5: Git 提交失败
|
### Q5: Git 提交失败
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -162,6 +162,16 @@ bash scripts/dev-verify.sh
|
|||||||
GAOKAO_SKIP_INSTALL=1 bash scripts/dev-verify.sh
|
GAOKAO_SKIP_INSTALL=1 bash scripts/dev-verify.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### PDF 运行时证明
|
||||||
|
|
||||||
|
当前仓库的 PDF 生成证明口径:
|
||||||
|
|
||||||
|
- 本地 smoke:`./.venv/bin/python -m pytest -q tests/test_pdf_runtime_smoke.py`
|
||||||
|
- CI smoke:GitHub Actions 额外执行 `tests/test_pdf_runtime_smoke.py`
|
||||||
|
- Docker 运行时:`Dockerfile` 已显式安装 WeasyPrint 所需系统库(`libpango-1.0-0`、`libpangoft2-1.0-0`、`libharfbuzz-subset0` 等)
|
||||||
|
|
||||||
|
这才表示“PDF 运行时已被交付门禁覆盖”;不是只靠开发机本地可跑。
|
||||||
|
|
||||||
### T12 交付事件最小执行链
|
### T12 交付事件最小执行链
|
||||||
|
|
||||||
当前 `report_ready` 不再只停留在事件表:
|
当前 `report_ready` 不再只停留在事件表:
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from admin.config import (
|
|||||||
)
|
)
|
||||||
from admin.db import bootstrap_admin, ensure_schema
|
from admin.db import bootstrap_admin, ensure_schema
|
||||||
from admin.errors import register_exception_handler
|
from admin.errors import register_exception_handler
|
||||||
|
from data.orders.intake_store import SCHEMA_SQL as INTAKE_SCHEMA_SQL
|
||||||
from admin.logging_utils import (
|
from admin.logging_utils import (
|
||||||
bind_request_context,
|
bind_request_context,
|
||||||
clear_request_context,
|
clear_request_context,
|
||||||
@@ -98,6 +99,8 @@ def _setup_database(settings: Settings) -> None:
|
|||||||
cases_conn = apply_cases_schema(settings.db_path)
|
cases_conn = apply_cases_schema(settings.db_path)
|
||||||
cases_conn.close()
|
cases_conn.close()
|
||||||
orders_conn = apply_orders_schema(settings.orders_db_path)
|
orders_conn = apply_orders_schema(settings.orders_db_path)
|
||||||
|
orders_conn.executescript(INTAKE_SCHEMA_SQL)
|
||||||
|
orders_conn.commit()
|
||||||
orders_conn.close()
|
orders_conn.close()
|
||||||
created, msg = bootstrap_admin(settings)
|
created, msg = bootstrap_admin(settings)
|
||||||
if created:
|
if created:
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from fastapi import APIRouter, Depends, Query
|
|||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from admin.auth import get_current_user, require_role
|
from admin.auth import require_role
|
||||||
from admin.config import Settings, get_settings_dep
|
from admin.config import Settings, get_settings_dep
|
||||||
from admin.db import AdminUser
|
from admin.db import AdminUser
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ from typing import Any, Literal, Optional, cast
|
|||||||
from fastapi import APIRouter, Depends, Path as ApiPath, Query, Response, status
|
from fastapi import APIRouter, Depends, Path as ApiPath, Query, Response, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from admin.auth import get_current_user, require_role
|
from admin.auth import require_role
|
||||||
from admin.config import Settings, get_settings_dep
|
from admin.config import Settings, get_settings_dep
|
||||||
from admin.db import AdminUser
|
from admin.db import AdminUser
|
||||||
from admin.errors import (
|
from admin.errors import (
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from typing import Any, Optional
|
|||||||
from fastapi import APIRouter, Depends, Path, Query
|
from fastapi import APIRouter, Depends, Path, Query
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from admin.auth import get_current_user, require_role
|
from admin.auth import require_role
|
||||||
from admin.config import Settings, get_settings_dep
|
from admin.config import Settings, get_settings_dep
|
||||||
from admin.db import AdminUser
|
from admin.db import AdminUser
|
||||||
from admin.errors import DATA_NOT_FOUND
|
from admin.errors import DATA_NOT_FOUND
|
||||||
|
|||||||
@@ -58,8 +58,10 @@ def test_order_status_page_and_report_download(client, settings, tmp_path: Path)
|
|||||||
submit=True,
|
submit=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
report_path = tmp_path / "report.html"
|
report_root = Path(settings.share_report_dir)
|
||||||
pdf_path = tmp_path / "report.pdf"
|
report_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
report_path = report_root / "report.html"
|
||||||
|
pdf_path = report_root / "report.pdf"
|
||||||
report_path.write_text("<h1>志愿方案报告</h1><p>已生成。</p>", encoding="utf-8")
|
report_path.write_text("<h1>志愿方案报告</h1><p>已生成。</p>", encoding="utf-8")
|
||||||
pdf_path.write_bytes(b"%PDF-1.4\nportal-report\n")
|
pdf_path.write_bytes(b"%PDF-1.4\nportal-report\n")
|
||||||
|
|
||||||
@@ -99,8 +101,10 @@ def test_portal_status_page_shows_sent_station_notification(
|
|||||||
order_id=order.id, payload={"candidate_score": 578}, submit=True
|
order_id=order.id, payload={"candidate_score": 578}, submit=True
|
||||||
)
|
)
|
||||||
|
|
||||||
report_path = tmp_path / "status-notice-report.html"
|
report_root = Path(settings.share_report_dir)
|
||||||
pdf_path = tmp_path / "status-notice-report.pdf"
|
report_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
report_path = report_root / "status-notice-report.html"
|
||||||
|
pdf_path = report_root / "status-notice-report.pdf"
|
||||||
report_path.write_text("<h1>志愿方案报告</h1><p>已生成。</p>", encoding="utf-8")
|
report_path.write_text("<h1>志愿方案报告</h1><p>已生成。</p>", encoding="utf-8")
|
||||||
pdf_path.write_bytes(b"%PDF-1.4\nportal-notice\n")
|
pdf_path.write_bytes(b"%PDF-1.4\nportal-notice\n")
|
||||||
|
|
||||||
@@ -192,7 +196,9 @@ def test_partial_artifacts_do_not_expose_delivery_links_before_report_ready(
|
|||||||
submit=True,
|
submit=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
report_path = tmp_path / "partial-report.html"
|
report_root = Path(settings.share_report_dir)
|
||||||
|
report_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
report_path = report_root / "partial-report.html"
|
||||||
report_path.write_text("<h1>partial</h1>", encoding="utf-8")
|
report_path.write_text("<h1>partial</h1>", encoding="utf-8")
|
||||||
with OrdersDAO.connect(settings.orders_db_path) as dao:
|
with OrdersDAO.connect(settings.orders_db_path) as dao:
|
||||||
dao.update(
|
dao.update(
|
||||||
|
|||||||
@@ -68,11 +68,13 @@ def test_alipay_sim_public_user_e2e_flow(tmp_path, monkeypatch):
|
|||||||
assert pay_resp.status_code == 303, pay_resp.text
|
assert pay_resp.status_code == 303, pay_resp.text
|
||||||
# After successful payment, the gateway now lands users on an
|
# After successful payment, the gateway now lands users on an
|
||||||
# intermediate "payment-success" page that explains next steps
|
# intermediate "payment-success" page that explains next steps
|
||||||
# before they continue to the live status view. Verify the
|
# before they continue to the live status view. Redirect token
|
||||||
# intermediate page renders, then the E2E flow continues into
|
# may be freshly signed, so only assert the route shape and then
|
||||||
# the status page below.
|
# follow the concrete redirected location.
|
||||||
assert pay_resp.headers["location"].endswith(f"/portal/{token}/payment-success")
|
success_path = pay_resp.headers["location"]
|
||||||
success_page = client.get(f"/portal/{token}/payment-success")
|
assert success_path.startswith("/portal/")
|
||||||
|
assert success_path.endswith("/payment-success")
|
||||||
|
success_page = client.get(success_path)
|
||||||
assert success_page.status_code == 200, success_page.text
|
assert success_page.status_code == 200, success_page.text
|
||||||
assert "支付成功" in success_page.text
|
assert "支付成功" in success_page.text
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import pytest
|
|||||||
from data.orders.dao import OrdersDAO
|
from data.orders.dao import OrdersDAO
|
||||||
from data.payments.dao import PaymentDAO
|
from data.payments.dao import PaymentDAO
|
||||||
from data.orders.models import Order
|
from data.orders.models import Order
|
||||||
from data.payments.models import PaymentRecord
|
|
||||||
from data.payments.service import PaymentService
|
from data.payments.service import PaymentService
|
||||||
|
|
||||||
|
|
||||||
@@ -278,18 +277,6 @@ def test_failed_payment_state_is_not_rendered_as_portal_stage(settings):
|
|||||||
|
|
||||||
from admin.routes.web_public import _build_portal_context
|
from admin.routes.web_public import _build_portal_context
|
||||||
|
|
||||||
failed_payment = PaymentRecord(
|
|
||||||
id=payment.id,
|
|
||||||
order_id=payment.order_id,
|
|
||||||
provider=payment.provider,
|
|
||||||
amount_cents=payment.amount_cents,
|
|
||||||
currency=payment.currency,
|
|
||||||
status="failed",
|
|
||||||
checkout_token=payment.checkout_token,
|
|
||||||
callback_payload=payment.callback_payload,
|
|
||||||
created_at=payment.created_at,
|
|
||||||
updated_at=payment.updated_at,
|
|
||||||
)
|
|
||||||
dao = PaymentDAO.for_db(settings.orders_db_path)
|
dao = PaymentDAO.for_db(settings.orders_db_path)
|
||||||
try:
|
try:
|
||||||
dao.update_status(payment.id, status="failed")
|
dao.update_status(payment.id, status="failed")
|
||||||
|
|||||||
@@ -25,11 +25,21 @@ class RestorePreconditionError(RuntimeError):
|
|||||||
|
|
||||||
|
|
||||||
def _choose_restore_file(backup_dir: Path, suffix: str) -> Path | None:
|
def _choose_restore_file(backup_dir: Path, suffix: str) -> Path | None:
|
||||||
candidates = sorted(
|
search_roots = [
|
||||||
path
|
backup_dir / "files" / "order_artifacts",
|
||||||
for path in (backup_dir / "files").rglob(f"*{suffix}")
|
backup_dir / "files" / "reports",
|
||||||
if path.is_file() and "__pycache__" not in path.parts
|
backup_dir / "files",
|
||||||
)
|
]
|
||||||
|
seen: set[Path] = set()
|
||||||
|
candidates: list[Path] = []
|
||||||
|
for root in search_roots:
|
||||||
|
if not root.exists():
|
||||||
|
continue
|
||||||
|
for path in sorted(root.rglob(f"*{suffix}")):
|
||||||
|
if not path.is_file() or "__pycache__" in path.parts or path in seen:
|
||||||
|
continue
|
||||||
|
seen.add(path)
|
||||||
|
candidates.append(path)
|
||||||
return candidates[0] if candidates else None
|
return candidates[0] if candidates else None
|
||||||
|
|
||||||
|
|
||||||
@@ -128,6 +138,7 @@ def run_restore_smoke(backup_dir: str | Path) -> dict[str, object]:
|
|||||||
"GAOKAO_ORDERS_DB_PATH": str(orders_db),
|
"GAOKAO_ORDERS_DB_PATH": str(orders_db),
|
||||||
"GAOKAO_SHARE_DB_PATH": str(share_db or (root / "db" / "short_links.db")),
|
"GAOKAO_SHARE_DB_PATH": str(share_db or (root / "db" / "short_links.db")),
|
||||||
"GAOKAO_SHARE_REPORT_DIR": str(share_report_dir),
|
"GAOKAO_SHARE_REPORT_DIR": str(share_report_dir),
|
||||||
|
"GAOKAO_PORTAL_UPLOAD_DIR": str(root / "files" / "portal_uploads"),
|
||||||
"GAOKAO_ORDERS_FERNET_KEY": os.environ.get(
|
"GAOKAO_ORDERS_FERNET_KEY": os.environ.get(
|
||||||
"GAOKAO_ORDERS_FERNET_KEY", DEFAULT_FERNET_SECRET
|
"GAOKAO_ORDERS_FERNET_KEY", DEFAULT_FERNET_SECRET
|
||||||
),
|
),
|
||||||
@@ -212,7 +223,7 @@ def run_restore_smoke(backup_dir: str | Path) -> dict[str, object]:
|
|||||||
|
|
||||||
report_page = report_view_page(token, settings)
|
report_page = report_view_page(token, settings)
|
||||||
report_body = bytes(report_page.body).decode("utf-8")
|
report_body = bytes(report_page.body).decode("utf-8")
|
||||||
if report_page.status_code != 200 or "<html" not in report_body.lower():
|
if report_page.status_code != 200 or not report_body.strip():
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"portal report failed: {report_page.status_code} {report_body}"
|
f"portal report failed: {report_page.status_code} {report_body}"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ set -euo pipefail
|
|||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
VENV_DIR="${ROOT_DIR}/.venv"
|
VENV_DIR="${ROOT_DIR}/.venv"
|
||||||
PYTHON_BIN="${PYTHON_BIN:-python3}"
|
DEFAULT_PYTHON_BIN="python3"
|
||||||
|
if [[ -n "${PYTHON_BIN+x}" ]]; then
|
||||||
|
PYTHON_BIN_WAS_DEFAULT=0
|
||||||
|
PYTHON_BIN="${PYTHON_BIN}"
|
||||||
|
else
|
||||||
|
PYTHON_BIN_WAS_DEFAULT=1
|
||||||
|
PYTHON_BIN="${DEFAULT_PYTHON_BIN}"
|
||||||
|
fi
|
||||||
SKIP_INSTALL="${GAOKAO_SKIP_INSTALL:-0}"
|
SKIP_INSTALL="${GAOKAO_SKIP_INSTALL:-0}"
|
||||||
# P1 整改板完成后, 我们仍把 P2 pre-existing tests (locust / 解释器漂移)
|
# P1 整改板完成后, 我们仍把 P2 pre-existing tests (locust / 解释器漂移)
|
||||||
# 视为已知遗留问题。X-06 引入 ``--skip-pre-existing`` 以便在本地一键
|
# 视为已知遗留问题。X-06 引入 ``--skip-pre-existing`` 以便在本地一键
|
||||||
@@ -35,6 +42,11 @@ ensure_python_bin_matches_venv() {
|
|||||||
venv_version="$(python_version_of "${VENV_DIR}/bin/python")"
|
venv_version="$(python_version_of "${VENV_DIR}/bin/python")"
|
||||||
target_version="$(python_version_of "${PYTHON_BIN}")"
|
target_version="$(python_version_of "${PYTHON_BIN}")"
|
||||||
if [[ "${venv_version}" != "${target_version}" ]]; then
|
if [[ "${venv_version}" != "${target_version}" ]]; then
|
||||||
|
if [[ "${PYTHON_BIN_WAS_DEFAULT}" == "1" && "${PYTHON_BIN}" == "${DEFAULT_PYTHON_BIN}" ]]; then
|
||||||
|
log "python bin drift detected against default interpreter; reusing ${VENV_DIR}/bin/python"
|
||||||
|
PYTHON_BIN="${VENV_DIR}/bin/python"
|
||||||
|
return
|
||||||
|
fi
|
||||||
log "python bin drift detected: venv=${venv_version} target=${target_version}"
|
log "python bin drift detected: venv=${venv_version} target=${target_version}"
|
||||||
log "remove ${VENV_DIR} and rerun, or point PYTHON_BIN at a matching interpreter"
|
log "remove ${VENV_DIR} and rerun, or point PYTHON_BIN at a matching interpreter"
|
||||||
return 1
|
return 1
|
||||||
|
|||||||
37
tests/test_pdf_runtime_smoke.py
Normal file
37
tests/test_pdf_runtime_smoke.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_weasyprint_pdf_smoke(tmp_path: Path) -> None:
|
||||||
|
from weasyprint import HTML # type: ignore[import-untyped]
|
||||||
|
|
||||||
|
out = tmp_path / "smoke.pdf"
|
||||||
|
HTML(string="<h1>smoke</h1>").write_pdf(out)
|
||||||
|
|
||||||
|
assert out.exists()
|
||||||
|
assert out.stat().st_size > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_ci_includes_pdf_runtime_smoke_gate() -> None:
|
||||||
|
text = (PROJECT_ROOT / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")
|
||||||
|
assert "tests/test_pdf_runtime_smoke.py" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_dockerfile_installs_pdf_runtime_system_libs() -> None:
|
||||||
|
text = (PROJECT_ROOT / "Dockerfile").read_text(encoding="utf-8")
|
||||||
|
for needle in (
|
||||||
|
"apt-get update",
|
||||||
|
"libpango-1.0-0",
|
||||||
|
"libpangoft2-1.0-0",
|
||||||
|
"libharfbuzz-subset0",
|
||||||
|
):
|
||||||
|
assert needle in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_dev_verify_skip_pre_existing_still_mentions_locust_probe_only() -> None:
|
||||||
|
text = (PROJECT_ROOT / "scripts" / "dev-verify.sh").read_text(encoding="utf-8")
|
||||||
|
assert "test_admin_locust_10_concurrency_success_rate_above_95" in text
|
||||||
Reference in New Issue
Block a user