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