160 lines
11 KiB
PHP
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";
|