$responses * @param array}> $history captured (request, options) pairs */ private function makeClient(array $responses, array &$history = []): Client { $mock = new MockHandler($responses); $stack = HandlerStack::create($mock); $stack->push(Middleware::history($history)); $guzzle = new GuzzleClient(['handler' => $stack, 'http_errors' => false]); return new Client(self::BASE_URL, self::TOKEN, $guzzle); } public function testHealthzReturnsDecodedJson(): void { $history = []; $client = $this->makeClient([ new Response(200, ['Content-Type' => 'application/json'], (string) json_encode([ 'ok' => true, 'claude_present' => true, 'claude_version' => '1.2.3', ])), ], $history); $health = $client->healthz(); self::assertTrue($health['ok']); self::assertSame('1.2.3', $health['claude_version']); self::assertCount(1, $history); /** @var \Psr\Http\Message\RequestInterface $req */ $req = $history[0]['request']; self::assertSame('GET', $req->getMethod()); self::assertSame('/healthz', $req->getUri()->getPath()); self::assertSame('Bearer ' . self::TOKEN, $req->getHeaderLine('Authorization')); } public function testRunSuccessJsonResult(): void { $history = []; $client = $this->makeClient([ new Response(200, ['Content-Type' => 'application/json'], (string) json_encode([ 'ok' => true, 'result' => ['hello' => 'world'], 'duration_ms' => 1234, 'stop_reason' => 'end_turn', ])), ], $history); $r = $client->run(new RunRequest(prompt: 'Reply with JSON: {"hello": "world"}')); self::assertInstanceOf(RunResult::class, $r); self::assertTrue($r->ok); self::assertIsArray($r->result); self::assertSame(['hello' => 'world'], $r->result); self::assertSame(1234, $r->durationMs); self::assertSame('end_turn', $r->stopReason); /** @var \Psr\Http\Message\RequestInterface $req */ $req = $history[0]['request']; $sentBody = json_decode((string) $req->getBody(), true); self::assertIsArray($sentBody); self::assertSame('Reply with JSON: {"hello": "world"}', $sentBody['prompt']); self::assertSame('sonnet', $sentBody['model']); // default self::assertArrayNotHasKey('system', $sentBody); self::assertArrayNotHasKey('files', $sentBody); self::assertArrayNotHasKey('timeout_secs', $sentBody); } public function testRunSuccessStringResult(): void { $client = $this->makeClient([ new Response(200, [], (string) json_encode([ 'ok' => true, 'result' => 'plain text reply', 'duration_ms' => 800, 'stop_reason' => 'end_turn', ])), ]); $r = $client->run(new RunRequest(prompt: 'hi')); self::assertIsString($r->result); self::assertSame('plain text reply', $r->result); } public function testRunSendsExpectedBodyWithAllFields(): void { $history = []; $client = $this->makeClient([ new Response(200, [], (string) json_encode([ 'ok' => true, 'result' => 'x', 'duration_ms' => 1, 'stop_reason' => 'end_turn', ])), ], $history); $client->run(new RunRequest( prompt: 'hi', model: 'opus', system: 'be terse', files: ['ff_abc'], timeoutSecs: 42, )); /** @var \Psr\Http\Message\RequestInterface $req */ $req = $history[0]['request']; $sent = json_decode((string) $req->getBody(), true); self::assertSame([ 'prompt' => 'hi', 'model' => 'opus', 'system' => 'be terse', 'files' => ['ff_abc'], 'timeout_secs' => 42, ], $sent); } public function testRun502RaisesApiExceptionWithDecodedEnvelope(): void { $client = $this->makeClient([ new Response(502, [], (string) json_encode([ 'ok' => false, 'error' => 'subprocess timed out', 'stderr' => '...', 'duration_ms' => 60000, 'stop_reason' => 'timeout', ])), ]); try { $client->run(new RunRequest(prompt: 'hi', timeoutSecs: 60)); self::fail('expected ApiException'); } catch (ApiException $e) { self::assertSame(502, $e->statusCode); self::assertNotNull($e->decoded); self::assertSame('timeout', $e->decoded['stop_reason']); self::assertStringContainsString('subprocess timed out', $e->getMessage()); self::assertInstanceOf(ForgeException::class, $e); } } public function testRun401RaisesAuthException(): void { $client = $this->makeClient([ new Response(401, [], (string) json_encode(['detail' => 'missing bearer'])), ]); try { $client->run(new RunRequest(prompt: 'hi')); self::fail('expected AuthException'); } catch (AuthException $e) { self::assertSame(401, $e->statusCode); self::assertInstanceOf(ApiException::class, $e); self::assertInstanceOf(ForgeException::class, $e); } } public function testRunTransportErrorWraps(): void { $client = $this->makeClient([ new ConnectException('Connection refused', new Request('POST', self::BASE_URL . '/run')), ]); $this->expectException(TransportException::class); $client->run(new RunRequest(prompt: 'hi')); } public function testRunRejectsEmptyPromptLocally(): void { $this->expectException(InvalidArgumentException::class); new RunRequest(prompt: ''); } public function testUploadFileFromPath(): void { $history = []; $client = $this->makeClient([ new Response(200, [], (string) json_encode([ 'file_token' => 'ff_abc123', 'ttl_secs' => 3600, 'size' => 11, ])), ], $history); $tmp = tempnam(sys_get_temp_dir(), 'cf_'); self::assertNotFalse($tmp); file_put_contents($tmp, 'hello world'); try { $ft = $client->uploadFile($tmp, 3600); } finally { @unlink($tmp); } self::assertInstanceOf(FileToken::class, $ft); self::assertSame('ff_abc123', $ft->fileToken); self::assertSame(3600, $ft->ttlSecs); self::assertSame(11, $ft->size); /** @var \Psr\Http\Message\RequestInterface $req */ $req = $history[0]['request']; self::assertSame('POST', $req->getMethod()); self::assertSame('/files', $req->getUri()->getPath()); self::assertStringStartsWith('multipart/form-data', $req->getHeaderLine('Content-Type')); // Multipart body should mention both fields by name. $body = (string) $req->getBody(); self::assertStringContainsString('name="ttl_secs"', $body); self::assertStringContainsString('name="file"', $body); } public function testCreateTokenRoundTrip(): void { $history = []; $client = $this->makeClient([ new Response(200, [], (string) json_encode([ 'name' => 'cauldron', 'token' => 'cf_brandnew_xxx', 'ip_cidrs' => ['172.24.0.0/16'], ])), ], $history); $t = $client->createToken('cauldron', ['172.24.0.0/16']); self::assertInstanceOf(AppToken::class, $t); self::assertSame('cauldron', $t->name); self::assertSame('cf_brandnew_xxx', $t->token); self::assertSame(['172.24.0.0/16'], $t->ipCidrs); /** @var \Psr\Http\Message\RequestInterface $req */ $req = $history[0]['request']; $sent = json_decode((string) $req->getBody(), true); self::assertSame(['name' => 'cauldron', 'ip_cidrs' => ['172.24.0.0/16']], $sent); } public function testListTokensNormalizesCommaJoinedCidrsAndEnabledFlag(): void { $client = $this->makeClient([ new Response(200, [], (string) json_encode([ 'tokens' => [ [ 'name' => 'cauldron', 'ip_cidrs' => '172.24.0.0/16', 'created_at' => 100, 'last_used' => 200, 'enabled' => 1, ], [ 'name' => 'petalparse', 'ip_cidrs' => '', 'created_at' => 50, 'last_used' => null, 'enabled' => 0, ], ], ])), ]); $toks = $client->listTokens(); self::assertCount(2, $toks); self::assertSame('cauldron', $toks[0]->name); self::assertSame(['172.24.0.0/16'], $toks[0]->ipCidrs); self::assertTrue($toks[0]->enabled); self::assertNull($toks[0]->token); // never returned on list self::assertSame(200, $toks[0]->lastUsed); self::assertSame([], $toks[1]->ipCidrs); self::assertFalse($toks[1]->enabled); self::assertNull($toks[1]->lastUsed); } public function testRevokeTokenReturnsTrueOnOk(): void { $history = []; $client = $this->makeClient([ new Response(200, [], (string) json_encode(['ok' => true])), ], $history); self::assertTrue($client->revokeToken('cauldron')); /** @var \Psr\Http\Message\RequestInterface $req */ $req = $history[0]['request']; self::assertSame('DELETE', $req->getMethod()); self::assertSame('/admin/tokens/cauldron', $req->getUri()->getPath()); } public function testRevokeTokenMissingRaises404(): void { $client = $this->makeClient([ new Response(404, [], (string) json_encode(['detail' => 'no such token'])), ]); try { $client->revokeToken('nosuch'); self::fail('expected ApiException'); } catch (ApiException $e) { self::assertSame(404, $e->statusCode); } } public function testConstructorRequiresBaseUrl(): void { $this->expectException(InvalidArgumentException::class); new Client('', 'cf_x'); } public function testConstructorRequiresToken(): void { $this->expectException(InvalidArgumentException::class); new Client(self::BASE_URL, ''); } // --------------------------------------------------------------------- // Audit-driven tests (1cff9b8 → next) // --------------------------------------------------------------------- // H1 — token redaction -------------------------------------------------- public function testClientDebugInfoRedactsToken(): void { $client = new Client(self::BASE_URL, self::TOKEN); // print_r honours __debugInfo() — this is what most framework error // reflectors (Whoops, Symfony VarDumper, Laravel Ignition, etc.) hit. $printed = print_r($client, true); self::assertStringNotContainsString(self::TOKEN, $printed); self::assertStringContainsString('***redacted***', $printed); // var_dump() likewise routes through __debugInfo(). ob_start(); var_dump($client); $dumped = (string) ob_get_clean(); self::assertStringNotContainsString(self::TOKEN, $dumped); self::assertStringContainsString('redacted', $dumped); // The __debugInfo() return value itself must not include the bearer. $info = $client->__debugInfo(); self::assertSame('***redacted***', $info['token']); self::assertSame(self::BASE_URL, $info['baseUrl']); } public function testAppTokenDebugInfoRedactsToken(): void { $plaintext = 'cf_brandnew_super_secret_xxxxxx'; $tok = new AppToken(name: 'cauldron', token: $plaintext); $printed = print_r($tok, true); self::assertStringNotContainsString($plaintext, $printed); self::assertStringContainsString('***redacted***', $printed); ob_start(); var_dump($tok); $dumped = (string) ob_get_clean(); self::assertStringNotContainsString($plaintext, $dumped); // List-row tokens (no plaintext) should remain null in the debug // view — no false redaction marker. $listed = new AppToken(name: 'cauldron', token: null); $printedListed = print_r($listed, true); self::assertStringNotContainsString('redacted', $printedListed); } public function testCreateTokenResponseRedactsPlaintextOnDebug(): void { $plaintext = 'cf_brandnew_xxx'; $client = $this->makeClient([ new Response(200, [], (string) json_encode([ 'name' => 'cauldron', 'token' => $plaintext, 'ip_cidrs' => [], ])), ]); $tok = $client->createToken('cauldron'); // The plaintext is still accessible on the property — that's the contract. self::assertSame($plaintext, $tok->token); // ...but it must not bleed into reflective output that goes through __debugInfo(). $printed = print_r($tok, true); self::assertStringNotContainsString($plaintext, $printed); } // M1 — uploadStream overload -------------------------------------------- public function testUploadStreamFromPsr7(): void { $history = []; $client = $this->makeClient([ new Response(200, [], (string) json_encode([ 'file_token' => 'ff_stream', 'ttl_secs' => 600, 'size' => 5, ])), ], $history); $stream = Utils::streamFor('hello'); $ft = $client->uploadStream($stream, 'greeting.txt', 600); self::assertSame('ff_stream', $ft->fileToken); self::assertSame(600, $ft->ttlSecs); /** @var \Psr\Http\Message\RequestInterface $req */ $req = $history[0]['request']; self::assertSame('POST', $req->getMethod()); self::assertSame('/files', $req->getUri()->getPath()); self::assertSame('Bearer ' . self::TOKEN, $req->getHeaderLine('Authorization')); $body = (string) $req->getBody(); self::assertStringContainsString('name="file"', $body); self::assertStringContainsString('filename="greeting.txt"', $body); self::assertStringContainsString('hello', $body); } public function testUploadStreamRejectsEmptyFilename(): void { $client = $this->makeClient([]); // no requests should fly $this->expectException(InvalidArgumentException::class); $client->uploadStream(Utils::streamFor('x'), '', 60); } // M2 — empty model/system rejection ------------------------------------- public function testRunRequestRejectsEmptyModel(): void { $this->expectException(InvalidArgumentException::class); new RunRequest(prompt: 'hi', model: ''); } public function testRunRequestRejectsEmptySystem(): void { $this->expectException(InvalidArgumentException::class); new RunRequest(prompt: 'hi', system: ''); } // M3/M4 — MalformedResponseException ------------------------------------- public function testMalformedResponseExceptionOnRun(): void { $client = $this->makeClient([ new Response(200, ['Content-Type' => 'text/html'], 'not json'), ]); try { $client->run(new RunRequest(prompt: 'hi')); self::fail('expected MalformedResponseException'); } catch (MalformedResponseException $e) { self::assertSame(200, $e->statusCode); self::assertSame('not json', $e->body); self::assertInstanceOf(ForgeException::class, $e); self::assertNotInstanceOf(ApiException::class, $e); } } public function testMalformedResponseExceptionOnHealthz(): void { $client = $this->makeClient([ new Response(200, [], 'definitely not json'), ]); $this->expectException(MalformedResponseException::class); $client->healthz(); } public function testMalformedResponseExceptionOnFiles(): void { $client = $this->makeClient([ new Response(200, [], 'still not json'), ]); $tmp = tempnam(sys_get_temp_dir(), 'cf_'); self::assertNotFalse($tmp); file_put_contents($tmp, 'x'); try { $this->expectException(MalformedResponseException::class); $client->uploadFile($tmp); } finally { @unlink($tmp); } } public function testMalformedResponseExceptionOnListTokensNonObject(): void { $client = $this->makeClient([ new Response(200, [], (string) json_encode(['unexpected' => 'shape'])), ]); $this->expectException(MalformedResponseException::class); $client->listTokens(); } public function testMalformedResponseExceptionOnListTokensGarbageBody(): void { $client = $this->makeClient([ new Response(200, [], '<<>>'), ]); $this->expectException(MalformedResponseException::class); $client->listTokens(); } public function testMalformedResponseExceptionOnCreateToken(): void { $client = $this->makeClient([ new Response(200, [], 'garbage'), ]); $this->expectException(MalformedResponseException::class); $client->createToken('cauldron'); } public function testNonUtf8BodyDegradesToMalformedException(): void { // A latin-1 / non-UTF-8 byte sequence is invalid JSON and json_decode // returns null — the SDK must surface this as MalformedResponseException // rather than slipping a null through. $body = "\xff\xfe\xfd\xfc not utf8"; $client = $this->makeClient([ new Response(200, [], $body), ]); $this->expectException(MalformedResponseException::class); $client->healthz(); } // M5 — object error fields surface a JSON-encoded snippet --------------- public function testApiExceptionMessageWithObjectError(): void { $client = $this->makeClient([ new Response(500, [], (string) json_encode([ 'error' => ['code' => 'internal', 'msg' => 'bad'], ])), ]); try { $client->healthz(); self::fail('expected ApiException'); } catch (ApiException $e) { self::assertSame(500, $e->statusCode); $msg = $e->getMessage(); self::assertNotEmpty($msg); // Should round-trip the JSON of the object so callers see the // structured error rather than an empty trailing message. self::assertStringContainsString('internal', $msg); self::assertStringContainsString('bad', $msg); } } public function testApiExceptionMessageObjectErrorCappedAt200Chars(): void { $longMsg = str_repeat('A', 500); $client = $this->makeClient([ new Response(500, [], (string) json_encode([ 'error' => ['code' => 'overflow', 'msg' => $longMsg], ])), ]); try { $client->healthz(); self::fail('expected ApiException'); } catch (ApiException $e) { $msg = $e->getMessage(); // Prefix is "500 : " and then the snippet — snippet // itself must be capped at 200 chars. self::assertLessThanOrEqual( 300, strlen($msg), 'message snippet should be capped near 200 chars' ); } } // L2 — revokeToken requires ok === true --------------------------------- public function testRevokeTokenRequiresOkTrue(): void { $client = $this->makeClient([ new Response(200, [], (string) json_encode([])), ]); $this->expectException(MalformedResponseException::class); $client->revokeToken('cauldron'); } public function testRevokeTokenRejectsOkFalse(): void { $client = $this->makeClient([ new Response(200, [], (string) json_encode(['ok' => false])), ]); $this->expectException(MalformedResponseException::class); $client->revokeToken('cauldron'); } public function testRevokeTokenRejectsEmptyName(): void { $client = $this->makeClient([]); $this->expectException(InvalidArgumentException::class); $client->revokeToken(''); } // L7 — coverage gaps ----------------------------------------------------- public function testRunRequestRejectsTimeoutBelowFloor(): void { $this->expectException(InvalidArgumentException::class); new RunRequest(prompt: 'hi', timeoutSecs: 4); } public function testRunRequestRejectsTimeoutAboveCeiling(): void { $this->expectException(InvalidArgumentException::class); new RunRequest(prompt: 'hi', timeoutSecs: 601); } public function testRunRequestAcceptsTimeoutAtBounds(): void { $low = new RunRequest(prompt: 'hi', timeoutSecs: 5); $high = new RunRequest(prompt: 'hi', timeoutSecs: 600); self::assertSame(5, $low->timeoutSecs); self::assertSame(600, $high->timeoutSecs); } public function testRunRequestRejectsNonStringFilesEntry(): void { $this->expectException(InvalidArgumentException::class); // @phpstan-ignore-next-line — deliberately pushing a bad type new RunRequest(prompt: 'hi', files: ['ff_ok', 123]); } public function testRunRequestRejectsEmptyStringFilesEntry(): void { $this->expectException(InvalidArgumentException::class); new RunRequest(prompt: 'hi', files: ['ff_ok', '']); } public function testUploadFileRejectsUnreadablePath(): void { $client = $this->makeClient([]); $this->expectException(InvalidArgumentException::class); $client->uploadFile('/no/such/file/anywhere/' . uniqid('cf_', true)); } public function testUploadFile4xxRaisesApiException(): void { $client = $this->makeClient([ new Response(413, [], (string) json_encode(['detail' => 'file too large'])), ]); $tmp = tempnam(sys_get_temp_dir(), 'cf_'); self::assertNotFalse($tmp); file_put_contents($tmp, 'x'); try { try { $client->uploadFile($tmp); self::fail('expected ApiException'); } catch (ApiException $e) { self::assertSame(413, $e->statusCode); self::assertStringContainsString('file too large', $e->getMessage()); } } finally { @unlink($tmp); } } public function testUploadFile5xxRaisesApiException(): void { $client = $this->makeClient([ new Response(503, [], (string) json_encode(['error' => 'storage unavailable'])), ]); $tmp = tempnam(sys_get_temp_dir(), 'cf_'); self::assertNotFalse($tmp); file_put_contents($tmp, 'x'); try { try { $client->uploadFile($tmp); self::fail('expected ApiException'); } catch (ApiException $e) { self::assertSame(503, $e->statusCode); self::assertNotInstanceOf(MalformedResponseException::class, $e); } } finally { @unlink($tmp); } } public function testHealthz500RaisesApiException(): void { $client = $this->makeClient([ new Response(500, [], (string) json_encode(['error' => 'boom'])), ]); try { $client->healthz(); self::fail('expected ApiException'); } catch (ApiException $e) { self::assertSame(500, $e->statusCode); self::assertNotInstanceOf(AuthException::class, $e); self::assertStringContainsString('boom', $e->getMessage()); } } public function testAuthorizationHeaderSentOnEveryEndpoint(): void { $history = []; $client = $this->makeClient([ new Response(200, [], (string) json_encode([ 'ok' => true, 'claude_present' => true, 'claude_version' => '1', ])), new Response(200, [], (string) json_encode([ 'ok' => true, 'result' => 'x', 'duration_ms' => 1, 'stop_reason' => 'end_turn', ])), new Response(200, [], (string) json_encode([ 'file_token' => 'ff_x', 'ttl_secs' => 60, 'size' => 1, ])), new Response(200, [], (string) json_encode([ 'name' => 'a', 'token' => 'cf_a', 'ip_cidrs' => [], ])), new Response(200, [], (string) json_encode([ 'tokens' => [], ])), new Response(200, [], (string) json_encode(['ok' => true])), ], $history); $client->healthz(); $client->run(new RunRequest(prompt: 'hi')); $tmp = tempnam(sys_get_temp_dir(), 'cf_'); self::assertNotFalse($tmp); file_put_contents($tmp, 'x'); try { $client->uploadFile($tmp); } finally { @unlink($tmp); } $client->createToken('a'); $client->listTokens(); $client->revokeToken('a'); self::assertCount(6, $history); foreach ($history as $i => $entry) { /** @var \Psr\Http\Message\RequestInterface $req */ $req = $entry['request']; self::assertSame( 'Bearer ' . self::TOKEN, $req->getHeaderLine('Authorization'), "Authorization missing/wrong on request #{$i} ({$req->getMethod()} {$req->getUri()->getPath()})" ); self::assertSame( 'application/json', $req->getHeaderLine('Accept'), "Accept missing on request #{$i}" ); } } }