$stub['errno']]; } function curl_setopt_array(\stdClass $handle, array $options): bool { $stub =& $GLOBALS['runStatusStub']; ++$stub['setopt_calls']; $stub['options'] = $handle->options = $options; $stub['now_ns'] += $stub['setopt_delay_ms'] * 1000000; if ($stub['setopt_throw']) throw new \RuntimeException($stub['sensitive']); return !$stub['setopt_false']; } function curl_exec(\stdClass $handle): bool { $stub =& $GLOBALS['runStatusStub']; ++$stub['exec_calls']; $stub['now_ns'] += $stub['exec_delay_ms'] * 1000000; if ($stub['exec_throw']) throw new \RuntimeException($stub['sensitive']); if ($stub['progress']) { $option = defined('CURLOPT_XFERINFOFUNCTION') ? CURLOPT_XFERINFOFUNCTION : CURLOPT_PROGRESSFUNCTION; $stub['progress_result'] = ($handle->options[$option])($handle, 0, 0, 0, 0); if ($stub['progress_result'] !== 0) { $handle->errno = CURLE_ABORTED_BY_CALLBACK; return false; } } $chunks = $stub['chunks'] ?? [$stub['body']]; foreach ($chunks as $index => $chunk) { $stub['now_ns'] += ($stub['chunk_delay_ms'][$index] ?? 0) * 1000000; $written = ($handle->options[CURLOPT_WRITEFUNCTION])($handle, $chunk); $stub['write_results'][] = $written; if ($written !== strlen($chunk)) { $handle->errno = CURLE_WRITE_ERROR; return false; } } $stub['now_ns'] += $stub['after_body_ms'] * 1000000; return !$stub['exec_false']; } function curl_errno(\stdClass $handle): int { return $handle->errno; } function curl_getinfo(\stdClass $handle, int $option): int { if ($option !== CURLINFO_RESPONSE_CODE) throw new \RuntimeException('Unexpected cURL info read'); $GLOBALS['runStatusStub']['now_ns'] += $GLOBALS['runStatusStub']['info_delay_ms'] * 1000000; return $GLOBALS['runStatusStub']['http']; } function curl_error(\stdClass $handle): string { ++$GLOBALS['runStatusStub']['error_reads']; throw new \RuntimeException('Raw cURL error must never be read'); } function curl_close(\stdClass $handle): void { ++$GLOBALS['runStatusStub']['close_calls']; $GLOBALS['runStatusStub']['now_ns'] += $GLOBALS['runStatusStub']['close_delay_ms'] * 1000000; if ($GLOBALS['runStatusStub']['close_throw']) throw new \RuntimeException($GLOBALS['runStatusStub']['sensitive']); } } namespace { require dirname(__DIR__) . '/app/common/service/DifyWorkflowRunStatus.php'; use app\common\service\DifyWorkflowRunStatus; $checks = 0; $cases = 0; $identity = ['profile' => 'qwen', 'user' => 'synthetic-private-user', 'task_id' => 'synthetic-task-001', 'workflow_run_id' => 'synthetic-run-001', 'generation' => 1]; function runStatusExpect(bool $condition, string $message): void { ++$GLOBALS['checks']; if (!$condition) throw new RuntimeException($message); } function runStatusReset(array $changes = []): void { $GLOBALS['runStatusStub'] = array_replace([ 'now_ns' => 10000000000, 'config' => ['enable' => true, 'base_url' => 'https://status-synthetic.invalid/v1/chat-messages', 'models' => ['qwen' => ['api_key' => 'qwen-fixture-only-key'], 'openai' => ['api_key' => 'openai-fixture-only-key']]], 'sensitive' => 'PRIVATE-PATIENT-OUTPUT-ERROR-CREDENTIAL', 'body' => json_encode(['id' => 'synthetic-run-001', 'status' => 'succeeded', 'inputs' => ['patient' => 'PRIVATE-PATIENT-OUTPUT-ERROR-CREDENTIAL'], 'outputs' => ['answer' => 'PRIVATE-PATIENT-OUTPUT-ERROR-CREDENTIAL'], 'error' => 'PRIVATE-PATIENT-OUTPUT-ERROR-CREDENTIAL'], JSON_THROW_ON_ERROR), 'http' => 200, 'errno' => 0, 'chunks' => null, 'write_results' => [], 'config_calls' => 0, 'init_calls' => 0, 'setopt_calls' => 0, 'exec_calls' => 0, 'close_calls' => 0, 'error_reads' => 0, 'options' => [], 'config_delay_ms' => 0, 'init_delay_ms' => 0, 'setopt_delay_ms' => 0, 'exec_delay_ms' => 0, 'chunk_delay_ms' => [], 'after_body_ms' => 0, 'info_delay_ms' => 0, 'close_delay_ms' => 0, 'config_throw' => false, 'init_false' => false, 'init_throw' => false, 'setopt_false' => false, 'setopt_throw' => false, 'exec_false' => false, 'exec_throw' => false, 'close_throw' => false, 'progress' => false, ], $changes); } function runStatusLookup(?array $identity = null, int $timeoutMs = 1000): array { ++$GLOBALS['cases']; ob_start(); try { $result = DifyWorkflowRunStatus::lookup($identity ?? $GLOBALS['identity'], $timeoutMs); } finally { $output = ob_get_clean(); } runStatusExpect($output === '', 'adapter emits no body, error or private data'); $stub = $GLOBALS['runStatusStub']; runStatusExpect($stub['error_reads'] === 0, 'raw provider transport error is never read'); runStatusExpect($stub['exec_calls'] <= 1 && $stub['init_calls'] <= 1, 'no retry or endpoint/protocol fallback'); if ($stub['init_calls'] === 1 && !$stub['init_false'] && !$stub['init_throw']) { runStatusExpect($stub['close_calls'] === 1, 'every initialized handle closes exactly once'); } $encoded = json_encode($result, JSON_THROW_ON_ERROR); foreach ([$stub['sensitive'], 'status-synthetic.invalid', 'fixture-only-key', 'synthetic-private-user', 'synthetic-task-001', 'inputs', 'outputs', 'Authorization', 'api_key'] as $private) { runStatusExpect(!str_contains($encoded, $private), 'result excludes sensitive or unrelated metadata'); } if (isset($stub['options'][CURLOPT_WRITEFUNCTION])) { $variables = (new ReflectionFunction($stub['options'][CURLOPT_WRITEFUNCTION]))->getStaticVariables(); runStatusExpect($variables['body'] === '', 'bounded sensitive response buffer is discarded on every result'); } return $result; } function runStatusError(array $result, string $code): void { runStatusExpect($result === ['ok' => false, 'error_code' => $code], 'failure returns only expected static code'); } function runStatusSuccess(array $result, string $status = 'succeeded', string $run = 'synthetic-run-001'): void { runStatusExpect($result === ['ok' => true, 'workflow_run_id' => $run, 'status' => $status], 'success returns only exact private run/status'); } function runStatusTransportOptions(int $timeoutMs = 1000, int $protocol = CURLPROTO_HTTPS): void { $stub = $GLOBALS['runStatusStub']; $options = $stub['options']; runStatusExpect($stub['config_calls'] === 1 && $stub['exec_calls'] === 1, 'one server profile read and one synthetic request'); foreach ([CURLOPT_HTTPGET => true, CURLOPT_RETURNTRANSFER => false, CURLOPT_HEADER => false, CURLOPT_VERBOSE => false, CURLOPT_FOLLOWLOCATION => false, CURLOPT_MAXREDIRS => 0, CURLOPT_PROXY => '', CURLOPT_NOPROXY => '*', CURLOPT_PROTOCOLS => $protocol, CURLOPT_REDIR_PROTOCOLS => $protocol, CURLOPT_NETRC => CURL_NETRC_IGNORED, CURLOPT_UNRESTRICTED_AUTH => false, CURLOPT_SSL_VERIFYPEER => true, CURLOPT_SSL_VERIFYHOST => 2, CURLOPT_TIMEOUT_MS => $timeoutMs, CURLOPT_CONNECTTIMEOUT_MS => $timeoutMs, CURLOPT_NOSIGNAL => true, CURLOPT_NOPROGRESS => false] as $name => $expected) { runStatusExpect(($options[$name] ?? null) === $expected, 'GET/direct/TLS/protocol/deadline option retained'); } foreach ([CURLOPT_POST, CURLOPT_POSTFIELDS, CURLOPT_CUSTOMREQUEST, CURLOPT_USERPWD] as $forbidden) { runStatusExpect(!array_key_exists($forbidden, $options), 'no inference/stop/body/alternate credential option'); } } foreach (['qwen', 'openai'] as $profile) { runStatusReset(); $target = array_replace($identity, ['profile' => $profile]); runStatusSuccess(runStatusLookup($target)); runStatusTransportOptions(); runStatusExpect($runStatusStub['options'][CURLOPT_URL] === 'https://status-synthetic.invalid/v1/workflows/run/synthetic-run-001', 'configured chat endpoint becomes exact run GET'); runStatusExpect($runStatusStub['options'][CURLOPT_HTTPHEADER] === ['Authorization: Bearer ' . $profile . '-fixture-only-key', 'Accept: application/json'], 'only original configured profile key is used'); } foreach (['https://status-synthetic.invalid', 'https://status-synthetic.invalid/', 'https://status-synthetic.invalid/v1', 'https://status-synthetic.invalid/v1/', 'https://status-synthetic.invalid/v1/chat-messages/', 'https://status-synthetic.invalid/prefix/v1/chat-messages', 'https://status-synthetic.invalid:8443/v1/chat-messages', 'http://status-synthetic.invalid/v1/chat-messages'] as $base) { runStatusReset(); $runStatusStub['config']['base_url'] = $base; runStatusSuccess(runStatusLookup()); $root = rtrim($base, '/'); if (str_ends_with($root, '/chat-messages')) $root = substr($root, 0, -strlen('/chat-messages')); runStatusExpect($runStatusStub['options'][CURLOPT_URL] === $root . '/workflows/run/synthetic-run-001', 'one configured root is preserved'); runStatusTransportOptions(1000, str_starts_with($base, 'https:') ? CURLPROTO_HTTPS : CURLPROTO_HTTP); } runStatusReset(); runStatusSuccess(runStatusLookup($identity + ['url' => 'https://untrusted.invalid/', 'base_url' => 'https://untrusted.invalid/', 'api_key' => 'untrusted-key', 'ca_file' => 'untrusted-file'])); runStatusExpect($runStatusStub['options'][CURLOPT_URL] === 'https://status-synthetic.invalid/v1/workflows/run/synthetic-run-001' && !str_contains(implode(',', $runStatusStub['options'][CURLOPT_HTTPHEADER]), 'untrusted') && !array_key_exists(CURLOPT_CAINFO, $runStatusStub['options']), 'identity cannot select a destination, credential or CA file'); runStatusReset(); runStatusSuccess(runStatusLookup(array_replace($identity, ['user' => '']))); foreach (['task_id', 'workflow_run_id'] as $field) { foreach (['a', str_repeat('x', 128), 'AZaz09_-'] as $valid) { runStatusReset(); $target = array_replace($identity, [$field => $valid]); if ($field === 'workflow_run_id') $runStatusStub['body'] = json_encode(['id' => $valid, 'status' => 'stopped']); runStatusSuccess(runStatusLookup($target), $field === 'workflow_run_id' ? 'stopped' : 'succeeded', $target['workflow_run_id']); } } $invalidIdentities = [[]]; foreach (array_keys($identity) as $field) { $missing = $identity; unset($missing[$field]); $invalidIdentities[] = $missing; } foreach (['task_id', 'workflow_run_id'] as $field) foreach (['', str_repeat('x', 129), 'a/b', '../run', 'https://untrusted.invalid', 'run%2Fother', "run\n", "run\0", 'run?x', 'run#x', 123, [], new stdClass()] as $bad) { $invalidIdentities[] = array_replace($identity, [$field => $bad]); } foreach (['', 'QWEN', 'other', null, []] as $bad) $invalidIdentities[] = array_replace($identity, ['profile' => $bad]); foreach ([0, -1, '1', 1.0, true, null] as $bad) $invalidIdentities[] = array_replace($identity, ['generation' => $bad]); foreach ([str_repeat('x', 129), "user\n", "user\0", 1, [], null] as $bad) $invalidIdentities[] = array_replace($identity, ['user' => $bad]); foreach ($invalidIdentities as $target) { runStatusReset(); runStatusError(runStatusLookup($target), 'RUN_STATUS_IDENTITY_INVALID'); runStatusExpect($runStatusStub['config_calls'] === 0 && $runStatusStub['init_calls'] === 0, 'invalid original identity performs no config or transport work'); } foreach ([0, -1, PHP_INT_MIN] as $timeout) { runStatusReset(); runStatusError(runStatusLookup(null, $timeout), 'RUN_STATUS_TIMEOUT'); runStatusExpect($runStatusStub['config_calls'] === 0 && $runStatusStub['init_calls'] === 0, 'expired budget performs no config or transport work'); } foreach ([null, false, 'bad', new stdClass()] as $bad) { runStatusReset(['config' => $bad]); runStatusError(runStatusLookup(), 'RUN_STATUS_CONFIG_INVALID'); runStatusExpect($runStatusStub['init_calls'] === 0, 'invalid config performs no transport'); } foreach ([false, null, 0, 'true'] as $bad) { runStatusReset(); $runStatusStub['config']['enable'] = $bad; runStatusError(runStatusLookup(), 'RUN_STATUS_CONFIG_DISABLED'); runStatusExpect($runStatusStub['init_calls'] === 0, 'disabled config performs no transport'); } foreach ([[], null, 'bad', new stdClass(), ['qwen' => null], ['qwen' => 'bad'], ['qwen' => new stdClass()]] as $bad) { runStatusReset(); $runStatusStub['config']['models'] = $bad; runStatusError(runStatusLookup(), 'RUN_STATUS_CONFIG_INVALID'); runStatusExpect($runStatusStub['init_calls'] === 0, 'profile has no fallback key'); } foreach (['', "bad\r\nAuthorization: leak", ' padded ', "bad\tkey", "bad\0key", str_repeat('k', 4097), null, 123, [], new stdClass()] as $bad) { runStatusReset(); $runStatusStub['config']['models']['qwen']['api_key'] = $bad; runStatusError(runStatusLookup(), 'RUN_STATUS_CONFIG_INVALID'); runStatusExpect($runStatusStub['init_calls'] === 0, 'malformed credential fails before cURL'); } foreach (['', 'https://status-synthetic.invalid/v1/chat/completions', 'https://status-synthetic.invalid/v1/chat/completions/', 'https://status-synthetic.invalid/v1/CHAT/COMPLETIONS', 'https://status-synthetic.invalid/v1/chat/completions/other', 'https://status-synthetic.invalid/v1?x=1', 'https://status-synthetic.invalid/v1?', 'https://status-synthetic.invalid/v1#fragment', 'https://status-synthetic.invalid/v1#', 'https://name@status-synthetic.invalid/v1', 'https://name:pass@status-synthetic.invalid/v1', 'ftp://status-synthetic.invalid/v1', 'file:///tmp/status', '//status-synthetic.invalid/v1', 'http://', 'https://status-synthetic.invalid:999999/v1', 'https://status-synthetic.invalid/v1%2fchat-messages', 'https://status-synthetic.invalid/v1/../other', 'https://status-synthetic.invalid/v1\\other', "https://status-synthetic.invalid/v1\n", "https://status-synthetic.invalid/v1\0", ' https://status-synthetic.invalid/v1', 'https://status-synthetic.invalid/v1 ', str_repeat('x', 2049), null, 1, [], new stdClass()] as $bad) { runStatusReset(); $runStatusStub['config']['base_url'] = $bad; runStatusError(runStatusLookup(), 'RUN_STATUS_CONFIG_INVALID'); runStatusExpect($runStatusStub['init_calls'] === 0, 'invalid/non-Dify destination performs no transport'); } foreach ([['ca_file' => __DIR__ . '/definitely-absent-status-ca.pem'], ['ca_file' => __DIR__], ['ca_file' => "bad\0path"], ['ca_file' => []], ['ca_file' => null], 'bad'] as $bad) { runStatusReset(); $runStatusStub['config']['required_attachment_upload'] = $bad; runStatusError(runStatusLookup(), 'RUN_STATUS_CONFIG_INVALID'); runStatusExpect($runStatusStub['init_calls'] === 0, 'invalid server CA fails before transport'); } runStatusReset(); $runStatusStub['config']['required_attachment_upload'] = ['ca_file' => __FILE__]; runStatusSuccess(runStatusLookup()); runStatusTransportOptions(); runStatusExpect($runStatusStub['options'][CURLOPT_CAINFO] === __FILE__, 'existing server CA option is forwarded with peer/hostname verification'); foreach (['scheduled', 'running', 'succeeded', 'failed', 'stopped', 'partial-succeeded', 'paused'] as $status) { runStatusReset(['body' => json_encode(['id' => $identity['workflow_run_id'], 'status' => $status, 'task_id' => $identity['task_id'], 'user' => $identity['user'], 'profile' => $identity['profile'], 'workflow_run_id' => $identity['workflow_run_id'], 'inputs' => ['secret' => 'PRIVATE-PATIENT-OUTPUT-ERROR-CREDENTIAL']])]); runStatusSuccess(runStatusLookup(), $status); } foreach (['', 'cancelled', 'Canceled', 'success', 'SUCCEEDED', 'unknown', 'stopped ', null, 1, [], ['status' => 'stopped']] as $bad) { runStatusReset(['body' => json_encode(['id' => $identity['workflow_run_id'], 'status' => $bad, 'error' => 'PRIVATE-PATIENT-OUTPUT-ERROR-CREDENTIAL'])]); runStatusError(runStatusLookup(), 'RUN_STATUS_STATUS_UNCONFIRMED'); } runStatusReset(['body' => json_encode(['id' => $identity['workflow_run_id'], 'result' => 'success'])]); runStatusError(runStatusLookup(), 'RUN_STATUS_STATUS_UNCONFIRMED'); foreach (['', 'synthetic-run-other', strtoupper($identity['workflow_run_id']), null, [], 123] as $bad) { runStatusReset(['body' => json_encode(['id' => $bad, 'status' => 'stopped'])]); runStatusError(runStatusLookup(), 'RUN_STATUS_IDENTITY_MISMATCH'); } foreach (['{}', '[]', 'null', 'true', '123', '"succeeded"', '{"status":"stopped"}', '{"workflow_id":"synthetic-run-001","status":"stopped"}', '{"data":{"id":"synthetic-run-001","status":"stopped"}}'] as $body) { runStatusReset(['body' => $body]); runStatusError(runStatusLookup(), 'RUN_STATUS_IDENTITY_MISMATCH'); } foreach (['task_id', 'user', 'profile', 'workflow_run_id'] as $field) foreach (['foreign', null, []] as $bad) { runStatusReset(['body' => json_encode(['id' => $identity['workflow_run_id'], 'status' => 'stopped', $field => $bad])]); runStatusError(runStatusLookup(), 'RUN_STATUS_IDENTITY_MISMATCH'); } foreach (['', '{broken', "{\"id\":\"\xff\"}", str_repeat('[', 65) . '0' . str_repeat(']', 65)] as $body) { runStatusReset(['body' => $body]); runStatusError(runStatusLookup(), 'RUN_STATUS_RESPONSE_INVALID'); } foreach ([0, 201, 204, 301, 302, 303, 307, 308, 400, 401, 403, 404, 429, 500, 503] as $http) { runStatusReset(['http' => $http]); runStatusError(runStatusLookup(), 'RUN_STATUS_HTTP_ERROR'); runStatusTransportOptions(); } $peerVerificationError = defined('CURLE_PEER_FAILED_VERIFICATION') ? constant('CURLE_PEER_FAILED_VERIFICATION') : CURLE_SSL_CACERT; foreach ([CURLE_COULDNT_RESOLVE_HOST, CURLE_COULDNT_CONNECT, CURLE_SSL_CONNECT_ERROR, $peerVerificationError, CURLE_SSL_CACERT_BADFILE, CURLE_RECV_ERROR, CURLE_WRITE_ERROR] as $errno) { runStatusReset(['errno' => $errno]); runStatusError(runStatusLookup(), 'RUN_STATUS_TRANSPORT_ERROR'); runStatusTransportOptions(); } runStatusReset(['errno' => CURLE_OPERATION_TIMEDOUT, 'http' => 200]); runStatusError(runStatusLookup(), 'RUN_STATUS_TIMEOUT'); foreach (['config_throw' => 'RUN_STATUS_CONFIG_UNAVAILABLE', 'init_false' => 'RUN_STATUS_TRANSPORT_ERROR', 'init_throw' => 'RUN_STATUS_TRANSPORT_ERROR', 'setopt_false' => 'RUN_STATUS_TRANSPORT_ERROR', 'setopt_throw' => 'RUN_STATUS_TRANSPORT_ERROR', 'exec_false' => 'RUN_STATUS_TRANSPORT_ERROR', 'exec_throw' => 'RUN_STATUS_TRANSPORT_ERROR'] as $flag => $code) { runStatusReset([$flag => true]); runStatusError(runStatusLookup(), $code); } runStatusReset(['close_throw' => true]); runStatusSuccess(runStatusLookup()); $small = json_encode(['id' => $identity['workflow_run_id'], 'status' => 'succeeded', 'outputs' => ''], JSON_THROW_ON_ERROR); $limitBody = substr($small, 0, -2) . str_repeat('x', 1048576 - strlen($small)) . '"}'; runStatusExpect(strlen($limitBody) === 1048576, 'synthetic exact body boundary is one MiB'); runStatusReset(['body' => $limitBody]); runStatusSuccess(runStatusLookup()); runStatusReset(['chunks' => [substr($limitBody, 0, 1048566), substr($limitBody, 1048566)]]); runStatusSuccess(runStatusLookup()); foreach ([[$limitBody . ' '], [$limitBody, ' '], [str_repeat('x', 1048577)]] as $chunks) { runStatusReset(['chunks' => $chunks]); runStatusError(runStatusLookup(), 'RUN_STATUS_RESPONSE_TOO_LARGE'); runStatusExpect(end($runStatusStub['write_results']) === 0, 'oversize body aborts cURL before accumulating beyond one MiB'); } foreach ([1, 137, 713, 1000, 1500, PHP_INT_MAX] as $timeout) { runStatusReset(); runStatusSuccess(runStatusLookup(null, $timeout)); runStatusTransportOptions(min(1000, $timeout)); } runStatusReset(['config_delay_ms' => 100, 'init_delay_ms' => 50]); runStatusSuccess(runStatusLookup()); runStatusTransportOptions(850); foreach (['config_delay_ms', 'init_delay_ms', 'setopt_delay_ms', 'exec_delay_ms', 'after_body_ms', 'info_delay_ms', 'close_delay_ms'] as $stage) { runStatusReset([$stage => 1000]); runStatusError(runStatusLookup(), 'RUN_STATUS_TIMEOUT'); if (in_array($stage, ['config_delay_ms', 'init_delay_ms', 'setopt_delay_ms'], true)) { runStatusExpect($runStatusStub['exec_calls'] === 0, 'setup expiration performs no GET'); } } runStatusReset(['exec_delay_ms' => 999]); runStatusSuccess(runStatusLookup()); runStatusReset(['exec_delay_ms' => 1000, 'progress' => true]); runStatusError(runStatusLookup(), 'RUN_STATUS_TIMEOUT'); runStatusExpect($runStatusStub['progress_result'] === 1 && $runStatusStub['write_results'] === [], 'progress callback aborts at exact monotonic deadline'); runStatusReset(['chunks' => ['{"id":', '"synthetic-run-001","status":"stopped"}'], 'chunk_delay_ms' => [100, 900]]); runStatusError(runStatusLookup(), 'RUN_STATUS_TIMEOUT'); runStatusExpect($runStatusStub['write_results'] === [6, 0], 'late body chunk cannot supply a terminal'); runStatusReset(['config_delay_ms' => 999, 'init_delay_ms' => 1]); runStatusError(runStatusLookup(), 'RUN_STATUS_TIMEOUT'); runStatusExpect($runStatusStub['exec_calls'] === 0, 'remaining fraction never becomes a fresh timeout'); echo 'Dify workflow exact-run status adapter: OK (' . $cases . ' synthetic cases, ' . $checks . " assertions; zero network/config-file/model/DB/control operations)\n"; }