Files
zyt/server/tests/MobileRecordingBindingTest.php
T
2026-11-09 11:56:28 +08:00

160 lines
11 KiB
PHP

<?php
declare(strict_types=1);
/** Disposable in-memory synthetic database only. No .env, services or production connection. */
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\adminapi\logic\tcm\MobileRecordingLogic as Logic;
use think\Container;
use think\facade\Db;
if (!in_array('sqlite', PDO::getAvailableDrivers(), true)) {
fwrite(STDERR, "SQLite PDO is required; no production fallback is allowed.\n");
exit(1);
}
new think\App(); // Do not initialize application services or load configuration.
$manager = new think\DbManager();
$manager->setConfig(['default' => 'sqlite', 'auto_timestamp' => false, 'datetime_format' => false,
'connections' => ['sqlite' => ['type' => 'sqlite', 'database' => ':memory:', 'prefix' => 'zyt_']]]);
Container::getInstance()->instance('think\DbManager', $manager);
$config = new think\Config();
$config->set(['data_scope' => ['enabled' => false]], 'project');
Container::getInstance()->instance('config', $config);
foreach ([
'CREATE TABLE zyt_admin (id INTEGER PRIMARY KEY, root INT, name TEXT, disable INT, delete_time INT)',
'CREATE TABLE zyt_admin_role (admin_id INT, role_id INT)',
'CREATE TABLE zyt_system_role_menu (role_id INT, menu_id INT)',
'CREATE TABLE zyt_system_menu (id INTEGER PRIMARY KEY, perms TEXT, is_disable INT)',
'CREATE TABLE zyt_tcm_diagnosis (id INTEGER PRIMARY KEY, patient_id INT, patient_name TEXT, phone TEXT, assistant_id INT, status INT, delete_time INT)',
'CREATE TABLE zyt_doctor_appointment (id INTEGER PRIMARY KEY, patient_id INT, doctor_id INT, status INT)',
'CREATE TABLE zyt_mobile_recording_binding (id INTEGER PRIMARY KEY AUTOINCREMENT, admin_id INT NOT NULL,
installation_id TEXT NOT NULL, recording_key TEXT NOT NULL, diagnosis_id INT NOT NULL, source_platform TEXT NOT NULL,
recorded_at INT NOT NULL, duration_ms INT NOT NULL, created_at INT NOT NULL, updated_at INT NOT NULL,
UNIQUE(admin_id, installation_id, recording_key))',
] as $ddl) {
Db::execute($ddl);
}
require __DIR__ . '/fixtures/mobile_recording_media_schema.php';
Db::name('admin')->insertAll([
['id' => 1, 'root' => 1, 'name' => '合成管理员', 'disable' => 0],
['id' => 11, 'root' => 0, 'name' => '合成助理甲', 'disable' => 0],
['id' => 12, 'root' => 0, 'name' => '合成助理乙', 'disable' => 0],
['id' => 13, 'root' => 0, 'name' => '合成医生', 'disable' => 0],
['id' => 14, 'root' => 0, 'name' => '合成停用账号', 'disable' => 1],
['id' => 15, 'root' => 0, 'name' => '合成无权限账号', 'disable' => 0],
]);
Db::name('admin_role')->insertAll([
['admin_id' => 11, 'role_id' => 2], ['admin_id' => 12, 'role_id' => 2],
['admin_id' => 13, 'role_id' => 1], ['admin_id' => 14, 'role_id' => 2],
]);
Db::name('system_menu')->insert(['id' => 1, 'perms' => Logic::PAGE_PERMISSION, 'is_disable' => 0]);
Db::name('system_role_menu')->insertAll([['role_id' => 1, 'menu_id' => 1], ['role_id' => 2, 'menu_id' => 1]]);
Db::name('tcm_diagnosis')->insertAll([
['id' => 101, 'patient_id' => 501, 'patient_name' => '合成患者甲', 'phone' => '13800000001', 'assistant_id' => 11, 'status' => 1, 'delete_time' => null],
['id' => 102, 'patient_id' => 502, 'patient_name' => '合成患者乙', 'phone' => '13800000002', 'assistant_id' => 11, 'status' => 1, 'delete_time' => null],
['id' => 103, 'patient_id' => 503, 'patient_name' => '合成患者丙', 'phone' => '13800000003', 'assistant_id' => 12, 'status' => 1, 'delete_time' => null],
['id' => 104, 'patient_id' => 504, 'patient_name' => '合成停用患者', 'phone' => '', 'assistant_id' => 11, 'status' => 0, 'delete_time' => null],
['id' => 105, 'patient_id' => 505, 'patient_name' => '合成已删患者', 'phone' => '', 'assistant_id' => 11, 'status' => 1, 'delete_time' => 1],
['id' => 106, 'patient_id' => 506, 'patient_name' => '合成接诊患者', 'phone' => '', 'assistant_id' => 12, 'status' => 1, 'delete_time' => null],
]);
Db::name('doctor_appointment')->insert(['id' => 1, 'patient_id' => 106, 'doctor_id' => 13, 'status' => 3]);
$checks = 0;
$expect = static function (bool $ok, string $why) use (&$checks): void {
if (!$ok) { throw new RuntimeException($why); }
$checks++;
};
$denied = static function (callable $call, string $why) use ($expect): void {
try { $call(); } catch (DomainException $e) { $expect(true, $why); return; }
throw new RuntimeException('Expected denial: ' . $why);
};
$input = [
'installation_id' => '12345678-1234-4123-8123-123456789abc',
'recording_key' => str_repeat('a', 64), 'diagnosis_id' => 101,
'source_platform' => 'android_bcr', 'recorded_at' => 1700000000000, 'duration_ms' => 65000,
];
$identity = array_intersect_key($input, array_flip(['installation_id', 'recording_key']));
foreach ([
['admin_id' => 1], ['phone' => '13800000000'], ['file_name' => 'secret.wav'],
['audio_path' => '/private/audio'], ['transcript_text' => 'content'],
] as $extra) {
$denied(fn () => Logic::bindingInput($input + $extra), 'unexpected private or actor field rejected');
}
foreach ([
['installation_id' => 'not-a-uuid'], ['recording_key' => '../path'], ['diagnosis_id' => true],
['duration_ms' => 1.5], ['duration_ms' => -1], ['duration_ms' => 604800001],
['recorded_at' => (time() + 3600) * 1000], ['recorded_at' => '1e10'], ['source_platform' => 'other'],
] as $invalid) {
$denied(fn () => Logic::bindingInput(array_replace($input, $invalid)), 'invalid typed field rejected');
}
$denied(fn () => Logic::bind($input, 15), 'missing page permission');
$denied(fn () => Logic::bind($input, 14), 'disabled actor');
$denied(fn () => Logic::bind($input, 0), 'missing actor');
$denied(fn () => Logic::bind(array_replace($input, ['diagnosis_id' => 103]), 11), 'other assistant patient');
$denied(fn () => Logic::bind(array_replace($input, ['diagnosis_id' => 104]), 11), 'disabled patient');
$denied(fn () => Logic::bind(array_replace($input, ['diagnosis_id' => 105]), 11), 'deleted patient');
$created = Logic::bind($input, 11)['binding'];
$again = Logic::bind($input, 11)['binding'];
$expect($created['id'] === $again['id'] && Db::name('mobile_recording_binding')->count() === 1, 'repeat bind is idempotent');
$expect($created['patient']['diagnosis_id'] === 101 && $created['patient']['source_patient_id'] === 501, 'separate diagnosis/source patient identities');
$expect($created['patient']['phone_masked'] === '138****0001', 'masked phone only');
$expect(!str_contains(json_encode($created), '13800000001') && !isset($created['admin_id']), 'private data and actor ID omitted');
$other = Logic::bind(array_replace($input, ['diagnosis_id' => 103]), 12)['binding'];
$expect($other['id'] !== $created['id'], 'unique key includes current actor');
Logic::unbind($identity, 12);
Logic::unbind($identity, 12);
$expect(Db::name('mobile_recording_binding')->where('id', $created['id'])->count() === 1, 'another actor unbind cannot touch original row');
$denied(fn () => Logic::bind(array_replace($input, ['diagnosis_id' => 103]), 11), 'replacement target is checked');
$replaced = Logic::bind(array_replace($input, ['diagnosis_id' => 102]), 11)['binding'];
$expect($replaced['id'] === $created['id'] && $replaced['diagnosis_id'] === 102, 'replacement updates existing identity');
$second = array_replace($input, ['recording_key' => str_repeat('b', 64)]);
Logic::bind($second, 11);
$device = ['installation_id' => $input['installation_id']];
$page = Logic::deviceLists($device + ['page_no' => 1, 'page_size' => 1], 11);
$expect($page['count'] === 2 && count($page['lists']) === 1 && $page['page_size'] === 1, 'bounded device pagination');
$expect(Logic::deviceLists(['installation_id' => '22345678-1234-4123-8123-123456789abc'], 11)['count'] === 0, 'device isolation');
$expect(Logic::deviceLists($device, 12)['count'] === 0, 'account isolation');
$denied(fn () => Logic::patientLists(['diagnosis_id' => 102], 12), 'patient listing scope');
$records = Logic::patientLists(['diagnosis_id' => 102], 11);
$record = $records['lists'][0];
$expect($record['bound_by'] === '合成助理甲' && $record['playback_location'] === 'original_device', 'archive playback and actor metadata');
$expect(!isset($record['installation_id'], $record['recording_key']) && !isset($record['patient_name']), 'archive omits device keys and unnecessary patient names');
$denied(fn () => Logic::deviceLists($device + ['page_size' => 101], 11), 'page limit');
$doctor = Logic::bind(array_replace($input, ['diagnosis_id' => 106]), 13);
$expect($doctor['binding']['diagnosis_id'] === 106, 'doctor uses own effective appointment');
$denied(fn () => Logic::bind($input, 13), 'doctor cannot use unrelated patient');
Logic::bind(array_replace($input, ['diagnosis_id' => 103]), 1);
$expect(Logic::patientLists(['diagnosis_id' => 103], 1)['count'] === 1, 'root uses same metadata contract');
Db::name('tcm_diagnosis')->where('id', 102)->update(['assistant_id' => 12]);
$denied(fn () => Logic::bind($input, 11), 'old patient checked on reassignment after scope loss');
$denied(fn () => Logic::unbind($identity, 11), 'old patient checked on unlink after scope loss');
$afterTransfer = Logic::deviceLists($device, 11);
$expect($afterTransfer['count'] === 1 && !str_contains(json_encode($afterTransfer, JSON_UNESCAPED_UNICODE), '合成患者乙'), 'lost-scope row and patient name hidden');
Db::name('tcm_diagnosis')->where('id', 101)->update(['delete_time' => 2]);
$expect(Logic::deviceLists($device, 11)['count'] === 0, 'deleted diagnosis hidden on restore');
$denied(fn () => Logic::patientLists(['diagnosis_id' => 101], 11), 'deleted patient archive denied');
Db::name('tcm_diagnosis')->where('id', 101)->update(['delete_time' => null]);
Logic::unbind(array_replace($identity, ['recording_key' => str_repeat('b', 64)]), 11);
$expect(Logic::deviceLists($device, 11)['count'] === 0, 'authorized unbind removes association');
Db::name('system_menu')->where('id', 1)->update(['is_disable' => 1]);
$denied(fn () => Logic::deviceLists($device, 11), 'revoked page permission is re-read');
Db::name('admin')->where('id', 1)->update(['root' => 0]);
$denied(fn () => Logic::deviceLists($device, 1), 'revoked root is re-read');
// Guard the integration boundary which runtime service tests cannot exercise without an HTTP app.
$controller = file_get_contents(dirname(__DIR__) . '/app/adminapi/controller/tcm/MobileRecordingController.php');
$middleware = file_get_contents(dirname(__DIR__) . '/app/adminapi/http/middleware/AuthMiddleware.php');
$expect(str_contains($controller, 'MobileRecordingLogic::actor((int) $this->adminId)'), 'controller explicitly checks page grant');
$expect(str_contains($controller, '$this->request->file()'), 'controller refuses binary upload');
$expect(str_contains($middleware, "'tcm.mobilerecording/'")
&& strpos($middleware, "'tcm.mobilerecording/'") < strpos($middleware, '$allUri ='), 'middleware guards before legacy unregistered-route bypass');
echo "Mobile recording binding: {$checks} checks passed (synthetic in-memory SQLite).\n";