126 lines
7.8 KiB
PHP
126 lines
7.8 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace app\common\service\followupaudio {
|
|
// This test exercises the real file/DB pipeline, with a deterministic synthetic row-scope boundary.
|
|
final class FollowupAudioAccess
|
|
{
|
|
public static function diagnosis(int $id, int $actor, array $info, bool $daily = false): array
|
|
{
|
|
if ($actor !== 7 || $id !== 91) {
|
|
throw new \DomainException('synthetic row scope denied');
|
|
}
|
|
return ['id' => 91, 'patient_id' => 101];
|
|
}
|
|
}
|
|
}
|
|
namespace {
|
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
|
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
|
|
$port = (int) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PORT');
|
|
if ($port <= 0 || $port === 3306 || getenv('FOLLOWUP_AUDIO_TEST_ALLOW_DISPOSABLE') !== '1') {
|
|
throw new \RuntimeException('Explicit local disposable MySQL port and acknowledgement required');
|
|
}
|
|
$database = 'fa_upload_' . bin2hex(random_bytes(6));
|
|
$password = (string) getenv('FOLLOWUP_AUDIO_TEST_MYSQL_PASSWORD');
|
|
$pdo = new \PDO("mysql:host=127.0.0.1;port={$port};charset=utf8mb4", 'root', $password, [\PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION]);
|
|
$pdo->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4");
|
|
$app = new \think\App(); // No initialize(), .env or deployment database configuration.
|
|
$config = new \think\Config(); \think\Container::getInstance()->instance('config', $config);
|
|
$manager = new \think\DbManager();
|
|
$manager->setConfig(['default' => 'mysql', 'connections' => ['mysql' => [
|
|
'type' => 'mysql', 'hostname' => '127.0.0.1', 'hostport' => $port,
|
|
'database' => $database, 'username' => 'root', 'password' => $password,
|
|
'charset' => 'utf8mb4', 'prefix' => 'far_', 'debug' => false,
|
|
]]]);
|
|
\think\Container::getInstance()->instance('think\DbManager', $manager);
|
|
set_exception_handler(static function (\Throwable $e): void { fwrite(STDERR, get_class($e) . ': ' . $e->getMessage() . PHP_EOL . $e->getTraceAsString() . PHP_EOL); exit(1); });
|
|
$dir = sys_get_temp_dir() . '/followup-upload-test-' . bin2hex(random_bytes(8));
|
|
mkdir($dir, 0700, true);
|
|
$app->config->set(['private_dir' => $dir . '/private', 'max_bytes' => 524288000,
|
|
'max_seconds' => 3600, 'chunk_bytes' => 65536, 'ffprobe' => 'ffprobe'], 'followup_audio');
|
|
$db = \think\facade\Db::class;
|
|
$db::execute('CREATE TABLE IF NOT EXISTS far_followup_audio_upload (
|
|
id VARCHAR(64) PRIMARY KEY, diagnosis_id BIGINT NOT NULL, actor_id BIGINT NOT NULL,
|
|
file_name VARCHAR(255) NOT NULL, extension VARCHAR(10) NOT NULL, total_bytes BIGINT NOT NULL,
|
|
received_bytes BIGINT NOT NULL DEFAULT 0, sha256 VARCHAR(64) NOT NULL DEFAULT "",
|
|
duration_seconds DECIMAL(12,3) NOT NULL DEFAULT 0, status VARCHAR(32) NOT NULL,
|
|
created_at BIGINT NOT NULL, expires_at BIGINT NOT NULL) ENGINE=InnoDB');
|
|
$checks = 0;
|
|
$ids = [];
|
|
$ok = function (bool $condition, string $message) use (&$checks): void {
|
|
if (!$condition) {
|
|
throw new \RuntimeException($message);
|
|
}
|
|
++$checks;
|
|
};
|
|
$reject = function (callable $f, string $message) use ($ok): void {
|
|
try { $f(); } catch (\DomainException $e) { $ok(true, $message); return; }
|
|
$ok(false, $message);
|
|
};
|
|
$upload = \app\common\service\followupaudio\FollowupAudioUpload::class;
|
|
try {
|
|
foreach (['../a.wav', 'a/b.wav', 'a\\b.wav', "x\0.mp3", 'x.php', 'x.MP4'] as $bad) {
|
|
$reject(fn () => $upload::fileName($bad), 'bad filename was accepted');
|
|
}
|
|
$ok($upload::fileName('回访.WAV')[1] === 'wav', 'uppercase extension normalization');
|
|
$reject(fn () => $upload::createSession(92, 'x.wav', 100, 7, []), 'wrong diagnosis');
|
|
$reject(fn () => $upload::createSession(91, 'x.wav', 0, 7, []), 'empty file');
|
|
$reject(fn () => $upload::createSession(91, 'x.wav', 524288001, 7, []), 'oversized file');
|
|
// 3 seconds of valid 16 kHz PCM audio, with actual RIFF metadata (multiple 64KiB test chunks).
|
|
$pcm = str_repeat(pack('v', 1000), 16000 * 3);
|
|
$wave = 'RIFF' . pack('V', 36 + strlen($pcm)) . 'WAVEfmt ' . pack('VvvVVvv', 16, 1, 1, 16000, 32000, 2, 16)
|
|
. 'data' . pack('V', strlen($pcm)) . $pcm;
|
|
file_put_contents($dir . '/source.wav', $wave);
|
|
$meta = $upload::inspect($dir . '/source.wav', 'wav');
|
|
$ok(abs($meta['duration_seconds'] - 3) < 0.01, 'real ffprobe duration');
|
|
$reject(fn () => $upload::inspect($dir . '/source.wav', 'mp3'), 'extension/content mismatch');
|
|
file_put_contents($dir . '/fake.wav', '<?php echo "not audio";');
|
|
$reject(fn () => $upload::inspect($dir . '/fake.wav', 'wav'), 'fake audio was accepted');
|
|
$session = $upload::createSession(91, '回访.wav', strlen($wave), 7, []);
|
|
$id = $session['upload_id']; $ids[] = $id;
|
|
$ok((bool) preg_match('/^[a-f0-9]{48}$/D', $id), 'unguessable session ID');
|
|
$reject(fn () => $upload::owned($id, 8, []), 'foreign actor');
|
|
$reject(fn () => $upload::session('../outside'), 'path traversal');
|
|
$reject(fn () => $upload::complete($id, 7, []), 'incomplete merge');
|
|
$chunks = str_split($wave, $session['chunk_bytes']);
|
|
foreach ($chunks as $index => $contents) {
|
|
file_put_contents($dir . '/part', $contents);
|
|
$upload::putChunk($id, $index, $dir . '/part', 7, []);
|
|
$upload::putChunk($id, $index, $dir . '/part', 7, []); // Exact repeat is idempotent.
|
|
}
|
|
$ok((int) $upload::session($id)['received_bytes'] === strlen($wave), 'repeated chunks counted twice');
|
|
file_put_contents($dir . '/part', str_repeat('x', strlen($chunks[0])));
|
|
$reject(fn () => $upload::putChunk($id, 0, $dir . '/part', 7, []), 'conflicting chunk');
|
|
$reject(fn () => $upload::putChunk($id, 999, $dir . '/part', 7, []), 'invalid chunk index');
|
|
$result = $upload::complete($id, 7, []);
|
|
$ok($result['sha256'] === hash('sha256', $wave), 'assembled bytes differ');
|
|
$ok($upload::complete($id, 7, []) === $result, 'complete not idempotent');
|
|
$row = $upload::session($id);
|
|
$path = $upload::path($row);
|
|
$ok(file_get_contents($path) === $wave, 'private file mismatch');
|
|
$response = new \app\common\service\followupaudio\FollowupAudioStream($path, 'wav');
|
|
$ok($response->getHeader('Cache-Control') === 'private, no-store, max-age=0', 'private response cached');
|
|
$send = new \ReflectionMethod($response, 'sendData');
|
|
$send->setAccessible(true); ob_start(); $send->invoke($response, ''); $bytes = ob_get_clean();
|
|
$ok($bytes === $wave, 'streamed response differs');
|
|
$reject(fn () => $upload::cleanup($id), 'early cleanup accepted');
|
|
$db::name('followup_audio_upload')->where('id', $id)->update(['expires_at' => time() - 1]);
|
|
$reject(fn () => $upload::path($upload::session($id)), 'expired audio still playable');
|
|
$upload::cleanup($id);
|
|
$ok(!file_exists($path) && $upload::session($id)['status'] === 'deleted', 'expired original not deleted');
|
|
$upload::cleanup($id);
|
|
$ok($upload::session($id)['status'] === 'deleted', 'cleanup repeat is idempotent');
|
|
$db::name('followup_audio_upload')->where('id', $id)->update(['status' => 'complete']);
|
|
$upload::cleanup($id);
|
|
$ok($upload::session($id)['status'] === 'deleted', 'missing directory after DB rollback can be reconciled');
|
|
echo "Followup audio private upload: {$checks} checks passed\n";
|
|
} finally {
|
|
$pdo->exec('DROP DATABASE `' . $database . '`');
|
|
$files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST);
|
|
foreach ($files as $f) { $f->isDir() && !$f->isLink() ? rmdir($f->getPathname()) : unlink($f->getPathname()); }
|
|
rmdir($dir);
|
|
}
|
|
}
|