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 同步
这个提交包含在:
+165
-20
@@ -21,13 +21,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import http.cookiejar
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import zlib
|
||||
from datetime import datetime
|
||||
|
||||
OK = 0
|
||||
@@ -40,6 +44,31 @@ def _d(s: str):
|
||||
return datetime.strptime(s, "%Y-%m-%d")
|
||||
|
||||
|
||||
def decode_session(cj) -> dict:
|
||||
"""从 Flask 会话 cookie 里解出那份**未加密**的载荷。
|
||||
|
||||
Flask 的会话是「签名 + base64,**不加密**」的 —— 也就是说持有 cookie 的人
|
||||
就能读到里面的内容。本项目因此把验证码答案放在服务端 captchas 表里,
|
||||
会话里只留一个随机 id;本函数存在的意义就是取出那个 id,
|
||||
好让自动化验收能跨过验证码这一关(顺便也验证了「答案不在会话里」)。
|
||||
"""
|
||||
for c in cj:
|
||||
if not c.name.startswith("workbuddy_portal_sid"):
|
||||
continue
|
||||
seg = urllib.parse.unquote(c.value).split(".")[0]
|
||||
seg += "=" * (-len(seg) % 4)
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(seg)
|
||||
try:
|
||||
raw = zlib.decompress(raw) # 某些版本的 itsdangerous 会压
|
||||
except zlib.error:
|
||||
pass
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except Exception: # noqa: BLE001
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def chk(name: str, cond: bool, extra: str = "") -> None:
|
||||
global OK, FAIL
|
||||
if cond:
|
||||
@@ -59,9 +88,10 @@ class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
|
||||
|
||||
class Live:
|
||||
def __init__(self, base: str, timeout: int = 20):
|
||||
def __init__(self, base: str, timeout: int = 20, db_path: str | None = None):
|
||||
self.base = base.rstrip("/")
|
||||
self.timeout = timeout
|
||||
self.db_path = db_path
|
||||
# 关键:显式清空代理,否则本机代理会把 127.0.0.1 也拦成 502
|
||||
self.cj = http.cookiejar.CookieJar()
|
||||
self.op = urllib.request.build_opener(
|
||||
@@ -114,6 +144,62 @@ class Live:
|
||||
st, body = self.get(path)
|
||||
return json.loads(body) if st == 200 else {}
|
||||
|
||||
def raw(self, path: str):
|
||||
"""返回 (status, headers, bytes)——验证码/响应头这类要原始字节的场景用。"""
|
||||
try:
|
||||
r = self.op.open(urllib.request.Request(self.base + path), timeout=self.timeout)
|
||||
return r.status, r.headers, r.read()
|
||||
except urllib.error.HTTPError as e:
|
||||
return e.code, e.headers, e.read()
|
||||
|
||||
def form_csrf(self, path: str) -> str:
|
||||
"""取某个页面里的 CSRF 隐藏域(该页面必须与当前会话同源)。"""
|
||||
_, html = self.get(path)
|
||||
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
# ---- 验证码辅助(仅验收脚本用)----
|
||||
def solve_captcha(self, purpose: str):
|
||||
"""取一张图 -> 从会话里读 id -> 从本地库里取答案。返回 (答案, 会话载荷)。"""
|
||||
self.raw("/captcha.png?purpose=" + purpose)
|
||||
sess = decode_session(self.cj)
|
||||
cid = sess.get("cap_" + purpose)
|
||||
if not cid or not self.db_path or not os.path.exists(self.db_path):
|
||||
return None, sess
|
||||
try:
|
||||
con = sqlite3.connect(self.db_path)
|
||||
try:
|
||||
row = con.execute("SELECT answer FROM captchas WHERE id=?", (cid,)).fetchone()
|
||||
finally:
|
||||
con.close()
|
||||
except sqlite3.Error:
|
||||
return None, sess
|
||||
return (row[0] if row else None), sess
|
||||
|
||||
def login(self, user: str, pwd: str, nxt: str = "", follow: bool = True):
|
||||
"""完整登录(验证码策略为 always 时自动解)。
|
||||
|
||||
follow=False 时返回原始 (status, Location),用于验证跳转目标是否安全。
|
||||
返回 (status, location, need_captcha, session_payload)。
|
||||
"""
|
||||
html = self.get("/login")[1]
|
||||
need_cap = 'name="captcha"' in html
|
||||
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
|
||||
data = {"username": user, "password": pwd, "_csrf": m.group(1) if m else ""}
|
||||
if nxt:
|
||||
data["next"] = nxt
|
||||
sess = {}
|
||||
if need_cap:
|
||||
ans, sess = self.solve_captcha("login")
|
||||
if ans is None:
|
||||
return None, None, True, sess
|
||||
data["captcha"] = ans
|
||||
if follow:
|
||||
st, _ = self.post("/login", data)
|
||||
return st, None, need_cap, sess
|
||||
st, loc = self.post_raw("/login", data)
|
||||
return st, loc, need_cap, sess
|
||||
|
||||
|
||||
def run(L: Live, user: str, pwd: str, frm: str, to: str) -> None:
|
||||
print("== 1. 未登录访问受保护资源 ==")
|
||||
@@ -123,13 +209,15 @@ def run(L: Live, user: str, pwd: str, frm: str, to: str) -> None:
|
||||
st, _ = L.get(p)
|
||||
chk("GET %-14s 未登录=401" % p, st == 401, "status=%s" % st)
|
||||
|
||||
print("== 2. 登录(含 CSRF) ==")
|
||||
print("== 2. 登录(含 CSRF;验证码策略为 always 时自动解) ==")
|
||||
st, html = L.get("/login")
|
||||
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
|
||||
chk("登录页含 CSRF 隐藏域", bool(m))
|
||||
st, _ = L.post("/login", {"username": user, "password": pwd,
|
||||
"_csrf": m.group(1) if m else ""})
|
||||
chk("登录页含 CSRF 隐藏域", bool(re.search(r'name="_csrf"\s+value="([^"]+)"', html)))
|
||||
st, _, need_cap, sess = L.login(user, pwd)
|
||||
chk("登录成功", st in (200, 302), "status=%s" % st)
|
||||
if need_cap:
|
||||
# 会话里只应有 id,不该有答案本身
|
||||
chk("会话里只存验证码 id(不是答案)", bool(sess.get("cap_login")),
|
||||
"cap_login=%s" % (sess.get("cap_login") or "无"))
|
||||
st, html = L.get("/")
|
||||
chk("登录后 GET / 到概览", st == 200 and "概览" in html, "len=%d" % len(html))
|
||||
|
||||
@@ -212,11 +300,25 @@ def run(L: Live, user: str, pwd: str, frm: str, to: str) -> None:
|
||||
|
||||
print("== 7. 凭据不外泄 ==")
|
||||
stj = L.jget("/api/settings")
|
||||
chk("settings 无 cookie 明文字段", "cookie" not in stj, "keys=%s" % list(stj.keys()))
|
||||
# 契约:settings 里 cookie 这个键**必须为空**(db.get_settings 统一置空),
|
||||
# 真正的状态只通过 cookie_hint / cookie_broken 这两个派生字段暴露。
|
||||
chk("settings 里 cookie 字段为空串",
|
||||
"cookie" in stj and not str(stj.get("cookie") or "").strip(),
|
||||
"cookie=%r" % stj.get("cookie"))
|
||||
chk("settings 用 cookie_hint/cookie_broken 代替明文",
|
||||
"cookie_hint" in stj and "cookie_broken" in stj)
|
||||
chk("settings 仅回 cookie_hint 掩码",
|
||||
bool(stj.get("cookie_hint")) and len(str(stj.get("cookie_hint"))) < 200,
|
||||
"hint=%s" % stj.get("cookie_hint"))
|
||||
chk("settings 回传实例级键清单", isinstance(stj.get("_globalKeys"), list)
|
||||
and bool(stj.get("_globalKeys")), "%s" % stj.get("_globalKeys"))
|
||||
chk("settings 标明能否改实例级配置", stj.get("_canEditGlobal") is True)
|
||||
chk("配置页 HTML 不含 cookie 明文", "eyJ" not in L.get("/config")[1])
|
||||
# 密文形态:v1.<b64salt>.<b64nonce>.<b64ct>.<b64tag>,恰好用正则判定,
|
||||
# 免得把版本号 "v1.2.0" 当成泄漏(这两者前缀撞车)
|
||||
cipher_re = re.compile(r"v1\.[A-Za-z0-9+/=]{8,}\.[A-Za-z0-9+/=]{8,}\.")
|
||||
for p in ("/config", "/profile", "/"):
|
||||
chk("%-9s HTML 里没有 Cookie 密文" % p, not cipher_re.search(L.get(p)[1]))
|
||||
|
||||
print("== 8. 错误处理 ==")
|
||||
for p in ("/api/nope", "/nope"):
|
||||
@@ -254,20 +356,13 @@ def run(L: Live, user: str, pwd: str, frm: str, to: str) -> None:
|
||||
"api=%s csv=%s" % (first_id, (lines[1][:40] if len(lines) > 1 else None)))
|
||||
|
||||
print("== 10. 安全:开放重定向与凭证外泄 ==")
|
||||
L2 = Live(L.base) # 全新会话,避免已登录被直跳
|
||||
st, html = L2.get("/login")
|
||||
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
|
||||
csrf = m.group(1) if m else ""
|
||||
st, loc = L2.post_raw("/login", {"username": user, "password": pwd,
|
||||
"_csrf": csrf, "next": "//evil.com"})
|
||||
L2 = Live(L.base, L.timeout, L.db_path) # 全新会话,避免已登录被直跳
|
||||
st, loc, _, _ = L2.login(user, pwd, nxt="//evil.com", follow=False)
|
||||
chk("next=//evil.com 被拒(不出现协议相对跳转)",
|
||||
st == 302 and "evil.com" not in (loc or "") and not (loc or "").startswith("//"),
|
||||
"status=%s Location=%s" % (st, loc))
|
||||
L3 = Live(L.base)
|
||||
st, html = L3.get("/login")
|
||||
m = re.search(r'name="_csrf"\s+value="([^"]+)"', html)
|
||||
st, loc = L3.post_raw("/login", {"username": user, "password": pwd,
|
||||
"_csrf": m.group(1) if m else "", "next": "/records"})
|
||||
L3 = Live(L.base, L.timeout, L.db_path)
|
||||
st, loc, _, _ = L3.login(user, pwd, nxt="/records", follow=False)
|
||||
chk("next=/records 站内路径正常放行", st == 302 and loc == "/records",
|
||||
"status=%s Location=%s" % (st, loc))
|
||||
st, loc = L3.post_raw("/login", {"username": user, "password": pwd, "_csrf": "wrong"})
|
||||
@@ -277,6 +372,50 @@ def run(L: Live, user: str, pwd: str, frm: str, to: str) -> None:
|
||||
st, html = L.get("/")
|
||||
chk("GET /logout 后仍处于登录态", st == 200 and "概览" in html, "status=%s" % st)
|
||||
|
||||
print("== 11. 多用户:注册入口 / 验证码 / 安全响应头 ==")
|
||||
L4 = Live(L.base, L.timeout, L.db_path) # 全新未登录会话
|
||||
st, html = L4.get("/register")
|
||||
chk("GET /register 可达", st == 200 and "注册" in html, "status=%s" % st)
|
||||
chk("注册页带验证码图", "capimg" in html and "/captcha.png" in html)
|
||||
chk("注册页带 CSRF 隐藏域", bool(L4.form_csrf("/register")))
|
||||
st, html = L4.get("/login")
|
||||
chk("登录页带验证码图", "capimg" in html and 'name="captcha"' in html)
|
||||
chk("登录页带自助注册链接", "/register" in html)
|
||||
|
||||
# 出图:真实字节 + 禁缓存 + 确实每次都不一样
|
||||
shots = {}
|
||||
for purpose in ("login", "register"):
|
||||
code, hdr, data = L4.raw("/captcha.png?purpose=" + purpose)
|
||||
chk("GET /captcha.png?purpose=%-8s 出 PNG" % purpose,
|
||||
code == 200 and data[:4] == b"\x89PNG" and len(data) > 200,
|
||||
"status=%s len=%d" % (code, len(data)))
|
||||
chk(" └ 禁缓存 no-store", "no-store" in (hdr.get("Cache-Control") or ""))
|
||||
chk(" └ 类型 image/png", (hdr.get("Content-Type") or "").startswith("image/png"))
|
||||
shots[purpose] = data
|
||||
_, _, again = L4.raw("/captcha.png?purpose=login")
|
||||
chk("两次取图内容不同(不是一张静态图)", again != shots["login"])
|
||||
chk("login 与 register 的图互不相同", shots["login"] != shots["register"])
|
||||
# 图必须由服务端单独下发,不能把答案内联进页面
|
||||
st, html = L4.get("/login")
|
||||
chk("登录页没有内联 data: 图片(答案不走页面源码)",
|
||||
"data:image" not in html and "base64," not in html)
|
||||
|
||||
# 安全响应头
|
||||
code, hdr, _ = L4.raw("/login")
|
||||
for name, want in (("X-Content-Type-Options", "nosniff"),
|
||||
("X-Frame-Options", "DENY"),
|
||||
("Referrer-Policy", "same-origin")):
|
||||
chk("响应头 %-24s" % name, (hdr.get(name) or "") == want, "=%s" % hdr.get(name))
|
||||
chk("响应头含 CSP 且 frame-ancestors 'none'",
|
||||
"frame-ancestors 'none'" in (hdr.get("Content-Security-Policy") or ""))
|
||||
code, hdr, _ = L4.raw("/captcha.png?purpose=login")
|
||||
chk("/captcha 路径带 no-store", "no-store" in (hdr.get("Cache-Control") or ""))
|
||||
|
||||
# 路径穿越式 purpose 必须被收敛到已知用途,而不是 500
|
||||
code, _, data = L4.raw("/captcha.png?purpose=../../etc/passwd")
|
||||
chk("非法 purpose 不报 500", code == 200 and data[:4] == b"\x89PNG",
|
||||
"status=%s" % code)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="对运行中的用量门户做端到端验收")
|
||||
@@ -285,11 +424,17 @@ def main() -> int:
|
||||
ap.add_argument("-p", "--password", default="admin123", help="登录密码")
|
||||
ap.add_argument("--from", dest="frm", default="2026-09-08", help="验收窗口起")
|
||||
ap.add_argument("--to", dest="to", default="2026-09-14", help="验收窗口止")
|
||||
ap.add_argument("--db", default=None,
|
||||
help="SQLite 路径(默认 <repo>/data/usage.sqlite)。"
|
||||
"验证码策略为 always 时用它取答案以完成自动登录;"
|
||||
"指向不存在的文件则跳过需要验证码的登录")
|
||||
ap.add_argument("--timeout", type=int, default=20)
|
||||
a = ap.parse_args()
|
||||
|
||||
print("目标:%s 窗口:%s ~ %s\n" % (a.base, a.frm, a.to))
|
||||
run(Live(a.base, a.timeout), a.user, a.password, a.frm, a.to)
|
||||
db_path = a.db or os.path.join(
|
||||
os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "usage.sqlite")
|
||||
print("目标:%s 窗口:%s ~ %s\n验证码答案源:%s\n" % (a.base, a.frm, a.to, db_path))
|
||||
run(Live(a.base, a.timeout, db_path), a.user, a.password, a.frm, a.to)
|
||||
print("\nRESULT: ok=%d fail=%d" % (OK, FAIL))
|
||||
if FAILS:
|
||||
print("失败项:%s" % "、".join(FAILS))
|
||||
|
||||
+96
-40
@@ -14,18 +14,21 @@
|
||||
--------
|
||||
| 表 | 说明 |
|
||||
|---|---|
|
||||
| `users` | 明示例两个账号:`admin`(管理员)与 `demo`(普通账号),各有自己的数据 |
|
||||
| `usage_records` | 约 900 条合成记录,跨 30 天,含假模型名 / 假 Prompt / 偏斜的积分分布 |
|
||||
| `collect_runs` | 14 条采集历史,含 ok / warn / error 三种状态 |
|
||||
| `settings` | 走项目默认值(`config.DEFAULTS`),**不写入任何凭据** |
|
||||
| `audit_log` | 30 条操作审计 |
|
||||
| `users` | 由 `db.init_db()` 建一个管理员 |
|
||||
| `collect_runs` | 采集历史,含 ok / warn / error 三种状态 |
|
||||
| `settings` | 走项目默认值(`config.DEFAULTS`),凭证只写**一眼可辨的假值** |
|
||||
| `audit_log` | 操作审计(按账号归属) |
|
||||
|
||||
刻意造两个账号,是因为「数据按账号隔离」在多用户版里是最该被截进文档的性质:
|
||||
只有一个账号的话,用户管理页和「我的数据」列都看不出区别。
|
||||
|
||||
用法
|
||||
----
|
||||
# 默认写到 data/demo/(该目录在 .gitignore 内,不会误提交)
|
||||
python tools/demo_data.py
|
||||
|
||||
# 指定目录与管理员口令,然后起服务看效果
|
||||
# 指定目录与口令,然后起服务看效果
|
||||
python tools/demo_data.py --out data/demo --admin-password demo123
|
||||
WB_DATA_DIR=$PWD/data/demo python manage.py serve --port 8849 --no-scheduler
|
||||
|
||||
@@ -61,7 +64,19 @@ CLIENTS = [("vscode", 74), ("webconsole", 18), ("sdk", 8)]
|
||||
|
||||
# 写进 settings 的假 Cookie。刻意用重复串,一眼就能看出不是真凭据;
|
||||
# 作用只是让概览页的健康指示灯是绿的(首装状态是「缺 Cookie」告警)。
|
||||
# 两个账号给不同的值,这样「各自持有自己的凭证」在截图里看得出来。
|
||||
DEMO_COOKIE = "wb_demo_session=" + "deadbeef" * 15
|
||||
DEMO_COOKIE_2 = "wb_demo_session=" + "cafef00d" * 15
|
||||
|
||||
# 第二个账号(普通用户)用的模型:刻意与管理员**不重名**,
|
||||
# 这样「按账号隔离」在按模型的图上立刻可见。
|
||||
MODELS_2 = [
|
||||
("demo-lite", 30, 0.55),
|
||||
("demo-nano", 26, 0.14),
|
||||
("demo-flash", 22, 0.32),
|
||||
("demo-vision", 12, 5.90),
|
||||
("demo-pro", 10, 2.05),
|
||||
]
|
||||
|
||||
PROMPTS_SHORT = [
|
||||
"帮我解释一下这段代码的作用",
|
||||
@@ -144,51 +159,74 @@ def build(out_dir: str, days: int, seed: int, admin_password: str,
|
||||
os.environ.setdefault("WB_LOG_DIR", os.path.join(out_dir, "logs"))
|
||||
os.environ["WB_DB"] = db_file
|
||||
|
||||
from workbuddy_portal import db # noqa: E402
|
||||
from workbuddy_portal import db, security # noqa: E402
|
||||
|
||||
conn = db.connect()
|
||||
try:
|
||||
db.init_db(conn, create_admin=True, admin_user=admin_user,
|
||||
admin_password=admin_password)
|
||||
|
||||
# 写入一个**明显是假值**的 Cookie:让概览页的健康状态显示为「正常」
|
||||
# ---------- 账号 ----------
|
||||
admin_row = db.user_by_name(conn, admin_user)
|
||||
admin_uid = admin_row["id"]
|
||||
demo_user = "demo"
|
||||
if not db.user_by_name(conn, demo_user):
|
||||
conn.execute(
|
||||
"INSERT INTO users(username,password_hash,display_name,email,is_admin,"
|
||||
"status,created_at) VALUES(?,?,?,?,0,'active',?)",
|
||||
(demo_user, security.hash_password(admin_password), "演示账号",
|
||||
"demo@example.invalid", db.now_str()))
|
||||
db_row = db.user_by_name(conn, demo_user)
|
||||
demo_uid = db_row["id"]
|
||||
|
||||
# 写入**明显是假值**的 Cookie:让概览页的健康状态显示为「正常」
|
||||
# 而不是首装的「缺 Cookie」告警——演示与截图应当呈现「配置完成」后的样子。
|
||||
# 这个值不会被任何真实服务接受,也不含任何真实凭据。
|
||||
db.set_setting(conn, "cookie", DEMO_COOKIE)
|
||||
# 经 set_secret 落库 = 真的走一遍加密,所以示例库里也是密文。
|
||||
db.set_secret(conn, "cookie", DEMO_COOKIE, admin_uid)
|
||||
db.set_secret(conn, "cookie", DEMO_COOKIE_2, demo_uid)
|
||||
# 两个账号各有一套调度时刻,界面上能看出「每人可改自己的」
|
||||
db.set_setting(conn, "schedule_times", "09:00,17:00", admin_uid)
|
||||
db.set_setting(conn, "schedule_times", "08:30,20:00", demo_uid)
|
||||
|
||||
rng = random.Random(seed)
|
||||
now = datetime.now().replace(second=0, microsecond=0)
|
||||
today0 = now.replace(hour=0, minute=0, second=0)
|
||||
model_pairs = [(m, w) for m, w, _ in MODELS]
|
||||
medians = {m: md for m, _, md in MODELS}
|
||||
model_pairs_2 = [(m, w) for m, w, _ in MODELS_2]
|
||||
medians_2 = {m: md for m, _, md in MODELS_2}
|
||||
client_pairs = list(CLIENTS)
|
||||
|
||||
# ---------- usage_records ----------
|
||||
rows = []
|
||||
for d in range(days - 1, -1, -1):
|
||||
day0 = today0 - timedelta(days=d)
|
||||
for _ in range(rng.randint(14, 46)):
|
||||
# 工作时间加权:9-19 点更密
|
||||
hour = _weighted(rng, [(h, 6 if 9 <= h <= 19 else 1) for h in range(24)])
|
||||
minute = rng.randint(0, 59)
|
||||
sec = rng.randint(0, 59)
|
||||
ts = day0 + timedelta(hours=hour, minutes=minute, seconds=sec)
|
||||
if ts > now:
|
||||
continue
|
||||
model = _weighted(rng, model_pairs)
|
||||
client = _weighted(rng, client_pairs)
|
||||
rid = "req-%s" % "".join(rng.choice("0123456789abcdef") for _ in range(16))
|
||||
stamp = ts.strftime("%Y-%m-%d %H:%M:%S")
|
||||
rows.append((
|
||||
rid, stamp, ts.strftime("%Y-%m-%d"), hour, model, client,
|
||||
_credits(rng, medians[model]), _prompt(rng),
|
||||
stamp, stamp, stamp,
|
||||
))
|
||||
# ---------- usage_records(两个账号各生成一份)----------
|
||||
def _gen_records(uid, pairs, med, lo=14, hi=46):
|
||||
rows = []
|
||||
for d in range(days - 1, -1, -1):
|
||||
day0 = today0 - timedelta(days=d)
|
||||
for _ in range(rng.randint(lo, hi)):
|
||||
# 工作时间加权:9-19 点更密
|
||||
hour = _weighted(rng, [(h, 6 if 9 <= h <= 19 else 1) for h in range(24)])
|
||||
ts = day0 + timedelta(hours=hour, minutes=rng.randint(0, 59),
|
||||
seconds=rng.randint(0, 59))
|
||||
if ts > now:
|
||||
continue
|
||||
model = _weighted(rng, pairs)
|
||||
client = _weighted(rng, client_pairs)
|
||||
rid = "req-%s" % "".join(rng.choice("0123456789abcdef") for _ in range(16))
|
||||
stamp = ts.strftime("%Y-%m-%d %H:%M:%S")
|
||||
rows.append((
|
||||
uid, rid, stamp, ts.strftime("%Y-%m-%d"), hour, model, client,
|
||||
_credits(rng, med[model]), _prompt(rng), stamp, stamp, stamp,
|
||||
))
|
||||
return rows
|
||||
|
||||
rows = _gen_records(admin_uid, model_pairs, medians)
|
||||
# 普通账号只给大约三分之二的量:列表里一眼能分出主次
|
||||
rows += _gen_records(demo_uid, model_pairs_2, medians_2, lo=9, hi=31)
|
||||
conn.execute("BEGIN")
|
||||
conn.executemany(
|
||||
"INSERT OR REPLACE INTO usage_records"
|
||||
"(request_id,ts,day,hour,model,client,credits,prompt,"
|
||||
" first_seen,last_seen,cloud_ts) VALUES(?,?,?,?,?,?,?,?,?,?,?)", rows)
|
||||
"(user_id,request_id,ts,day,hour,model,client,credits,prompt,"
|
||||
" first_seen,last_seen,cloud_ts) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", rows)
|
||||
conn.execute("COMMIT")
|
||||
|
||||
# ---------- collect_runs ----------
|
||||
@@ -212,7 +250,7 @@ def build(out_dir: str, days: int, seed: int, admin_password: str,
|
||||
fetched = dup = added = 0
|
||||
trigger = "schedule" if i % 3 else "manual"
|
||||
runs.append((
|
||||
trigger, status, started.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
admin_uid, trigger, status, started.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
(started + timedelta(milliseconds=rng.randint(180, 1400))
|
||||
).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
rng.randint(180, 1400),
|
||||
@@ -221,11 +259,20 @@ def build(out_dir: str, days: int, seed: int, admin_password: str,
|
||||
fetched, added, dup, total, 0, exit_code, msg,
|
||||
"[%s] %s" % (status, msg),
|
||||
))
|
||||
# 普通账号也给两条,让「日志只显示自己的」在截图里成立
|
||||
for k, (st, msg) in enumerate((("ok", "新增 6 条,重复 4 条,存档共 192 条"),
|
||||
("ok", "新增 3 条,重复 5 条,存档共 186 条"))):
|
||||
at = now - timedelta(hours=5 + k * 9)
|
||||
runs.append((demo_uid, "schedule", st, at.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
at.strftime("%Y-%m-%d %H:%M:%S"), 420,
|
||||
(at - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
at.strftime("%Y-%m-%d %H:%M:%S"), 10 - k, 6 - k * 3, 4, 192, 0, 0,
|
||||
msg, "[%s] %s" % (st, msg)))
|
||||
conn.execute("BEGIN")
|
||||
conn.executemany(
|
||||
"INSERT INTO collect_runs(trigger,status,started_at,finished_at,duration_ms,"
|
||||
"INSERT INTO collect_runs(user_id,trigger,status,started_at,finished_at,duration_ms,"
|
||||
"win_from,win_to,fetched,added,dup,total,conflicts,exit_code,message,detail)"
|
||||
" VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", runs)
|
||||
" VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", runs)
|
||||
conn.execute("COMMIT")
|
||||
|
||||
# ---------- audit_log ----------
|
||||
@@ -235,18 +282,21 @@ def build(out_dir: str, days: int, seed: int, admin_password: str,
|
||||
actions = [
|
||||
("login", "登录成功", "192.0.2.10"),
|
||||
("login", "登录成功", "192.0.2.10"),
|
||||
("settings", "修改:调度时刻 09:00,17:00", "192.0.2.10"),
|
||||
("settings", "修改:分页大小 200", "192.0.2.10"),
|
||||
("settings", "修改:schedule_times", "192.0.2.10"),
|
||||
("settings", "修改:page_size", "192.0.2.10"),
|
||||
("maintenance.count", "存档当前 %d 条记录" % total, "192.0.2.10"),
|
||||
("settings_rejected", "参数错误:page_size 必须是数字(条/页)", "192.0.2.10"),
|
||||
]
|
||||
for i in range(30):
|
||||
act, detail, ip = actions[i % len(actions)]
|
||||
at = now - timedelta(hours=i * 3 + rng.randint(0, 2))
|
||||
audits.append((at.strftime("%Y-%m-%d %H:%M:%S"), admin_user, act, detail, ip))
|
||||
uid = admin_uid if i % 3 else demo_uid
|
||||
actor = admin_user if i % 3 else demo_user
|
||||
audits.append((uid, at.strftime("%Y-%m-%d %H:%M:%S"), actor, act, detail, ip))
|
||||
conn.execute("BEGIN")
|
||||
conn.executemany(
|
||||
"INSERT INTO audit_log(at,actor,action,detail,ip) VALUES(?,?,?,?,?)", audits)
|
||||
"INSERT INTO audit_log(user_id,at,actor,action,detail,ip)"
|
||||
" VALUES(?,?,?,?,?,?)", audits)
|
||||
conn.execute("COMMIT")
|
||||
|
||||
# 统计一下,便于打印
|
||||
@@ -255,7 +305,13 @@ def build(out_dir: str, days: int, seed: int, admin_password: str,
|
||||
d = conn.execute("SELECT COUNT(DISTINCT day) FROM usage_records").fetchone()[0]
|
||||
print("示例库:%s" % db_file)
|
||||
print(" 记录 %d 条 / 积分 %s / 覆盖 %d 天" % (n, c, d))
|
||||
print(" 管理员 %s,口令 %s" % (admin_user, admin_password))
|
||||
for r in conn.execute(
|
||||
"SELECT u.id,u.username,u.is_admin,"
|
||||
" (SELECT COUNT(*) FROM usage_records x WHERE x.user_id=u.id) AS n"
|
||||
" FROM users u ORDER BY u.id"):
|
||||
print(" 账号 #%s %-8s %-6s %d 条"
|
||||
% (r["id"], r["username"], "管理员" if r["is_admin"] else "普通", r["n"]))
|
||||
print(" 口令都是 %s(仅供本地演示)" % admin_password)
|
||||
print(" 该目录在 .gitignore 内,不会被提交")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
+98
-29
@@ -6,33 +6,47 @@
|
||||
"""界面实检:登录后逐页截图,用来目视确认「统一美化」是否真的落地。
|
||||
|
||||
用法:
|
||||
python manage.py serve --port 8849 --no-scheduler # 另开一个终端
|
||||
python tools/shots.py --base http://127.0.0.1:8849
|
||||
WB_DATA_DIR=$PWD/data/demo python manage.py serve --port 8849 --no-scheduler
|
||||
python tools/shots.py --base http://127.0.0.1:8849 --db data/demo/usage.sqlite
|
||||
python tools/shots.py --full # 整页长图(默认只截首屏)
|
||||
|
||||
产物:data/shots/*.png(已被 .gitignore 之外的目录,可直接删)。
|
||||
产物:data/shots/*.png(该目录在 .gitignore 内,可直接删)。
|
||||
|
||||
为什么不用 headless chrome 直出:本项目的页面都要登录态,
|
||||
`--screenshot` 无法注入会话 Cookie,所以必须用 Playwright 走一次真实登录。
|
||||
|
||||
验证码:默认策略是 always,所以本脚本会**从本地库里取答案**(取的是会话里的
|
||||
captcha id,答案只存在于服务端),这样自动化能跨过验证码这一关。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import urllib.parse
|
||||
import zlib
|
||||
|
||||
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, BASE)
|
||||
|
||||
PAGES = [
|
||||
("login", "/login", "登录页"),
|
||||
("overview", "/", "概览"),
|
||||
("records", "/records", "数据明细"),
|
||||
("tasks", "/tasks", "任务管理"),
|
||||
("config", "/config", "配置管理"),
|
||||
("logs", "/logs", "日志管理"),
|
||||
("users", "/users", "用户管理"),
|
||||
("dashboard", "/dashboard", "用量大屏"),
|
||||
# 文件名刻意与原有编号保持一致(docs/USER-GUIDE.md 里就是按这些名字引用的),
|
||||
# 新增页面排在后面,避免为了两张新图去改一堆文档链接。
|
||||
CAPTURES = [
|
||||
# (文件名, 路径, 标签, 是否需登录)
|
||||
("00-login.png", "/login", "登录页 (含图形验证码)", False),
|
||||
("09-register.png", "/register", "注册页", False),
|
||||
("01-overview.png", "/", "概览", True),
|
||||
("02-records.png", "/records", "数据明细", True),
|
||||
("03-tasks.png", "/tasks", "任务管理", True),
|
||||
("04-config.png", "/config", "配置管理", True),
|
||||
("05-logs.png", "/logs", "日志管理", True),
|
||||
("06-users.png", "/users", "用户管理", True),
|
||||
("07-dashboard.png", "/dashboard", "用量大屏", True),
|
||||
("10-profile.png", "/profile", "个人中心", True),
|
||||
]
|
||||
|
||||
|
||||
@@ -61,18 +75,59 @@ def _find_browser() -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _session_payload(ctx) -> dict:
|
||||
"""解出 Flask 会话 cookie 里的载荷,只为拿 captcha 的 id。
|
||||
|
||||
Flask 会话是「签名 + base64,不加密」的,所以这里面能读出内容 ——
|
||||
正因如此,验证码答案绝不能放进去(本项目只放一个随机 id)。
|
||||
"""
|
||||
for c in ctx.cookies():
|
||||
if not c.get("name", "").startswith("workbuddy_portal_sid"):
|
||||
continue
|
||||
seg = urllib.parse.unquote(c.get("value", "")).split(".")[0]
|
||||
seg += "=" * (-len(seg) % 4)
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(seg)
|
||||
try:
|
||||
raw = zlib.decompress(raw)
|
||||
except zlib.error:
|
||||
pass
|
||||
return json.loads(raw.decode("utf-8"))
|
||||
except Exception: # noqa: BLE001
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def _captcha_answer(ctx, db_path: str, purpose: str) -> str | None:
|
||||
cid = _session_payload(ctx).get("cap_" + purpose)
|
||||
if not cid or not db_path or not os.path.exists(db_path):
|
||||
return None
|
||||
try:
|
||||
con = sqlite3.connect(db_path)
|
||||
try:
|
||||
row = con.execute("SELECT answer FROM captchas WHERE id=?", (cid,)).fetchone()
|
||||
finally:
|
||||
con.close()
|
||||
except sqlite3.Error:
|
||||
return None
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--base", default="http://127.0.0.1:8849")
|
||||
ap.add_argument("-u", "--user", default="admin")
|
||||
ap.add_argument("-p", "--password", default="admin123")
|
||||
ap.add_argument("--out", default=os.path.join(BASE, "data", "shots"))
|
||||
ap.add_argument("--db", default=None,
|
||||
help="SQLite 路径(默认 <repo>/data/usage.sqlite),用于取验证码答案")
|
||||
ap.add_argument("--full", action="store_true", help="截整页长图")
|
||||
ap.add_argument("--browser", default="", help="显式指定 chrome/msedge 可执行文件")
|
||||
ap.add_argument("--width", type=int, default=1440)
|
||||
ap.add_argument("--height", type=int, default=900)
|
||||
a = ap.parse_args()
|
||||
|
||||
db_path = a.db or os.path.join(BASE, "data", "usage.sqlite")
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
exe = a.browser or _find_browser()
|
||||
@@ -93,14 +148,35 @@ def main() -> int:
|
||||
page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
|
||||
page.on("pageerror", lambda e: errors.append(str(e)))
|
||||
|
||||
# 1) 先截未登录的登录页
|
||||
page.goto(a.base + "/login", wait_until="networkidle")
|
||||
page.screenshot(path=os.path.join(a.out, "00-login.png"), full_page=a.full)
|
||||
print("[ok] 00-login.png")
|
||||
def shoot(name, path, label):
|
||||
errors.clear()
|
||||
page.goto(a.base + path, wait_until="networkidle")
|
||||
page.wait_for_timeout(900) # 等 ECharts / 表格渲染稳下来
|
||||
page.screenshot(path=os.path.join(a.out, name), full_page=a.full)
|
||||
js_err = [e for e in errors if "favicon" not in e.lower()]
|
||||
if js_err:
|
||||
problems.append("%s: %s" % (label, js_err[:3]))
|
||||
print("[ok] %-22s %s%s" % (name, label,
|
||||
"" if not js_err else " [JS错误] " + " | ".join(js_err[:3])))
|
||||
|
||||
# 2) 登录
|
||||
# 1) 未登录的两页
|
||||
for name, path, label, need_auth in CAPTURES:
|
||||
if need_auth:
|
||||
break
|
||||
shoot(name, path, label)
|
||||
|
||||
# 2) 登录(策略为 always 时自动解验证码)
|
||||
page.goto(a.base + "/login", wait_until="networkidle")
|
||||
page.fill('input[name=username]', a.user)
|
||||
page.fill('input[name=password]', a.password)
|
||||
if page.query_selector('input[name=captcha]'):
|
||||
ans = _captcha_answer(ctx, db_path, "login")
|
||||
if not ans:
|
||||
print("[FAIL] 需要验证码但取不到答案(--db 是否指向本实例的库?):%s" % db_path)
|
||||
br.close()
|
||||
return 1
|
||||
page.fill('input[name=captcha]', ans)
|
||||
print("[ok] 已用库里的答案通过验证码")
|
||||
page.click('button[type=submit]')
|
||||
page.wait_for_load_state("networkidle")
|
||||
if "/login" in page.url:
|
||||
@@ -108,18 +184,11 @@ def main() -> int:
|
||||
br.close()
|
||||
return 1
|
||||
|
||||
# 3) 逐页截图
|
||||
for i, (slug, path, label) in enumerate(PAGES[1:], start=1):
|
||||
errors.clear()
|
||||
page.goto(a.base + path, wait_until="networkidle")
|
||||
page.wait_for_timeout(900) # 等 ECharts / 表格渲染稳下来
|
||||
page.screenshot(path=os.path.join(a.out, "%02d-%s.png" % (i, slug)),
|
||||
full_page=a.full)
|
||||
js_err = [e for e in errors if "favicon" not in e.lower()]
|
||||
flag = "" if not js_err else " [JS错误] " + " | ".join(js_err[:3])
|
||||
if js_err:
|
||||
problems.append("%s: %s" % (label, js_err[:3]))
|
||||
print("[ok] %02d-%s.png %s%s" % (i, slug, label, flag))
|
||||
# 3) 登录后的页面
|
||||
for name, path, label, need_auth in CAPTURES:
|
||||
if not need_auth:
|
||||
continue
|
||||
shoot(name, path, label)
|
||||
|
||||
# 4) 大屏页再点几个交互,确认控件联动不炸
|
||||
page.goto(a.base + "/dashboard", wait_until="networkidle")
|
||||
|
||||
+293
-55
@@ -13,9 +13,14 @@
|
||||
覆盖内容:
|
||||
1. 全页面渲染(含 /users,需管理员身份)——模板报错会直接暴露成 500
|
||||
2. 模板未渲染残留(HTML 里出现 {{ / {% 说明有变量名写错)
|
||||
3. 历史缺陷防回归(见下 REGRESSIONS)
|
||||
4. CSV 导出可被标准 csv 解析、列数一致
|
||||
5. 页面 HTML 里的 class 与 app.css 的选择器做差集(抓类名拼写错误)
|
||||
3. 历史缺陷防回归(见 4. 的 ①~⑭)
|
||||
4. 多用户:数据隔离 / 凭证保密 / 注册与验证码 / 权限边界
|
||||
5. CSV 导出可被标准 csv 解析、列数一致
|
||||
6. 页面 HTML 里的 class 与 app.css 的选择器做差集(抓类名拼写错误)
|
||||
|
||||
写库说明:会写少量 audit_log 行;另外会**临时**建两个普通账号
|
||||
(一个用来验权限边界,一个用来走完整注册链路),无论成功失败都在 finally 里删掉。
|
||||
不会改动任何用量数据。
|
||||
|
||||
用法:
|
||||
cd workbuddy-portal
|
||||
@@ -28,7 +33,9 @@ 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__)))
|
||||
@@ -57,14 +64,19 @@ def note(msg: str) -> None:
|
||||
print(" [note] %s" % msg)
|
||||
|
||||
|
||||
def login(cli, admin=True):
|
||||
"""注入会话绕过登录:GET 不触发 CSRF,因此可直接测页面渲染。"""
|
||||
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"] = 1
|
||||
s["uname"] = "admin" if admin else "viewer"
|
||||
s["dname"] = "管理员" if admin else "只读账号"
|
||||
s["adm"] = 1 if admin else 0
|
||||
s["_csrf"] = "smoke-csrf-token"
|
||||
s["uid"] = uid
|
||||
s["uname"] = "smoke-%s" % uid
|
||||
s["dname"] = "smoke"
|
||||
s["adm"] = 0
|
||||
s["_csrf"] = csrf
|
||||
|
||||
|
||||
def page(cli, path, method="GET", **kw):
|
||||
@@ -72,36 +84,59 @@ def page(cli, path, method="GET", **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 create_app, db, query
|
||||
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("路由数量 >= 35", n_routes >= 35, "routes=%d" % n_routes)
|
||||
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"):
|
||||
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=True)
|
||||
login(cli, ADMIN)
|
||||
pages = [
|
||||
("/", "概览"), ("/records", "数据明细"), ("/tasks", "任务管理"),
|
||||
("/config", "配置管理"), ("/logs", "日志管理"), ("/users", "用户管理"),
|
||||
("/dashboard", "<html"),
|
||||
("/profile", "个人中心"), ("/dashboard", "<html"),
|
||||
]
|
||||
for p, kw in pages:
|
||||
st, html = page(cli, p)
|
||||
@@ -112,24 +147,44 @@ def run() -> None:
|
||||
chk(" └ 含导航栏", "topbar" in html or p == "/dashboard")
|
||||
|
||||
st, html = page(cli, "/users")
|
||||
chk("用户管理页列出账号", 'data-uid=' in html, "含行内编辑按钮")
|
||||
chk("用户管理页列出账号", 'data-uid=' in html)
|
||||
chk("用户管理页含新建表单", 'id="formNewUser"' in html)
|
||||
chk("用户管理页含审计表", "用户操作审计" 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=True)
|
||||
for p in ("/", "/records", "/tasks", "/config", "/logs", "/users", "/dashboard"):
|
||||
login(cli, ADMIN)
|
||||
for p in ("/", "/records", "/tasks", "/config", "/logs", "/users",
|
||||
"/profile", "/dashboard"):
|
||||
_, html = page(cli, p)
|
||||
# 先剥掉 HTML 注释:注释里常写示例路径(src="vendor/x.js"),
|
||||
# 不剥会把示例当真实引用误报。
|
||||
@@ -150,25 +205,205 @@ def run() -> None:
|
||||
chk("%-11s 资源引用全部 200" % p, not bad,
|
||||
("坏引用=%s" % bad) if bad else "%d 个引用" % n)
|
||||
|
||||
# ---------------- 3. 非管理员:权限边界 ----------------
|
||||
print("== 3. 非管理员:/users 必须 403,导航不出现该入口 ==")
|
||||
with app.test_client() as cli:
|
||||
login(cli, admin=False)
|
||||
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, _ = page(cli, "/api/users")
|
||||
st, html = page(cli, "/")
|
||||
chk("概览导航不含「用户管理」", "用户管理" not in html)
|
||||
for p in ("/", "/records", "/tasks", "/logs"):
|
||||
st, _ = page(cli, p)
|
||||
chk("GET %-10s 非管理员=200" % p, st == 200, "status=%s" % st)
|
||||
# ---------------- 3. 多用户:隔离 / 保密 / 注册与验证码 ----------------
|
||||
print("== 3. 多用户:数据隔离 / 凭证保密 / 注册与验证码 ==")
|
||||
viewer = "smoke_v_%s" % _rand()
|
||||
regged = "smoke_r_%s" % _rand()
|
||||
created: list[str] = [viewer]
|
||||
saved_ua_inst = None
|
||||
|
||||
# ---------------- 4. 历史缺陷防回归 ----------------
|
||||
print("== 4. 历史缺陷防回归 ==")
|
||||
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=True)
|
||||
login(cli, ADMIN)
|
||||
# ① 非法日期曾 500
|
||||
st, body = page(cli, "/api/summary?from=abc&to=def")
|
||||
chk("① /api/summary 非法日期=400", st == 400, "status=%s" % st)
|
||||
@@ -222,8 +457,8 @@ def run() -> None:
|
||||
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]))
|
||||
# ⑪ 非管理员调用户管理 API
|
||||
st, _ = page(cli, "/api/users/1/delete", method="POST", json={},
|
||||
# ⑪ 管理员不能删自己
|
||||
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
|
||||
@@ -251,10 +486,10 @@ def run() -> None:
|
||||
chk("⑭ 审计筛选结果不含其他动作", not others and bool(tags),
|
||||
"命中=%d 混入=%s" % (len(tags), others))
|
||||
|
||||
# ---------------- 5. 数据自洽 ----------------
|
||||
print("== 5. 数据自洽(只读) ==")
|
||||
# ---------------- 6. 数据自洽 ----------------
|
||||
print("== 6. 数据自洽(只读) ==")
|
||||
with app.test_client() as cli:
|
||||
login(cli, admin=True)
|
||||
login(cli, ADMIN)
|
||||
mf = json.loads(page(cli, "/api/manifest")[1])
|
||||
src = (mf.get("sources") or [{}])[0]
|
||||
chk("manifest 存档条数 == 数据源条数",
|
||||
@@ -267,29 +502,32 @@ def run() -> None:
|
||||
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())
|
||||
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)))
|
||||
note("管理员存档 %s 条 / %s 积分 / %d 天" % (mf["totals"]["records"],
|
||||
mf["totals"]["credits"], len(d)))
|
||||
|
||||
# ---------------- 6. class 名与 CSS 选择器对账 ----------------
|
||||
print("== 6. 页面 class 与 app.css 选择器对账 ==")
|
||||
# ---------------- 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=True)
|
||||
used: set[str] = set()
|
||||
for p in ("/", "/records", "/tasks", "/config", "/logs", "/users", "/login"):
|
||||
if p == "/login":
|
||||
with app.test_client() as c2:
|
||||
html = page(c2, p)[1]
|
||||
else:
|
||||
html = page(cli, p)[1]
|
||||
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 钩子、第三方/语义标记
|
||||
|
||||
在新工单中引用
屏蔽一个用户