221 lines
14 KiB
PHP
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);
|