Files
zyt/server/tests/FollowupAudioPermissionTest.php
T

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);
}