Files
zyt/server/tests/FollowupAudioDeletePermissionTest.php
T

221 lines
14 KiB
PHP

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