feat(权限): 收敛普通账号写权限至本人凭证
将调度时刻、采集参数等实例级配置收归管理员,普通账号仅可维护本人 Cookie 与 User-Agent。 新增 config.writable_by 作为唯一写权限入口,set_setting 强制全局键落到 user_id=0, 消除「管理员改了只有自己生效」的静默缺陷。新增 tools/check_docs.py 文档自检, smoke 断言扩至 215 项、check_live 扩至 122 项并支持普通账号越权验收, 忽略 backups/、data/*.bak* 与 legacy-v1/,版本升至 v1.3.0。
这个提交包含在:
+57
-19
@@ -49,6 +49,14 @@ CAPTURES = [
|
||||
("10-profile.png", "/profile", "个人中心", True),
|
||||
]
|
||||
|
||||
# v1.3.0 起「任务管理 / 配置管理」在普通账号下是**只读**形态,
|
||||
# 与管理员看到的表单不是同一个页面。文档要同时展示两种视角,
|
||||
# 所以单独跑一趟普通账号的登录会话(换个 context = 干净 Cookie)。
|
||||
USER_CAPTURES = [
|
||||
("03b-tasks-user.png", "/tasks", "任务管理(普通账号:调度只读)"),
|
||||
("04b-config-user.png", "/config", "配置管理(普通账号:仅凭证可改)"),
|
||||
]
|
||||
|
||||
|
||||
def _find_browser() -> str | None:
|
||||
"""找一个可用的 Chromium 可执行文件。
|
||||
@@ -118,6 +126,9 @@ def main() -> int:
|
||||
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("--user2", default="demo",
|
||||
help="普通账号用户名(截只读视角用;设为空则跳过)")
|
||||
ap.add_argument("--password2", 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),用于取验证码答案")
|
||||
@@ -148,38 +159,42 @@ 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)))
|
||||
|
||||
def shoot(name, path, label):
|
||||
def shoot(name, path, label, pg=None):
|
||||
pg = pg or page
|
||||
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)
|
||||
pg.goto(a.base + path, wait_until="networkidle")
|
||||
pg.wait_for_timeout(900) # 等 ECharts / 表格渲染稳下来
|
||||
pg.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])))
|
||||
|
||||
def login(pg, ctx, user, password):
|
||||
"""登录并跨过验证码(策略为 always 时从本地库取答案)。"""
|
||||
pg.goto(a.base + "/login", wait_until="networkidle")
|
||||
pg.fill('input[name=username]', user)
|
||||
pg.fill('input[name=password]', password)
|
||||
if pg.query_selector('input[name=captcha]'):
|
||||
ans = _captcha_answer(ctx, db_path, "login")
|
||||
if not ans:
|
||||
print("[FAIL] 需要验证码但取不到答案(--db 是否指向本实例的库?):%s" % db_path)
|
||||
return False
|
||||
pg.fill('input[name=captcha]', ans)
|
||||
print("[ok] 已用库里的答案通过验证码(%s)" % user)
|
||||
pg.click('button[type=submit]')
|
||||
pg.wait_for_load_state("networkidle")
|
||||
return "/login" not in pg.url
|
||||
|
||||
# 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:
|
||||
# 2) 以管理员登录(策略为 always 时自动解验证码)
|
||||
if not login(page, ctx, a.user, a.password):
|
||||
print("[FAIL] 登录失败,后续截图无意义")
|
||||
br.close()
|
||||
return 1
|
||||
@@ -208,6 +223,29 @@ def main() -> int:
|
||||
problems.append("大屏交互: %s" % js_err[:2])
|
||||
break
|
||||
|
||||
# 5) 换一个干净 context,用普通账号再跑一趟只读视角
|
||||
if a.user2:
|
||||
ctx2 = br.new_context(viewport={"width": a.width, "height": a.height},
|
||||
device_scale_factor=2, locale="zh-CN")
|
||||
page2 = ctx2.new_page()
|
||||
page2.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
|
||||
page2.on("pageerror", lambda e: errors.append(str(e)))
|
||||
if not login(page2, ctx2, a.user2, a.password2):
|
||||
print("[warn] 普通账号 %s 登录失败,跳过只读视角截图" % a.user2)
|
||||
problems.append("普通账号 %s 登录失败" % a.user2)
|
||||
else:
|
||||
for name, path, label in USER_CAPTURES:
|
||||
shoot(name, path, label, pg=page2)
|
||||
# 顺带把越权面再验一次:普通账号访问这些必须不是 200
|
||||
for probe in ("/logs", "/logs/tail", "/users"):
|
||||
r = page2.goto(a.base + probe, wait_until="domcontentloaded")
|
||||
code = r.status if r else 0
|
||||
ok = code in (403, 401)
|
||||
print("[%s] 越权面 %-12s -> %s" % ("ok" if ok else "!!", probe, code))
|
||||
if not ok:
|
||||
problems.append("越权面未关死:%s 返回 %s" % (probe, code))
|
||||
ctx2.close()
|
||||
|
||||
br.close()
|
||||
|
||||
print("\n截图目录:%s" % a.out)
|
||||
|
||||
在新工单中引用
屏蔽一个用户