Files
2026-09-22 16:08:12 +08:00

701 lines
32 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""GUI 冒烟测试(无头运行,驱动真实控件而非只构造窗口)。
覆盖:生成密钥对(含 0600 权限)→ 私钥状态跨页同步 → 解析申请码(含 weak 标注)
→ 插件列表自动发现 → 载荷预览 → 签发 → 查看/校验(目标机通过、异机拒绝)
→ 有效期三种模式的取值。
运行(需要 PySide6,故用本包的独立环境)::
cd utils/license-gui
uv run python smoke_test.py
注意:界面里的信息框/确认框会**阻塞事件循环等待点击**,无头环境下必须先替换掉,
否则测试会挂住。这也是本测试唯一需要 monkeypatch 的地方。
"""
from __future__ import annotations
import json
import os
import re
import sys
import tempfile
from pathlib import Path
from PySide6.QtCore import QSettings
from PySide6.QtWidgets import QApplication
failures: list[str] = []
def check(name: str, cond: bool, detail: str = "") -> None:
print(f"[{'PASS' if cond else 'FAIL'}] {name}" + (f" — {detail}" if detail else ""))
if not cond:
failures.append(name)
def _synthetic_sources() -> dict[str, str]:
"""非 Linux 平台上的替代指纹(已按库的规则归一化:小写、仅 [0-9a-z])。
含 1 个 weak 来源(machine_id),以便覆盖"weak 不参与判定"的标注逻辑。
"""
return {
"board_serial": "cnx7b2a4419e5",
"product_uuid": "4c4c4544003734108033b7c04f4a4d32",
"mac": "001b44113ab7",
"machine_id": "d9b8f0e6a1c34b2f9e7d5a6c8b0f1e2d",
}
def _count_colors(widget, colors, step: int = 4) -> dict[str, int]:
"""统计渲染结果里各颜色的采样点数(验证主题确实画上去了)。
样式表写错时 Qt 往往**静默**忽略规则,界面只会退化回原生外观而不报错 ——
所以这里直接对抓下来的图取色,比只看 QSS 字符串可靠。
"""
image = widget.grab().toImage()
wanted = {c.lower(): 0 for c in colors}
for y in range(0, image.height(), step):
for x in range(0, image.width(), step):
name = image.pixelColor(x, y).name()
if name in wanted:
wanted[name] += 1
return wanted
def _rejects(call) -> bool:
"""调用是否按预期抛异常(用于校验"非法输入被拒绝")。"""
try:
call()
except Exception: # noqa: BLE001
return True
return False
def _error_text(call) -> str | None:
"""调用并返回异常文本;没抛异常则返回 None。"""
try:
call()
except Exception as exc: # noqa: BLE001
return str(exc)
return None
class _FakePgConn:
"""假连接:记录执行过的 SQL,用来在**没有服务器**的环境下验证 PG 适配层。
只实现被 PostgresRegistry 用到的几个方法,不解释 SQL。
"""
def __init__(self, rows: list | None = None) -> None:
self.calls: list[tuple[str, tuple]] = []
self.rows = rows or []
def execute(self, sql, params=None): # noqa: ANN001, ANN201
self.calls.append((sql, tuple(params or ())))
return self
def fetchall(self):
return self.rows
def fetchone(self):
return self.rows[0] if self.rows else None
def close(self) -> None:
pass
def _pg_registry(fake, old=None):
"""绕过 __init__(不连数据库)构造一个可用的 PostgresRegistry。"""
import threading
from chongming_license_gui.pg_store import PgConfig, PostgresRegistry
reg = object.__new__(PostgresRegistry)
reg.config = PgConfig(host="h", port=5432, dbname="d", user="u")
reg.password = ""
reg.path = reg.config.display()
reg._lock = threading.RLock()
reg._schema = '"public"'
reg._table = '"public"."issued_licenses"'
reg._meta = '"public"."chongming_schema"'
reg._conn = fake
if old is not None:
# 模拟"台账里已经有这条":save() 的预读由它返回
reg.get = lambda lic_id, _old=old: _old
return reg
def main() -> int:
# QApplication 实例必须保活,否则控件无法创建;引用由 Qt 内部持有,这里也留一份
app = QApplication.instance() or QApplication([])
from chongming_license import (
build_license,
collect_fingerprints,
encode_request_code,
machine_code,
read_license,
write_license,
)
from chongming_license_gui import (
common,
inspect_tab,
issue_tab,
keys_tab,
registry_tab,
)
from chongming_license_gui.app import MainWindow
# 配置必须隔离到临时目录:**不能**指望 QSettings.setDefaultFormat 就够了 ——
# 实测两参数构造仍然写注册表 HKCU\Software\chongming\license-gui,
# 于是"清空初始状态"会清掉用户真实设置,"设个路径"会写到用户配置里。
settings = common.isolated_settings(tempfile.mkdtemp(prefix="clg-smoke-"))
common.assert_isolated_settings(settings, Path(settings.fileName()).parent)
settings.clear() # 现在清的是临时配置,初始状态确定
# 把模态弹窗换成记录器(否则会阻塞等待点击)
dialogs: list[tuple[str, str]] = []
def _mk(kind: str):
def _f(parent, title, text, *a, **k):
dialogs.append((kind, title))
return True
return _f
for mod in (keys_tab, issue_tab, inspect_tab, registry_tab):
mod.info = _mk("info")
mod.warn = _mk("warn")
mod.error = _mk("error")
if hasattr(mod, "ask_overwrite"):
mod.ask_overwrite = _mk("ask")
common.ask_overwrite = _mk("ask")
w = MainWindow(settings)
check("配置已隔离到临时目录(不碰真实配置)",
Path(w.settings.fileName()).is_relative_to(Path(settings.fileName()).parent),
w.settings.fileName())
# 显式给一个够大的窗口再取色:默认尺寸是"打开即最小"(940x640),
# 那样主按钮可能落在滚动区外,像素断言会跟着"应用默认窗口多大"漂移
w.resize(1240, 940)
w.show()
check("四个页面就绪",
[w.tabs.tabText(i) for i in range(w.tabs.count())]
== ["签发", "台账", "密钥", "查看 / 校验"],
str([w.tabs.tabText(i) for i in range(w.tabs.count())]))
# ---- 0) 界面主题:确认美化真的画到了界面上(而不是 QSS 里写了却没生效)----
from PySide6.QtWidgets import QPushButton
from chongming_license_gui import icons, theme
app.processEvents() # 让布局落位后再抓图取色
check("主题样式表已应用", bool(app.styleSheet()) and theme.ACCENT in app.styleSheet())
check("图标已渲染(非空 QIcon)",
not icons.icon("issue").isNull() and icons.svg_available(),
f"svg_available={icons.svg_available()}")
def _button_texts(parent) -> set[str]:
return {b.text() for b in parent.findChildren(QPushButton)}
def _fill_at(widget, x_ratio: float) -> str:
"""取控件内某个水平位置的颜色(避开边框与文字)。"""
image = widget.grab().toImage()
return image.pixelColor(int(image.width() * x_ratio), image.height() // 2).name()
def _has_color(widget, color: str, step: int = 2) -> bool:
image = widget.grab().toImage()
for y in range(0, image.height(), step):
for x in range(0, image.width(), step):
if image.pixelColor(x, y).name() == color.lower():
return True
return False
check("主操作按钮用主题主色填充",
_fill_at(w.issue_tab.issue_btn, 0.06) == theme.ACCENT.lower(),
_fill_at(w.issue_tab.issue_btn, 0.06))
secondary = next(
b for b in w.issue_tab.findChildren(QPushButton) if b.text() == "刷新预览"
)
check("次按钮保持白底(未误用主色)",
_fill_at(secondary, 0.06) == theme.SURFACE.lower(),
f"次按钮填充={_fill_at(secondary, 0.06)} 主色={theme.ACCENT}")
danger_button = next(
b for b in w.registry_tab.findChildren(QPushButton) if b.text() == "删除记录"
)
check("「删除记录」用危险色文字",
_has_color(danger_button, theme.DANGER),
f"含危险色={_has_color(danger_button, theme.DANGER)} 填充={_fill_at(danger_button, 0.06)}")
check("台账页按钮齐全",
{"续期选中…", "改插件…", "编辑备注", "查看详情", "删除记录"} <= _button_texts(w.registry_tab),
str(sorted(_button_texts(w.registry_tab))))
verdict_image = w.inspect_tab.verdict.grab().toImage()
check("结论横幅已渲染(非空)", verdict_image.width() > 40 and verdict_image.height() > 10,
f"{verdict_image.width()}x{verdict_image.height()}")
# 指示器是"出错时最安静"的地方:QSS 的 image 加载失败时 Qt 不报错,
# 勾选框会直接变成空白 —— 曾经因为占位符拼接写错而真的踩过一次。
check("样式表无未替换的占位符",
re.search(r"@[A-Z][A-Z0-9_]*@?", app.styleSheet()) is None,
str(re.search(r"@[A-Z][A-Z0-9_]*@?", app.styleSheet())))
check("勾选态指示器按主题渲染(指示灯不会变空白)",
_has_color(w.issue_tab.record_check, theme.ACCENT)
and _has_color(w.issue_tab.rb_days, theme.ACCENT),
"复选框/单选框均命中主色")
covered = _count_colors(w, [theme.ACCENT, theme.SURFACE, theme.BG, theme.WARN])
check("主色/卡片底/页面底/警示色都已渲染",
covered[theme.ACCENT] > 10 and covered[theme.SURFACE] > 200
and covered[theme.BG] > 50 and covered[theme.WARN] > 5,
str(covered))
# ---- 1) 生成密钥对 ----
tmp = Path(tempfile.mkdtemp())
priv = tmp / "priv.pem"
w.keys_tab.out_edit.setText(str(priv))
w.keys_tab._generate()
check("私钥已生成并加载", w.signing_key.loaded and priv.is_file())
# Windows 无 POSIX 权限位(chmod 退化为 0o666),只在 POSIX 平台断言
mode = oct(priv.stat().st_mode & 0o777)
check("私钥权限为 0600", (priv.stat().st_mode & 0o777) == 0o600 or os.name == "nt",
mode + ("(Windows 无 POSIX 权限位,跳过)" if os.name == "nt" else ""))
check("公钥指纹已算出", len(w.signing_key.fingerprint) == 16,
w.signing_key.fingerprint)
check("标题显示公钥指纹", w.signing_key.fingerprint in w.windowTitle(),
w.windowTitle())
# ---- 2) 私钥状态跨页同步 ----
check("签发页同步到私钥", w.signing_key.fingerprint in w.issue_tab.key_fp.text(),
w.issue_tab.key_fp.text())
# ---- 3) 解析申请码 ----
src = collect_fingerprints()
if not src:
# 硬件指纹来源全是 Linux 侧的(DMI / /proc/cpuinfo / eMMC CID / systemd
# machine-id),Windows/macOS 上必然采集为空。这里用合成指纹继续跑:
# 被测的是界面流程(解析→勾选→预览→签发→校验→台账),指纹真假不影响覆盖度。
src = _synthetic_sources()
print("[NOTE] 本平台采集不到硬件指纹,改用合成指纹继续(界面流程覆盖不受影响)")
rc = encode_request_code(src)
it = w.issue_tab
it.rc_edit.setPlainText(rc)
it._parse_request_code()
check("解析出机器码", it._machine_code == machine_code(src), it._machine_code)
check("解析出来源指纹", it._sources == src, str(sorted(it._sources)))
check("weak 来源被标注为不参与判定",
"不参与判定" in it.target_result.text(), it.target_result.text())
check("来源明细已展示",
len(it.sources_view.toPlainText().splitlines()) == len(src))
# ---- 4) 插件列表自动发现 ----
types = [it.plugin_list.item(i).text() for i in range(it.plugin_list.count())]
check("从 config/workers 自动列出插件", types == ["aibox_sophon_adapter", "ip_sound_column"],
str(types))
it._set_all_checked(True)
check("全选生效", it._checked_plugins() == types, str(it._checked_plugins()))
# 未勾选插件时应拒绝生成
it._set_all_checked(False)
it._refresh_preview()
check("未勾选插件时预览给出提示",
"至少勾选一个" in it.preview.toPlainText(), it.preview.toPlainText()[:40])
it._set_all_checked(True)
# ---- 5) 载荷预览 ----
it.customer_edit.setText("GUI 测试客户")
it.note_edit.setText("合同备注")
it.days_spin.setValue(30)
it._refresh_preview()
preview = json.loads(it.preview.toPlainText())
check("预览含全部授权插件", preview["plugins"] == types, str(preview["plugins"]))
check("预览含客户名", preview["customer"] == "GUI 测试客户")
check("预览含来源指纹", preview["sources"] == src)
# ---- 6) 签发 ----
out = tmp / "license.lic"
payload = it._build_payload()
doc = build_license(payload, w.signing_key.key, kid="gui-test")
write_license(doc, out)
check("license 文件已落盘", out.is_file(), str(out))
# ---- 7) 查看/校验 ----
vt = w.inspect_tab
vt.lic_edit.setText(str(out))
vt.pub_edit.setText(w.signing_key.public_b64)
vt._show_only()
check("仅查看不验签也能看到编号", payload["lic_id"] in vt.detail.text())
vt.rb_remote.setChecked(True)
vt.rc_edit.setText(rc)
vt._run()
check("目标机器校验通过", "✓" in vt.verdict.text(), vt.verdict.text())
other = {k: ("ffffffff" if k == "mac" else v) for k, v in src.items()}
other["machine_id"] = "anothervalueanothervalue"
vt.rc_edit.setText(encode_request_code(other))
vt._run()
check("异机校验被拒绝", "✗" in vt.verdict.text(), vt.verdict.text())
# 无公钥时应提示而不是静默失败
vt.pub_edit.setText("")
before = len(dialogs)
vt._run()
check("缺公钥时给出提示", len(dialogs) > before, str(dialogs[-1] if dialogs else None))
# ---- 8) 有效期三种模式 ----
it.rb_forever.setChecked(True)
it._refresh_preview()
check("永久模式 expires_at 为 None",
json.loads(it.preview.toPlainText())["expires_at"] is None)
it.rb_days.setChecked(True)
it._refresh_preview()
days_value = json.loads(it.preview.toPlainText())["expires_at"]
check("N 天模式给出到期日", bool(days_value), str(days_value))
it.rb_date.setChecked(True)
it._refresh_preview()
check("指定日期模式生效",
json.loads(it.preview.toPlainText())["expires_at"]
== it.date_edit.date().toString("yyyy-MM-dd"))
check("不勾选时对应输入框被禁用",
it.days_spin.isEnabled() is False and it.date_edit.isEnabled() is True)
# ---- 9) 台账页:签发即入库、检索、续期、导出 ----
rt = w.registry_tab
db = tmp / "licenses.db"
rt.db_edit.setText(str(db))
rt._open_db(silent=True)
check("台账库已打开", w.registry.is_open and w.registry.path == db, str(w.registry.path))
check("新库为空", rt.table.rowCount() == 0, str(rt.table.rowCount()))
# 签发页的"签发即入库"是台账的数据来源,这里直接调 holder 模拟该动作
check("签发页默认勾选「记入台账」", it.record_check.isChecked())
w.registry.save(doc, public_key_fp=w.signing_key.fingerprint, source="smoke")
rt.refresh()
check("入库后台账可见", rt.table.rowCount() == 1, str(rt.table.rowCount()))
row0 = [rt.table.item(0, c).text() for c in range(9)]
check("台账显示客户", row0[1] == "GUI 测试客户", row0[1])
check("台账显示机器码", row0[2] == it._machine_code, row0[2])
check("台账显示状态与剩余天数", "即将到期" in row0[4] and "d)" in row0[4], row0[4])
check("台账显示备注", row0[7] == "合同备注", row0[7])
check("状态列用主题状态色",
rt.table.item(0, 4).foreground().color().name()
== theme.status_color(rt.records[0].status()).lower(),
f"{rt.table.item(0, 4).foreground().color().name()} / {rt.records[0].status()}")
check("状态列加粗(急缓一眼可辨)", rt.table.item(0, 4).font().bold())
check("机器码列用等宽字体(便于逐字符核对)",
rt.table.item(0, 2).font().fixedPitch())
# 筛选
rt.machine_edit.setText(it._machine_code[:12])
rt.refresh()
check("按机器码筛选命中", rt.table.rowCount() == 1, str(rt.table.rowCount()))
rt.machine_edit.setText("ZZZZ-NOT-EXIST")
rt.refresh()
check("筛选无结果时为空表", rt.table.rowCount() == 0, str(rt.table.rowCount()))
rt._reset_filters()
check("重置后恢复全量", rt.table.rowCount() == 1, str(rt.table.rowCount()))
# 续期对话框:默认生成新编号、复用原指纹
from chongming_license_gui.registry_tab import RenewDialog
rec = rt.records[0]
dlg = RenewDialog(rec)
check("续期默认生成新编号(新旧都留台账)", dlg.values()["keep_lic_id"] is False)
dlg.rb_keep_id.setChecked(True)
check("可切换为沿用原编号", dlg.values()["keep_lic_id"] is True)
dlg.rb_keep_id.setChecked(False)
dlg.days_spin.setValue(365)
vals = dlg.values()
check("续期预填原插件", vals["plugins"] == rec.plugins, str(vals["plugins"]))
check("续期复用原指纹(无需再要申请码)", rec.sources == it._sources)
# 执行续期(走与界面相同的构造路径)
from chongming_license import build_license as _bl, build_payload as _bp, write_license as _wl
np = _bp(machine_code=rec.machine_code, plugins=vals["plugins"], sources=rec.sources,
customer=vals["customer"], note=vals["note"], expires_at=vals["expires_at"])
ndoc = _bl(np, w.signing_key.key, kid=rec.kid)
_wl(ndoc, tmp / "renewed.lic")
new_rec = w.registry.save(ndoc, public_key_fp=w.signing_key.fingerprint, source="renew")
reg = w.registry.ensure_open()
reg.update_note(rec.lic_id, f"{rec.note};已续期 → {new_rec.lic_id}")
rt.refresh()
check("续期后新旧记录都在台账", rt.table.rowCount() == 2, str(rt.table.rowCount()))
check("新授权为有效状态", new_rec.status() == "active", new_rec.status())
check("旧记录留下续期链",
f"已续期 → {new_rec.lic_id}" in reg.get(rec.lic_id).note,
reg.get(rec.lic_id).note)
check("续期后文件可独立验签",
read_license(tmp / "renewed.lic", w.signing_key.public_b64,
sources=it._sources).ok)
# 编辑备注:人工批注不被重新导入覆盖
reg.update_note(new_rec.lic_id, "人工批注")
reg.save(ndoc, public_key_fp=w.signing_key.fingerprint)
check("重新入库不覆盖人工批注",
reg.get(new_rec.lic_id).note == "人工批注", reg.get(new_rec.lic_id).note)
# 导出
files = reg.export_licenses(tmp / "archive", rt.records, subdir_by_customer=True)
check("导出 .lic 数量正确", len(files) == 2, str([f.name for f in files]))
csv_path = reg.export_csv(tmp / "台账.csv", rt.records)
check("CSV 含表头 + 数据行",
len(csv_path.read_text(encoding="utf-8-sig").splitlines()) == 3)
check("CSV 为 UTF-8 BOM(Excel 中文不乱码)",
csv_path.read_bytes()[:3] == b"\xef\xbb\xbf")
# 删除
check("删除记录", reg.delete(new_rec.lic_id) is True)
rt.refresh()
check("删除后台账少一条", rt.table.rowCount() == 1, str(rt.table.rowCount()))
# ---- 9b) 改授权插件:只动白名单,有效期与生效日逐字写回 ----
# 授权文件是签名过的,改插件只能重签一份新的;这组断言是那个契约的护栏。
from PySide6.QtWidgets import QDialog
from chongming_license_gui.registry_tab import ChangePluginsDialog
base = rt.records[0]
cdlg = ChangePluginsDialog(base, rt.settings)
check("改插件默认预勾选原白名单",
cdlg.values()["plugins"] == list(base.plugins), str(cdlg.values()["plugins"]))
check("改插件默认生成新编号(新旧都留台账)", cdlg.values()["keep_lic_id"] is False)
check("改插件没有有效期选项(改插件不该顺手改到期日)",
not hasattr(cdlg, "days_spin") and not hasattr(cdlg, "date_edit"))
check("改插件未动时差异显示「与原来一致」",
cdlg.diff_label.text() == "与原来一致", cdlg.diff_label.text())
check("改插件未动时不亮拒载告警", not cdlg._plugin_form.isRowVisible(cdlg.remove_hint))
# 移掉一个 → 差异行与被移除插件的拒载告警都要出来
cdlg.plugin_picker.set_checked(base.plugins[:1])
check("改插件差异行列出移除项", "移除" in cdlg.diff_label.text(), cdlg.diff_label.text())
check("改插件移除插件时亮出拒载告警", cdlg._plugin_form.isRowVisible(cdlg.remove_hint))
# 一个都不勾 → 拒绝提交(与签发页同一条规则)
cdlg.plugin_picker.set_all(False)
cdlg.accept()
check("改插件未勾选任何插件时拒绝提交",
cdlg.result() != QDialog.DialogCode.Accepted)
# 项目里已经没有的 type 也要能补回列表(老白名单必须始终看得见、勾得回来)
cdlg.plugin_picker.ensure_types(["legacy_plugin"])
cdlg.plugin_picker.set_checked([base.plugins[0], "legacy_plugin"])
cvals = cdlg.values()
check("改插件按列表顺序返回新白名单",
cvals["plugins"] == [base.plugins[0], "legacy_plugin"], str(cvals["plugins"]))
# 走与界面相同的构造路径重签(界面上只多一步"另存为"对话框)
np = _bp(machine_code=base.machine_code, plugins=cvals["plugins"], sources=base.sources,
customer=cvals["customer"], note=cvals["note"],
not_before=base.not_before, expires_at=base.expires_at)
ndoc = _bl(np, w.signing_key.key, kid=base.kid)
_wl(ndoc, tmp / "plugins.lic")
changed_rec = w.registry.save(ndoc, public_key_fp=w.signing_key.fingerprint, source="plugins")
reg.update_note(base.lic_id, f"{base.note};已变更插件 → {changed_rec.lic_id}")
rt.refresh()
check("改插件后新旧记录都在台账", rt.table.rowCount() == 2, str(rt.table.rowCount()))
check("改插件不改到期日", changed_rec.expires_at == base.expires_at,
f"{changed_rec.expires_at} / {base.expires_at}")
check("改插件复用台账指纹(无需再索取申请码)", changed_rec.sources == base.sources)
check("改插件后台账里的白名单已更新", changed_rec.plugins == cvals["plugins"],
str(changed_rec.plugins))
check("旧记录留下变更批注",
f"已变更插件 → {changed_rec.lic_id}" in reg.get(base.lic_id).note,
reg.get(base.lic_id).note)
# 载荷级别的证据:只有白名单该变,其余字段逐字未变
# (编号/签发时间天然会变;备注是"仅展示"字段,沿用台账批注 —— 与续期一致)
old_payload = dict(base.license_doc()["payload"])
new_payload = dict(ndoc["payload"])
check("改插件后载荷里的白名单就是新勾选",
new_payload.get("plugins") == cvals["plugins"], str(new_payload.get("plugins")))
check("改插件沿用台账备注(人工批注不随重签丢失)",
new_payload.get("note") == base.note, f"{new_payload.get('note')!r} / {base.note!r}")
for key in ("lic_id", "issued_at", "note", "plugins"):
old_payload.pop(key, None)
new_payload.pop(key, None)
check("改插件只改白名单:载荷其余字段逐字不变(含 not_before/expires_at/sources)",
old_payload == new_payload,
str([k for k in old_payload if old_payload[k] != new_payload.get(k)]))
plugins_col = {
rt.table.item(r, 0).text(): rt.table.item(r, 3).text()
for r in range(rt.table.rowCount())
}
check("改插件后台账「授权插件」列已更新",
plugins_col.get(changed_rec.lic_id) == ", ".join(cvals["plugins"]),
str(plugins_col))
vstatus = read_license(tmp / "plugins.lic", w.signing_key.public_b64, sources=it._sources)
check("改插件后的文件可独立验签且白名单已换",
vstatus.ok and vstatus.licensed_plugins == set(cvals["plugins"]),
f"ok={vstatus.ok} plugins={sorted(vstatus.licensed_plugins)}")
# 查看详情:构造时曾漏导入 QHBoxLayout,点了必崩(NameError)—— 这里守住回归
detail = rt._build_detail_dialog(base)
check("查看详情可正常构造", detail.windowTitle() == f"授权详情 {base.lic_id}",
detail.windowTitle())
detail.deleteLater()
# 收尾:删掉本节新增的那条,让本节结束时的台账状态与插入之前一致(第 10 节按 1 条断言)
reg.delete(changed_rec.lic_id)
rt.refresh()
# ---- 10) PostgreSQL 后端(可选功能;本机无服务器,故只验证可验证的部分)----
from chongming_license_gui import pg_store
from chongming_license_gui.pg_store import PgConfig, PostgresRegistry, driver_available
check("PG 默认端口是 5432", pg_store.DEFAULT_PORT == 5432, str(pg_store.DEFAULT_PORT))
check("PG 连接展示串不含密码",
"S3cr3t-PW" not in PgConfig(host="h", user="u", password="S3cr3t-PW").display())
check("PG 拒绝非法 schema(防标识符注入)", _rejects(lambda: pg_store._quote_identifier("a;DROP")))
# SQL 形状:必须与库的 SQLite 版逐列一致,否则两个后端会漂移
check("PG 的 INSERT 是 15 列且占位符数量匹配",
pg_store.INSERT_SQL.count("%s") == len(pg_store.COLUMNS) == 15,
f"{pg_store.INSERT_SQL.count('%s')} 个占位符")
check("PG 列序与库一致",
list(pg_store.COLUMNS) == [
"lic_id", "machine_code", "customer", "note", "plugins_json",
"sources_json", "not_before", "expires_at", "issued_at", "kid",
"public_key_fp", "license_json", "source", "created_at", "updated_at",
])
check("PG 冲突时更新 13 列且**不含** created_at(保留首次入库时间)",
len(pg_store.UPDATE_COLUMNS) == 13
and "created_at" not in pg_store.UPDATE_COLUMNS
and "lic_id" not in pg_store.UPDATE_COLUMNS)
# 入库:走幂等 upsert,且参数与库的派生规则一致
fake = _FakePgConn()
pg_reg = _pg_registry(fake)
pg_reg.save(doc, public_key_fp="PG-FP", source="pg-smoke")
insert_sql, params = fake.calls[-1]
check("PG 入库用 ON CONFLICT(lic_id) 幂等写入",
"INSERT INTO" in insert_sql and "ON CONFLICT (lic_id) DO UPDATE" in insert_sql)
check("PG 入库传满 15 个参数", len(params) == 15, str(len(params)))
check("PG 机器码大写、公钥指纹与来源已写入",
params[1] == doc["payload"]["machine_code"].upper()
and params[10] == "PG-FP" and params[12] == "pg-smoke")
check("PG 的 JSON 文本中文不转义(与库一致)",
"\\u" not in params[4] and params[4].startswith("["))
# 覆盖已有记录:保留人工批注、保留 created_at、只刷新 updated_at(库的硬语义)
from chongming_license import LicenseRecord
old = LicenseRecord(
lic_id=doc["payload"]["lic_id"],
machine_code=doc["payload"]["machine_code"],
note="人工批注(不该被覆盖)",
created_at="2026-01-01T00:00:00Z",
)
fake2 = _FakePgConn()
pg_reg2 = _pg_registry(fake2, old=old)
pg_reg2.save(doc, public_key_fp="PG-FP", source="renew")
_, params2 = fake2.calls[-1]
check("PG 覆盖记录时保留人工批注", params2[3] == "人工批注(不该被覆盖)", params2[3])
check("PG 覆盖记录时保留首次入库时间", params2[13] == "2026-01-01T00:00:00Z", params2[13])
check("PG 覆盖记录时刷新 updated_at", params2[14] != "2026-01-01T00:00:00Z", params2[14])
pg_reg2.save(doc, public_key_fp="PG-FP", source="renew", overwrite_note=True)
_, params3 = fake2.calls[-1]
check("PG 显式 overwrite_note=True 时才用载荷备注",
params3[3] == (doc["payload"].get("note") or ""), params3[3])
# 查询:方言差异必须被抹平(SQLite 的 LIKE 大小写不敏感 → PG 用 ILIKE)
fake3 = _FakePgConn()
pg_reg3 = _pg_registry(fake3)
pg_reg3.query(customer="Acme", machine_code="bc60", plugin="aibox", keyword="kw")
query_sql, query_args = fake3.calls[-1]
check("PG 四个过滤条件都用 ILIKE(对齐 SQLite 的大小写不敏感)",
query_sql.count("ILIKE") == 7, f"{query_sql.count('ILIKE')} 处")
check("PG 机器码条件参数已大写", query_args[1].strip("%") == "BC60", query_args[1])
check("PG 插件条件带引号(避免前缀误命中)", query_args[2] == '%"aibox"%', query_args[2])
check("PG 排序与库一致(永久排最后)",
"COALESCE(expires_at, '9999-12-31')" in query_sql)
check("PG 状态筛选不写进 SQL(留在 Python 侧,与库同一套优先级)",
"status" not in query_sql.lower())
# 界面:后端下拉存在、默认仍是本地 SQLite(本地路径的行为不能变)
check("台账页有「存储」后端下拉", hasattr(rt, "backend_combo"))
check("默认后端仍是本地 SQLite",
rt.backend_combo.currentData() == "sqlite", str(rt.backend_combo.currentData()))
check("下拉里有 PostgreSQL 选项",
any(rt.backend_combo.itemData(i) == "postgres"
for i in range(rt.backend_combo.count())))
# 切换后端:行要跟着切,holder 也必须知道目标后端
# (不知道的话"确保可用"会按旧后端开库 —— 用户切回本地库、签发却写进了 PG)
rt.backend_combo.setCurrentIndex(rt.backend_combo.findData("postgres"))
app.processEvents()
check("切到 PG 后只显示「连接」行",
rt._db_form.isRowVisible(rt.conn_row) and not rt._db_form.isRowVisible(rt.path_row))
check("切到 PG 后 holder 目标后端为 postgres", w.registry.backend == "postgres")
check("切到 PG 后原连接已断开(不留「界面显示 A、实际写 B」)",
not w.registry.is_open)
check("未连接时 ensure_open 给出可读提示,而不是静默连错库",
"未连接" in (_error_text(lambda: w.registry.ensure_open()) or ""),
(_error_text(lambda: w.registry.ensure_open()) or "").splitlines()[0])
rt.backend_combo.setCurrentIndex(rt.backend_combo.findData("sqlite"))
app.processEvents()
check("切回本地后只显示「路径」行",
rt._db_form.isRowVisible(rt.path_row) and not rt._db_form.isRowVisible(rt.conn_row))
check("切回本地后 holder 目标后端为 sqlite", w.registry.backend == "sqlite")
rt.db_edit.setText(str(db))
rt._open_db(silent=True)
check("切回本地后能重新打开本地库(原记录还在)",
w.registry.is_open and len(rt.records) == 1, str(len(rt.records)))
# 未安装驱动时要给出可照抄的安装命令(把 psycopg 置为 None 即为"不可导入")
import sys as _sys
saved_module = _sys.modules.get("psycopg")
_sys.modules["psycopg"] = None
try:
check("未装驱动时 driver_available() 返回 False", not driver_available())
missing = _error_text(lambda: PostgresRegistry(PgConfig(host="h", dbname="d")))
check("未装驱动时给出可照抄的安装提示",
"uv sync --extra postgres" in missing, missing.splitlines()[0])
finally:
if saved_module is None:
_sys.modules.pop("psycopg", None)
else:
_sys.modules["psycopg"] = saved_module
# 连不上时给的是人话而不是原始堆栈。
# 用绝不可能存在的库名,确保任何情况下都不会在真实服务器上建表。
dead = PgConfig(host="127.0.0.1", port=5432,
dbname="chongming_selftest_no_such_db_9f3a", user="nobody",
password="S3cr3t-NEVER-PRINT")
failure = _error_text(lambda: PostgresRegistry(dead))
if failure is None:
print("[NOTE] 本机 5432 上有 PostgreSQL 在跑,跳过「连不上时的提示」检查")
else:
check("连不上时给出可读提示(含地址与排查方向)",
"无法连接 PostgreSQL" in failure and "5432" in failure,
failure.splitlines()[0])
check("连接失败的提示与异常里都不含密码", "S3cr3t-NEVER-PRINT" not in failure)
print("\n" + "=" * 56)
if failures:
print(f"❌ {len(failures)} 项未通过: {failures}")
return 1
print("✅ GUI 冒烟测试全部通过")
return 0
if __name__ == "__main__":
sys.exit(main())