feat(followup-audio): separate uploader-only recording delete permission

This commit is contained in:
2026-10-12 16:36:47 +08:00
parent 11306e6314
commit bcd42a5c39
10 changed files with 263 additions and 13 deletions
@@ -87,7 +87,8 @@ class AuthMiddleware
$action = substr($accessUri, strlen('tcm.followupaudio/')); $action = substr($accessUri, strlen('tcm.followupaudio/'));
$uris = $this->formatUrl($adminAuthCache->getAdminUri() ?? []); $uris = $this->formatUrl($adminAuthCache->getAdminUri() ?? []);
if (in_array($action, $allowed, true) && in_array('tcm.diagnosis/edit', $uris, true) if (in_array($action, $allowed, true) && in_array('tcm.diagnosis/edit', $uris, true)
&& in_array('tcm.diagnosis/followupaudio', $uris, true)) { && in_array('tcm.diagnosis/followupaudio', $uris, true)
&& ($action !== 'delete' || in_array('tcm.followupaudio/delete', $uris, true))) {
return $next($request); // The service also checks the current patient scope and daily permissions. return $next($request); // The service also checks the current patient scope and daily permissions.
} }
return JsonService::fail('权限不足,无法访问或操作'); return JsonService::fail('权限不足,无法访问或操作');
@@ -108,9 +108,8 @@ final class FollowupAudioLogic
{ {
$task = Store::task((int) $summary['id']); $task = Store::task((int) $summary['id']);
$info = Access::actor($actor); $info = Access::actor($actor);
$reason = !Store::enabled() ? '回访录音功能尚未启用' $reason = !Store::enabled() ? '回访录音功能尚未启用' : Access::deleteReason($task, $actor);
: ((int) $info['root'] !== 1 && (int) $task['actor_id'] !== $actor if ($reason === '') { $reason = Store::deleteBlockReason($task); }
? '仅上传者或超级管理员可删除录音' : Store::deleteBlockReason($task));
$reextractReason = (int) $info['root'] !== 1 && (int) $task['actor_id'] !== $actor $reextractReason = (int) $info['root'] !== 1 && (int) $task['actor_id'] !== $actor
? '仅上传者或超级管理员可重新提炼' : Reextract::blockReason($task); ? '仅上传者或超级管理员可重新提炼' : Reextract::blockReason($task);
return $summary + ['can_delete' => $reason === '', 'delete_block_reason' => $reason, return $summary + ['can_delete' => $reason === '', 'delete_block_reason' => $reason,
@@ -74,6 +74,19 @@ final class FollowupAudioAccess
return $task; return $task;
} }
/** One fresh authorization rule for both delete capability and the locked delete transaction. */
public static function deleteReason(array $task, int $actor): string
{
$info = self::actor($actor);
if (!self::allowed($actor, $info, 'tcm.followupAudio/delete')) {
return '缺少删除回访录音权限';
}
if ((int) $info['root'] !== 1 && (int) ($task['actor_id'] ?? 0) !== $actor) {
return '仅上传者或超级管理员可删除录音';
}
return '';
}
public static function canDaily(int $actor, array $info): bool public static function canDaily(int $actor, array $info): bool
{ {
$info = self::actor($actor); $info = self::actor($actor);
@@ -273,9 +273,8 @@ final class FollowupAudioStore
$task = self::lockTask($taskId); $task = self::lockTask($taskId);
$info = FollowupAudioAccess::actor($actor); $info = FollowupAudioAccess::actor($actor);
FollowupAudioAccess::task($taskId, $actor, $info); FollowupAudioAccess::task($taskId, $actor, $info);
if ((int) $info['root'] !== 1 && (int) $task['actor_id'] !== $actor) { $permissionReason = FollowupAudioAccess::deleteReason($task, $actor);
throw new DomainException('仅上传者或超级管理员可删除录音'); if ($permissionReason !== '') { throw new DomainException($permissionReason); }
}
if ((int) $task['version'] !== $version) { throw new DomainException('FOLLOWUP_AUDIO_VERSION_CONFLICT'); } if ((int) $task['version'] !== $version) { throw new DomainException('FOLLOWUP_AUDIO_VERSION_CONFLICT'); }
$reason = self::deleteBlockReason($task); $reason = self::deleteBlockReason($task);
if ($reason !== '') { throw new DomainException($reason); } if ($reason !== '') { throw new DomainException($reason); }
@@ -0,0 +1,15 @@
-- 删除回访录音:按钮权限,与各入口的“回访录音”同层级。
-- 不复制任何角色授权;非超级管理员仍限本人上传且未采用的可删除任务。
INSERT INTO zyt_system_menu (
pid, type, name, icon, sort, perms, paths, component,
selected, params, is_cache, is_show, is_disable, create_time, update_time
)
SELECT r.pid, 'A', '删除回访录音', '', GREATEST(CAST(MAX(r.sort) AS SIGNED) - 1, 0),
'tcm.followupAudio/delete', '', '', '', '', 0, 1, 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()
FROM zyt_system_menu AS r
WHERE r.perms = 'tcm.diagnosis/followupAudio' AND r.type = 'A'
AND NOT EXISTS (
SELECT 1 FROM zyt_system_menu AS d
WHERE d.pid = r.pid AND d.perms = 'tcm.followupAudio/delete'
)
GROUP BY r.pid;
+2 -2
View File
@@ -99,9 +99,9 @@ try {
$pdo->exec('CREATE TABLE zyt_admin_dept(admin_id INT,dept_id INT)'); $pdo->exec('CREATE TABLE zyt_admin_dept(admin_id INT,dept_id INT)');
$pdo->exec('CREATE TABLE zyt_admin_jobs(admin_id INT,jobs_id INT)'); $pdo->exec('CREATE TABLE zyt_admin_jobs(admin_id INT,jobs_id INT)');
$pdo->exec('CREATE TABLE zyt_system_menu(id INT PRIMARY KEY,perms VARCHAR(100),is_disable INT)'); $pdo->exec('CREATE TABLE zyt_system_menu(id INT PRIMARY KEY,perms VARCHAR(100),is_disable INT)');
$pdo->exec("INSERT INTO zyt_system_menu VALUES(1,'tcm.diagnosis/edit',0),(2,'tcm.diagnosis/dailyRecord',0),(3,'firstvisit.wecomPromotion/overview',0),(4,'tcm.diagnosis/followupAudio',0)"); $pdo->exec("INSERT INTO zyt_system_menu VALUES(1,'tcm.diagnosis/edit',0),(2,'tcm.diagnosis/dailyRecord',0),(3,'firstvisit.wecomPromotion/overview',0),(4,'tcm.diagnosis/followupAudio',0),(5,'tcm.followupAudio/delete',0)");
$pdo->exec('CREATE TABLE zyt_system_role_menu(role_id INT,menu_id INT)'); $pdo->exec('CREATE TABLE zyt_system_role_menu(role_id INT,menu_id INT)');
$pdo->exec('INSERT INTO zyt_system_role_menu VALUES(2,1),(2,2),(2,3),(2,4),(5,1),(5,3),(5,4)'); $pdo->exec('INSERT INTO zyt_system_role_menu VALUES(2,1),(2,2),(2,3),(2,4),(2,5),(5,1),(5,3),(5,4),(5,5)');
$pdo->exec('CREATE TABLE zyt_doctor_appointment(id INT PRIMARY KEY,patient_id INT,doctor_id INT,status INT)'); $pdo->exec('CREATE TABLE zyt_doctor_appointment(id INT PRIMARY KEY,patient_id INT,doctor_id INT,status INT)');
$pdo->exec('CREATE TABLE zyt_dict_data(id INT PRIMARY KEY AUTO_INCREMENT,name VARCHAR(50),value VARCHAR(50),type_value VARCHAR(50),status INT,sort INT)'); $pdo->exec('CREATE TABLE zyt_dict_data(id INT PRIMARY KEY AUTO_INCREMENT,name VARCHAR(50),value VARCHAR(50),type_value VARCHAR(50),status INT,sort INT)');
$pdo->exec("INSERT INTO zyt_dict_data(name,value,type_value,status,sort) VALUES('Synthetic Enabled','dry','appetite',1,1),('Synthetic Disabled','bad','appetite',0,2)"); $pdo->exec("INSERT INTO zyt_dict_data(name,value,type_value,status,sort) VALUES('Synthetic Enabled','dry','appetite',1,1),('Synthetic Disabled','bad','appetite',0,2)");
@@ -0,0 +1,220 @@
<?php
declare(strict_types=1);
/** Real controller, middleware and current RBAC reads; disposable MySQL 23319 only. */
require __DIR__ . '/fixtures/followup_audio/database.php';
use app\adminapi\controller\tcm\FollowupAudioController;
use app\adminapi\http\middleware\AuthMiddleware;
use app\common\service\followupaudio\FollowupAudioStore as Store;
use think\Container;
use think\facade\Db;
if ((int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT') !== 23319
|| getenv('FOLLOWUP_AUDIO_TEST_ALLOW_DISPOSABLE') !== '1') {
throw new RuntimeException('Delete permission test requires disposable MySQL 23319');
}
// Immutable pre-change snapshots allow the identical regression test to prove the original defect.
foreach (['ACCESS', 'STORE', 'LOGIC', 'MIDDLEWARE'] as $component) {
$source = getenv('FOLLOWUP_AUDIO_TEST_' . $component . '_SOURCE');
if (is_string($source) && $source !== '') {
if (!is_file($source)) { throw new RuntimeException('Missing explicit source override'); }
require $source;
}
}
final class FollowupDeletePermissionCacheDouble
{
public static array $uris = [];
public function __construct($adminId = '') {}
public function getAdminUri(): array { return self::$uris; }
public function getAllUri(): array { throw new RuntimeException('Recording routes must fail closed'); }
}
if (!class_alias(FollowupDeletePermissionCacheDouble::class, 'app\\common\\cache\\AdminAuthCache')) {
throw new RuntimeException('Cache double must be installed before middleware autoload');
}
function followupDeletePermissionRequest(string $action, array $body, int $actor = 2, 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]);
$checks = 0; $failures = [];
$expect = static function (bool $ok, string $label) use (&$checks, &$failures): void {
$checks++;
if (!$ok) { $failures[] = $label; fwrite(STDERR, 'FAIL: ' . $label . PHP_EOL); }
};
$success = static function (array $response): array {
if ($response['code'] !== 1) { throw new RuntimeException(json_encode($response, JSON_UNESCAPED_UNICODE)); }
return $response['data'];
};
$body = static fn (array $task): array => ['id' => (int) $task['id'], 'version' => (int) $task['version']];
$make = static function (int $owner = 2) use ($f): array {
static $serial = 0; $serial++;
$pcm = str_repeat(pack('v', 2000 + $serial), 16000);
$path = $f['private'] . '/permission-' . $serial . '.wav';
file_put_contents($path, 'RIFF' . pack('V', 36 + strlen($pcm)) . 'WAVEfmt '
. pack('VvvVVvv', 16, 1, 1, 16000, 32000, 2, 16) . 'data' . pack('V', strlen($pcm)) . $pcm);
chmod($path, 0600);
$upload = followupAudioTestUpload($f, $path, 'synthetic.wav');
$created = Store::create($upload, '2026-09-29 10:00:00', 'qwen', 1, $f['actor']);
$claim = Store::claim();
if ((int) $claim['id'] !== (int) $created['id'] || !Store::complete((int) $created['id'], $claim['lease_token'], [
'summary' => 'Synthetic', 'transcript' => '今天合成症状', 'uncertainties' => [],
'items' => [['kind' => 'diagnosis', 'values' => ['symptoms' => '合成症状'], 'record_date' => '2026-09-29',
'date_text' => '今天', 'evidence' => [['text' => '今天合成症状']]]],
])) { throw new RuntimeException('Synthetic completion failed'); }
// Explicit synthetic ownership, with patient/diagnosis unchanged; never call an upstream model.
Db::name('followup_audio_task')->where('id', $created['id'])->update(['actor_id' => $owner]);
Db::name('followup_audio_upload')->where('id', $upload['id'])->update(['actor_id' => $owner]);
return Store::task((int) $created['id']);
};
$grant = static function (bool $enabled): void {
Db::name('system_role_menu')->where('role_id', 2)->where('menu_id', 5)->delete();
if ($enabled) { Db::name('system_role_menu')->insert(['role_id' => 2, 'menu_id' => 5]); }
};
$assertDenied = static function (array $task, string $label) use ($expect, $success, $body): void {
$detail = $success(followupDeletePermissionRequest('detail', ['id' => $task['id']]));
$expect($detail['can_delete'] === false && $detail['delete_block_reason'] !== '', $label . ': detail button disabled');
$list = $success(followupDeletePermissionRequest('lists', ['diagnosis_id' => 1]));
$rows = array_values(array_filter($list['items'], static fn (array $row): bool => (int) $row['id'] === (int) $task['id']));
$expect(count($rows) === 1 && $rows[0]['can_delete'] === false, $label . ': list button disabled');
$response = followupDeletePermissionRequest('delete', $body($task), 2, 'POST');
$expect($response['code'] === 0 && $response['msg'] === $detail['delete_block_reason'], $label . ': actual controller rejects with same reason');
$row = Db::name('followup_audio_task')->where('id', $task['id'])->find();
$expect($row['status'] === $task['status'] && (int) $row['active_slot'] === 0
&& (int) $row['version'] === (int) $task['version'], $label . ': rejected task unchanged');
};
try {
if (!Db::name('system_menu')->where('id', 5)->find()) {
Db::name('system_menu')->insert(['id' => 5, 'perms' => 'tcm.followupAudio/delete', 'is_disable' => 0]);
}
$grant(false);
$assertDenied($make(), 'own upload without delete permission');
$grant(true);
$own = $make();
$detail = $success(followupDeletePermissionRequest('detail', ['id' => $own['id']]));
$expect($detail['can_delete'] === true && $detail['delete_block_reason'] === '', 'grant enables own completed task');
$deleted = $success(followupDeletePermissionRequest('delete', $body($own), 2, 'POST'));
$expect($deleted['status'] === 'deleted', 'grant allows own actual delete');
$assertDenied($make(1), 'delete grant does not allow another uploader on own patient');
// Load the grant in a repeatable-read snapshot, then revoke from a separate connection.
$revoked = $make();
$expect($success(followupDeletePermissionRequest('detail', ['id' => $revoked['id']]))['can_delete'] === true, 'grant visible before revocation');
Db::startTrans(); Db::name('system_role_menu')->select();
$f['pdo']->exec('DELETE FROM zyt_system_role_menu WHERE role_id=2 AND menu_id=5');
$fresh = $success(followupDeletePermissionRequest('detail', ['id' => $revoked['id']]));
$expect($fresh['can_delete'] === false, 'revocation beats repeatable-read snapshot and stale client');
Db::rollback();
FollowupDeletePermissionCacheDouble::$uris = ['tcm.diagnosis/edit', 'tcm.diagnosis/followupAudio', 'tcm.followupAudio/delete'];
$assertDenied($revoked, 'revoked grant despite positive cached URI');
$grant(true);
$disabled = $make();
Db::startTrans(); Db::name('system_menu')->select();
$f['pdo']->exec('UPDATE zyt_system_menu SET is_disable=1 WHERE id=5');
$fresh = $success(followupDeletePermissionRequest('detail', ['id' => $disabled['id']]));
$expect($fresh['can_delete'] === false, 'disabled menu beats repeatable-read snapshot');
Db::rollback();
$assertDenied($disabled, 'disabled delete menu despite role grant');
Db::name('system_menu')->where('id', 5)->update(['is_disable' => 0]);
if ((int) Db::name('followup_audio_task')->where('id', $disabled['id'])->value('active_slot') === 0) {
$expect($success(followupDeletePermissionRequest('detail', ['id' => $disabled['id']]))['can_delete'] === true, 'reenabling menu restores current permission');
$success(followupDeletePermissionRequest('delete', $body($disabled), 2, 'POST'));
}
foreach (['applied', 'queued', 'running', 'needs_reconciliation'] as $status) {
$task = $make();
Db::name('followup_audio_task')->where('id', $task['id'])->update(['status' => $status]);
$task['status'] = $status;
$assertDenied($task, 'grant does not bypass status ' . $status);
Db::name('followup_audio_task')->where('id', $task['id'])->update(['status' => 'failed']);
}
$root = $make(2); $grant(false);
$rootDetail = $success(followupDeletePermissionRequest('detail', ['id' => $root['id']], 1));
$expect($rootDetail['can_delete'] === true, 'current root retains existing exception without role grant');
$expect($success(followupDeletePermissionRequest('delete', $body($root), 1, 'POST'))['status'] === 'deleted', 'root actual delete retained');
$blockedActor = $make(); $grant(true);
Db::name('admin')->where('id', 2)->update(['disable' => 1]);
$result = followupDeletePermissionRequest('delete', $body($blockedActor), 2, 'POST');
$expect($result['code'] === 0 && $result['msg'] === '账号已停用或无权访问', 'disabled actor rejected despite injected root and cached grants');
Db::name('admin')->where('id', 2)->update(['disable' => 0]);
$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();
$otherActions = ['capabilities', 'uploadSession', 'uploadChunk', 'uploadComplete', 'create', 'lists',
'detail', 'saveDraft', 'apply', 'retry', 'audio', 'reextract'];
foreach (array_merge($otherActions, ['delete']) as $action) {
for ($mask = 0; $mask < 8; $mask++) {
FollowupDeletePermissionCacheDouble::$uris = [];
foreach (['tcm.diagnosis/edit', 'tcm.diagnosis/followupAudio', 'tcm.followupAudio/delete'] as $bit => $uri) {
if (($mask & (1 << $bit)) !== 0) { FollowupDeletePermissionCacheDouble::$uris[] = $uri; }
}
$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; }
};
$actual = $middleware->handle($route, static fn () => 'ALLOWED');
$allowed = ($mask & 3) === 3 && ($action !== 'delete' || ($mask & 4) !== 0);
$expect(($actual === 'ALLOWED') === $allowed, 'middleware ' . $action . ' permission bitmask ' . $mask);
}
}
// Real menu migration: mirror every existing recording parent, remain idempotent, never grant roles.
$pdo = $f['pdo'];
$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");
$pdo->exec("UPDATE zyt_system_menu SET pid=182,name='回访录音',sort=58 WHERE id=4");
$pdo->exec("UPDATE zyt_system_menu SET pid=182,name='删除回访录音',sort=57 WHERE id=5");
$pdo->exec("INSERT INTO zyt_system_menu(id,pid,type,name,sort,perms,is_disable) VALUES
(191,0,'C','Synthetic second page',0,'firstvisit.myPatient/lists',0),
(192,191,'A','回访录音',0,'tcm.diagnosis/followupAudio',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_DELETE_MENU_MIGRATION') ?: dirname(__DIR__) . '/sql/2026_10_12_followup_audio_delete_permission.sql';
if (!is_file($migrationFile)) { throw new RuntimeException('Delete permission migration missing'); }
$migration = preg_replace('/^\s*(?:--|#).*$/m', '', file_get_contents($migrationFile));
$first = 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 LOWER(perms)='tcm.followupaudio/delete' ORDER BY pid,id")->fetchAll(PDO::FETCH_ASSOC);
$expect(count($menus) === 2 && array_map('intval', array_column($menus, 'pid')) === [182, 191], 'migration mirrors recording menu parent set without duplicates');
foreach ($menus as $menu) {
$expect($menu['type'] === 'A' && $menu['name'] === '删除回访录音' && (int) $menu['is_disable'] === 0
&& (int) $menu['is_show'] === 1, 'delete node assignable and correctly labeled');
}
if ($first !== null) { $expect($menus === $first, 'second migration is idempotent'); }
$first = $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 changes no role grants');
}
} finally {
try { Db::rollback(); } catch (Throwable $e) {}
followupAudioTestDatabaseCleanup($f);
}
echo 'FOLLOWUP_AUDIO_DELETE_PERMISSION assertions=' . $checks . ' failures=' . count($failures)
. ' ' . ($failures === [] ? 'PASS' : 'FAIL')
. ' mysql23319=1 controller=1 middleware_actions=13 nondelete_actions=12 fresh_rbac=1 owner_scope=1 state_guards=1 migration_idempotent=1 role_grants_unchanged=1 upstream_calls=0' . PHP_EOL;
exit($failures === [] ? 0 : 1);
@@ -23,6 +23,8 @@ expectEndpoint(str_contains($middleware, "in_array('tcm.diagnosis/edit', \$uris,
expectEndpoint(str_contains($access, "Db::name('admin')->where('id', \$actor)->whereNull('delete_time')->where('disable', 0)->lock(true)"), 'active actor refresh'); expectEndpoint(str_contains($access, "Db::name('admin')->where('id', \$actor)->whereNull('delete_time')->where('disable', 0)->lock(true)"), 'active actor refresh');
expectEndpoint(str_contains($access, 'MyPatientLogic::applyScope($query, $actor, $info)') && str_contains($access, '$query->lock(true)->find()'), 'row management guard'); expectEndpoint(str_contains($access, 'MyPatientLogic::applyScope($query, $actor, $info)') && str_contains($access, '$query->lock(true)->find()'), 'row management guard');
expectEndpoint(str_contains($access, 'tcm.diagnosis/dailyRecord'), 'daily permission guard'); expectEndpoint(str_contains($access, 'tcm.diagnosis/dailyRecord'), 'daily permission guard');
expectEndpoint(str_contains($middleware, "in_array('tcm.followupaudio/delete', \$uris, true)"), 'delete has an independent route permission');
expectEndpoint(str_contains($access, "self::allowed(\$actor, \$info, 'tcm.followupAudio/delete')"), 'delete permission also checked against current DB');
expectEndpoint(str_contains($middleware, "in_array('tcm.diagnosis/followupaudio', \$uris, true)"), 'independent audio route permission'); expectEndpoint(str_contains($middleware, "in_array('tcm.diagnosis/followupaudio', \$uris, true)"), 'independent audio route permission');
expectEndpoint(str_contains($access, "self::allowed(\$actor, \$info, 'tcm.diagnosis/followupAudio')"), 'fresh independent audio permission at every service entry'); expectEndpoint(str_contains($access, "self::allowed(\$actor, \$info, 'tcm.diagnosis/followupAudio')"), 'fresh independent audio permission at every service entry');
expectEndpoint(str_contains($access, "\$task['patient_id'] !== (int) (\$diagnosis['patient_id']"), 'historical patient rebind guard'); expectEndpoint(str_contains($access, "\$task['patient_id'] !== (int) (\$diagnosis['patient_id']"), 'historical patient rebind guard');
+3 -2
View File
@@ -79,7 +79,8 @@ try {
'detail', 'saveDraft', 'apply', 'retry', 'audio', 'delete', 'reextract']; 'detail', 'saveDraft', 'apply', 'retry', 'audio', 'delete', 'reextract'];
foreach ($actions as $action) { foreach ($actions as $action) {
foreach ([[], ['tcm.diagnosis/edit'], ['tcm.diagnosis/followupAudio'], foreach ([[], ['tcm.diagnosis/edit'], ['tcm.diagnosis/followupAudio'],
['tcm.diagnosis/edit', 'tcm.diagnosis/followupAudio']] as $uris) { ['tcm.diagnosis/edit', 'tcm.diagnosis/followupAudio'],
['tcm.diagnosis/edit', 'tcm.diagnosis/followupAudio', 'tcm.followupAudio/delete']] as $uris) {
FollowupPermissionAuthCacheDouble::$uris = $uris; FollowupPermissionAuthCacheDouble::$uris = $uris;
$route = new class($action, $request->ip()) { $route = new class($action, $request->ip()) {
public array $adminInfo; public array $adminInfo;
@@ -93,7 +94,7 @@ try {
public function param($name, $default = null) { return $default; } public function param($name, $default = null) { return $default; }
}; };
$result = $middleware->handle($route, static fn () => 'ALLOWED'); $result = $middleware->handle($route, static fn () => 'ALLOWED');
$expected = count($uris) === 2; $expected = count($uris) >= 2 && ($action !== 'delete' || in_array('tcm.followupAudio/delete', $uris, true));
$expect(($result === 'ALLOWED') === $expected, 'middleware ' . $action . ' permission set ' . json_encode($uris)); $expect(($result === 'ALLOWED') === $expected, 'middleware ' . $action . ' permission set ' . json_encode($uris));
if (!$expected) { $expect($result->getData()['code'] === 0, 'middleware failure envelope'); } if (!$expected) { $expect($result->getData()['code'] === 0, 'middleware failure envelope'); }
} }
+2 -2
View File
@@ -47,9 +47,9 @@ function followupAudioTestDatabase(array $settings = []): array
$pdo->exec('CREATE TABLE zyt_admin_dept(admin_id INT,dept_id INT)'); $pdo->exec('CREATE TABLE zyt_admin_dept(admin_id INT,dept_id INT)');
$pdo->exec('CREATE TABLE zyt_admin_jobs(admin_id INT,jobs_id INT)'); $pdo->exec('CREATE TABLE zyt_admin_jobs(admin_id INT,jobs_id INT)');
$pdo->exec('CREATE TABLE zyt_system_menu(id INT PRIMARY KEY,perms VARCHAR(100),is_disable INT)'); $pdo->exec('CREATE TABLE zyt_system_menu(id INT PRIMARY KEY,perms VARCHAR(100),is_disable INT)');
$pdo->exec("INSERT INTO zyt_system_menu VALUES(1,'tcm.diagnosis/edit',0),(2,'tcm.diagnosis/dailyRecord',0),(3,'firstvisit.wecomPromotion/overview',0),(4,'tcm.diagnosis/followupAudio',0)"); $pdo->exec("INSERT INTO zyt_system_menu VALUES(1,'tcm.diagnosis/edit',0),(2,'tcm.diagnosis/dailyRecord',0),(3,'firstvisit.wecomPromotion/overview',0),(4,'tcm.diagnosis/followupAudio',0),(5,'tcm.followupAudio/delete',0)");
$pdo->exec('CREATE TABLE zyt_system_role_menu(role_id INT,menu_id INT)'); $pdo->exec('CREATE TABLE zyt_system_role_menu(role_id INT,menu_id INT)');
$pdo->exec('INSERT INTO zyt_system_role_menu VALUES(2,1),(2,2),(2,3),(2,4),(5,1),(5,3),(5,4)'); $pdo->exec('INSERT INTO zyt_system_role_menu VALUES(2,1),(2,2),(2,3),(2,4),(2,5),(5,1),(5,3),(5,4),(5,5)');
$pdo->exec('CREATE TABLE zyt_doctor_appointment(id INT PRIMARY KEY,patient_id INT,doctor_id INT,status INT)'); $pdo->exec('CREATE TABLE zyt_doctor_appointment(id INT PRIMARY KEY,patient_id INT,doctor_id INT,status INT)');
$pdo->exec('CREATE TABLE zyt_dict_data(id INT PRIMARY KEY AUTO_INCREMENT,name VARCHAR(50),value VARCHAR(50),type_value VARCHAR(50),status INT,sort INT)'); $pdo->exec('CREATE TABLE zyt_dict_data(id INT PRIMARY KEY AUTO_INCREMENT,name VARCHAR(50),value VARCHAR(50),type_value VARCHAR(50),status INT,sort INT)');
$pdo->exec("INSERT INTO zyt_dict_data(name,value,type_value,status,sort) VALUES('Synthetic Enabled','dry','appetite',1,1),('Synthetic Disabled','bad','appetite',0,2)"); $pdo->exec("INSERT INTO zyt_dict_data(name,value,type_value,status,sort) VALUES('Synthetic Enabled','dry','appetite',1,1),('Synthetic Disabled','bad','appetite',0,2)");