Files
ny-exam-platform/tests/test-config-persist.php
T
nxxy335 f661862d46 fix(auth): 修复登录后反复报未配置账号并跳回登录页的死循环
根因:ny_save_config 只写不校验,Db::settingsSet 内部吞异常且返回 void。
data/ 目录不可写时登录页照常显示登录成功,但下一请求读到的账号仍是空的
→ 抛未配置卷王登录账号 → 前端 auth 打标 → 跳登录页 → 无限循环。

修复:
- Db::settingsSet 改返回 bool 并回读校验;新增 settingsDelete
- ny_save_config 双写后均做回读校验,失败写入 ny_config_save_error()
- 新增 ny_config_diag()/ny_session_usable(),暴露来源/可写性/是否已存账号
- ny_load_config 把 null 账号强制成空字符串(脏数据不再伪装成未配置)
- login.php 存凭据后回读确认,存不住/会话不可写直接报错而不是假成功
- login.php 账号为空时页面直接给出 data 目录写权限提示
- config_save 合并逻辑抽成 ny_apply_config_input:账号/密码不许被改空
- 鉴权失败响应带 extra.diag,前端 401 也算 auth
- 前端防死循环:刚登录 120s 内或已跳 2 次则停止自动跳转,弹窗给出真实原因+诊断

测试:新增 tests/test-config-persist.php(29) 与 tools/test-login-loop.js(19);
run-all.sh 纳入两者。全套 148+48=196 项通过。
2026-09-14 08:44:09 +08:00

126 lines
5.8 KiB
PHP

