187 lines
12 KiB
PHP
187 lines
12 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/** Real middleware + current database authorization, using only an explicitly disposable local DB. */
|
|
require __DIR__ . '/fixtures/followup_audio/database.php';
|
|
|
|
use app\adminapi\controller\tcm\FollowupAudioController;
|
|
use app\adminapi\http\middleware\AuthMiddleware;
|
|
use app\common\service\followupaudio\FollowupAudioAccess as Access;
|
|
use app\common\service\followupaudio\FollowupAudioUpload as Upload;
|
|
use think\Container;
|
|
use think\facade\Db;
|
|
|
|
// Optional immutable source snapshots prove that the same negative tests catch the old behavior.
|
|
foreach (['FOLLOWUP_AUDIO_TEST_ACCESS_SOURCE', 'FOLLOWUP_AUDIO_TEST_MIDDLEWARE_SOURCE'] as $name) {
|
|
$source = getenv($name);
|
|
if (is_string($source) && $source !== '') {
|
|
if (!is_file($source)) { throw new RuntimeException('Missing explicit test source override'); }
|
|
require $source;
|
|
}
|
|
}
|
|
|
|
final class FollowupPermissionAuthCacheDouble
|
|
{
|
|
public static array $uris = [];
|
|
public function __construct($adminId = '') {}
|
|
public function getAdminUri(): array { return self::$uris; }
|
|
public function getAllUri(): array { throw new RuntimeException('Audio routes must not use unregistered-route bypass'); }
|
|
}
|
|
if (!class_alias(FollowupPermissionAuthCacheDouble::class, 'app\\common\\cache\\AdminAuthCache')) {
|
|
throw new RuntimeException('Permission cache double must be installed before middleware autoload');
|
|
}
|
|
|
|
function followupPermissionController(string $action, array $body, int $actor, string $method = 'GET'): array
|
|
{
|
|
$request = (new app\Request())->withServer(['REQUEST_METHOD' => $method]);
|
|
$request = $method === 'GET' ? $request->withGet($body) : $request->withPost($body);
|
|
$controller = (new ReflectionClass(FollowupAudioController::class))->newInstanceWithoutConstructor();
|
|
foreach (['request' => $request, 'adminId' => $actor, 'adminInfo' => ['root' => 1]] as $name => $value) {
|
|
$property = new ReflectionProperty($controller, $name);
|
|
$property->setAccessible(true);
|
|
$property->setValue($controller, $value);
|
|
}
|
|
return $controller->$action()->getData();
|
|
}
|
|
|
|
$f = followupAudioTestDatabase(['preview_only' => false]);
|
|
set_exception_handler(static function (Throwable $e): void {
|
|
fwrite(STDERR, $e->getMessage() . PHP_EOL); exit(1);
|
|
});
|
|
$checks = 0;
|
|
$expect = static function (bool $ok, string $label) use (&$checks): void {
|
|
if (!$ok) { throw new RuntimeException('FAIL: ' . $label); }
|
|
$checks++;
|
|
};
|
|
$deny = static function (callable $call, string $label) use ($expect): void {
|
|
try { $call(); } catch (DomainException $e) {
|
|
$expect($e->getMessage() === '诊单不存在或无权操作', $label . ' returns authorization denial'); return;
|
|
}
|
|
$expect(false, $label);
|
|
};
|
|
$pdo = $f['pdo'];
|
|
try {
|
|
// Independent of shared-fixture timing/default grants: configure all permission cases explicitly.
|
|
if (!Db::name('system_menu')->where('id', 4)->find()) {
|
|
Db::name('system_menu')->insert(['id' => 4, 'perms' => 'tcm.diagnosis/followupAudio', 'is_disable' => 0]);
|
|
}
|
|
$setMenus = static function (array $ids): void {
|
|
Db::name('system_role_menu')->where('role_id', 2)->delete();
|
|
foreach ($ids as $id) { Db::name('system_role_menu')->insert(['role_id' => 2, 'menu_id' => $id]); }
|
|
};
|
|
|
|
$request = (new app\Request())->withServer(['REMOTE_ADDR' => '127.0.0.1', 'REQUEST_METHOD' => 'GET']);
|
|
Container::getInstance()->instance('request', $request);
|
|
Container::getInstance()->instance('think\\Request', $request);
|
|
$middleware = new AuthMiddleware();
|
|
$actions = ['capabilities', 'uploadSession', 'uploadChunk', 'uploadComplete', 'create', 'lists',
|
|
'detail', 'saveDraft', 'apply', 'retry', 'audio', 'delete', 'reextract'];
|
|
foreach ($actions as $action) {
|
|
foreach ([[], ['tcm.diagnosis/edit'], ['tcm.diagnosis/followupAudio'],
|
|
['tcm.diagnosis/edit', 'tcm.diagnosis/followupAudio']] as $uris) {
|
|
FollowupPermissionAuthCacheDouble::$uris = $uris;
|
|
$route = new class($action, $request->ip()) {
|
|
public array $adminInfo;
|
|
public object $controllerObject;
|
|
public function __construct(private string $verb, string $ip) {
|
|
$this->adminInfo = ['admin_id' => 2, 'root' => 0, 'login_ip' => $ip, 'work_wechat_userid' => 'synthetic'];
|
|
$this->controllerObject = new class { public function isNotNeedLogin(): bool { return false; } };
|
|
}
|
|
public function controller(): string { return 'tcm.FollowupAudio'; }
|
|
public function action(): string { return $this->verb; }
|
|
public function param($name, $default = null) { return $default; }
|
|
};
|
|
$result = $middleware->handle($route, static fn () => 'ALLOWED');
|
|
$expected = count($uris) === 2;
|
|
$expect(($result === 'ALLOWED') === $expected, 'middleware ' . $action . ' permission set ' . json_encode($uris));
|
|
if (!$expected) { $expect($result->getData()['code'] === 0, 'middleware failure envelope'); }
|
|
}
|
|
}
|
|
$unknown = clone $route;
|
|
$verb = new ReflectionProperty($unknown, 'verb'); $verb->setAccessible(true); $verb->setValue($unknown, 'unknownFutureAction');
|
|
$expect($middleware->handle($unknown, static fn () => 'ALLOWED') !== 'ALLOWED', 'unknown audio route fails closed');
|
|
$route->adminInfo['root'] = 1;
|
|
FollowupPermissionAuthCacheDouble::$uris = [];
|
|
$expect($middleware->handle($route, static fn () => 'ALLOWED') === 'ALLOWED', 'middleware root keeps existing bypass');
|
|
|
|
foreach ([[], [1], [4], [1, 2]] as $menus) {
|
|
$setMenus($menus);
|
|
$deny(fn () => Access::diagnosis(1, 2, ['root' => 1]), 'current DB rejects missing permission ' . json_encode($menus));
|
|
}
|
|
$setMenus([1, 4]);
|
|
$expect((int) Access::diagnosis(1, 2, ['root' => 1])['id'] === 1, 'edit and recording grant allows own patient');
|
|
$expect(!Access::canDaily(2, ['root' => 1]), 'recording grant does not grant daily permission');
|
|
$deny(fn () => Access::diagnosis(1, 2, [], true), 'daily write requires separate daily permission');
|
|
$setMenus([1, 2, 4]);
|
|
$expect((int) Access::diagnosis(1, 2, [], true)['id'] === 1, 'all three permissions allow daily context');
|
|
$deny(fn () => Access::diagnosis(2, 2, ['root' => 1]), 'forged root cannot bypass patient scope');
|
|
$expect((int) Access::diagnosis(2, 1, [])['id'] === 2, 'current root remains authorized without role grants');
|
|
|
|
$caps = followupPermissionController('capabilities', ['diagnosis_id' => 1], 2);
|
|
$expect($caps['code'] === 1 && $caps['data']['can_upload'] === true, 'real controller permits current recording grant');
|
|
$upload = Upload::createSession(1, 'synthetic.wav', 256, 2, ['root' => 1]);
|
|
$expect(isset($upload['upload_id']), 'real upload session permitted before revocation');
|
|
|
|
// A repeatable-read snapshot must not retain a grant revoked through another DB connection.
|
|
Db::startTrans(); Db::name('system_role_menu')->select();
|
|
$pdo->exec('DELETE FROM zyt_system_role_menu WHERE role_id=2 AND menu_id=4');
|
|
$deny(fn () => Access::diagnosis(1, 2, ['root' => 1]), 'recording revocation beats repeatable-read snapshot');
|
|
Db::rollback();
|
|
$caps = followupPermissionController('capabilities', ['diagnosis_id' => 1], 2);
|
|
$expect($caps['code'] === 0 && $caps['msg'] === '诊单不存在或无权操作', 'real controller denies revoked grant despite injected root');
|
|
$deniedUpload = followupPermissionController('uploadSession', ['diagnosis_id' => 1, 'file_name' => 'synthetic.wav', 'total_bytes' => 256], 2, 'POST');
|
|
$expect($deniedUpload['code'] === 0 && $deniedUpload['msg'] === '诊单不存在或无权操作', 'real controller upload denied after revocation');
|
|
$deny(fn () => Upload::owned($upload['upload_id'], 2, ['root' => 1]), 'existing upload no longer usable after revocation');
|
|
$setMenus([1, 2, 4]);
|
|
Db::name('system_menu')->where('id', 4)->update(['is_disable' => 1]);
|
|
$deny(fn () => Access::diagnosis(1, 2, []), 'disabled menu denies access despite role grant');
|
|
Db::name('system_menu')->where('id', 4)->update(['is_disable' => 0]);
|
|
$expect((int) Access::diagnosis(1, 2, [])['id'] === 1, 'reenabled menu restores authorized access');
|
|
Db::name('admin')->where('id', 2)->update(['disable' => 1]);
|
|
try { Access::diagnosis(1, 2, ['root' => 1]); $expect(false, 'disabled actor'); }
|
|
catch (DomainException $e) { $expect($e->getMessage() === '账号已停用或无权访问', 'disabled actor stays denied'); }
|
|
Db::name('admin')->where('id', 2)->update(['disable' => 0]);
|
|
|
|
// Migration runs against realistic menu columns in this database only; no shared fixture changes.
|
|
$pdo->exec("ALTER TABLE zyt_system_menu MODIFY id INT NOT NULL AUTO_INCREMENT,
|
|
ADD pid INT NOT NULL DEFAULT 0, ADD type CHAR(1) NOT NULL DEFAULT 'A', ADD name VARCHAR(100) NOT NULL DEFAULT '',
|
|
ADD icon VARCHAR(100) NOT NULL DEFAULT '', ADD sort INT UNSIGNED NOT NULL DEFAULT 0,
|
|
ADD paths VARCHAR(200) NOT NULL DEFAULT '', ADD component VARCHAR(200) NOT NULL DEFAULT '',
|
|
ADD selected VARCHAR(200) NOT NULL DEFAULT '', ADD params VARCHAR(200) NOT NULL DEFAULT '',
|
|
ADD is_cache INT NOT NULL DEFAULT 0, ADD is_show INT NOT NULL DEFAULT 1,
|
|
ADD create_time INT NOT NULL DEFAULT 0, ADD update_time INT NOT NULL DEFAULT 0");
|
|
// The old fixture recording node is deliberately outside the two targeted menu families.
|
|
$pdo->exec("UPDATE zyt_system_menu SET pid=182,name='日常记录',sort=59 WHERE id=2");
|
|
$pdo->exec("INSERT INTO zyt_system_menu (id,pid,type,name,sort,perms,is_disable) VALUES
|
|
(182,0,'C','Synthetic first diagnosis page',0,'tcm.diagnosis/lists',0),
|
|
(191,0,'C','Synthetic second diagnosis page',0,'firstvisit.myPatient/lists',0),
|
|
(192,191,'A','日常记录',0,'tcm.diagnosis/dailyRecord',0)");
|
|
$grants = $pdo->query('SELECT role_id,menu_id FROM zyt_system_role_menu ORDER BY role_id,menu_id')->fetchAll(PDO::FETCH_ASSOC);
|
|
$migrationFile = getenv('FOLLOWUP_AUDIO_TEST_MENU_MIGRATION') ?: dirname(__DIR__) . '/sql/2026_10_12_followup_audio_permission.sql';
|
|
$migration = file_get_contents($migrationFile);
|
|
$expect(is_string($migration), 'permission migration exists');
|
|
$migration = preg_replace('/^\s*(?:--|#).*$/m', '', $migration);
|
|
$firstMenus = null;
|
|
for ($run = 0; $run < 2; $run++) {
|
|
foreach (explode(';', $migration) as $sql) {
|
|
if (trim($sql) !== '') { $statement = $pdo->query($sql); $statement->closeCursor(); }
|
|
}
|
|
$menus = $pdo->query("SELECT pid,type,name,sort,perms,is_disable,is_show FROM zyt_system_menu
|
|
WHERE perms='tcm.diagnosis/followupAudio' AND pid IN (182,191) ORDER BY pid,id")->fetchAll(PDO::FETCH_ASSOC);
|
|
$expect(count($menus) === 2 && array_map('intval', array_column($menus, 'pid')) === [182, 191], 'migration creates exactly one recording node per daily parent');
|
|
$expect(array_map('intval', array_column($menus, 'sort')) === [58, 0], 'unsigned zero sort never underflows');
|
|
foreach ($menus as $menu) {
|
|
$expect($menu['type'] === 'A' && $menu['name'] === '回访录音' && (int) $menu['is_disable'] === 0 && (int) $menu['is_show'] === 1, 'migration node is assignable');
|
|
}
|
|
if ($firstMenus !== null) { $expect($menus === $firstMenus, 'second migration preserves nodes'); }
|
|
$firstMenus = $menus;
|
|
$expect($pdo->query('SELECT role_id,menu_id FROM zyt_system_role_menu ORDER BY role_id,menu_id')->fetchAll(PDO::FETCH_ASSOC) === $grants,
|
|
'migration does not automatically grant any role');
|
|
}
|
|
echo 'FOLLOWUP_AUDIO_PERMISSION assertions=' . $checks . ' PASS middleware_actions=13 current_db=1 controller=1 revoked_upload=1 migration_idempotent=1 role_grants_unchanged=1' . PHP_EOL;
|
|
} finally {
|
|
try { Db::rollback(); } catch (Throwable $e) {}
|
|
followupAudioTestDatabaseCleanup($f);
|
|
}
|