文件
workbuddy-portal/tools/smoke.py
T
wangchuanli df7db3582e feat(multi-user): 多用户化 + 凭证加密 + 自助注册与图形验证码
数据隔离
- settings / usage_records 主键改为 (user_id, key) / (user_id, request_id),
  索引一律以 user_id 打头;collect_runs / audit_log 增加 user_id
- query / collect / scheduler 全链路把 uid 作为 conn 之后的第一个位置参数且无默认值
  (漏传直接 TypeError,不会退化成「返回全量」)
- 配置三级回落 个人→实例→DEFAULTS;NO_FALLBACK_KEYS={cookie,user_agent} 不回落

凭证保密
- 新增 workbuddy_portal/crypto.py:手写 ChaCha20(RFC8439 §2.3) + HMAC-SHA256
  encrypt-then-MAC,零第三方依赖;主密钥 cookie_key 与 SECRET_KEY 分键位存放
- get_secret() 是取明文的唯一通道;get_settings() 把加密键置空;
  secret_state() 只回 {set,chars,tail,broken};升级时自动加密历史明文

注册与验证码
- 新增 /register 与 workbuddy_portal/captcha.py(手写 PNG + 点阵字模 + 干扰线)
- 验证码答案只存服务端表、不进 session,一次性、5 分钟过期、按 purpose 隔离
- allow_register / register_max_per_ip / captcha_policy / captcha_length 四个实例级开关
- 失败限速改为 IP + 用户名双维度;停用账号每请求回查、立即失效

页面
- 新增 /profile(个人中心)与注册页;登录页加验证码与自助注册入口
- /config 增加凭证状态、cookie_broken 告警、实例级设置区;/users 增加邮箱/状态与启停

修复
- base.html 顶层 {% set me %} 覆盖子模板同名变量,导致个人中心「注册于」渲染为空
- WB_COOKIE_SECURE 未写进 compose 的 environment,在 .env 里设了不生效
- 「修改登录密码」提示写「至少 6 位」,与实际策略(≥8 位 + 两类字符)不符
- 「用户管理」删除说明写「可勾选保留」,与页面实际行为不符
- 注册页与 flash 文案里的 **强调** Markdown 字面量

验证与文档
- smoke.py 99 → 165 项断言(多用户隔离 / 凭证保密 / 注册与验证码 / 3 条防回归)
- check_live.py 56 → 83 项断言(新增注册 / 验证码 / 安全响应头一节)
- demo_data.py 造两个账号;shots.py 自动过验证码、重出 11 张截图
- README / SECURITY / ARCHITECTURE / API / DEPLOYMENT / USER-GUIDE / FAQ / CHANGELOG / CONTRIBUTING 同步
2026-09-15 17:32:35 +08:00

560 行
29 KiB
Python

