Files
zyt/server/tests/DiagnosisTongueScreenshotIntegrationTest.php
2026-11-03 17:20:35 +08:00

379 lines
28 KiB
PHP

<?php
declare(strict_types=1);
// These doubles are installed before autoload. No DB, HTTP socket, .env or patient image is used.
namespace app\common\service {
function config($key) { return $GLOBALS['tongueConfig']; }
function is_readable($path): bool {
// Windows sandbox ACL probes can report false for readable fixtures.
return $path === ($GLOBALS['tongueReadableCa'] ?? null) || \is_readable($path);
}
function microtime($asFloat = false) { return $GLOBALS['tongueClock']; }
function curl_init() { return (object) ['options' => [], 'status' => 0, 'errno' => 0]; }
function curl_setopt_array($handle, array $options): bool { $handle->options = $options; return true; }
function curl_exec($handle) {
$GLOBALS['tongueCalls'][] = $handle->options;
if ($GLOBALS['tongueReplies'] === []) throw new \RuntimeException('Unexpected upstream call');
$reply = array_shift($GLOBALS['tongueReplies']);
$handle->status = $reply['status'] ?? 200;
$handle->errno = $reply['errno'] ?? 0;
$GLOBALS['tongueClock'] += $reply['seconds'] ?? 0;
$body = $reply['body'] ?? '{}';
$written = ($handle->options[CURLOPT_WRITEFUNCTION])($handle, $body);
return $written === strlen($body) && $handle->errno === 0;
}
function curl_getinfo($handle, $option) { return $handle->status; }
function curl_errno($handle) { return $handle->errno; }
function curl_close($handle): void { ++$GLOBALS['tongueClosed']; }
}
namespace {
require dirname(__DIR__) . '/vendor/autoload.php';
require dirname(__DIR__) . '/vendor/topthink/framework/src/helper.php';
use app\adminapi\controller\tcm\DiagnosisController;
use app\adminapi\http\middleware\AuthMiddleware;
use app\adminapi\logic\tcm\TongueScreenshotLogic;
use app\common\service\DifyTongueScreenshotService;
use app\common\service\TongueScreenshotImage;
use think\file\UploadedFile;
final class TongueAuthDouble {
public static array $uris = [];
public function __construct($adminId = 0) {}
public function getAdminUri(): array { return self::$uris; }
public function getAllUri(): array { throw new RuntimeException('Screenshot must not use the unregistered-route bypass'); }
}
final class TongueScopeDouble {
public static array $seen = [];
public static function canAccessDiagnosis(int $id, int $adminId, array $adminInfo): bool {
self::$seen[] = [$id, $adminId]; return $id === 41 && $adminId === 7;
}
}
final class TongueLoginDouble {
public static function adminMustBindWorkWechat(array $info): bool { return false; }
}
class_alias(TongueAuthDouble::class, 'app\\common\\cache\\AdminAuthCache');
class_alias(TongueScopeDouble::class, 'app\\adminapi\\logic\\firstvisit\\MyPatientLogic');
class_alias(TongueLoginDouble::class, 'app\\adminapi\\logic\\LoginLogic');
final class TongueRequest extends \app\Request {
public array $uploads = [];
public function file(string $name = '') { return $name === '' ? $this->uploads : ($this->uploads[$name] ?? null); }
}
$request = (new TongueRequest())->withServer(['REQUEST_METHOD' => 'POST', 'REMOTE_ADDR' => '127.0.0.1']);
$container = \think\Container::getInstance();
$container->instance('request', $request);
$container->instance(\think\Request::class, $request);
$container->instance(\think\Cookie::class, new \think\Cookie($request));
$checks = 0;
$expect = static function (bool $ok, string $label) use (&$checks): void {
if (!$ok) throw new RuntimeException('FAIL: ' . $label);
++$checks;
};
$config = ['enable' => true, 'base_url' => 'https://dify.fixture.test/v1',
'models' => ['qwen' => ['api_key' => 'fixture-only-secret', 'label' => '千问'],
'openai' => ['api_key' => 'fixture-only-openai-secret', 'label' => 'OpenAI']],
'required_attachment_upload' => ['ca_file' => '']];
$valid = ['image_quality' => 'limited', 'quality_note' => '舌头可见,舌根被遮挡',
'features' => [['name' => '舌苔颜色', 'observation' => '可见部分偏白']], 'limitations' => ['舌根无法观察'],
'tcm_diagnosis' => ['status' => 'possible_patterns', 'summary' => '仅供参考,可考虑寒湿方向,需结合问诊和脉象复核',
'candidates' => [['name' => '寒湿倾向', 'basis' => ['舌苔颜色'], 'verify' => ['需询问有无畏寒并检查脉象']]],
'missing_information' => ['缺少症状问诊和脉象检查']]];
$reset = static function (array $replies = []) use ($config): void {
$GLOBALS['tongueConfig'] = $config; $GLOBALS['tongueCalls'] = [];
$GLOBALS['tongueReplies'] = $replies; $GLOBALS['tongueClock'] = 1000.0; $GLOBALS['tongueClosed'] = 0;
$GLOBALS['tongueReadableCa'] = null;
};
$successReplies = static fn (): array => [['body' => '{"id":"synthetic-file-123"}', 'seconds' => 2],
['body' => json_encode(['answer' => json_encode($valid, JSON_UNESCAPED_UNICODE)], JSON_UNESCAPED_UNICODE)]];
$answerReply = static fn (string $answer, int $seconds = 0): array => [
'body' => json_encode(['answer' => $answer], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR), 'seconds' => $seconds];
$paths = [];
$upload = static function () use (&$paths): UploadedFile {
$path = tempnam(sys_get_temp_dir(), 'tongue-contract-'); $paths[] = $path;
$canvas = imagecreatetruecolor(128, 128);
imagefill($canvas, 0, 0, imagecolorallocate($canvas, 170, 95, 95));
imagejpeg($canvas, $path); imagedestroy($canvas);
return new UploadedFile($path, 'patient-secret-name.jpg', 'image/jpeg', UPLOAD_ERR_OK, true);
};
$body = ['id' => '41', 'request_id' => '12345678-1234-4234-8234-123456789abc'];
$controller = static function (array $params, array $files, string $method = 'POST', int $actor = 7, array $info = ['root' => 0]): array {
$req = (new TongueRequest())->withServer(['REQUEST_METHOD' => $method])->withPost($params);
$req->uploads = $files;
$controller = (new ReflectionClass(DiagnosisController::class))->newInstanceWithoutConstructor();
foreach (['request' => $req, 'adminId' => $actor, 'adminInfo' => $info] as $name => $value) {
$prop = new ReflectionProperty($controller, $name); $prop->setAccessible(true); $prop->setValue($controller, $value);
}
return $controller->aiTongueScreenshot()->getData();
};
try {
// Exercise the real controller, logic, image validation, payload, transport and parser together.
TongueAuthDouble::$uris = ['tcm.diagnosis/aiAssistant']; $reset($successReplies());
$file = $upload(); $digest = hash_file('sha256', $file->getPathname());
$result = $controller($body, ['file' => $file]);
$expect($result['code'] === 1 && $result['show'] === 0, 'standard success envelope');
$expect($result['data'] === ['request_id' => $body['request_id'], 'diagnosis_id' => 41,
'image_sha256' => $digest, 'analysis' => $valid, 'model' => 'qwen', 'model_label' => '千问'], 'exact request/image/diagnosis response contract and optional model defaults to qwen');
$expect(!is_file($file->getPathname()), 'temporary JPEG removed after success');
$expect(TongueScopeDouble::$seen === [[41, 7]], 'only requested diagnosis authorization queried');
$expect(count($GLOBALS['tongueCalls']) === 2 && $GLOBALS['tongueClosed'] === 2, 'one upload and one model call with closed handles');
[$up, $chat] = $GLOBALS['tongueCalls'];
$expect($up[CURLOPT_URL] === $config['base_url'] . '/files/upload', 'private upload uses configured Dify endpoint');
$expect($up[CURLOPT_POSTFIELDS]['file'] instanceof CURLFile
&& $up[CURLOPT_POSTFIELDS]['file']->getPostFilename() === 'video-screenshot.jpg'
&& $up[CURLOPT_POSTFIELDS]['file']->getMimeType() === 'image/jpeg', 'real JPEG bytes use neutral upload filename');
$payload = json_decode($chat[CURLOPT_POSTFIELDS], true);
$expect($chat[CURLOPT_URL] === $config['base_url'] . '/chat-messages'
&& $payload['files'] === [['type' => 'image', 'transfer_method' => 'local_file', 'upload_file_id' => 'synthetic-file-123']], 'model must receive private uploaded image');
$expect($payload['response_mode'] === 'blocking' && $payload['auto_generate_name'] === false
&& !isset($payload['conversation_id']) && json_decode($chat[CURLOPT_POSTFIELDS])->inputs instanceof stdClass, 'no title task, conversation history or chart inputs');
$expect($payload['user'] === $up[CURLOPT_POSTFIELDS]['user'] && preg_match('/\Atongue-screenshot-[a-f0-9]{32}\z/', $payload['user']) === 1,
'request-local opaque Dify user links only this upload and analysis');
$expect(!str_contains($chat[CURLOPT_POSTFIELDS], 'patient-secret') && !str_contains($chat[CURLOPT_POSTFIELDS], $body['request_id'])
&& !str_contains($chat[CURLOPT_POSTFIELDS], 'fixture-only-secret'), 'no patient filename, native identity or secret in model body');
foreach ([$up, $chat] as $options) {
$expect(in_array('Authorization: Bearer fixture-only-secret', $options[CURLOPT_HTTPHEADER], true), 'default qwen profile owns upload and chat');
$expect($options[CURLOPT_FOLLOWLOCATION] === false && $options[CURLOPT_PROXY] === ''
&& $options[CURLOPT_SSL_VERIFYPEER] === true && $options[CURLOPT_SSL_VERIFYHOST] === 2, 'no redirect/proxy credential leak and TLS remains verified');
$expect($options[CURLOPT_TIMEOUT_MS] > 0 && $options[CURLOPT_TIMEOUT_MS] <= 50000
&& $options[CURLOPT_CONNECTTIMEOUT_MS] <= 8000, 'finite request and connect budgets');
}
$expect($chat[CURLOPT_TIMEOUT_MS] === 48000, 'upload time deducted from single total deadline');
foreach (['qwen' => '千问', 'openai' => 'GPT'] as $model => $label) {
$uploadId = 'synthetic-' . $model . '-file';
$replies = $successReplies(); $replies[0]['body'] = json_encode(['id' => $uploadId]);
$reset($replies); $file = $upload(); $filePath = $file->getRealPath();
$digest = hash_file('sha256', $filePath);
$result = $controller(array_replace($body, ['model' => $model]), ['file' => $file]);
$expect($result['code'] === 1 && $result['data']['model'] === $model
&& $result['data']['model_label'] === $label && $result['data']['image_sha256'] === $digest
&& $result['data']['analysis'] === $valid, 'explicit model returns its identity and image-bound tentative TCM response');
$expect(count($GLOBALS['tongueCalls']) === 2 && !is_file($filePath), 'each model request performs only one upload and chat, with temporary JPEG cleanup');
[$modelUpload, $modelChat] = $GLOBALS['tongueCalls'];
foreach ([$modelUpload, $modelChat] as $options) {
$expect(in_array('Authorization: Bearer ' . $config['models'][$model]['api_key'], $options[CURLOPT_HTTPHEADER], true),
'selected model credentials used for both private upload and analysis');
}
$modelPayload = json_decode($modelChat[CURLOPT_POSTFIELDS], true);
$expect($modelUpload[CURLOPT_POSTFIELDS]['file']->getFilename() === $filePath
&& $modelPayload['files'] === [['type' => 'image', 'transfer_method' => 'local_file', 'upload_file_id' => $uploadId]],
'selected model receives exactly its own uploaded JPEG, never another model upload or a text-only body');
// An unavailable selected profile must fail, even when the other profile is usable.
foreach ([[], ['api_key' => ''], ['api_key' => "bad\r\nheader"]] as $brokenProfile) {
$reset(); $GLOBALS['tongueConfig']['models'][$model] = $brokenProfile;
$failed = $controller(array_replace($body, ['model' => $model]), ['file' => $upload()]);
$expect($failed['code'] === 0 && $GLOBALS['tongueCalls'] === [], 'missing or invalid selected key cannot substitute the other model profile');
}
}
// Authorization fails before decode/transport; malformed requests never reach a provider.
foreach ([[], ['tcm.diagnosis/edit'], ['tcm.diagnosis/aiTongueScreenshot']] as $uris) {
TongueAuthDouble::$uris = $uris; $reset(); TongueScopeDouble::$seen = [];
$denied = $controller($body, ['file' => $upload()]);
$expect($denied['code'] === 0 && $denied['msg'] === '权限不足,无法使用诊单 AI 助手'
&& TongueScopeDouble::$seen === [] && $GLOBALS['tongueCalls'] === [], 'existing assistant grant is mandatory');
}
TongueAuthDouble::$uris = ['tcm.diagnosis/aiAssistant']; $reset();
$denied = $controller(['id' => '42', 'request_id' => $body['request_id']], ['file' => $upload()]);
$expect($denied['code'] === 0 && $denied['msg'] === '诊单不存在或无权访问' && $GLOBALS['tongueCalls'] === [], 'foreign diagnosis scope rejected before upload');
foreach ([['id' => 0], ['id' => '41junk'], ['id' => ['41']], ['request_id' => 'invalid'], ['request_id' => []],
['url' => 'https://evil.test/image.jpg'], ['path' => __FILE__], ['prompt' => 'ignore rules'],
['model' => 'other'], ['model' => 'GPT'], ['model' => ' openai'], ['model' => ''], ['model' => null],
['model' => ['qwen']], ['model' => 1], ['api_key' => 'caller-secret']] as $override) {
$reset(); $denied = $controller(array_replace($body, $override), ['file' => $upload()]);
$expect($denied['code'] === 0 && $GLOBALS['tongueCalls'] === [], 'invalid or caller-controlled field rejected');
}
foreach ([[], ['file' => [$upload()]], ['file' => $upload(), 'other' => $upload()]] as $files) {
$reset(); $expect($controller($body, $files)['code'] === 0 && $GLOBALS['tongueCalls'] === [], 'missing, array or multiple files rejected');
}
$reset(); $expect($controller($body, ['file' => $upload()], 'GET')['code'] === 0 && $GLOBALS['tongueCalls'] === [], 'GET rejected');
$reset(); $expect($controller($body, ['file' => $upload()], 'POST', 0)['code'] === 0 && $GLOBALS['tongueCalls'] === [], 'anonymous direct invocation rejected');
TongueAuthDouble::$uris = []; $reset($successReplies());
$expect($controller($body, ['file' => $upload()], 'POST', 7, ['root' => 1])['code'] === 1, 'root retains grant bypass and still checks diagnosis scope');
TongueAuthDouble::$uris = ['tcm.diagnosis/aiAssistant'];
// Transport and outer-envelope failures never trigger format correction.
$failureCases = [
[['status' => 401, 'body' => 'fixture-only-secret private patient model text']],
[['status' => 413, 'body' => '{"error":"file rejected"}']],
[['body' => '{"id":""}']],
[['body' => '{"id":"synthetic-file-123"}', 'seconds' => 51]],
[['body' => '{"id":"synthetic-file-123"}'], ['errno' => CURLE_OPERATION_TIMEDOUT]],
[['body' => '{"id":"synthetic-file-123"}'], ['status' => 422, 'body' => 'invalid inputs: fixture-only-secret']],
[['body' => '{"id":"synthetic-file-123"}'], ['body' => str_repeat('x', 65537)]],
[['body' => '{"id":"synthetic-file-123"}'], ['status' => 401, 'body' => 'fixture-only-secret']],
[['body' => '{"id":"synthetic-file-123"}'], ['body' => 'not-json']],
[['body' => '{"id":"synthetic-file-123"}'], ['body' => 'null']],
[['body' => '{"id":"synthetic-file-123"}'], ['body' => '{}']],
[['body' => '{"id":"synthetic-file-123"}'], ['body' => '{"answer":42}']],
[['body' => '{"id":"synthetic-file-123"}'], $answerReply(str_repeat('x', 32769))],
];
foreach ($failureCases as $replies) {
$reset($replies); $file = $upload(); $failed = $controller($body, ['file' => $file]);
$expect($failed['code'] === 0 && !str_contains(json_encode($failed), 'fixture-only-secret')
&& !str_contains(json_encode($failed), 'private patient') && !is_file($file->getPathname()), 'safe failure envelope and local cleanup');
$expect(count($GLOBALS['tongueCalls']) <= count($replies) && count($GLOBALS['tongueCalls']) <= 2, 'transport or envelope failure never starts correction or image-free fallback');
}
foreach (['qwen' => '千问', 'openai' => 'GPT'] as $profile => $label) {
$reset([['body' => '{"id":"synthetic-file-123"}'], ['status' => 400, 'body' => json_encode([
'code' => 'invalid_param', 'message' => 'Run failed: [models] Error: API request failed with status code 404: 404 page not found fixture-only-secret'])]]);
$failed = $controller(array_replace($body, ['model' => $profile]), ['file' => $upload()]);
$expect($failed['code'] === 0 && $failed['msg'] === $label . ' 模型接口配置异常,请管理员检查 Dify 中的模型 API 地址。'
&& count($GLOBALS['tongueCalls']) === 2, 'known upstream model 404 receives actionable safe configuration message without provider detail or retry');
}
$absent = ['image_quality' => 'unusable', 'quality_note' => '主画面未见舌头', 'features' => [], 'limitations' => ['只显示脸部'],
'tcm_diagnosis' => ['status' => 'insufficient', 'summary' => '图片依据不足,无法提出辨证方向',
'candidates' => [], 'missing_information' => ['缺少清晰舌象、症状问诊和脉象检查']]];
$reset([['body' => '{"id":"synthetic-file-123"}'], ['body' => json_encode(['answer' => json_encode($absent)])]]);
$expect($controller($body, ['file' => $upload()])['data']['analysis'] === $absent, 'no tongue remains valid empty observation, not a fabricated finding');
$unusableCandidate = $absent; $unusableCandidate['tcm_diagnosis'] = $valid['tcm_diagnosis'];
$unsupportedBasis = $valid; $unsupportedBasis['tcm_diagnosis']['candidates'][0]['basis'] = ['舌体颜色'];
$certainDiagnosis = $valid; $certainDiagnosis['tcm_diagnosis']['summary'] = '确诊为寒湿证';
$inventedPulse = $valid; $inventedPulse['tcm_diagnosis']['summary'] = '患者伴有乏力,脉象为弦滑';
$normalCandidate = $valid; $normalCandidate['tcm_diagnosis']['candidates'][0]['name'] = '正常';
$clinicalLimitation = $valid; $clinicalLimitation['limitations'] = ['缺少脉象资料'];
$invalidAnswers = ['诊断:湿热,建议用药'];
foreach ([$unusableCandidate, $unsupportedBasis, $certainDiagnosis, $inventedPulse, $normalCandidate, $clinicalLimitation] as $invalid) {
$invalidAnswers[] = json_encode($invalid, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}
foreach ($invalidAnswers as $invalidAnswer) {
$reset([['body' => '{"id":"synthetic-file-123"}'], $answerReply($invalidAnswer), $answerReply($invalidAnswer)]);
$file = $upload(); $failed = $controller(array_replace($body, ['model' => 'openai']), ['file' => $file]);
$expect($failed['code'] === 0 && $failed['msg'] === '截图分析结果格式无效,请重新截图后重试'
&& count($GLOBALS['tongueCalls']) === 3 && $GLOBALS['tongueClosed'] === 3 && !is_file($file->getPathname()),
'invalid answer and invalid correction fail safely after exactly two chats, without fallback or retained image');
}
// Parser rejection permits one same-image regeneration, without replaying model content.
$rejectedMarker = 'fixture-only-rejected-answer-never-replay';
$rejectedData = $normalCandidate; $rejectedData['quality_note'] = '舌头可见,' . $rejectedMarker;
$rejectedAnswer = json_encode($rejectedData, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
$correctedAnswer = json_encode($valid, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
foreach (['qwen' => '千问', 'openai' => 'GPT'] as $model => $label) {
$reset([['body' => '{"id":"synthetic-file-123"}', 'seconds' => 2],
$answerReply($rejectedAnswer, 6), $answerReply($correctedAnswer)]);
$file = $upload(); $digest = hash_file('sha256', $file->getPathname());
$result = $controller(array_replace($body, ['model' => $model]), ['file' => $file]);
$expect($result['code'] === 1 && $result['data'] === ['request_id' => $body['request_id'], 'diagnosis_id' => 41,
'image_sha256' => $digest, 'analysis' => $valid, 'model' => $model, 'model_label' => $label],
'valid correction preserves the exact image, request, model and result contract');
$expect(count($GLOBALS['tongueCalls']) === 3 && $GLOBALS['tongueClosed'] === 3 && !is_file($file->getPathname()),
'valid correction performs one upload and two closed model calls then removes the temporary image');
[$up, $firstChat, $correctionChat] = $GLOBALS['tongueCalls'];
$firstPayload = json_decode($firstChat[CURLOPT_POSTFIELDS], true);
$correctionPayload = json_decode($correctionChat[CURLOPT_POSTFIELDS], true);
$expect($correctionChat[CURLOPT_URL] === $firstChat[CURLOPT_URL]
&& $firstPayload['files'] === $correctionPayload['files']
&& $correctionPayload['user'] === $firstPayload['user']
&& $correctionPayload['user'] === $up[CURLOPT_POSTFIELDS]['user']
&& json_decode($correctionChat[CURLOPT_POSTFIELDS])->inputs instanceof stdClass
&& !isset($correctionPayload['conversation_id']), 'correction reuses the same private upload and anonymous user without history');
$expect(array_diff_key($correctionPayload, $firstPayload) === []
&& array_diff_key($firstPayload, $correctionPayload) === []
&& array_replace($correctionPayload, ['query' => $firstPayload['query']]) === $firstPayload,
'only the fixed query changes between analysis and correction');
$expect(str_starts_with($correctionPayload['query'], $firstPayload['query'] . "\n")
&& str_contains($correctionPayload['query'], '唯一一次格式纠正')
&& str_contains($correctionPayload['query'], 'limitations 只写')
&& str_contains($correctionPayload['query'], 'tcm_diagnosis.missing_information')
&& str_contains($correctionPayload['query'], '正常、健康、无异常')
&& str_contains($correctionPayload['query'], '保留原图中实际可见的 features')
&& !str_contains($correctionChat[CURLOPT_POSTFIELDS], $rejectedMarker),
'fixed correction rules preserve clinical boundaries and never contain the rejected answer');
$expect($firstChat[CURLOPT_TIMEOUT_MS] === 48000 && $correctionChat[CURLOPT_TIMEOUT_MS] === 42000,
'both upload and initial analysis time are deducted from the unchanged total deadline');
foreach ([$up, $firstChat, $correctionChat] as $options) {
$expect(in_array('Authorization: Bearer ' . $config['models'][$model]['api_key'], $options[CURLOPT_HTTPHEADER], true)
&& $options[CURLOPT_FOLLOWLOCATION] === false && $options[CURLOPT_PROXY] === ''
&& $options[CURLOPT_SSL_VERIFYPEER] === true && $options[CURLOPT_SSL_VERIFYHOST] === 2,
'correction keeps the selected profile credentials and transport protections');
}
}
foreach ([46, 48] as $analysisSeconds) {
$reset([['body' => '{"id":"synthetic-file-123"}', 'seconds' => 2], $answerReply($rejectedMarker, $analysisSeconds)]);
$file = $upload(); $failed = $controller($body, ['file' => $file]);
$expect($failed['code'] === 0 && $failed['msg'] === '截图分析结果格式无效,请重新截图后重试'
&& count($GLOBALS['tongueCalls']) === 2 && $GLOBALS['tongueClosed'] === 2 && !is_file($file->getPathname()),
'less than three seconds remaining preserves the original parser error without another call');
}
$reset([['body' => '{"id":"synthetic-file-123"}', 'seconds' => 2],
$answerReply($rejectedMarker, 45), $answerReply($correctedAnswer)]);
$expect($controller($body, ['file' => $upload()])['code'] === 1
&& count($GLOBALS['tongueCalls']) === 3 && $GLOBALS['tongueCalls'][2][CURLOPT_TIMEOUT_MS] === 3000,
'exactly three seconds remaining permits a correction without extending the deadline');
foreach ([['status' => 401, 'body' => 'fixture-only-secret private patient model text'],
['status' => 400, 'body' => json_encode(['code' => 'invalid_param',
'message' => 'Run failed: [models] Error: status code 404 fixture-only-secret'])],
['errno' => CURLE_OPERATION_TIMEDOUT], ['body' => 'not-json'], ['body' => '{"answer":null}'],
$answerReply(str_repeat('x', 32769))] as $correctionFailure) {
$reset([['body' => '{"id":"synthetic-file-123"}'], $answerReply($rejectedMarker), $correctionFailure]);
$file = $upload(); $failed = $controller($body, ['file' => $file]);
$expect($failed['code'] === 0 && count($GLOBALS['tongueCalls']) === 3 && $GLOBALS['tongueClosed'] === 3
&& !is_file($file->getPathname()) && !str_contains(json_encode($failed), 'fixture-only-secret')
&& !str_contains(json_encode($failed), 'private patient') && !str_contains(json_encode($failed), $rejectedMarker),
'correction transport or envelope failure ends safely without a third chat or retained image');
}
foreach ([['enable' => false], ['base_url' => 'https://evil.test/v1?leak=yes'],
['base_url' => 'https://user:password@evil.test'], ['base_url' => 'file:///private'],
['base_url' => 'https://example.test/v1/chat/completions'],
['models' => ['qwen' => ['api_key' => "key\r\nX-Injection: yes"]]]] as $override) {
$reset(); $GLOBALS['tongueConfig'] = array_replace($config, $override);
$expect($controller($body, ['file' => $upload()])['code'] === 0 && $GLOBALS['tongueCalls'] === [], 'invalid service configuration fails before network');
}
$reset($successReplies());
$GLOBALS['tongueConfig']['required_attachment_upload']['ca_file'] = __DIR__ . '/missing-optional-trust-store.pem';
$expect($controller($body, ['file' => $upload()])['code'] === 1, 'stale optional CA path uses system trust like the original transport');
foreach ($GLOBALS['tongueCalls'] as $call) {
$expect(!isset($call[CURLOPT_CAINFO]) && $call[CURLOPT_SSL_VERIFYPEER] === true && $call[CURLOPT_SSL_VERIFYHOST] === 2,
'system CA fallback preserves peer and hostname verification for upload and analysis');
}
$reset($successReplies());
$GLOBALS['tongueReadableCa'] = __FILE__;
$GLOBALS['tongueConfig']['required_attachment_upload']['ca_file'] = __FILE__;
$expect($controller($body, ['file' => $upload()])['code'] === 1
&& $GLOBALS['tongueCalls'][0][CURLOPT_CAINFO] === __FILE__ && $GLOBALS['tongueCalls'][1][CURLOPT_CAINFO] === __FILE__,
'existing readable configured trust bundle is kept for both requests');
foreach (['https://dify.fixture.test', 'https://dify.fixture.test/v1/chat-messages'] as $base) {
$reset($successReplies()); $GLOBALS['tongueConfig']['base_url'] = $base;
$expect($controller($body, ['file' => $upload()])['code'] === 1
&& $GLOBALS['tongueCalls'][0][CURLOPT_URL] === 'https://dify.fixture.test/v1/files/upload', 'existing Dify base URL forms supported');
}
// Real auth middleware, with menu lookup deliberately explosive: exact shared grant only.
$route = new class {
public array $adminInfo = ['admin_id' => 7, 'root' => 0, 'login_ip' => '127.0.0.1'];
public object $controllerObject;
public function __construct() { $this->controllerObject = new class { public function isNotNeedLogin(): bool { return false; } }; }
public function controller(): string { return 'tcm.Diagnosis'; }
public function action(): string { return 'aiTongueScreenshot'; }
public function param($name, $default = null) { return $default; }
};
$middleware = new AuthMiddleware();
foreach ([[], ['tcm.diagnosis/edit'], ['tcm.diagnosis/aiTongueScreenshot'], ['tcm.diagnosis/aiAssistant']] as $uris) {
TongueAuthDouble::$uris = $uris;
$result = $middleware->handle($route, static fn () => 'ALLOWED');
$expect(($result === 'ALLOWED') === in_array('tcm.diagnosis/aiAssistant', $uris, true), 'middleware shares existing permission without menu-registration bypass');
}
$route->adminInfo['root'] = 1; TongueAuthDouble::$uris = [];
$expect($middleware->handle($route, static fn () => 'ALLOWED') === 'ALLOWED', 'middleware root behavior preserved');
// If operation audit logging is re-enabled, this endpoint must still not persist model text or submitted parameters.
$logRequest = (new \app\Request())->setController('tcm.Diagnosis')->setAction('aiTongueScreenshot');
$container->instance('request', $logRequest);
$expect((new \app\adminapi\listener\OperationLog())->handle(new stdClass()) === null, 'generic audit listener skips temporary screenshot analysis before reading any data');
$logicSource = file_get_contents(dirname(__DIR__) . '/app/adminapi/logic/tcm/TongueScreenshotLogic.php');
$expect(!preg_match('/DiagnosisLogic::detail|::save|->save|::insert|->insert|::update|->update|Log::|base64/', $logicSource), 'screenshot logic contains no chart aggregation, mutation or payload logging');
} finally {
foreach ($paths as $path) if (is_file($path)) unlink($path);
}
echo "Diagnosis tongue screenshot integration (no network): {$checks} checks OK\n";
}