16 KiB
登录页 DEBUG_MODE 门禁分析
结论
当前实现不存在 DEBUG_MODE(项目内唯一含 debug_mode 的命中只是一个测试函数名)。登录页始终创建并展示“演示模式”和“服务器设置”入口;AppConfig.demo_mode 默认又是 True,且 preferences.json 会覆盖环境配置。因此,仅对两个控件调用 hide() 不能满足目标:隐藏的 checkbox 仍可能保持 checked,普通登录仍会自动读取/写回残留 QSettings,控制器也会接受伪造或残留的 demo 状态。
建议把 DEBUG_MODE 设计成非用户偏好、不可由 QSettings 或 preferences.json 覆盖的单一运行时门禁,并在配置加载、LoginWindow 行为和 ApplicationController 三层同时收口:
DEBUG_MODE=True:显示且允许演示仓库切换和登录页服务器设置,保留现有调试行为。DEBUG_MODE=False:隐藏完整 UI 区块,强制 effective demo 为False,忽略残留服务器 QSettings,登录只能使用 composition root 提供的远程仓库;直接调用槽函数、设置隐藏 checkbox、发信号或构造 demo payload 也不能绕过。
demo_mode 只能表示 DEBUG 模式下的默认选择/当前选择,不能再充当“是否有权使用 demo”的授权位。
当前实现与风险点
1. 配置与持久化
| 位置 | 当前行为 | DEBUG_MODE=False 的风险 |
|---|---|---|
src/doctor_workstation/config.py:88-99 |
AppConfig.demo_mode 默认 True,没有 debug gate |
直接构造 AppConfig() 就默认允许 demo |
config.py:113-133 |
DOCTOR_DEMO_MODE 未设置时也按 True 加载,然后调用 _merge_preferences() |
生产未显式注入环境变量时默认 demo;即使环境设为 false,后续偏好仍可覆盖 |
config.py:135-153 |
preferences.json 中所有 dataclass 字段均会合并,包括 demo_mode、api_base_url、request_timeout、verify_ssl |
旧 debug profile 的 demo/server 值可覆盖本次受控配置 |
config.py:155-165 |
save_preferences() 用 asdict(self) 保存完整配置 |
demo 切换和服务器设置会持续残留在 JSON 中 |
config.py:167-176 |
with_updates() 可随时把 demo/服务器字段改回调试值 |
UI 隐藏后仍可从信号/直接调用修改 |
需要特别区分两套持久化:demo 当前不写 QSettings,它通过 config_changed -> ApplicationController._on_config_changed -> save_preferences() 写入 preferences.json;服务器地址、超时和证书校验先写 QSettings,随后同一信号链又写入 preferences.json。相关位置是 ui/login.py:1038-1054 和 app.py:460-491。
当前 Debug_DoctorWorkstation.bat:16-24 只是设置 DOCTOR_DEMO_MODE=true 和 DOCTOR_LOG_LEVEL=DEBUG,没有提供独立 debug capability。若新门禁来自环境,调试启动器应显式设置专用值(例如 DOCTOR_DEBUG_MODE=true);普通/冻结启动不得设置。若门禁是构建期常量,则无需让用户偏好或 .env 参与。无论采用哪种来源,都不要把它作为普通 AppConfig dataclass 字段写入 preferences.json。
2. LoginWindow 组件和信号
| 位置 | 组件/信号链 | 当前行为与缺口 |
|---|---|---|
ui/login.py:444-449 |
server_settings_changed(dict)、config_changed(object)、demo_mode_changed(bool) |
server_settings_changed 目前仅测试监听;另外两个信号由 controller 监听。所有发射点都无 debug gate |
login.py:451-468 |
构造参数、demo_repository、active_repository |
只要传入 demo repository 就保留可切换能力;controller 当前总会传入 |
login.py:739-755 |
“记住密码”行和 demo_check |
checkbox 始终加入布局;只有 repository 为空时 disabled,不会隐藏 |
login.py:770-855 |
“或”分隔线、server_toggle、server_panel 及 URL/timeout/self-signed/save 子控件 |
toggle 始终显示,panel 只是在初始时折叠。若只隐藏 toggle,“或”分隔线和固定 spacing 仍会残留 |
login.py:917-948 |
_restore_settings() |
始终从 QSettings 恢复三项 server 值;只看 config.demo_mode 就勾选 demo。setChecked(True) 会触发已连接的 _on_demo_toggled() |
login.py:956-969 |
_credential_scope() |
优先读取 server_url_edit;即便控件隐藏,残留 QSettings URL 仍可改变凭据读取/保存 scope |
login.py:1002-1013 |
demo_check.toggled -> _on_demo_toggled() |
切换 active_repository,发射 demo_mode_changed,再经 _emit_config_update 发射 config_changed;没有权限判断 |
login.py:1015-1027 |
server_toggle.clicked、save button |
方法可被直接调用,隐藏控件并不能阻止 panel 展开或保存 |
login.py:1029-1054 |
_apply_server_settings() |
会持久化 QSettings、发射两个配置相关信号;没有权限判断 |
login.py:1079-1126 |
submit() |
直接以隐藏 checkbox 的 checked 状态决定 demo;非 demo 登录会无条件自动应用服务器控件当前值,所以旧 QSettings 即便不展开 panel 也会生效 |
login.py:1140-1159 |
_set_loading() |
loading 结束会按 demo_repository is not None 重新 enable demo,并重新 enable server 子控件;需把 debug gate 合入 enable 条件 |
login.py:1177-1216 |
登录成功与凭据保存 | payload["demo_mode"] 决定是否保存密码;凭据 scope 又可能来自隐藏的 server edit |
login.py:1224-1234 |
证书错误 | 会直接勾选 toggle 并展开 panel;生产隐藏后仍可被错误路径重新显示 |
证书错误文案还在 ui/widgets.py:376-383 明确引导用户展开服务器设置、关闭证书校验。非 debug 模式必须改为不引用隐藏入口的运维提示,否则 UI 和文案契约矛盾。
3. ApplicationController 与登录可信边界
| 位置 | 当前行为 | 需要的防线 |
|---|---|---|
app.py:402-423 |
总是实例化 DemoDoctorRepository();current_demo_mode=config.demo_mode |
非 debug 不创建/不暴露 demo repository,并强制 current demo false |
app.py:438-455 |
总把 demo repository 传给 LoginWindow;复用窗口时信任 demo_check |
传递显式 gate;非 debug 复用时重置 checkbox/active repository |
app.py:460-506 |
接受 demo_mode 及全部 server 字段,保存 preferences 并重建 repository |
非 debug 拒绝 debug-only changes,避免伪造 config_changed 绕过 UI |
app.py:508-511 |
任意 demo_mode_changed(True) 都会设置 current demo 并取消 session restore |
非 debug 忽略/纠正 true |
app.py:530-540 |
config.demo_mode=True 会跳过生产 token restore |
必须基于经过门禁归一化的 effective demo;残留 preference 不能阻止 restore |
app.py:653-681 |
信任成功 payload 中的 demo_mode 与 repository |
非 debug 必须拒绝 demo payload/repository,或无条件把 effective demo 归零;这是 UI 之外的最后可信边界 |
app.py:782-792 |
current_demo_mode 决定是否打开离线 demo 视频窗 |
前述边界不收口时,伪造状态还会扩散到登录后的功能 |
精确修改建议
A. 建立单一、不可持久化的 capability
在 src/doctor_workstation/config.py 定义唯一 DEBUG_MODE(或等价只读函数),由受控构建/专用调试启动器决定。不要从 QSettings 读取,不要让 preferences.json 覆盖,也不要随 asdict(AppConfig) 保存。
配置加载完成后必须做一次最终归一化:effective_demo_mode = DEBUG_MODE and requested_demo_mode。在 DEBUG_MODE=False 时,_merge_preferences() 至少忽略 demo_mode;若“服务器设置不可用”意味着生产连接完全由受控环境提供,还应同时忽略偏好中的 api_base_url、request_timeout、verify_ssl,否则旧登录页设置虽然 UI 不可见,仍会从 JSON 生效。with_updates() 也应拒绝或丢弃非 debug 下对这些 debug-only 字段的修改。
推荐把 debug capability 显式传给 ApplicationController/LoginWindow 或保存为只读实例属性,便于测试 True/False 两条路径。不要在多个模块各自复制一个可 monkeypatch 的常量,否则测试或运行时可能出现 config 判 false、UI 判 true 的分裂状态。
B. LoginWindow:可见性和行为同时门禁
在 ui/login.py:451-468 记录 self.debug_mode,并把 self.demo_repository 设为 demo_repository if debug_mode else None。建议仍构造具名控件以保持测试和代码引用稳定,但所有状态转换都使用 self.debug_mode 判断。
UI 结构建议:
demo_check仅在 debug 时 visible,并且 enabled 条件为debug_mode and demo_repository is not None and not loading。- 把
login.py:770-855的“或”分隔线、server toggle、panel 和上下 spacing 包进一个self.debug_server_sectionQWidget;整个 section 仅在 debug 时 visible。单独隐藏server_toggle会留下“或”和空白。 - panel 初始仍折叠;debug true 时保持现有 toggle 行为。
行为防线建议:
_restore_settings():非 debug 不读取server/*QSettings,不恢复 demo,明确令 demo unchecked、active repository 为 production repository;服务器控件若仍构造,只从受控config填充。是否删除旧键是迁移策略,忽略它们才是安全要求。_credential_scope():非 debug 始终从受控 config URL 取 scope,不读取隐藏的server_url_edit。_on_demo_toggled(True):非 debug 立即用 signal blocker 恢复 unchecked/production repository,然后 return;不得发demo_mode_changed或config_changed。_toggle_server_panel()、_save_server_settings()、_apply_server_settings():非 debug 强制 panel 关闭且不写 QSettings、不发server_settings_changed/config_changed。直接调用也必须无效。submit():用demo_mode = self.debug_mode and self.demo_check.isChecked(),并从这个 effective 值选择 repository。非 debug 跳过_apply_server_settings(),只使用 composition root 已构造的 remote repository;否则会再次应用隐藏控件中的旧值。_set_loading():所有 demo/server enabled 状态与self.debug_mode做 AND,防止 loading 完成后重新激活。_on_login_error():仅 debug 时自动展开 certificate panel;非 debug 保持 section 隐藏,并显示“请联系管理员检查受控服务器/证书配置”之类不提供绕过证书校验的文案。
C. Controller:不要信任 UI 状态或 payload
在 app.py:402-423 以同一 capability 计算 effective state;非 debug 最好根本不实例化 DemoDoctorRepository。_show_login() 显式传 gate,窗口复用时不要读取隐藏 checkbox 决定 repository。
_on_config_changed() 必须再次过滤 demo/server debug-only 字段;_on_demo_mode_changed() 非 debug 不接受 true;_begin_session_restore() 不得因未经门禁的旧 config.demo_mode 跳过;_on_login_succeeded() 应把 demo capability 作为可信边界,非 debug 收到 demo_mode=True 或 demo repository 时拒绝进入 shell并清理 session,而不是静默接受 payload。这样即使未来有其他代码直接调用槽函数,也不能重新开启演示路径。
tests/test_ui_contract.py 现状与调整
实际文件是 app/tests/test_ui_contract.py,app/tests 下没有 conftest.py;这里使用的 tmp_path/monkeypatch 是 pytest 内置 fixture,相关 helper 都定义在测试函数内。
现有相关契约:
test_ui_contract.py:230-273:真实 demo 登录,证明config.demo_mode=True会勾选 checkbox、使用空账号密码登录 demo,并发出 demo payload;未覆盖 debug capability。test_ui_contract.py:276-337:同一 QSettings 跨窗口恢复账号/密码;不涉及 demo。test_ui_contract.py:340-375:服务器 panel 在最小窗口的布局。test_ui_contract.py:378-399:函数名虽含debug_mode,实际仅验证 self-signed 值写入 QSettings,没有任何DEBUG_MODE判断。test_ui_contract.py:402-455:普通登录前自动应用 server 值、经config_changed换成新 repository。test_ui_contract.py:458-463:证书错误文案指向服务器设置。test_ui_contract.py:495-513:证书错误会自动展开服务器 panel;这个契约只应在 debug true 成立。
引入 gate 后,230、340、378、402、495 这几组依赖 demo/server 的测试都应显式运行在 DEBUG_MODE=True,避免它们因测试默认值偶然通过。不要新增“把 demo 写入 QSettings”的契约;当前 demo 的持久化源是 AppConfig/preferences,目标反而要求非 debug 忽略该残留值。
建议回归测试矩阵
tests/test_ui_contract.py
- debug true 可见且可用:show 窗口后断言 demo checkbox、完整 server section/toggle 可见;原 demo 登录、panel 几何、自签名保存、登录前应用服务器设置均继续通过。
- debug false 无视觉残件:断言 demo checkbox、
debug_server_section(包括“或”分隔线)、toggle、panel 都不可见;demo unchecked,active_repository is remote_repository。 - 残留 QSettings 不生效:预写
server/base_url=旧地址、server/read_timeout、server/verify_ssl=false,用 debug false 构造窗口;断言 credential scope/实际登录 repository 使用受控 config,QSettings 值未被_apply_server_settings()写回或发射成配置更新。 - 直接调用不能绕过:debug false 下程序化
demo_check.setChecked(True)、_on_demo_toggled(True)、_toggle_server_panel(True)、_save_server_settings();断言仍 unchecked、production repository、panel hidden,demo_mode_changed、server_settings_changed、config_changed均无 debug 更新。 - 提交强制 production:给 debug false 窗口同时传 remote 和 demo repository,并让 stale config 的
demo_mode=True;输入账号密码后立即执行 worker,断言只有 remotelogin()被调用,payloaddemo_mode=False。 - 证书错误分模式:debug true 仍自动展开并给出 self-signed 指引;debug false 不展开/不显示 section,错误文案不再提隐藏的“服务器设置”或关闭证书校验。
- loading 不重启入口:debug false 执行
_set_loading(True)再_set_loading(False),断言 demo/server 控件持续 hidden + disabled。
tests/test_config.py
- 在隔离
DOCTOR_CONFIG_DIR写入旧preferences.json(至少demo_mode:true);DEBUG false 加载后必须demo_mode is False,即使DOCTOR_DEMO_MODE=true也不能越权。 - DEBUG true 时确认
DOCTOR_DEMO_MODE/允许的 demo preference 仍能选择默认 demo 状态。 - 若生产服务器配置要求环境权威,再写入旧 JSON server 字段,断言 DEBUG false 仍采用环境的 URL/timeout/verify_ssl。
with_updates(demo_mode=True)在 DEBUG false 下不能产生 effective demo true;同理覆盖 server debug-only 更新的策略。
tests/test_ui_contract.py 中的 controller 边界(或拆到 controller 专属测试)
- DEBUG false 时
_begin_session_restore()不因 staleconfig.demo_mode=True而跳过远程恢复。 - DEBUG false 时直接调用
_on_demo_mode_changed(True)不改变current_demo_mode。 - DEBUG false 时把
demo_mode=True/demo repository 的伪造 payload 传给_on_login_succeeded(),断言不能创建 ShellWindow。
若采用专用 DOCTOR_DEBUG_MODE 环境变量,还应在 tests/test_one_click_entrypoints.py:102-108 增加调试启动器显式开启、普通启动器/打包入口不开启的静态契约,并同步 .env.example:8-17 与 README.md:56-72,避免继续把 DOCTOR_DEMO_MODE=true 描述成足以启用演示能力。
最小验收标准
非 debug 模式应同时满足以下可观察结果:登录页看不到 demo、服务器入口、“或”分隔线或相关空白;旧 demo preference 不能阻止远程 token restore;旧 server QSettings 不能改变 URL、timeout、TLS 校验或凭据 scope;程序化调用隐藏控件/槽函数/信号也不能切换仓库或进入 demo shell。只有这四层都成立,才不是单纯的视觉隐藏。