From b0b5da617adbc1befab0c29c303127867e88ae6b Mon Sep 17 00:00:00 2001 From: oleh ilin Date: Sat, 26 Sep 2026 21:47:39 +0300 Subject: [PATCH 1/5] [Upgrade] PDF-12283 fix PHP 8.1-8.5 deprecations and FillableFieldsList fatal - FillableFieldsList::offsetExists(): add `: bool` like ListObject. Without it the class fails to load on any PHP 7+ ("must be compatible"). - ListObject / FillableFieldsList: #[\ReturnTypeWillChange] on ArrayAccess and Iterator methods (PHP 8.1). No signature change for subclasses. - AbstractObject: #[\AllowDynamicProperties] (PHP 8.2), DTOs store attributes as properties. - explicit ?Type for 6 implicitly nullable parameters (PHP 8.4). Co-Authored-By: Claude Opus 5.5 --- src/Core/AbstractObject.php | 1 + src/Core/Exception.php | 2 +- src/Core/ListObject.php | 8 ++++++++ src/DTO/FillableFieldsList.php | 5 ++++- src/Exceptions/ErrorsException.php | 2 +- src/Exceptions/OptionsMissingException.php | 2 +- src/Exceptions/TypeException.php | 2 +- src/SignatureRequestRecipient.php | 4 ++-- 8 files changed, 19 insertions(+), 7 deletions(-) diff --git a/src/Core/AbstractObject.php b/src/Core/AbstractObject.php index 354f75f..7032e71 100644 --- a/src/Core/AbstractObject.php +++ b/src/Core/AbstractObject.php @@ -13,6 +13,7 @@ * * @package PDFfiller\OAuth2\Client\Provider\Core */ +#[\AllowDynamicProperties] abstract class AbstractObject implements Arrayable { use CastsTrait; diff --git a/src/Core/Exception.php b/src/Core/Exception.php index 151c04f..7369622 100644 --- a/src/Core/Exception.php +++ b/src/Core/Exception.php @@ -21,7 +21,7 @@ class Exception extends \Exception * @param int $code * @param Exception|null $previous */ - public function __construct(string $message = "", $code = 0, Exception $previous = null) + public function __construct(string $message = "", $code = 0, ?Exception $previous = null) { if (empty($message)) { $message = $this->getDefaultMessage(); diff --git a/src/Core/ListObject.php b/src/Core/ListObject.php index 7d09478..c1aecf8 100644 --- a/src/Core/ListObject.php +++ b/src/Core/ListObject.php @@ -49,6 +49,7 @@ public function offsetExists($offset): bool /** * @inheritdoc */ + #[\ReturnTypeWillChange] public function offsetGet($offset) { if (isset($this->items[$offset])) { @@ -61,6 +62,7 @@ public function offsetGet($offset) /** * @inheritdoc */ + #[\ReturnTypeWillChange] public function offsetSet($offset, $value) { if (is_null($offset)) { @@ -73,6 +75,7 @@ public function offsetSet($offset, $value) /** * @inheritdoc */ + #[\ReturnTypeWillChange] public function offsetUnset($offset) { unset($this->items[$offset]); @@ -81,6 +84,7 @@ public function offsetUnset($offset) /** * @inheritdoc */ + #[\ReturnTypeWillChange] public function current() { return current($this->items); @@ -89,6 +93,7 @@ public function current() /** * @inheritdoc */ + #[\ReturnTypeWillChange] public function next() { return next($this->items); @@ -97,6 +102,7 @@ public function next() /** * @inheritdoc */ + #[\ReturnTypeWillChange] public function key() { return key($this->items); @@ -105,6 +111,7 @@ public function key() /** * @inheritdoc */ + #[\ReturnTypeWillChange] public function valid() { return key($this->items) !== null; @@ -113,6 +120,7 @@ public function valid() /** * @inheritdoc */ + #[\ReturnTypeWillChange] public function rewind() { return reset($this->items); diff --git a/src/DTO/FillableFieldsList.php b/src/DTO/FillableFieldsList.php index b33244d..9b42ac3 100644 --- a/src/DTO/FillableFieldsList.php +++ b/src/DTO/FillableFieldsList.php @@ -61,7 +61,7 @@ private function getFields() /** * @inheritdoc */ - public function offsetExists($offset) + public function offsetExists($offset): bool { $fields = $this->getFields(); @@ -71,6 +71,7 @@ public function offsetExists($offset) /** * @inheritdoc */ + #[\ReturnTypeWillChange] public function offsetGet($offset) { $fields = array_flip($this->getFields()); @@ -87,6 +88,7 @@ public function offsetGet($offset) /** * @inheritdoc */ + #[\ReturnTypeWillChange] public function offsetSet($offset, $value) { if ($value instanceof FillableField) { @@ -122,6 +124,7 @@ public function offsetSet($offset, $value) /** * @inheritdoc */ + #[\ReturnTypeWillChange] public function offsetUnset($offset) { $fields = array_flip($this->getFields()); diff --git a/src/Exceptions/ErrorsException.php b/src/Exceptions/ErrorsException.php index e4e6de6..a7f1067 100644 --- a/src/Exceptions/ErrorsException.php +++ b/src/Exceptions/ErrorsException.php @@ -21,7 +21,7 @@ class ErrorsException extends Exception * @param int $code * @param Exception|null $previous */ - public function __construct($errors, $message = "", $code = 0, Exception $previous = null) + public function __construct($errors, $message = "", $code = 0, ?Exception $previous = null) { $this->errors = $errors; parent::__construct($message, $code, $previous); diff --git a/src/Exceptions/OptionsMissingException.php b/src/Exceptions/OptionsMissingException.php index 2f7eda5..e779b63 100644 --- a/src/Exceptions/OptionsMissingException.php +++ b/src/Exceptions/OptionsMissingException.php @@ -20,7 +20,7 @@ class OptionsMissingException extends Exception * @param int $code * @param Exception|null $previous */ - public function __construct($options, $code = 0, Exception $previous = null) + public function __construct($options, $code = 0, ?Exception $previous = null) { $this->options = $options; parent::__construct("", $code, $previous); diff --git a/src/Exceptions/TypeException.php b/src/Exceptions/TypeException.php index b904248..2669897 100644 --- a/src/Exceptions/TypeException.php +++ b/src/Exceptions/TypeException.php @@ -20,7 +20,7 @@ class TypeException extends Exception * @param int $code * @param Exception|null $previous */ - public function __construct($types = [], $message = "", $code = 0, Exception $previous = null) + public function __construct($types = [], $message = "", $code = 0, ?Exception $previous = null) { $this->typesAllowed = $types; parent::__construct($message, $code, $previous); diff --git a/src/SignatureRequestRecipient.php b/src/SignatureRequestRecipient.php index 88b9911..854700f 100644 --- a/src/SignatureRequestRecipient.php +++ b/src/SignatureRequestRecipient.php @@ -155,7 +155,7 @@ public function update($options = []) /** * @inheritdoc */ - public static function all(PDFfiller $provider = null, array $params = []) + public static function all(?PDFfiller $provider = null, array $params = []) { throw new Exception("Getting list of this items isn't supported."); } @@ -163,7 +163,7 @@ public static function all(PDFfiller $provider = null, array $params = []) /** * @inheritdoc */ - public static function one(PDFfiller $provider = null, $id = null) + public static function one(?PDFfiller $provider = null, $id = null) { throw new Exception("Getting instance of this items isn't supported. Use SignatureRequest class."); } From 28dd78eeffe4c0e6ee0f985ac5fd14f179928252 Mon Sep 17 00:00:00 2001 From: oleh ilin Date: Sat, 26 Sep 2026 21:47:59 +0300 Subject: [PATCH 2/5] [Fix] PDF-12283 make the client work with league/oauth2-client 2.x and guzzlehttp/psr7 2.x Since v3.1.0 (league/oauth2-client 1.x -> 2.x) every request failed: - getResponse() called sendRequest(), which only exists in league 1.x. Add a protected sendRequest() with the same behaviour (return 4xx/5xx responses instead of throwing, so checkResponse() handles them). - league 2.x getAccessToken() goes through getParsedResponse(), which expects getResponse() to return a ResponseInterface. Override getParsedResponse() to use our getResponse() (parse + checkResponse). - psr7 2.x removed Uri::resolve(), stream_for() and modify_request(): use UriResolver::resolve(), Utils::streamFor(), Utils::modifyRequest() (available since psr7 1.7). - http_build_query(): '' instead of null (PHP 8.1 deprecation). Co-Authored-By: Claude Opus 5.5 --- src/PDFfiller.php | 45 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/src/PDFfiller.php b/src/PDFfiller.php index e2b7ab4..bf3ffd4 100644 --- a/src/PDFfiller.php +++ b/src/PDFfiller.php @@ -12,6 +12,7 @@ use PDFfiller\OAuth2\Client\Provider\Exceptions\ResponseException; use PDFfiller\OAuth2\Client\Provider\Exceptions\TokenMissingException; use League\OAuth2\Client\Provider\GenericProvider; +use GuzzleHttp\Exception\BadResponseException; use Psr\Http\Message\RequestInterface; use \GuzzleHttp\Psr7 as Psr7; use Psr\Http\Message\ResponseInterface; @@ -69,7 +70,7 @@ public function getAuthenticatedRequest($method, $url, $token, array $options = { $baseUri = new Psr7\Uri($this->urlApiDomain); $relativeUri = new Psr7\Uri($url); - $newUri = Psr7\Uri::resolve($baseUri, $relativeUri); + $newUri = Psr7\UriResolver::resolve($baseUri, $relativeUri); return parent::getAuthenticatedRequest($method, $newUri, $token, $options); } @@ -91,7 +92,7 @@ private function applyOptions(RequestInterface $request, array &$options) if (isset($options['multipart'])) { throw new InvalidBodySourceException(); } - $options['body'] = http_build_query($options['form_params'], null, '&'); + $options['body'] = http_build_query($options['form_params'], '', '&'); unset($options['form_params']); $options['_conditional']['Content-Type'] = 'application/x-www-form-urlencoded'; } @@ -121,7 +122,7 @@ private function applyOptions(RequestInterface $request, array &$options) if (is_array($options['body'])) { throw new InvalidBodyException(); } - $modify['body'] = Psr7\stream_for($options['body']); + $modify['body'] = Psr7\Utils::streamFor($options['body']); unset($options['body']); } @@ -147,7 +148,7 @@ private function applyOptions(RequestInterface $request, array &$options) if (isset($options['query'])) { $value = $options['query']; if (is_array($value)) { - $value = http_build_query($value, null, '&', PHP_QUERY_RFC3986); + $value = http_build_query($value, '', '&', PHP_QUERY_RFC3986); } if (!is_string($value)) { throw new InvalidQueryException(); @@ -157,12 +158,12 @@ private function applyOptions(RequestInterface $request, array &$options) } if (isset($options['json'])) { - $modify['body'] = Psr7\stream_for(json_encode($options['json'])); + $modify['body'] = Psr7\Utils::streamFor(json_encode($options['json'])); $options['_conditional']['Content-Type'] = 'application/json'; unset($options['json']); } - $request = Psr7\modify_request($request, $modify); + $request = Psr7\Utils::modifyRequest($request, $modify); if ($request->getBody() instanceof Psr7\MultipartStream) { // Use a multipart/form-data POST if a Content-Type is not set. @@ -179,7 +180,7 @@ private function applyOptions(RequestInterface $request, array &$options) $modify['set_headers'][$k] = $v; } } - $request = Psr7\modify_request($request, $modify); + $request = Psr7\Utils::modifyRequest($request, $modify); // Don't pass this internal value along to middleware/handlers. unset($options['_conditional']); } @@ -259,6 +260,36 @@ public function getStatusCode() return $this->statusCode; } + /** + * Sends a request and returns the response. A 4xx/5xx response is returned, + * not thrown, so that checkResponse() can handle it (as in league/oauth2-client 1.x). + * + * @param RequestInterface $request + * @return ResponseInterface + */ + protected function sendRequest(RequestInterface $request) + { + try { + return $this->getHttpClient()->send($request); + } catch (BadResponseException $e) { + return $e->getResponse(); + } + } + + /** + * Sends a request and returns the parsed response. + * + * league/oauth2-client 2.x expects getResponse() to return a ResponseInterface + * here; our getResponse() already parses and checks it. + * + * @param RequestInterface $request + * @return array + */ + public function getParsedResponse(RequestInterface $request) + { + return $this->getResponse($request); + } + /** * @inheritdoc */ From 2dab4f27f57a7a3fef3b2c8fe06541aeacbd9a81 Mon Sep 17 00:00:00 2001 From: oleh ilin Date: Sat, 26 Sep 2026 21:47:59 +0300 Subject: [PATCH 3/5] [Security] PDF-12283 do not send the access token outside urlApiDomain getAuthenticatedRequest() resolves the URL against urlApiDomain. An absolute or protocol-relative URL passed to apiCall() replaced the host, so the Bearer token was sent to that host (also on an http:// downgrade or another port). Refuse such requests with InvalidRequestException. Relative paths and absolute URLs on urlApiDomain still work. Co-Authored-By: Claude Opus 5.5 --- src/PDFfiller.php | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/PDFfiller.php b/src/PDFfiller.php index bf3ffd4..f3992af 100644 --- a/src/PDFfiller.php +++ b/src/PDFfiller.php @@ -8,6 +8,7 @@ use PDFfiller\OAuth2\Client\Provider\Exceptions\InvalidBodyException; use PDFfiller\OAuth2\Client\Provider\Exceptions\InvalidBodySourceException; use PDFfiller\OAuth2\Client\Provider\Exceptions\InvalidQueryException; +use PDFfiller\OAuth2\Client\Provider\Exceptions\InvalidRequestException; use PDFfiller\OAuth2\Client\Provider\Exceptions\OptionsMissingException; use PDFfiller\OAuth2\Client\Provider\Exceptions\ResponseException; use PDFfiller\OAuth2\Client\Provider\Exceptions\TokenMissingException; @@ -65,6 +66,7 @@ public function __construct(array $options = [], array $collaborators = []) * @param AccessToken|string $token * @param array $options * @return RequestInterface + * @throws InvalidRequestException if the URL leaves urlApiDomain */ public function getAuthenticatedRequest($method, $url, $token, array $options = []) { @@ -72,6 +74,14 @@ public function getAuthenticatedRequest($method, $url, $token, array $options = $relativeUri = new Psr7\Uri($url); $newUri = Psr7\UriResolver::resolve($baseUri, $relativeUri); + // Never send the access token outside of urlApiDomain + if ($newUri->getScheme() !== $baseUri->getScheme() + || strtolower($newUri->getHost()) !== strtolower($baseUri->getHost()) + || $newUri->getPort() !== $baseUri->getPort() + ) { + throw new InvalidRequestException('The request URL must use the scheme, host and port of urlApiDomain.'); + } + return parent::getAuthenticatedRequest($method, $newUri, $token, $options); } From 1b0353d4301af33f9228094db09c46c49980e576 Mon Sep 17 00:00:00 2001 From: oleh ilin Date: Sat, 26 Sep 2026 21:47:59 +0300 Subject: [PATCH 4/5] [Upgrade] PDF-12283 support PHP 8.3-8.5 and Laravel 13 apps, add tests - php >=7.0 -> >=8.1 - league/oauth2-client ^2.6 -> ^2.8 (PHP 8.3/8.4 support, nullable fixes, Guzzle >= 7.4.5 for security fixes) - dev: phpunit ^10.5 || ^11.5 || ^12.5 || ^13.0; carbon ^2.31 || ^3.0 - add PHPUnit tests (Guzzle MockHandler, no network): token grants and refusals, API calls, request options, models, lists, enums, and the urlApiDomain guard; fail on warnings, notices and deprecations - README: PHP >= 8.1 Co-Authored-By: Claude Opus 5.5 --- .gitignore | 2 + README.md | 2 +- composer.json | 17 ++- phpunit.xml.dist | 19 +++ tests/ListObjectTest.php | 46 +++++++ tests/MockedProviderTrait.php | 57 +++++++++ tests/ModelTest.php | 79 ++++++++++++ tests/PDFfillerTest.php | 227 ++++++++++++++++++++++++++++++++++ 8 files changed, 443 insertions(+), 6 deletions(-) create mode 100644 phpunit.xml.dist create mode 100644 tests/ListObjectTest.php create mode 100644 tests/MockedProviderTrait.php create mode 100644 tests/ModelTest.php create mode 100644 tests/PDFfillerTest.php diff --git a/.gitignore b/.gitignore index 27ea8b6..fda1bde 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ vendor/ # Commit your application's lock file http://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file # You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file # composer.lock +.phpunit.cache/ +.phpunit.result.cache diff --git a/README.md b/README.md index 0d43435..784a3df 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ You can sign up for the API [here](https://www.pdffiller.com/en/developers#tab-pricing) ## System Requirements -* PHP >= 7.0 but the latest stable version of PHP is recommended; +* PHP >= 8.1 (tested on PHP 8.1–8.5); * `mbstring` extension; * `intl` extension; diff --git a/composer.json b/composer.json index d965e24..b5104c6 100644 --- a/composer.json +++ b/composer.json @@ -10,16 +10,17 @@ } ], "require": { - "php": ">=7.0", - "league/oauth2-client": "^2.6", + "php": ">=8.1", + "league/oauth2-client": "^2.8", "oodle/inflect": "^0.2.0", "guzzlehttp/guzzle": "^7.4" }, "require-dev": { "adammbalogh/key-value-store-file": "^0.5.3", "larapack/dd": "^1.0", - "nesbot/carbon": "^2.31", - "vlucas/phpdotenv": "^3.3|^5.4" + "nesbot/carbon": "^2.31 || ^3.0", + "vlucas/phpdotenv": "^3.3|^5.4", + "phpunit/phpunit": "^10.5 || ^11.5 || ^12.5 || ^13.0" }, "autoload": { "psr-4": { @@ -27,11 +28,17 @@ } }, "autoload-dev": { - "psr-4": { "Examples\\": "examples/" } + "psr-4": { + "Examples\\": "examples/", + "PDFfiller\\OAuth2\\Client\\Provider\\Tests\\": "tests/" + } }, "config": { "allow-plugins": { "kylekatarnls/update-helper": false } + }, + "scripts": { + "test": "phpunit" } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..d540700 --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,19 @@ + + + + + tests + + + + + + src + + + diff --git a/tests/ListObjectTest.php b/tests/ListObjectTest.php new file mode 100644 index 0000000..b7f2f40 --- /dev/null +++ b/tests/ListObjectTest.php @@ -0,0 +1,46 @@ + 1, 'b' => 2]); + + $this->assertTrue(isset($list['a'])); + $this->assertSame(2, $list['b']); + + $list['c'] = 3; + unset($list['a']); + + $this->assertFalse(isset($list['a'])); + $this->assertSame(['b' => 2, 'c' => 3], iterator_to_array($list)); + $this->assertSame(['b' => 2, 'c' => 3], $list->toArray()); + } + + public function testFillableFieldsListCanBeLoadedAndUsed(): void + { + $list = new FillableFieldsList(['first_name' => 'John', ['name' => 'age', 'value' => '42', 'fillable' => true]]); + + $this->assertTrue(isset($list['first_name'])); + $this->assertFalse(isset($list['unknown'])); + $this->assertInstanceOf(FillableField::class, $list['age']); + $this->assertSame('John', $list['first_name']->value); + $this->assertCount(2, iterator_to_array($list)); + } + + public function testEnumAcceptsOnlyItsConstants(): void + { + $this->assertSame('password', (new GrantType(GrantType::PASSWORD_GRANT))->getValue()); + + $this->expectException(\InvalidArgumentException::class); + new GrantType('unknown_grant'); + } +} diff --git a/tests/MockedProviderTrait.php b/tests/MockedProviderTrait.php new file mode 100644 index 0000000..98f16b5 --- /dev/null +++ b/tests/MockedProviderTrait.php @@ -0,0 +1,57 @@ +history = []; + $stack = HandlerStack::create(new MockHandler($responses)); + $stack->push(Middleware::history($this->history)); + + $provider = new PDFfiller([ + 'clientId' => 'test-client-id', + 'clientSecret' => 'test-client-secret', + 'urlAccessToken' => 'https://api.test/v2/oauth/token', + 'urlApiDomain' => 'https://api.test/v2/', + ], ['httpClient' => new Client(['handler' => $stack])]); + + if ($withToken) { + $provider->setAccessToken(new AccessToken(['access_token' => 'test-access-token'])); + } + + return $provider; + } + + private static function json($data, int $status = 200): Response + { + return new Response($status, ['Content-Type' => 'application/json'], json_encode($data)); + } + + private function request(int $index = -1): RequestInterface + { + $index = $index < 0 ? count($this->history) + $index : $index; + + return $this->history[$index]['request']; + } + + private function formBody(RequestInterface $request): array + { + parse_str((string) $request->getBody(), $body); + + return $body; + } +} diff --git a/tests/ModelTest.php b/tests/ModelTest.php new file mode 100644 index 0000000..b4ec235 --- /dev/null +++ b/tests/ModelTest.php @@ -0,0 +1,79 @@ +provider([self::json([ + 'items' => [['id' => 7, 'name' => 'Docs'], ['id' => 8, 'name' => 'Old']], + 'total' => 2, 'current_page' => 1, 'per_page' => 15, 'prev_page_url' => null, 'next_page_url' => null, + ])], true); + + $list = Folder::all($provider); + + $this->assertInstanceOf(ModelsList::class, $list); + $this->assertSame(2, $list->getTotal()); + $this->assertSame(['Docs', 'Old'], array_values(array_map(fn (Folder $folder) => $folder->name, iterator_to_array($list)))); + $this->assertSame('https://api.test/v2/folders/', (string) $this->request()->getUri()); + } + + public function testOneLoadsModelById(): void + { + $provider = $this->provider([self::json(['id' => 7, 'name' => 'Docs'])], true); + + $folder = Folder::one($provider, 7); + + $this->assertSame(7, $folder->id); + $this->assertSame('Docs', $folder->name); + $this->assertSame(['id' => 7, 'name' => 'Docs'], $folder->toArray()); + $this->assertSame('https://api.test/v2/folders/7', (string) $this->request()->getUri()); + } + + public function testSaveCreatesNewModel(): void + { + $provider = $this->provider([self::json(['id' => 9, 'name' => 'New'])], true); + + $folder = new Folder($provider, ['name' => 'New']); + $folder->save(); + + $this->assertSame(9, $folder->id); + $request = $this->request(); + $this->assertSame('POST', $request->getMethod()); + $this->assertSame(['name' => 'New'], json_decode((string) $request->getBody(), true)); + } + + public function testSaveUpdatesExistingModel(): void + { + $provider = $this->provider([ + self::json(['folder_id' => 7, 'name' => 'Docs']), + self::json(['folder_id' => 7, 'name' => 'Renamed']), + ], true); + + $folder = Folder::one($provider, 7); + $folder->name = 'Renamed'; + $folder->save(); + + $request = $this->request(); + $this->assertSame('PUT', $request->getMethod()); + $this->assertSame('https://api.test/v2/folders/7', (string) $request->getUri()); + $this->assertSame('Renamed', json_decode((string) $request->getBody(), true)['name']); + $this->assertSame('Renamed', $folder->name); + } + + public function testDeleteOne(): void + { + $provider = $this->provider([self::json(['total' => 1])], true); + + $this->assertSame(['total' => 1], Folder::deleteOne($provider, 9)); + $this->assertSame('DELETE', $this->request()->getMethod()); + $this->assertSame('https://api.test/v2/folders/9', (string) $this->request()->getUri()); + } +} diff --git a/tests/PDFfillerTest.php b/tests/PDFfillerTest.php new file mode 100644 index 0000000..7e892d3 --- /dev/null +++ b/tests/PDFfillerTest.php @@ -0,0 +1,227 @@ +expectException(OptionsMissingException::class); + + new PDFfiller(['clientId' => 'id', 'clientSecret' => 'secret', 'urlAccessToken' => 'https://api.test/token']); + } + + public function testPasswordGrantIssuesAccessToken(): void + { + $provider = $this->provider([ + self::json(['access_token' => 'tok-1', 'token_type' => 'Bearer', 'expires_in' => 3600, 'refresh_token' => 'ref-1']), + ]); + + $token = $provider->getAccessToken(new GrantType(GrantType::PASSWORD_GRANT), [ + 'username' => 'user@example.test', + 'password' => 'test-password', + ]); + + $this->assertInstanceOf(AccessToken::class, $token); + $this->assertSame('tok-1', $token->getToken()); + $this->assertSame('ref-1', $token->getRefreshToken()); + + $request = $this->request(); + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('https://api.test/v2/oauth/token', (string) $request->getUri()); + $this->assertSame('', $request->getHeaderLine('Authorization')); + $body = $this->formBody($request); + $this->assertSame('password', $body['grant_type']); + $this->assertSame('user@example.test', $body['username']); + $this->assertSame('test-password', $body['password']); + $this->assertSame('test-client-id', $body['client_id']); + $this->assertSame('test-client-secret', $body['client_secret']); + } + + public function testDefaultGrantIsClientCredentials(): void + { + $provider = $this->provider([self::json(['access_token' => 'tok-2'])]); + + $this->assertSame('tok-2', $provider->getAccessToken()->getToken()); + $this->assertSame('client_credentials', $this->formBody($this->request())['grant_type']); + } + + public function testAccessTokenIsReusedAfterFirstRequest(): void + { + $provider = $this->provider([self::json(['access_token' => 'tok-1'])]); + + $first = $provider->getAccessToken(); + $second = $provider->getAccessToken(); + + $this->assertSame($first, $second); + $this->assertCount(1, $this->history); + } + + public function testOAuthErrorResponseIsRefused(): void + { + $provider = $this->provider([self::json(['error' => 'invalid_client', 'message' => 'Client authentication failed'], 401)]); + + try { + $provider->getAccessToken(); + $this->fail('No exception for a refused token request'); + } catch (IdentityProviderException $e) { + $this->assertSame(401, $provider->getStatusCode()); + } + + // A failed request must not leave a token behind + $this->expectException(TokenMissingException::class); + $provider->queryApiCall('folders'); + } + + public function testErrorsPayloadOnTokenRequestIsRefused(): void + { + $provider = $this->provider([self::json(['errors' => [['message' => 'Invalid credentials']]], 400)]); + + $this->expectException(ResponseException::class); + + $provider->getAccessToken(new GrantType(GrantType::PASSWORD_GRANT), ['username' => 'u', 'password' => 'p']); + } + + public function testApiCallWithoutTokenIsRefusedAndNotSent(): void + { + $provider = $this->provider([self::json([])]); + + try { + $provider->queryApiCall('folders'); + $this->fail('No exception without access token'); + } catch (TokenMissingException $e) { + $this->assertCount(0, $this->history); + } + } + + public function testApiCallSendsBearerTokenAndResolvesUrlAgainstApiDomain(): void + { + $provider = $this->provider([self::json(['items' => []])], true); + + $this->assertSame(['items' => []], $provider->queryApiCall('folders', ['query' => ['per_page' => 5]])); + + $request = $this->request(); + $this->assertSame('GET', $request->getMethod()); + $this->assertSame('https://api.test/v2/folders?per_page=5', (string) $request->getUri()); + $this->assertSame('Bearer test-access-token', $request->getHeaderLine('Authorization')); + $this->assertSame(PDFfiller::USER_AGENT . '/' . PDFfiller::VERSION, $request->getHeaderLine('User-Agent')); + $this->assertSame(200, $provider->getStatusCode()); + } + + public function testFormParamsAreSentUrlEncoded(): void + { + $provider = $this->provider([self::json(['id' => 1], 201)], true); + + $provider->postApiCall('folders', ['form_params' => ['name' => 'A B', 'tags' => ['x', 'y']]]); + + $request = $this->request(); + $this->assertSame('application/x-www-form-urlencoded', $request->getHeaderLine('Content-Type')); + $this->assertSame(['name' => 'A B', 'tags' => ['x', 'y']], $this->formBody($request)); + $this->assertSame(201, $provider->getStatusCode()); + } + + public function testJsonOptionIsSentAsJson(): void + { + $provider = $this->provider([self::json(['id' => 1])], true); + + $provider->putApiCall('folders/1', ['json' => ['name' => 'Docs']]); + + $request = $this->request(); + $this->assertSame('PUT', $request->getMethod()); + $this->assertSame('application/json', $request->getHeaderLine('Content-Type')); + $this->assertSame(['name' => 'Docs'], json_decode((string) $request->getBody(), true)); + } + + public function testFormParamsAndMultipartTogetherAreRefused(): void + { + $provider = $this->provider([], true); + + $this->expectException(InvalidBodySourceException::class); + + $provider->postApiCall('document', ['form_params' => ['a' => 1], 'multipart' => [['name' => 'file', 'contents' => 'x']]]); + } + + public function testErrorsPayloadOnApiCallThrowsResponseException(): void + { + $provider = $this->provider([self::json(['errors' => [['message' => 'Folder not found']]], 404)], true); + + try { + $provider->deleteApiCall('folders/404'); + $this->fail('No exception for an errors payload'); + } catch (ResponseException $e) { + $this->assertStringContainsString('Folder not found', $e->getMessage()); + $this->assertSame(404, $provider->getStatusCode()); + } + } + + public static function urlsOutsideApiDomain(): array + { + return [ + 'other host' => ['https://evil.test/steal'], + 'protocol-relative host' => ['//evil.test/steal'], + 'scheme downgrade' => ['http://api.test/v2/folders'], + 'other port' => ['https://api.test:8443/v2/folders'], + 'host suffix' => ['https://api.test.evil.test/v2/folders'], + ]; + } + + #[DataProvider('urlsOutsideApiDomain')] + public function testAccessTokenIsNotSentOutsideApiDomain(string $url): void + { + $provider = $this->provider([self::json([])], true); + + try { + $provider->queryApiCall($url); + $this->fail('Request outside urlApiDomain was not refused'); + } catch (InvalidRequestException $e) { + $this->assertCount(0, $this->history); + } + } + + public function testAbsoluteUrlOnApiDomainIsAllowed(): void + { + $provider = $this->provider([self::json(['items' => []])], true); + + $provider->queryApiCall('https://API.test/v2/folders?page=2'); + + $this->assertSame('https://api.test/v2/folders?page=2', (string) $this->request()->getUri()); + $this->assertSame('Bearer test-access-token', $this->request()->getHeaderLine('Authorization')); + } + + public function testTokenResponseWithoutAccessTokenIsRefused(): void + { + $provider = $this->provider([self::json(['message' => 'Internal error'], 500)]); + + try { + $provider->getAccessToken(); + $this->fail('A token response without access_token was accepted'); + } catch (\InvalidArgumentException $e) { + $this->expectException(TokenMissingException::class); + $provider->queryApiCall('folders'); + } + } + + public function testNonJsonTokenResponseIsRefused(): void + { + $provider = $this->provider([new \GuzzleHttp\Psr7\Response(502, ['Content-Type' => 'text/html'], 'Bad Gateway')]); + + $this->expectException(UnexpectedValueException::class); + + $provider->getAccessToken(); + } +} From 7687e0fd4605c58e2187fd3267b61df7000ec8cd Mon Sep 17 00:00:00 2001 From: oleh ilin Date: Sat, 26 Sep 2026 21:47:59 +0300 Subject: [PATCH 5/5] [CI] PDF-12283 run tests on PHP 8.3/8.4/8.5 and 8.1 with lowest deps GitHub-hosted runner, because the repository is public. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/tests.yml | 47 +++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..cd2b658 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,47 @@ +name: Tests + +on: + push: + branches: + - 'master' + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + phpunit: + # GitHub-hosted runner: this repository is public, so pull requests from forks + # must not run on self-hosted runners. + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.3', '8.4', '8.5'] + dependencies: ['highest'] + include: + # Lowest supported PHP with the lowest allowed dependencies + - php: '8.1' + dependencies: 'lowest' + name: PHP ${{ matrix.php }} (${{ matrix.dependencies }}) + steps: + - uses: actions/checkout@v4 + + - name: Set PHP version + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, intl + coverage: none + + - name: Composer Install + run: | + if [ "${{ matrix.dependencies }}" = "lowest" ]; then + composer update -n --prefer-dist --no-scripts --prefer-lowest --prefer-stable + else + composer update -n --prefer-dist --no-scripts + fi + + - name: Run PHPUnit + run: ./vendor/bin/phpunit