diff --git a/composer.json b/composer.json index ff6a859..7a24d7b 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,8 @@ "phpstan/phpstan": "^1.10", "phpstan/phpstan-symfony": "^1.3", "phpunit/phpunit": "^10.5", - "rector/rector": "^1.2" + "rector/rector": "^1.2", + "mikey179/vfsstream": "^1.6" }, "autoload": { "psr-4": { diff --git a/src/Client/JsonRpc/JsonRpcClient.php b/src/Client/JsonRpc/JsonRpcClient.php new file mode 100644 index 0000000..37c5bfc --- /dev/null +++ b/src/Client/JsonRpc/JsonRpcClient.php @@ -0,0 +1,88 @@ +http->addHeader(HttpHeaderFactory::contentType(ContentType::JSON)); + } + + public function setEndpoint(string $endpoint): void + { + $this->endpoint = $endpoint; + } + + public function setAuth(mixed $auth): void + { + $this->auth = $auth; + } + + /** + * @param mixed[] $params + * + * @throws GuzzleException + * @throws \RuntimeException + * @throws ExceptionInterface + */ + public function call(string $method, array $params = []): JsonRpcResponse + { + if (null === $this->endpoint) { + throw new \RuntimeException('The JSON-RPC endpoint not set.'); + } + + $payload = [ + 'jsonrpc' => '2.0', + 'method' => $method, + 'params' => $params, + 'id' => (string) microtime(), + 'auth' => $this->auth, + ]; + + $json = json_encode($payload); + if (false === $json) { + throw new \RuntimeException('Invalid JSON-RPC payload.'); + } + + $response = $this->http->post( + $this->endpoint, + json: $json + ); + + if (!$response->isSuccessful()) { + throw new \RuntimeException("JSON-RPC responded with {$response->getStatusCode()}: '{$response->getBody()?->getContents()}'"); + } + + $body = $response->getBody()?->getContents(); + if (null === $body) { + throw new \RuntimeException('No JSON-RPC response received.'); + } + + $response = $this->serializer->deserialize( + $body, + JsonRpcResponse::class, + 'json' + ); + + if (!$response instanceof JsonRpcResponse) { + throw new \RuntimeException('Invalid JSON-RPC response.'); + } + + return $response; + } +} diff --git a/src/Client/JsonRpc/JsonRpcClientInterface.php b/src/Client/JsonRpc/JsonRpcClientInterface.php new file mode 100644 index 0000000..f7cbb64 --- /dev/null +++ b/src/Client/JsonRpc/JsonRpcClientInterface.php @@ -0,0 +1,23 @@ +code; + } + + public function getMessage(): string + { + return $this->message; + } + + public function getData(): mixed + { + return $this->data; + } +} diff --git a/src/Model/JsonRpc/JsonRpcResponse.php b/src/Model/JsonRpc/JsonRpcResponse.php new file mode 100644 index 0000000..b4d75d6 --- /dev/null +++ b/src/Model/JsonRpc/JsonRpcResponse.php @@ -0,0 +1,33 @@ +id; + } + + public function getResult(): mixed + { + return $this->result; + } + + public function getError(): ?JsonRpcError + { + return $this->error; + } + + public function isSuccessful(): bool + { + return null === $this->error; + } +} diff --git a/tests/Adapter/Ssh/SshAdapterTest.php b/tests/Adapter/Ssh/SshAdapterTest.php index 2898502..cf827d1 100644 --- a/tests/Adapter/Ssh/SshAdapterTest.php +++ b/tests/Adapter/Ssh/SshAdapterTest.php @@ -10,7 +10,7 @@ class SshAdapterTest extends TestCase { - public function testSetLogger() + public function testSetLogger(): void { $adapter = new SshAdapter(); /** @var LoggerInterface|MockObject */ @@ -24,7 +24,7 @@ public function testSetLogger() $this->assertInstanceOf(NullLogger::class, $adapter->getLogger()); } - public function testSetTimeout() + public function testSetTimeout(): void { $adapter = new SshAdapter(); $adapter->setTimeout(120); @@ -36,7 +36,7 @@ public function testSetTimeout() $this->assertContains('ConnectTimeout=0', $adapter->getOptions()); } - public function testSetters() + public function testSetters(): void { $adapter = new SshAdapter(); $adapter->setHost('127.0.0.1'); @@ -49,7 +49,7 @@ public function testSetters() $this->assertContains('-o', $adapter->getOptions()); } - public function testAddIdentityFile() + public function testAddIdentityFile(): void { $adapter = new SshAdapter(); $adapter->addIdentityFile('/path/to/id_rsa'); @@ -57,7 +57,7 @@ public function testAddIdentityFile() $this->assertContains('/path/to/id_rsa', $adapter->getOptions()); } - public function testAddJump() + public function testAddJump(): void { $adapter = new SshAdapter(); $adapter->addJump('jump.example.com'); @@ -65,7 +65,7 @@ public function testAddJump() $this->assertContains('jump.example.com', $adapter->getOptions()); } - public function testAddConfigFile() + public function testAddConfigFile(): void { $adapter = new SshAdapter(); $adapter->addConfigFile('/path/to/ssh_config'); @@ -73,7 +73,7 @@ public function testAddConfigFile() $this->assertContains('/path/to/ssh_config', $adapter->getOptions()); } - public function testAddOption() + public function testAddOption(): void { $adapter = new SshAdapter(); $adapter->addOption('test'); @@ -81,7 +81,7 @@ public function testAddOption() $this->assertContains('test', $adapter->getOptions()); } - public function testPermitDsaHostKey() + public function testPermitDsaHostKey(): void { $adapter = new SshAdapter(); $options = $adapter->getOptions(); diff --git a/tests/Client/JsonRpc/JsonRpcClientTest.php b/tests/Client/JsonRpc/JsonRpcClientTest.php new file mode 100644 index 0000000..04d3442 --- /dev/null +++ b/tests/Client/JsonRpc/JsonRpcClientTest.php @@ -0,0 +1,139 @@ +serializer = $this->createMock(SerializerInterface::class); + $this->http = $this->createMock(HttpAdapterInterface::class); + + $this->http + ->expects($this->once()) + ->method('addHeader') + ->with(HttpHeaderFactory::contentType(ContentType::JSON)); + + $this->client = new JsonRpcClient($this->serializer, $this->http); + } + + public function testThrowsIfEndpointMissing(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('The JSON-RPC endpoint not set.'); + $this->client->call('method'); + } + + public function testThrowsOnInvalidJsonPayload(): void + { + $this->client->setEndpoint('http://test'); + + $this->client->setAuth("\xB1\x31"); // force json_decode to fail + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Invalid JSON-RPC payload.'); + $this->client->call('method'); + } + + public function testThrowsIfHttpNotSuccessful(): void + { + $this->client->setEndpoint('http://test'); + $this->client->setAuth('auth'); + + $stream = $this->createMock(StreamInterface::class); + $stream->method('getContents')->willReturn('ERR'); + + $httpResponse = $this->createMock(HttpResponse::class); + $httpResponse->method('isSuccessful')->willReturn(false); + $httpResponse->method('getStatusCode')->willReturn(500); + $httpResponse->method('getBody')->willReturn($stream); + + $this->http->method('post')->willReturn($httpResponse); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage("500: 'ERR'"); + + $this->client->call('test'); + } + + public function testThrowsIfNoBody(): void + { + $this->client->setEndpoint('http://test'); + + $httpResponse = $this->createMock(HttpResponse::class); + $httpResponse->method('isSuccessful')->willReturn(true); + $httpResponse->method('getBody')->willReturn(null); + + $this->http->method('post')->willReturn($httpResponse); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No JSON-RPC response received.'); + + $this->client->call('test'); + } + + public function testThrowsIfInvalidResponseObject(): void + { + $this->client->setEndpoint('http://test'); + + $stream = $this->createMock(StreamInterface::class); + $stream->method('getContents')->willReturn('{"ok":1}'); + + $httpResponse = $this->createMock(HttpResponse::class); + $httpResponse->method('isSuccessful')->willReturn(true); + $httpResponse->method('getBody')->willReturn($stream); + + $this->http->method('post')->willReturn($httpResponse); + $this->serializer->method('deserialize')->willReturn(new \stdClass()); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Invalid JSON-RPC response.'); + + $this->client->call('test'); + } + + public function testSuccessfulCall(): void + { + $this->client->setEndpoint('http://test'); + $this->client->setAuth('auth'); + + $stream = $this->createMock(StreamInterface::class); + $stream->method('getContents')->willReturn('{"result":123}'); + + $httpResponse = $this->createMock(HttpResponse::class); + $httpResponse->method('isSuccessful')->willReturn(true); + $httpResponse->method('getBody')->willReturn($stream); + + $this->http->method('post')->willReturn($httpResponse); + + $expected = $this->createMock(JsonRpcResponse::class); + + $this->serializer + ->method('deserialize') + ->with('{"result":123}', JsonRpcResponse::class, 'json') + ->willReturn($expected); + + $out = $this->client->call('method', ['a' => 1]); + + $this->assertSame($expected, $out); + } +} diff --git a/tests/Loader/SshAdapterConfigLoaderTest.php b/tests/Loader/SshAdapterConfigLoaderTest.php new file mode 100644 index 0000000..0e8dbbd --- /dev/null +++ b/tests/Loader/SshAdapterConfigLoaderTest.php @@ -0,0 +1,75 @@ +createMock(SerializerInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $logger->expects($this->once()) + ->method('error') + ->with($this->stringContains('does not exist')); + + $loader = new SshAdapterConfigLoader( + '/nonexistent.yml', + $serializer, + $logger + ); + + $this->assertNull($loader->load()); + } + + public function testReturnsConfigOnSuccessfulDeserialize(): void + { + $path = tempnam(sys_get_temp_dir(), 't'); + file_put_contents($path, "options:\n - a\n"); + + $serializer = $this->createMock(SerializerInterface::class); + + $config = new SshAdapterConfig(['a']); + + $serializer->method('deserialize') + ->with($this->stringContains('options'), SshAdapterConfig::class, 'yaml') + ->willReturn($config); + + $loader = new SshAdapterConfigLoader($path, $serializer); + + $this->assertSame($config, $loader->load()); + + unlink($path); + } + + public function testReturnsNullOnDeserializeException(): void + { + $path = tempnam(sys_get_temp_dir(), 't'); + file_put_contents($path, "broken:\n - x\n"); + + $serializer = $this->createMock(SerializerInterface::class); + $logger = $this->createMock(LoggerInterface::class); + + $serializer->method('deserialize') + ->willThrowException( + new BadMethodCallException() + ); + + $logger->expects($this->once()) + ->method('error') + ->with($this->stringContains('Unable to deserialize')); + + $loader = new SshAdapterConfigLoader($path, $serializer, $logger); + + $this->assertNull($loader->load()); + + unlink($path); + } +} diff --git a/tests/Model/Config/SshAdapterConfigTest.php b/tests/Model/Config/SshAdapterConfigTest.php new file mode 100644 index 0000000..d61c741 --- /dev/null +++ b/tests/Model/Config/SshAdapterConfigTest.php @@ -0,0 +1,26 @@ +assertSame(['a', 'b'], $config->getOptions()); + } + + public function testAddOption(): void + { + $config = new SshAdapterConfig(['x']); + + $returned = $config->addOption('y'); + + $this->assertSame($config, $returned); + $this->assertSame(['x', 'y'], $config->getOptions()); + } +} diff --git a/tests/Model/HttpResponseTest.php b/tests/Model/Http/HttpResponseTest.php similarity index 84% rename from tests/Model/HttpResponseTest.php rename to tests/Model/Http/HttpResponseTest.php index 35b9358..38bc9f3 100644 --- a/tests/Model/HttpResponseTest.php +++ b/tests/Model/Http/HttpResponseTest.php @@ -1,6 +1,6 @@ assertNull($response->parseJson()); } - public function testGetStatusCode() + public function testGetStatusCode(): void { $response = new HttpResponse(200); $this->assertSame(200, $response->getStatusCode()); } - public function testGetBody() + public function testGetBody(): void { /** @var StreamInterface|MockObject $stream */ $stream = $this->createMock(StreamInterface::class); @@ -35,7 +35,7 @@ public function testGetBody() $this->assertSame($stream, $response->getBody()); } - public function testIsSuccessful() + public function testIsSuccessful(): void { $response = new HttpResponse(200); $this->assertTrue($response->isSuccessful()); @@ -44,7 +44,7 @@ public function testIsSuccessful() $this->assertFalse($response->isSuccessful()); } - public function testParseJsonReturnsArray() + public function testParseJsonReturnsArray(): void { /** @var StreamInterface|MockObject $stream */ $stream = $this->createMock(StreamInterface::class); @@ -54,13 +54,13 @@ public function testParseJsonReturnsArray() $this->assertSame(['foo' => 'bar'], $response->parseJson()); } - public function testParseJsonReturnsNullIfBodyIsNull() + public function testParseJsonReturnsNullIfBodyIsNull(): void { $response = new HttpResponse(200, null); $this->assertNull($response->parseJson()); } - public function testParseJsonThrowsExceptionOnInvalidJson() + public function testParseJsonThrowsExceptionOnInvalidJson(): void { $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Invalid JSON response.'); diff --git a/tests/Model/JsonRpc/JsonRpcErrorTest.php b/tests/Model/JsonRpc/JsonRpcErrorTest.php new file mode 100644 index 0000000..59ccc0e --- /dev/null +++ b/tests/Model/JsonRpc/JsonRpcErrorTest.php @@ -0,0 +1,34 @@ + 1] + ); + + $this->assertSame(123, $error->getCode()); + $this->assertSame('Something went wrong', $error->getMessage()); + $this->assertSame(['x' => 1], $error->getData()); + } + + public function testGettersWithNullData(): void + { + $error = new JsonRpcError( + code: -1, + message: 'Error message' + ); + + $this->assertSame(-1, $error->getCode()); + $this->assertSame('Error message', $error->getMessage()); + $this->assertNull($error->getData()); + } +} diff --git a/tests/Model/JsonRpc/JsonRpcResponseTest.php b/tests/Model/JsonRpc/JsonRpcResponseTest.php new file mode 100644 index 0000000..ee4fbff --- /dev/null +++ b/tests/Model/JsonRpc/JsonRpcResponseTest.php @@ -0,0 +1,43 @@ + true], + error: null + ); + + $this->assertSame('123', $response->getId()); + $this->assertSame(['ok' => true], $response->getResult()); + $this->assertNull($response->getError()); + $this->assertTrue($response->isSuccessful()); + } + + public function testErrorResponse(): void + { + $error = new JsonRpcError( + code: -32600, + message: 'Invalid request' + ); + + $response = new JsonRpcResponse( + id: null, + result: null, + error: $error + ); + + $this->assertNull($response->getId()); + $this->assertNull($response->getResult()); + $this->assertSame($error, $response->getError()); + $this->assertFalse($response->isSuccessful()); + } +}