Files
zyt/server/tests/FollowupAudioUploadTest.php
T

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);
}
}