From 6bae922b811c03b704c77c31dff3d06170281207 Mon Sep 17 00:00:00 2001 From: Neto Date: Sun, 18 Oct 2026 19:40:07 +1030 Subject: [PATCH] Send requests through an internal transport and test the shape of every request. Tests replace the transport with a recorder, so the suite no longer touches the network. Requests without params now send {} instead of [], and GETs without params no longer end in '?'. --- CHANGELOG.md | 2 + CONTRIBUTING.md | 2 + src/BitGoExpress.php | 24 +- src/BitGoSDK.php | 29 +-- src/Internal/CurlTransport.php | 41 ++++ src/Internal/Transport.php | 30 +++ tests/AccessesPrivateMembers.php | 2 +- tests/CurlTransportTest.php | 78 ++++++ tests/DeprecatedAliasTest.php | 8 +- tests/ErrorHandlingTest.php | 122 +++++++--- tests/ExpressLoginTest.php | 57 +++-- tests/ExpressPingTest.php | 15 +- tests/ExpressRequestShapeTest.php | 336 ++++++++++++++++++++++++++ tests/Fixtures/CapturedRequest.php | 40 +++ tests/Fixtures/RecordingTransport.php | 67 +++++ tests/PolicyRuleTest.php | 11 +- tests/RequestParamsTest.php | 81 ++++--- tests/SdkRequestShapeTest.php | 259 ++++++++++++++++++++ tests/SmokeTest.php | 2 +- 19 files changed, 1058 insertions(+), 148 deletions(-) create mode 100644 src/Internal/CurlTransport.php create mode 100644 src/Internal/Transport.php create mode 100644 tests/CurlTransportTest.php create mode 100644 tests/ExpressRequestShapeTest.php create mode 100644 tests/Fixtures/CapturedRequest.php create mode 100644 tests/Fixtures/RecordingTransport.php create mode 100644 tests/SdkRequestShapeTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 8cbdfd5..d8da1aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ Entries for 2.2.3 and earlier were reconstructed from the git history in October - `.editorconfig` and `.gitattributes`. - Dev tooling: PHPUnit 9.6, PHPStan (level 6, with a baseline for the existing code) and PHP-CS-Fixer (PSR-12). - A GitHub Actions CI workflow: `php -l` and PHPUnit on PHP 7.4 to 8.5 with lowest and highest dependencies, plus PHPStan and a coding-standard check. +- Request-shape tests for every public request method, checking the HTTP method, URL, headers and body. Requests go through an internal transport that tests replace with a recorder, so the test suite no longer touches the network. - Composer scripts: `composer test`, `composer analyse`, `composer cs` and `composer cs:fix`. - `homepage` and `support` (issues, source, security policy) in `composer.json`. @@ -32,6 +33,7 @@ Entries for 2.2.3 and earlier were reconstructed from the git history in October - **`0`, `false` and `''` params are now sent.** Only `null` params are dropped. Before, `chain => 0`, `minConfirms => 0`, `numConfirmations => 0` and every `false` flag were silently left out. If you passed `0` or `false` to mean "leave it unset", pass `null` instead. GET query strings now send booleans as `true`/`false` instead of `1`/`0`. - **Errors now come back as `['error' => …, 'status' => …]`.** A cURL failure, a non-2xx status or an invalid JSON body used to return `null` or BitGo's raw error body. Any other fields BitGo sent are kept, so `isset($r['error'])` checks keep working. `status` is the HTTP status, or `0` if no response arrived. `buildTransaction()` and `signBuildTransaction()` return the same fields as an object. - Amounts in `sendTransaction()`, `sendTransactionToMany()` and `buildTransaction()` accept an `int` or a string of digits, and are always sent as strings, so 18-decimal amounts no longer overflow `PHP_INT_MAX`. `toSatoshi()` accepts strings and no longer breaks under locales that use a decimal comma. `toBTC()` no longer converts through a float. +- Requests without params send `{}` as the JSON body instead of `[]`, and GET requests without params no longer end the URL with `?`. - Requests no longer force IPv4. cURL uses IPv4 or IPv6 as the system allows. A local address bound with `setRequestIP()` decides the IP version. - `BitGoSDK::setRequestIP()` returns `true` if the IP was applied, and `false` on testnet or for an invalid IP. - **TLS certificates are checked against the system CA store.** The bundled 2022 `cacert.pem` is gone. If PHP finds no system CA store, the Mozilla bundle from the new `composer/ca-bundle` dependency is used. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3df2613..f68b0f5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,6 +74,8 @@ CI runs these checks on every pull request. PHPUnit and `php -l` run on PHP 7.4 PHPStan runs at level 6 against a baseline (`phpstan-baseline.neon`) of the errors in the existing code. Don't add new entries to it. When a fix removes an error, regenerate the baseline with `vendor/bin/phpstan analyse --generate-baseline phpstan-baseline.neon`. + Tests never touch the network. To check what a method sends, install `Tests\Fixtures\RecordingTransport` on the client with `RecordingTransport::on($client)`, then read the captured method, URL, headers and body. You can queue responses or cURL failures on it too. When you add a public method, add a case for it to `SdkRequestShapeTest` or `ExpressRequestShapeTest`. A guard test fails until you do. + 2. Exercise your change against BitGo **testnet**. Copy the relevant script from `examples/` into `local_tests/` (which is gitignored), fill in your testnet token and wallet ID there, and run it from the repository root: ```sh diff --git a/src/BitGoExpress.php b/src/BitGoExpress.php index 2481c4d..4ce2536 100644 --- a/src/BitGoExpress.php +++ b/src/BitGoExpress.php @@ -16,6 +16,8 @@ use Composer\CaBundle\CaBundle; use neto737\BitGoSDK\Enum\CurrencyCode; +use neto737\BitGoSDK\Internal\CurlTransport; +use neto737\BitGoSDK\Internal\Transport; class BitGoExpress { @@ -44,6 +46,7 @@ class BitGoExpress private ?string $caFile = null; private bool $verifyTls = true; private bool $forceIPv4 = false; + private ?Transport $transport = null; /** * BitGoExpress Initialization @@ -672,20 +675,15 @@ private function __execute(string $requestType = 'POST', bool $array = true, boo $params = self::withoutNulls($this->params); $this->params = []; - $ch = curl_init(); - curl_setopt_array($ch, $this->curlOptions($requestType, $params, $authenticate)); + $this->transport = $this->transport ?? new CurlTransport(); + $response = $this->transport->send($this->curlOptions($requestType, $params, $authenticate)); - $response = curl_exec($ch); - $errno = curl_errno($ch); - $error = curl_error($ch); - $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); - - if ($errno !== 0) { - $result = ['error' => $error !== '' ? $error : 'cURL error ' . $errno, 'status' => 0]; + if ($response['errno'] !== 0) { + $result = ['error' => $response['error'] !== '' ? $response['error'] : 'cURL error ' . $response['errno'], 'status' => 0]; return $array ? $result : (object) $result; } - return self::decodeResponse((string) $response, $status, $array); + return self::decodeResponse($response['body'], $response['status'], $array); } /** @@ -698,12 +696,14 @@ private function __execute(string $requestType = 'POST', bool $array = true, boo private function curlOptions(string $requestType, array $params, bool $authenticate = true): array { $options = [CURLOPT_URL => $this->url]; + // Cast to an object so that no params are sent as {} rather than []. + $body = json_encode((object) $params); if ($requestType === 'POST') { $options[CURLOPT_POST] = true; - $options[CURLOPT_POSTFIELDS] = json_encode($params); + $options[CURLOPT_POSTFIELDS] = $body; } elseif ($requestType === 'PUT') { $options[CURLOPT_CUSTOMREQUEST] = 'PUT'; - $options[CURLOPT_POSTFIELDS] = json_encode($params); + $options[CURLOPT_POSTFIELDS] = $body; } $options[CURLOPT_SSL_VERIFYPEER] = $this->verifyTls; $options[CURLOPT_SSL_VERIFYHOST] = $this->verifyTls ? 2 : 0; diff --git a/src/BitGoSDK.php b/src/BitGoSDK.php index 6fb28f1..ccc676f 100644 --- a/src/BitGoSDK.php +++ b/src/BitGoSDK.php @@ -17,6 +17,8 @@ use Composer\CaBundle\CaBundle; use neto737\BitGoSDK\Enum\CurrencyCode; use neto737\BitGoSDK\Enum\AddressType; +use neto737\BitGoSDK\Internal\CurlTransport; +use neto737\BitGoSDK\Internal\Transport; class BitGoSDK { @@ -45,6 +47,7 @@ class BitGoSDK private int $connectTimeout = self::DEFAULT_CONNECT_TIMEOUT; private ?string $caFile = null; private bool $forceIPv4 = false; + private ?Transport $transport = null; /** * BitGoSDK Initialization @@ -1021,19 +1024,14 @@ private function __execute(string $requestType = 'POST') $params = self::withoutNulls($this->params); $this->params = []; - $ch = curl_init(); - curl_setopt_array($ch, $this->curlOptions($requestType, $params)); + $this->transport = $this->transport ?? new CurlTransport(); + $response = $this->transport->send($this->curlOptions($requestType, $params)); - $response = curl_exec($ch); - $errno = curl_errno($ch); - $error = curl_error($ch); - $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); - - if ($errno !== 0) { - return ['error' => $error !== '' ? $error : 'cURL error ' . $errno, 'status' => 0]; + if ($response['errno'] !== 0) { + return ['error' => $response['error'] !== '' ? $response['error'] : 'cURL error ' . $response['errno'], 'status' => 0]; } - return self::decodeResponse((string) $response, $status); + return self::decodeResponse($response['body'], $response['status']); } /** @@ -1045,18 +1043,21 @@ private function __execute(string $requestType = 'POST') private function curlOptions(string $requestType, array $params): array { $options = [CURLOPT_URL => $this->url]; + // Cast to an object so that no params are sent as {} rather than []. + $body = json_encode((object) $params); if ($requestType === 'GET') { - $options[CURLOPT_URL] = $this->url . '?' . self::buildQuery($params); + $query = self::buildQuery($params); + $options[CURLOPT_URL] = $query === '' ? $this->url : $this->url . '?' . $query; $options[CURLOPT_CUSTOMREQUEST] = 'GET'; } elseif ($requestType === 'PUT') { $options[CURLOPT_CUSTOMREQUEST] = 'PUT'; - $options[CURLOPT_POSTFIELDS] = json_encode($params); + $options[CURLOPT_POSTFIELDS] = $body; } elseif ($requestType === 'DELETE') { $options[CURLOPT_CUSTOMREQUEST] = 'DELETE'; - $options[CURLOPT_POSTFIELDS] = json_encode($params); + $options[CURLOPT_POSTFIELDS] = $body; } elseif ($requestType === 'POST') { $options[CURLOPT_POST] = true; - $options[CURLOPT_POSTFIELDS] = json_encode($params); + $options[CURLOPT_POSTFIELDS] = $body; } $options[CURLOPT_SSL_VERIFYPEER] = true; $options[CURLOPT_RETURNTRANSFER] = true; diff --git a/src/Internal/CurlTransport.php b/src/Internal/CurlTransport.php new file mode 100644 index 0000000..91ca0e6 --- /dev/null +++ b/src/Internal/CurlTransport.php @@ -0,0 +1,41 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +namespace neto737\BitGoSDK\Internal; + +/** + * Sends requests with ext-curl. + * + * @internal Not part of the public API. It may change or go away in any release. + */ +final class CurlTransport implements Transport +{ + public function send(array $options): array + { + $ch = curl_init(); + if ($ch === false) { + return ['body' => '', 'status' => 0, 'errno' => CURLE_FAILED_INIT, 'error' => 'Failed to initialise cURL']; + } + + curl_setopt_array($ch, $options); + $body = curl_exec($ch); + + return [ + 'body' => is_string($body) ? $body : '', + 'status' => (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE), + 'errno' => curl_errno($ch), + 'error' => curl_error($ch), + ]; + } +} diff --git a/src/Internal/Transport.php b/src/Internal/Transport.php new file mode 100644 index 0000000..09e2459 --- /dev/null +++ b/src/Internal/Transport.php @@ -0,0 +1,30 @@ + + * @license https://opensource.org/licenses/MIT MIT License + */ + +namespace neto737\BitGoSDK\Internal; + +/** + * Sends one HTTP request described by cURL options. Lets tests capture requests instead of sending them. + * + * @internal Not part of the public API. It may change or go away in any release. + */ +interface Transport +{ + /** + * @param array $options cURL options, as passed to curl_setopt_array() + * @return array{body: string, status: int, errno: int, error: string} + * The response body and HTTP status, or a non-zero cURL errno and its message + */ + public function send(array $options): array; +} diff --git a/tests/AccessesPrivateMembers.php b/tests/AccessesPrivateMembers.php index dd6032c..91fae73 100644 --- a/tests/AccessesPrivateMembers.php +++ b/tests/AccessesPrivateMembers.php @@ -6,7 +6,7 @@ use ReflectionProperty; /** - * Reflection helpers for reaching client internals until requests go through a capturable transport. + * Reflection helpers for reaching client internals. To check what a request sends, prefer Fixtures\RecordingTransport. */ trait AccessesPrivateMembers { diff --git a/tests/CurlTransportTest.php b/tests/CurlTransportTest.php new file mode 100644 index 0000000..8becdd3 --- /dev/null +++ b/tests/CurlTransportTest.php @@ -0,0 +1,78 @@ +send([ + CURLOPT_URL => 'file://' . str_replace('\\', '/', (string) realpath($file)), + CURLOPT_RETURNTRANSFER => true, + ]); + } finally { + unlink($file); + } + + self::assertSame('{"id":"w1"}', $response['body']); + self::assertSame(0, $response['errno']); + self::assertSame('', $response['error']); + } + + public function testReportsCurlErrors(): void + { + $response = (new CurlTransport())->send([ + CURLOPT_URL => 'nosuchscheme://example.com/', + CURLOPT_RETURNTRANSFER => true, + ]); + + self::assertSame('', $response['body']); + self::assertSame(0, $response['status']); + self::assertSame(CURLE_UNSUPPORTED_PROTOCOL, $response['errno']); + self::assertNotSame('', $response['error']); + } + + /** + * @dataProvider clients + * @param BitGoSDK|BitGoExpress $client + * @param callable(mixed): mixed $request + */ + public function testClientsSendThroughCurlByDefault($client, callable $request): void + { + // cURL rejects the scheme before anything leaves the machine. + $this->setPrivate($client, 'APIEndpoint', 'nosuchscheme://example.com/api/v2/tbtc'); + + $result = (array) $request($client); + + self::assertInstanceOf(CurlTransport::class, $this->getPrivate($client, 'transport')); + self::assertSame(0, $result['status']); + self::assertIsString($result['error']); + self::assertNotSame('', $result['error']); + } + + /** + * @return array + */ + public static function clients(): array + { + return [ + 'BitGoSDK' => [new BitGoSDK('token', 'tbtc', true), static fn (BitGoSDK $c) => $c->getWallet()], + 'BitGoExpress' => [new BitGoExpress('localhost', 3080, 'tbtc'), static fn (BitGoExpress $c) => $c->buildTransaction([])], + ]; + } +} diff --git a/tests/DeprecatedAliasTest.php b/tests/DeprecatedAliasTest.php index 3917d77..9b9344f 100644 --- a/tests/DeprecatedAliasTest.php +++ b/tests/DeprecatedAliasTest.php @@ -4,6 +4,7 @@ use neto737\BitGoSDK\BitGoExpress; use neto737\BitGoSDK\BitGoSDK; +use neto737\BitGoSDK\Tests\Fixtures\RecordingTransport; use neto737\BitGoSDK\Tests\Fixtures\RecordingWebhookSdk; use PHPUnit\Framework\TestCase; use ReflectionMethod; @@ -13,16 +14,15 @@ */ final class DeprecatedAliasTest extends TestCase { - use AccessesPrivateMembers; - public function testFanoutBuildsTheFanoutUrl(): void { - $express = new BitGoExpress('127.0.0.1', 1, 'tbtc'); + $express = new BitGoExpress('localhost', 3080, 'tbtc'); $express->walletId = 'wallet-id'; + $transport = RecordingTransport::on($express); $express->fanoutWalletUnspents('passphrase'); - self::assertSame('http://127.0.0.1:1/api/v2/tbtc/wallet/wallet-id/fanoutunspents', $this->getPrivate($express, 'url')); + self::assertSame('http://localhost:3080/api/v2/tbtc/wallet/wallet-id/fanoutunspents', $transport->last()->url); } public function testFunoutForwardsEveryArgumentToFanout(): void diff --git a/tests/ErrorHandlingTest.php b/tests/ErrorHandlingTest.php index 15153aa..539f436 100644 --- a/tests/ErrorHandlingTest.php +++ b/tests/ErrorHandlingTest.php @@ -4,6 +4,7 @@ use neto737\BitGoSDK\BitGoExpress; use neto737\BitGoSDK\BitGoSDK; +use neto737\BitGoSDK\Tests\Fixtures\RecordingTransport; use PHPUnit\Framework\TestCase; /** @@ -11,87 +12,136 @@ */ final class ErrorHandlingTest extends TestCase { - use AccessesPrivateMembers; - public function testSdkReturnsErrorWhenRequestFails(): void { $sdk = new BitGoSDK('token', 'tbtc', true); - $this->setPrivate($sdk, 'APIEndpoint', 'http://127.0.0.1:1/api/v2/tbtc'); + RecordingTransport::on($sdk)->fail(CURLE_COULDNT_CONNECT, 'Failed to connect to app.bitgo-test.com'); - $result = $sdk->getWallet(); + self::assertSame(['error' => 'Failed to connect to app.bitgo-test.com', 'status' => 0], $sdk->getWallet()); + } + + public function testErrorWithoutMessageNamesTheCurlErrno(): void + { + $sdk = new BitGoSDK('token', 'tbtc', true); + RecordingTransport::on($sdk)->fail(CURLE_OPERATION_TIMEDOUT); - self::assertIsString($result['error']); - self::assertNotSame('', $result['error']); - self::assertSame(0, $result['status']); + self::assertSame(['error' => 'cURL error ' . CURLE_OPERATION_TIMEDOUT, 'status' => 0], $sdk->getWallet()); } - public function testExpressReturnsErrorObjectWhenObjectRequestFails(): void + public function testExpressReturnsErrorArrayWhenRequestFails(): void { - $express = new BitGoExpress('127.0.0.1', 1, 'tbtc'); + $express = new BitGoExpress('localhost', 3080, 'tbtc'); + RecordingTransport::on($express)->fail(CURLE_COULDNT_CONNECT, 'Connection refused'); - $result = $express->buildTransaction([['address' => 'a', 'amount' => 1]]); + self::assertSame(['error' => 'Connection refused', 'status' => 0], $express->pingExpress()); + } - self::assertInstanceOf(\stdClass::class, $result); - self::assertIsString($result->error); - self::assertSame(0, $result->status); + public function testExpressReturnsErrorObjectWhenObjectRequestFails(): void + { + $express = new BitGoExpress('localhost', 3080, 'tbtc'); + RecordingTransport::on($express)->fail(CURLE_COULDNT_CONNECT, 'Connection refused'); + + self::assertEquals((object) ['error' => 'Connection refused', 'status' => 0], $express->buildTransaction([['address' => 'a', 'amount' => 1]])); } public function testSuccessfulResponseIsReturnedUnchanged(): void { - self::assertSame(['id' => 'w1'], $this->invoke(BitGoSDK::class, 'decodeResponse', '{"id":"w1"}', 200)); - self::assertNull($this->invoke(BitGoSDK::class, 'decodeResponse', '', 204)); + $sdk = new BitGoSDK('token', 'tbtc', true); + RecordingTransport::on($sdk)->respond('{"id":"w1"}')->respond('', 204); + + self::assertSame(['id' => 'w1'], $sdk->getWallet()); + self::assertNull($sdk->getWallet()); } public function testBitGoErrorKeepsItsFieldsAndGainsStatus(): void { - self::assertSame( - ['error' => 'wallet not found', 'name' => 'WalletNotFound', 'status' => 404], - $this->invoke(BitGoSDK::class, 'decodeResponse', '{"error":"wallet not found","name":"WalletNotFound"}', 404) - ); + $sdk = new BitGoSDK('token', 'tbtc', true); + RecordingTransport::on($sdk)->respond('{"error":"wallet not found","name":"WalletNotFound"}', 404); + + self::assertSame(['error' => 'wallet not found', 'name' => 'WalletNotFound', 'status' => 404], $sdk->getWallet()); } public function testNonJsonErrorReportsStatus(): void { - self::assertSame( - ['error' => 'HTTP 502', 'status' => 502], - $this->invoke(BitGoSDK::class, 'decodeResponse', 'Bad Gateway', 502) - ); + $sdk = new BitGoSDK('token', 'tbtc', true); + RecordingTransport::on($sdk)->respond('Bad Gateway', 502); + + self::assertSame(['error' => 'HTTP 502', 'status' => 502], $sdk->getWallet()); } public function testInvalidJsonOnSuccessIsAnError(): void { - $result = $this->invoke(BitGoSDK::class, 'decodeResponse', '{"id":', 200); + $sdk = new BitGoSDK('token', 'tbtc', true); + RecordingTransport::on($sdk)->respond('{"id":', 200); + + $result = $sdk->getWallet(); - self::assertIsArray($result); self::assertStringStartsWith('Invalid JSON response: ', $result['error']); self::assertSame(200, $result['status']); } public function testExpressObjectErrorKeepsItsFields(): void { - $result = $this->invoke(BitGoExpress::class, 'decodeResponse', '{"error":"insufficient funds","name":"Error"}', 400, false); + $express = new BitGoExpress('localhost', 3080, 'tbtc'); + RecordingTransport::on($express)->respond('{"error":"insufficient funds","name":"Error"}', 400); - self::assertEquals((object) ['error' => 'insufficient funds', 'name' => 'Error', 'status' => 400], $result); + self::assertEquals((object) ['error' => 'insufficient funds', 'name' => 'Error', 'status' => 400], $express->buildTransaction([])); } - public function testTimeoutsDefaultAndCanBeChanged(): void + /** + * @return array + */ + public static function clients(): array { - $sdk = new BitGoSDK('token', 'tbtc', true); - self::assertSame(BitGoSDK::DEFAULT_TIMEOUT, $this->getPrivate($sdk, 'timeout')); - self::assertSame(BitGoSDK::DEFAULT_CONNECT_TIMEOUT, $this->getPrivate($sdk, 'connectTimeout')); + return [ + 'BitGoSDK' => [ + new BitGoSDK('token', 'tbtc', true), + static fn (BitGoSDK $c) => $c->getTotalBalances(), + BitGoSDK::DEFAULT_TIMEOUT, + BitGoSDK::DEFAULT_CONNECT_TIMEOUT, + ], + 'BitGoExpress' => [ + new BitGoExpress('localhost', 3080, 'tbtc'), + static fn (BitGoExpress $c) => $c->pingExpress(), + BitGoExpress::DEFAULT_TIMEOUT, + BitGoExpress::DEFAULT_CONNECT_TIMEOUT, + ], + ]; + } - $sdk->setTimeout(5); - $sdk->setConnectTimeout(2); + /** + * @dataProvider clients + * @param BitGoSDK|BitGoExpress $client + * @param callable(mixed): mixed $request + */ + public function testTimeoutsDefaultAndCanBeChanged($client, callable $request, int $timeout, int $connectTimeout): void + { + $transport = RecordingTransport::on($client); - self::assertSame(5, $this->getPrivate($sdk, 'timeout')); - self::assertSame(2, $this->getPrivate($sdk, 'connectTimeout')); + $request($client); + $client->setTimeout(5); + $client->setConnectTimeout(2); + $request($client); + + self::assertSame($timeout, $transport->requests[0]->options[CURLOPT_TIMEOUT]); + self::assertSame($connectTimeout, $transport->requests[0]->options[CURLOPT_CONNECTTIMEOUT]); + self::assertSame(5, $transport->requests[1]->options[CURLOPT_TIMEOUT]); + self::assertSame(2, $transport->requests[1]->options[CURLOPT_CONNECTTIMEOUT]); } public function testNegativeTimeoutIsRejected(): void { - $express = new BitGoExpress('127.0.0.1', 1, 'tbtc'); + $express = new BitGoExpress('localhost', 3080, 'tbtc'); $this->expectException(\InvalidArgumentException::class); $express->setTimeout(-1); } + + public function testNegativeConnectTimeoutIsRejected(): void + { + $sdk = new BitGoSDK('token', 'tbtc', true); + + $this->expectException(\InvalidArgumentException::class); + $sdk->setConnectTimeout(-1); + } } diff --git a/tests/ExpressLoginTest.php b/tests/ExpressLoginTest.php index 85aa23b..40dfa97 100644 --- a/tests/ExpressLoginTest.php +++ b/tests/ExpressLoginTest.php @@ -4,6 +4,7 @@ use neto737\BitGoSDK\BitGoExpress; use neto737\BitGoSDK\BitGoSDK; +use neto737\BitGoSDK\Tests\Fixtures\RecordingTransport; use PHPUnit\Framework\TestCase; /** @@ -12,67 +13,73 @@ */ final class ExpressLoginTest extends TestCase { - use AccessesPrivateMembers; - private const USER_AGENT = 'User-Agent: BitGoSDK-PHP/' . BitGoSDK::VERSION; public function testRequestWithoutTokenStillSendsContentType(): void { - $express = new BitGoExpress('127.0.0.1', 1, 'tbtc'); + $express = new BitGoExpress('localhost', 3080, 'tbtc'); + $transport = RecordingTransport::on($express); + + $express->pingExpress(); - self::assertSame(['Content-Type: application/json', self::USER_AGENT], $this->headers($express)); + self::assertSame(['Content-Type: application/json', self::USER_AGENT], $transport->last()->headers); } public function testRequestSendsToken(): void { - $express = new BitGoExpress('127.0.0.1', 1, 'tbtc'); + $express = new BitGoExpress('localhost', 3080, 'tbtc'); $express->accessToken = 'v2xtoken'; + $transport = RecordingTransport::on($express); - self::assertSame(['Content-Type: application/json', 'Authorization: Bearer v2xtoken', self::USER_AGENT], $this->headers($express)); - } - - public function testLoginRequestSendsNoToken(): void - { - $express = new BitGoExpress('127.0.0.1', 1, 'tbtc'); - $express->accessToken = 'v2xtoken'; + $express->pingExpress(); - self::assertSame(['Content-Type: application/json', self::USER_AGENT], $this->headers($express, false)); + self::assertSame(['Content-Type: application/json', 'Authorization: Bearer v2xtoken', self::USER_AGENT], $transport->last()->headers); } - public function testCallsAfterLoginSendToken(): void + public function testLoginRequestSendsNoToken(): void { - $express = new BitGoExpress('127.0.0.1', 1, 'tbtc'); + $express = new BitGoExpress('localhost', 3080, 'tbtc'); $express->accessToken = 'v2xtoken'; + $transport = RecordingTransport::on($express); $express->login('user@example.com', 'password', '000000'); - self::assertSame(['Content-Type: application/json', 'Authorization: Bearer v2xtoken', self::USER_AGENT], $this->headers($express)); + self::assertSame(['Content-Type: application/json', self::USER_AGENT], $transport->last()->headers); } public function testSuccessfulLoginStoresToken(): void { - $express = new BitGoExpress('127.0.0.1', 1, 'tbtc'); + $express = new BitGoExpress('localhost', 3080, 'tbtc'); + $transport = RecordingTransport::on($express)->respond('{"access_token":"v2xnew","token_type":"bearer"}'); - $this->invoke($express, 'rememberAccessToken', ['access_token' => 'v2xnew', 'token_type' => 'bearer']); + $express->login('user@example.com', 'password', '000000'); + $express->pingExpress(); self::assertSame('v2xnew', $express->accessToken); + self::assertContains('Authorization: Bearer v2xnew', $transport->last()->headers); } public function testFailedLoginKeepsToken(): void { - $express = new BitGoExpress('127.0.0.1', 1, 'tbtc'); + $express = new BitGoExpress('localhost', 3080, 'tbtc'); $express->accessToken = 'v2xtoken'; + $transport = RecordingTransport::on($express)->respond('{"error":"needs a valid otp","access_token":"v2xbogus"}', 401); - $this->invoke($express, 'rememberAccessToken', ['error' => 'needs a valid otp', 'status' => 401]); + $express->login('user@example.com', 'password', '000000'); + $express->pingExpress(); self::assertSame('v2xtoken', $express->accessToken); + self::assertContains('Authorization: Bearer v2xtoken', $transport->last()->headers); } - /** - * @return mixed - */ - private function headers(BitGoExpress $express, bool $authenticate = true) + public function testUnreachableLoginKeepsToken(): void { - return $this->invoke($express, 'curlOptions', 'POST', [], $authenticate)[CURLOPT_HTTPHEADER]; + $express = new BitGoExpress('localhost', 3080, 'tbtc'); + $express->accessToken = 'v2xtoken'; + RecordingTransport::on($express)->fail(CURLE_COULDNT_CONNECT, 'Connection refused'); + + $express->login('user@example.com', 'password', '000000'); + + self::assertSame('v2xtoken', $express->accessToken); } } diff --git a/tests/ExpressPingTest.php b/tests/ExpressPingTest.php index 0754db2..3bcf408 100644 --- a/tests/ExpressPingTest.php +++ b/tests/ExpressPingTest.php @@ -3,32 +3,31 @@ namespace neto737\BitGoSDK\Tests; use neto737\BitGoSDK\BitGoExpress; +use neto737\BitGoSDK\Tests\Fixtures\RecordingTransport; use PHPUnit\Framework\TestCase; /** * B12: ping() pings BitGo through Express, and pingExpress() pings Express alone. - * - * The client points at a closed local port, so each call fails fast and the URL it built can be inspected. */ final class ExpressPingTest extends TestCase { - use AccessesPrivateMembers; - public function testPingGoesThroughExpressToBitGo(): void { - $express = new BitGoExpress('127.0.0.1', 1, 'tbtc'); + $express = new BitGoExpress('localhost', 3080, 'tbtc'); + $transport = RecordingTransport::on($express); $express->ping(); - self::assertSame('http://127.0.0.1:1/api/v2/ping', $this->getPrivate($express, 'url')); + self::assertSame('http://localhost:3080/api/v2/ping', $transport->last()->url); } public function testPingExpressPingsExpressOnly(): void { - $express = new BitGoExpress('127.0.0.1', 1, 'tbtc'); + $express = new BitGoExpress('localhost', 3080, 'tbtc'); + $transport = RecordingTransport::on($express); $express->pingExpress(); - self::assertSame('http://127.0.0.1:1/api/v2/pingexpress', $this->getPrivate($express, 'url')); + self::assertSame('http://localhost:3080/api/v2/pingexpress', $transport->last()->url); } } diff --git a/tests/ExpressRequestShapeTest.php b/tests/ExpressRequestShapeTest.php new file mode 100644 index 0000000..23e2756 --- /dev/null +++ b/tests/ExpressRequestShapeTest.php @@ -0,0 +1,336 @@ + 'pass', + 'prv' => 'prv1', + 'numBlocks' => 2, + 'feeRate' => 2000, + 'comment' => 'comment', + 'unspents' => ['tx1:0'], + 'minConfirms' => 1, + 'enforceMinConfirmsForChange' => false, + 'targetWalletUnspents' => 10, + 'noSplitChange' => true, + 'minValue' => 100, + 'maxValue' => 5000, + 'segwit' => true, + ]; + private const ETH_SEND_BODY = ['walletPassphrase' => 'pass', 'prv' => 'prv1', 'comment' => 'comment', 'gasPrice' => 30, 'gasLimit' => 21000, 'sequenceId' => 7]; + private const XRP_SEND_BODY = ['walletPassphrase' => 'pass', 'prv' => 'prv1', 'comment' => 'comment', 'lastLedgerSequence' => 900, 'ledgerSequenceDelta' => '10']; + + // walletPassphrase, xprv, maxNumInputsToUse, numUnspentsToMake, minValue, maxValue, minHeight, maxFeePercentage, minConfirms, + // enforceMinConfirmsForChange, feeRate, feeTxConfirmTarget + private const FANOUT_ARGS = ['pass', 'xprv1', 20, 200, null, 5000, 700000, 10, 1, false, 2000, 3]; + private const FANOUT_BODY = [ + 'walletPassphrase' => 'pass', + 'xprv' => 'xprv1', + 'maxNumInputsToUse' => 20, + 'numUnspentsToMake' => 200, + 'maxValue' => 5000, + 'minHeight' => 700000, + 'maxFeePercentage' => 10, + 'minConfirms' => 1, + 'enforceMinConfirmsForChange' => false, + 'feeRate' => 2000, + 'feeTxConfirmTarget' => 3, + ]; + + /** + * @return array|null}> + */ + public static function requests(): array + { + return [ + // Auth and health. login() is covered by testLoginRequestShape(), since it sends no token. + 'ping' => ['tbtc', static fn (BitGoExpress $c) => $c->ping(), 'GET', self::API . 'ping', null], + 'pingExpress' => ['tbtc', static fn (BitGoExpress $c) => $c->pingExpress(), 'GET', self::API . 'pingexpress', null], + + // Wallets + 'generateWallet' => [ + 'tbtc', + static fn (BitGoExpress $c) => $c->generateWallet('label', 'pass', 'xpubU', 'xpubB', 'keyternal', 'ent1', true, 30, 'code'), + 'POST', + self::TBTC . '/wallet/generate', + [ + 'label' => 'label', + 'passphrase' => 'pass', + 'userKey' => 'xpubU', + 'backupXpub' => 'xpubB', + 'backupXpubProvider' => 'keyternal', + 'enterprise' => 'ent1', + 'disableTransactionNotifications' => true, + 'passcodeEncryptionCode' => 'code', + ], + ], + 'generateWallet on teth' => [ + 'teth', + static fn (BitGoExpress $c) => $c->generateWallet('label', 'pass', null, null, null, 'ent1', null, 30), + 'POST', + self::API . 'teth/wallet/generate', + ['label' => 'label', 'passphrase' => 'pass', 'enterprise' => 'ent1', 'gasPrice' => 30], + ], + 'addWallet' => [ + 'tbtc', + static fn (BitGoExpress $c) => $c->addWallet('label', 2, 3, ['k1', 'k2', 'k3'], 'ent1', false, true), + 'POST', + self::TBTC . '/wallet', + ['label' => 'label', 'm' => 2, 'n' => 3, 'keys' => ['k1', 'k2', 'k3'], 'enterprise' => 'ent1', 'isCold' => false, 'disableTransactionNotifications' => true], + ], + + // Sending + 'sendTransaction' => [ + 'tbtc', + static fn (BitGoExpress $c) => $c->sendTransaction('addr1', 1000, ...self::SEND_ARGS), + 'POST', + self::TBTC . '/wallet/w1/sendcoins', + ['address' => 'addr1', 'amount' => '1000'] + self::UTXO_SEND_BODY, + ], + 'sendTransaction on teth' => [ + 'teth', + static fn (BitGoExpress $c) => $c->sendTransaction('addr1', '10000000000000000000', ...self::SEND_ARGS), + 'POST', + self::API . 'teth/wallet/w1/sendcoins', + ['address' => 'addr1', 'amount' => '10000000000000000000'] + self::ETH_SEND_BODY, + ], + 'sendTransaction on txrp' => [ + 'txrp', + static fn (BitGoExpress $c) => $c->sendTransaction('addr1', 1000, ...self::SEND_ARGS), + 'POST', + self::API . 'txrp/wallet/w1/sendcoins', + ['address' => 'addr1', 'amount' => '1000'] + self::XRP_SEND_BODY, + ], + 'sendTransactionToMany' => [ + 'tbtc', + static fn (BitGoExpress $c) => $c->sendTransactionToMany([['address' => 'a1', 'amount' => 1000], ['address' => 'a2', 'amount' => '2000']], ...self::SEND_ARGS), + 'POST', + self::TBTC . '/wallet/w1/sendmany', + ['recipients' => [['address' => 'a1', 'amount' => '1000'], ['address' => 'a2', 'amount' => '2000']]] + self::UTXO_SEND_BODY, + ], + 'sendTransactionToMany on teth' => [ + 'teth', + static fn (BitGoExpress $c) => $c->sendTransactionToMany([['address' => 'a1', 'amount' => 1000]], ...self::SEND_ARGS), + 'POST', + self::API . 'teth/wallet/w1/sendmany', + ['recipients' => [['address' => 'a1', 'amount' => '1000']]] + self::ETH_SEND_BODY, + ], + + // UTXO management + 'consolidateWalletUnspents' => [ + 'tbtc', + static fn (BitGoExpress $c) => $c->consolidateWalletUnspents('pass', 1, 200, 1000, 5000, 700000, 2000, 3, 10, 1, false), + 'POST', + self::TBTC . '/wallet/w1/consolidateunspents', + [ + 'walletPassphrase' => 'pass', + 'numUnspentsToMake' => 1, + 'limit' => 200, + 'minValue' => 1000, + 'maxValue' => 5000, + 'minHeight' => 700000, + 'feeRate' => 2000, + 'feeTxConfirmTarget' => 3, + 'minConfirms' => 1, + 'enforceMinConfirmsForChange' => false, + ], + ], + 'consolidateWalletUnspents on tltc' => [ + 'tltc', + static fn (BitGoExpress $c) => $c->consolidateWalletUnspents('pass', null, null, null, null, null, null, 3, 10), + 'POST', + self::API . 'tltc/wallet/w1/consolidateunspents', + ['walletPassphrase' => 'pass', 'maxFeePercentage' => 10], + ], + 'fanoutWalletUnspents' => ['tbtc', static fn (BitGoExpress $c) => $c->fanoutWalletUnspents(...self::FANOUT_ARGS), 'POST', self::TBTC . '/wallet/w1/fanoutunspents', self::FANOUT_BODY], + 'funoutWalletUnspents' => ['tbtc', static fn (BitGoExpress $c) => $c->funoutWalletUnspents(...self::FANOUT_ARGS), 'POST', self::TBTC . '/wallet/w1/fanoutunspents', self::FANOUT_BODY], + 'sweep' => [ + 'tbtc', + static fn (BitGoExpress $c) => $c->sweep('addr1', 'pass', 'xprv1', '000000', 2000, 3, 30, 7, 900, '10'), + 'POST', + self::TBTC . '/wallet/w1/sweep', + ['address' => 'addr1', 'walletPassphrase' => 'pass', 'xprv' => 'xprv1', 'otp' => '000000', 'feeRate' => 2000, 'feeTxConfirmTarget' => 3, 'sequenceId' => 7], + ], + 'sweep on txrp' => [ + 'txrp', + static fn (BitGoExpress $c) => $c->sweep('addr1', 'pass', null, null, 2000, 3, 30, null, 900, '10'), + 'POST', + self::API . 'txrp/wallet/w1/sweep', + ['address' => 'addr1', 'walletPassphrase' => 'pass', 'lastLedgerSequence' => 900, 'ledgerSequenceDelta' => '10'], + ], + + // Manual flow + 'buildTransaction' => [ + 'tbtc', + static fn (BitGoExpress $c) => $c->buildTransaction([['address' => 'a1', 'amount' => 1000]], 2, 2000, 1, false, ['tx1:0'], 10, true, 100, 5000, 30, 900, '10'), + 'POST', + self::TBTC . '/wallet/w1/tx/build', + [ + 'recipients' => [['address' => 'a1', 'amount' => '1000']], + 'numBlocks' => 2, + 'feeRate' => 2000, + 'minConfirms' => 1, + 'enforceMinConfirmsForChange' => false, + 'unspents' => ['tx1:0'], + 'targetWalletUnspents' => 10, + 'noSplitChange' => true, + 'minValue' => 100, + 'maxValue' => 5000, + ], + ], + 'buildTransaction on teth' => [ + 'teth', + static fn (BitGoExpress $c) => $c->buildTransaction([['address' => 'a1', 'amount' => 1000]], 2, 2000, null, null, null, null, null, null, null, 30), + 'POST', + self::API . 'teth/wallet/w1/tx/build', + ['recipients' => [['address' => 'a1', 'amount' => '1000']], 'gasPrice' => 30], + ], + 'signBuildTransaction' => [ + 'tbtc', + static fn (BitGoExpress $c) => $c->signBuildTransaction((object) ['txHex' => '00'], 'prv1', 'seed', (object) ['encryptedPrv' => 'enc'], 'pass'), + 'POST', + self::TBTC . '/wallet/w1/signtx', + ['txPrebuild' => ['txHex' => '00'], 'prv' => 'prv1', 'coldDerivationSeed' => 'seed', 'keychain' => ['encryptedPrv' => 'enc'], 'walletPassphrase' => 'pass'], + ], + 'sendBuildTransaction' => [ + 'tbtc', + static fn (BitGoExpress $c) => $c->sendBuildTransaction((object) ['txHex' => '00'], '000000', '00', 'comment'), + 'POST', + self::TBTC . '/wallet/w1/tx/send', + ['halfSigned' => ['txHex' => '00'], 'otp' => '000000', 'txHex' => '00', 'comment' => 'comment'], + ], + + // Keychains, sharing and approvals + 'createKeychain' => ['tbtc', static fn (BitGoExpress $c) => $c->createKeychain(), 'POST', self::TBTC . '/keychain/local', []], + 'shareWallet' => [ + 'tbtc', + static fn (BitGoExpress $c) => $c->shareWallet('user@example.com', 'view,spend', 'pass', false, true), + 'POST', + self::TBTC . '/wallet/w1/share', + ['email' => 'user@example.com', 'permissions' => 'view,spend', 'walletPassphrase' => 'pass', 'skipKeychain' => false, 'disableEmail' => true], + ], + 'acceptWalletShare' => [ + 'tbtc', + static fn (BitGoExpress $c) => $c->acceptWalletShare('s1', 'newpass', 'password', 'enc'), + 'POST', + self::TBTC . '/walletshare/s1/acceptshare', + ['walletShareId' => 's1', 'newWalletPassphrase' => 'newpass', 'userPassword' => 'password', 'overrideEncryptedPrv' => 'enc'], + ], + 'updatePendingApproval' => [ + 'tbtc', + static fn (BitGoExpress $c) => $c->updatePendingApproval('p1', 'approved', '000000'), + 'PUT', + self::TBTC . '/pendingapprovals/p1', + ['state' => 'approved', 'otp' => '000000'], + ], + + // Utilities + 'encrypt' => ['tbtc', static fn (BitGoExpress $c) => $c->encrypt('plain', 'pw'), 'POST', self::API . 'encrypt', ['input' => 'plain', 'password' => 'pw']], + 'decrypt' => ['tbtc', static fn (BitGoExpress $c) => $c->decrypt('cipher', 'pw'), 'POST', self::API . 'decrypt', ['input' => 'cipher', 'password' => 'pw']], + 'verifyAddress' => ['tbtc', static fn (BitGoExpress $c) => $c->verifyAddress('addr1'), 'POST', self::TBTC . '/verifyaddress', ['address' => 'addr1']], + ]; + } + + /** + * @dataProvider requests + * @param callable(BitGoExpress): mixed $call + * @param array|null $body + */ + public function testRequestShape(string $coin, callable $call, string $method, string $url, ?array $body): void + { + $express = new BitGoExpress('localhost', 3080, $coin); + $express->accessToken = 'v2xtoken'; + $express->walletId = 'w1'; + $transport = RecordingTransport::on($express); + + $call($express); + + self::assertCount(1, $transport->requests); + $request = $transport->last(); + self::assertSame($method, $request->method); + self::assertSame($url, $request->url); + self::assertSame(['Content-Type: application/json', 'Authorization: Bearer v2xtoken', 'User-Agent: BitGoSDK-PHP/' . BitGoSDK::VERSION], $request->headers); + self::assertSame($body, $request->json()); + } + + public function testLoginRequestShape(): void + { + $express = new BitGoExpress('localhost', 3080, 'tbtc'); + $express->accessToken = 'v2xold'; + $transport = RecordingTransport::on($express); + + $express->login('user@example.com', 'password', '000000', true); + + $request = $transport->last(); + self::assertSame('POST', $request->method); + self::assertSame(self::API . 'user/login', $request->url); + self::assertSame(['Content-Type: application/json', 'User-Agent: BitGoSDK-PHP/' . BitGoSDK::VERSION], $request->headers); + self::assertSame(['email' => 'user@example.com', 'password' => 'password', 'otp' => '000000', 'extensible' => true], $request->json()); + } + + public function testEveryRequestMethodHasAShapeTest(): void + { + $tested = array_unique(array_map(static function (string $name): string { + return explode(' ', $name)[0]; + }, array_merge(array_keys(self::requests()), ['login']))); + + $methods = array_filter((new ReflectionClass(BitGoExpress::class))->getMethods(ReflectionMethod::IS_PUBLIC), static function (ReflectionMethod $method): bool { + return !$method->isStatic() && !$method->isConstructor() && !in_array($method->getName(), self::NOT_REQUESTS, true); + }); + $untested = array_diff(array_map(static function (ReflectionMethod $method): string { + return $method->getName(); + }, $methods), $tested); + + self::assertSame([], array_values($untested)); + } + + public function testBodyWithoutParamsIsAnEmptyJsonObject(): void + { + $express = new BitGoExpress('localhost', 3080, 'tbtc'); + $transport = RecordingTransport::on($express); + + $express->createKeychain(); + + self::assertSame('{}', $transport->last()->body); + } + + public function testHttpsExpressIsReachedOverHttps(): void + { + $express = new BitGoExpress('https://express.internal', 3443, 'tbtc'); + $transport = RecordingTransport::on($express); + + $express->pingExpress(); + + self::assertSame('https://express.internal:3443/api/v2/pingexpress', $transport->last()->url); + } + + public function testObjectResponsesAreDecodedAsObjects(): void + { + $express = new BitGoExpress('localhost', 3080, 'tbtc'); + RecordingTransport::on($express)->respond('{"txHex":"00","feeInfo":{"fee":100}}'); + + self::assertEquals((object) ['txHex' => '00', 'feeInfo' => (object) ['fee' => 100]], $express->buildTransaction([])); + } +} diff --git a/tests/Fixtures/CapturedRequest.php b/tests/Fixtures/CapturedRequest.php new file mode 100644 index 0000000..2faa64b --- /dev/null +++ b/tests/Fixtures/CapturedRequest.php @@ -0,0 +1,40 @@ + */ + public array $headers; + /** The raw body, or null if none was set. */ + public ?string $body; + /** @var array */ + public array $options; + + /** + * @param array $options + */ + public function __construct(array $options) + { + $this->options = $options; + $this->method = $options[CURLOPT_CUSTOMREQUEST] ?? (!empty($options[CURLOPT_POST]) ? 'POST' : 'GET'); + $this->url = $options[CURLOPT_URL]; + $this->headers = $options[CURLOPT_HTTPHEADER] ?? []; + $this->body = $options[CURLOPT_POSTFIELDS] ?? null; + } + + /** + * The body decoded as JSON, or null if there is none. + * + * @return mixed + */ + public function json() + { + return $this->body === null ? null : json_decode($this->body, true, 512, JSON_THROW_ON_ERROR); + } +} diff --git a/tests/Fixtures/RecordingTransport.php b/tests/Fixtures/RecordingTransport.php new file mode 100644 index 0000000..e4809af --- /dev/null +++ b/tests/Fixtures/RecordingTransport.php @@ -0,0 +1,67 @@ + */ + public array $requests = []; + /** @var list */ + private array $responses = []; + + /** + * Install a new recording transport on $client. + * + * @param BitGoSDK|BitGoExpress $client + */ + public static function on($client): self + { + $transport = new self(); + $property = new ReflectionProperty($client, 'transport'); + if (PHP_VERSION_ID < 80100) { + $property->setAccessible(true); + } + $property->setValue($client, $transport); + return $transport; + } + + /** + * Queue a response. Requests take queued responses in order, then get 200 with an empty JSON object. + */ + public function respond(string $body, int $status = 200): self + { + $this->responses[] = ['body' => $body, 'status' => $status, 'errno' => 0, 'error' => '']; + return $this; + } + + /** + * Queue a cURL failure. + */ + public function fail(int $errno, string $error = ''): self + { + $this->responses[] = ['body' => '', 'status' => 0, 'errno' => $errno, 'error' => $error]; + return $this; + } + + public function send(array $options): array + { + $this->requests[] = new CapturedRequest($options); + return array_shift($this->responses) ?? ['body' => '{}', 'status' => 200, 'errno' => 0, 'error' => '']; + } + + public function last(): CapturedRequest + { + if ($this->requests === []) { + throw new \LogicException('No request was sent.'); + } + return $this->requests[count($this->requests) - 1]; + } +} diff --git a/tests/PolicyRuleTest.php b/tests/PolicyRuleTest.php index 01044b4..b48fa54 100644 --- a/tests/PolicyRuleTest.php +++ b/tests/PolicyRuleTest.php @@ -3,19 +3,14 @@ namespace neto737\BitGoSDK\Tests; use neto737\BitGoSDK\BitGoSDK; +use neto737\BitGoSDK\Tests\Fixtures\RecordingTransport; use PHPUnit\Framework\TestCase; /** * B4: policy-rule methods must put the wallet ID, not the rule ID, in the URL. - * - * The client points at a closed local port, so each call fails fast and the URL it built can be inspected. */ final class PolicyRuleTest extends TestCase { - use AccessesPrivateMembers; - - private const UNREACHABLE_ENDPOINT = 'http://127.0.0.1:1/api/v2/tbtc'; - /** * @return array */ @@ -34,11 +29,11 @@ public static function policyRuleMethods(): array public function testUrlUsesWalletId(string $method): void { $sdk = new BitGoSDK('token', 'tbtc', true); - $this->setPrivate($sdk, 'APIEndpoint', self::UNREACHABLE_ENDPOINT); $sdk->walletId = 'wallet-id'; + $transport = RecordingTransport::on($sdk); $sdk->$method('rule-id', 'velocityLimit', '{}', 'deny'); - self::assertSame(self::UNREACHABLE_ENDPOINT . '/wallet/wallet-id/policy/rule', $this->getPrivate($sdk, 'url')); + self::assertSame('https://app.bitgo-test.com/api/v2/tbtc/wallet/wallet-id/policy/rule', $transport->last()->url); } } diff --git a/tests/RequestParamsTest.php b/tests/RequestParamsTest.php index 73ed2ba..73b792d 100644 --- a/tests/RequestParamsTest.php +++ b/tests/RequestParamsTest.php @@ -4,78 +4,81 @@ use neto737\BitGoSDK\BitGoExpress; use neto737\BitGoSDK\BitGoSDK; +use neto737\BitGoSDK\Tests\Fixtures\RecordingTransport; use PHPUnit\Framework\TestCase; /** * B1: request params must not leak from one call into the next. * B2: only null params are dropped, so 0, false and '' are still sent. - * - * Until requests go through a capturable transport, these tests point the client - * at a closed local port and inspect the internal params state after the call. */ final class RequestParamsTest extends TestCase { - use AccessesPrivateMembers; + public function testSdkParamsDoNotLeakIntoTheNextCall(): void + { + $sdk = new BitGoSDK('token', 'tbtc', true); + $transport = RecordingTransport::on($sdk); - private const UNREACHABLE_URL = 'http://127.0.0.1:1/'; + $sdk->unlockSession('000000', 600); + $sdk->lockSession(); + $sdk->getTotalBalances(); + + self::assertSame('{}', $transport->requests[1]->body); + self::assertSame('https://app.bitgo-test.com/api/v2/tbtc/wallet/balances', $transport->requests[2]->url); + } - public function testSdkClearsParamsAfterEachCall(): void + public function testSdkParamsDoNotLeakAfterAFailedCall(): void { $sdk = new BitGoSDK('token', 'tbtc', true); - $this->setPrivate($sdk, 'params', ['otp' => '000000', 'duration' => 600]); - $this->setPrivate($sdk, 'url', self::UNREACHABLE_URL); + $transport = RecordingTransport::on($sdk)->fail(CURLE_COULDNT_CONNECT, 'Connection refused'); - $this->execute($sdk, 'POST'); + $sdk->unlockSession('000000', 600); + $sdk->lockSession(); - self::assertSame([], $this->getPrivate($sdk, 'params')); + self::assertSame('{}', $transport->last()->body); } - public function testExpressClearsParamsAfterEachCall(): void + public function testExpressParamsDoNotLeakIntoTheNextCall(): void { - $express = new BitGoExpress('127.0.0.1', 1, 'tbtc'); - $this->setPrivate($express, 'params', ['walletPassphrase' => 'secret']); - $this->setPrivate($express, 'url', self::UNREACHABLE_URL); + $express = new BitGoExpress('localhost', 3080, 'tbtc'); + $transport = RecordingTransport::on($express); - $this->execute($express, 'POST'); + $express->sendTransaction('address', 1000, 'secret'); + $express->createKeychain(); - self::assertSame([], $this->getPrivate($express, 'params')); + self::assertSame('{}', $transport->last()->body); } - public function testSdkDropsOnlyNullParams(): void + public function testSdkSendsFalsyBodyParams(): void { $sdk = new BitGoSDK('token', 'tbtc', true); + $transport = RecordingTransport::on($sdk); - self::assertSame( - ['limit' => 0, 'mine' => false, 'label' => ''], - $this->invoke($sdk, 'withoutNulls', ['limit' => 0, 'prevId' => null, 'mine' => false, 'label' => '']) - ); + $sdk->createWalletAddress(0, ''); + + self::assertSame(['chain' => 0, 'label' => ''], $transport->last()->json()); } - public function testExpressDropsOnlyNullParams(): void + public function testSdkSendsFalsyQueryParams(): void { - $express = new BitGoExpress('127.0.0.1', 1, 'tbtc'); + $sdk = new BitGoSDK('token', 'tbtc', true); + $sdk->walletId = 'w1'; + $transport = RecordingTransport::on($sdk); - self::assertSame( - ['amount' => 0, 'noSplitChange' => false], - $this->invoke($express, 'withoutNulls', ['amount' => 0, 'comment' => null, 'noSplitChange' => false]) - ); + $sdk->getWalletAddresses('', 0, false); + + self::assertSame('https://app.bitgo-test.com/api/v2/tbtc/wallet/w1/addresses?labelContains=&limit=0&mine=false', $transport->last()->url); } - public function testSdkQuerySendsBooleansAsWords(): void + public function testExpressSendsFalsyParams(): void { - $sdk = new BitGoSDK('token', 'tbtc', true); + $express = new BitGoExpress('localhost', 3080, 'tbtc'); + $transport = RecordingTransport::on($express); + + $express->sendTransaction('address', 0, '', null, null, null, '', null, 0, false); self::assertSame( - 'limit=0&mine=false&allTokens=true', - $this->invoke($sdk, 'buildQuery', ['limit' => 0, 'mine' => false, 'allTokens' => true]) + ['address' => 'address', 'amount' => '0', 'walletPassphrase' => '', 'comment' => '', 'minConfirms' => 0, 'enforceMinConfirmsForChange' => false], + $transport->last()->json() ); } - - /** - * @param object $client - */ - private function execute($client, string $requestType): void - { - $this->invoke($client, '__execute', $requestType); - } } diff --git a/tests/SdkRequestShapeTest.php b/tests/SdkRequestShapeTest.php new file mode 100644 index 0000000..1c75b85 --- /dev/null +++ b/tests/SdkRequestShapeTest.php @@ -0,0 +1,259 @@ + 'r1', 'type' => 'velocityLimit', 'condition' => '{"amount":1}', 'action' => 'deny']; + + // Methods that configure the client instead of sending a request. + private const NOT_REQUESTS = ['setRequestIP', 'setCaFile', 'setTimeout', 'setConnectTimeout', 'setForceIPv4']; + + /** + * @return array|null}> + */ + public static function requests(): array + { + return [ + // Auth + 'getCurrentUserProfile' => ['tbtc', static fn (BitGoSDK $c) => $c->getCurrentUserProfile(), 'GET', self::API . 'user/me', null], + 'getSessionInfo' => ['tbtc', static fn (BitGoSDK $c) => $c->getSessionInfo(), 'GET', self::API . 'user/session', null], + 'lockSession' => ['tbtc', static fn (BitGoSDK $c) => $c->lockSession(), 'POST', self::API . 'user/lock', []], + 'unlockSession' => ['tbtc', static fn (BitGoSDK $c) => $c->unlockSession('000000', 300), 'POST', self::API . 'user/unlock', ['otp' => '000000', 'duration' => 300]], + 'logout' => ['tbtc', static fn (BitGoSDK $c) => $c->logout(), 'GET', self::API . 'user/logout', null], + + // Wallets + 'listWallets' => ['tbtc', static fn (BitGoSDK $c) => $c->listWallets(10, 'prev', true), 'GET', self::TBTC . '/wallet?limit=10&prevId=prev', null], + 'listWallets on teth' => ['teth', static fn (BitGoSDK $c) => $c->listWallets(10, 'prev', true), 'GET', self::API . 'teth/wallet?limit=10&prevId=prev&allTokens=true', null], + 'getWallet' => ['tbtc', static fn (BitGoSDK $c) => $c->getWallet(true), 'GET', self::TBTC . '/wallet/w1', null], + 'getWallet on teth' => ['teth', static fn (BitGoSDK $c) => $c->getWallet(), 'GET', self::API . 'teth/wallet/w1?allTokens=false', null], + 'getWalletByAddress' => ['tbtc', static fn (BitGoSDK $c) => $c->getWalletByAddress('addr1'), 'GET', self::TBTC . '/wallet/address/addr1', null], + 'getTotalBalances' => ['tbtc', static fn (BitGoSDK $c) => $c->getTotalBalances(), 'GET', self::TBTC . '/wallet/balances', null], + 'getMaximumSpendable' => [ + 'tbtc', + static fn (BitGoSDK $c) => $c->getMaximumSpendable(200, 1000, 5000, 700000, 2000, 1, false), + 'GET', + self::TBTC . '/wallet/w1/maximumSpendable?limit=200&minValue=1000&maxValue=5000&minHeight=700000&feeRate=2000&minConfirms=1&enforceMinConfirmsForChange=false', + null, + ], + 'freezeWallet' => ['tbtc', static fn (BitGoSDK $c) => $c->freezeWallet('000000', 60), 'POST', self::TBTC . '/wallet/w1/freeze', ['otp' => '000000', 'duration' => 60]], + + // Transfers and transactions + 'listWalletTransfers' => ['tbtc', static fn (BitGoSDK $c) => $c->listWalletTransfers('prev', true), 'GET', self::TBTC . '/wallet/w1/transfer?prevId=prev', null], + 'listWalletTransfers on teth' => ['teth', static fn (BitGoSDK $c) => $c->listWalletTransfers('prev', true), 'GET', self::API . 'teth/wallet/w1/transfer?prevId=prev&allTokens=true', null], + 'getWalletTransfer' => ['tbtc', static fn (BitGoSDK $c) => $c->getWalletTransfer('t1'), 'GET', self::TBTC . '/wallet/w1/transfer/t1', null], + 'getWalletTransferBySequenceId' => ['tbtc', static fn (BitGoSDK $c) => $c->getWalletTransferBySequenceId('seq1'), 'GET', self::TBTC . '/wallet/w1/transfer/sequenceId/seq1', null], + 'getWalletTransactions' => ['tbtc', static fn (BitGoSDK $c) => $c->getWalletTransactions('prev', true), 'GET', self::TBTC . '/wallet/w1/tx?prevId=prev', null], + 'getWalletTransactions on teth' => ['teth', static fn (BitGoSDK $c) => $c->getWalletTransactions('prev', true), 'GET', self::API . 'teth/wallet/w1/tx?prevId=prev&allTokens=true', null], + 'getWalletTransaction' => ['tbtc', static fn (BitGoSDK $c) => $c->getWalletTransaction('tx1'), 'GET', self::TBTC . '/wallet/w1/tx/tx1', null], + 'listWalletUnspents' => [ + 'tbtc', + static fn (BitGoSDK $c) => $c->listWalletUnspents('prev', 1000, 5000, 700000, 0), + 'GET', + self::TBTC . '/wallet/w1/unspents?prevId=prev&minValue=1000&maxValue=5000&minHeight=700000&minConfirms=0', + null, + ], + 'estimateTransactionFees' => ['tbtc', static fn (BitGoSDK $c) => $c->estimateTransactionFees(3), 'GET', self::TBTC . '/tx/fee?numBlocks=3', null], + 'getMarketPriceData' => ['tbtc', static fn (BitGoSDK $c) => $c->getMarketPriceData(), 'GET', self::TBTC . '/market/latest', null], + + // Addresses + 'createWalletAddress' => [ + 'tbtc', + static fn (BitGoSDK $c) => $c->createWalletAddress(AddressType::P2SH_P2WSH_DEPOSIT, 'label', true, 30), + 'POST', + self::TBTC . '/wallet/w1/address', + ['chain' => 10, 'label' => 'label'], + ], + 'createWalletAddress on teth' => [ + 'teth', + static fn (BitGoSDK $c) => $c->createWalletAddress(AddressType::LEGACY_DEPOSIT, 'label', true, 30), + 'POST', + self::API . 'teth/wallet/w1/address', + ['chain' => 0, 'label' => 'label', 'lowPriority' => true, 'gasPrice' => 30], + ], + 'getWalletAddress' => ['tbtc', static fn (BitGoSDK $c) => $c->getWalletAddress('addr1', '123', 'memo'), 'GET', self::TBTC . '/wallet/w1/address/addr1', null], + 'getWalletAddress on txrp' => ['txrp', static fn (BitGoSDK $c) => $c->getWalletAddress('addr1', '123', 'memo'), 'GET', self::API . 'txrp/wallet/w1/address/addr1?dt=123', null], + 'getWalletAddress on txlm' => ['txlm', static fn (BitGoSDK $c) => $c->getWalletAddress('addr1', '123', 'memo'), 'GET', self::API . 'txlm/wallet/w1/address/addr1?memoId=memo', null], + 'updateWalletAddress' => ['tbtc', static fn (BitGoSDK $c) => $c->updateWalletAddress('addr1', '123', 'memo', 'label'), 'PUT', self::TBTC . '/wallet/w1/address/addr1', ['label' => 'label']], + 'updateWalletAddress on txrp' => [ + 'txrp', + static fn (BitGoSDK $c) => $c->updateWalletAddress('addr1', '123', 'memo', 'label'), + 'PUT', + self::API . 'txrp/wallet/w1/address/addr1', + ['dt' => '123', 'label' => 'label'], + ], + 'updateWalletAddress on txlm' => [ + 'txlm', + static fn (BitGoSDK $c) => $c->updateWalletAddress('addr1', '123', 'memo', 'label'), + 'PUT', + self::API . 'txlm/wallet/w1/address/addr1', + ['memoId' => 'memo', 'label' => 'label'], + ], + 'getWalletAddresses' => [ + 'tbtc', + static fn (BitGoSDK $c) => $c->getWalletAddresses('lab', 0, false, 'prev', [0, 10], -1), + 'GET', + self::TBTC . '/wallet/w1/addresses?labelContains=lab&limit=0&mine=false&prevId=prev&chains%5B0%5D=0&chains%5B1%5D=10&sort=-1', + null, + ], + + // Keychains + 'listKeychains' => ['tbtc', static fn (BitGoSDK $c) => $c->listKeychains(10, 'prev'), 'GET', self::TBTC . '/key?limit=10&prevId=prev', null], + 'getKeychain' => ['tbtc', static fn (BitGoSDK $c) => $c->getKeychain('k1'), 'GET', self::TBTC . '/key/k1', null], + 'createBitGoKeychain' => ['tbtc', static fn (BitGoSDK $c) => $c->createBitGoKeychain('bitgo', 'ent1', true), 'POST', self::TBTC . '/key', ['source' => 'bitgo']], + 'createBitGoKeychain on teth' => [ + 'teth', + static fn (BitGoSDK $c) => $c->createBitGoKeychain('bitgo', 'ent1', true), + 'POST', + self::API . 'teth/key', + ['source' => 'bitgo', 'enterprise' => 'ent1', 'newFeeAddress' => true], + ], + 'createBackupKeychain' => ['tbtc', static fn (BitGoSDK $c) => $c->createBackupKeychain('cme', 'backup'), 'POST', self::TBTC . '/key', ['source' => 'backup', 'provider' => 'cme']], + 'addKeychain' => ['tbtc', static fn (BitGoSDK $c) => $c->addKeychain('xpub1', 'enc1', 'user'), 'POST', self::TBTC . '/key', ['pub' => 'xpub1', 'encryptedPrv' => 'enc1', 'source' => 'user']], + + // Policy + 'setVelocityPolicyRule' => ['tbtc', static fn (BitGoSDK $c) => $c->setVelocityPolicyRule(...array_values(self::POLICY_RULE)), 'POST', self::TBTC . '/wallet/w1/policy/rule', self::POLICY_RULE], + 'updateVelocityPolicyRule' => ['tbtc', static fn (BitGoSDK $c) => $c->updateVelocityPolicyRule(...array_values(self::POLICY_RULE)), 'PUT', self::TBTC . '/wallet/w1/policy/rule', self::POLICY_RULE], + 'removePolicyRule' => ['tbtc', static fn (BitGoSDK $c) => $c->removePolicyRule(...array_values(self::POLICY_RULE)), 'DELETE', self::TBTC . '/wallet/w1/policy/rule', self::POLICY_RULE], + + // Sharing + 'listWalletShares' => ['tbtc', static fn (BitGoSDK $c) => $c->listWalletShares(), 'GET', self::TBTC . '/walletshare', null], + 'getWalletShare' => ['tbtc', static fn (BitGoSDK $c) => $c->getWalletShare('s1'), 'GET', self::TBTC . '/walletshare/s1', null], + 'resendWalletShareInvite' => ['tbtc', static fn (BitGoSDK $c) => $c->resendWalletShareInvite('s1'), 'POST', self::TBTC . '/walletshare/s1/resendemail', []], + 'cancelWalletShare' => ['tbtc', static fn (BitGoSDK $c) => $c->cancelWalletShare('s1'), 'DELETE', self::TBTC . '/walletshare/s1', ['walletShareId' => 's1']], + 'removeWalletUser' => ['tbtc', static fn (BitGoSDK $c) => $c->removeWalletUser('u1'), 'DELETE', self::TBTC . '/wallet/w1/user/u1', ['walletId' => 'w1', 'userId' => 'u1']], + + // Pending approvals + 'getPendingApproval' => ['tbtc', static fn (BitGoSDK $c) => $c->getPendingApproval('p1'), 'GET', self::TBTC . '/pendingapprovals/p1', null], + 'listPendingApprovals' => ['tbtc', static fn (BitGoSDK $c) => $c->listPendingApprovals('w2', 'ent1', true), 'GET', self::TBTC . '/pendingapprovals?walletID=w2', null], + 'listPendingApprovals on teth' => [ + 'teth', + static fn (BitGoSDK $c) => $c->listPendingApprovals('w2', 'ent1', true), + 'GET', + self::API . 'teth/pendingapprovals?walletID=w2&enterprise=ent1&allTokens=true', + null, + ], + + // Wallet webhooks + 'listWalletWebhooks' => ['tbtc', static fn (BitGoSDK $c) => $c->listWalletWebhooks(true), 'GET', self::TBTC . '/wallet/w1/webhooks', null], + 'listWalletWebhooks on teth' => ['teth', static fn (BitGoSDK $c) => $c->listWalletWebhooks(true), 'GET', self::API . 'teth/wallet/w1/webhooks?allTokens=true', null], + 'addWalletWebhook' => [ + 'tbtc', + static fn (BitGoSDK $c) => $c->addWalletWebhook(self::HOOK, 'transfer', 0), + 'POST', + self::TBTC . '/wallet/w1/webhooks', + ['url' => self::HOOK, 'type' => 'transfer', 'numConfirmations' => 0], + ], + 'removeWalletWebhook' => ['tbtc', static fn (BitGoSDK $c) => $c->removeWalletWebhook(self::HOOK, 'transfer'), 'DELETE', self::TBTC . '/wallet/w1/webhooks', ['url' => self::HOOK, 'type' => 'transfer']], + 'simulateWalletWebhook' => [ + 'tbtc', + static fn (BitGoSDK $c) => $c->simulateWalletWebhook('h1', 't1', 'p1'), + 'POST', + self::TBTC . '/wallet/w1/webhooks/h1/simulate', + ['webhookId' => 'h1', 'transferId' => 't1', 'pendingApprovalId' => 'p1'], + ], + + // User webhooks + 'listUserWebhooks' => ['tbtc', static fn (BitGoSDK $c) => $c->listUserWebhooks(), 'GET', self::TBTC . '/webhooks', null], + 'addUserWebhook' => [ + 'tbtc', + static fn (BitGoSDK $c) => $c->addUserWebhook(self::HOOK, 'block', 'label', 2), + 'POST', + self::TBTC . '/webhooks', + ['url' => self::HOOK, 'type' => 'block', 'label' => 'label', 'numConfirmations' => 2], + ], + 'removeUserWebhook' => ['tbtc', static fn (BitGoSDK $c) => $c->removeUserWebhook(self::HOOK, 'block'), 'DELETE', self::TBTC . '/webhooks', ['url' => self::HOOK, 'type' => 'block']], + 'simulateUserWebhook' => ['tbtc', static fn (BitGoSDK $c) => $c->simulateUserWebhook('h1', 'b1'), 'POST', self::TBTC . '/webhooks/h1/simulate', ['webhookId' => 'h1', 'blockId' => 'b1']], + 'addBlockWebhook' => [ + 'tbtc', + static fn (BitGoSDK $c) => $c->addBlockWebhook(self::HOOK, 'wallet_confirmation', 'label', 2), + 'POST', + self::TBTC . '/webhooks', + ['url' => self::HOOK, 'type' => 'wallet_confirmation', 'label' => 'label', 'numConfirmations' => 2], + ], + 'listBlockWebhooks' => ['tbtc', static fn (BitGoSDK $c) => $c->listBlockWebhooks(), 'GET', self::TBTC . '/webhooks', null], + 'removeBlockWebhook' => ['tbtc', static fn (BitGoSDK $c) => $c->removeBlockWebhook(self::HOOK), 'DELETE', self::TBTC . '/webhooks', ['url' => self::HOOK, 'type' => 'block']], + ]; + } + + /** + * @dataProvider requests + * @param callable(BitGoSDK): mixed $call + * @param array|null $body + */ + public function testRequestShape(string $coin, callable $call, string $method, string $url, ?array $body): void + { + $sdk = new BitGoSDK('v2xtoken', $coin, true); + $sdk->walletId = 'w1'; + $transport = RecordingTransport::on($sdk); + + $call($sdk); + + self::assertCount(1, $transport->requests); + $request = $transport->last(); + self::assertSame($method, $request->method); + self::assertSame($url, $request->url); + self::assertSame(['Content-Type: application/json', 'Authorization: Bearer v2xtoken', 'User-Agent: BitGoSDK-PHP/' . BitGoSDK::VERSION], $request->headers); + self::assertSame($body, $request->json()); + } + + public function testEveryRequestMethodHasAShapeTest(): void + { + $tested = array_unique(array_map(static function (string $name): string { + return explode(' ', $name)[0]; + }, array_keys(self::requests()))); + + $methods = array_filter((new ReflectionClass(BitGoSDK::class))->getMethods(ReflectionMethod::IS_PUBLIC), static function (ReflectionMethod $method): bool { + return !$method->isStatic() && !$method->isConstructor() && !in_array($method->getName(), self::NOT_REQUESTS, true); + }); + $untested = array_diff(array_map(static function (ReflectionMethod $method): string { + return $method->getName(); + }, $methods), $tested); + + self::assertSame([], array_values($untested)); + } + + public function testBodyWithoutParamsIsAnEmptyJsonObject(): void + { + $sdk = new BitGoSDK('v2xtoken', 'tbtc', true); + $transport = RecordingTransport::on($sdk); + + $sdk->lockSession(); + + self::assertSame('{}', $transport->last()->body); + } + + public function testProductionUsesTheProductionEndpoint(): void + { + $sdk = new BitGoSDK('v2xtoken', 'btc'); + $sdk->walletId = 'w1'; + $transport = RecordingTransport::on($sdk); + + $sdk->getWallet(); + $sdk->getCurrentUserProfile(); + + self::assertSame('https://app.bitgo.com/api/v2/btc/wallet/w1', $transport->requests[0]->url); + self::assertSame('https://app.bitgo.com/api/v2/user/me', $transport->requests[1]->url); + } + + public function testResponseBodyIsDecoded(): void + { + $sdk = new BitGoSDK('v2xtoken', 'tbtc', true); + RecordingTransport::on($sdk)->respond('{"id":"w1","balance":0}'); + + self::assertSame(['id' => 'w1', 'balance' => 0], $sdk->getWallet()); + } +} diff --git a/tests/SmokeTest.php b/tests/SmokeTest.php index 6b503e2..166c019 100644 --- a/tests/SmokeTest.php +++ b/tests/SmokeTest.php @@ -9,7 +9,7 @@ use PHPUnit\Framework\TestCase; /** - * Placeholder suite so CI has something to run until the request-shape tests land. + * The classes autoload and the public constants keep their values. */ final class SmokeTest extends TestCase {