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 项通过。
This commit is contained in:
+4
-2
@@ -18,7 +18,7 @@ echo "PHP = $PHP"
|
||||
echo "NODE = $NODE"
|
||||
|
||||
echo "================ PHP 自测 ================"
|
||||
for f in tests/test-aggregate.php tests/test-new-features.php tools/test-xlsxwriter.php; do
|
||||
for f in tests/test-aggregate.php tests/test-new-features.php tests/test-config-persist.php tools/test-xlsxwriter.php; do
|
||||
echo ""
|
||||
echo ">>> $f"
|
||||
"$PHP" $INI "$f"
|
||||
@@ -30,7 +30,9 @@ echo "================ Node e2e(需 mock-server + PHP 内置服务器) =====
|
||||
# 彻底避免污染生产 data/ny.db(曾因此把登录页连接信息写成 127.0.0.1)。
|
||||
NY_DATA_TMP="$(pwd)/.ny-test-data"
|
||||
mkdir -p "$NY_DATA_TMP"
|
||||
for f in tools/test-config-save.js tools/test-upload-guard.js tools/test-whitelist.js; do
|
||||
# 说明:test-login-loop.js 会清空数据目录里的账密来复现"未配置账号"现场,
|
||||
# 即使不传 NY_DATA 它也会自己建临时目录,绝不回退到项目 data/。
|
||||
for f in tools/test-config-save.js tools/test-upload-guard.js tools/test-whitelist.js tools/test-login-loop.js; do
|
||||
echo ""
|
||||
echo ">>> $f (NY_DATA=$NY_DATA_TMP)"
|
||||
NY_DATA="$NY_DATA_TMP" "$NODE" "$f"
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
<?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);
|
||||
Reference in New Issue
Block a user