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', ' $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); } }