#!/usr/bin/env python
# -*- coding: utf-8 -*-
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Wang Chuanli
"""离线回归:用 Flask test_client 对**真实库**做全页面只读渲染 + 缺陷防回归断言。
与 tools/check_live.py 的分工:
* check_live.py 对运行中的服务发真实 HTTP,验「起没起来、登录/CSRF/API 通不通」
* smoke.py(本脚本)不发网络请求,直接把请求灌进 WSGI 应用,
因此能覆盖到「页面模板渲染是否正确」,且不需要先起服务、不需要密码。
覆盖内容:
1. 全页面渲染(含 /users,需管理员身份)——模板报错会直接暴露成 500
2. 模板未渲染残留(HTML 里出现 {{ / {% 说明有变量名写错)
3. 历史缺陷防回归(见 4. 的 ①~⑭)
4. 多用户:数据隔离 / 凭证保密 / 注册与验证码 / 权限边界
5. CSV 导出可被标准 csv 解析、列数一致
6. 页面 HTML 里的 class 与 app.css 的选择器做差集(抓类名拼写错误)
写库说明:会写少量 audit_log 行;另外会**临时**建两个普通账号
(一个用来验权限边界,一个用来走完整注册链路),无论成功失败都在 finally 里删掉。
不会改动任何用量数据。
用法:
cd workbuddy-portal
python tools/smoke.py
退出码:0 全通过;1 有失败项。
"""
from __future__ import annotations
import csv
import io
import json
import os
import random
import re
import string
import sys
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
sys.path.insert(0, BASE)
OK = 0
FAIL = 0
FAILS: list[str] = []
NOTES: list[str] = []
def chk(name: str, cond: bool, extra: str = "") -> bool:
global OK, FAIL
if cond:
OK += 1
print(" [OK] %s %s" % (name, extra))
else:
FAIL += 1
FAILS.append(name)
print(" [FAIL] %s %s" % (name, extra))
return bool(cond)
def note(msg: str) -> None:
NOTES.append(msg)
print(" [note] %s" % msg)
def login(cli, uid: int, csrf: str = "smoke-csrf-token"):
"""注入会话绕过登录:GET 不触发 CSRF,因此可直接测页面渲染。
只有 `uid` 是真正生效的键 —— `security.current_user()` 每请求回查
users 表(这样做是为了「停用账号立即失效」),所以 `uname/dname/adm`
只是写给自己看的标记,改不了权限。
"""
with cli.session_transaction() as s:
s["uid"] = uid
s["uname"] = "smoke-%s" % uid
s["dname"] = "smoke"
s["adm"] = 0
s["_csrf"] = csrf
def page(cli, path, method="GET", **kw):
r = getattr(cli, method.lower())(path, **kw)
return r.status_code, r.get_data(as_text=True)
def _rand(n=8):
return "".join(random.choice(string.ascii_lowercase) for _ in range(n))
def run() -> None:
from workbuddy_portal import captcha, config, create_app, crypto, db, query, security
print("== 0. 构建应用 ==")
app = create_app(start_scheduler=False, do_init_db=False)
app.config["WTF_CSRF_ENABLED"] = False
n_routes = len([r for r in app.url_map.iter_rules()])
chk("create_app 成功", app is not None)
chk("路由数量 >= 40", n_routes >= 40, "routes=%d" % n_routes)
# 真实库里的账号:管理员必须有,普通账号按需临时造
conn = db.connect()
admin_row = conn.execute("SELECT id FROM users WHERE is_admin=1 AND status='active'"
" ORDER BY id LIMIT 1").fetchone()
if admin_row is None:
print("\n[FATAL] 库里没有启用的管理员账号,先跑 python manage.py init")
return
ADMIN = admin_row["id"]
# ---------------- 1. 未登录 ----------------
print("== 1. 未登录:受保护页应跳登录、API 应 401 ==")
with app.test_client() as cli:
for p in ("/", "/records", "/tasks", "/config", "/logs", "/users", "/profile"):
st, _ = page(cli, p)
chk("GET %-10s 未登录=302" % p, st == 302, "status=%s" % st)
for p in ("/api/summary", "/api/users", "/api/settings", "/api/audit"):
st, _ = page(cli, p)
chk("GET %-14s 未登录=401" % p, st == 401, "status=%s" % st)
# 只认 POST 的接口:GET 应当 405(而不是落到 401 或被 GET 直接执行)
# 未登录的 POST 会被 before_request 里的 CSRF 先拦下(400)——
# 这比先鉴权更好:没有会话就不该被允许碰任何写接口。
for p in ("/api/profile", "/api/captcha"):
st, _ = page(cli, p)
chk("GET %-14s 未登录=405" % p, st == 405, "status=%s" % st)
st, _ = page(cli, p, method="POST")
chk("POST %-13s 无 CSRF=400" % p, st == 400, "status=%s" % st)
st, html = page(cli, "/login")
chk("登录页含 CSRF 隐藏域", 'name="_csrf"' in html)
chk("登录页含验证码图", "capimg" in html and 'name="captcha"' in html)
chk("登录页含自助注册入口", "/register" in html)
# ---------------- 2. 管理员:全页面渲染 ----------------
print("== 2. 管理员:全页面渲染 ==")
with app.test_client() as cli:
login(cli, ADMIN)
pages = [
("/", "概览"), ("/records", "数据明细"), ("/tasks", "任务管理"),
("/config", "配置管理"), ("/logs", "日志管理"), ("/users", "用户管理"),
("/profile", "个人中心"), ("/dashboard", "<html"),
]
for p, kw in pages:
st, html = page(cli, p)
ok = chk("GET %-10s 200" % p, st == 200, "status=%s len=%d" % (st, len(html)))
if ok:
chk(" └ 含关键字 %s" % kw, kw in html)
chk(" └ 无模板残留 {{ / {%%", "{{" not in html and "{%" not in html)
chk(" └ 含导航栏", "topbar" in html or p == "/dashboard")
st, html = page(cli, "/users")
chk("用户管理页列出账号", 'data-uid=' in html)
chk("用户管理页含新建表单", 'id="formNewUser"' in html)
chk("用户管理页含账号操作审计", "账号操作审计" in html)
chk("用户管理页含状态列", "status" in html and "停用" in html)
chk("用户管理页含邮箱列", "邮箱" in html)
st, cfg = page(cli, "/config")
chk("配置页含维护按钮组", cfg.count("data-maint=") >= 3, "n=%d" % cfg.count("data-maint="))
chk("配置页含 TLS 校验下拉", 'name="ssl_verify"' in cfg)
chk("配置页含实例级设置区", "仅管理员可改" in cfg)
chk("配置页显示凭证状态而非明文", "cookie_hint" in cfg or "字符" in cfg)
st, rec = page(cli, "/records")
chk("明细页含快捷区间", 'data-range="today"' in rec and 'data-range="30d"' in rec)
chk("明细页表格包在 .tablewrap", "tablewrap" in rec)
st, pf = page(cli, "/profile")
chk("个人中心含改密表单", "/api/password" in pf or "password" in pf)
chk("个人中心说明凭证归属本人", "本人凭证" in pf or "我的 Cookie" in pf)
# 防回归:base.html 里曾用 {% set me = current_user() %},把子模板的 me
# 覆盖掉了 —— current_user() 只有 id/username/display_name/is_admin,
# 于是「注册于」渲染成空。变量已改名 cur,这里把两处都盯住。
lead = re.search(r'<p class="lead">(.*?)</p>', pf, re.S)
lead_txt = " ".join(lead.group(1).split()) if lead else ""
chk("个人中心「注册于」有真实时间",
bool(re.search(r"注册于\s+\d{4}-\d{2}-\d{2}", lead_txt)), lead_txt[:80])
chk("个人中心「最近登录」不是空占位",
bool(re.search(r"最近登录\s+\S", lead_txt)), lead_txt[:80])
chk("base.html 未再用 me 作局部变量(防覆盖子模板)",
"set me = " not in open(os.path.join(
BASE, "workbuddy_portal", "web", "templates", "base.html"),
encoding="utf-8").read())
# ---------------- 2b. 静态资源引用可解析 ----------------
print("== 2b. 页面引用的静态资源全部可达 ==")
asset_re = re.compile(r"\.(?:js|css|svg|png|jpe?g|gif|webp|ico|woff2?)(?:\?|$)", re.I)
with app.test_client() as cli:
login(cli, ADMIN)
for p in ("/", "/records", "/tasks", "/config", "/logs", "/users",
"/profile", "/dashboard"):
_, html = page(cli, p)
# 先剥掉 HTML 注释:注释里常写示例路径(src="vendor/x.js"),
# 不剥会把示例当真实引用误报。
html = re.sub(r"<!--.*?-->", "", html, flags=re.S)
refs = set(re.findall(r'src="([^"]+)"', html))
refs |= {h for h in re.findall(r'href="([^"]+)"', html) if asset_re.search(h)}
bad, n = [], 0
for r in sorted(refs):
if r.startswith(("data:", "http:", "https:", "//", "#")):
continue
target = r
if not r.startswith("/"): # 相对路径按该页 URL 解析(曾因此 404)
target = (p if p.endswith("/") else p.rsplit("/", 1)[0] + "/") + r
n += 1
ast, _ = page(cli, target)
if ast != 200:
bad.append("%s -> %s(%s)" % (r, target, ast))
chk("%-11s 资源引用全部 200" % p, not bad,
("坏引用=%s" % bad) if bad else "%d 个引用" % n)
# ---------------- 3. 多用户:隔离 / 保密 / 注册与验证码 ----------------
print("== 3. 多用户:数据隔离 / 凭证保密 / 注册与验证码 ==")
viewer = "smoke_v_%s" % _rand()
regged = "smoke_r_%s" % _rand()
created: list[str] = [viewer]
saved_ua_inst = None
def _mk_user(name):
conn.execute("INSERT INTO users(username,password_hash,display_name,is_admin,"
"status,created_at) VALUES(?,?,?,0,'active',?)",
(name, security.hash_password("Smoke-Pass1"), "冒烟账号", db.now_str()))
return conn.execute("SELECT id FROM users WHERE username=?", (name,)).fetchone()["id"]
try:
VIEWER = _mk_user(viewer)
# ① 凭证密文入库
row = conn.execute("SELECT value FROM settings WHERE key='cookie' AND user_id=?",
(ADMIN,)).fetchone()
if row and row["value"]:
chk("① Cookie 以密文入库(v1. 前缀)", crypto.is_encrypted(row["value"]),
"head=%s" % row["value"][:12])
chk("① 密文不含明文片段",
crypto.is_encrypted(row["value"]) and ";" not in row["value"][:4])
plain = db.get_secret(conn, "cookie", ADMIN)
chk("① 解回来长度合理(>100 字符)", len(plain) > 100, "chars=%d" % len(plain))
else:
note("库里没有 Cookie,跳过密文断言")
# ② 凭证绝不跨账号回落
chk("② NO_FALLBACK_KEYS 含 cookie/user_agent",
{"cookie", "user_agent"} <= db.NO_FALLBACK_KEYS)
chk("② 新账号读不到别人的 Cookie", db.get_secret(conn, "cookie", VIEWER) == "")
# User-Agent 本身不是秘密,新账号拿到 DEFAULTS 里的**通用** UA 是对的;
# 要守住的是「不能继承别人存下来的那一份」。用一个哨兵值把这点钉死:
sentinel = "SMOKE-SENTINEL-UA/%s" % _rand()
saved_ua_inst = db.get_setting(conn, "user_agent", "", 0)
db.set_setting(conn, "user_agent", sentinel, 0) # 写实例级
chk("② 实例级放哨兵后,新账号仍看不到它",
db.get_setting(conn, "user_agent", "", VIEWER) != sentinel,
"new=%s…" % (db.get_setting(conn, "user_agent", "", VIEWER) or "")[:22])
chk("② 哨兵在实例级确实生效(证明上面的断言不是在空跑)",
db.get_setting(conn, "user_agent", "", 0) == sentinel)
db.set_setting(conn, "user_agent", saved_ua_inst, 0) # 还原
chk("② 已还原实例级 UA", db.get_setting(conn, "user_agent", "", 0) == saved_ua_inst)
# 普通配置应当能回落到实例级(否则每个新账号都拿到空配置)
chk("② 普通配置仍回落实例级",
db.get_setting(conn, "page_size", None, VIEWER) ==
db.get_setting(conn, "page_size", None, 0))
# ③ /api/settings 只给掩码,绝不给明文
with app.test_client() as cli:
login(cli, ADMIN)
st, body = page(cli, "/api/settings")
j = json.loads(body)
chk("③ /api/settings 200", st == 200, "status=%s" % st)
plain = db.get_secret(conn, "cookie", ADMIN)
chk("③ 响应体不含 Cookie 明文", not plain or plain not in body)
chk("③ cookie 字段被置空", not (j.get("cookie") or "").strip())
chk("③ 只给 cookie_hint 掩码", "cookie_hint" in j and "cookie_broken" in j)
chk("③ 标注实例级键清单", isinstance(j.get("_globalKeys"), list) and j["_globalKeys"])
chk("③ 管理员 _canEditGlobal=True", j.get("_canEditGlobal") is True)
chk("③ 无 slot:* 内部键", "slot:" not in body)
# ④ 验证码:不落 session、一次性、出图禁缓存
with app.test_client() as cli:
r = cli.get("/captcha.png?purpose=login")
chk("④ /captcha.png=200", r.status_code == 200, "status=%s" % r.status_code)
chk("④ 是 PNG 字节流",
r.headers.get("Content-Type", "").startswith("image/png")
and r.get_data()[:8] == b"\x89PNG\r\n\x1a\n")
chk("④ 出图禁缓存", "no-store" in r.headers.get("Cache-Control", ""))
with cli.session_transaction() as s:
cid = s.get("cap_login")
chk("④ 会话里只存验证码 id", bool(cid), "id=%s" % (cid or "无"))
ans = conn.execute("SELECT answer,purpose FROM captchas WHERE id=?",
(cid,)).fetchone() if cid else None
chk("④ 答案只存在服务端 captchas 表",
ans is not None and len(ans["answer"]) >= 4 and ans["purpose"] == "login")
if ans:
st, html = page(cli, "/login")
chk("④ 页面 HTML 里搜不到答案", ans["answer"] not in html)
chk("④ 页面 JS 里也搜不到会话密钥", cid not in html)
# 一次性:同一个 id 用两次,第二次必须失败
if ans:
chk("④ 首次校验通过",
captcha.verify(conn, cid, ans["answer"], "login"))
chk("④ 同 id 二次校验失败(已消费)",
not captcha.verify(conn, cid, ans["answer"], "login"))
chk("④ 消费后记录已删除",
conn.execute("SELECT COUNT(*) FROM captchas WHERE id=?",
(cid,)).fetchone()[0] == 0)
# ⑤ 自助注册全链路(取答案 -> POST /register -> 账号可用)
with app.test_client() as cli:
cli.get("/register")
# 验证码图是浏览器去取的,test_client 不会自动加载 <img>,
# 所以这里显式打一次 —— 这一步正是「注册页有没有发挑战」的验证
r = cli.get("/captcha.png?purpose=register")
chk("⑤ 注册页的验证码接口可用", r.status_code == 200
and r.get_data()[:4] == b"\x89PNG", "status=%s" % r.status_code)
with cli.session_transaction() as s:
cid = s.get("cap_register")
# 这个客户端没有走 login() 注入固定 token,所以要取真实值;
# 顺手也证明了 /register 的 CSRF 校验确实在生效
csrf = s.get("_csrf")
a2 = conn.execute("SELECT answer,purpose FROM captchas WHERE id=?",
(cid,)).fetchone() if cid else None
chk("⑤ 注册用的挑战落在 register 用途下",
a2 is not None and a2["purpose"] == "register")
if a2:
st, _ = page(cli, "/register", method="POST", data={
"username": regged, "display_name": "冒烟注册", "email": "",
"password": "Smoke-Pass1", "password2": "Smoke-Pass1",
"captcha": a2["answer"]},
headers={"X-CSRF-Token": csrf or ""})
chk("⑤ 注册成功=302", st == 302, "status=%s" % st)
created.append(regged)
u = conn.execute("SELECT id,is_admin,status,display_name,last_login_ip"
" FROM users WHERE username=?", (regged,)).fetchone()
chk("⑤ 建出的是普通账号",
u is not None and u["is_admin"] == 0 and u["status"] == "active")
chk("⑤ 注册即登录(会话已建立)",
u is not None and u["id"] == db.user_by_name(conn, regged)["id"])
# 缺 CSRF 必须 400
st, _ = page(cli, "/register", method="POST", data={"username": "x" * 3})
chk("⑤ 注册缺 CSRF=400", st == 400, "status=%s" % st)
# ⑥ 权限边界:普通账号改不了实例级配置
with app.test_client() as cli:
login(cli, VIEWER)
st, j = page(cli, "/api/settings")
chk("⑥ 非管理员 _canEditGlobal=False", json.loads(j).get("_canEditGlobal") is False)
evil = "http://evil.invalid"
st, _ = page(cli, "/api/settings", method="POST", json={"api_base": evil},
headers={"X-CSRF-Token": "smoke-csrf-token"})
chk("⑥ 非管理员改实例级配置=400", st == 400, "status=%s" % st)
chk("⑥ 且确实没写进去",
db.get_setting(conn, "api_base", "", VIEWER) != evil
and db.get_setting(conn, "api_base", "", 0) != evil)
# ⑦ 数据隔离:所有查询函数都必须显式带 uid
try:
query.daily(conn)
chk("⑦ query.daily 漏传 uid 会报错", False, "居然没报错")
except TypeError:
chk("⑦ query.daily 漏传 uid 会报错", True)
d_admin = query.daily(conn, ADMIN)
d_viewer = query.daily(conn, VIEWER)
chk("⑦ 不同账号的 daily 互不相同",
not d_admin or d_viewer != d_admin or len(d_viewer) == 0)
chk("⑦ 新账号 totals 为空", query.totals(conn, VIEWER)["records"] == 0)
t_admin = query.totals(conn, ADMIN)
chk("⑦ 管理员 totals 有数据", t_admin["records"] > 0, "records=%d" % t_admin["records"])
chk("⑦ totals(uid=0) 不含任何人的数据",
query.totals(conn, 0)["records"] == 0)
finally:
# 哨兵 UA 一定要还原(否则下次真采集会带着测试字符串发出去)
if saved_ua_inst is not None:
db.set_setting(conn, "user_agent", saved_ua_inst, 0)
for name in created:
conn.execute("DELETE FROM users WHERE username=?", (name,))
conn.execute("DELETE FROM settings WHERE user_id NOT IN (SELECT id FROM users)")
conn.execute("DELETE FROM usage_records WHERE user_id NOT IN (SELECT id FROM users)")
# 确认清理干净
left = conn.execute("SELECT COUNT(*) FROM users WHERE username LIKE 'smoke\\_%' ESCAPE '\\'"
).fetchone()[0]
chk("3. 临时账号已清理", left == 0, "残留=%d" % left)
# ---------------- 4. 普通账号的权限边界 ----------------
print("== 4. 非管理员:/users 必须 403,导航不出现该入口 ==")
viewer2 = "smoke_w_%s" % _rand()
try:
V2 = _mk_user(viewer2)
with app.test_client() as cli:
login(cli, V2)
st, html = page(cli, "/users")
chk("GET /users 非管理员=403", st == 403, "status=%s" % st)
st, _ = page(cli, "/api/users")
chk("GET /api/users 非管理员=403", st == 403, "status=%s" % st)
st, html = page(cli, "/")
chk("概览导航不含「用户管理」", "用户管理" not in html)
chk("普通账号导航含「个人中心」入口", 'class="who"' in html)
for p in ("/", "/records", "/tasks", "/logs", "/config", "/profile"):
st, _ = page(cli, p)
chk("GET %-10s 非管理员=200" % p, st == 200, "status=%s" % st)
# 日志尾部是管理员专属
st, _ = page(cli, "/logs/tail?lines=10")
chk("GET /logs/tail 非管理员=403", st == 403, "status=%s" % st)
finally:
conn.execute("DELETE FROM users WHERE username=?", (viewer2,))
conn.execute("DELETE FROM settings WHERE user_id NOT IN (SELECT id FROM users)")
conn.close()
# ---------------- 5. 历史缺陷防回归 ----------------
print("== 5. 历史缺陷防回归 ==")
with app.test_client() as cli:
login(cli, ADMIN)
# ① 非法日期曾 500
st, body = page(cli, "/api/summary?from=abc&to=def")
chk("① /api/summary 非法日期=400", st == 400, "status=%s" % st)
chk(" └ 返回 JSON 错误体", '"ok": false' in body.replace('":', '": '))
# ② /tasks 非法页码曾 500
st, _ = page(cli, "/tasks?page=abc")
chk("② /tasks?page=abc=200", st == 200, "status=%s" % st)
# ③ 日志尾部非法行数曾 500
st, _ = page(cli, "/logs/tail?lines=abc")
chk("③ /logs/tail?lines=abc=200", st == 200, "status=%s" % st)
# ④ 内部簿记键 slot:* 曾泄漏到 /api/settings
st, body = page(cli, "/api/settings")
chk("④ /api/settings 无 slot:* 键", "slot:" not in body, "status=%s" % st)
# ⑤ 概览「云端」列曾因 SQL 少选列而恒为空
st, ov = page(cli, "/")
chk("⑤ 概览含「云端」列", "云端" in ov)
# ⑥ 明细页日期回填:模板曾读 f.from,导致输入框永远为空
st, rec = page(cli, "/records?from=2026-09-08&to=2026-09-10")
chk("⑥ 明细页回填 from", 'value="2026-09-08"' in rec)
chk("⑥ 明细页回填 to", 'value="2026-09-10"' in rec)
# ⑦ 导出链接必须带规范参数名 from(模板内部用 frm,拼 URL 时要换回来)
m = re.search(r'href="(/records/export[^"]*)"', rec)
chk("⑦ 导出链接存在", bool(m))
if m:
chk("⑦ 导出链接带 from=", "from=2026-09-08" in m.group(1), m.group(1))
chk("⑦ 导出链接带 to=", "to=2026-09-10" in m.group(1))
# ⑧ 导出接口本身也要认 from/to
st, csv_body = page(cli, "/records/export?from=2026-09-08&to=2026-09-10")
chk("⑧ GET /records/export=200", st == 200, "status=%s" % st)
rows = list(csv.reader(io.StringIO(csv_body.lstrip("\ufeff"))))
chk("⑧ CSV 至少含表头", len(rows) >= 1, "rows=%d" % len(rows))
chk("⑧ CSV 列数一致",
len({len(r) for r in rows if r}) == 1,
"列数集合=%s" % sorted({len(r) for r in rows if r}))
chk("⑧ CSV 表头为官方同构列",
rows and rows[0] == ["RequestID", "积分消耗", "User Prompt", "模型", "客户端", "时间"],
"header=%s" % (rows[0] if rows else None))
# ⑨ 非法设置必须在写入时被拒(曾让采集崩掉)
st, body = page(cli, "/api/settings", method="POST", json={"page_size": "abc"},
headers={"X-CSRF-Token": "smoke-csrf-token"})
chk("⑨ 非法设置写入=400", st == 400, "status=%s" % st)
st, body = page(cli, "/api/settings", method="POST", json={"slot:09:00": "x"},
headers={"X-CSRF-Token": "smoke-csrf-token"})
chk("⑨ 内部键被忽略而非写入",
st == 200 and "slot:09:00" in (json.loads(body).get("ignored") or []),
"status=%s body=%s" % (st, body[:140]))
# ⑩ 维护动作
st, _ = page(cli, "/api/maintenance/nope", method="POST", json={},
headers={"X-CSRF-Token": "smoke-csrf-token"})
chk("⑩ 未知维护动作=404", st == 404, "status=%s" % st)
st, body = page(cli, "/api/maintenance/recount", method="POST", json={},
headers={"X-CSRF-Token": "smoke-csrf-token"})
chk("⑩ recount=200", st == 200, "status=%s body=%s" % (st, body[:90]))
# ⑪ 管理员不能删自己
st, _ = page(cli, "/api/users/%d/delete" % ADMIN, method="POST", json={},
headers={"X-CSRF-Token": "smoke-csrf-token"})
chk("⑪ 删除自己=400(不允许)", st == 400, "status=%s" % st)
# ⑫ CSRF 缺失必须 400
st, _ = page(cli, "/api/settings", method="POST", json={"max_prompt": "100"})
chk("⑫ 缺 CSRF=400", st == 400, "status=%s" % st)
# ⑬ 分页/筛选参数非法不能 500
for p in ("/logs?page=abc", "/logs?apage=abc", "/records?page=abc&size=abc",
"/records?from=2026-13-99", "/logs?status=%27%20OR%201=1--"):
st, _ = page(cli, p)
chk("⑬ GET %-30s =200" % p, st == 200, "status=%s" % st)
# ⑭ 审计筛选:只返回指定动作,且计数与筛选一致
st, lg = page(cli, "/logs")
chk("⑭ 日志页含操作审计筛选", "操作审计" in lg and "seg" in lg)
m = re.search(r'href="/logs\?act=([^"&]+)', lg)
if chk("⑭ 审计筛选有可选动作", bool(m), "act=%s" % (m.group(1) if m else "无")):
act = m.group(1)
st, lg2 = page(cli, "/logs?act=" + act)
chk("⑭ 带 act=%s 仍 200" % act, st == 200, "status=%s" % st)
# 从审计表的 id 处切片:前面采集表里的 trigger 也用了 tag mute,不能混入
i = lg2.find('id="auditTable"')
tail = lg2[i:] if i >= 0 else ""
chk("⑭ 审计表存在", i >= 0)
tags = re.findall(r'<td><span class="tag mute">([^<]+)</span></td>', tail)
others = sorted({t for t in tags if t != act})
chk("⑭ 审计筛选结果不含其他动作", not others and bool(tags),
"命中=%d 混入=%s" % (len(tags), others))
# ---------------- 6. 数据自洽 ----------------
print("== 6. 数据自洽(只读) ==")
with app.test_client() as cli:
login(cli, ADMIN)
mf = json.loads(page(cli, "/api/manifest")[1])
src = (mf.get("sources") or [{}])[0]
chk("manifest 存档条数 == 数据源条数",
mf["totals"]["records"] == src.get("count"),
"%s vs %s" % (mf["totals"]["records"], src.get("count")))
sm = json.loads(page(cli, "/api/summary")[1])
chk("summary 全量 calls == 存档条数",
sm.get("calls") == mf["totals"]["records"],
"calls=%s records=%s" % (sm.get("calls"), mf["totals"]["records"]))
chk("summary 全量 credits 自洽",
abs(float(sm.get("credits", 0)) - float(mf["totals"]["credits"])) < 0.005,
"%s vs %s" % (sm.get("credits"), mf["totals"]["credits"]))
d = query.daily(db.get_db(), ADMIN)
chk("daily 逐日积分求和 == 存档总额",
abs(round(sum(float(x["c"]) for x in d), 2)
- round(float(mf["totals"]["credits"]), 2)) < 0.005)
chk("daily 逐日 h[24] 求和 == 当日积分",
all(abs(round(sum(x["h"]), 2) - round(x["c"], 2)) < 0.005 for x in d))
note("管理员存档 %s 条 / %s 积分 / %d 天" % (mf["totals"]["records"],
mf["totals"]["credits"], len(d)))
# ---------------- 7. class 名与 CSS 选择器对账 ----------------
print("== 7. 页面 class 与 app.css 选择器对账 ==")
css = open(os.path.join(BASE, "workbuddy_portal", "web", "static", "css", "app.css"),
encoding="utf-8").read()
css_classes = set(re.findall(r"\.([A-Za-z][\w-]*)", css))
anon = ("/login", "/register")
auth = ("/", "/records", "/tasks", "/config", "/logs", "/users", "/profile")
used: set[str] = set()
for p in anon:
with app.test_client() as c2:
html = page(c2, p)[1]
for m in re.findall(r'class="([^"]*)"', html):
used.update(t for t in m.split() if t)
with app.test_client() as cli:
login(cli, ADMIN)
for p in auth:
html = page(cli, p)[1]
for m in re.findall(r'class="([^"]*)"', html):
used.update(t for t in m.split() if t)
# 允许的无样式类:JS 钩子、第三方/语义标记
allow = {"no-js", "on", "cur", "gap", "meta", "unit", "field-err"}
missing = sorted(c for c in used - css_classes - allow)
chk("无「用了但 CSS 里不存在」的类名", not missing, "缺失=%s" % missing if missing else "")
def main() -> int:
print("工程目录:%s\n" % BASE)
try:
run()
except Exception as e: # noqa: BLE001
import traceback
traceback.print_exc()
print("\n[FATAL] 脚本本身异常:%s" % e)
return 1
print("\nRESULT: ok=%d fail=%d" % (OK, FAIL))
if NOTES:
print("备注:")
for n in NOTES:
print(" - " + n)
if FAILS:
print("失败项:\n - " + "\n - ".join(FAILS))
return 1 if FAIL else 0
if __name__ == "__main__":
sys.exit(main())