<?php
/**
* 配置落盘 / 鉴权死循环 回归测试
*
* 背景:登录页"登录成功"后立刻又报"未配置卷王登录账号"并反复跳回登录页。
* 根因是 ny_save_config 只写不校验(Db::settingsSet 内部吞异常且返回 void),
* data/ 目录不可写时上层以为保存成功,下一个请求读到的账号仍是空的。
*
* 覆盖:
* 1) ny_save_config 必须回读校验并真实反映落盘结果
* 2) ny_load_config 把 null 账号强制成空字符串(历史脏数据不再伪装成"未配置")
* 3) ny_apply_config_input:账号/密码不许被设置页误清空,连接信息才触发重连
* 4) ny_config_diag 能给出可自查的诊断字段
*/
declare(strict_types=1);
// 必须赶在 bootstrap 之前把数据目录重定向到临时目录,避免污染生产 data/ny.db
$tmpDir = sys_get_temp_dir() . '/ny-cfgtest-' . getmypid() . '-' . bin2hex(random_bytes(3));
putenv('NY_DATA=' . $tmpDir);
$_ENV['NY_DATA'] = $tmpDir;
require __DIR__ . '/../bootstrap.php';
register_shutdown_function(static function () use ($tmpDir): void {
// 清理临时数据目录(Windows 下先删文件再删目录)
if (!is_dir($tmpDir)) {
return;
}
foreach (glob($tmpDir . '/*') ?: [] as $f) {
@unlink($f);
}
@rmdir($tmpDir);
});
$pass = 0;
$fail = 0;
function check(string $name, bool $cond, string $detail = ''): void
{
global $pass, $fail;
if ($cond) { $pass++; echo " ✅ {$name}\n"; }
else { $fail++; echo " ❌ {$name} {$detail}\n"; }
}
echo "\n== 1) 保存后回读(模拟登录页写凭据) ==\n";
$cfg0 = ny_default_config();
$cfg0['username'] = 'teacher01';
$cfg0['password'] = 'P@ss w0rd';
$saved = ny_save_config($cfg0);
check('ny_save_config 返回 true', $saved === true, 'save_error=' . ny_config_save_error());
$back = ny_load_config();
check('回读账号一致', ($back['username'] ?? '') === 'teacher01', json_encode($back['username'] ?? null));
check('回读密码一致(含空格原样保留)', ($back['password'] ?? '') === 'P@ss w0rd', json_encode($back['password'] ?? null));
check('配置来源为 db 或 file', in_array(ny_config_source(), ['db', 'file'], true), ny_config_source());
check('无保存错误提示', ny_config_save_error() === '', ny_config_save_error());
echo "\n== 2) 脏数据兜底:账号被存成 null ==\n";
$dirty = $cfg0;
$dirty['username'] = null;
ny_write_json(ny_config_file(), $dirty);
// 强制走文件分支读取:把 DB 里的记录清掉
if (class_exists('Db') && Db::pdo() !== null) {
Db::settingsDelete('config');
}
$dirtyCfg = ny_load_config();
check('null 账号 → 空字符串而不是 null', $dirtyCfg['username'] === '', var_export($dirtyCfg['username'], true));
check('账号为字符串类型', is_string($dirtyCfg['username']));
echo "\n== 3) 设置保存:账号/密码不被误清空 ==\n";
$old = ny_default_config();
$old['username'] = 'admin';
$old['password'] = 'secret';
$r = ny_apply_config_input($old, ['username' => '']);
check('账号传空 → 保留原账号(防止陷入未配置死循环)', $r['config']['username'] === 'admin', $r['config']['username']);
check('账号传空 → 不算连接信息变化', $r['conn_changed'] === false);
$r = ny_apply_config_input($old, ['username' => null]);
check('账号传 null → 保留原账号', $r['config']['username'] === 'admin', var_export($r['config']['username'], true));
$r = ny_apply_config_input($old, ['password' => '******']);
check('密码占位符 ****** → 不修改', $r['config']['password'] === 'secret');
$r = ny_apply_config_input($old, ['password' => '']);
check('密码传空 → 不修改', $r['config']['password'] === 'secret');
$r = ny_apply_config_input($old, ['password' => 'newpwd']);
check('密码传新值 → 更新', $r['config']['password'] === 'newpwd');
check('密码变化 → 标记连接信息已变', $r['conn_changed'] === true);
$r = ny_apply_config_input($old, ['username' => 'admin2']);
check('账号变化 → 标记连接信息已变', $r['conn_changed'] === true);
$r = ny_apply_config_input($old, ['full_score' => 90, 'page_size' => 50]);
check('仅改满分/分页 → 不触发重连', $r['conn_changed'] === false);
check('满分已写入', (int)$r['config']['full_score'] === 90);
check('未传的字段保持原值', (int)$r['config']['page_size'] === 50 && $r['config']['username'] === 'admin');
$r = ny_apply_config_input($old, ['refresh_interval' => 1]);
check('刷新间隔最小值收敛到 3', (int)$r['config']['refresh_interval'] === 3);
$r = ny_apply_config_input($old, ['base_url' => 'https://exam.nxxy335.top/']);
check('base_url 去掉末尾斜杠', $r['config']['base_url'] === 'https://exam.nxxy335.top', $r['config']['base_url']);
echo "\n== 4) 落盘诊断 ==\n";
$d = ny_config_diag();
check('诊断含 storage', isset($d['storage']));
check('诊断含目录可写标记', array_key_exists('dir_writable', $d));
check('诊断含配置文件可写标记', array_key_exists('cfg_writable', $d));
check('诊断含数据库可写标记', array_key_exists('db_writable', $d));
check('诊断含是否已存账号', array_key_exists('has_username', $d));
check('临时目录可写时 cfg_writable=true', $d['cfg_writable'] === true, json_encode($d, JSON_UNESCAPED_UNICODE));
echo "\n== 5) 数据库写入回读校验 ==\n";
if (class_exists('Db') && Db::pdo() !== null) {
check('Db::settingsSet 成功返回 true', Db::settingsSet('__ny_test_key', 'hello') === true);
check('Db::settingsGet 读回一致', Db::settingsGet('__ny_test_key', '') === 'hello');
Db::settingsDelete('__ny_test_key');
check('Db::settingsDelete 生效', Db::settingsGet('__ny_test_key', 'x') === 'x');
} else {
echo " ⚠️ 无 pdo_sqlite,跳过数据库回读用例\n";
}
echo "\n----------------------------------------\n";
echo "通过 {$pass} 项,失败 {$fail} 项\n";
exit($fail === 0 ? 0 : 1);