341 lines
22 KiB
PHP
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";
|
|
}
|