Files
zyt/server/tests/AdminBcrSessionTest.php
2026-11-12 14:27:49 +08:00

341 lines
22 KiB
PHP

<?php
declare(strict_types=1);
// Real service, token cache, middleware, ORM and account mutation classes; synthetic SQLite only.
// Optional argv[1] supplies an unchanged server directory for dependencies when testing a staged patch.
// No App::initialize(), .env, application config, network or filesystem-backed cache is used.
namespace {
final class BcrClock { public static int $now = 1900000000; }
}
namespace app\adminapi\service { function time(): int { return \BcrClock::$now; } }
namespace app\common\cache {
function time(): int { return \BcrClock::$now; }
abstract class BaseCache {
public static array $entries = [];
public static bool $keepDeleted = false;
public function __construct() {}
public function get($key) {
$entry = self::$entries[$key] ?? null;
return $entry && ($entry['until'] === null || $entry['until'] > \BcrClock::$now) ? $entry['value'] : null;
}
public function set($key, $value, $ttl = null): bool {
self::$entries[$key] = ['value' => $value, 'tag' => static::class,
'until' => $ttl instanceof \DateTimeInterface ? $ttl->getTimestamp() : ($ttl ? \BcrClock::$now + $ttl : null)];
return true;
}
public function delete($key): bool {
if (self::$keepDeleted) { return false; }
unset(self::$entries[$key]); return true;
}
public function deleteTag(): bool {
foreach (self::$entries as $key => $entry) {
if ($entry['tag'] === static::class) { unset(self::$entries[$key]); }
}
return true;
}
}
}
namespace app\adminapi\http\middleware { function time(): int { return \BcrClock::$now; } }
namespace app\adminapi\logic { function time(): int { return \BcrClock::$now; } }
namespace app\adminapi\logic\auth { function time(): int { return \BcrClock::$now; } }
namespace app\common\service {
// These unrelated collaborators must never invoke a provider or read runtime configuration.
final class FileService {
public static function getFileUrl(string $uri = '', string $type = ''): string { return $uri; }
public static function setFileUrl($uri) { return $uri; }
}
final class TencentImService { public function importAccount($id, $name): bool { return true; } }
}
namespace {
$server = dirname(__DIR__);
$dependencies = isset($argv[1]) ? realpath($argv[1]) : $server;
if (!$dependencies || !is_file($dependencies . '/vendor/autoload.php')) {
throw new RuntimeException('A local server vendor directory is required');
}
$loader = require $dependencies . '/vendor/autoload.php';
$loader->addPsr4('app\\', $server . '/app', true);
require $dependencies . '/vendor/topthink/framework/src/helper.php';
use app\adminapi\service\AdminTokenService as Tokens;
use app\common\cache\AdminTokenCache as TokenCache;
use app\common\cache\AdminAuthCache;
use app\common\cache\BaseCache;
use app\common\enum\AdminTerminalEnum as Terminal;
use app\adminapi\http\middleware\LoginMiddleware;
use app\adminapi\http\middleware\AuthMiddleware;
use app\adminapi\logic\LoginLogic;
use app\adminapi\logic\auth\AdminLogic;
use app\adminapi\logic\tcm\MobileRecordingLogic;
use think\facade\Db;
function create_token(string $extra = ''): string { return bin2hex(random_bytes(16)); }
function create_password(string $plaintext, string $salt): string { return md5($salt . md5($plaintext . $salt)); }
function expectBcr(bool $ok, string $label): void {
if (!$ok) { throw new RuntimeException($label); }
$GLOBALS['bcrChecks']++;
}
function deniedBcr(callable $operation, string $label): void {
try { $operation(); } catch (DomainException $error) { expectBcr(true, $label); return; }
throw new RuntimeException('Expected denial: ' . $label);
}
$GLOBALS['bcrChecks'] = 0;
if (!in_array('sqlite', PDO::getAvailableDrivers(), true)) {
throw new RuntimeException('In-memory SQLite required; no external database fallback');
}
// A nonexistent root also prevents the constructor from including app/provider.php.
$app = new think\App(sys_get_temp_dir() . '/bcr-session-no-app-' . bin2hex(random_bytes(8)));
$app->instance('log', new Psr\Log\NullLogger());
$manager = new think\DbManager();
$manager->setConfig(['default' => 'sqlite', 'auto_timestamp' => false, 'datetime_format' => false,
'connections' => ['sqlite' => ['type' => 'sqlite', 'database' => ':memory:', 'prefix' => 'zyt_']]]);
$app->instance('think\DbManager', $manager);
$config = new think\Config();
$config->set(['admin_token' => ['expire_duration' => 28800, 'be_expire_duration' => 3600,
'bcr_expire_duration' => 15552000, 'bcr_be_expire_duration' => 12960000],
'unique_identification' => 'synthetic-bcr-salt', 'default_image' => ['admin_avatar' => ''],
'data_scope' => ['enabled' => false]], 'project');
$config->set(['default' => 'test', 'channels' => ['test' => ['type' => 'File', 'close' => true]]], 'log');
$app->instance('config', $config);
foreach (['WORK_WECHAT_FORCE_BIND_LOGIN', 'work_wechat.force_bind_login',
'work_wechat.work_wechat_force_bind_login', 'work_wechat.corp_id', 'work_wechat.secret', 'work_wechat.agent_id',
'wecom_rpa.server_ips'] as $key) { $app->env->set($key, ''); }
foreach ([
'CREATE TABLE zyt_admin (id INTEGER PRIMARY KEY, account TEXT, name TEXT, root INT, disable INT, delete_time INT,
password TEXT, is_paw INT, multipoint_login INT, work_wechat_userid TEXT, avatar TEXT, update_time INT,
gender INT, age INT, phone TEXT, title TEXT, department TEXT, specialty TEXT, education TEXT, experience TEXT,
honors TEXT, license_no TEXT, qualification_images TEXT, enable_image_consult INT, enable_video_consult INT,
enable_charge INT, login_time INT, login_ip TEXT)',
'CREATE TABLE zyt_admin_session (id INTEGER PRIMARY KEY AUTOINCREMENT, admin_id INT, terminal INT, token TEXT,
expire_time INT, create_time INT, update_time INT, UNIQUE(admin_id, terminal))',
'CREATE TABLE zyt_admin_role (admin_id INT, role_id INT)',
'CREATE TABLE zyt_admin_dept (admin_id INT, dept_id INT)',
'CREATE TABLE zyt_admin_jobs (admin_id INT, jobs_id INT)',
'CREATE TABLE zyt_system_role (id INTEGER PRIMARY KEY, name TEXT, data_scope INT, delete_time INT)',
'CREATE TABLE zyt_system_menu (id INTEGER PRIMARY KEY, perms TEXT, is_disable INT, delete_time INT)',
'CREATE TABLE zyt_system_role_menu (role_id INT, menu_id INT)',
'CREATE TABLE zyt_tcm_diagnosis (id INTEGER PRIMARY KEY, patient_id INT, patient_name TEXT, phone TEXT,
assistant_id INT, status INT, delete_time INT)',
'CREATE TABLE zyt_doctor_appointment (id INTEGER PRIMARY KEY, patient_id INT, doctor_id INT, status INT)',
] as $ddl) { Db::execute($ddl); }
foreach ([11, 12, 13] as $id) {
Db::name('admin')->insert(['id' => $id, 'account' => 'synthetic_' . $id, 'name' => 'Synthetic actor',
'root' => 0, 'disable' => 0, 'delete_time' => null, 'password' => create_password('old-pass', 'synthetic-bcr-salt'),
'is_paw' => 1, 'multipoint_login' => 1, 'work_wechat_userid' => '', 'avatar' => '']);
Db::name('admin_role')->insert(['admin_id' => $id, 'role_id' => 2]);
}
Db::name('system_role')->insert(['id' => 2, 'name' => 'Synthetic assistant', 'data_scope' => 4]);
Db::name('system_menu')->insertAll([
['id' => 1, 'perms' => 'firstvisit.myPatient/lists', 'is_disable' => 0],
['id' => 2, 'perms' => 'tcm.diagnosis/lists', 'is_disable' => 0],
]);
Db::name('system_role_menu')->insert(['role_id' => 2, 'menu_id' => 1]);
Db::name('tcm_diagnosis')->insertAll([
['id' => 101, 'patient_id' => 501, 'patient_name' => 'Synthetic A', 'phone' => '', 'assistant_id' => 11, 'status' => 1, 'delete_time' => null],
['id' => 102, 'patient_id' => 502, 'patient_name' => 'Synthetic B', 'phone' => '', 'assistant_id' => 12, 'status' => 1, 'delete_time' => null],
]);
$setRequest = static function (string $token = '', string $ip = '198.51.100.10', string $controller = 'tcm.mobileRecording', string $action = 'deviceLists') use ($app) {
$request = (new think\Request())->withServer(['REMOTE_ADDR' => $ip])->withHeader(['token' => $token]);
$request->setController($controller); $request->setAction($action);
$request->controllerObject = new class { public function isNotNeedLogin(): bool { return false; } };
$app->instance('request', $request); return $request;
};
$setRequest();
$cache = new TokenCache();
$session = static fn(string $token) => Db::name('admin_session')->where('token', $token)->find();
$login = static function (string $token, string $ip = '198.51.100.10') use ($setRequest) {
$request = $setRequest($token, $ip);
$result = (new LoginMiddleware())->handle($request, static fn($request) => ['code' => 1, 'admin' => $request->adminInfo]);
return is_array($result) ? $result : $result->getData();
};
$authorize = static function (array $info, string $ip, string $controller = 'tcm.mobileRecording', string $action = 'deviceLists') use ($setRequest) {
$request = $setRequest($info['token'] ?? '', $ip, $controller, $action);
$request->adminInfo = $info;
$result = (new AuthMiddleware())->handle($request, static fn() => ['code' => 1]);
return is_array($result) ? $result : $result->getData();
};
$invalidateCache = static function () use ($cache) { $cache->deleteTag(); };
$fresh = static function (int $id = 11, int $terminal = Terminal::BCR, int $multi = 0) use ($setRequest): array {
$setRequest(); return Tokens::setToken($id, $terminal, $multi);
};
$base = BcrClock::$now;
expectBcr(Terminal::BCR === 20 && Terminal::MOBILE === 2 && Terminal::wecomRpaTerminals() === [7, 9], 'dedicated terminal allocation');
$ssoTerminals = (new ReflectionClass(app\mcp\service\SsoService::class))->getReflectionConstant('SESSION_TERMINALS')->getValue();
expectBcr(!in_array(Terminal::BCR, $ssoTerminals, true), 'long BCR token does not acquire normal admin SSO authority');
$validator = new app\adminapi\validate\LoginValidate();
expectBcr($validator->only(['terminal'])->check(['terminal' => 20]), 'password login accepts BCR terminal');
$validator = new app\adminapi\validate\LoginValidate();
$validator->setLang($app->lang);
expectBcr(!$validator->only(['terminal'])->check(['terminal' => 99]), 'unknown terminal rejected');
$legacy = [];
foreach ([1, 2, 7, 8, 9] as $terminal) {
$info = $fresh(11, $terminal);
$legacy[$terminal] = $info;
expectBcr($info['expire_time'] === $base + 28800 && Tokens::renewalWindow($terminal) === 3600, 'legacy duration ' . $terminal);
}
$bcr = $fresh();
expectBcr($bcr['expire_time'] === $base + 180 * 86400, 'BCR is valid for 180 days');
expectBcr($session($legacy[1]['token'])['expire_time'] === $base + 28800, 'BCR login leaves PC session unchanged');
BcrClock::$now = $base + 29 * 86400;
expectBcr($login($bcr['token'])['code'] === 1 && $session($bcr['token'])['expire_time'] === $base + 180 * 86400, 'day 29 does not renew');
BcrClock::$now = $base + 30 * 86400;
expectBcr($login($bcr['token'])['code'] === 1 && $session($bcr['token'])['expire_time'] === $base + 180 * 86400, 'exact 150 day remainder does not renew');
BcrClock::$now++;
$renewed = $login($bcr['token']);
expectBcr($renewed['code'] === 1 && $session($bcr['token'])['expire_time'] === BcrClock::$now + 180 * 86400, 'after 30 days renews for 180 days');
expectBcr($renewed['admin']['expire_time'] === BcrClock::$now + 180 * 86400, 'middleware uses renewed adminInfo');
$invalidateCache();
expectBcr($login($bcr['token'])['code'] === 1, 'cache loss reconstructs a valid long session');
$expired = $fresh();
Db::name('admin_session')->where('token', $expired['token'])->update(['expire_time' => BcrClock::$now]);
BaseCache::$keepDeleted = true;
expectBcr($login($expired['token'])['code'] === -1, 'warm cache cannot hide exact expiry');
expectBcr(Tokens::overtimeToken($expired['token']) === false && $session($expired['token'])['expire_time'] === BcrClock::$now, 'expired token cannot renew');
BaseCache::$keepDeleted = false;
$invalidateCache();
expectBcr(!$cache->getAdminInfo($expired['token']), 'cold cache also rejects expiry');
$exactBoundaryLogin = $fresh(11, Terminal::BCR, 1);
expectBcr($exactBoundaryLogin['token'] !== $expired['token'] && !$cache->getAdminInfo($expired['token']), 'multipoint login at exact expiry rotates token instead of reviving it');
$legacyMobile = $fresh(11, 2);
Db::name('admin_session')->where('token', $legacyMobile['token'])->update(['expire_time' => BcrClock::$now + 100]);
$invalidateCache();
$shortRenewal = $login($legacyMobile['token']);
expectBcr($shortRenewal['admin']['terminal'] === 2 && $session($legacyMobile['token'])['expire_time'] === BcrClock::$now + 28800, 'terminal 2 never upgrades to BCR lifetime');
foreach (['disable', 'delete_time'] as $state) {
$info = $fresh();
Db::name('admin')->where('id', 11)->update([$state => $state === 'disable' ? 1 : BcrClock::$now]);
expectBcr(!$cache->getAdminInfo($info['token']), 'warm cache rejects ' . $state);
$invalidateCache();
expectBcr(!$cache->getAdminInfo($info['token']), 'cold cache rejects ' . $state);
expectBcr(Tokens::overtimeToken($info['token']) === false, 'renewal rejects ' . $state);
Db::name('admin')->where('id', 11)->update([$state => $state === 'disable' ? 0 : null]);
}
$info = $fresh(13);
Db::name('admin')->where('id', 13)->delete();
expectBcr(!$cache->getAdminInfo($info['token']), 'deleted admin row is rejected without dereferencing null');
$info = $fresh();
Db::name('admin_session')->where('token', $info['token'])->delete();
expectBcr(!$cache->getAdminInfo($info['token']) && Tokens::overtimeToken($info['token']) === false, 'removed session cannot be rebuilt');
// Real DB hook inserts a revocation/replacement between session read and renewal CAS.
$interleave = null;
Db::event('before_find', static function ($query) use (&$interleave): void {
if ($interleave !== null && $query->getTable() === 'zyt_admin') {
$operation = $interleave; $interleave = null; $operation();
}
});
foreach (['logout', 'password', 'replacement', 'other_renewal'] as $race) {
$info = $fresh();
$old = $info['token']; $replacement = bin2hex(random_bytes(16));
$interleave = static function () use ($race, $old, $replacement): void {
if ($race === 'password') { Tokens::revokeBcrSessions(11); return; }
$values = $race === 'replacement' ? ['token' => $replacement] : ['expire_time' => $race === 'logout' ? BcrClock::$now - 1 : BcrClock::$now + 181 * 86400];
Db::name('admin_session')->where('token', $old)->update($values);
};
$result = Tokens::overtimeToken($old);
expectBcr($interleave === null, 'interleaving executed: ' . $race);
if ($race === 'other_renewal') {
expectBcr($result['expire_time'] === BcrClock::$now + 181 * 86400, 'CAS loser preserves another successful renewal');
} else {
expectBcr(!$result && !$cache->getAdminInfo($old), 'CAS cannot revive ' . $race);
if ($race === 'replacement') { expectBcr($session($replacement) !== null, 'CAS cannot overwrite replacement token'); }
}
}
$first = $fresh();
$same = $fresh(11, Terminal::BCR, 1);
expectBcr($first['token'] === $same['token'], 'multipoint BCR reuses existing terminal token');
$replaced = $fresh(11, Terminal::BCR, 0);
expectBcr($first['token'] !== $replaced['token'] && !$cache->getAdminInfo($first['token']), 'single-login BCR replaces old token');
$pc = $fresh(11, 1, 1);
BaseCache::$keepDeleted = true;
(new LoginLogic())->logout($replaced);
expectBcr(!$cache->getAdminInfo($replaced['token']) && !Tokens::overtimeToken($replaced['token']), 'BCR logout revokes shared token despite multipoint and stale cache');
expectBcr($cache->getAdminInfo($pc['token']) !== false && Tokens::expireToken($pc['token']) === false, 'legacy multipoint logout is unchanged');
BaseCache::$keepDeleted = false;
$sameSecondLogin = $fresh(11, Terminal::BCR, 1);
expectBcr($sameSecondLogin['token'] !== $replaced['token'] && !$cache->getAdminInfo($replaced['token']), 'same-second login after BCR logout cannot revive revoked shared token');
foreach (['admin_edit', 'self_edit', 'first_password'] as $mode) {
$info = $fresh(); $pc = $fresh(11, 1, 1);
BaseCache::$keepDeleted = true;
if ($mode === 'admin_edit') {
$ok = AdminLogic::edit(['id' => 11, 'name' => 'Synthetic actor', 'account' => 'synthetic_11',
'disable' => 0, 'multipoint_login' => 1, 'role_id' => [2], 'password' => 'new-admin-pass']);
expectBcr($ok, 'real AdminLogic::edit succeeds: ' . AdminLogic::getError());
} elseif ($mode === 'self_edit') {
expectBcr((bool) AdminLogic::editSelf(['admin_id' => 11, 'name' => 'Synthetic actor', 'avatar' => '', 'password' => 'new-self-pass']), 'real self edit succeeds');
} else {
expectBcr((new LoginLogic())->changeFirstPassword($pc['token'], 'new-first-pass'), 'real initial password change succeeds');
}
expectBcr(!$cache->getAdminInfo($info['token']) && !Tokens::overtimeToken($info['token']), $mode . ' revokes long session despite stale cache');
expectBcr($session($pc['token'])['expire_time'] > BcrClock::$now, $mode . ' preserves existing legacy multipoint semantics');
BaseCache::$keepDeleted = false;
}
$info = $fresh(12);
BaseCache::$keepDeleted = true;
expectBcr(AdminLogic::edit(['id' => 12, 'name' => 'Synthetic actor', 'account' => 'synthetic_12',
'disable' => 1, 'multipoint_login' => 1, 'role_id' => [2]]), 'real admin disable succeeds');
expectBcr(!$cache->getAdminInfo($info['token']), 'admin disable revokes warm BCR cache');
Db::name('admin')->where('id', 12)->update(['disable' => 0]);
expectBcr(!$cache->getAdminInfo($info['token']) && !Tokens::overtimeToken($info['token']), 'reenabling account cannot resurrect disabled session');
BaseCache::$keepDeleted = false;
$info = $fresh(12);
BaseCache::$keepDeleted = true;
expectBcr(AdminLogic::delete(['id' => 12]), 'real admin delete succeeds');
expectBcr(!$cache->getAdminInfo($info['token']), 'admin delete revokes warm BCR cache');
Db::name('admin')->where('id', 12)->update(['delete_time' => null]);
expectBcr(!$cache->getAdminInfo($info['token']) && !Tokens::overtimeToken($info['token']), 'restoring account cannot resurrect deleted session');
BaseCache::$keepDeleted = false;
$info = $fresh();
expectBcr($authorize($info, '203.0.113.20')['code'] === 1, 'BCR Wi-Fi to mobile IP change is allowed');
expectBcr($authorize($info, '203.0.113.20', 'tcm.diagnosis', 'lists')['code'] === 0, 'IP exception does not bypass menu permissions');
foreach ([1, 2, 7, 8, 9] as $terminal) {
$ordinary = $fresh(11, $terminal);
expectBcr($authorize($ordinary, '203.0.113.20')['code'] === -1, 'legacy IP binding remains ' . $terminal);
}
$app->env->set('wecom_rpa.server_ips', '203.0.113.20');
foreach ([7, 9] as $terminal) {
expectBcr($authorize($fresh(11, $terminal), '203.0.113.20')['code'] === 1, 'RPA trusted-server exception remains ' . $terminal);
}
expectBcr($authorize($fresh(11, 2), '203.0.113.20')['code'] === -1, 'generic mobile cannot use RPA exception');
$renewingPc = $fresh(11, 1);
Db::name('admin_session')->where('token', $renewingPc['token'])->update(['expire_time' => BcrClock::$now + 100]);
$cache->deleteAdminInfo($renewingPc['token']);
$setRequest($renewingPc['token']);
$cache->setAdminInfo($renewingPc['token']);
$pcAfterRenewal = $login($renewingPc['token'], '203.0.113.20');
expectBcr($authorize($pcAfterRenewal['admin'], '203.0.113.20')['code'] === -1, 'legacy renewal request does not bypass original fixed IP check');
$app->env->set('work_wechat.force_bind_login', true);
$app->env->set('work_wechat.corp_id', 'synthetic-corp');
$app->env->set('work_wechat.secret', 'synthetic-secret');
$app->env->set('work_wechat.agent_id', 'synthetic-agent');
expectBcr($login($info['token'], '203.0.113.20')['code'] === LoginLogic::CODE_NEED_BIND_WORK_WECHAT, 'BCR still requires configured WeCom binding');
Db::name('admin')->where('id', 11)->update(['work_wechat_userid' => 'synthetic-member']);
$bound = $fresh();
expectBcr($login($bound['token'], '203.0.113.20')['code'] === 1, 'bound BCR account passes WeCom gate');
Db::name('admin')->where('id', 11)->update(['work_wechat_userid' => '']);
expectBcr($login($bound['token'], '203.0.113.20')['code'] === LoginLogic::CODE_NEED_BIND_WORK_WECHAT, 'cached bound status cannot hide WeCom unlink');
$app->env->set('work_wechat.force_bind_login', false);
Db::name('admin')->where('id', 11)->update(['root' => 1]);
$rootInfo = $fresh();
Db::name('admin')->where('id', 11)->update(['root' => 0]);
$liveInfo = $cache->getAdminInfo($rootInfo['token']);
expectBcr($liveInfo['root'] === 0 && $authorize($liveInfo, '203.0.113.20', 'tcm.diagnosis', 'lists')['code'] === 0, 'cached root status cannot hide revoked root');
Db::name('admin_role')->where('admin_id', 11)->delete();
expectBcr($cache->getAdminInfo($rootInfo['token'])['role_id'] === [], 'role snapshot is live for downstream policy middleware');
Db::name('admin_role')->insert(['admin_id' => 11, 'role_id' => 2]);
$actor = MobileRecordingLogic::actor(11);
expectBcr(MobileRecordingLogic::diagnosis(101, 11, $actor)['id'] === 101, 'own patient remains accessible');
deniedBcr(static fn() => MobileRecordingLogic::diagnosis(102, 11, $actor), 'BCR has no access to another assistant patient');
Db::name('system_role_menu')->where('role_id', 2)->delete();
(new AdminAuthCache(11))->clearAuthCache();
expectBcr($authorize($info, '203.0.113.20')['code'] === 0, 'BCR IP exception cannot bypass removed role grant');
deniedBcr(static fn() => MobileRecordingLogic::actor(11), 'patient service rechecks revoked grant');
expectBcr(!$app->initialized(), 'test never initialized the application or loaded .env');
echo 'AdminBcrSessionTest: ' . $GLOBALS['bcrChecks'] . " checks passed (real classes, synthetic in-memory SQLite/cache, no network).\n";
}