From 9feedf7ea5828fa1d3a6a7ff191573bc5e070b60 Mon Sep 17 00:00:00 2001 From: Sander van Hooft Date: Fri, 4 Jul 2025 10:45:49 +0200 Subject: [PATCH 01/27] v4 initial work --- README.md | 14 +-- UPGRADE.md | 27 ++++-- composer.json | 21 +++-- docs/recurring_and_direct_charge.md | 2 +- src/MollieLaravelHttpClientAdapter.php | 115 +++++++++++++++++++------ src/MollieServiceProvider.php | 34 ++++---- src/MollieSocialiteServiceProvider.php | 47 ++++++++++ tests/MollieApiClientTest.php | 32 ++++--- 8 files changed, 212 insertions(+), 80 deletions(-) create mode 100644 src/MollieSocialiteServiceProvider.php diff --git a/README.md b/README.md index 26e5ae6..c310cea 100644 --- a/README.md +++ b/README.md @@ -22,12 +22,12 @@ Accepting [iDEAL](https://www.mollie.com/en/payments/ideal/), [Apple Pay](https: * Now you're ready to use the Mollie API client in test mode. * Follow [a few steps](https://www.mollie.com/dashboard/?modal=onboarding) to enable payment methods in live mode, and let us handle the rest. * Up-to-date OpenSSL (or other SSL/TLS toolkit) -* PHP >= 8.1 -* [Laravel](https://www.laravel.com) >= 10.0 +* PHP >= 8.2 +* [Laravel](https://www.laravel.com) >= 11.0 * [Laravel Socialite](https://github.com/laravel/socialite) >= 5.0 (if you intend on using [Mollie Connect](https://docs.mollie.com/oauth/overview)) -## Upgrading from v2.x? -To support the enhanced Mollie API, some breaking changes were introduced. Make sure to follow the instructions in the [upgrade guide](UPGRADE.md). +## Upgrading from v3.x? +To support the enhanced Mollie API v3, some breaking changes were introduced. Make sure to follow the instructions in the [upgrade guide](UPGRADE.md). Fresh install? Continue with the installation guide below. @@ -43,7 +43,7 @@ Or add it to `composer.json` manually: ```json "require": { - "mollie/laravel-mollie": "^3.0" + "mollie/laravel-mollie": "^4.0" } ``` @@ -78,6 +78,10 @@ public function preparePayment() "webhookUrl" => route('webhooks.mollie'), "metadata" => [ "order_id" => "12345", + "customer_info" => [ + "name" => "John Doe", + "email" => "john@example.com" + ] ], ]); diff --git a/UPGRADE.md b/UPGRADE.md index ca6483a..05fdc1d 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,6 +1,6 @@ ![Mollie](https://www.mollie.nl/files/Mollie-Logo-Style-Small.png) -# Migrating from Laravel-Mollie v2.x to v3 +# Migrating from Laravel-Mollie v3.x to v4 ## Update composer dependencies @@ -8,7 +8,7 @@ Update `composer.json` to match this: ```json "require": { - "mollie/laravel-mollie": "^3.0" + "mollie/laravel-mollie": "^4.0" } ``` @@ -16,19 +16,28 @@ Then run `composer update mollie/laravel-mollie`. ## Review Changes ### Updating Dependencies -Laravel-Mollie now requires PHP 8.1.0 or greater. +Laravel-Mollie now requires PHP 8.2.0 or greater and supports Laravel 11.0 and 12.0 only. If you are using the mollie connect feature, make sure to checkout the upgrade instructions for [Laravel-Socialite](https://github.com/laravel/socialite/blob/5.x/UPGRADE.md) -#### Lumen support dropped -The Laravel team has added a note a while ago on the [Lumen Repository](https://github.com/laravel/lumen?tab=readme-ov-file) as well as the official [Lumen documentation](https://lumen.laravel.com/docs/master#installation) that they discourage starting a new project with Lumen. Therefore we dropped the Lumen support for this package. +### Mollie API PHP v3 Upgrade +This version upgrades to mollie-api-php v3, which includes several breaking changes: -### Removed Classes -In order to enhance maintainability the following class was removed: +1. **Metadata Type Restriction**: In v3, metadata in request payloads is restricted to only accept arrays (not strings or objects). +2. **Class & Method Renames**: Several endpoint classes and methods have been renamed. +3. **Streamlined Constants**: Redundant prefixes have been removed for a cleaner API. +4. **Test Mode Handling**: Automatic detection with API keys and explicit parameter for organization credentials. +5. **Modern HTTP Handling**: PSR-18 support and typed request objects. -- `MollieApiWrapper` +For full details on the mollie-api-php v3 changes, see the [official upgrade guide](https://github.com/mollie/mollie-api-php/blob/master/UPGRADING.md). -Instead the `MollieApiClient` is now directly resolved and provided through the container without any abstractions. This change means you can directly access the newest API features that are added to the underlying [mollie/mollie-api-php](https://github.com/mollie/mollie-api-php) client without having to wait on this repository being updated. +### Socialite Integration Changes +The Socialite integration has been moved to a dedicated service provider (`MollieSocialiteServiceProvider`) which is automatically registered alongside the main provider. This allows the main `MollieServiceProvider` to be deferred, improving application performance when the Mollie API is not being used. + +### Deferred Service Provider +The main `MollieServiceProvider` is now deferrable, which means it will only be loaded when the Mollie API is actually used in your application. This can improve application performance. + +If you're using the Socialite integration, the `MollieSocialiteServiceProvider` will still be loaded on every request to ensure the Socialite driver is properly registered. ### Change in calling API endpoints Earlier versions of Laravel-Mollie provided access to endpoints via both methods and properties. Moving forward, access to endpoints will be exclusively through properties, aligning with the practices of the mollie-api-php SDK.**** diff --git a/composer.json b/composer.json index 9c06fae..ada5617 100644 --- a/composer.json +++ b/composer.json @@ -41,17 +41,16 @@ "socialite" ], "require": { - "php": "^8.1|^8.2", - "mollie/mollie-api-php": "^2.60", - "illuminate/support": "^10.0|^11.0|^12.0", + "php": "^8.2", + "mollie/mollie-api-php": "^3.0", + "illuminate/support": "^11.0|^12.0", "ext-json": "*" }, "require-dev": { - "mockery/mockery": "^1.4", - "orchestra/testbench": "^8.0|^9.0|^10.0", - "phpunit/phpunit": "^10.0|^11.5.3", - "laravel/socialite": "^5.5", - "laravel/pint": "^1.1" + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.0|^10.0", + "phpunit/phpunit": "^11.0", + "laravel/socialite": "^5.8" }, "suggest": { "laravel/socialite": "Use Mollie Connect (OAuth) to authenticate via Laravel Socialite with the Mollie API. This is needed for some endpoints." @@ -69,7 +68,8 @@ "extra": { "laravel": { "providers": [ - "Mollie\\Laravel\\MollieServiceProvider" + "Mollie\\Laravel\\MollieServiceProvider", + "Mollie\\Laravel\\MollieSocialiteServiceProvider" ], "aliases": { "Mollie": "Mollie\\Laravel\\Facades\\Mollie" @@ -77,8 +77,7 @@ } }, "scripts": { - "test": "./vendor/bin/phpunit tests", - "format": "./vendor/bin/pint" + "test": "./vendor/bin/phpunit tests" }, "minimum-stability": "dev", "prefer-stable": true diff --git a/docs/recurring_and_direct_charge.md b/docs/recurring_and_direct_charge.md index dfbb35c..b7a8828 100644 --- a/docs/recurring_and_direct_charge.md +++ b/docs/recurring_and_direct_charge.md @@ -41,7 +41,7 @@ return redirect($payment->getCheckoutUrl(), 303); After doing the initial payment, you may [charge the users card/account directly](https://docs.mollie.com/payments/recurring#payments-recurring-charging-on-demand). Make sure there's a valid mandate connected to the customer. In case there are multiple mandates at least one should have `status` set to `valid`. Checking mandates is easy: ```php -$mandates = Mollie::api()->mandates->listFor($customer); +$mandates = Mollie::api()->mandates->pageFor($customer); ``` If any of the mandates is valid, charging the user is a piece of cake. Make sure `sequenceType` is set to `recurring`. diff --git a/src/MollieLaravelHttpClientAdapter.php b/src/MollieLaravelHttpClientAdapter.php index 296c8bf..c4a3215 100644 --- a/src/MollieLaravelHttpClientAdapter.php +++ b/src/MollieLaravelHttpClientAdapter.php @@ -2,45 +2,106 @@ namespace Mollie\Laravel; -use Illuminate\Http\Client\Response; +use Illuminate\Http\Client\Response as LaravelResponse; use Illuminate\Support\Facades\Http; use Mollie\Api\Exceptions\ApiException; -use Mollie\Api\HttpAdapter\MollieHttpAdapterInterface; +use Mollie\Api\Contracts\HttpAdapterContract; +use Mollie\Api\Http\PendingRequest; +use Mollie\Api\Http\Response; +use Mollie\Api\Utils\Factories; +use Nyholm\Psr7\Factory\Psr17Factory; -class MollieLaravelHttpClientAdapter implements MollieHttpAdapterInterface +class MollieLaravelHttpClientAdapter implements HttpAdapterContract { - public function send($httpMethod, $url, $headers, $httpBody): ?object + /** + * Get the HTTP factories used by this adapter. + * + * @return Factories + */ + public function factories(): Factories { - $contentType = $headers['Content-Type'] ?? 'application/json'; - unset($headers['Content-Type']); - - $response = Http::withBody($httpBody, $contentType) - ->withHeaders($headers) - ->send($httpMethod, $url); - - return match (true) { - $response->noContent() => null, - $response->failed() => throw ApiException::createFromResponse($response->toPsrResponse(), null), - empty($response->body()) => throw new ApiException('Mollie response body is empty.'), - default => $this->parseResponseBody($response), - }; + $psr17Factory = new Psr17Factory(); + + return new Factories( + $psr17Factory, // RequestFactoryInterface + $psr17Factory, // ResponseFactoryInterface + $psr17Factory, // StreamFactoryInterface + $psr17Factory // UriFactoryInterface + ); } - private function parseResponseBody(Response $response): ?object + /** + * Get the version string for this HTTP adapter. + * + * @return string|null + */ + public function version(): ?string { - $body = $response->body(); + return 'Laravel/HttpClient'; + } - $object = @json_decode($body); + /** + * Send a request to the specified Mollie api url. + * + * @param PendingRequest $pendingRequest + * @return Response + * @throws ApiException + */ + public function sendRequest(PendingRequest $pendingRequest): Response + { + // Get request details from PendingRequest + $method = $pendingRequest->method(); + $url = $pendingRequest->url(); + + // Prepare headers for Laravel HTTP client + $headers = []; + + // Execute request handlers from middleware + $pendingRequest->executeRequestHandlers(); + + // Create PSR-7 request using factories + $psrRequest = $this->factories() + ->requestFactory + ->createRequest($method, $url); - if (json_last_error() !== JSON_ERROR_NONE) { - throw new ApiException("Unable to decode Mollie response: '{$body}'."); + $httpClient = Http::withHeaders($headers); + + // Send request using Laravel HTTP client + try { + $laravelResponse = $httpClient->send($method, $url); + + // Convert Laravel response to PSR-7 response + $psrResponse = $laravelResponse->toPsrResponse(); + + // Create and return Mollie Response + return new Response($psrResponse, $psrRequest, $pendingRequest); + } catch (\Exception $e) { + throw new ApiException($psrResponse, $e->getMessage(), $e->getCode(), $e); } - - return $object; } - - public function versionString(): string + + /** + * @deprecated Use sendRequest() instead. + * @param string $httpMethod + * @param string $url + * @param array $headers + * @param string $httpBody + * @return LaravelResponse + * @throws ApiException + */ + public function send(string $httpMethod, string $url, array $headers, string $httpBody): LaravelResponse { - return 'Laravel/HttpClient'; + $contentType = $headers['Content-Type'] ?? 'application/json'; + unset($headers['Content-Type']); + + try { + $response = Http::withBody($httpBody, $contentType) + ->withHeaders($headers) + ->send($httpMethod, $url); + + return $response; + } catch (\Exception $e) { + throw new ApiException($e->getMessage(), $e->getCode(), $e); + } } } diff --git a/src/MollieServiceProvider.php b/src/MollieServiceProvider.php index 3641992..3c1ea82 100644 --- a/src/MollieServiceProvider.php +++ b/src/MollieServiceProvider.php @@ -8,7 +8,14 @@ class MollieServiceProvider extends ServiceProvider { - const PACKAGE_VERSION = '3.1.0'; + const PACKAGE_VERSION = '4.0.0'; + + /** + * Indicates if loading of the provider is deferred. + * + * @var bool + */ + protected $defer = true; /** * Boot the service provider. @@ -17,29 +24,24 @@ class MollieServiceProvider extends ServiceProvider */ public function boot() { + $this->mergeConfigFrom(__DIR__.'/../config/mollie.php', 'mollie'); + if ($this->app->runningInConsole()) { $this->publishes([__DIR__.'/../config/mollie.php' => config_path('mollie.php')]); } - - $this->extendSocialite(); } /** - * Extend the Laravel Socialite factory class, if available. + * Get the services provided by the provider. * - * @return void + * @return array */ - protected function extendSocialite() + public function provides() { - if (interface_exists($socialiteFactoryClass = \Laravel\Socialite\Contracts\Factory::class)) { - $socialite = $this->app->make($socialiteFactoryClass); - - $socialite->extend('mollie', function (Container $app) use ($socialite) { - $config = $app['config']['services.mollie']; - - return $socialite->buildProvider(MollieConnectProvider::class, $config); - }); - } + return [ + MollieApiClient::class, + MollieManager::class, + ]; } /** @@ -49,8 +51,6 @@ protected function extendSocialite() */ public function register() { - $this->mergeConfigFrom(__DIR__.'/../config/mollie.php', 'mollie'); - $this->app->singleton( MollieApiClient::class, function (Container $app) { diff --git a/src/MollieSocialiteServiceProvider.php b/src/MollieSocialiteServiceProvider.php new file mode 100644 index 0000000..17edc48 --- /dev/null +++ b/src/MollieSocialiteServiceProvider.php @@ -0,0 +1,47 @@ +extendSocialite(); + } + + /** + * Register the application services. + * + * @return void + */ + public function register() + { + // No registration needed for Socialite extension + } + + /** + * Extend the Laravel Socialite factory class, if available. + * + * @return void + */ + protected function extendSocialite() + { + if (interface_exists($socialiteFactoryClass = \Laravel\Socialite\Contracts\Factory::class)) { + $socialite = $this->app->make($socialiteFactoryClass); + + $socialite->extend('mollie', function (Container $app) use ($socialite) { + $config = $app['config']['services.mollie']; + + return $socialite->buildProvider(MollieConnectProvider::class, $config); + }); + } + } +} diff --git a/tests/MollieApiClientTest.php b/tests/MollieApiClientTest.php index b28105b..e5ab6e0 100644 --- a/tests/MollieApiClientTest.php +++ b/tests/MollieApiClientTest.php @@ -2,9 +2,11 @@ namespace Mollie\Laravel\Tests; +use Mollie\Api\Http\Auth\ApiKeyAuthenticator; use Mollie\Api\MollieApiClient; use Mollie\Laravel\MollieLaravelHttpClientAdapter; use ReflectionClass; +use ReflectionMethod; class MollieApiClientTest extends TestCase { @@ -19,21 +21,22 @@ public function test_injected_http_adapter_is_laravel_http_client_adapter() public function test_api_key_is_set_on_resolving_api_client() { config(['mollie.key' => 'test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxyz']); - - $this->assertEquals( - 'test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxyz', - $this->getUnaccessiblePropertyValue('apiKey') - ); + + $client = resolve(MollieApiClient::class); + $authenticator = $this->getAuthenticator($client); + + $this->assertInstanceOf(ApiKeyAuthenticator::class, $authenticator); + $this->assertTrue($authenticator->isTestToken()); // Test tokens start with 'test_' } public function test_does_not_set_api_key_if_key_is_empty() { config(['mollie.key' => '']); - - $this->assertEquals( - null, - $this->getUnaccessiblePropertyValue('apiKey') - ); + + $client = resolve(MollieApiClient::class); + $authenticator = $this->getAuthenticator($client); + + $this->assertNull($authenticator); } private function getUnaccessiblePropertyValue(string $propertyName): mixed @@ -46,4 +49,13 @@ private function getUnaccessiblePropertyValue(string $propertyName): mixed return $property->getValue($resolvedInstance); } + + private function getAuthenticator(MollieApiClient $client): ?object + { + $reflection = new ReflectionClass($client); + $property = $reflection->getProperty('authenticator'); + $property->setAccessible(true); + + return $property->getValue($client); + } } From 17115123d73416fe4e2e0fefb7a814c0d0aba5b1 Mon Sep 17 00:00:00 2001 From: Sander van Hooft Date: Fri, 4 Jul 2025 11:53:18 +0200 Subject: [PATCH 02/27] Add MollieServiceProviderTest --- tests/MollieServiceProviderTest.php | 62 +++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 tests/MollieServiceProviderTest.php diff --git a/tests/MollieServiceProviderTest.php b/tests/MollieServiceProviderTest.php new file mode 100644 index 0000000..ddf3812 --- /dev/null +++ b/tests/MollieServiceProviderTest.php @@ -0,0 +1,62 @@ + '']); + + // Create a new instance of the service provider + $provider = new MollieServiceProvider($this->app); + + // Register and boot should not throw exceptions + $provider->register(); + $provider->boot(); + + // Verify the service provider registered the MollieApiClient + $this->assertTrue($this->app->bound(MollieApiClient::class)); + + // Resolving the client should not throw an exception + $client = $this->app->make(MollieApiClient::class); + $this->assertInstanceOf(MollieApiClient::class, $client); + + // Verify no API key was set (authenticator should be null) + $reflection = new \ReflectionClass($client); + $property = $reflection->getProperty('authenticator'); + $property->setAccessible(true); + $this->assertNull($property->getValue($client)); + } + + /** + * Test that the service provider can be registered and booted with a valid API key. + */ + public function test_service_provider_with_valid_api_key() + { + // Set a valid API key in the config + config(['mollie.key' => 'test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxyz']); + + // Create a new instance of the service provider + $provider = new MollieServiceProvider($this->app); + + // Register and boot should not throw exceptions + $provider->register(); + $provider->boot(); + + // Verify the service provider registered the MollieApiClient + $this->assertTrue($this->app->bound(MollieApiClient::class)); + + // Resolving the client should not throw an exception + $client = $this->app->make(MollieApiClient::class); + $this->assertInstanceOf(MollieApiClient::class, $client); + } +} From 6f70596a98ed092134d2adb72bd2d3d2fd9ebe2c Mon Sep 17 00:00:00 2001 From: Sander van Hooft Date: Fri, 4 Jul 2025 12:26:01 +0200 Subject: [PATCH 03/27] wip --- UPGRADE.md | 8 ++-- src/MollieLaravelHttpClientAdapter.php | 50 +++++++------------- tests/MollieApiClientTest.php | 20 ++++---- tests/MollieLaravelHttpClientAdapterTest.php | 38 ++++++++++++++- 4 files changed, 69 insertions(+), 47 deletions(-) diff --git a/UPGRADE.md b/UPGRADE.md index 05fdc1d..8ebe10d 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -16,9 +16,7 @@ Then run `composer update mollie/laravel-mollie`. ## Review Changes ### Updating Dependencies -Laravel-Mollie now requires PHP 8.2.0 or greater and supports Laravel 11.0 and 12.0 only. - -If you are using the mollie connect feature, make sure to checkout the upgrade instructions for [Laravel-Socialite](https://github.com/laravel/socialite/blob/5.x/UPGRADE.md) +Laravel-Mollie now requires PHP 8.2.0 or greater and supports Laravel 11.0 and 12.0 only. It leverages mollie-api-php version 3, which includes several breaking changes. ### Mollie API PHP v3 Upgrade This version upgrades to mollie-api-php v3, which includes several breaking changes: @@ -53,7 +51,7 @@ Mollie::api()->payments->create(); ### No more global helper function The `mollie()` helper function was deleted. If you rely on the helper function, either consider switching to - injecting or resolving the `MollieApiClient` from the container, or -- use the `Mollie` facade +- use the `Mollie\Laravel\Facades\Mollie::api()` facade If none of these are an option for you, you can create your own `helpers.php` file and insert the code for the `mollie()` function yourself. @@ -75,4 +73,4 @@ if (! function_exists('mollie')) { ``` ## Stuck? -Feel free to open an [issue](https://github.com/mollie/laravel-mollie/issues). +Feel free to open an [issue](https://github.com/mollie/laravel-mollie/issues) or come say hi in the [Mollie Community Discord](https://discord.gg/mollie). diff --git a/src/MollieLaravelHttpClientAdapter.php b/src/MollieLaravelHttpClientAdapter.php index c4a3215..8c89859 100644 --- a/src/MollieLaravelHttpClientAdapter.php +++ b/src/MollieLaravelHttpClientAdapter.php @@ -2,7 +2,6 @@ namespace Mollie\Laravel; -use Illuminate\Http\Client\Response as LaravelResponse; use Illuminate\Support\Facades\Http; use Mollie\Api\Exceptions\ApiException; use Mollie\Api\Contracts\HttpAdapterContract; @@ -41,7 +40,7 @@ public function version(): ?string } /** - * Send a request to the specified Mollie api url. + * Send a request to the specified Mollie API URL. * * @param PendingRequest $pendingRequest * @return Response @@ -52,9 +51,7 @@ public function sendRequest(PendingRequest $pendingRequest): Response // Get request details from PendingRequest $method = $pendingRequest->method(); $url = $pendingRequest->url(); - - // Prepare headers for Laravel HTTP client - $headers = []; + $headers = $pendingRequest->headers(); // Execute request handlers from middleware $pendingRequest->executeRequestHandlers(); @@ -64,7 +61,14 @@ public function sendRequest(PendingRequest $pendingRequest): Response ->requestFactory ->createRequest($method, $url); - $httpClient = Http::withHeaders($headers); + // Convert headers from ArrayStore to plain array + $headersArray = []; + foreach ($headers->all() as $key => $value) { + $headersArray[$key] = $value; + } + + // Configure Laravel HTTP client with headers + $httpClient = Http::withHeaders($headersArray); // Send request using Laravel HTTP client try { @@ -76,32 +80,14 @@ public function sendRequest(PendingRequest $pendingRequest): Response // Create and return Mollie Response return new Response($psrResponse, $psrRequest, $pendingRequest); } catch (\Exception $e) { - throw new ApiException($psrResponse, $e->getMessage(), $e->getCode(), $e); - } - } - - /** - * @deprecated Use sendRequest() instead. - * @param string $httpMethod - * @param string $url - * @param array $headers - * @param string $httpBody - * @return LaravelResponse - * @throws ApiException - */ - public function send(string $httpMethod, string $url, array $headers, string $httpBody): LaravelResponse - { - $contentType = $headers['Content-Type'] ?? 'application/json'; - unset($headers['Content-Type']); - - try { - $response = Http::withBody($httpBody, $contentType) - ->withHeaders($headers) - ->send($httpMethod, $url); - - return $response; - } catch (\Exception $e) { - throw new ApiException($e->getMessage(), $e->getCode(), $e); + // Create a generic error response + $factory = $this->factories()->responseFactory; + $psrErrorResponse = $factory->createResponse(500); + + // Create a Mollie Response with the error + $errorResponse = new Response($psrErrorResponse, $psrRequest, $pendingRequest); + + throw new ApiException($errorResponse, $e->getMessage(), $e->getCode(), $e); } } } diff --git a/tests/MollieApiClientTest.php b/tests/MollieApiClientTest.php index e5ab6e0..e541bb7 100644 --- a/tests/MollieApiClientTest.php +++ b/tests/MollieApiClientTest.php @@ -6,7 +6,6 @@ use Mollie\Api\MollieApiClient; use Mollie\Laravel\MollieLaravelHttpClientAdapter; use ReflectionClass; -use ReflectionMethod; class MollieApiClientTest extends TestCase { @@ -23,10 +22,13 @@ public function test_api_key_is_set_on_resolving_api_client() config(['mollie.key' => 'test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxyz']); $client = resolve(MollieApiClient::class); - $authenticator = $this->getAuthenticator($client); + $authenticator = $client->getAuthenticator(); $this->assertInstanceOf(ApiKeyAuthenticator::class, $authenticator); - $this->assertTrue($authenticator->isTestToken()); // Test tokens start with 'test_' + $this->assertEquals( + 'test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxyz', + $this->getApiKeyFromAuthenticator($authenticator) + ); } public function test_does_not_set_api_key_if_key_is_empty() @@ -34,7 +36,7 @@ public function test_does_not_set_api_key_if_key_is_empty() config(['mollie.key' => '']); $client = resolve(MollieApiClient::class); - $authenticator = $this->getAuthenticator($client); + $authenticator = $client->getAuthenticator(); $this->assertNull($authenticator); } @@ -49,13 +51,13 @@ private function getUnaccessiblePropertyValue(string $propertyName): mixed return $property->getValue($resolvedInstance); } - - private function getAuthenticator(MollieApiClient $client): ?object + + private function getApiKeyFromAuthenticator(object $authenticator): ?string { - $reflection = new ReflectionClass($client); - $property = $reflection->getProperty('authenticator'); + $reflection = new ReflectionClass($authenticator); + $property = $reflection->getProperty('token'); $property->setAccessible(true); - return $property->getValue($client); + return $property->getValue($authenticator); } } diff --git a/tests/MollieLaravelHttpClientAdapterTest.php b/tests/MollieLaravelHttpClientAdapterTest.php index 5e49acc..8b6f15f 100644 --- a/tests/MollieLaravelHttpClientAdapterTest.php +++ b/tests/MollieLaravelHttpClientAdapterTest.php @@ -3,11 +3,12 @@ namespace Mollie\Laravel\Tests; use Illuminate\Support\Facades\Http; +use Mollie\Api\Exceptions\ApiException; use Mollie\Api\MollieApiClient; use Mollie\Api\Resources\Payment; /** - * Class MollieApiWrapper + * Class MollieLaravelHttpClientAdapterTest */ class MollieLaravelHttpClientAdapterTest extends TestCase { @@ -57,4 +58,39 @@ public function test_get_request() $this->assertEquals($payment->redirectUrl, $returnedPayment->redirectUrl); $this->assertEquals($payment->description, $returnedPayment->description); } + + public function test_exception_handling() + { + /** @var MollieApiClient $client */ + $client = app(MollieApiClient::class); + + // Simulate a network error + Http::fake([ + 'https://api.mollie.com/*' => Http::response('', 500), + ]); + + $this->expectException(ApiException::class); + + // This should throw an ApiException + $client->payments->get('non_existing_payment'); + } + + public function test_connection_error_handling() + { + /** @var MollieApiClient $client */ + $client = app(MollieApiClient::class); + + // Simulate a connection error + Http::fake([ + 'https://api.mollie.com/*' => function() { + throw new \Exception('Connection error'); + }, + ]); + + $this->expectException(ApiException::class); + $this->expectExceptionMessage('Connection error'); + + // This should throw an ApiException with the connection error message + $client->payments->get('any_payment_id'); + } } From f655daee3c5c51e351b551271945a7473fe0c1b1 Mon Sep 17 00:00:00 2001 From: sandervanhooft Date: Fri, 4 Jul 2025 10:26:23 +0000 Subject: [PATCH 04/27] Fix styling --- src/MollieLaravelHttpClientAdapter.php | 26 ++++++++------------ src/MollieServiceProvider.php | 4 +-- tests/MollieApiClientTest.php | 10 ++++---- tests/MollieLaravelHttpClientAdapterTest.php | 18 +++++++------- tests/MollieServiceProviderTest.php | 20 +++++++-------- 5 files changed, 36 insertions(+), 42 deletions(-) diff --git a/src/MollieLaravelHttpClientAdapter.php b/src/MollieLaravelHttpClientAdapter.php index 8c89859..2b1278a 100644 --- a/src/MollieLaravelHttpClientAdapter.php +++ b/src/MollieLaravelHttpClientAdapter.php @@ -3,8 +3,8 @@ namespace Mollie\Laravel; use Illuminate\Support\Facades\Http; -use Mollie\Api\Exceptions\ApiException; use Mollie\Api\Contracts\HttpAdapterContract; +use Mollie\Api\Exceptions\ApiException; use Mollie\Api\Http\PendingRequest; use Mollie\Api\Http\Response; use Mollie\Api\Utils\Factories; @@ -14,13 +14,11 @@ class MollieLaravelHttpClientAdapter implements HttpAdapterContract { /** * Get the HTTP factories used by this adapter. - * - * @return Factories */ public function factories(): Factories { - $psr17Factory = new Psr17Factory(); - + $psr17Factory = new Psr17Factory; + return new Factories( $psr17Factory, // RequestFactoryInterface $psr17Factory, // ResponseFactoryInterface @@ -31,8 +29,6 @@ public function factories(): Factories /** * Get the version string for this HTTP adapter. - * - * @return string|null */ public function version(): ?string { @@ -42,8 +38,6 @@ public function version(): ?string /** * Send a request to the specified Mollie API URL. * - * @param PendingRequest $pendingRequest - * @return Response * @throws ApiException */ public function sendRequest(PendingRequest $pendingRequest): Response @@ -52,10 +46,10 @@ public function sendRequest(PendingRequest $pendingRequest): Response $method = $pendingRequest->method(); $url = $pendingRequest->url(); $headers = $pendingRequest->headers(); - + // Execute request handlers from middleware $pendingRequest->executeRequestHandlers(); - + // Create PSR-7 request using factories $psrRequest = $this->factories() ->requestFactory @@ -69,24 +63,24 @@ public function sendRequest(PendingRequest $pendingRequest): Response // Configure Laravel HTTP client with headers $httpClient = Http::withHeaders($headersArray); - + // Send request using Laravel HTTP client try { $laravelResponse = $httpClient->send($method, $url); - + // Convert Laravel response to PSR-7 response $psrResponse = $laravelResponse->toPsrResponse(); - + // Create and return Mollie Response return new Response($psrResponse, $psrRequest, $pendingRequest); } catch (\Exception $e) { // Create a generic error response $factory = $this->factories()->responseFactory; $psrErrorResponse = $factory->createResponse(500); - + // Create a Mollie Response with the error $errorResponse = new Response($psrErrorResponse, $psrRequest, $pendingRequest); - + throw new ApiException($errorResponse, $e->getMessage(), $e->getCode(), $e); } } diff --git a/src/MollieServiceProvider.php b/src/MollieServiceProvider.php index 3c1ea82..2e534c8 100644 --- a/src/MollieServiceProvider.php +++ b/src/MollieServiceProvider.php @@ -9,7 +9,7 @@ class MollieServiceProvider extends ServiceProvider { const PACKAGE_VERSION = '4.0.0'; - + /** * Indicates if loading of the provider is deferred. * @@ -25,7 +25,7 @@ class MollieServiceProvider extends ServiceProvider public function boot() { $this->mergeConfigFrom(__DIR__.'/../config/mollie.php', 'mollie'); - + if ($this->app->runningInConsole()) { $this->publishes([__DIR__.'/../config/mollie.php' => config_path('mollie.php')]); } diff --git a/tests/MollieApiClientTest.php b/tests/MollieApiClientTest.php index e541bb7..00f8a90 100644 --- a/tests/MollieApiClientTest.php +++ b/tests/MollieApiClientTest.php @@ -20,10 +20,10 @@ public function test_injected_http_adapter_is_laravel_http_client_adapter() public function test_api_key_is_set_on_resolving_api_client() { config(['mollie.key' => 'test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxyz']); - + $client = resolve(MollieApiClient::class); $authenticator = $client->getAuthenticator(); - + $this->assertInstanceOf(ApiKeyAuthenticator::class, $authenticator); $this->assertEquals( 'test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxyz', @@ -34,10 +34,10 @@ public function test_api_key_is_set_on_resolving_api_client() public function test_does_not_set_api_key_if_key_is_empty() { config(['mollie.key' => '']); - + $client = resolve(MollieApiClient::class); $authenticator = $client->getAuthenticator(); - + $this->assertNull($authenticator); } @@ -57,7 +57,7 @@ private function getApiKeyFromAuthenticator(object $authenticator): ?string $reflection = new ReflectionClass($authenticator); $property = $reflection->getProperty('token'); $property->setAccessible(true); - + return $property->getValue($authenticator); } } diff --git a/tests/MollieLaravelHttpClientAdapterTest.php b/tests/MollieLaravelHttpClientAdapterTest.php index 8b6f15f..4a0e054 100644 --- a/tests/MollieLaravelHttpClientAdapterTest.php +++ b/tests/MollieLaravelHttpClientAdapterTest.php @@ -58,38 +58,38 @@ public function test_get_request() $this->assertEquals($payment->redirectUrl, $returnedPayment->redirectUrl); $this->assertEquals($payment->description, $returnedPayment->description); } - + public function test_exception_handling() { /** @var MollieApiClient $client */ $client = app(MollieApiClient::class); - + // Simulate a network error Http::fake([ 'https://api.mollie.com/*' => Http::response('', 500), ]); - + $this->expectException(ApiException::class); - + // This should throw an ApiException $client->payments->get('non_existing_payment'); } - + public function test_connection_error_handling() { /** @var MollieApiClient $client */ $client = app(MollieApiClient::class); - + // Simulate a connection error Http::fake([ - 'https://api.mollie.com/*' => function() { + 'https://api.mollie.com/*' => function () { throw new \Exception('Connection error'); }, ]); - + $this->expectException(ApiException::class); $this->expectExceptionMessage('Connection error'); - + // This should throw an ApiException with the connection error message $client->payments->get('any_payment_id'); } diff --git a/tests/MollieServiceProviderTest.php b/tests/MollieServiceProviderTest.php index ddf3812..4bf9c2e 100644 --- a/tests/MollieServiceProviderTest.php +++ b/tests/MollieServiceProviderTest.php @@ -15,28 +15,28 @@ public function test_service_provider_installation_without_api_key() { // Clear the API key in the config config(['mollie.key' => '']); - + // Create a new instance of the service provider $provider = new MollieServiceProvider($this->app); - + // Register and boot should not throw exceptions $provider->register(); $provider->boot(); - + // Verify the service provider registered the MollieApiClient $this->assertTrue($this->app->bound(MollieApiClient::class)); - + // Resolving the client should not throw an exception $client = $this->app->make(MollieApiClient::class); $this->assertInstanceOf(MollieApiClient::class, $client); - + // Verify no API key was set (authenticator should be null) $reflection = new \ReflectionClass($client); $property = $reflection->getProperty('authenticator'); $property->setAccessible(true); $this->assertNull($property->getValue($client)); } - + /** * Test that the service provider can be registered and booted with a valid API key. */ @@ -44,17 +44,17 @@ public function test_service_provider_with_valid_api_key() { // Set a valid API key in the config config(['mollie.key' => 'test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxyz']); - + // Create a new instance of the service provider $provider = new MollieServiceProvider($this->app); - + // Register and boot should not throw exceptions $provider->register(); $provider->boot(); - + // Verify the service provider registered the MollieApiClient $this->assertTrue($this->app->bound(MollieApiClient::class)); - + // Resolving the client should not throw an exception $client = $this->app->make(MollieApiClient::class); $this->assertInstanceOf(MollieApiClient::class, $client); From 331967108d158e8832e501d7e7d463bc386a3f9d Mon Sep 17 00:00:00 2001 From: Sander van Hooft Date: Fri, 4 Jul 2025 12:41:54 +0200 Subject: [PATCH 05/27] gh test action --- .github/workflows/tests.yml | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 96fc34a..c928fe9 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -13,15 +13,11 @@ jobs: strategy: fail-fast: true matrix: - php: ['8.1', '8.2', '8.3'] - laravel: ['10.0', '11.0', '12.0'] + php: ['8.2', '8.3', '8.4'] + laravel: ['11.0', '12.0'] exclude: - - laravel: '11.0' - php: '8.0' - - laravel: '11.0' - php: '8.1' - laravel: '12.0' - php: '8.1' + php: '8.2' name: P${{ matrix.php }} - L${{ matrix.laravel }} @@ -37,6 +33,20 @@ jobs: tools: composer:v2 coverage: none + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php }}-${{ matrix.laravel }} + restore-keys: | + ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}-${{ matrix.php }}- + ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}- + ${{ runner.os }}-composer- + - name: Install dependencies run: | composer require "illuminate/contracts=^${{ matrix.laravel }}" --no-update From 15b214dfbff8034aad2a4b93f005d427cad7d739 Mon Sep 17 00:00:00 2001 From: Sander van Hooft Date: Fri, 4 Jul 2025 12:46:57 +0200 Subject: [PATCH 06/27] reintroduce pint --- composer.json | 12 +++++---- pint.json | 28 ++++++++++++++++++++ src/MollieConnectProvider.php | 8 +++--- src/MollieServiceProvider.php | 6 ++--- tests/MollieLaravelHttpClientAdapterTest.php | 2 +- 5 files changed, 43 insertions(+), 13 deletions(-) create mode 100644 pint.json diff --git a/composer.json b/composer.json index ada5617..7db97ff 100644 --- a/composer.json +++ b/composer.json @@ -47,10 +47,10 @@ "ext-json": "*" }, "require-dev": { - "mockery/mockery": "^1.6", - "orchestra/testbench": "^9.0|^10.0", - "phpunit/phpunit": "^11.0", - "laravel/socialite": "^5.8" + "graham-campbell/testbench": "^6.0", + "laravel/pint": "^1.13", + "mockery/mockery": "^1.5", + "phpunit/phpunit": "^11.0" }, "suggest": { "laravel/socialite": "Use Mollie Connect (OAuth) to authenticate via Laravel Socialite with the Mollie API. This is needed for some endpoints." @@ -77,7 +77,9 @@ } }, "scripts": { - "test": "./vendor/bin/phpunit tests" + "test": "./vendor/bin/phpunit tests", + "format": "./vendor/bin/pint", + "format:check": "./vendor/bin/pint --test" }, "minimum-stability": "dev", "prefer-stable": true diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..57cd6bf --- /dev/null +++ b/pint.json @@ -0,0 +1,28 @@ +{ + "preset": "laravel", + "rules": { + "concat_space": { + "spacing": "one" + }, + "ordered_imports": { + "sort_algorithm": "alpha" + }, + "no_unused_imports": true, + "single_quote": true, + "array_syntax": { + "syntax": "short" + }, + "class_attributes_separation": { + "elements": { + "method": "one" + } + }, + "no_extra_blank_lines": { + "tokens": [ + "extra", + "throw", + "use" + ] + } + } +} diff --git a/src/MollieConnectProvider.php b/src/MollieConnectProvider.php index bd3f9d1..15f56a5 100644 --- a/src/MollieConnectProvider.php +++ b/src/MollieConnectProvider.php @@ -47,7 +47,7 @@ class MollieConnectProvider extends AbstractProvider implements ProviderInterfac */ protected function getAuthUrl($state) { - return $this->buildAuthUrlFromBase(static::MOLLIE_WEB_URL.'/oauth2/authorize', $state); + return $this->buildAuthUrlFromBase(static::MOLLIE_WEB_URL . '/oauth2/authorize', $state); } /** @@ -57,7 +57,7 @@ protected function getAuthUrl($state) */ protected function getTokenUrl() { - return static::MOLLIE_API_URL.'/oauth2/tokens'; + return static::MOLLIE_API_URL . '/oauth2/tokens'; } /** @@ -68,8 +68,8 @@ protected function getTokenUrl() */ protected function getUserByToken($token) { - $response = $this->getHttpClient()->get(static::MOLLIE_API_URL.'/v2/organizations/me', [ - 'headers' => ['Authorization' => 'Bearer '.$token], + $response = $this->getHttpClient()->get(static::MOLLIE_API_URL . '/v2/organizations/me', [ + 'headers' => ['Authorization' => 'Bearer ' . $token], ]); return json_decode($response->getBody(), true); diff --git a/src/MollieServiceProvider.php b/src/MollieServiceProvider.php index 2e534c8..576d32b 100644 --- a/src/MollieServiceProvider.php +++ b/src/MollieServiceProvider.php @@ -24,10 +24,10 @@ class MollieServiceProvider extends ServiceProvider */ public function boot() { - $this->mergeConfigFrom(__DIR__.'/../config/mollie.php', 'mollie'); + $this->mergeConfigFrom(__DIR__ . '/../config/mollie.php', 'mollie'); if ($this->app->runningInConsole()) { - $this->publishes([__DIR__.'/../config/mollie.php' => config_path('mollie.php')]); + $this->publishes([__DIR__ . '/../config/mollie.php' => config_path('mollie.php')]); } } @@ -55,7 +55,7 @@ public function register() MollieApiClient::class, function (Container $app) { $client = (new MollieApiClient(new MollieLaravelHttpClientAdapter)) - ->addVersionString('MollieLaravel/'.self::PACKAGE_VERSION); + ->addVersionString('MollieLaravel/' . self::PACKAGE_VERSION); if (! empty($apiKey = $app['config']['mollie.key'])) { $client->setApiKey($apiKey); diff --git a/tests/MollieLaravelHttpClientAdapterTest.php b/tests/MollieLaravelHttpClientAdapterTest.php index 4a0e054..b7fcda1 100644 --- a/tests/MollieLaravelHttpClientAdapterTest.php +++ b/tests/MollieLaravelHttpClientAdapterTest.php @@ -49,7 +49,7 @@ public function test_get_request() $payment->description = 'test'; Http::fake([ - 'https://api.mollie.com/v2/payments/'.$payment->id => Http::response(json_encode($payment)), + 'https://api.mollie.com/v2/payments/' . $payment->id => Http::response(json_encode($payment)), ]); $returnedPayment = $client->payments->get($payment->id); From 3835b0c74db500baa6512ea080fab458e62a2de4 Mon Sep 17 00:00:00 2001 From: Sander van Hooft Date: Wed, 9 Jul 2025 14:18:18 +0200 Subject: [PATCH 07/27] remove berkeley reference --- composer.json | 1 + config/mollie.php | 31 ------------------------------- 2 files changed, 1 insertion(+), 31 deletions(-) diff --git a/composer.json b/composer.json index 7db97ff..e74ed8c 100644 --- a/composer.json +++ b/composer.json @@ -49,6 +49,7 @@ "require-dev": { "graham-campbell/testbench": "^6.0", "laravel/pint": "^1.13", + "laravel/socialite": "^5.21", "mockery/mockery": "^1.5", "phpunit/phpunit": "^11.0" }, diff --git a/config/mollie.php b/config/mollie.php index efbabd7..3cc10cb 100644 --- a/config/mollie.php +++ b/config/mollie.php @@ -1,36 +1,5 @@ - * @copyright Mollie B.V. - * - * @link https://www.mollie.com - */ return [ 'key' => env('MOLLIE_KEY', 'test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'), From 45093f94c5c68ba70dfbaefe7f48f80e4e854d5b Mon Sep 17 00:00:00 2001 From: Sander van Hooft Date: Wed, 9 Jul 2025 14:22:22 +0200 Subject: [PATCH 08/27] strict types enabled --- config/mollie.php | 2 ++ src/Facades/Mollie.php | 2 ++ src/MollieConnectProvider.php | 2 ++ src/MollieLaravelHttpClientAdapter.php | 2 ++ src/MollieManager.php | 2 ++ src/MollieServiceProvider.php | 2 ++ src/MollieSocialiteServiceProvider.php | 2 ++ tests/MollieApiClientTest.php | 2 ++ tests/MollieLaravelHttpClientAdapterTest.php | 2 ++ tests/MollieServiceProviderTest.php | 2 ++ tests/TestCase.php | 2 ++ 11 files changed, 22 insertions(+) diff --git a/config/mollie.php b/config/mollie.php index 3cc10cb..e8192bc 100644 --- a/config/mollie.php +++ b/config/mollie.php @@ -1,5 +1,7 @@ env('MOLLIE_KEY', 'test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'), diff --git a/src/Facades/Mollie.php b/src/Facades/Mollie.php index ed8c992..76dc81a 100644 --- a/src/Facades/Mollie.php +++ b/src/Facades/Mollie.php @@ -1,5 +1,7 @@ Date: Wed, 23 Jul 2025 16:20:31 +0200 Subject: [PATCH 09/27] fix: laravel mollie client adapter --- src/MollieLaravelHttpClientAdapter.php | 70 +++++++------------------- 1 file changed, 19 insertions(+), 51 deletions(-) diff --git a/src/MollieLaravelHttpClientAdapter.php b/src/MollieLaravelHttpClientAdapter.php index f708291..1e6b8f4 100644 --- a/src/MollieLaravelHttpClientAdapter.php +++ b/src/MollieLaravelHttpClientAdapter.php @@ -4,30 +4,19 @@ namespace Mollie\Laravel; +use Illuminate\Http\Client\ConnectionException; +use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; use Mollie\Api\Contracts\HttpAdapterContract; use Mollie\Api\Exceptions\ApiException; +use Mollie\Api\Exceptions\RetryableNetworkRequestException; use Mollie\Api\Http\PendingRequest; use Mollie\Api\Http\Response; -use Mollie\Api\Utils\Factories; -use Nyholm\Psr7\Factory\Psr17Factory; +use Mollie\Api\Traits\HasDefaultFactories; class MollieLaravelHttpClientAdapter implements HttpAdapterContract { - /** - * Get the HTTP factories used by this adapter. - */ - public function factories(): Factories - { - $psr17Factory = new Psr17Factory; - - return new Factories( - $psr17Factory, // RequestFactoryInterface - $psr17Factory, // ResponseFactoryInterface - $psr17Factory, // StreamFactoryInterface - $psr17Factory // UriFactoryInterface - ); - } + use HasDefaultFactories; /** * Get the version string for this HTTP adapter. @@ -44,46 +33,25 @@ public function version(): ?string */ public function sendRequest(PendingRequest $pendingRequest): Response { - // Get request details from PendingRequest - $method = $pendingRequest->method(); - $url = $pendingRequest->url(); - $headers = $pendingRequest->headers(); - - // Execute request handlers from middleware - $pendingRequest->executeRequestHandlers(); + $psrRequest = $pendingRequest->createPsrRequest(); - // Create PSR-7 request using factories - $psrRequest = $this->factories() - ->requestFactory - ->createRequest($method, $url); - - // Convert headers from ArrayStore to plain array - $headersArray = []; - foreach ($headers->all() as $key => $value) { - $headersArray[$key] = $value; - } - - // Configure Laravel HTTP client with headers - $httpClient = Http::withHeaders($headersArray); - - // Send request using Laravel HTTP client try { - $laravelResponse = $httpClient->send($method, $url); + $response = Http::withHeaders($pendingRequest->headers()->all()) + ->withUrlParameters($pendingRequest->query()->all()) + ->withBody($psrRequest->getBody()) + ->send( + $pendingRequest->method(), + $pendingRequest->url(), + ); - // Convert Laravel response to PSR-7 response - $psrResponse = $laravelResponse->toPsrResponse(); + $psrResponse = $response->toPsrResponse(); - // Create and return Mollie Response return new Response($psrResponse, $psrRequest, $pendingRequest); - } catch (\Exception $e) { - // Create a generic error response - $factory = $this->factories()->responseFactory; - $psrErrorResponse = $factory->createResponse(500); - - // Create a Mollie Response with the error - $errorResponse = new Response($psrErrorResponse, $psrRequest, $pendingRequest); - - throw new ApiException($errorResponse, $e->getMessage(), $e->getCode(), $e); + } catch (ConnectionException $e) { + throw new RetryableNetworkRequestException($pendingRequest, $e->getMessage(), $e); + } catch (RequestException $e) { + // RequestExceptions without response are handled by the retryable network request exception + return new Response($e->response->toPsrResponse(), $psrRequest, $pendingRequest, $e); } } } From 58e8ae4d34c0c06c25ef3f419422d2a9c9c24242 Mon Sep 17 00:00:00 2001 From: Krishan Koenig Date: Wed, 23 Jul 2025 16:30:09 +0200 Subject: [PATCH 10/27] chore: fix merge config registration --- src/MollieServiceProvider.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/MollieServiceProvider.php b/src/MollieServiceProvider.php index 0d20ff6..d844d7e 100644 --- a/src/MollieServiceProvider.php +++ b/src/MollieServiceProvider.php @@ -26,8 +26,6 @@ class MollieServiceProvider extends ServiceProvider */ public function boot() { - $this->mergeConfigFrom(__DIR__ . '/../config/mollie.php', 'mollie'); - if ($this->app->runningInConsole()) { $this->publishes([__DIR__ . '/../config/mollie.php' => config_path('mollie.php')]); } @@ -53,6 +51,10 @@ public function provides() */ public function register() { + $this->mergeConfigFrom( + __DIR__ . '/../config/mollie.php', 'mollie' + ); + $this->app->singleton( MollieApiClient::class, function (Container $app) { From 2e3270e8da60bef4eb639e7cf0450173269fe5fb Mon Sep 17 00:00:00 2001 From: Krishan Koenig Date: Wed, 23 Jul 2025 16:31:30 +0200 Subject: [PATCH 11/27] chore: remove unneded method --- src/MollieSocialiteServiceProvider.php | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/MollieSocialiteServiceProvider.php b/src/MollieSocialiteServiceProvider.php index 3bc1a54..993060c 100644 --- a/src/MollieSocialiteServiceProvider.php +++ b/src/MollieSocialiteServiceProvider.php @@ -19,16 +19,6 @@ public function boot() $this->extendSocialite(); } - /** - * Register the application services. - * - * @return void - */ - public function register() - { - // No registration needed for Socialite extension - } - /** * Extend the Laravel Socialite factory class, if available. * From 6f77a1e617a9fd9f1f5fe6e3083e0fee3c90468b Mon Sep 17 00:00:00 2001 From: Krishan Koenig Date: Wed, 23 Jul 2025 16:46:32 +0200 Subject: [PATCH 12/27] test: improved tests slightly --- tests/MollieApiClientTest.php | 17 ++++------------- tests/MollieLaravelHttpClientAdapterTest.php | 6 ++++-- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/tests/MollieApiClientTest.php b/tests/MollieApiClientTest.php index 9c5a85d..742f57f 100644 --- a/tests/MollieApiClientTest.php +++ b/tests/MollieApiClientTest.php @@ -4,6 +4,7 @@ namespace Mollie\Laravel\Tests; +use Mollie\Api\Contracts\Authenticator; use Mollie\Api\Http\Auth\ApiKeyAuthenticator; use Mollie\Api\MollieApiClient; use Mollie\Laravel\MollieLaravelHttpClientAdapter; @@ -15,7 +16,7 @@ public function test_injected_http_adapter_is_laravel_http_client_adapter() { $this->assertInstanceOf( MollieLaravelHttpClientAdapter::class, - $this->getUnaccessiblePropertyValue('httpClient') + resolve(MollieApiClient::class)->getHttpClient() ); } @@ -24,6 +25,7 @@ public function test_api_key_is_set_on_resolving_api_client() config(['mollie.key' => 'test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxyz']); $client = resolve(MollieApiClient::class); + /** @var ApiKeyAuthenticator $authenticator */ $authenticator = $client->getAuthenticator(); $this->assertInstanceOf(ApiKeyAuthenticator::class, $authenticator); @@ -43,18 +45,7 @@ public function test_does_not_set_api_key_if_key_is_empty() $this->assertNull($authenticator); } - private function getUnaccessiblePropertyValue(string $propertyName): mixed - { - $resolvedInstance = resolve(MollieApiClient::class); - - $reflection = new ReflectionClass($resolvedInstance); - $property = $reflection->getProperty($propertyName); - $property->setAccessible(true); - - return $property->getValue($resolvedInstance); - } - - private function getApiKeyFromAuthenticator(object $authenticator): ?string + private function getApiKeyFromAuthenticator(Authenticator $authenticator): ?string { $reflection = new ReflectionClass($authenticator); $property = $reflection->getProperty('token'); diff --git a/tests/MollieLaravelHttpClientAdapterTest.php b/tests/MollieLaravelHttpClientAdapterTest.php index 628e79f..c72b856 100644 --- a/tests/MollieLaravelHttpClientAdapterTest.php +++ b/tests/MollieLaravelHttpClientAdapterTest.php @@ -4,8 +4,10 @@ namespace Mollie\Laravel\Tests; +use Illuminate\Http\Client\ConnectionException; use Illuminate\Support\Facades\Http; use Mollie\Api\Exceptions\ApiException; +use Mollie\Api\Exceptions\RetryableNetworkRequestException; use Mollie\Api\MollieApiClient; use Mollie\Api\Resources\Payment; @@ -85,11 +87,11 @@ public function test_connection_error_handling() // Simulate a connection error Http::fake([ 'https://api.mollie.com/*' => function () { - throw new \Exception('Connection error'); + throw new ConnectionException('Connection error'); }, ]); - $this->expectException(ApiException::class); + $this->expectException(RetryableNetworkRequestException::class); $this->expectExceptionMessage('Connection error'); // This should throw an ApiException with the connection error message From fa1e95f681742e521b0b394c180a8fd71a347a6b Mon Sep 17 00:00:00 2001 From: Krishan Koenig Date: Wed, 23 Jul 2025 16:52:37 +0200 Subject: [PATCH 13/27] chore: add github worklfows and release script --- .github/workflows/changelog.yml | 28 ++++++ .github/workflows/phpstan.yml | 28 ++++++ bin/release | 158 ++++++++++++++++++++++++++++++++ 3 files changed, 214 insertions(+) create mode 100644 .github/workflows/changelog.yml create mode 100644 .github/workflows/phpstan.yml create mode 100644 bin/release diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000..41f9857 --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,28 @@ +name: "Update Changelog" + +on: + release: + types: [released] + +jobs: + update: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: master + + - name: Update Changelog + uses: stefanzweifel/changelog-updater-action@v1 + with: + latest-version: ${{ github.event.release.name }} + release-notes: ${{ github.event.release.body }} + + - name: Commit updated CHANGELOG + uses: stefanzweifel/git-auto-commit-action@v5 + with: + branch: master + commit_message: Update CHANGELOG + file_pattern: CHANGELOG.md diff --git a/.github/workflows/phpstan.yml b/.github/workflows/phpstan.yml new file mode 100644 index 0000000..3fb6f87 --- /dev/null +++ b/.github/workflows/phpstan.yml @@ -0,0 +1,28 @@ +name: PHPStan + +on: + push: + paths: + - "**.php" + - "phpstan.neon.dist" + - ".github/workflows/phpstan.yml" + +jobs: + phpstan: + name: phpstan + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + coverage: none + + - name: Install composer dependencies + uses: ramsey/composer-install@v3 + + - name: Run PHPStan + run: ./vendor/bin/phpstan --error-format=github diff --git a/bin/release b/bin/release new file mode 100644 index 0000000..381c158 --- /dev/null +++ b/bin/release @@ -0,0 +1,158 @@ +#!/bin/bash + +VERSION=$1 + +# Check if version is provided +if [ -z "$VERSION" ]; then + echo "❌ Error: Version number is required" + echo "Usage: ./bin/release " + echo "Example: ./bin/release 1.0.0" + exit 1 +fi + +# Check if gh CLI is installed +if ! command -v gh &> /dev/null; then + echo "❌ Error: GitHub CLI (gh) is not installed" + echo "Please install it from: https://cli.github.com/" + exit 1 +fi + +# Check if we're in a git repository +if ! git rev-parse --git-dir > /dev/null 2>&1; then + echo "❌ Error: Not in a git repository" + exit 1 +fi + +# Check if there are uncommitted changes +if ! git diff-index --quiet HEAD --; then + echo "❌ Error: There are uncommitted changes. Please commit or stash them first." + exit 1 +fi + +# Get default branch from GitHub with better error handling +echo "🔍 Getting default branch from GitHub..." +DEFAULT_BRANCH_JSON=$(gh repo view --json defaultBranchRef 2>/dev/null) +if [ $? -ne 0 ]; then + echo "❌ Error: Failed to get repository information from GitHub" + echo " Please check your GitHub CLI authentication and repository access" + exit 1 +fi + +DEFAULT_BRANCH=$(echo "$DEFAULT_BRANCH_JSON" | grep -o '"name":"[^"]*"' | cut -d'"' -f4) +if [ -z "$DEFAULT_BRANCH" ]; then + echo "⚠️ Warning: Could not determine default branch, using 'main'" + DEFAULT_BRANCH="main" +fi + +echo "📌 Local HEAD: $(git rev-parse HEAD)" +echo "📌 GitHub default branch: $DEFAULT_BRANCH" + +# Fetch latest changes from remote +echo "🔄 Fetching latest changes from remote..." +git fetch origin + +# Check if local branch is up-to-date with remote +LOCAL_HEAD=$(git rev-parse HEAD) +REMOTE_HEAD=$(git rev-parse origin/$DEFAULT_BRANCH 2>/dev/null) + +if [ -z "$REMOTE_HEAD" ]; then + echo "❌ Error: Could not find remote branch origin/$DEFAULT_BRANCH" + exit 1 +fi + +if [ "$LOCAL_HEAD" != "$REMOTE_HEAD" ]; then + echo "❌ Error: Local branch is not up-to-date with remote" + echo " Local HEAD: $LOCAL_HEAD" + echo " Remote HEAD: $REMOTE_HEAD" + echo " Please pull the latest changes: git pull origin $DEFAULT_BRANCH" + exit 1 +fi + +echo "✅ Local repository is up-to-date with remote" + +# Define the path to MollieServiceProvider.php +MOLLIE_CLIENT_FILE="src/MollieServiceProvider.php" + +# Check if MollieServiceProvider.php exists +if [ ! -f "$MOLLIE_CLIENT_FILE" ]; then + echo "❌ Error: MollieServiceProvider.php not found at $MOLLIE_CLIENT_FILE" + exit 1 +fi + +# Check if tag already exists +if git tag -l | grep -q "^v$VERSION$"; then + echo "❌ Error: Tag v$VERSION already exists" + exit 1 +fi + +echo "🔄 Updating CLIENT_VERSION to $VERSION in MollieServiceProvider.php..." + +# Update the CLIENT_VERSION constant in MollieServiceProvider.php +if sed -i.bak "s/const PACKAGE_VERSION = '[^']*'/const PACKAGE_VERSION = '$VERSION'/g" "$MOLLIE_CLIENT_FILE"; then + echo "✅ CLIENT_VERSION updated successfully" + # Remove backup file created by sed + rm "${MOLLIE_CLIENT_FILE}.bak" +else + echo "❌ Error: Failed to update CLIENT_VERSION" + exit 1 +fi + +# Check if the version was actually updated +if grep -q "CLIENT_VERSION = '$VERSION'" "$MOLLIE_CLIENT_FILE"; then + echo "✅ Version update verified" +else + echo "❌ Error: Version update verification failed" + exit 1 +fi + +# Commit the version update +echo "📝 Committing version update..." +git add "$MOLLIE_CLIENT_FILE" +git commit -m "Update CLIENT_VERSION to $VERSION" + +echo "🏷️ Creating tag v$VERSION..." + +# Create tag and push +git tag -a "v$VERSION" -m "Release $VERSION" +git push origin "v$VERSION" + +echo "⏱️ Waiting for GitHub to recognize the new tag..." +sleep 3 + +# Verify tag exists on GitHub +echo "🔍 Verifying tag exists on GitHub..." +TAG_CHECK=$(gh release list --repo mollie/laravel-mollie 2>/dev/null | grep "v$VERSION" || echo "") +if [ -n "$TAG_CHECK" ]; then + echo "❌ Error: Release v$VERSION already exists on GitHub" + exit 1 +fi + +# Double-check that we can see the tag +GH_TAG_CHECK=$(gh api repos/mollie/laravel-mollie/git/refs/tags/v$VERSION 2>/dev/null || echo "not_found") +if [ "$GH_TAG_CHECK" = "not_found" ]; then + echo "⚠️ Warning: Tag not immediately visible on GitHub, waiting longer..." + sleep 5 +fi + +echo "🚀 Creating GitHub release..." + +# Generate release from current HEAD with better error handling +RELEASE_OUTPUT=$(gh release create "v$VERSION" \ + --target "$(git rev-parse HEAD)" \ + --latest \ + --generate-notes 2>&1) + +if [ $? -eq 0 ]; then + echo "✅ Release v$VERSION created successfully!" + echo "$RELEASE_OUTPUT" +else + echo "❌ Error: Failed to create GitHub release" + echo "$RELEASE_OUTPUT" + echo "" + echo "🔍 Troubleshooting tips:" + echo " 1. Check if you have write permissions to the repository" + echo " 2. Verify your GitHub CLI authentication: gh auth status" + echo " 3. Try creating the release manually on GitHub.com" + echo " 4. Check if the tag was created successfully: git tag -l | grep v$VERSION" + exit 1 +fi From bf1d5c5d4464a580d22cbbf719f9c7100ce9f6f2 Mon Sep 17 00:00:00 2001 From: Krishan Koenig Date: Wed, 23 Jul 2025 17:23:57 +0200 Subject: [PATCH 14/27] feat: add webhook signature validating middleware --- config/mollie.php | 14 ++++++ .../ValidatesWebhookSignatures.php | 47 +++++++++++++++++++ src/MollieServiceProvider.php | 4 ++ 3 files changed, 65 insertions(+) create mode 100644 src/Middlewares/ValidatesWebhookSignatures.php diff --git a/config/mollie.php b/config/mollie.php index e8192bc..d227132 100644 --- a/config/mollie.php +++ b/config/mollie.php @@ -13,4 +13,18 @@ // 'redirect' => env('MOLLIE_REDIRECT_URI'), // ], + 'webhooks' => [ + 'enabled' => env('MOLLIE_WEBHOOKS_ENABLED', false), + + /** + * A comma separated list of signing secrets. + */ + 'signing_secrets' => env('MOLLIE_WEBHOOK_SIGNING_SECRETS'), + + /** + * If true, legacy webhooks without a signature will be accepted. + */ + 'legacy_webhook_enabled' => env('MOLLIE_LEGACY_WEBHOOK_ENABLED', false), + ], + ]; diff --git a/src/Middlewares/ValidatesWebhookSignatures.php b/src/Middlewares/ValidatesWebhookSignatures.php new file mode 100644 index 0000000..6036549 --- /dev/null +++ b/src/Middlewares/ValidatesWebhookSignatures.php @@ -0,0 +1,47 @@ +getContent(); + $signature = $request->header('X-Mollie-Signature'); + + $isLegacyWebhook = $validator->validatePayload( + $body, + $signature + ); + + if ($isLegacyWebhook && !config('mollie.webhooks.legacy_webhook_enabled')) { + throw new \Exception('Legacy webhook feature is disabled'); + } + + return $next($request); + + } catch (InvalidSignatureException $e) { + $response = response()->json([ + 'message' => 'Invalid webhook signature', + ], 400); + + throw new HttpResponseException($response, $e); + } + } +} diff --git a/src/MollieServiceProvider.php b/src/MollieServiceProvider.php index d844d7e..6e3cf1b 100644 --- a/src/MollieServiceProvider.php +++ b/src/MollieServiceProvider.php @@ -26,6 +26,10 @@ class MollieServiceProvider extends ServiceProvider */ public function boot() { + if (config('mollie.webhooks.enabled') && ! config('mollie.webhooks.signing_secrets')) { + throw new \Exception('Webhooks are enabled but no signing secrets are set'); + } + if ($this->app->runningInConsole()) { $this->publishes([__DIR__ . '/../config/mollie.php' => config_path('mollie.php')]); } From 3e02cf2fc6d4a080d6c1ddd91eb9e54e25c5c8bd Mon Sep 17 00:00:00 2001 From: Krishan Koenig Date: Wed, 23 Jul 2025 17:36:26 +0200 Subject: [PATCH 15/27] feat: add validate webhook signature tests [wip] --- .../ValidatesWebhookSignatures.php | 10 +- src/MollieServiceProvider.php | 7 + .../ValidatesWebhookSignaturesTest.php | 288 ++++++++++++++++++ 3 files changed, 301 insertions(+), 4 deletions(-) rename src/{Middlewares => Middleware}/ValidatesWebhookSignatures.php (85%) create mode 100644 src/Tests/Middleware/ValidatesWebhookSignaturesTest.php diff --git a/src/Middlewares/ValidatesWebhookSignatures.php b/src/Middleware/ValidatesWebhookSignatures.php similarity index 85% rename from src/Middlewares/ValidatesWebhookSignatures.php rename to src/Middleware/ValidatesWebhookSignatures.php index 6036549..691baf8 100644 --- a/src/Middlewares/ValidatesWebhookSignatures.php +++ b/src/Middleware/ValidatesWebhookSignatures.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Mollie\Laravel\Middlewares; +namespace Mollie\Laravel\Middleware; use Illuminate\Http\Request; use Closure; @@ -12,6 +12,10 @@ class ValidatesWebhookSignatures { + public function __construct( + private SignatureValidator $validator + ) {} + public function handle(Request $request, Closure $next) { if (! config('mollie.webhooks.enabled')) { @@ -19,13 +23,11 @@ public function handle(Request $request, Closure $next) } try { - $validator = new SignatureValidator(config('mollie.webhooks.signing_secrets')); - /** @var string $body */ $body = $request->getContent(); $signature = $request->header('X-Mollie-Signature'); - $isLegacyWebhook = $validator->validatePayload( + $isLegacyWebhook = $this->validator->validatePayload( $body, $signature ); diff --git a/src/MollieServiceProvider.php b/src/MollieServiceProvider.php index 6e3cf1b..102247b 100644 --- a/src/MollieServiceProvider.php +++ b/src/MollieServiceProvider.php @@ -7,6 +7,7 @@ use Illuminate\Contracts\Container\Container; use Illuminate\Support\ServiceProvider; use Mollie\Api\MollieApiClient; +use Mollie\Api\Webhooks\SignatureValidator; class MollieServiceProvider extends ServiceProvider { @@ -74,5 +75,11 @@ function (Container $app) { ); $this->app->singleton(MollieManager::class); + + if (config('mollie.webhooks.enabled')) { + $this->app->singleton(SignatureValidator::class, function (Container $app) { + return new SignatureValidator(config('mollie.webhooks.signing_secrets')); + }); + } } } diff --git a/src/Tests/Middleware/ValidatesWebhookSignaturesTest.php b/src/Tests/Middleware/ValidatesWebhookSignaturesTest.php new file mode 100644 index 0000000..455a674 --- /dev/null +++ b/src/Tests/Middleware/ValidatesWebhookSignaturesTest.php @@ -0,0 +1,288 @@ +middleware = new ValidatesWebhookSignatures( + $this->app->make(SignatureValidator::class) + ); + $this->next = function ($request) { + return response('OK'); + }; + } + + public function test_bypasses_validation_when_webhooks_are_disabled() + { + config(['mollie.webhooks.enabled' => false]); + $request = Request::create('/webhook', 'POST'); + + // Act + $response = $this->middleware->handle($request, $this->next); + + // Assert + $this->assertEquals('OK', $response->getContent()); + } + + public function test_validates_signature_when_webhooks_are_enabled() + { + // Arrange + config([ + 'mollie.webhooks.enabled' => true, + 'mollie.webhooks.signing_secrets' => 'secret1,secret2', + 'mollie.webhooks.legacy_webhook_enabled' => true + ]); + + $request = $this->createRequestWithSignature('{"id":"payment_123"}', 'valid_signature'); + + $validatorMock = Mockery::mock('overload:' . SignatureValidator::class); + $validatorMock->shouldReceive('__construct') + ->once() + ->with('secret1,secret2'); + $validatorMock->shouldReceive('validatePayload') + ->once() + ->with('{"id":"payment_123"}', 'valid_signature') + ->andReturn(false); // Not a legacy webhook + + // Act + $response = $this->middleware->handle($request, $this->next); + + // Assert + $this->assertEquals('OK', $response->getContent()); + } + + public function test_throws_http_response_exception_on_invalid_signature() + { + // Arrange + config([ + 'mollie.webhooks.enabled' => true, + 'mollie.webhooks.signing_secrets' => 'secret1,secret2' + ]); + + $request = $this->createRequestWithSignature('{"id":"payment_123"}', 'invalid_signature'); + + $validatorMock = Mockery::mock('overload:' . SignatureValidator::class); + $validatorMock->shouldReceive('__construct') + ->once() + ->with('secret1,secret2'); + $validatorMock->shouldReceive('validatePayload') + ->once() + ->with('{"id":"payment_123"}', 'invalid_signature') + ->andThrow(new InvalidSignatureException('Invalid signature')); + + // Act & Assert + $this->expectException(HttpResponseException::class); + + try { + $this->middleware->handle($request, $this->next); + } catch (HttpResponseException $e) { + $this->assertEquals(400, $e->getResponse()->getStatusCode()); + $this->assertEquals( + '{"message":"Invalid webhook signature"}', + $e->getResponse()->getContent() + ); + throw $e; + } + } + + public function test_allows_legacy_webhook_when_legacy_is_enabled() + { + // Arrange + config([ + 'mollie.webhooks.enabled' => true, + 'mollie.webhooks.signing_secrets' => 'secret1,secret2', + 'mollie.webhooks.legacy_webhook_enabled' => true + ]); + + $request = $this->createRequestWithSignature('{"id":"payment_123"}', 'legacy_signature'); + + $validatorMock = Mockery::mock('overload:' . SignatureValidator::class); + $validatorMock->shouldReceive('__construct') + ->once() + ->with('secret1,secret2'); + $validatorMock->shouldReceive('validatePayload') + ->once() + ->with('{"id":"payment_123"}', 'legacy_signature') + ->andReturn(true); // Is a legacy webhook + + // Act + $response = $this->middleware->handle($request, $this->next); + + // Assert + $this->assertEquals('OK', $response->getContent()); + } + + public function test_throws_exception_for_legacy_webhook_when_legacy_is_disabled() + { + // Arrange + config([ + 'mollie.webhooks.enabled' => true, + 'mollie.webhooks.signing_secrets' => 'secret1,secret2', + 'mollie.webhooks.legacy_webhook_enabled' => false + ]); + + $request = $this->createRequestWithSignature('{"id":"payment_123"}', 'legacy_signature'); + + $validatorMock = Mockery::mock('overload:' . SignatureValidator::class); + $validatorMock->shouldReceive('__construct') + ->once() + ->with('secret1,secret2'); + $validatorMock->shouldReceive('validatePayload') + ->once() + ->with('{"id":"payment_123"}', 'legacy_signature') + ->andReturn(true); // Is a legacy webhook + + // Act & Assert + $this->expectException(Exception::class); + $this->expectExceptionMessage('Legacy webhook feature is disabled'); + + $this->middleware->handle($request, $this->next); + } + + public function test_handles_request_without_signature_header() + { + // Arrange + config([ + 'mollie.webhooks.enabled' => true, + 'mollie.webhooks.signing_secrets' => 'secret1,secret2' + ]); + + $request = $this->createRequestWithSignature('{"id":"payment_123"}', null); + + $validatorMock = Mockery::mock('overload:' . SignatureValidator::class); + $validatorMock->shouldReceive('__construct') + ->once() + ->with('secret1,secret2'); + $validatorMock->shouldReceive('validatePayload') + ->once() + ->with('{"id":"payment_123"}', null) + ->andThrow(new InvalidSignatureException('No signature provided')); + + // Act & Assert + $this->expectException(HttpResponseException::class); + + try { + $this->middleware->handle($request, $this->next); + } catch (HttpResponseException $e) { + $this->assertEquals(400, $e->getResponse()->getStatusCode()); + $this->assertEquals( + '{"message":"Invalid webhook signature"}', + $e->getResponse()->getContent() + ); + throw $e; + } + } + + public function test_handles_empty_request_body() + { + // Arrange + config([ + 'mollie.webhooks.enabled' => true, + 'mollie.webhooks.signing_secrets' => 'secret1,secret2' + ]); + + $request = $this->createRequestWithSignature('', 'some_signature'); + + $validatorMock = Mockery::mock('overload:' . SignatureValidator::class); + $validatorMock->shouldReceive('__construct') + ->once() + ->with('secret1,secret2'); + $validatorMock->shouldReceive('validatePayload') + ->once() + ->with('', 'some_signature') + ->andReturn(false); + + // Act + $response = $this->middleware->handle($request, $this->next); + + // Assert + $this->assertEquals('OK', $response->getContent()); + } + + public function test_uses_configured_signing_secrets() + { + // Arrange + $customSecrets = 'custom_secret_1,custom_secret_2,custom_secret_3'; + config([ + 'mollie.webhooks.enabled' => true, + 'mollie.webhooks.signing_secrets' => $customSecrets + ]); + + $request = $this->createRequestWithSignature('{"id":"payment_123"}', 'valid_signature'); + + $validatorMock = Mockery::mock('overload:' . SignatureValidator::class); + $validatorMock->shouldReceive('__construct') + ->once() + ->with($customSecrets); + $validatorMock->shouldReceive('validatePayload') + ->once() + ->andReturn(false); + + // Act + $response = $this->middleware->handle($request, $this->next); + + // Assert + $this->assertEquals('OK', $response->getContent()); + } + + public function test_passes_request_to_next_middleware_on_success() + { + // Arrange + config([ + 'mollie.webhooks.enabled' => true, + 'mollie.webhooks.signing_secrets' => 'secret1' + ]); + + $request = $this->createRequestWithSignature('{"id":"payment_123"}', 'valid_signature'); + + $validatorMock = Mockery::mock('overload:' . SignatureValidator::class); + $validatorMock->shouldReceive('__construct')->once(); + $validatorMock->shouldReceive('validatePayload')->once()->andReturn(false); + + $nextCalled = false; + $next = function ($request) use (&$nextCalled) { + $nextCalled = true; + return response('Next middleware called'); + }; + + // Act + $response = $this->middleware->handle($request, $next); + + // Assert + $this->assertTrue($nextCalled); + $this->assertEquals('Next middleware called', $response->getContent()); + } + + /** + * Create a request with the specified body and signature header. + */ + private function createRequestWithSignature(string $body, ?string $signature): Request + { + $request = Request::create('/webhook', 'POST', [], [], [], [], $body); + + if ($signature !== null) { + $request->headers->set('X-Mollie-Signature', $signature); + } + + return $request; + } +} From cc1bcad171a1ee2a9ceefbbe147eb70c1982a986 Mon Sep 17 00:00:00 2001 From: Naoray Date: Wed, 23 Jul 2025 15:37:31 +0000 Subject: [PATCH 16/27] Fix styling --- src/Middleware/ValidatesWebhookSignatures.php | 4 ++-- .../ValidatesWebhookSignaturesTest.php | 17 +++++++++-------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/Middleware/ValidatesWebhookSignatures.php b/src/Middleware/ValidatesWebhookSignatures.php index 691baf8..02cec65 100644 --- a/src/Middleware/ValidatesWebhookSignatures.php +++ b/src/Middleware/ValidatesWebhookSignatures.php @@ -4,9 +4,9 @@ namespace Mollie\Laravel\Middleware; -use Illuminate\Http\Request; use Closure; use Illuminate\Http\Exceptions\HttpResponseException; +use Illuminate\Http\Request; use Mollie\Api\Exceptions\InvalidSignatureException; use Mollie\Api\Webhooks\SignatureValidator; @@ -32,7 +32,7 @@ public function handle(Request $request, Closure $next) $signature ); - if ($isLegacyWebhook && !config('mollie.webhooks.legacy_webhook_enabled')) { + if ($isLegacyWebhook && ! config('mollie.webhooks.legacy_webhook_enabled')) { throw new \Exception('Legacy webhook feature is disabled'); } diff --git a/src/Tests/Middleware/ValidatesWebhookSignaturesTest.php b/src/Tests/Middleware/ValidatesWebhookSignaturesTest.php index 455a674..c259a78 100644 --- a/src/Tests/Middleware/ValidatesWebhookSignaturesTest.php +++ b/src/Tests/Middleware/ValidatesWebhookSignaturesTest.php @@ -49,7 +49,7 @@ public function test_validates_signature_when_webhooks_are_enabled() config([ 'mollie.webhooks.enabled' => true, 'mollie.webhooks.signing_secrets' => 'secret1,secret2', - 'mollie.webhooks.legacy_webhook_enabled' => true + 'mollie.webhooks.legacy_webhook_enabled' => true, ]); $request = $this->createRequestWithSignature('{"id":"payment_123"}', 'valid_signature'); @@ -75,7 +75,7 @@ public function test_throws_http_response_exception_on_invalid_signature() // Arrange config([ 'mollie.webhooks.enabled' => true, - 'mollie.webhooks.signing_secrets' => 'secret1,secret2' + 'mollie.webhooks.signing_secrets' => 'secret1,secret2', ]); $request = $this->createRequestWithSignature('{"id":"payment_123"}', 'invalid_signature'); @@ -110,7 +110,7 @@ public function test_allows_legacy_webhook_when_legacy_is_enabled() config([ 'mollie.webhooks.enabled' => true, 'mollie.webhooks.signing_secrets' => 'secret1,secret2', - 'mollie.webhooks.legacy_webhook_enabled' => true + 'mollie.webhooks.legacy_webhook_enabled' => true, ]); $request = $this->createRequestWithSignature('{"id":"payment_123"}', 'legacy_signature'); @@ -137,7 +137,7 @@ public function test_throws_exception_for_legacy_webhook_when_legacy_is_disabled config([ 'mollie.webhooks.enabled' => true, 'mollie.webhooks.signing_secrets' => 'secret1,secret2', - 'mollie.webhooks.legacy_webhook_enabled' => false + 'mollie.webhooks.legacy_webhook_enabled' => false, ]); $request = $this->createRequestWithSignature('{"id":"payment_123"}', 'legacy_signature'); @@ -163,7 +163,7 @@ public function test_handles_request_without_signature_header() // Arrange config([ 'mollie.webhooks.enabled' => true, - 'mollie.webhooks.signing_secrets' => 'secret1,secret2' + 'mollie.webhooks.signing_secrets' => 'secret1,secret2', ]); $request = $this->createRequestWithSignature('{"id":"payment_123"}', null); @@ -197,7 +197,7 @@ public function test_handles_empty_request_body() // Arrange config([ 'mollie.webhooks.enabled' => true, - 'mollie.webhooks.signing_secrets' => 'secret1,secret2' + 'mollie.webhooks.signing_secrets' => 'secret1,secret2', ]); $request = $this->createRequestWithSignature('', 'some_signature'); @@ -224,7 +224,7 @@ public function test_uses_configured_signing_secrets() $customSecrets = 'custom_secret_1,custom_secret_2,custom_secret_3'; config([ 'mollie.webhooks.enabled' => true, - 'mollie.webhooks.signing_secrets' => $customSecrets + 'mollie.webhooks.signing_secrets' => $customSecrets, ]); $request = $this->createRequestWithSignature('{"id":"payment_123"}', 'valid_signature'); @@ -249,7 +249,7 @@ public function test_passes_request_to_next_middleware_on_success() // Arrange config([ 'mollie.webhooks.enabled' => true, - 'mollie.webhooks.signing_secrets' => 'secret1' + 'mollie.webhooks.signing_secrets' => 'secret1', ]); $request = $this->createRequestWithSignature('{"id":"payment_123"}', 'valid_signature'); @@ -261,6 +261,7 @@ public function test_passes_request_to_next_middleware_on_success() $nextCalled = false; $next = function ($request) use (&$nextCalled) { $nextCalled = true; + return response('Next middleware called'); }; From acce5ba6c877c805a92b764f315125334a52c540 Mon Sep 17 00:00:00 2001 From: Krishan Koenig Date: Fri, 25 Jul 2025 09:47:47 +0200 Subject: [PATCH 17/27] chore: refactor adapter --- src/MollieLaravelHttpClientAdapter.php | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/src/MollieLaravelHttpClientAdapter.php b/src/MollieLaravelHttpClientAdapter.php index 1e6b8f4..2dba045 100644 --- a/src/MollieLaravelHttpClientAdapter.php +++ b/src/MollieLaravelHttpClientAdapter.php @@ -7,6 +7,7 @@ use Illuminate\Http\Client\ConnectionException; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; +use Mollie\Api\Contracts\HasPayload; use Mollie\Api\Contracts\HttpAdapterContract; use Mollie\Api\Exceptions\ApiException; use Mollie\Api\Exceptions\RetryableNetworkRequestException; @@ -36,15 +37,19 @@ public function sendRequest(PendingRequest $pendingRequest): Response $psrRequest = $pendingRequest->createPsrRequest(); try { - $response = Http::withHeaders($pendingRequest->headers()->all()) - ->withUrlParameters($pendingRequest->query()->all()) - ->withBody($psrRequest->getBody()) - ->send( - $pendingRequest->method(), - $pendingRequest->url(), - ); - - $psrResponse = $response->toPsrResponse(); + $client = Http::withHeaders($pendingRequest->headers()->all()); + + if ($pendingRequest->query()->isNotEmpty()) { + $client = $client->withUrlParameters($pendingRequest->query()->all()); + } + + if ($pendingRequest->getRequest() instanceof HasPayload) { + $client = $client->withBody($psrRequest->getBody()); + } + + $psrResponse = $client + ->send($pendingRequest->method(), $pendingRequest->url()) + ->toPsrResponse(); return new Response($psrResponse, $psrRequest, $pendingRequest); } catch (ConnectionException $e) { From 182742138d1cac4b2a472b48251df45aedc0a93c Mon Sep 17 00:00:00 2001 From: Krishan Koenig Date: Fri, 25 Jul 2025 11:46:32 +0200 Subject: [PATCH 18/27] chore: merge in changes --- composer.json | 4 +- src/Middleware/ValidatesWebhookSignatures.php | 28 +- src/MollieServiceProvider.php | 18 +- src/SignatureValidator.php | 50 +++ .../ValidatesWebhookSignaturesTest.php | 289 ------------------ .../ValidatesWebhookSignaturesTest.php | 27 ++ tests/SignatureValidatorTest.php | 94 ++++++ 7 files changed, 185 insertions(+), 325 deletions(-) create mode 100644 src/SignatureValidator.php delete mode 100644 src/Tests/Middleware/ValidatesWebhookSignaturesTest.php create mode 100644 tests/Middleware/ValidatesWebhookSignaturesTest.php create mode 100644 tests/SignatureValidatorTest.php diff --git a/composer.json b/composer.json index e74ed8c..8e7f25a 100644 --- a/composer.json +++ b/composer.json @@ -42,9 +42,9 @@ ], "require": { "php": "^8.2", - "mollie/mollie-api-php": "^3.0", "illuminate/support": "^11.0|^12.0", - "ext-json": "*" + "ext-json": "*", + "mollie/mollie-api-php": "^3.3" }, "require-dev": { "graham-campbell/testbench": "^6.0", diff --git a/src/Middleware/ValidatesWebhookSignatures.php b/src/Middleware/ValidatesWebhookSignatures.php index 02cec65..b69e42f 100644 --- a/src/Middleware/ValidatesWebhookSignatures.php +++ b/src/Middleware/ValidatesWebhookSignatures.php @@ -5,10 +5,8 @@ namespace Mollie\Laravel\Middleware; use Closure; -use Illuminate\Http\Exceptions\HttpResponseException; use Illuminate\Http\Request; -use Mollie\Api\Exceptions\InvalidSignatureException; -use Mollie\Api\Webhooks\SignatureValidator; +use Mollie\Laravel\SignatureValidator; class ValidatesWebhookSignatures { @@ -22,28 +20,8 @@ public function handle(Request $request, Closure $next) return $next($request); } - try { - /** @var string $body */ - $body = $request->getContent(); - $signature = $request->header('X-Mollie-Signature'); + $this->validator->validate($request); - $isLegacyWebhook = $this->validator->validatePayload( - $body, - $signature - ); - - if ($isLegacyWebhook && ! config('mollie.webhooks.legacy_webhook_enabled')) { - throw new \Exception('Legacy webhook feature is disabled'); - } - - return $next($request); - - } catch (InvalidSignatureException $e) { - $response = response()->json([ - 'message' => 'Invalid webhook signature', - ], 400); - - throw new HttpResponseException($response, $e); - } + return $next($request); } } diff --git a/src/MollieServiceProvider.php b/src/MollieServiceProvider.php index 102247b..e81bb03 100644 --- a/src/MollieServiceProvider.php +++ b/src/MollieServiceProvider.php @@ -8,6 +8,7 @@ use Illuminate\Support\ServiceProvider; use Mollie\Api\MollieApiClient; use Mollie\Api\Webhooks\SignatureValidator; +use RuntimeException; class MollieServiceProvider extends ServiceProvider { @@ -27,10 +28,6 @@ class MollieServiceProvider extends ServiceProvider */ public function boot() { - if (config('mollie.webhooks.enabled') && ! config('mollie.webhooks.signing_secrets')) { - throw new \Exception('Webhooks are enabled but no signing secrets are set'); - } - if ($this->app->runningInConsole()) { $this->publishes([__DIR__ . '/../config/mollie.php' => config_path('mollie.php')]); } @@ -76,10 +73,13 @@ function (Container $app) { $this->app->singleton(MollieManager::class); - if (config('mollie.webhooks.enabled')) { - $this->app->singleton(SignatureValidator::class, function (Container $app) { - return new SignatureValidator(config('mollie.webhooks.signing_secrets')); - }); - } + $this->app->bind(SignatureValidator::class, function (Container $app) { + throw_if( + config('mollie.webhooks.enabled') && ! config('mollie.webhooks.signing_secrets'), + new RuntimeException('Webhooks are enabled but no signing secrets are set') + ); + + return new SignatureValidator(config('mollie.webhooks.signing_secrets')); + }); } } diff --git a/src/SignatureValidator.php b/src/SignatureValidator.php new file mode 100644 index 0000000..1ef7530 --- /dev/null +++ b/src/SignatureValidator.php @@ -0,0 +1,50 @@ +validator = $validator; + } + + public function validate(Request $request): void + { + $body = (string) $request->getContent(); + $signatures = $request->header(BaseSignatureValidator::SIGNATURE_HEADER, ''); + + try { + $isLegacyWebhook = !$this->validator->validatePayload( + $body, + $signatures + ); + } catch (InvalidSignatureException $e) { + $this->marshalInvalidSignatureException($e); + } + + $this->abortIfLegacyWebhookIsDisabled($isLegacyWebhook); + } + + /** + * Handle the given invalid signature exception. + * + * @param InvalidSignatureException $e + * @return void + */ + private function marshalInvalidSignatureException(InvalidSignatureException $e): void + { + abort(401, $e->getMessage()); + } + + private function abortIfLegacyWebhookIsDisabled(bool $isLegacyWebhook): void + { + abort_if($isLegacyWebhook && !config('mollie.webhooks.legacy_webhook_enabled'), 403, 'Legacy webhook feature is disabled'); + } +} diff --git a/src/Tests/Middleware/ValidatesWebhookSignaturesTest.php b/src/Tests/Middleware/ValidatesWebhookSignaturesTest.php deleted file mode 100644 index c259a78..0000000 --- a/src/Tests/Middleware/ValidatesWebhookSignaturesTest.php +++ /dev/null @@ -1,289 +0,0 @@ -middleware = new ValidatesWebhookSignatures( - $this->app->make(SignatureValidator::class) - ); - $this->next = function ($request) { - return response('OK'); - }; - } - - public function test_bypasses_validation_when_webhooks_are_disabled() - { - config(['mollie.webhooks.enabled' => false]); - $request = Request::create('/webhook', 'POST'); - - // Act - $response = $this->middleware->handle($request, $this->next); - - // Assert - $this->assertEquals('OK', $response->getContent()); - } - - public function test_validates_signature_when_webhooks_are_enabled() - { - // Arrange - config([ - 'mollie.webhooks.enabled' => true, - 'mollie.webhooks.signing_secrets' => 'secret1,secret2', - 'mollie.webhooks.legacy_webhook_enabled' => true, - ]); - - $request = $this->createRequestWithSignature('{"id":"payment_123"}', 'valid_signature'); - - $validatorMock = Mockery::mock('overload:' . SignatureValidator::class); - $validatorMock->shouldReceive('__construct') - ->once() - ->with('secret1,secret2'); - $validatorMock->shouldReceive('validatePayload') - ->once() - ->with('{"id":"payment_123"}', 'valid_signature') - ->andReturn(false); // Not a legacy webhook - - // Act - $response = $this->middleware->handle($request, $this->next); - - // Assert - $this->assertEquals('OK', $response->getContent()); - } - - public function test_throws_http_response_exception_on_invalid_signature() - { - // Arrange - config([ - 'mollie.webhooks.enabled' => true, - 'mollie.webhooks.signing_secrets' => 'secret1,secret2', - ]); - - $request = $this->createRequestWithSignature('{"id":"payment_123"}', 'invalid_signature'); - - $validatorMock = Mockery::mock('overload:' . SignatureValidator::class); - $validatorMock->shouldReceive('__construct') - ->once() - ->with('secret1,secret2'); - $validatorMock->shouldReceive('validatePayload') - ->once() - ->with('{"id":"payment_123"}', 'invalid_signature') - ->andThrow(new InvalidSignatureException('Invalid signature')); - - // Act & Assert - $this->expectException(HttpResponseException::class); - - try { - $this->middleware->handle($request, $this->next); - } catch (HttpResponseException $e) { - $this->assertEquals(400, $e->getResponse()->getStatusCode()); - $this->assertEquals( - '{"message":"Invalid webhook signature"}', - $e->getResponse()->getContent() - ); - throw $e; - } - } - - public function test_allows_legacy_webhook_when_legacy_is_enabled() - { - // Arrange - config([ - 'mollie.webhooks.enabled' => true, - 'mollie.webhooks.signing_secrets' => 'secret1,secret2', - 'mollie.webhooks.legacy_webhook_enabled' => true, - ]); - - $request = $this->createRequestWithSignature('{"id":"payment_123"}', 'legacy_signature'); - - $validatorMock = Mockery::mock('overload:' . SignatureValidator::class); - $validatorMock->shouldReceive('__construct') - ->once() - ->with('secret1,secret2'); - $validatorMock->shouldReceive('validatePayload') - ->once() - ->with('{"id":"payment_123"}', 'legacy_signature') - ->andReturn(true); // Is a legacy webhook - - // Act - $response = $this->middleware->handle($request, $this->next); - - // Assert - $this->assertEquals('OK', $response->getContent()); - } - - public function test_throws_exception_for_legacy_webhook_when_legacy_is_disabled() - { - // Arrange - config([ - 'mollie.webhooks.enabled' => true, - 'mollie.webhooks.signing_secrets' => 'secret1,secret2', - 'mollie.webhooks.legacy_webhook_enabled' => false, - ]); - - $request = $this->createRequestWithSignature('{"id":"payment_123"}', 'legacy_signature'); - - $validatorMock = Mockery::mock('overload:' . SignatureValidator::class); - $validatorMock->shouldReceive('__construct') - ->once() - ->with('secret1,secret2'); - $validatorMock->shouldReceive('validatePayload') - ->once() - ->with('{"id":"payment_123"}', 'legacy_signature') - ->andReturn(true); // Is a legacy webhook - - // Act & Assert - $this->expectException(Exception::class); - $this->expectExceptionMessage('Legacy webhook feature is disabled'); - - $this->middleware->handle($request, $this->next); - } - - public function test_handles_request_without_signature_header() - { - // Arrange - config([ - 'mollie.webhooks.enabled' => true, - 'mollie.webhooks.signing_secrets' => 'secret1,secret2', - ]); - - $request = $this->createRequestWithSignature('{"id":"payment_123"}', null); - - $validatorMock = Mockery::mock('overload:' . SignatureValidator::class); - $validatorMock->shouldReceive('__construct') - ->once() - ->with('secret1,secret2'); - $validatorMock->shouldReceive('validatePayload') - ->once() - ->with('{"id":"payment_123"}', null) - ->andThrow(new InvalidSignatureException('No signature provided')); - - // Act & Assert - $this->expectException(HttpResponseException::class); - - try { - $this->middleware->handle($request, $this->next); - } catch (HttpResponseException $e) { - $this->assertEquals(400, $e->getResponse()->getStatusCode()); - $this->assertEquals( - '{"message":"Invalid webhook signature"}', - $e->getResponse()->getContent() - ); - throw $e; - } - } - - public function test_handles_empty_request_body() - { - // Arrange - config([ - 'mollie.webhooks.enabled' => true, - 'mollie.webhooks.signing_secrets' => 'secret1,secret2', - ]); - - $request = $this->createRequestWithSignature('', 'some_signature'); - - $validatorMock = Mockery::mock('overload:' . SignatureValidator::class); - $validatorMock->shouldReceive('__construct') - ->once() - ->with('secret1,secret2'); - $validatorMock->shouldReceive('validatePayload') - ->once() - ->with('', 'some_signature') - ->andReturn(false); - - // Act - $response = $this->middleware->handle($request, $this->next); - - // Assert - $this->assertEquals('OK', $response->getContent()); - } - - public function test_uses_configured_signing_secrets() - { - // Arrange - $customSecrets = 'custom_secret_1,custom_secret_2,custom_secret_3'; - config([ - 'mollie.webhooks.enabled' => true, - 'mollie.webhooks.signing_secrets' => $customSecrets, - ]); - - $request = $this->createRequestWithSignature('{"id":"payment_123"}', 'valid_signature'); - - $validatorMock = Mockery::mock('overload:' . SignatureValidator::class); - $validatorMock->shouldReceive('__construct') - ->once() - ->with($customSecrets); - $validatorMock->shouldReceive('validatePayload') - ->once() - ->andReturn(false); - - // Act - $response = $this->middleware->handle($request, $this->next); - - // Assert - $this->assertEquals('OK', $response->getContent()); - } - - public function test_passes_request_to_next_middleware_on_success() - { - // Arrange - config([ - 'mollie.webhooks.enabled' => true, - 'mollie.webhooks.signing_secrets' => 'secret1', - ]); - - $request = $this->createRequestWithSignature('{"id":"payment_123"}', 'valid_signature'); - - $validatorMock = Mockery::mock('overload:' . SignatureValidator::class); - $validatorMock->shouldReceive('__construct')->once(); - $validatorMock->shouldReceive('validatePayload')->once()->andReturn(false); - - $nextCalled = false; - $next = function ($request) use (&$nextCalled) { - $nextCalled = true; - - return response('Next middleware called'); - }; - - // Act - $response = $this->middleware->handle($request, $next); - - // Assert - $this->assertTrue($nextCalled); - $this->assertEquals('Next middleware called', $response->getContent()); - } - - /** - * Create a request with the specified body and signature header. - */ - private function createRequestWithSignature(string $body, ?string $signature): Request - { - $request = Request::create('/webhook', 'POST', [], [], [], [], $body); - - if ($signature !== null) { - $request->headers->set('X-Mollie-Signature', $signature); - } - - return $request; - } -} diff --git a/tests/Middleware/ValidatesWebhookSignaturesTest.php b/tests/Middleware/ValidatesWebhookSignaturesTest.php new file mode 100644 index 0000000..8a34e8c --- /dev/null +++ b/tests/Middleware/ValidatesWebhookSignaturesTest.php @@ -0,0 +1,27 @@ + false]); + + $request = Request::create('/webhook', 'POST'); + + $middleware = resolve(ValidatesWebhookSignatures::class); + + $middleware->handle($request, function () { + return response('OK'); + }); + + $this->assertTrue(true); + } +} diff --git a/tests/SignatureValidatorTest.php b/tests/SignatureValidatorTest.php new file mode 100644 index 0000000..6233437 --- /dev/null +++ b/tests/SignatureValidatorTest.php @@ -0,0 +1,94 @@ + false + ]); + + $body = '{"id":"payment_123"}'; + $request = $this->createRequestWithSignature($body, 'valid_secret'); + + $validator = new SignatureValidator(new BaseSignatureValidator('valid_secret')); + + $validator->validate($request); + + $this->assertTrue(true); + } + + public function test_allows_legacy_webhook_when_legacy_is_enabled() + { + config(['mollie.webhooks.legacy_webhook_enabled' => true]); + + $body = '{"id":"payment_123"}'; + $request = $this->createRequest($body); + + $validator = new SignatureValidator(new BaseSignatureValidator('some_secret')); + + $validator->validate($request); + + $this->assertTrue(true); + } + + public function test_throws_http_response_exception_on_invalid_signature() + { + config(['mollie.webhooks.legacy_webhook_enabled' => false]); + + $validator = new SignatureValidator(new BaseSignatureValidator('valid_secret')); + + $request = $this->createRequestWithSignature('{"id":"payment_123"}', 'invalid_secret'); + + try { + $validator->validate($request); + } catch (HttpException $e) { + $this->assertEquals(401, $e->getStatusCode()); + $this->assertEquals('Invalid webhook signature', $e->getMessage()); + } + } + + public function test_aborts_if_legacy_webhook_is_disabled() + { + config(['mollie.webhooks.legacy_webhook_enabled' => false]); + + $body = '{"id":"payment_123"}'; + $request = $this->createRequest($body); + + $validator = new SignatureValidator(new BaseSignatureValidator('some_secret')); + + try { + $validator->validate($request); + } catch (HttpException $e) { + $this->assertEquals(403, $e->getStatusCode()); + $this->assertEquals('Legacy webhook feature is disabled', $e->getMessage()); + } + } + + /** + * Create a request with the specified body and signature header. + */ + private function createRequestWithSignature(string $body, string $secret): Request + { + $request = $this->createRequest($body); + + $request->headers->set(BaseSignatureValidator::SIGNATURE_HEADER, BaseSignatureValidator::createSignature($body, $secret)); + + return $request; + } + + private function createRequest(string $body): Request + { + return Request::create('/webhook', 'POST', [], [], [], [], $body); + } +} From 3d983fd2bbb0938e55491fc80a395ff618728c58 Mon Sep 17 00:00:00 2001 From: Naoray Date: Fri, 25 Jul 2025 09:47:04 +0000 Subject: [PATCH 19/27] Fix styling --- src/SignatureValidator.php | 7 ++----- tests/Middleware/ValidatesWebhookSignaturesTest.php | 2 +- tests/SignatureValidatorTest.php | 3 +-- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/SignatureValidator.php b/src/SignatureValidator.php index 1ef7530..5cfb134 100644 --- a/src/SignatureValidator.php +++ b/src/SignatureValidator.php @@ -21,7 +21,7 @@ public function validate(Request $request): void $signatures = $request->header(BaseSignatureValidator::SIGNATURE_HEADER, ''); try { - $isLegacyWebhook = !$this->validator->validatePayload( + $isLegacyWebhook = ! $this->validator->validatePayload( $body, $signatures ); @@ -34,9 +34,6 @@ public function validate(Request $request): void /** * Handle the given invalid signature exception. - * - * @param InvalidSignatureException $e - * @return void */ private function marshalInvalidSignatureException(InvalidSignatureException $e): void { @@ -45,6 +42,6 @@ private function marshalInvalidSignatureException(InvalidSignatureException $e): private function abortIfLegacyWebhookIsDisabled(bool $isLegacyWebhook): void { - abort_if($isLegacyWebhook && !config('mollie.webhooks.legacy_webhook_enabled'), 403, 'Legacy webhook feature is disabled'); + abort_if($isLegacyWebhook && ! config('mollie.webhooks.legacy_webhook_enabled'), 403, 'Legacy webhook feature is disabled'); } } diff --git a/tests/Middleware/ValidatesWebhookSignaturesTest.php b/tests/Middleware/ValidatesWebhookSignaturesTest.php index 8a34e8c..1e8996e 100644 --- a/tests/Middleware/ValidatesWebhookSignaturesTest.php +++ b/tests/Middleware/ValidatesWebhookSignaturesTest.php @@ -5,8 +5,8 @@ namespace Mollie\Laravel\Tests\Middleware; use Illuminate\Http\Request; -use Mollie\Laravel\Tests\TestCase; use Mollie\Laravel\Middleware\ValidatesWebhookSignatures; +use Mollie\Laravel\Tests\TestCase; class ValidatesWebhookSignaturesTest extends TestCase { diff --git a/tests/SignatureValidatorTest.php b/tests/SignatureValidatorTest.php index 6233437..af49697 100644 --- a/tests/SignatureValidatorTest.php +++ b/tests/SignatureValidatorTest.php @@ -7,7 +7,6 @@ use Illuminate\Http\Request; use Mollie\Api\Webhooks\SignatureValidator as BaseSignatureValidator; use Mollie\Laravel\SignatureValidator; -use Mollie\Laravel\Tests\TestCase; use Symfony\Component\HttpKernel\Exception\HttpException; class SignatureValidatorTest extends TestCase @@ -15,7 +14,7 @@ class SignatureValidatorTest extends TestCase public function test_validates_signature_with_valid_secret() { config([ - 'mollie.webhooks.legacy_webhook_enabled' => false + 'mollie.webhooks.legacy_webhook_enabled' => false, ]); $body = '{"id":"payment_123"}'; From 49e99f93e5e6fac71d8a77fcf05100921610465c Mon Sep 17 00:00:00 2001 From: Krishan Koenig Date: Wed, 15 Oct 2025 11:43:29 +0200 Subject: [PATCH 20/27] wip --- composer.json | 3 +- config/mollie.php | 15 +++ routes/api.php | 11 ++ src/Commands/RevealWebhookPathCommand.php | 21 +++ src/Commands/SetupWebhookCommand.php | 127 +++++++++++++++++++ src/Contracts/WebhookDispatcher.php | 12 ++ src/Controllers/HandleIncomingWebhook.php | 32 +++++ src/EventWebhookDispatcher.php | 16 +++ src/Facades/Mollie.php | 2 + src/MollieManager.php | 8 ++ src/MollieServiceProvider.php | 17 +++ tests/Commands/SetupWebhookCommandTest.php | 41 ++++++ tests/MollieLaravelHttpClientAdapterTest.php | 99 +++++++-------- tests/TestCase.php | 10 ++ 14 files changed, 361 insertions(+), 53 deletions(-) create mode 100644 routes/api.php create mode 100644 src/Commands/RevealWebhookPathCommand.php create mode 100644 src/Commands/SetupWebhookCommand.php create mode 100644 src/Contracts/WebhookDispatcher.php create mode 100644 src/Controllers/HandleIncomingWebhook.php create mode 100644 src/EventWebhookDispatcher.php create mode 100644 tests/Commands/SetupWebhookCommandTest.php diff --git a/composer.json b/composer.json index 8e7f25a..f57b869 100644 --- a/composer.json +++ b/composer.json @@ -44,7 +44,8 @@ "php": "^8.2", "illuminate/support": "^11.0|^12.0", "ext-json": "*", - "mollie/mollie-api-php": "^3.3" + "mollie/mollie-api-php": "dev-feat/add-webhook-mapping-and-events", + "laravel/prompts": "^0.3.7" }, "require-dev": { "graham-campbell/testbench": "^6.0", diff --git a/config/mollie.php b/config/mollie.php index d227132..be6bf92 100644 --- a/config/mollie.php +++ b/config/mollie.php @@ -2,6 +2,8 @@ declare(strict_types=1); +use Mollie\Laravel\EventWebhookDispatcher; + return [ 'key' => env('MOLLIE_KEY', 'test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'), @@ -16,6 +18,19 @@ 'webhooks' => [ 'enabled' => env('MOLLIE_WEBHOOKS_ENABLED', false), + 'prefix' => env('MOLLIE_WEBHOOKS_PREFIX', 'api'), + + 'path' => env('MOLLIE_WEBHOOKS_PATH', 'mollie/webhooks'), + + 'middleware' => env('MOLLIE_WEBHOOKS_MIDDLEWARE', 'api'), + + /** + * The dispatcher to use for webhook events. + * + * Note: The dispatcher must implement the WebhookDispatcher interface. + */ + 'dispatcher' => EventWebhookDispatcher::class, + /** * A comma separated list of signing secrets. */ diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..74895e3 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,11 @@ +prefix(config('mollie.webhooks.prefix')) + ->post(config('mollie.webhooks.path'), HandleIncomingWebhook::class) + ->name('mollie.webhooks'); +} diff --git a/src/Commands/RevealWebhookPathCommand.php b/src/Commands/RevealWebhookPathCommand.php new file mode 100644 index 0000000..b816504 --- /dev/null +++ b/src/Commands/RevealWebhookPathCommand.php @@ -0,0 +1,21 @@ +after(config('app.url')); + + $this->info($path); + } +} diff --git a/src/Commands/SetupWebhookCommand.php b/src/Commands/SetupWebhookCommand.php new file mode 100644 index 0000000..d46d44f --- /dev/null +++ b/src/Commands/SetupWebhookCommand.php @@ -0,0 +1,127 @@ +askForWebhookDetails(); + + clear(); + + $proceed = $this->confirmDetails($responses); + + if (! $proceed) { + $this->info('Webhook setup cancelled'); + + return Command::SUCCESS; + } + + try { + $request = new CreateWebhookRequest( + url: $responses['url'], + name: $responses['name'], + eventTypes: $responses['events'], + ); + + /** @var Webhook $response */ + $response = spin( + fn () => $mollie->send($request->test($responses['testmode'] === 'yes')), + message: 'Creating webhook...' + ); + + } catch (MollieException $e) { + error('Failed to create webhook: ' . $e->getMessage()); + + return Command::FAILURE; + } + + info('Webhook created successfully'); + note('🤫 Add this secret to your .env file: ' . $response->webhookSecret); + + $existingSecrets = config('mollie.webhooks.signing_secrets'); + + note( + 'MOLLIE_WEBHOOK_SIGNING_SECRETS=' . + str($existingSecrets) + ->whenNotEmpty( + fn ($str) => $str->append(','), + )->append($response->webhookSecret) + ); + + return Command::SUCCESS; + + } + + private function askForWebhookDetails(): array + { + return form() + ->text( + label: 'Name', + placeholder: 'Webhook name', + default: config('app.name'), + required: true, + name: 'name' + ) + ->text( + label: 'Url', + placeholder: 'Incoming webhook url', + default: route('mollie.webhooks'), + required: true, + validate: ['url' => 'required|url'], + name: 'url' + ) + ->multiselect( + label: 'Events', + options: WebhookEventType::getAllNextGenWebhookEventTypes(), + default: [WebhookEventType::ALL], + required: true, + name: 'events' + ) + ->select( + label: 'Testmode', + options: ['yes', 'no'], + default: 'yes', + required: true, + name: 'testmode' + ) + ->submit(); + } + + private function confirmDetails(array $responses): bool + { + table( + headers: ['Name', 'Url', 'Events', 'Testmode'], + rows: [ + [$responses['name'], $responses['url'], $responses['events'], $responses['testmode']] + ] + ); + + return confirm( + label: 'Proceed with setup?', + default: true, + required: true + ); + } +} diff --git a/src/Contracts/WebhookDispatcher.php b/src/Contracts/WebhookDispatcher.php new file mode 100644 index 0000000..e4bfcf7 --- /dev/null +++ b/src/Contracts/WebhookDispatcher.php @@ -0,0 +1,12 @@ +validate($request); + + /** @var BaseEvent $event */ + $event = $eventMapper->processPayload($request->getParsedBody()); + + $dispatcher->dispatch($event); + + return response()->json(); + } +} diff --git a/src/EventWebhookDispatcher.php b/src/EventWebhookDispatcher.php new file mode 100644 index 0000000..ba4db12 --- /dev/null +++ b/src/EventWebhookDispatcher.php @@ -0,0 +1,16 @@ +app->make(MollieApiClient::class); } + + public function fake(array $expectedResponses = []): MockMollieClient + { + return tap(MollieApiClient::fake($expectedResponses), function ($fake) { + $this->app->instance(MollieApiClient::class, $fake); + }); + } } diff --git a/src/MollieServiceProvider.php b/src/MollieServiceProvider.php index e81bb03..437cc05 100644 --- a/src/MollieServiceProvider.php +++ b/src/MollieServiceProvider.php @@ -9,6 +9,9 @@ use Mollie\Api\MollieApiClient; use Mollie\Api\Webhooks\SignatureValidator; use RuntimeException; +use Mollie\Laravel\Contracts\WebhookDispatcher; +use Mollie\Laravel\Commands\RevealWebhookPathCommand; +use Mollie\Laravel\Commands\SetupWebhookCommand; class MollieServiceProvider extends ServiceProvider { @@ -28,8 +31,15 @@ class MollieServiceProvider extends ServiceProvider */ public function boot() { + $this->loadRoutesFrom(__DIR__.'/../routes/api.php'); + if ($this->app->runningInConsole()) { $this->publishes([__DIR__ . '/../config/mollie.php' => config_path('mollie.php')]); + + $this->commands([ + SetupWebhookCommand::class, + RevealWebhookPathCommand::class, + ]); } } @@ -41,8 +51,11 @@ public function boot() public function provides() { return [ + SetupWebhookCommand::class, + RevealWebhookPathCommand::class, MollieApiClient::class, MollieManager::class, + WebhookDispatcher::class, ]; } @@ -81,5 +94,9 @@ function (Container $app) { return new SignatureValidator(config('mollie.webhooks.signing_secrets')); }); + + $this->app->bind(WebhookDispatcher::class, function (Container $app) { + return $app->make(config('mollie.webhooks.dispatcher')); + }); } } diff --git a/tests/Commands/SetupWebhookCommandTest.php b/tests/Commands/SetupWebhookCommandTest.php new file mode 100644 index 0000000..3e725fb --- /dev/null +++ b/tests/Commands/SetupWebhookCommandTest.php @@ -0,0 +1,41 @@ + MockResponse::resource(Webhook::class) + ->with([ + 'id' => 'webhook_123', + 'webhookSecret' => 'secret_123', + ]) + ->create(), + ]); + + $this->artisan(SetupWebhookCommand::class) + ->expectsQuestion('Name', 'Test Webhook') + ->expectsQuestion('Url', 'https://test.com/webhook') + ->expectsQuestion('Events', [WebhookEventType::ALL]) + ->expectsQuestion('Testmode', 'yes') + ->expectsConfirmation('Proceed with setup?', 'yes') + ->expectsOutputToContain('Webhook created successfully') + ->expectsOutputToContain('🤫 Add this secret to your .env file: secret_123') + ->assertSuccessful(); + } +} diff --git a/tests/MollieLaravelHttpClientAdapterTest.php b/tests/MollieLaravelHttpClientAdapterTest.php index c72b856..2fa6f0a 100644 --- a/tests/MollieLaravelHttpClientAdapterTest.php +++ b/tests/MollieLaravelHttpClientAdapterTest.php @@ -4,12 +4,17 @@ namespace Mollie\Laravel\Tests; -use Illuminate\Http\Client\ConnectionException; -use Illuminate\Support\Facades\Http; +use GuzzleHttp\Exception\ConnectException; use Mollie\Api\Exceptions\ApiException; use Mollie\Api\Exceptions\RetryableNetworkRequestException; -use Mollie\Api\MollieApiClient; +use Mollie\Api\Fake\MockResponse; +use Mollie\Api\Http\PendingRequest; +use Mollie\Api\Http\Requests\CreatePaymentRequest; +use Mollie\Api\Http\Requests\GetPaymentRequest; +use Mollie\Api\Http\Data\Money; +use Mollie\Api\Http\LinearRetryStrategy; use Mollie\Api\Resources\Payment; +use Mollie\Laravel\Facades\Mollie; /** * Class MollieLaravelHttpClientAdapterTest @@ -18,83 +23,73 @@ class MollieLaravelHttpClientAdapterTest extends TestCase { public function test_post_request() { - /** @var MollieApiClient $client */ - $client = app(MollieApiClient::class); - $payment = new Payment($client); - $payment->id = uniqid('tr_'); - $payment->redirectUrl = 'https://google.com/redirect'; - $payment->description = 'test'; - - Http::fake([ - 'https://api.mollie.com/*' => Http::response(json_encode($payment)), + Mollie::fake([ + CreatePaymentRequest::class => MockResponse::resource(Payment::class) + ->with([ + 'id' => $paymentId = uniqid('tr_'), + 'redirectUrl' => $redirectUrl = 'https://google.com/redirect', + 'description' => $description = 'test', + ]) + ->create(), ]); - $returnedPayment = $client->payments->create([ - 'redirectUrl' => 'https://google.com/redirect', - 'description' => 'test', - 'amount' => [ - 'value' => '10.00', - 'currency' => 'EUR', - ], - ]); + /** @var Payment $returnedPayment */ + $returnedPayment = Mollie::api()->send(new CreatePaymentRequest( + description: $description, + amount: new Money('10.00', 'EUR'), + redirectUrl: $redirectUrl, + )); - $this->assertEquals($payment->id, $returnedPayment->id); - $this->assertEquals($payment->redirectUrl, $returnedPayment->redirectUrl); - $this->assertEquals($payment->description, $returnedPayment->description); + $this->assertEquals($paymentId, $returnedPayment->id); + $this->assertEquals($redirectUrl, $returnedPayment->redirectUrl); + $this->assertEquals($description, $returnedPayment->description); } public function test_get_request() { - /** @var MollieApiClient $client */ - $client = app(MollieApiClient::class); - $payment = new Payment($client); - $payment->id = uniqid('tr_'); - $payment->redirectUrl = 'https://google.com/redirect'; - $payment->description = 'test'; - - Http::fake([ - 'https://api.mollie.com/v2/payments/' . $payment->id => Http::response(json_encode($payment)), + Mollie::fake([ + GetPaymentRequest::class => MockResponse::resource(Payment::class) + ->with([ + 'id' => $paymentId = uniqid('tr_'), + 'redirectUrl' => $redirectUrl = 'https://google.com/redirect', + 'description' => $description = 'test', + ]) + ->create(), ]); - $returnedPayment = $client->payments->get($payment->id); + $returnedPayment = Mollie::api()->send(new GetPaymentRequest($paymentId)); - $this->assertEquals($payment->id, $returnedPayment->id); - $this->assertEquals($payment->redirectUrl, $returnedPayment->redirectUrl); - $this->assertEquals($payment->description, $returnedPayment->description); + $this->assertEquals($paymentId, $returnedPayment->id); + $this->assertEquals($redirectUrl, $returnedPayment->redirectUrl); + $this->assertEquals($description, $returnedPayment->description); } public function test_exception_handling() { - /** @var MollieApiClient $client */ - $client = app(MollieApiClient::class); - - // Simulate a network error - Http::fake([ - 'https://api.mollie.com/*' => Http::response('', 500), + Mollie::fake([ + GetPaymentRequest::class => MockResponse::error(500, 'Internal Server Error', 'Internal Server Error'), ]); $this->expectException(ApiException::class); // This should throw an ApiException - $client->payments->get('non_existing_payment'); + Mollie::api()->send(new GetPaymentRequest('non_existing_payment')); } public function test_connection_error_handling() { - /** @var MollieApiClient $client */ - $client = app(MollieApiClient::class); - - // Simulate a connection error - Http::fake([ - 'https://api.mollie.com/*' => function () { - throw new ConnectionException('Connection error'); + Mollie::fake([ + GetPaymentRequest::class => function (PendingRequest $pendingRequest) { + throw new ConnectException('Connection error', $pendingRequest->createPsrRequest()); }, ]); $this->expectException(RetryableNetworkRequestException::class); $this->expectExceptionMessage('Connection error'); - // This should throw an ApiException with the connection error message - $client->payments->get('any_payment_id'); + Mollie::api() + // set retry to 0 to exit early + ->setRetryStrategy(new LinearRetryStrategy(0, 0)) + ->send(new GetPaymentRequest('any_payment_id')); } } diff --git a/tests/TestCase.php b/tests/TestCase.php index a88d4b5..9dab28c 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -27,4 +27,14 @@ protected function getPackageProviders($app) return $providers; } + + /** + * Set up the environment. + * + * @param \Illuminate\Foundation\Application $app + */ + protected function getEnvironmentSetUp($app) + { + $app['config']->set('mollie.webhooks.enabled', true); + } } From 34f9f0144026f360dc1a9d216d43c135c7159ef3 Mon Sep 17 00:00:00 2001 From: Krishan Koenig Date: Thu, 16 Oct 2025 09:32:16 +0200 Subject: [PATCH 21/27] chore: update phpunit and use attributes for tests --- composer.json | 2 +- config/mollie.php | 5 +++- src/Commands/SetupWebhookCommand.php | 3 ++- src/Facades/Mollie.php | 21 ++++++++++++--- src/MollieManager.php | 26 ------------------- src/MollieServiceProvider.php | 3 --- tests/Commands/SetupWebhookCommandTest.php | 6 ++++- .../ValidatesWebhookSignaturesTest.php | 4 ++- tests/MollieApiClientTest.php | 10 ++++--- tests/MollieLaravelHttpClientAdapterTest.php | 23 +++++++++------- tests/MollieServiceProviderTest.php | 13 ++++++---- tests/SignatureValidatorTest.php | 13 +++++++--- 12 files changed, 69 insertions(+), 60 deletions(-) delete mode 100644 src/MollieManager.php diff --git a/composer.json b/composer.json index f57b869..27a76c8 100644 --- a/composer.json +++ b/composer.json @@ -52,7 +52,7 @@ "laravel/pint": "^1.13", "laravel/socialite": "^5.21", "mockery/mockery": "^1.5", - "phpunit/phpunit": "^11.0" + "phpunit/phpunit": "^12.0" }, "suggest": { "laravel/socialite": "Use Mollie Connect (OAuth) to authenticate via Laravel Socialite with the Mollie API. This is needed for some endpoints." diff --git a/config/mollie.php b/config/mollie.php index be6bf92..ce473a1 100644 --- a/config/mollie.php +++ b/config/mollie.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Mollie\Laravel\EventWebhookDispatcher; +use Mollie\Laravel\Middleware\ValidatesWebhookSignatures; return [ @@ -22,7 +23,9 @@ 'path' => env('MOLLIE_WEBHOOKS_PATH', 'mollie/webhooks'), - 'middleware' => env('MOLLIE_WEBHOOKS_MIDDLEWARE', 'api'), + 'middleware' => [ + ValidatesWebhookSignatures::class, + ], /** * The dispatcher to use for webhook events. diff --git a/src/Commands/SetupWebhookCommand.php b/src/Commands/SetupWebhookCommand.php index d46d44f..757ca9b 100644 --- a/src/Commands/SetupWebhookCommand.php +++ b/src/Commands/SetupWebhookCommand.php @@ -3,6 +3,7 @@ namespace Mollie\Laravel\Commands; use Illuminate\Console\Command; +use Illuminate\Support\Arr; use Mollie\Api\Exceptions\MollieException; use Mollie\Api\Http\Requests\CreateWebhookRequest; use Mollie\Api\MollieApiClient; @@ -114,7 +115,7 @@ private function confirmDetails(array $responses): bool table( headers: ['Name', 'Url', 'Events', 'Testmode'], rows: [ - [$responses['name'], $responses['url'], $responses['events'], $responses['testmode']] + ['name' => $responses['name'], 'url' => $responses['url'], 'events' => Arr::join($responses['events'], ', '), 'testmode' => $responses['testmode']] ] ); diff --git a/src/Facades/Mollie.php b/src/Facades/Mollie.php index ee01442..b4db421 100644 --- a/src/Facades/Mollie.php +++ b/src/Facades/Mollie.php @@ -7,13 +7,14 @@ use Illuminate\Support\Facades\Facade; use Mollie\Api\MollieApiClient; use Mollie\Api\Fake\MockMollieClient; -use Mollie\Laravel\MollieManager; /** * (Facade) Class Mollie. * - * @method static MollieApiClient api() - * @method static MockMollieClient fake(array $expectedResponses = []) + * @method static void assertSent(string $class) + * @method static void assertSentCount(int $count) + * + * @see \Mollie\Api\Fake\MockMollieClient */ class Mollie extends Facade { @@ -24,6 +25,18 @@ class Mollie extends Facade */ protected static function getFacadeAccessor() { - return MollieManager::class; + return MollieApiClient::class; + } + + public static function fake(array $expectedResponses = []): MockMollieClient + { + return tap(new MockMollieClient($expectedResponses), function ($fake) { + static::swap($fake); + }); + } + + public static function api() + { + return static::getFacadeRoot(); } } diff --git a/src/MollieManager.php b/src/MollieManager.php deleted file mode 100644 index 9792a9b..0000000 --- a/src/MollieManager.php +++ /dev/null @@ -1,26 +0,0 @@ -app->make(MollieApiClient::class); - } - - public function fake(array $expectedResponses = []): MockMollieClient - { - return tap(MollieApiClient::fake($expectedResponses), function ($fake) { - $this->app->instance(MollieApiClient::class, $fake); - }); - } -} diff --git a/src/MollieServiceProvider.php b/src/MollieServiceProvider.php index 437cc05..1315360 100644 --- a/src/MollieServiceProvider.php +++ b/src/MollieServiceProvider.php @@ -54,7 +54,6 @@ public function provides() SetupWebhookCommand::class, RevealWebhookPathCommand::class, MollieApiClient::class, - MollieManager::class, WebhookDispatcher::class, ]; } @@ -84,8 +83,6 @@ function (Container $app) { } ); - $this->app->singleton(MollieManager::class); - $this->app->bind(SignatureValidator::class, function (Container $app) { throw_if( config('mollie.webhooks.enabled') && ! config('mollie.webhooks.signing_secrets'), diff --git a/tests/Commands/SetupWebhookCommandTest.php b/tests/Commands/SetupWebhookCommandTest.php index 3e725fb..4db1ce6 100644 --- a/tests/Commands/SetupWebhookCommandTest.php +++ b/tests/Commands/SetupWebhookCommandTest.php @@ -14,10 +14,12 @@ use Mollie\Laravel\Tests\TestCase; use Mollie\Api\Fake\MockResponse; use Mollie\Api\Resources\Webhook; +use PHPUnit\Framework\Attributes\Test; class SetupWebhookCommandTest extends TestCase { - public function test_setup_webhook_command() + #[Test] + public function it_can_setup_a_webhook() { Mollie::fake([ CreateWebhookRequest::class => MockResponse::resource(Webhook::class) @@ -37,5 +39,7 @@ public function test_setup_webhook_command() ->expectsOutputToContain('Webhook created successfully') ->expectsOutputToContain('🤫 Add this secret to your .env file: secret_123') ->assertSuccessful(); + + Mollie::assertSent(CreateWebhookRequest::class); } } diff --git a/tests/Middleware/ValidatesWebhookSignaturesTest.php b/tests/Middleware/ValidatesWebhookSignaturesTest.php index 1e8996e..4f73f8e 100644 --- a/tests/Middleware/ValidatesWebhookSignaturesTest.php +++ b/tests/Middleware/ValidatesWebhookSignaturesTest.php @@ -7,10 +7,12 @@ use Illuminate\Http\Request; use Mollie\Laravel\Middleware\ValidatesWebhookSignatures; use Mollie\Laravel\Tests\TestCase; +use PHPUnit\Framework\Attributes\Test; class ValidatesWebhookSignaturesTest extends TestCase { - public function test_bypasses_validation_when_webhooks_are_disabled() + #[Test] + public function it_bypasses_validation_when_webhooks_are_disabled() { config(['mollie.webhooks.enabled' => false]); diff --git a/tests/MollieApiClientTest.php b/tests/MollieApiClientTest.php index 742f57f..a368b04 100644 --- a/tests/MollieApiClientTest.php +++ b/tests/MollieApiClientTest.php @@ -9,10 +9,12 @@ use Mollie\Api\MollieApiClient; use Mollie\Laravel\MollieLaravelHttpClientAdapter; use ReflectionClass; +use PHPUnit\Framework\Attributes\Test; class MollieApiClientTest extends TestCase { - public function test_injected_http_adapter_is_laravel_http_client_adapter() + #[Test] + public function it_injected_http_adapter_is_laravel_http_client_adapter() { $this->assertInstanceOf( MollieLaravelHttpClientAdapter::class, @@ -20,7 +22,8 @@ public function test_injected_http_adapter_is_laravel_http_client_adapter() ); } - public function test_api_key_is_set_on_resolving_api_client() + #[Test] + public function it_api_key_is_set_on_resolving_api_client() { config(['mollie.key' => 'test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxyz']); @@ -35,7 +38,8 @@ public function test_api_key_is_set_on_resolving_api_client() ); } - public function test_does_not_set_api_key_if_key_is_empty() + #[Test] + public function it_does_not_set_api_key_if_key_is_empty() { config(['mollie.key' => '']); diff --git a/tests/MollieLaravelHttpClientAdapterTest.php b/tests/MollieLaravelHttpClientAdapterTest.php index 2fa6f0a..dddae12 100644 --- a/tests/MollieLaravelHttpClientAdapterTest.php +++ b/tests/MollieLaravelHttpClientAdapterTest.php @@ -15,13 +15,15 @@ use Mollie\Api\Http\LinearRetryStrategy; use Mollie\Api\Resources\Payment; use Mollie\Laravel\Facades\Mollie; +use PHPUnit\Framework\Attributes\Test; /** * Class MollieLaravelHttpClientAdapterTest */ class MollieLaravelHttpClientAdapterTest extends TestCase { - public function test_post_request() + #[Test] + public function it_can_send_a_post_request() { Mollie::fake([ CreatePaymentRequest::class => MockResponse::resource(Payment::class) @@ -34,7 +36,7 @@ public function test_post_request() ]); /** @var Payment $returnedPayment */ - $returnedPayment = Mollie::api()->send(new CreatePaymentRequest( + $returnedPayment = Mollie::send(new CreatePaymentRequest( description: $description, amount: new Money('10.00', 'EUR'), redirectUrl: $redirectUrl, @@ -45,7 +47,8 @@ public function test_post_request() $this->assertEquals($description, $returnedPayment->description); } - public function test_get_request() + #[Test] + public function it_can_send_a_get_request() { Mollie::fake([ GetPaymentRequest::class => MockResponse::resource(Payment::class) @@ -57,14 +60,15 @@ public function test_get_request() ->create(), ]); - $returnedPayment = Mollie::api()->send(new GetPaymentRequest($paymentId)); + $returnedPayment = Mollie::send(new GetPaymentRequest($paymentId)); $this->assertEquals($paymentId, $returnedPayment->id); $this->assertEquals($redirectUrl, $returnedPayment->redirectUrl); $this->assertEquals($description, $returnedPayment->description); } - public function test_exception_handling() + #[Test] + public function it_can_handle_an_exception() { Mollie::fake([ GetPaymentRequest::class => MockResponse::error(500, 'Internal Server Error', 'Internal Server Error'), @@ -73,10 +77,11 @@ public function test_exception_handling() $this->expectException(ApiException::class); // This should throw an ApiException - Mollie::api()->send(new GetPaymentRequest('non_existing_payment')); + Mollie::send(new GetPaymentRequest('non_existing_payment')); } - public function test_connection_error_handling() + #[Test] + public function it_can_handle_a_connection_error() { Mollie::fake([ GetPaymentRequest::class => function (PendingRequest $pendingRequest) { @@ -87,9 +92,7 @@ public function test_connection_error_handling() $this->expectException(RetryableNetworkRequestException::class); $this->expectExceptionMessage('Connection error'); - Mollie::api() - // set retry to 0 to exit early - ->setRetryStrategy(new LinearRetryStrategy(0, 0)) + Mollie::setRetryStrategy(new LinearRetryStrategy(0, 0)) ->send(new GetPaymentRequest('any_payment_id')); } } diff --git a/tests/MollieServiceProviderTest.php b/tests/MollieServiceProviderTest.php index 29c5c49..c5d6f04 100644 --- a/tests/MollieServiceProviderTest.php +++ b/tests/MollieServiceProviderTest.php @@ -6,6 +6,7 @@ use Mollie\Api\MollieApiClient; use Mollie\Laravel\MollieServiceProvider; +use PHPUnit\Framework\Attributes\Test; class MollieServiceProviderTest extends TestCase { @@ -13,23 +14,24 @@ class MollieServiceProviderTest extends TestCase * Test that the service provider can be registered and booted without an API key. * This simulates the package installation scenario where the user hasn't configured a key yet. */ - public function test_service_provider_installation_without_api_key() + #[Test] + public function it_service_provider_installation_without_api_key() { // Clear the API key in the config config(['mollie.key' => '']); // Create a new instance of the service provider - $provider = new MollieServiceProvider($this->app); + $provider = new MollieServiceProvider(app()); // Register and boot should not throw exceptions $provider->register(); $provider->boot(); // Verify the service provider registered the MollieApiClient - $this->assertTrue($this->app->bound(MollieApiClient::class)); + $this->assertTrue(app()->bound(MollieApiClient::class)); // Resolving the client should not throw an exception - $client = $this->app->make(MollieApiClient::class); + $client = resolve(MollieApiClient::class); $this->assertInstanceOf(MollieApiClient::class, $client); // Verify no API key was set (authenticator should be null) @@ -42,7 +44,8 @@ public function test_service_provider_installation_without_api_key() /** * Test that the service provider can be registered and booted with a valid API key. */ - public function test_service_provider_with_valid_api_key() + #[Test] + public function it_service_provider_with_valid_api_key() { // Set a valid API key in the config config(['mollie.key' => 'test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxyz']); diff --git a/tests/SignatureValidatorTest.php b/tests/SignatureValidatorTest.php index af49697..33b2b5f 100644 --- a/tests/SignatureValidatorTest.php +++ b/tests/SignatureValidatorTest.php @@ -8,10 +8,12 @@ use Mollie\Api\Webhooks\SignatureValidator as BaseSignatureValidator; use Mollie\Laravel\SignatureValidator; use Symfony\Component\HttpKernel\Exception\HttpException; +use PHPUnit\Framework\Attributes\Test; class SignatureValidatorTest extends TestCase { - public function test_validates_signature_with_valid_secret() + #[Test] + public function it_validates_signature_with_valid_secret() { config([ 'mollie.webhooks.legacy_webhook_enabled' => false, @@ -27,7 +29,8 @@ public function test_validates_signature_with_valid_secret() $this->assertTrue(true); } - public function test_allows_legacy_webhook_when_legacy_is_enabled() + #[Test] + public function it_allows_legacy_webhook_when_legacy_is_enabled() { config(['mollie.webhooks.legacy_webhook_enabled' => true]); @@ -41,7 +44,8 @@ public function test_allows_legacy_webhook_when_legacy_is_enabled() $this->assertTrue(true); } - public function test_throws_http_response_exception_on_invalid_signature() + #[Test] + public function it_throws_http_response_exception_on_invalid_signature() { config(['mollie.webhooks.legacy_webhook_enabled' => false]); @@ -57,7 +61,8 @@ public function test_throws_http_response_exception_on_invalid_signature() } } - public function test_aborts_if_legacy_webhook_is_disabled() + #[Test] + public function it_aborts_if_legacy_webhook_is_disabled() { config(['mollie.webhooks.legacy_webhook_enabled' => false]); From 6c07391cf1f798465be9305ec14aa5966c2c96d6 Mon Sep 17 00:00:00 2001 From: Krishan Koenig Date: Thu, 16 Oct 2025 12:00:09 +0200 Subject: [PATCH 22/27] feat: add command to setup webhook; finalize inbound webhook implementation --- composer.json | 7 +- config/mollie.php | 33 ++- docs/webhook.md | 73 +++-- routes/api.php | 1 - src/Commands/RevealWebhookPathCommand.php | 21 -- src/Commands/SetupWebhookCommand.php | 123 +++++--- src/Controllers/HandleIncomingWebhook.php | 6 +- src/Facades/Mollie.php | 2 +- src/Middleware/ValidatesWebhookSignatures.php | 4 - src/MollieServiceProvider.php | 17 +- src/SignatureValidator.php | 9 +- tests/Commands/SetupWebhookCommandTest.php | 278 +++++++++++++++++- .../Controllers/HandleIncomingWebhookTest.php | 35 +++ .../ValidatesWebhookSignaturesTest.php | 29 -- tests/SignatureValidatorTest.php | 4 - 15 files changed, 475 insertions(+), 167 deletions(-) delete mode 100644 src/Commands/RevealWebhookPathCommand.php create mode 100644 tests/Controllers/HandleIncomingWebhookTest.php delete mode 100644 tests/Middleware/ValidatesWebhookSignaturesTest.php diff --git a/composer.json b/composer.json index 27a76c8..97ccfd3 100644 --- a/composer.json +++ b/composer.json @@ -48,10 +48,9 @@ "laravel/prompts": "^0.3.7" }, "require-dev": { - "graham-campbell/testbench": "^6.0", - "laravel/pint": "^1.13", - "laravel/socialite": "^5.21", - "mockery/mockery": "^1.5", + "graham-campbell/testbench": "^6.2", + "laravel/pint": "^1.25", + "laravel/socialite": "^5.23", "phpunit/phpunit": "^12.0" }, "suggest": { diff --git a/config/mollie.php b/config/mollie.php index ce473a1..6c7e57f 100644 --- a/config/mollie.php +++ b/config/mollie.php @@ -6,29 +6,37 @@ use Mollie\Laravel\Middleware\ValidatesWebhookSignatures; return [ - + /** + * API Key or Access Token to authenticate with the Mollie API. + */ 'key' => env('MOLLIE_KEY', 'test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx'), - // If you intend on using Mollie Connect, place the following in the 'config/services.php' - // 'mollie' => [ - // 'client_id' => env('MOLLIE_CLIENT_ID', 'app_xxx'), - // 'client_secret' => env('MOLLIE_CLIENT_SECRET'), - // 'redirect' => env('MOLLIE_REDIRECT_URI'), - // ], - + /** + * Webhooks configuration. + */ 'webhooks' => [ + /** + * If true, the webhook route will be registered. + */ 'enabled' => env('MOLLIE_WEBHOOKS_ENABLED', false), - 'prefix' => env('MOLLIE_WEBHOOKS_PREFIX', 'api'), - - 'path' => env('MOLLIE_WEBHOOKS_PATH', 'mollie/webhooks'), + /** + * The path to use for incoming webhook requests. + */ + 'path' => env('MOLLIE_WEBHOOKS_PATH', '/webhooks/mollie'), + /** + * The middleware to use for incoming webhook requests. + */ 'middleware' => [ ValidatesWebhookSignatures::class, ], /** - * The dispatcher to use for webhook events. + * The dispatcher determines how webhook events are treated by the app. + * By default, events are dispatched as Laravel events. You can then listen to + * these events via Subscriber or Listeners and react accordingly. Or you may implement + * your own dispatcher to handle the events in a different way. * * Note: The dispatcher must implement the WebhookDispatcher interface. */ @@ -44,5 +52,4 @@ */ 'legacy_webhook_enabled' => env('MOLLIE_LEGACY_WEBHOOK_ENABLED', false), ], - ]; diff --git a/docs/webhook.md b/docs/webhook.md index 260e3a2..7b474b2 100644 --- a/docs/webhook.md +++ b/docs/webhook.md @@ -1,9 +1,25 @@ ![Mollie](https://www.mollie.nl/files/Mollie-Logo-Style-Small.png) -# Process realtime status updates with a webhook -A webhook is a URL Mollie will call when an object’s status changes, for example when a payment changes from `open` to `paid`. More specifics can be found in [the webhook guide](https://docs.mollie.com/guides/webhooks). +# Process real-time status updates with a webhook +A webhook is a URL that Mollie calls when an object's status changes, for example when a payment transitions from `open` to `paid`. For more details, see [the webhook guide](https://docs.mollie.com/reference/webhooks-new). -To implement the webhook in your Laravel application you need to provide a `webhookUrl` parameter when creating a payment (or subscription): +## Next-gen Webhooks +Mollie's legacy webhooks only send the ID of updated resources, which requires your app to fetch the resource and determine what changed (see https://docs.mollie.com/reference/webhooks). Besides requiring an extra HTTP call, you'd have to handle IDs from potentially malicious sources without being able to verify their authenticity, risking information exposure to attackers. + +Next-gen webhooks solve this through a signature sent in the header of the webhook request, along with the data of the resource that changed if you create a snapshot webhook. + +### Setup +To enable the inbound route for Mollie webhooks, set `MOLLIE_WEBHOOKS_ENABLED=true`. + +Next, decide how webhook events should be handled in your app. By default, an event is dispatched (e.g. `PaymentLinkPaid`) that you can listen to throughout your application. To learn how to consume these webhook events, see [Generating Events and Listeners](https://laravel.com/docs/12.x/events#generating-events-and-listeners). + +> [!NOTE] +> If you want to handle webhook requests differently, you can create your own dispatcher by implementing `Mollie\Laravel\Contracts\WebhookDispatcher` and setting `mollie.webhooks.dispatcher` to your custom class. + +Finally, run the `mollie:setup-webhook` command to create a webhook. + +## Legacy Webhooks +To implement legacy webhooks in your Laravel application, provide a `webhookUrl` parameter when creating a payment (or subscription): ```php $payment = Mollie::api()->payments->create([ @@ -17,44 +33,37 @@ $payment = Mollie::api()->payments->create([ ]); ``` -And create a matching route and controller for the webhook in your application: +Create a route and apply the `ValidatesWebhookSignatures` middleware to accept incoming webhook requests: ```php -// routes/web.php +use Mollie\Laravel\Middleware\ValidatesWebhookSignatures; -Route::name('webhooks.mollie')->post('webhooks/mollie', 'MollieWebhookController@handle'); +Route::name('webhooks.mollie') + ->middleware(ValidatesWebhookSignatures::class) + ->post('webhooks/mollie', HandleIncomingWebhooks::class); ``` -```php -// App/Http/Controllers/MollieWebhookController.php +> [!WARNING] +> Make sure to enable legacy webhooks by setting `MOLLIE_LEGACY_WEBHOOK_ENABLED=true`. Otherwise, `ValidatesWebhookSignatures` will throw an `AuthorizationException`. -class MollieWebhookController extends Controller { - public function handle(Request $request) { - if (! $request->has('id')) { - return; - } +Then create a matching controller: + +```php +use Illuminate\Http\Request; +use Illuminate\Routing\Controller; - $payment = Mollie::api()->payments->get($request->id); +class HandleIncomingWebhooks extends Controller +{ + public function __invoke(MollieApiClient $client, Request $request) + { + // Fetch the resource using the ID from the request + $payment = $client->send(new GetPaymentRequest($request->input('id'))); + // Act accordingly based on payment status if ($payment->isPaid()) { - // do your thing... + $this->handlePaymentPaid($payment); + } elseif (...) { + // ... } } } -``` - -Finally, it is _strongly advised_ to disable the `VerifyCsrfToken` middleware, which is included in the `web` middleware group by default. (Out of the box, Laravel applies the `web` middleware group to all routes in `routes/web.php`.) - -You can exclude the route from the CSRF protection in your `bootstrap/app.php`: - -```php -// bootstrap/app.php - -->withMiddleware(function (Middleware $middleware) { - $middleware->validateCsrfTokens( - except: ['webhooks/mollie'] - ); -}) -``` - -If this solution does not work, open an [issue](https://github.com/mollie/laravel-mollie/issues) so we can assist you. diff --git a/routes/api.php b/routes/api.php index 74895e3..73a09f0 100644 --- a/routes/api.php +++ b/routes/api.php @@ -5,7 +5,6 @@ if (config('mollie.webhooks.enabled')) { Route::middleware(config('mollie.webhooks.middleware')) - ->prefix(config('mollie.webhooks.prefix')) ->post(config('mollie.webhooks.path'), HandleIncomingWebhook::class) ->name('mollie.webhooks'); } diff --git a/src/Commands/RevealWebhookPathCommand.php b/src/Commands/RevealWebhookPathCommand.php deleted file mode 100644 index b816504..0000000 --- a/src/Commands/RevealWebhookPathCommand.php +++ /dev/null @@ -1,21 +0,0 @@ -after(config('app.url')); - - $this->info($path); - } -} diff --git a/src/Commands/SetupWebhookCommand.php b/src/Commands/SetupWebhookCommand.php index 757ca9b..f267751 100644 --- a/src/Commands/SetupWebhookCommand.php +++ b/src/Commands/SetupWebhookCommand.php @@ -5,74 +5,70 @@ use Illuminate\Console\Command; use Illuminate\Support\Arr; use Mollie\Api\Exceptions\MollieException; +use Mollie\Api\Http\Auth\TokenValidator; use Mollie\Api\Http\Requests\CreateWebhookRequest; +use Mollie\Api\Http\Requests\ListPermissionsRequest; use Mollie\Api\MollieApiClient; +use Mollie\Api\Resources\Permission; use Mollie\Api\Resources\Webhook; use Mollie\Api\Webhooks\WebhookEventType; use function Laravel\Prompts\clear; -use function Laravel\Prompts\form; -use function Laravel\Prompts\table; use function Laravel\Prompts\confirm; use function Laravel\Prompts\error; -use function Laravel\Prompts\note; +use function Laravel\Prompts\form; use function Laravel\Prompts\info; +use function Laravel\Prompts\note; +use function Laravel\Prompts\pause; use function Laravel\Prompts\spin; +use function Laravel\Prompts\table; +use function Laravel\Prompts\warning; class SetupWebhookCommand extends Command { protected $signature = 'mollie:setup-webhook'; - protected $description = 'Setup the webhook'; + protected $description = 'Setup and create a webhook in your Mollie account'; - public function handle(MollieApiClient $mollie) + public function handle(MollieApiClient $mollie): int { - $responses = $this->askForWebhookDetails(); + $webhookDetails = $this->askForWebhookDetails(); clear(); - $proceed = $this->confirmDetails($responses); - - if (! $proceed) { - $this->info('Webhook setup cancelled'); + if (! $this->confirmDetails($webhookDetails)) { + info('Webhook setup cancelled'); return Command::SUCCESS; } - try { - $request = new CreateWebhookRequest( - url: $responses['url'], - name: $responses['name'], - eventTypes: $responses['events'], - ); + if (! $this->hasValidAccessToken()) { + info('To automatically create the webhook, you need to provide an access token.'); + $this->suggestManualCreation(); - /** @var Webhook $response */ - $response = spin( - fn () => $mollie->send($request->test($responses['testmode'] === 'yes')), - message: 'Creating webhook...' - ); + return Command::SUCCESS; + } - } catch (MollieException $e) { - error('Failed to create webhook: ' . $e->getMessage()); + if (! $this->hasWebhookWritePermission($mollie)) { + warning('You do not have permission to create webhooks. You will need to create the webhook manually in the Mollie dashboard.'); + warning('Or create an access token with the permission of webhooks.write'); - return Command::FAILURE; + $this->suggestManualCreation(); + + return Command::SUCCESS; } - info('Webhook created successfully'); - note('🤫 Add this secret to your .env file: ' . $response->webhookSecret); + pause('Press ENTER to continue'); - $existingSecrets = config('mollie.webhooks.signing_secrets'); + $webhook = $this->createWebhook($mollie, $webhookDetails); - note( - 'MOLLIE_WEBHOOK_SIGNING_SECRETS=' . - str($existingSecrets) - ->whenNotEmpty( - fn ($str) => $str->append(','), - )->append($response->webhookSecret) - ); + if (! $webhook) { + return Command::FAILURE; + } - return Command::SUCCESS; + $this->displaySuccess($webhook); + return Command::SUCCESS; } private function askForWebhookDetails(): array @@ -125,4 +121,61 @@ private function confirmDetails(array $responses): bool required: true ); } + + private function hasValidAccessToken(): bool + { + return TokenValidator::isAccessToken(config('mollie.key')); + } + + private function hasWebhookWritePermission(MollieApiClient $mollie): bool + { + return $mollie->send(new ListPermissionsRequest()) + ->contains(function (Permission $permission) { + return $permission->id === 'webhooks.write' + && $permission->granted; + }); + } + + private function createWebhook(MollieApiClient $mollie, array $webhookDetails): ?Webhook + { + try { + $request = new CreateWebhookRequest( + url: $webhookDetails['url'], + name: $webhookDetails['name'], + eventTypes: $webhookDetails['events'], + ); + + return spin( + fn () => $mollie->send($request->test($webhookDetails['testmode'] === 'yes')), + message: 'Creating webhook...' + ); + } catch (MollieException $e) { + error('Failed to create webhook: '.$e->getMessage()); + + return null; + } + } + + private function displaySuccess(Webhook $webhook): void + { + info('Webhook created successfully'); + note('🤫 Add this secret to your .env file: '.$webhook->webhookSecret); + + $existingSecrets = config('mollie.webhooks.signing_secrets'); + + note( + 'MOLLIE_WEBHOOK_SIGNING_SECRETS='. + str($existingSecrets) + ->whenNotEmpty(fn ($str) => $str->append(',')) + ->append($webhook->webhookSecret) + ); + } + + private function suggestManualCreation(): void + { + info('Otherwise, you can create the webhook manually in the Mollie dashboard.'); + info('The webhook path is: '.config('mollie.webhooks.path')); + + note('Make sure to append the webhook secret to the .env file: MOLLIE_WEBHOOK_SIGNING_SECRETS=...'); + } } diff --git a/src/Controllers/HandleIncomingWebhook.php b/src/Controllers/HandleIncomingWebhook.php index 2994ade..b21898f 100644 --- a/src/Controllers/HandleIncomingWebhook.php +++ b/src/Controllers/HandleIncomingWebhook.php @@ -8,7 +8,6 @@ use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Mollie\Api\Webhooks\WebhookEventMapper; -use Mollie\Laravel\SignatureValidator; use Mollie\Laravel\Contracts\WebhookDispatcher; use Mollie\Api\Webhooks\Events\BaseEvent; @@ -16,14 +15,11 @@ class HandleIncomingWebhook extends Controller { public function __invoke( Request $request, - SignatureValidator $validator, WebhookEventMapper $eventMapper, WebhookDispatcher $dispatcher ): JsonResponse { - $validator->validate($request); - /** @var BaseEvent $event */ - $event = $eventMapper->processPayload($request->getParsedBody()); + $event = $eventMapper->processPayload($request->toArray()); $dispatcher->dispatch($event); diff --git a/src/Facades/Mollie.php b/src/Facades/Mollie.php index b4db421..b6cac4b 100644 --- a/src/Facades/Mollie.php +++ b/src/Facades/Mollie.php @@ -11,7 +11,7 @@ /** * (Facade) Class Mollie. * - * @method static void assertSent(string $class) + * @method static void assertSent(callable|string $callback) * @method static void assertSentCount(int $count) * * @see \Mollie\Api\Fake\MockMollieClient diff --git a/src/Middleware/ValidatesWebhookSignatures.php b/src/Middleware/ValidatesWebhookSignatures.php index b69e42f..a93b4f6 100644 --- a/src/Middleware/ValidatesWebhookSignatures.php +++ b/src/Middleware/ValidatesWebhookSignatures.php @@ -16,10 +16,6 @@ public function __construct( public function handle(Request $request, Closure $next) { - if (! config('mollie.webhooks.enabled')) { - return $next($request); - } - $this->validator->validate($request); return $next($request); diff --git a/src/MollieServiceProvider.php b/src/MollieServiceProvider.php index 1315360..cb49fdc 100644 --- a/src/MollieServiceProvider.php +++ b/src/MollieServiceProvider.php @@ -5,25 +5,18 @@ namespace Mollie\Laravel; use Illuminate\Contracts\Container\Container; +use Illuminate\Contracts\Support\DeferrableProvider; use Illuminate\Support\ServiceProvider; use Mollie\Api\MollieApiClient; use Mollie\Api\Webhooks\SignatureValidator; use RuntimeException; use Mollie\Laravel\Contracts\WebhookDispatcher; -use Mollie\Laravel\Commands\RevealWebhookPathCommand; use Mollie\Laravel\Commands\SetupWebhookCommand; -class MollieServiceProvider extends ServiceProvider +class MollieServiceProvider extends ServiceProvider implements DeferrableProvider { const PACKAGE_VERSION = '4.0.0'; - /** - * Indicates if loading of the provider is deferred. - * - * @var bool - */ - protected $defer = true; - /** * Boot the service provider. * @@ -38,7 +31,6 @@ public function boot() $this->commands([ SetupWebhookCommand::class, - RevealWebhookPathCommand::class, ]); } } @@ -52,7 +44,6 @@ public function provides() { return [ SetupWebhookCommand::class, - RevealWebhookPathCommand::class, MollieApiClient::class, WebhookDispatcher::class, ]; @@ -85,8 +76,8 @@ function (Container $app) { $this->app->bind(SignatureValidator::class, function (Container $app) { throw_if( - config('mollie.webhooks.enabled') && ! config('mollie.webhooks.signing_secrets'), - new RuntimeException('Webhooks are enabled but no signing secrets are set') + ! config('mollie.webhooks.signing_secrets'), + new RuntimeException('No signing secrets for Mollie webhooks are set') ); return new SignatureValidator(config('mollie.webhooks.signing_secrets')); diff --git a/src/SignatureValidator.php b/src/SignatureValidator.php index 5cfb134..7cc43bf 100644 --- a/src/SignatureValidator.php +++ b/src/SignatureValidator.php @@ -15,7 +15,7 @@ public function __construct(BaseSignatureValidator $validator) $this->validator = $validator; } - public function validate(Request $request): void + public function validate(Request $request): self { $body = (string) $request->getContent(); $signatures = $request->header(BaseSignatureValidator::SIGNATURE_HEADER, ''); @@ -30,6 +30,13 @@ public function validate(Request $request): void } $this->abortIfLegacyWebhookIsDisabled($isLegacyWebhook); + + return $this; + } + + public function hasNoSignature(Request $request): bool + { + return ! $request->hasHeader(BaseSignatureValidator::SIGNATURE_HEADER); } /** diff --git a/tests/Commands/SetupWebhookCommandTest.php b/tests/Commands/SetupWebhookCommandTest.php index 4db1ce6..0a14c66 100644 --- a/tests/Commands/SetupWebhookCommandTest.php +++ b/tests/Commands/SetupWebhookCommandTest.php @@ -4,24 +4,34 @@ namespace Mollie\Laravel\Tests\Commands; -use Illuminate\Support\Facades\Http; -use Mollie\Api\Exceptions\ApiException; +use Illuminate\Support\Arr; use Mollie\Api\Http\Requests\CreateWebhookRequest; -use Mollie\Api\MollieApiClient; use Mollie\Api\Webhooks\WebhookEventType; use Mollie\Laravel\Commands\SetupWebhookCommand; use Mollie\Laravel\Facades\Mollie; use Mollie\Laravel\Tests\TestCase; use Mollie\Api\Fake\MockResponse; +use Mollie\Api\Http\PendingRequest; +use Mollie\Api\Http\Requests\ListPermissionsRequest; +use Mollie\Api\Resources\PermissionCollection; use Mollie\Api\Resources\Webhook; use PHPUnit\Framework\Attributes\Test; class SetupWebhookCommandTest extends TestCase { #[Test] - public function it_can_setup_a_webhook() + public function it_can_setup_a_webhook_automatically() { + config(['mollie.key' => 'access_xxxxxxxxxxxxxxxxxxxxxxxxxxxxyz']); + Mollie::fake([ + ListPermissionsRequest::class => MockResponse::list(PermissionCollection::class) + ->add([ + 'id' => 'webhooks.write', + 'description' => 'Write webhooks', + 'granted' => true, + ]) + ->create(), CreateWebhookRequest::class => MockResponse::resource(Webhook::class) ->with([ 'id' => 'webhook_123', @@ -36,10 +46,270 @@ public function it_can_setup_a_webhook() ->expectsQuestion('Events', [WebhookEventType::ALL]) ->expectsQuestion('Testmode', 'yes') ->expectsConfirmation('Proceed with setup?', 'yes') + ->expectsQuestion('Press ENTER to continue', 'yes') ->expectsOutputToContain('Webhook created successfully') ->expectsOutputToContain('🤫 Add this secret to your .env file: secret_123') ->assertSuccessful(); Mollie::assertSent(CreateWebhookRequest::class); + Mollie::assertSent(ListPermissionsRequest::class); + } + + #[Test] + public function it_can_setup_webhook_with_payment_link_event() + { + config(['mollie.key' => 'access_xxxxxxxxxxxxxxxxxxxxxxxxxxxxyz']); + + Mollie::fake([ + ListPermissionsRequest::class => MockResponse::list(PermissionCollection::class) + ->add([ + 'id' => 'webhooks.write', + 'description' => 'Write webhooks', + 'granted' => true, + ]) + ->create(), + CreateWebhookRequest::class => function (PendingRequest $pendingRequest) { + $this->assertEquals(WebhookEventType::PAYMENT_LINK_PAID, $pendingRequest->payload()->get('eventTypes')); + + return MockResponse::resource(Webhook::class) + ->with([ + 'id' => 'webhook_payment_link', + 'webhookSecret' => 'secret_payment_link', + ]) + ->create(); + }, + ]); + + $this->artisan(SetupWebhookCommand::class) + ->expectsQuestion('Name', 'Payment Link Webhook') + ->expectsQuestion('Url', 'https://test.com/webhook') + ->expectsQuestion('Events', [WebhookEventType::PAYMENT_LINK_PAID]) + ->expectsQuestion('Testmode', 'yes') + ->expectsConfirmation('Proceed with setup?', 'yes') + ->expectsQuestion('Press ENTER to continue', 'yes') + ->expectsOutputToContain('Webhook created successfully') + ->expectsOutputToContain('secret_payment_link') + ->assertSuccessful(); + + Mollie::assertSent(CreateWebhookRequest::class); + } + + #[Test] + public function it_suggests_manual_creation_when_no_access_token_is_provided() + { + config([ + 'mollie.key' => 'test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxyz', + ]); + + $this->artisan(SetupWebhookCommand::class) + ->expectsQuestion('Name', 'Test Webhook') + ->expectsQuestion('Url', 'https://test.com/webhook') + ->expectsQuestion('Events', [WebhookEventType::ALL]) + ->expectsQuestion('Testmode', 'yes') + ->expectsConfirmation('Proceed with setup?', 'yes') + ->expectsOutputToContain('To automatically create the webhook, you need to provide an access token') + ->expectsOutputToContain('you can create the webhook manually in the Mollie dashboard') + ->assertSuccessful(); + } + + #[Test] + public function it_suggests_manual_creation_when_missing_webhook_write_permission() + { + config(['mollie.key' => 'access_xxxxxxxxxxxxxxxxxxxxxxxxxxxxyz']); + + Mollie::fake([ + ListPermissionsRequest::class => MockResponse::list(PermissionCollection::class) + ->add([ + 'id' => 'payments.read', + 'description' => 'Read payments', + 'granted' => true, + ]) + ->create(), + ]); + + $this->artisan(SetupWebhookCommand::class) + ->expectsQuestion('Name', 'Test Webhook') + ->expectsQuestion('Url', 'https://test.com/webhook') + ->expectsQuestion('Events', [WebhookEventType::ALL]) + ->expectsQuestion('Testmode', 'yes') + ->expectsConfirmation('Proceed with setup?', 'yes') + ->expectsOutputToContain('You do not have permission to create webhooks') + ->expectsOutputToContain('create an access token with the permission of webhooks.write') + ->expectsOutputToContain('you can create the webhook manually in the Mollie dashboard') + ->assertSuccessful(); + + Mollie::assertSent(ListPermissionsRequest::class); + } + + #[Test] + public function it_can_setup_webhook_in_live_mode() + { + config(['mollie.key' => 'access_xxxxxxxxxxxxxxxxxxxxxxxxxxxxyz']); + + Mollie::fake([ + ListPermissionsRequest::class => MockResponse::list(PermissionCollection::class) + ->add([ + 'id' => 'webhooks.write', + 'description' => 'Write webhooks', + 'granted' => true, + ]) + ->create(), + CreateWebhookRequest::class => function (PendingRequest $pendingRequest) { + $this->assertEquals($pendingRequest->getTestmode(), false); + + return MockResponse::resource(Webhook::class) + ->with([ + 'id' => 'webhook_live_123', + 'webhookSecret' => 'secret_live_123', + ]) + ->create(); + }, + ]); + + $this->artisan(SetupWebhookCommand::class) + ->expectsQuestion('Name', 'Production Webhook') + ->expectsQuestion('Url', 'https://production.com/webhook') + ->expectsQuestion('Events', [WebhookEventType::ALL]) + ->expectsQuestion('Testmode', 'no') + ->expectsConfirmation('Proceed with setup?', 'yes') + ->expectsQuestion('Press ENTER to continue', 'yes') + ->expectsOutputToContain('Webhook created successfully') + ->expectsOutputToContain('🤫 Add this secret to your .env file: secret_live_123') + ->assertSuccessful(); + + Mollie::assertSent(CreateWebhookRequest::class); + } + + #[Test] + public function it_can_setup_webhook_with_specific_event_types() + { + config(['mollie.key' => 'access_xxxxxxxxxxxxxxxxxxxxxxxxxxxxyz']); + + $eventTypes = [ + WebhookEventType::SALES_INVOICE_CREATED, + WebhookEventType::SALES_INVOICE_PAID, + WebhookEventType::BALANCE_TRANSACTION_CREATED, + ]; + + Mollie::fake([ + ListPermissionsRequest::class => MockResponse::list(PermissionCollection::class) + ->add([ + 'id' => 'webhooks.write', + 'description' => 'Write webhooks', + 'granted' => true, + ]) + ->create(), + CreateWebhookRequest::class => function (PendingRequest $pendingRequest) use ($eventTypes) { + $this->assertEquals(Arr::join($eventTypes, ','), $pendingRequest->payload()->get('eventTypes')); + + return MockResponse::resource(Webhook::class) + ->with([ + 'id' => 'webhook_456', + 'webhookSecret' => 'secret_456', + ]) + ->create(); + }, + ]); + + $this->artisan(SetupWebhookCommand::class) + ->expectsQuestion('Name', 'Invoice Webhook') + ->expectsQuestion('Url', 'https://test.com/webhook') + ->expectsQuestion('Events', $eventTypes) + ->expectsQuestion('Testmode', 'yes') + ->expectsConfirmation('Proceed with setup?', 'yes') + ->expectsQuestion('Press ENTER to continue', 'yes') + ->expectsOutputToContain('Webhook created successfully') + ->assertSuccessful(); + + Mollie::assertSent(CreateWebhookRequest::class); + } + + #[Test] + public function it_appends_secret_to_existing_secrets_in_output() + { + config([ + 'mollie.key' => 'access_xxxxxxxxxxxxxxxxxxxxxxxxxxxxyz', + 'mollie.webhooks.signing_secrets' => 'existing_secret_1,existing_secret_2', + ]); + + Mollie::fake([ + ListPermissionsRequest::class => MockResponse::list(PermissionCollection::class) + ->add([ + 'id' => 'webhooks.write', + 'description' => 'Write webhooks', + 'granted' => true, + ]) + ->create(), + CreateWebhookRequest::class => MockResponse::resource(Webhook::class) + ->with([ + 'id' => 'webhook_789', + 'webhookSecret' => 'secret_new_789', + ]) + ->create(), + ]); + + $this->artisan(SetupWebhookCommand::class) + ->expectsQuestion('Name', 'Additional Webhook') + ->expectsQuestion('Url', 'https://test.com/webhook') + ->expectsQuestion('Events', [WebhookEventType::ALL]) + ->expectsQuestion('Testmode', 'yes') + ->expectsConfirmation('Proceed with setup?', 'yes') + ->expectsQuestion('Press ENTER to continue', 'yes') + ->expectsOutputToContain('MOLLIE_WEBHOOK_SIGNING_SECRETS=existing_secret_1,existing_secret_2,secret_new_789') + ->assertSuccessful(); + } + + #[Test] + public function it_displays_correct_format_for_first_secret() + { + config([ + 'mollie.key' => 'access_xxxxxxxxxxxxxxxxxxxxxxxxxxxxyz', + 'mollie.webhooks.signing_secrets' => '', + ]); + + Mollie::fake([ + ListPermissionsRequest::class => MockResponse::list(PermissionCollection::class) + ->add([ + 'id' => 'webhooks.write', + 'description' => 'Write webhooks', + 'granted' => true, + ]) + ->create(), + CreateWebhookRequest::class => MockResponse::resource(Webhook::class) + ->with([ + 'id' => 'webhook_first', + 'webhookSecret' => 'secret_first', + ]) + ->create(), + ]); + + $this->artisan(SetupWebhookCommand::class) + ->expectsQuestion('Name', 'First Webhook') + ->expectsQuestion('Url', 'https://test.com/webhook') + ->expectsQuestion('Events', [WebhookEventType::ALL]) + ->expectsQuestion('Testmode', 'yes') + ->expectsConfirmation('Proceed with setup?', 'yes') + ->expectsQuestion('Press ENTER to continue', 'yes') + ->expectsOutputToContain('MOLLIE_WEBHOOK_SIGNING_SECRETS=secret_first') + ->assertSuccessful(); + } + + #[Test] + public function it_displays_webhook_path_in_manual_creation_message() + { + config([ + 'mollie.key' => 'test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxyz', + ]); + + $this->artisan(SetupWebhookCommand::class) + ->expectsQuestion('Name', 'Test Webhook') + ->expectsQuestion('Url', 'https://test.com/webhook') + ->expectsQuestion('Events', [WebhookEventType::ALL]) + ->expectsQuestion('Testmode', 'yes') + ->expectsConfirmation('Proceed with setup?', 'yes') + ->expectsOutputToContain('To automatically create the webhook, you need to provide an access token') + ->expectsOutputToContain('you can create the webhook manually in the Mollie dashboard') + ->expectsOutputToContain('The webhook path is: ' . config('mollie.webhooks.path')) + ->assertSuccessful(); } } diff --git a/tests/Controllers/HandleIncomingWebhookTest.php b/tests/Controllers/HandleIncomingWebhookTest.php new file mode 100644 index 0000000..d65f32f --- /dev/null +++ b/tests/Controllers/HandleIncomingWebhookTest.php @@ -0,0 +1,35 @@ + 'test_secret']); + + Event::fake(); + + $webhookPayload = MockEvent::for(PaymentLinkPaid::class) + ->snapshot() + ->create(); + + $this + ->withHeader( + SignatureValidator::SIGNATURE_HEADER, + SignatureValidator::createSignature(json_encode($webhookPayload), config('mollie.webhooks.signing_secrets')) + ) + ->postJson(route('mollie.webhooks'), $webhookPayload) + ->assertSuccessful(); + + Event::assertDispatched(PaymentLinkPaid::class); + } +} diff --git a/tests/Middleware/ValidatesWebhookSignaturesTest.php b/tests/Middleware/ValidatesWebhookSignaturesTest.php deleted file mode 100644 index 4f73f8e..0000000 --- a/tests/Middleware/ValidatesWebhookSignaturesTest.php +++ /dev/null @@ -1,29 +0,0 @@ - false]); - - $request = Request::create('/webhook', 'POST'); - - $middleware = resolve(ValidatesWebhookSignatures::class); - - $middleware->handle($request, function () { - return response('OK'); - }); - - $this->assertTrue(true); - } -} diff --git a/tests/SignatureValidatorTest.php b/tests/SignatureValidatorTest.php index 33b2b5f..61ab4bf 100644 --- a/tests/SignatureValidatorTest.php +++ b/tests/SignatureValidatorTest.php @@ -15,10 +15,6 @@ class SignatureValidatorTest extends TestCase #[Test] public function it_validates_signature_with_valid_secret() { - config([ - 'mollie.webhooks.legacy_webhook_enabled' => false, - ]); - $body = '{"id":"payment_123"}'; $request = $this->createRequestWithSignature($body, 'valid_secret'); From 03150509bf9547cfde129bc33746101ce10ae700 Mon Sep 17 00:00:00 2001 From: Naoray Date: Thu, 16 Oct 2025 10:18:12 +0000 Subject: [PATCH 23/27] Fix styling --- config/mollie.php | 2 +- src/Commands/SetupWebhookCommand.php | 29 ++++++++++---------- src/Controllers/HandleIncomingWebhook.php | 2 +- src/Facades/Mollie.php | 2 +- src/MollieServiceProvider.php | 6 ++-- tests/Commands/SetupWebhookCommandTest.php | 10 +++---- tests/MollieApiClientTest.php | 2 +- tests/MollieLaravelHttpClientAdapterTest.php | 4 +-- tests/SignatureValidatorTest.php | 2 +- 9 files changed, 30 insertions(+), 29 deletions(-) diff --git a/config/mollie.php b/config/mollie.php index 6c7e57f..36749d9 100644 --- a/config/mollie.php +++ b/config/mollie.php @@ -28,7 +28,7 @@ /** * The middleware to use for incoming webhook requests. */ - 'middleware' => [ + 'middleware' => [ ValidatesWebhookSignatures::class, ], diff --git a/src/Commands/SetupWebhookCommand.php b/src/Commands/SetupWebhookCommand.php index f267751..84ea367 100644 --- a/src/Commands/SetupWebhookCommand.php +++ b/src/Commands/SetupWebhookCommand.php @@ -4,14 +4,6 @@ use Illuminate\Console\Command; use Illuminate\Support\Arr; -use Mollie\Api\Exceptions\MollieException; -use Mollie\Api\Http\Auth\TokenValidator; -use Mollie\Api\Http\Requests\CreateWebhookRequest; -use Mollie\Api\Http\Requests\ListPermissionsRequest; -use Mollie\Api\MollieApiClient; -use Mollie\Api\Resources\Permission; -use Mollie\Api\Resources\Webhook; -use Mollie\Api\Webhooks\WebhookEventType; use function Laravel\Prompts\clear; use function Laravel\Prompts\confirm; @@ -24,6 +16,15 @@ use function Laravel\Prompts\table; use function Laravel\Prompts\warning; +use Mollie\Api\Exceptions\MollieException; +use Mollie\Api\Http\Auth\TokenValidator; +use Mollie\Api\Http\Requests\CreateWebhookRequest; +use Mollie\Api\Http\Requests\ListPermissionsRequest; +use Mollie\Api\MollieApiClient; +use Mollie\Api\Resources\Permission; +use Mollie\Api\Resources\Webhook; +use Mollie\Api\Webhooks\WebhookEventType; + class SetupWebhookCommand extends Command { protected $signature = 'mollie:setup-webhook'; @@ -111,7 +112,7 @@ private function confirmDetails(array $responses): bool table( headers: ['Name', 'Url', 'Events', 'Testmode'], rows: [ - ['name' => $responses['name'], 'url' => $responses['url'], 'events' => Arr::join($responses['events'], ', '), 'testmode' => $responses['testmode']] + ['name' => $responses['name'], 'url' => $responses['url'], 'events' => Arr::join($responses['events'], ', '), 'testmode' => $responses['testmode']], ] ); @@ -129,7 +130,7 @@ private function hasValidAccessToken(): bool private function hasWebhookWritePermission(MollieApiClient $mollie): bool { - return $mollie->send(new ListPermissionsRequest()) + return $mollie->send(new ListPermissionsRequest) ->contains(function (Permission $permission) { return $permission->id === 'webhooks.write' && $permission->granted; @@ -150,7 +151,7 @@ private function createWebhook(MollieApiClient $mollie, array $webhookDetails): message: 'Creating webhook...' ); } catch (MollieException $e) { - error('Failed to create webhook: '.$e->getMessage()); + error('Failed to create webhook: ' . $e->getMessage()); return null; } @@ -159,12 +160,12 @@ private function createWebhook(MollieApiClient $mollie, array $webhookDetails): private function displaySuccess(Webhook $webhook): void { info('Webhook created successfully'); - note('🤫 Add this secret to your .env file: '.$webhook->webhookSecret); + note('🤫 Add this secret to your .env file: ' . $webhook->webhookSecret); $existingSecrets = config('mollie.webhooks.signing_secrets'); note( - 'MOLLIE_WEBHOOK_SIGNING_SECRETS='. + 'MOLLIE_WEBHOOK_SIGNING_SECRETS=' . str($existingSecrets) ->whenNotEmpty(fn ($str) => $str->append(',')) ->append($webhook->webhookSecret) @@ -174,7 +175,7 @@ private function displaySuccess(Webhook $webhook): void private function suggestManualCreation(): void { info('Otherwise, you can create the webhook manually in the Mollie dashboard.'); - info('The webhook path is: '.config('mollie.webhooks.path')); + info('The webhook path is: ' . config('mollie.webhooks.path')); note('Make sure to append the webhook secret to the .env file: MOLLIE_WEBHOOK_SIGNING_SECRETS=...'); } diff --git a/src/Controllers/HandleIncomingWebhook.php b/src/Controllers/HandleIncomingWebhook.php index b21898f..d2b067f 100644 --- a/src/Controllers/HandleIncomingWebhook.php +++ b/src/Controllers/HandleIncomingWebhook.php @@ -7,9 +7,9 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller; +use Mollie\Api\Webhooks\Events\BaseEvent; use Mollie\Api\Webhooks\WebhookEventMapper; use Mollie\Laravel\Contracts\WebhookDispatcher; -use Mollie\Api\Webhooks\Events\BaseEvent; class HandleIncomingWebhook extends Controller { diff --git a/src/Facades/Mollie.php b/src/Facades/Mollie.php index b6cac4b..00e09b8 100644 --- a/src/Facades/Mollie.php +++ b/src/Facades/Mollie.php @@ -5,8 +5,8 @@ namespace Mollie\Laravel\Facades; use Illuminate\Support\Facades\Facade; -use Mollie\Api\MollieApiClient; use Mollie\Api\Fake\MockMollieClient; +use Mollie\Api\MollieApiClient; /** * (Facade) Class Mollie. diff --git a/src/MollieServiceProvider.php b/src/MollieServiceProvider.php index cb49fdc..7965b36 100644 --- a/src/MollieServiceProvider.php +++ b/src/MollieServiceProvider.php @@ -9,9 +9,9 @@ use Illuminate\Support\ServiceProvider; use Mollie\Api\MollieApiClient; use Mollie\Api\Webhooks\SignatureValidator; -use RuntimeException; -use Mollie\Laravel\Contracts\WebhookDispatcher; use Mollie\Laravel\Commands\SetupWebhookCommand; +use Mollie\Laravel\Contracts\WebhookDispatcher; +use RuntimeException; class MollieServiceProvider extends ServiceProvider implements DeferrableProvider { @@ -24,7 +24,7 @@ class MollieServiceProvider extends ServiceProvider implements DeferrableProvide */ public function boot() { - $this->loadRoutesFrom(__DIR__.'/../routes/api.php'); + $this->loadRoutesFrom(__DIR__ . '/../routes/api.php'); if ($this->app->runningInConsole()) { $this->publishes([__DIR__ . '/../config/mollie.php' => config_path('mollie.php')]); diff --git a/tests/Commands/SetupWebhookCommandTest.php b/tests/Commands/SetupWebhookCommandTest.php index 0a14c66..5cd74e0 100644 --- a/tests/Commands/SetupWebhookCommandTest.php +++ b/tests/Commands/SetupWebhookCommandTest.php @@ -5,16 +5,16 @@ namespace Mollie\Laravel\Tests\Commands; use Illuminate\Support\Arr; -use Mollie\Api\Http\Requests\CreateWebhookRequest; -use Mollie\Api\Webhooks\WebhookEventType; -use Mollie\Laravel\Commands\SetupWebhookCommand; -use Mollie\Laravel\Facades\Mollie; -use Mollie\Laravel\Tests\TestCase; use Mollie\Api\Fake\MockResponse; use Mollie\Api\Http\PendingRequest; +use Mollie\Api\Http\Requests\CreateWebhookRequest; use Mollie\Api\Http\Requests\ListPermissionsRequest; use Mollie\Api\Resources\PermissionCollection; use Mollie\Api\Resources\Webhook; +use Mollie\Api\Webhooks\WebhookEventType; +use Mollie\Laravel\Commands\SetupWebhookCommand; +use Mollie\Laravel\Facades\Mollie; +use Mollie\Laravel\Tests\TestCase; use PHPUnit\Framework\Attributes\Test; class SetupWebhookCommandTest extends TestCase diff --git a/tests/MollieApiClientTest.php b/tests/MollieApiClientTest.php index a368b04..e5ed742 100644 --- a/tests/MollieApiClientTest.php +++ b/tests/MollieApiClientTest.php @@ -8,8 +8,8 @@ use Mollie\Api\Http\Auth\ApiKeyAuthenticator; use Mollie\Api\MollieApiClient; use Mollie\Laravel\MollieLaravelHttpClientAdapter; -use ReflectionClass; use PHPUnit\Framework\Attributes\Test; +use ReflectionClass; class MollieApiClientTest extends TestCase { diff --git a/tests/MollieLaravelHttpClientAdapterTest.php b/tests/MollieLaravelHttpClientAdapterTest.php index dddae12..a300c57 100644 --- a/tests/MollieLaravelHttpClientAdapterTest.php +++ b/tests/MollieLaravelHttpClientAdapterTest.php @@ -8,11 +8,11 @@ use Mollie\Api\Exceptions\ApiException; use Mollie\Api\Exceptions\RetryableNetworkRequestException; use Mollie\Api\Fake\MockResponse; +use Mollie\Api\Http\Data\Money; +use Mollie\Api\Http\LinearRetryStrategy; use Mollie\Api\Http\PendingRequest; use Mollie\Api\Http\Requests\CreatePaymentRequest; use Mollie\Api\Http\Requests\GetPaymentRequest; -use Mollie\Api\Http\Data\Money; -use Mollie\Api\Http\LinearRetryStrategy; use Mollie\Api\Resources\Payment; use Mollie\Laravel\Facades\Mollie; use PHPUnit\Framework\Attributes\Test; diff --git a/tests/SignatureValidatorTest.php b/tests/SignatureValidatorTest.php index 61ab4bf..49e6510 100644 --- a/tests/SignatureValidatorTest.php +++ b/tests/SignatureValidatorTest.php @@ -7,8 +7,8 @@ use Illuminate\Http\Request; use Mollie\Api\Webhooks\SignatureValidator as BaseSignatureValidator; use Mollie\Laravel\SignatureValidator; -use Symfony\Component\HttpKernel\Exception\HttpException; use PHPUnit\Framework\Attributes\Test; +use Symfony\Component\HttpKernel\Exception\HttpException; class SignatureValidatorTest extends TestCase { From 4327722289367efe66a6a20ef403eb1c654a6cf3 Mon Sep 17 00:00:00 2001 From: Krishan Koenig Date: Thu, 16 Oct 2025 12:19:40 +0200 Subject: [PATCH 24/27] chore: add compatible phpunit dep for php8.2 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 97ccfd3..9d8d57a 100644 --- a/composer.json +++ b/composer.json @@ -51,7 +51,7 @@ "graham-campbell/testbench": "^6.2", "laravel/pint": "^1.25", "laravel/socialite": "^5.23", - "phpunit/phpunit": "^12.0" + "phpunit/phpunit": "^11.0|^12.0" }, "suggest": { "laravel/socialite": "Use Mollie Connect (OAuth) to authenticate via Laravel Socialite with the Mollie API. This is needed for some endpoints." From ec7c1814832c561b8cca4f2716d396c54e3aa616 Mon Sep 17 00:00:00 2001 From: Krishan Koenig Date: Thu, 16 Oct 2025 12:40:55 +0200 Subject: [PATCH 25/27] wip --- src/MollieServiceProvider.php | 2 +- tests/Controllers/HandleIncomingWebhookTest.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/MollieServiceProvider.php b/src/MollieServiceProvider.php index 7965b36..86c9a7c 100644 --- a/src/MollieServiceProvider.php +++ b/src/MollieServiceProvider.php @@ -74,7 +74,7 @@ function (Container $app) { } ); - $this->app->bind(SignatureValidator::class, function (Container $app) { + $this->app->singleton(SignatureValidator::class, function (Container $app) { throw_if( ! config('mollie.webhooks.signing_secrets'), new RuntimeException('No signing secrets for Mollie webhooks are set') diff --git a/tests/Controllers/HandleIncomingWebhookTest.php b/tests/Controllers/HandleIncomingWebhookTest.php index d65f32f..745d093 100644 --- a/tests/Controllers/HandleIncomingWebhookTest.php +++ b/tests/Controllers/HandleIncomingWebhookTest.php @@ -14,6 +14,7 @@ class HandleIncomingWebhookTest extends TestCase #[Test] public function it_can_handle_incoming_webhook() { + $this->withoutExceptionHandling(); config(['mollie.webhooks.signing_secrets' => 'test_secret']); Event::fake(); From d82462ff37216b53055bf1831a01478cdfd7898b Mon Sep 17 00:00:00 2001 From: Krishan Koenig Date: Thu, 16 Oct 2025 13:00:56 +0200 Subject: [PATCH 26/27] chore: downgrade to phpunit 11 as graham-campbell/testbench does not support v12 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 9d8d57a..4df54a9 100644 --- a/composer.json +++ b/composer.json @@ -51,7 +51,7 @@ "graham-campbell/testbench": "^6.2", "laravel/pint": "^1.25", "laravel/socialite": "^5.23", - "phpunit/phpunit": "^11.0|^12.0" + "phpunit/phpunit": "^11.0" }, "suggest": { "laravel/socialite": "Use Mollie Connect (OAuth) to authenticate via Laravel Socialite with the Mollie API. This is needed for some endpoints." From 95cb9f284cba0b653708210b2e422fecce926d6d Mon Sep 17 00:00:00 2001 From: Krishan Koenig Date: Thu, 16 Oct 2025 13:15:12 +0200 Subject: [PATCH 27/27] chore: fix laravel v11 config behaviour in tests --- src/MollieServiceProvider.php | 2 +- tests/TestCase.php | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/MollieServiceProvider.php b/src/MollieServiceProvider.php index 86c9a7c..855af73 100644 --- a/src/MollieServiceProvider.php +++ b/src/MollieServiceProvider.php @@ -84,7 +84,7 @@ function (Container $app) { }); $this->app->bind(WebhookDispatcher::class, function (Container $app) { - return $app->make(config('mollie.webhooks.dispatcher')); + return $app->make(config('mollie.webhooks.dispatcher') ?? EventWebhookDispatcher::class); }); } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 9dab28c..0868976 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -4,6 +4,8 @@ namespace Mollie\Laravel\Tests; +use Mollie\Laravel\EventWebhookDispatcher; +use Mollie\Laravel\Middleware\ValidatesWebhookSignatures; use Mollie\Laravel\MollieServiceProvider; /** @@ -36,5 +38,10 @@ protected function getPackageProviders($app) protected function getEnvironmentSetUp($app) { $app['config']->set('mollie.webhooks.enabled', true); + $app['config']->set('mollie.webhooks.path', '/webhooks/mollie'); + $app['config']->set('mollie.webhooks.middleware', [ValidatesWebhookSignatures::class]); + $app['config']->set('mollie.webhooks.dispatcher', EventWebhookDispatcher::class); + $app['config']->set('mollie.webhooks.signing_secrets', 'test_secret'); + $app['config']->set('mollie.webhooks.legacy_webhook_enabled', false); } }