diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..b18d628 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,54 @@ +# Receiver – Copilot Instructions + +## Commands + +```bash +# Run all tests +composer test +# or +vendor/bin/phpunit + +# Run a single test file +vendor/bin/phpunit tests/GithubProviderTest.php + +# Run a single test method +vendor/bin/phpunit --filter test_it_can_receive_github_webhook + +# Fix code style +vendor/bin/php-cs-fixer fix +``` + +## Architecture + +Receiver is a Laravel package that provides a driver-based webhook handling pipeline. + +**Entry point:** `Receiver::driver('stripe')->receive($request)->ok()` + +**Driver resolution:** `ReceiverManager` extends Laravel's `Manager` class. Each built-in driver has a corresponding `create{Name}Driver()` method that reads `services.{driver}.webhook_secret` from config and instantiates the provider. + +**Provider lifecycle (in `AbstractProvider::receive()`):** +1. `handshake()` – if defined, runs first; returning a truthy value short-circuits handling and sends that as the response +2. `verify()` – if defined, returns `false` → 401 abort +3. `mapWebhook()` – builds a `Webhook` object from `getEvent()` and `getData()` +4. `handle()` – resolves and dispatches a handler class + +**Handler class resolution:** `\App\Http\Handlers\{DriverName}\{EventClassName}` +- `{DriverName}` = provider class basename with `Provider` stripped (e.g., `GithubProvider` → `Github`) +- `{EventClassName}` = event string with non-alphanumeric chars replaced by spaces, then converted to StudlyCase (e.g., `customer.created` → `CustomerCreated`) +- GitHub is a special case: event = `{X-GitHub-Event}_{action}` (e.g., `issues_opened`) + +**Handlers** must use the `Dispatchable` trait and accept `(string $event, array $data)` in the constructor. Implement `ShouldQueue` to queue them. + +**Custom providers** are registered via `app('receiver')->extend('name', fn($app) => new MyProvider($secret))` in a service provider. Use `php artisan receiver:make ` (or `--verified`) to scaffold. + +## Key Conventions + +**Config key for secrets:** Always `services.{driver}.webhook_secret` — see `ReceiverManager::buildProvider()`. + +**`FakeProvider`** is available as the `fake` driver for testing purposes. + +**Testing approach:** Tests use [Orchestra Testbench](https://github.com/orchestral/testbench). The base `TestCase` extends `\Orchestra\Testbench\TestCase` and registers `ReceiverServiceProvider`. Use `Mockery` to mock `Request` objects. To test handler dispatch in isolation, call `$provider->setHandlerNamespace('Your\\Test\\Namespace')` before `receive()`. + +**Fixture handlers** in `tests/Fixtures/` use `Log::info('Webhook handled.')` to signal that dispatch occurred — assert with `Log::partialMock()->shouldReceive('info')->withArgs(['Webhook handled.'])`. + +**Code style:** PHP CS Fixer with the config in `.php-cs-fixer.php`. Notable rules: single quotes, alpha-sorted imports (`ordered_imports`), trailing commas in multiline structures, blank line before `return`. diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index b9d68bd..cd78eab 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -1,4 +1,4 @@ -name: Tests +name: CI on: push: @@ -7,54 +7,96 @@ on: branches: [ main ] jobs: - build: + lint: + name: Lint (Pint) runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Check code style + run: composer run-script lint:check + + analyse: + name: Analyse (PHPStan) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + coverage: none + + - name: Install dependencies + run: composer install --prefer-dist --no-interaction --no-progress + + - name: Run PHPStan + run: composer run-script analyse + + tests: + name: P${{ matrix.php }} - L${{ matrix.laravel }} + runs-on: ubuntu-latest + strategy: - fail-fast: true + fail-fast: false matrix: - laravel: [ 10.*, 11.* ] - php: [ 8.2, 8.3 ] + laravel: [ '10.*', '11.*', '12.*', '13.*' ] + php: [ '8.2', '8.3', '8.4' ] include: - - laravel: 10.* - testbench: 8.* - - laravel: 11.* - testbench: 9.* - - laravel: 12.* - testbench: 10.* - - name: P${{ matrix.php }} - L${{ matrix.laravel }} + - laravel: '10.*' + testbench: '8.*' + - laravel: '11.*' + testbench: '9.*' + - laravel: '12.*' + testbench: '10.*' + - laravel: '13.*' + testbench: '11.*' + exclude: + # Laravel 10 was released before PHP 8.4 + - laravel: '10.*' + php: '8.4' + # Laravel 13 requires PHP ^8.3 + - laravel: '13.*' + php: '8.2' steps: - - uses: actions/checkout@v4 - - - name: Validate composer.json and composer.lock - run: composer validate - - - name: Cache Composer packages - id: composer-cache - uses: actions/cache@v4 - with: - path: vendor - key: ${{ runner.os }}-${{ matrix.php }}--${{ matrix.laravel }}--node-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-${{ matrix.php }}--${{ matrix.laravel }}-node- - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - extensions: dom, fileinfo, libxml, mbstring - coverage: none - - - name: Install dependencies - if: steps.composer-cache.outputs.cache-hit != 'true' - run: | - composer require "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update - composer update --prefer-dist --no-progress --no-suggest - - # Add a test script to composer.json, for instance: "test": "vendor/bin/phpunit" - # Docs: https://getcomposer.org/doc/articles/scripts.md - - - name: Run test suite - run: composer run-script test + - uses: actions/checkout@v4 + + - name: Validate composer.json + run: composer validate + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: dom, fileinfo, libxml, mbstring + coverage: none + + - name: Cache Composer packages + id: composer-cache + uses: actions/cache@v4 + with: + path: vendor + key: ${{ runner.os }}-${{ matrix.php }}-${{ matrix.laravel }}-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-${{ matrix.php }}-${{ matrix.laravel }}- + + - name: Install dependencies + if: steps.composer-cache.outputs.cache-hit != 'true' + run: | + composer require --dev "laravel/framework:${{ matrix.laravel }}" "orchestra/testbench:${{ matrix.testbench }}" --no-interaction --no-update + composer update --prefer-dist --no-interaction --no-progress + + - name: Run test suite + run: composer run-script test diff --git a/README.md b/README.md index dc53229..adc63cf 100644 --- a/README.md +++ b/README.md @@ -4,17 +4,24 @@ **Receiver is a drop-in webhook handling library for Laravel.** -Webhooks are a powerful part of any API lifecycle. **Receiver** aims to make handling incoming webhooks in your Laravel app a consistent and easy process. +Receiver gives you a consistent, expressive way to receive, verify, and handle incoming webhooks in your Laravel app. Point a route at a controller, call three methods, and you're done. -Out of the box, Receiver has built in support for: +Out of the box, Receiver supports: -- [GitHub Webhooks](https://docs.github.com/en/developers/webhooks-and-events/webhooks/about-webhooks) -- [Hubspot Webhooks](https://developers.hubspot.com/docs/api/webhooks) -- [Postmark Webhooks](https://postmarkapp.com/developer/webhooks/webhooks-overview) -- [Slack Events API](https://api.slack.com/apis/connections/events-api) -- [Stripe Webhooks](https://stripe.com/docs/webhooks) +| Provider | Driver | +|----------|--------| +| [GitHub](https://docs.github.com/en/developers/webhooks-and-events/webhooks/about-webhooks) | `github` | +| [HubSpot](https://developers.hubspot.com/docs/api/webhooks) | `hubspot` | +| [Mailchimp Marketing](https://mailchimp.com/developer/marketing/guides/sync-audience-data-webhooks/) | `mailchimp` | +| [Paddle Billing](https://developer.paddle.com/webhooks/overview) | `paddle` | +| [Postmark](https://postmarkapp.com/developer/webhooks/webhooks-overview) | `postmark` | +| [SendGrid Events](https://docs.sendgrid.com/for-developers/tracking-events/getting-started-event-webhook-security-features) | `sendgrid` | +| [Shopify](https://shopify.dev/docs/apps/webhooks) | `shopify` | +| [Slack Events API](https://api.slack.com/apis/connections/events-api) | `slack` | +| [Stripe](https://stripe.com/docs/webhooks) | `stripe` | +| [Twilio](https://www.twilio.com/docs/usage/webhooks) | `twilio` | -Of course, Receiver can receive webhooks from any source using [custom providers](#extending-receiver). +Any other webhook source can be added with a [custom provider](#extending-receiver). ![Tests](https://github.com/hotmeteor/receiver/workflows/Tests/badge.svg) [![Latest Version on Packagist](https://img.shields.io/packagist/vpre/hotmeteor/receiver.svg?style=flat-square)](https://packagist.org/packages/hotmeteor/receiver) @@ -23,157 +30,206 @@ Of course, Receiver can receive webhooks from any source using [custom providers ## Table of Contents - [Installation](#installation) +- [Configuration](#configuration) - [Receiving Webhooks](#receiving-webhooks) - - [The Basics](#the-basics) - - [Receiving from multiple apps](#receiving-from-multiple-apps) + - [Single provider](#single-provider) + - [Multiple providers](#multiple-providers) + - [Fallbacks](#fallbacks) - [Handling Webhooks](#handling-webhooks) - - [The Basics](#the-basics-1) + - [Handler naming](#handler-naming) - [Queueing handlers](#queueing-handlers) - [Extending Receiver](#extending-receiver) - - [Adding custom providers](#adding-custom-providers) - - [Defining attributes](#defining-attributes) + - [Generating a provider](#generating-a-provider) + - [Defining getEvent() and getData()](#defining-getevent-and-getdata) - [Securing webhooks](#securing-webhooks) - [Handshakes](#handshakes) -- [Community Receivers](#share-your-receivers) + - [Multiple events per request](#multiple-events-per-request) + - [Creating a community provider](#creating-a-community-provider) +- [Share Your Receivers!](#share-your-receivers) - [Credits](#credits) - [License](#license) ## Installation -Requires: - -- PHP ^8.1 -- Laravel 9+ +Requires PHP ^8.2 and Laravel 10+. ```shell composer require hotmeteor/receiver ``` -Optional: +> **Note:** The Stripe provider requires [`stripe/stripe-php`](https://github.com/stripe/stripe-php): +> ```shell +> composer require stripe/stripe-php +> ``` -**Stripe** support requires [`stripe/stripe-php`](https://github.com/stripe/stripe-php) +## Configuration -## Receiving Webhooks +Each provider reads its secret from `config/services.php`. Add an entry for each source you intend to receive from. -### The Basics - -Webhooks require an exposed endpoint to POST to. Receiver aims to make this a one-time setup that supports any of your incoming webhooks. - -1. Create a controller and route for the webhooks you expect to receive. -2. Receive the webhook and handle it, as necessary: - ```php - receive($request) - ->ok(); - } - } - ``` +Most providers use the same shape: + +```php +'github' => ['webhook_secret' => env('GITHUB_WEBHOOK_SECRET')], +'hubspot' => ['webhook_secret' => env('HUBSPOT_WEBHOOK_SECRET')], +'paddle' => ['webhook_secret' => env('PADDLE_WEBHOOK_SECRET')], +'shopify' => ['webhook_secret' => env('SHOPIFY_WEBHOOK_SECRET')], +'slack' => ['webhook_secret' => env('SLACK_WEBHOOK_SECRET')], +'stripe' => ['webhook_secret' => env('STRIPE_WEBHOOK_SECRET')], +'twilio' => ['webhook_secret' => env('TWILIO_AUTH_TOKEN')], +``` + +**Mailchimp** — Mailchimp Marketing webhooks are verified via a secret you embed in your webhook URL (`?secret=...`). Configure the same value here so Receiver can compare it: + +```php +'mailchimp' => ['webhook_secret' => env('MAILCHIMP_WEBHOOK_SECRET')], +``` + +**SendGrid** — Signature verification is opt-in. Set `webhook_secret` to the PEM-format public key found in the SendGrid dashboard under Settings → Mail Settings → Event Webhook. Leave it empty to accept all requests without verification. -The methods being used are simple: +```php +'sendgrid' => ['webhook_secret' => env('SENDGRID_WEBHOOK_PUBLIC_KEY', '')], +``` + +**Postmark** — Postmark supports several verification strategies. Configure which ones to use under the `webhook` key: + +```php +'postmark' => [ + 'token' => env('POSTMARK_TOKEN'), + 'webhook' => [ + // One or more of: 'auth', 'headers', 'ips' + 'verification_types' => ['headers', 'ips'], + + // Header name => expected value pairs (used with 'headers') + 'headers' => [ + 'X-Custom-Header' => env('POSTMARK_WEBHOOK_HEADER'), + ], + + // Allowed source IPs (used with 'ips') + // https://postmarkapp.com/support/article/800-ips-for-firewalls#webhooks + 'ips' => [ + '3.134.147.250', + '50.31.156.6', + '50.31.156.77', + '18.217.206.57', + ], + ], +], +``` -- Define the `driver` that should process the webhook -- `receive` the request for handling -- Respond back to the sender with a `200` `ok` response +| Postmark `verification_type` | Description | +|------------------------------|-------------| +| `auth` | HTTP Basic Auth via `Auth::onceBasic()` | +| `headers` | Validates that specific request headers match expected values | +| `ips` | Validates that the request originates from an allowed IP | +If `verification_types` is empty or not set, all Postmark requests are accepted without verification. -### Receiving from multiple apps +## Receiving Webhooks + +### Single provider -Maybe you have webhooks coming in from multiple services -- handle them all from one controller with a driver variable from your route. +Create a controller and route for each webhook source, then call `driver()`, `receive()`, and `ok()`: ```php receive($request) - ->ok(); - } + public function store(Request $request) + { + return Receiver::driver('stripe') + ->receive($request) + ->ok(); + } } ``` -The provided `ReceivesWebhooks` trait will take care of this for you. +- `driver()` — selects the provider and reads its config +- `receive()` — verifies the signature and maps the event +- `ok()` — dispatches matched handlers and returns a `200` response + +### Multiple providers + +If you'd rather handle all webhooks through a single controller, use a `{provider}` route parameter: + +```php +// routes/web.php +Route::post('/webhooks/{provider}', [WebhookController::class, 'store']) + ->withoutMiddleware(\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class); +``` ```php receive($request) + ->ok(); + } } ``` -_Note: you'll still need to create the route to this action._ Example: +Or use the included `ReceivesWebhooks` trait, which provides this exact `store()` method for you: ```php -Route::post('/hooks/{driver}', [\App\Http\Controllers\Webhooks\WebhooksController::class, 'store']) - ->withoutMiddleware(\App\Http\Middleware\VerifyCsrfToken::class); -``` +receive($request) - ->fallback(function(Webhook $webhook) { - // Do whatever you like here... - }) - ->ok(); - } -} +return Receiver::driver($provider) + ->receive($request) + ->fallback(function (Webhook $webhook) { + Log::info('Unhandled webhook', ['event' => $webhook->getEvent()]); + }) + ->ok(); ``` ## Handling Webhooks -### The Basics +Once a webhook is received, Receiver looks for a handler class that matches the event and dispatches it. Handlers live in `App\Http\Handlers\{Driver}\` by default. If no matching handler is found the webhook is silently ignored and a `200` is returned. + +### Handler naming -Now that webhooks are being received they need to be handled. Receiver will look for designated `Handler` classes for each event type that comes in in the `App\Http\Handlers\[Driver]` namespace. Receiver *does not* provide these handlers -- they are up to you to provide as needed. If Receiver doesn't find a matching handler it simplies ignores the event and responds with a 200 status code. +The handler class name is derived from the event name — all non-alphanumeric characters are treated as word separators, then converted to `StudlyCase`: -For example, a Stripe webhook handler would be `App\Http\Handlers\Stripe\CustomerCreated` for the incoming [`customer.created`](https://stripe.com/docs/api/events/types#event_types-customer.created) event. +| Event name | Handler class | +|------------|---------------| +| `customer.created` | `CustomerCreated` | +| `subscription_activated` | `SubscriptionActivated` | +| `orders_created` | `OrdersCreated` | +| `invoice.payment_failed` | `InvoicePaymentFailed` | -Each handler is constructed with the `event` (name of the webhook event) and `data` properties. +For example, Stripe's `customer.created` event dispatches `App\Http\Handlers\Stripe\CustomerCreated`. -Each handler must also use the `Dispatchable` trait. +Each handler receives the `$event` name and the `$data` array, and must use the `Dispatchable` trait: ```php -``` - -This command will generate a new provider with the name you defined. This class will be created in the `App\Http\Receivers` namespace. - -If your provider needs to be able to verify webhook signatures simply add the `--verified` flag to the command: +# Basic provider +php artisan receiver:make Mailgun -```shell -php artisan receiver:make --verified +# With signature verification scaffolded +php artisan receiver:make Mailgun --verified ``` -Once you've created your new provider you can simply extend Receiver in your `AppServiceProvider` so that Receiver can use it: +The generated class is placed in `App\Http\Receivers`. Once created, register the driver in your `AppServiceProvider`: ```php -extend('mailchimp', function ($app) { - return new MailchimpProvider( - config('services.mailchimp.webhook_secret') - ); - }); - - $receiver->extend('mailgun', function ($app) { - return new MailgunProvider( - config('services.mailgun.webhook_secret') - ); - }); - } + app('receiver')->extend('mailgun', function () { + return new \App\Http\Receivers\MailgunProvider( + config('services.mailgun.webhook_secret') + ); + }); } - ``` -### Defining Attributes - -Receiver needs two pieces of information to receive and handle webhook events: +### Defining getEvent() and getData() -- The event `name` -- The event `data` - -Since these are found in different attributes or headers depending on the webhook, Receiver makes it simple ways to define them in your provider. +Implement `getEvent()` to return the event name. Optionally implement `getData()` to return the event payload — by default it returns `$request->all()`. ```php input('event.name'); + return $request->input('event-data.event'); } - - /** - * @param Request $request - * @return array - */ + public function getData(Request $request): array { - return $request->all(); + return $request->input('event-data', []); } } ``` -The *`getEvent()`* method is used to return the name of the webhook event, ie. `customer.created`. +### Securing webhooks + +Implement a `verify()` method that returns `true` if the request is authentic, or `false` to reject it with a `401` response: -The *`getData()`* method is used to return the payload of data that can be used within your handler. By default this is set to `$request->all()`. +```php +public function verify(Request $request): bool +{ + $signature = $request->header('X-Mailgun-Signature'); + $expected = hash_hmac('sha256', $request->getContent(), $this->secret); + + return hash_equals($expected, (string) $signature); +} +``` -### Securing Webhooks +The signing secret from `config/services.{driver}.webhook_secret` is available as `$this->secret`. -Many webhooks have ways of verifying their authenticity as they are received, most commonly through signatures or basic authentication. No matter the strategy, Receiver allows you to write custom verification code as necessary. Simply implement the `verify` method in your provider and return true or false if it passes. +### Handshakes -A `false` return will result in a 401 response being returned to the webhook sender. +Some services send a verification request when a webhook URL is first registered. Implement `handshake()` to respond to it: ```php - $request->input('challenge')]; +} +``` -namespace Receiver\Providers; +When `handshake()` returns a non-empty array, Receiver responds immediately with that payload and skips normal event handling. When it returns an empty array, Receiver processes the request normally. -use Illuminate\Http\Request; -use Illuminate\Support\Facades\Auth; +### Multiple events per request + +Some services batch multiple events into a single request. Return an `['event_name' => $eventData]` array from `getEvent()` and Receiver will dispatch a separate handler for each entry: -class CustomProvider extends AbstractProvider +```php +public function getEvent(Request $request): string|array { - public function verify(Request $request): bool - { - // return result of verification + $events = []; + + foreach (json_decode($request->getContent(), true) as $event) { + $type = $event['type'] ?? null; + if ($type && ! isset($events[$type])) { + $events[$type] = $event; + } } + + return $events; } ``` -### Handshakes +### Creating a community provider -Some webhooks want to perform a "handshake" to check if your endpoint exists and returns a valid response when it's first set up. To facilitate this, implement the `handshake` method in your provider: +If you're building a reusable provider package to share, add the `--provider` flag to also generate a companion `ServiceProvider` that registers the driver automatically: -```php - - + + Made with [contributors-img](https://contrib.rocks). diff --git a/composer.json b/composer.json index b2ccafb..d5b8b5f 100644 --- a/composer.json +++ b/composer.json @@ -23,9 +23,11 @@ "illuminate/support": ">= 10.0" }, "require-dev": { + "laravel/pint": "^1.29", "nunomaduro/collision": "^7.0|^8.0", - "orchestra/testbench": "^8.0|^9.0", - "phpunit/phpunit": "^10.0|^11.0", + "orchestra/testbench": "^8.0|^9.0|^10.0|^11.0", + "phpstan/phpstan": "^2.1", + "phpunit/phpunit": "^10.0|^11.0|^12.0", "stripe/stripe-php": "^13.0" }, "autoload": { @@ -39,7 +41,10 @@ } }, "scripts": { - "test": "vendor/bin/phpunit" + "test": "vendor/bin/phpunit", + "lint": "vendor/bin/pint", + "lint:check": "vendor/bin/pint --test", + "analyse": "vendor/bin/phpstan analyse --no-progress" }, "extra": { "laravel": { diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..c300fbe --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,7 @@ +parameters: + paths: + - src/ + level: 5 + ignoreErrors: + # Traits are not analysed when unused within the package itself + - '#Trait Receiver\\ReceivesWebhooks is used zero times#' diff --git a/src/AbstractWebhook.php b/src/AbstractWebhook.php index 0ff28c1..0947681 100644 --- a/src/AbstractWebhook.php +++ b/src/AbstractWebhook.php @@ -8,37 +8,26 @@ abstract class AbstractWebhook implements ArrayAccess, Webhook { /** - * The normalized name of the webhook event. - * - * @var string|null + * The normalized name of the webhook event. May be an array of [event => data] + * pairs when the provider returns multiple events in a single payload. */ - public string|null $event = null; + public string|array|null $event = null; /** * The payload of the webhook event. - * - * @var array */ public array $data = []; /** * The webhook's raw attributes. - * - * @var array */ public array $webhook = []; - /** - * @return string - */ - public function getEvent(): string + public function getEvent(): string|array { - return $this->event; + return $this->event ?? ''; } - /** - * @return array - */ public function getData(): array { return $this->data; @@ -46,8 +35,6 @@ public function getData(): array /** * Get the raw webhook array. - * - * @return array */ public function getRaw(): array { @@ -57,7 +44,6 @@ public function getRaw(): array /** * Set the raw webhook array from the provider. * - * @param array $webhook * @return $this */ public function setRaw(array $webhook): static @@ -70,7 +56,6 @@ public function setRaw(array $webhook): static /** * Map the given array onto the webhook's properties. * - * @param array $attributes * @return $this */ public function map(array $attributes): static @@ -86,7 +71,6 @@ public function map(array $attributes): static * Determine if the given raw webhook attribute exists. * * @param string $offset - * @return bool */ public function offsetExists($offset): bool { @@ -97,7 +81,6 @@ public function offsetExists($offset): bool * Get the given key from the raw webhook. * * @param string $offset - * @return mixed */ public function offsetGet($offset): mixed { @@ -108,8 +91,6 @@ public function offsetGet($offset): mixed * Set the given attribute on the raw webhook array. * * @param string $offset - * @param mixed $value - * @return void */ public function offsetSet($offset, mixed $value): void { @@ -120,7 +101,6 @@ public function offsetSet($offset, mixed $value): void * Unset the given value from the raw webhook array. * * @param string $offset - * @return void */ public function offsetUnset($offset): void { diff --git a/src/Console/Commands/GenerateReceiver.php b/src/Console/Commands/GenerateReceiver.php index 85601ed..639aa32 100644 --- a/src/Console/Commands/GenerateReceiver.php +++ b/src/Console/Commands/GenerateReceiver.php @@ -30,12 +30,9 @@ class GenerateReceiver extends GeneratorCommand */ protected $type = 'Receiver'; - /** - * @return string - */ protected function getStub(): string { - return $this->option('verified') === false + return ! $this->option('verified') ? __DIR__.'/../../../stubs/receiver.stub' : __DIR__.'/../../../stubs/receiver-verified.stub'; } @@ -43,8 +40,9 @@ protected function getStub(): string /** * Build the class with the given name. * - * @param string $name + * @param string $name * @return string + * * @throws FileNotFoundException */ protected function buildClass($name) @@ -70,7 +68,7 @@ protected function buildClass($name) /** * Get the destination class path. * - * @param string $name + * @param string $name * @return string */ protected function getPath($name) @@ -89,6 +87,53 @@ protected function getOptions() { return [ ['verified', false, InputOption::VALUE_NONE, 'Webhooks are verified with a signature'], + ['provider', false, InputOption::VALUE_NONE, 'Also generate a companion ServiceProvider for package distribution'], ]; } + + public function handle() + { + if (parent::handle() === false) { + return false; + } + + if ($this->option('provider')) { + $this->generateServiceProvider(); + } + + return null; + } + + protected function generateServiceProvider(): void + { + $name = $this->argument('name'); + $class = class_basename(Str::studly(str_replace(['Receiver'], '', $name))); + $driverName = Str::lower($class); + $serviceProviderClass = $class.'ReceiverServiceProvider'; + + $path = $this->laravel->basePath().'/app/Providers/'.$serviceProviderClass.'.php'; + + if ($this->files->exists($path)) { + $this->components->error("Service provider [{$serviceProviderClass}] already exists!"); + + return; + } + + $directory = dirname($path); + if (! $this->files->isDirectory($directory)) { + $this->files->makeDirectory($directory, 0777, true, true); + } + + $stub = $this->files->get(__DIR__.'/../../../stubs/receiver-provider.stub'); + + $stub = str_replace( + ['{{ serviceProviderNamespace }}', '{{ serviceProviderClass }}', '{{ driverName }}', '{{ providerNamespace }}', '{{ providerClass }}'], + ['App\\Providers', $serviceProviderClass, $driverName, 'App\\Http\\Receivers', $class], + $stub + ); + + $this->files->put($path, $stub); + + $this->components->info("Service provider [{$path}] created successfully."); + } } diff --git a/src/Contracts/Factory.php b/src/Contracts/Factory.php index 37ddd54..05551bd 100644 --- a/src/Contracts/Factory.php +++ b/src/Contracts/Factory.php @@ -4,5 +4,9 @@ interface Factory { + /** + * @param string|null $driver + * @return mixed + */ public function driver($driver = null); } diff --git a/src/Contracts/Provider.php b/src/Contracts/Provider.php index 3af4521..c7f308f 100644 --- a/src/Contracts/Provider.php +++ b/src/Contracts/Provider.php @@ -10,8 +10,6 @@ public function receive(Request $request): static; /** * Get the webhook instance. - * - * @return Webhook|null */ public function webhook(): ?Webhook; } diff --git a/src/Contracts/Webhook.php b/src/Contracts/Webhook.php index 9e88392..89e6bd8 100644 --- a/src/Contracts/Webhook.php +++ b/src/Contracts/Webhook.php @@ -4,13 +4,7 @@ interface Webhook { - /** - * @return string - */ - public function getEvent(): string; + public function getEvent(): string|array; - /** - * @return array - */ public function getData(): array; } diff --git a/src/Facades/Receiver.php b/src/Facades/Receiver.php index 19c79a4..48733d1 100644 --- a/src/Facades/Receiver.php +++ b/src/Facades/Receiver.php @@ -5,13 +5,15 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Facade; use Receiver\Contracts\Factory; +use Receiver\Contracts\Provider; /** * @method static \Receiver\Providers\AbstractProvider driver(string $driver = null) * @method static \Receiver\Providers\AbstractProvider receive(Request $request) * @method static \Symfony\Component\HttpFoundation\Response ok() - * @see \Receiver\Contracts\Factory - * @see \Receiver\Contracts\Provider + * + * @see Factory + * @see Provider */ class Receiver extends Facade { diff --git a/src/Providers/AbstractProvider.php b/src/Providers/AbstractProvider.php index 27ecaa4..74c0a8c 100644 --- a/src/Providers/AbstractProvider.php +++ b/src/Providers/AbstractProvider.php @@ -14,53 +14,23 @@ abstract class AbstractProvider implements ProviderContract, Responsable { /** * The cached webhook instance. - * - * @var Webhook|null */ - protected Webhook|null $webhook = null; + protected ?Webhook $webhook = null; - /** - * @var Request|null - */ - protected Request|null $request = null; + protected ?Request $request = null; - /** - * @var mixed - */ protected mixed $response = null; - /** - * @var Closure|null - */ - protected Closure|null $fallback = null; + protected ?Closure $fallback = null; - /** - * @var bool - */ - protected mixed $dispatched = false; + protected array $dispatchedEvents = []; - /** - * @var string - */ protected string $handlerNamespace = '\\App\\Http\\Handlers'; - /** - * @param string|null $secret - */ - public function __construct(protected ?string $secret = null) - { - } + public function __construct(protected ?string $secret = null) {} - /** - * @param Request $request - * @return string - */ - abstract public function getEvent(Request $request): string; + abstract public function getEvent(Request $request): string|array; - /** - * @param Request $request - * @return array - */ public function getData(Request $request): array { return $request->all(); @@ -69,7 +39,6 @@ public function getData(Request $request): array /** * Set the scopes of the requested access. * - * @param Request $request * @return $this */ public function receive(Request $request): static @@ -95,9 +64,6 @@ public function receive(Request $request): static return $this; } - /** - * @return JsonResponse|Response - */ public function ok(): JsonResponse|Response { if (! $this->dispatched() && $this->fallback) { @@ -110,7 +76,6 @@ public function ok(): JsonResponse|Response } /** - * @param Closure $closure * @return $this */ public function fallback(Closure $closure): static @@ -120,63 +85,55 @@ public function fallback(Closure $closure): static return $this; } - /** - * @param $request - * @return JsonResponse|Response - */ public function toResponse($request): JsonResponse|Response { return response()->json($this->response, 200); } - /** - * @return Webhook|null - */ public function webhook(): ?Webhook { return $this->webhook; } /** - * @return bool + * @param string|null $key Handler class name to check (e.g. MyHandler::class) */ - public function dispatched(): bool + public function dispatched(?string $key = null): bool { - return $this->dispatched; + return $key + ? in_array($key, $this->dispatchedEvents) + : ! empty($this->dispatchedEvents); } - /** - * @param Request $request - * @return Webhook - */ protected function mapWebhook(Request $request): Webhook { - return (new Webhook())->setRaw($request->all())->map([ + return (new Webhook)->setRaw($request->all())->map([ 'event' => $this->getEvent($request), 'data' => $this->getData($request), ]); } - /** - * @return AbstractProvider - */ protected function handle(): static { - $class = $this->getClass($event = $this->webhook->getEvent()); + $events = $this->webhook->getEvent(); + + if (! is_array($events)) { + $events = [$events => $this->webhook->getData()]; + } - if (class_exists($class)) { - $class::dispatch($event, $this->webhook->getData()); + foreach ($events as $event => $data) { + $class = $this->getClass($event); - $this->dispatched = true; + if (class_exists($class)) { + $class::dispatch($event, $data); + + $this->dispatchedEvents[] = $class; + } } return $this; } - /** - * @param string $event - * @return string - */ protected function getClass(string $event): string { $className = $this->prepareHandlerClassname($event); @@ -187,25 +144,17 @@ protected function getClass(string $event): string return implode('\\', [$basepath, $driverName, $className]); } - /** - * @param string $event - * @return string - */ protected function prepareHandlerClassname(string $event): string { - return (string) Str::of($event)->replaceMatches('/[^A-Za-z0-9]++/', ' ')->studly(); + return (string) Str::of($event)->lower()->replaceMatches('/[^A-Za-z0-9]++/', ' ')->studly(); } - /** - * @return string - */ protected function prepareDriverClassname(): string { return Str::replace('Provider', '', class_basename(static::class)); } /** - * @param string $namespace * @return $this */ public function setHandlerNamespace(string $namespace): static @@ -215,9 +164,6 @@ public function setHandlerNamespace(string $namespace): static return $this; } - /** - * @return string - */ public function getHandlerNamespace(): string { return $this->handlerNamespace; diff --git a/src/Providers/FakeProvider.php b/src/Providers/FakeProvider.php index 363b3e8..a34cbf9 100644 --- a/src/Providers/FakeProvider.php +++ b/src/Providers/FakeProvider.php @@ -7,18 +7,13 @@ class FakeProvider extends AbstractProvider { /** - * @param Request $request * @return string */ - public function getEvent(Request $request): string + public function getEvent(Request $request): string|array { return $request->input('type', 'fake'); } - /** - * @param Request $request - * @return array - */ public function getData(Request $request): array { return $request->input('data', []); diff --git a/src/Providers/GithubProvider.php b/src/Providers/GithubProvider.php index f1b03fe..d408f49 100644 --- a/src/Providers/GithubProvider.php +++ b/src/Providers/GithubProvider.php @@ -8,9 +8,6 @@ class GithubProvider extends AbstractProvider { /** * https://docs.github.com/en/developers/webhooks-and-events/webhooks/securing-your-webhooks. - * - * @param Request $request - * @return bool */ public function verify(Request $request): bool { @@ -21,11 +18,10 @@ public function verify(Request $request): bool } /** - * @param Request $request * @return string */ - public function getEvent(Request $request): string + public function getEvent(Request $request): string|array { - return implode('_', [$request->header('X-GitHub-Event'), $request->input('action')]); + return implode('_', array_filter([$request->header('X-GitHub-Event'), $request->input('action')])); } } diff --git a/src/Providers/HubspotProvider.php b/src/Providers/HubspotProvider.php index 2906ffa..9304124 100644 --- a/src/Providers/HubspotProvider.php +++ b/src/Providers/HubspotProvider.php @@ -8,9 +8,6 @@ class HubspotProvider extends AbstractProvider { /** * https://developers.hubspot.com/docs/api/webhooks/validating-requests#validate-the-v3-request-signature. - * - * @param Request $request - * @return bool */ public function verify(Request $request): bool { @@ -23,7 +20,6 @@ public function verify(Request $request): bool $request->getContent(), ]); - $signature = urlencode($signature); $signature = hash_hmac('sha256', $signature, $this->secret); $signature = base64_encode($signature); @@ -34,10 +30,9 @@ public function verify(Request $request): bool } /** - * @param Request $request * @return string */ - public function getEvent(Request $request): string + public function getEvent(Request $request): string|array { return $request->input('eventType'); } diff --git a/src/Providers/MailchimpProvider.php b/src/Providers/MailchimpProvider.php new file mode 100644 index 0000000..6e30aaf --- /dev/null +++ b/src/Providers/MailchimpProvider.php @@ -0,0 +1,25 @@ +secret, (string) $request->query('secret')); + } + + public function getEvent(Request $request): string|array + { + return (string) $request->input('type', ''); + } +} diff --git a/src/Providers/PaddleProvider.php b/src/Providers/PaddleProvider.php new file mode 100644 index 0000000..6a9ffe9 --- /dev/null +++ b/src/Providers/PaddleProvider.php @@ -0,0 +1,58 @@ +;h1=[;h1=] + * + * Multiple h1 values may be present during secret rotation; a match + * against any of them is accepted. + * + * https://developer.paddle.com/webhooks/signature-verification + */ + public function verify(Request $request): bool + { + $header = $request->header('Paddle-Signature'); + + if (! $header) { + return false; + } + + $ts = null; + $signatures = []; + + foreach (explode(';', $header) as $part) { + [$key, $value] = array_pad(explode('=', $part, 2), 2, null); + if ($key === 'ts') { + $ts = $value; + } elseif ($key === 'h1') { + $signatures[] = $value; + } + } + + if (! $ts || empty($signatures)) { + return false; + } + + $payload = $ts.':'.$request->getContent(); + $computed = hash_hmac('sha256', $payload, $this->secret); + + foreach ($signatures as $signature) { + if (hash_equals($computed, $signature)) { + return true; + } + } + + return false; + } + + public function getEvent(Request $request): string|array + { + return (string) $request->input('event_type', ''); + } +} diff --git a/src/Providers/PostmarkProvider.php b/src/Providers/PostmarkProvider.php index 825e32f..aae1595 100644 --- a/src/Providers/PostmarkProvider.php +++ b/src/Providers/PostmarkProvider.php @@ -8,27 +8,52 @@ class PostmarkProvider extends AbstractProvider { /** - * https://postmarkapp.com/developer/webhooks/webhooks-overview#protecting-your-webhook. + * Verify the incoming Postmark webhook request. + * + * Configure which verification methods to run (and in what order) via: + * config('services.postmark.webhook.verification_types') + * + * Available types: + * - 'auth' Verify via HTTP Basic Auth (Auth::onceBasic) + * - 'headers' Verify that specific headers are present and match expected values + * - 'ips' Verify that the request originates from an allowed IP address * - * @param Request $request - * @return bool + * https://postmarkapp.com/developer/webhooks/webhooks-overview#protecting-your-webhook. */ public function verify(Request $request): bool { - try { - Auth::onceBasic(); + foreach (config('services.postmark.webhook.verification_types') ?? [] as $type) { + switch ($type) { + case 'auth': + if (Auth::onceBasic() !== null) { + return false; + } + break; + + case 'headers': + foreach (config('services.postmark.webhook.headers') ?? [] as $key => $value) { + if (! $request->hasHeader($key) || $request->header($key) !== $value) { + return false; + } + } + break; - return true; - } catch (\Exception $exception) { - return false; + case 'ips': + $allowed = config('services.postmark.webhook.ips') ?? []; + if (! in_array($request->getClientIp(), $allowed, true)) { + return false; + } + break; + } } + + return true; } /** - * @param Request $request * @return string */ - public function getEvent(Request $request): string + public function getEvent(Request $request): string|array { return $request->filled('RecordType') ? $request->input('RecordType') : 'Inbound'; } diff --git a/src/Providers/SendGridProvider.php b/src/Providers/SendGridProvider.php new file mode 100644 index 0000000..d372865 --- /dev/null +++ b/src/Providers/SendGridProvider.php @@ -0,0 +1,59 @@ +secret)) { + return true; + } + + $signature = $request->header('X-Twilio-Email-Event-Webhook-Signature'); + $timestamp = $request->header('X-Twilio-Email-Event-Webhook-Timestamp'); + + if (! $signature || ! $timestamp) { + return false; + } + + $payload = $timestamp.$request->getContent(); + + return openssl_verify($payload, base64_decode($signature), $this->secret, OPENSSL_ALGO_SHA256) === 1; + } + + /** + * SendGrid sends a JSON array of events per request. + * Returns an event-type => first-event-of-that-type map for multi-dispatch. + */ + public function getEvent(Request $request): string|array + { + $events = json_decode($request->getContent(), true) ?? []; + + $result = []; + foreach ($events as $event) { + $type = $event['event'] ?? null; + if ($type && ! isset($result[$type])) { + $result[$type] = $event; + } + } + + return $result ?: ''; + } + + public function getData(Request $request): array + { + return json_decode($request->getContent(), true) ?? []; + } +} diff --git a/src/Providers/ShopifyProvider.php b/src/Providers/ShopifyProvider.php new file mode 100644 index 0000000..7bd666d --- /dev/null +++ b/src/Providers/ShopifyProvider.php @@ -0,0 +1,24 @@ +header('X-Shopify-Hmac-Sha256'); + $computed = base64_encode(hash_hmac('sha256', $request->getContent(), $this->secret, true)); + + return hash_equals($computed, (string) $header); + } + + public function getEvent(Request $request): string|array + { + return str_replace('/', '_', (string) ($request->header('X-Shopify-Topic') ?? '')); + } +} diff --git a/src/Providers/SlackProvider.php b/src/Providers/SlackProvider.php index 2de3c96..bea0d9d 100644 --- a/src/Providers/SlackProvider.php +++ b/src/Providers/SlackProvider.php @@ -6,20 +6,13 @@ class SlackProvider extends AbstractProvider { - /** - * @param Request $request - * @return array|null - */ - public function handshake(Request $request): array|null + public function handshake(Request $request): ?array { return $request->has('challenge') ? $request->only('challenge') : null; } /** * https://api.slack.com/authentication/verifying-requests-from-slack#verifying-requests-from-slack-using-signing-secrets__a-recipe-for-security__step-by-step-walk-through-for-validating-a-request. - * - * @param Request $request - * @return bool */ public function verify(Request $request): bool { @@ -37,10 +30,9 @@ public function verify(Request $request): bool } /** - * @param Request $request * @return string */ - public function getEvent(Request $request): string + public function getEvent(Request $request): string|array { return $request->input('event.type'); } diff --git a/src/Providers/StripeProvider.php b/src/Providers/StripeProvider.php index 45b9918..5b4b4fe 100644 --- a/src/Providers/StripeProvider.php +++ b/src/Providers/StripeProvider.php @@ -3,23 +3,17 @@ namespace Receiver\Providers; use Illuminate\Http\Request; +use Stripe\Webhook; class StripeProvider extends AbstractProvider { - /** - * @param Request $request - * @return array|null - */ - public function handshake(Request $request): array|null + public function handshake(Request $request): ?array { return $request->has('challenge') ? $request->only('challenge') : null; } /** * https://stripe.com/docs/webhooks/signatures#verify-official-libraries. - * - * @param Request $request - * @return bool */ public function verify(Request $request): bool { @@ -27,7 +21,7 @@ public function verify(Request $request): bool $signature = $request->header('STRIPE_SIGNATURE'); try { - \Stripe\Webhook::constructEvent( + Webhook::constructEvent( $payload, $signature, $this->secret @@ -40,18 +34,13 @@ public function verify(Request $request): bool } /** - * @param Request $request * @return string */ - public function getEvent(Request $request): string + public function getEvent(Request $request): string|array { return $request->input('type'); } - /** - * @param Request $request - * @return array - */ public function getData(Request $request): array { return $request->input('data'); diff --git a/src/Providers/TwilioProvider.php b/src/Providers/TwilioProvider.php new file mode 100644 index 0000000..c220883 --- /dev/null +++ b/src/Providers/TwilioProvider.php @@ -0,0 +1,45 @@ +header('X-Twilio-Signature'); + + if (! $header) { + return false; + } + + $url = $request->fullUrl(); + $params = $request->post(); + + if (! empty($params)) { + ksort($params); + foreach ($params as $key => $value) { + $url .= $key.$value; + } + } + + $computed = base64_encode(hash_hmac('sha1', $url, $this->secret, true)); + + return hash_equals($computed, $header); + } + + public function getEvent(Request $request): string|array + { + return $request->input('EventType') + ?? $request->input('MessageStatus') + ?? $request->input('CallStatus') + ?? ''; + } +} diff --git a/src/Providers/Webhook.php b/src/Providers/Webhook.php index b462855..440b538 100644 --- a/src/Providers/Webhook.php +++ b/src/Providers/Webhook.php @@ -4,6 +4,4 @@ use Receiver\AbstractWebhook; -class Webhook extends AbstractWebhook -{ -} +class Webhook extends AbstractWebhook {} diff --git a/src/ReceiverManager.php b/src/ReceiverManager.php index 1c60b20..57a90b8 100644 --- a/src/ReceiverManager.php +++ b/src/ReceiverManager.php @@ -10,17 +10,19 @@ use Receiver\Providers\FakeProvider; use Receiver\Providers\GithubProvider; use Receiver\Providers\HubspotProvider; +use Receiver\Providers\MailchimpProvider; +use Receiver\Providers\PaddleProvider; use Receiver\Providers\PostmarkProvider; +use Receiver\Providers\SendGridProvider; +use Receiver\Providers\ShopifyProvider; use Receiver\Providers\SlackProvider; use Receiver\Providers\StripeProvider; +use Receiver\Providers\TwilioProvider; class ReceiverManager extends Manager implements Factory { /** * Get a driver instance. - * - * @param string $driver - * @return mixed */ public function with(string $driver): mixed { @@ -103,14 +105,70 @@ protected function createFakeDriver(): FakeProvider ); } + /** + * Create an instance of the specified driver. + */ + protected function createShopifyDriver(): ShopifyProvider + { + return $this->buildProvider( + ShopifyProvider::class, + $this->config->get('services.shopify') + ); + } + + /** + * Create an instance of the specified driver. + */ + protected function createTwilioDriver(): TwilioProvider + { + return $this->buildProvider( + TwilioProvider::class, + $this->config->get('services.twilio') + ); + } + + /** + * Create an instance of the specified driver. + */ + protected function createMailchimpDriver(): MailchimpProvider + { + return $this->buildProvider( + MailchimpProvider::class, + $this->config->get('services.mailchimp') + ); + } + + /** + * Create an instance of the specified driver. + */ + protected function createSendgridDriver(): SendGridProvider + { + return $this->buildProvider( + SendGridProvider::class, + $this->config->get('services.sendgrid') + ); + } + + /** + * Create an instance of the specified driver. + */ + protected function createPaddleDriver(): PaddleProvider + { + return $this->buildProvider( + PaddleProvider::class, + $this->config->get('services.paddle') + ); + } + /** * Build a webhook provider instance. * - * @param string $provider - * @param array $config - * @return AbstractProvider + * @template T of AbstractProvider + * + * @param class-string $provider + * @return T */ - public function buildProvider(string $provider, array $config): Providers\AbstractProvider + public function buildProvider(string $provider, array $config): AbstractProvider { return new $provider( Arr::get($config, 'webhook_secret') @@ -134,7 +192,7 @@ public function forgetDrivers(): static * * @return string * - * @throws \InvalidArgumentException + * @throws InvalidArgumentException */ public function getDefaultDriver() { diff --git a/src/ReceivesWebhooks.php b/src/ReceivesWebhooks.php index 8b6a92e..e8f2f09 100644 --- a/src/ReceivesWebhooks.php +++ b/src/ReceivesWebhooks.php @@ -3,10 +3,11 @@ namespace Receiver; use Illuminate\Http\Request; +use Illuminate\Routing\Controller; use Receiver\Facades\Receiver; /** - * @mixin \Illuminate\Routing\Controller + * @mixin Controller */ trait ReceivesWebhooks { diff --git a/stubs/receiver-provider.stub b/stubs/receiver-provider.stub new file mode 100644 index 0000000..a560288 --- /dev/null +++ b/stubs/receiver-provider.stub @@ -0,0 +1,19 @@ +buildProvider( + \{{ providerNamespace }}\{{ providerClass }}::class, + config('services.{{ driverName }}') + ); + }); + } +} diff --git a/stubs/receiver-verified.stub b/stubs/receiver-verified.stub index ea8c7ca..1506778 100644 --- a/stubs/receiver-verified.stub +++ b/stubs/receiver-verified.stub @@ -2,36 +2,21 @@ namespace {{ providerNamespace }}; -use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Http\Request; use Receiver\Providers\AbstractProvider; class {{ providerClass }} extends AbstractProvider { - use Dispatchable; - - /** - * @param Request $request - * @return bool - */ public function verify(Request $request): bool { return true; } - /** - * @param Request $request - * @return string - */ - public function getEvent(Request $request): string + public function getEvent(Request $request): string|array { // } - /** - * @param Request $request - * @return array - */ public function getData(Request $request): array { // diff --git a/stubs/receiver.stub b/stubs/receiver.stub index 6cd11fc..5562b29 100644 --- a/stubs/receiver.stub +++ b/stubs/receiver.stub @@ -2,27 +2,16 @@ namespace {{ providerNamespace }}; -use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Http\Request; use Receiver\Providers\AbstractProvider; class {{ providerClass }} extends AbstractProvider { - use Dispatchable; - - /** - * @param Request $request - * @return string - */ - public function getEvent(Request $request): string + public function getEvent(Request $request): string|array { // } - /** - * @param Request $request - * @return array - */ public function getData(Request $request): array { // diff --git a/tests/FacadeTest.php b/tests/FacadeTest.php index 7d19ea4..06653aa 100644 --- a/tests/FacadeTest.php +++ b/tests/FacadeTest.php @@ -4,13 +4,15 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use PHPUnit\Framework\Attributes\Test; use Receiver\Facades\Receiver; class FacadeTest extends TestCase { - public function test_ide_helpers() + #[Test] + public function ide_helpers(): void { - $request = new Request(); + $request = new Request; $receiver = Receiver::driver('fake')->receive($request); diff --git a/tests/Fixtures/EventA.php b/tests/Fixtures/EventA.php new file mode 100644 index 0000000..1d642a1 --- /dev/null +++ b/tests/Fixtures/EventA.php @@ -0,0 +1,14 @@ +input('event'); } - /** - * @param Request $request - * @return array - */ public function getData(Request $request): array { return $request->input('data'); } - /** - * @param string $event - * @return string - */ protected function getClass(string $event): string { $className = $this->prepareHandlerClassname($event); diff --git a/tests/GithubProviderTest.php b/tests/GithubProviderTest.php index 4999784..5eab973 100644 --- a/tests/GithubProviderTest.php +++ b/tests/GithubProviderTest.php @@ -5,12 +5,14 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Log; use Mockery; +use PHPUnit\Framework\Attributes\Test; use Receiver\Providers\GithubProvider; use Receiver\Providers\Webhook; class GithubProviderTest extends TestCase { - public function test_it_can_receive_github_webhook() + #[Test] + public function it_can_receive_github_webhook(): void { Log::partialMock()->shouldReceive('info')->never(); @@ -31,7 +33,8 @@ public function test_it_can_receive_github_webhook() $this->assertInstanceOf(Webhook::class, $webhook); } - public function test_it_dispatches_matching_handler() + #[Test] + public function it_dispatches_matching_handler(): void { Log::partialMock()->shouldReceive('info')->withArgs(['Webhook handled.'])->andReturnNull(); @@ -55,11 +58,6 @@ public function test_it_dispatches_matching_handler() $this->assertInstanceOf(Webhook::class, $webhook); } - /** - * @param Request $request - * @param string $signature - * @return Request - */ protected function signUsing(Request $request, string $signature): Request { $request->allows('getContent')->andReturns(json_encode($this->mockPayload())); @@ -70,11 +68,8 @@ protected function signUsing(Request $request, string $signature): Request /** * https://docs.github.com/en/developers/webhooks-and-events/webhooks/webhook-events-and-payloads#example-delivery. - * - * @param string|null $key - * @return mixed */ - protected function mockPayload(string $key = null): mixed + protected function mockPayload(?string $key = null): mixed { $data = [ [ diff --git a/tests/HubspotProviderTest.php b/tests/HubspotProviderTest.php new file mode 100644 index 0000000..9ffac42 --- /dev/null +++ b/tests/HubspotProviderTest.php @@ -0,0 +1,98 @@ +mockPayload()); + + $signature = base64_encode(hash_hmac('sha256', $method.$uri.$body, $secret)); + + $request = Mockery::mock(Request::class); + $request->allows('header')->with('X-HubSpot-Request-Timestamp')->andReturns($time->unix()); + $request->allows('header')->with('X-HubSpot-Signature-v3')->andReturns($signature); + $request->allows('method')->andReturns($method); + $request->allows('getUri')->andReturns($uri); + $request->allows('getContent')->andReturns($body); + $request->allows('input')->with('eventType')->andReturns($this->mockPayload('eventType')); + $request->allows('all')->andReturns($this->mockPayload()); + + $provider = new HubspotProvider($secret); + $provider->receive($request); + + $this->assertInstanceOf(Webhook::class, $provider->webhook()); + } + + #[Test] + public function it_denies_invalid_hubspot_webhook(): void + { + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Unauthorized'); + + $time = Carbon::parse('2022-08-01 12:00:00', 'America/Chicago'); + Carbon::setTestNow($time); + + $request = Mockery::mock(Request::class); + $request->allows('header')->with('X-HubSpot-Request-Timestamp')->andReturns($time->unix()); + $request->allows('header')->with('X-HubSpot-Signature-v3')->andReturns('invalid-signature'); + $request->allows('method')->andReturns('POST'); + $request->allows('getUri')->andReturns('https://example.com/webhooks/hubspot'); + $request->allows('getContent')->andReturns(json_encode($this->mockPayload())); + + $provider = new HubspotProvider('hubspot-webhook-secret'); + $provider->receive($request); + } + + #[Test] + public function it_denies_expired_hubspot_webhook(): void + { + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Unauthorized'); + + $staleTimestamp = now()->subMinutes(10)->unix(); + + $request = Mockery::mock(Request::class); + $request->allows('header')->with('X-HubSpot-Request-Timestamp')->andReturns($staleTimestamp); + + $provider = new HubspotProvider('hubspot-webhook-secret'); + $provider->receive($request); + } + + #[Test] + public function it_gets_event_from_event_type(): void + { + $request = Mockery::mock(Request::class); + $request->allows('input')->with('eventType')->andReturns('contact.creation'); + + $provider = new HubspotProvider('secret'); + + $this->assertEquals('contact.creation', $provider->getEvent($request)); + } + + protected function mockPayload(?string $key = null): mixed + { + $data = [ + 'eventType' => 'contact.creation', + 'objectId' => 123, + ]; + + return $key ? data_get($data, $key) : $data; + } +} diff --git a/tests/MailchimpProviderTest.php b/tests/MailchimpProviderTest.php new file mode 100644 index 0000000..6b200e9 --- /dev/null +++ b/tests/MailchimpProviderTest.php @@ -0,0 +1,63 @@ +allows('query')->with('secret')->andReturns($secret); + $request->allows('input')->with('type', '')->andReturns('subscribe'); + $request->allows('all')->andReturns(['type' => 'subscribe', 'data' => []]); + + $provider = new MailchimpProvider($secret); + $provider->receive($request); + + $this->assertInstanceOf(Webhook::class, $provider->webhook()); + } + + #[Test] + public function it_denies_a_mismatched_secret(): void + { + $this->expectException(HttpException::class); + + $request = Mockery::mock(Request::class); + $request->allows('query')->with('secret')->andReturns('wrong-secret'); + + $provider = new MailchimpProvider('mailchimp-webhook-secret'); + $provider->receive($request); + } + + #[Test] + public function it_returns_the_type_as_event(): void + { + $request = Mockery::mock(Request::class); + $request->allows('input')->with('type', '')->andReturns('unsubscribe'); + + $provider = new MailchimpProvider('secret'); + + $this->assertEquals('unsubscribe', $provider->getEvent($request)); + } + + #[Test] + public function it_returns_empty_string_when_no_type(): void + { + $request = Mockery::mock(Request::class); + $request->allows('input')->with('type', '')->andReturns(''); + + $provider = new MailchimpProvider('secret'); + + $this->assertEquals('', $provider->getEvent($request)); + } +} diff --git a/tests/ManagerTest.php b/tests/ManagerTest.php index 6d6deb5..3e5b392 100644 --- a/tests/ManagerTest.php +++ b/tests/ManagerTest.php @@ -2,13 +2,20 @@ namespace Receiver\Tests; +use PHPUnit\Framework\Attributes\Test; use Receiver\Contracts\Factory; use Receiver\Providers\GithubProvider; +use Receiver\Providers\MailchimpProvider; +use Receiver\Providers\PaddleProvider; use Receiver\Providers\PostmarkProvider; +use Receiver\Providers\SendGridProvider; +use Receiver\Providers\ShopifyProvider; +use Receiver\Providers\TwilioProvider; class ManagerTest extends TestCase { - public function test_it_can_instantiate_the_github_driver() + #[Test] + public function it_can_instantiate_the_github_driver(): void { $factory = $this->app->make(Factory::class); @@ -17,7 +24,8 @@ public function test_it_can_instantiate_the_github_driver() $this->assertInstanceOf(GithubProvider::class, $provider); } - public function test_it_can_instantiate_the_postmark_driver() + #[Test] + public function it_can_instantiate_the_postmark_driver(): void { $factory = $this->app->make(Factory::class); @@ -25,4 +33,44 @@ public function test_it_can_instantiate_the_postmark_driver() $this->assertInstanceOf(PostmarkProvider::class, $provider); } + + #[Test] + public function it_can_instantiate_the_shopify_driver(): void + { + $factory = $this->app->make(Factory::class); + + $this->assertInstanceOf(ShopifyProvider::class, $factory->driver('shopify')); + } + + #[Test] + public function it_can_instantiate_the_twilio_driver(): void + { + $factory = $this->app->make(Factory::class); + + $this->assertInstanceOf(TwilioProvider::class, $factory->driver('twilio')); + } + + #[Test] + public function it_can_instantiate_the_mailchimp_driver(): void + { + $factory = $this->app->make(Factory::class); + + $this->assertInstanceOf(MailchimpProvider::class, $factory->driver('mailchimp')); + } + + #[Test] + public function it_can_instantiate_the_sendgrid_driver(): void + { + $factory = $this->app->make(Factory::class); + + $this->assertInstanceOf(SendGridProvider::class, $factory->driver('sendgrid')); + } + + #[Test] + public function it_can_instantiate_the_paddle_driver(): void + { + $factory = $this->app->make(Factory::class); + + $this->assertInstanceOf(PaddleProvider::class, $factory->driver('paddle')); + } } diff --git a/tests/PaddleProviderTest.php b/tests/PaddleProviderTest.php new file mode 100644 index 0000000..c155470 --- /dev/null +++ b/tests/PaddleProviderTest.php @@ -0,0 +1,101 @@ +mockPayload()); + $timestamp = (string) time(); + $computed = hash_hmac('sha256', "{$timestamp}:{$payload}", $secret); + $header = "ts={$timestamp};h1={$computed}"; + + $request = Mockery::mock(Request::class); + $request->allows('header')->with('Paddle-Signature')->andReturns($header); + $request->allows('getContent')->andReturns($payload); + $request->allows('input')->with('event_type', '')->andReturns('transaction.created'); + $request->allows('all')->andReturns($this->mockPayload()); + + $provider = new PaddleProvider($secret); + $provider->receive($request); + + $this->assertInstanceOf(Webhook::class, $provider->webhook()); + } + + #[Test] + public function it_denies_an_invalid_signature(): void + { + $this->expectException(HttpException::class); + + $payload = json_encode($this->mockPayload()); + $header = 'ts='.time().';h1=invalidsignature'; + + $request = Mockery::mock(Request::class); + $request->allows('header')->with('Paddle-Signature')->andReturns($header); + $request->allows('getContent')->andReturns($payload); + + $provider = new PaddleProvider('paddle-webhook-secret'); + $provider->receive($request); + } + + #[Test] + public function it_returns_false_when_signature_header_missing(): void + { + $request = Mockery::mock(Request::class); + $request->allows('header')->with('Paddle-Signature')->andReturns(null); + + $provider = new PaddleProvider('secret'); + + $this->assertFalse($provider->verify($request)); + } + + #[Test] + public function it_accepts_any_matching_h1_during_key_rotation(): void + { + $secret = 'paddle-webhook-secret'; + $payload = json_encode($this->mockPayload()); + $timestamp = (string) time(); + $good = hash_hmac('sha256', "{$timestamp}:{$payload}", $secret); + $header = "ts={$timestamp};h1=oldinvalidsignature;h1={$good}"; + + $request = Mockery::mock(Request::class); + $request->allows('header')->with('Paddle-Signature')->andReturns($header); + $request->allows('getContent')->andReturns($payload); + + $provider = new PaddleProvider($secret); + + $this->assertTrue($provider->verify($request)); + } + + #[Test] + public function it_returns_the_event_type(): void + { + $request = Mockery::mock(Request::class); + $request->allows('input')->with('event_type', '')->andReturns('subscription.created'); + + $provider = new PaddleProvider('secret'); + + $this->assertEquals('subscription.created', $provider->getEvent($request)); + } + + protected function mockPayload(): array + { + return [ + 'event_type' => 'transaction.created', + 'data' => [ + 'id' => 'txn_123', + 'status' => 'completed', + ], + ]; + } +} diff --git a/tests/PostmarkProviderTest.php b/tests/PostmarkProviderTest.php new file mode 100644 index 0000000..31f83f3 --- /dev/null +++ b/tests/PostmarkProviderTest.php @@ -0,0 +1,196 @@ +once()->andReturnNull(); + + $request = $this->mockBaseRequest(); + + $provider = new PostmarkProvider(null); + $provider->receive($request); + + $this->assertInstanceOf(Webhook::class, $provider->webhook()); + } + + #[Test] + public function it_denies_postmark_webhook_with_invalid_auth(): void + { + Config::set('services.postmark.webhook.verification_types', ['auth']); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Unauthorized'); + + Auth::shouldReceive('onceBasic')->once()->andReturn(response('Unauthorized', 401)); + + $request = $this->mockBaseRequest(); + + $provider = new PostmarkProvider(null); + $provider->receive($request); + } + + // ------------------------------------------------------------------------- + // verify() — headers mode + // ------------------------------------------------------------------------- + + #[Test] + public function it_can_verify_postmark_webhook_via_valid_headers(): void + { + Config::set('services.postmark.webhook.verification_types', ['headers']); + + $request = $this->mockBaseRequest(); + $request->allows('hasHeader')->with('X-Custom-Header')->andReturns(true); + $request->allows('header')->with('X-Custom-Header')->andReturns('PostmarkExpected'); + + $provider = new PostmarkProvider(null); + $provider->receive($request); + + $this->assertInstanceOf(Webhook::class, $provider->webhook()); + } + + #[Test] + public function it_denies_postmark_webhook_with_missing_header(): void + { + Config::set('services.postmark.webhook.verification_types', ['headers']); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Unauthorized'); + + $request = $this->mockBaseRequest(); + $request->allows('hasHeader')->with('X-Custom-Header')->andReturns(false); + + $provider = new PostmarkProvider(null); + $provider->receive($request); + } + + #[Test] + public function it_denies_postmark_webhook_with_wrong_header_value(): void + { + Config::set('services.postmark.webhook.verification_types', ['headers']); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Unauthorized'); + + $request = $this->mockBaseRequest(); + $request->allows('hasHeader')->with('X-Custom-Header')->andReturns(true); + $request->allows('header')->with('X-Custom-Header')->andReturns('WrongValue'); + + $provider = new PostmarkProvider(null); + $provider->receive($request); + } + + // ------------------------------------------------------------------------- + // verify() — IPs mode + // ------------------------------------------------------------------------- + + #[Test] + public function it_can_verify_postmark_webhook_via_allowed_ip(): void + { + Config::set('services.postmark.webhook.verification_types', ['ips']); + + $request = $this->mockBaseRequest(); + $request->allows('getClientIp')->andReturns('3.134.147.250'); + + $provider = new PostmarkProvider(null); + $provider->receive($request); + + $this->assertInstanceOf(Webhook::class, $provider->webhook()); + } + + #[Test] + public function it_denies_postmark_webhook_from_disallowed_ip(): void + { + Config::set('services.postmark.webhook.verification_types', ['ips']); + + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Unauthorized'); + + $request = $this->mockBaseRequest(); + $request->allows('getClientIp')->andReturns('1.2.3.4'); + + $provider = new PostmarkProvider(null); + $provider->receive($request); + } + + // ------------------------------------------------------------------------- + // verify() — no verification_types configured + // ------------------------------------------------------------------------- + + #[Test] + public function it_passes_when_no_verification_types_configured(): void + { + Config::set('services.postmark.webhook.verification_types', []); + + $request = $this->mockBaseRequest(); + + $provider = new PostmarkProvider(null); + $provider->receive($request); + + $this->assertInstanceOf(Webhook::class, $provider->webhook()); + } + + // ------------------------------------------------------------------------- + // getEvent() + // ------------------------------------------------------------------------- + + #[Test] + public function it_gets_record_type_event(): void + { + $request = Mockery::mock(Request::class); + $request->allows('filled')->with('RecordType')->andReturns(true); + $request->allows('input')->with('RecordType')->andReturns('Bounce'); + + $provider = new PostmarkProvider(null); + + $this->assertEquals('Bounce', $provider->getEvent($request)); + } + + #[Test] + public function it_defaults_to_inbound_event(): void + { + $request = Mockery::mock(Request::class); + $request->allows('filled')->with('RecordType')->andReturns(false); + + $provider = new PostmarkProvider(null); + + $this->assertEquals('Inbound', $provider->getEvent($request)); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + protected function mockBaseRequest(): Request + { + $payload = [ + 'RecordType' => 'Delivery', + 'MessageID' => '883953f4-6105-42a2-a16a-77a8eac79483', + ]; + + $request = Mockery::mock(Request::class); + $request->allows('filled')->with('RecordType')->andReturns(true); + $request->allows('input')->with('RecordType')->andReturns('Delivery'); + $request->allows('all')->andReturns($payload); + + return $request; + } +} diff --git a/tests/ProviderTest.php b/tests/ProviderTest.php index 4e2b65a..0fd9a64 100644 --- a/tests/ProviderTest.php +++ b/tests/ProviderTest.php @@ -4,16 +4,18 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use PHPUnit\Framework\Attributes\Test; use Receiver\Providers\Webhook; use Receiver\Tests\Fixtures\TestProvider; class ProviderTest extends TestCase { - public function test_handles_webhook_with_existing_handler() + #[Test] + public function handles_webhook_with_existing_handler(): void { $request = new Request($this->mockPayload()); - $provider = new TestProvider(); + $provider = new TestProvider; $response = $provider ->receive($request) @@ -23,7 +25,8 @@ public function test_handles_webhook_with_existing_handler() $this->assertInstanceOf(JsonResponse::class, $response); } - public function test_handles_webhook_with_missing_handler() + #[Test] + public function handles_webhook_with_missing_handler(): void { $this->expectExceptionMessage('Fallback!'); @@ -32,7 +35,7 @@ public function test_handles_webhook_with_missing_handler() $request = new Request($payload); - $provider = new TestProvider(); + $provider = new TestProvider; $response = $provider ->receive($request) @@ -42,7 +45,48 @@ public function test_handles_webhook_with_missing_handler() $this->assertInstanceOf(JsonResponse::class, $response); } - protected function mockPayload(string $key = null): mixed + #[Test] + public function handles_multiple_events_in_single_payload(): void + { + $events = [ + 'event_a' => ['id' => 1], + 'event_b' => ['id' => 2], + ]; + + $request = new Request(['event' => $events, 'data' => []]); + + $provider = new TestProvider; + + $response = $provider + ->receive($request) + ->ok(); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertTrue($provider->dispatched()); + $this->assertTrue($provider->dispatched(Fixtures\EventA::class)); + $this->assertTrue($provider->dispatched(Fixtures\EventB::class)); + } + + #[Test] + public function handler_class_resolved_case_insensitively(): void + { + // 'FOO.BARRED' and 'foo.barred' must resolve to the same class + $payload = $this->mockPayload(); + data_set($payload, 'event', 'FOO.BARRED'); + + $request = new Request($payload); + + $provider = new TestProvider; + + $response = $provider + ->receive($request) + ->ok(); + + $this->assertInstanceOf(JsonResponse::class, $response); + $this->assertTrue($provider->dispatched()); + } + + protected function mockPayload(?string $key = null): mixed { $payload = [ 'event' => 'foo.barred', diff --git a/tests/SendGridProviderTest.php b/tests/SendGridProviderTest.php new file mode 100644 index 0000000..9beda47 --- /dev/null +++ b/tests/SendGridProviderTest.php @@ -0,0 +1,161 @@ +allows('getContent')->andReturns(json_encode($this->mockBatch())); + $request->allows('input')->with('event')->andReturns(null); + $request->allows('all')->andReturns($this->mockBatch()); + + $provider = new SendGridProvider(''); + $provider->receive($request); + + $this->assertInstanceOf(Webhook::class, $provider->webhook()); + } + + #[Test] + public function it_denies_when_signature_header_is_missing(): void + { + $this->expectException(HttpException::class); + + $keyPair = $this->generateEcKeyPair(); + + $request = Mockery::mock(Request::class); + $request->allows('getContent')->andReturns('{}'); + $request->allows('header')->with('X-Twilio-Email-Event-Webhook-Signature')->andReturns(null); + $request->allows('header')->with('X-Twilio-Email-Event-Webhook-Timestamp')->andReturns('1234567890'); + + $provider = new SendGridProvider($keyPair['public']); + $provider->receive($request); + } + + #[Test] + public function it_verifies_a_valid_ecdsa_signature(): void + { + $keyPair = $this->generateEcKeyPair(); + $timestamp = (string) time(); + $payload = json_encode($this->mockBatch()); + $toSign = $timestamp.$payload; + + openssl_sign($toSign, $rawSignature, $keyPair['private'], OPENSSL_ALGO_SHA256); + $signature = base64_encode($rawSignature); + + $request = Mockery::mock(Request::class); + $request->allows('getContent')->andReturns($payload); + $request->allows('header')->with('X-Twilio-Email-Event-Webhook-Signature')->andReturns($signature); + $request->allows('header')->with('X-Twilio-Email-Event-Webhook-Timestamp')->andReturns($timestamp); + $request->allows('all')->andReturns($this->mockBatch()); + + $provider = new SendGridProvider($keyPair['public']); + $provider->receive($request); + + $this->assertInstanceOf(Webhook::class, $provider->webhook()); + } + + #[Test] + public function it_denies_an_invalid_ecdsa_signature(): void + { + $this->expectException(HttpException::class); + + $keyPair = $this->generateEcKeyPair(); + + $request = Mockery::mock(Request::class); + $request->allows('getContent')->andReturns(json_encode($this->mockBatch())); + $request->allows('header')->with('X-Twilio-Email-Event-Webhook-Signature')->andReturns(base64_encode('invalidsig')); + $request->allows('header')->with('X-Twilio-Email-Event-Webhook-Timestamp')->andReturns('1234567890'); + + $provider = new SendGridProvider($keyPair['public']); + $provider->receive($request); + } + + #[Test] + public function it_returns_a_map_of_unique_event_types(): void + { + $batch = $this->mockBatch(); + + $request = Mockery::mock(Request::class); + $request->allows('getContent')->andReturns(json_encode($batch)); + + $provider = new SendGridProvider(''); + + $result = $provider->getEvent($request); + + $this->assertIsArray($result); + $this->assertArrayHasKey('click', $result); + $this->assertArrayHasKey('open', $result); + $this->assertCount(2, $result); + } + + #[Test] + public function it_returns_only_the_first_event_per_type(): void + { + $batch = [ + ['event' => 'click', 'url' => 'https://first.example.com'], + ['event' => 'click', 'url' => 'https://second.example.com'], + ]; + + $request = Mockery::mock(Request::class); + $request->allows('getContent')->andReturns(json_encode($batch)); + + $provider = new SendGridProvider(''); + + $result = $provider->getEvent($request); + + $this->assertEquals('https://first.example.com', $result['click']['url']); + } + + #[Test] + public function it_returns_the_full_batch_as_data(): void + { + $batch = $this->mockBatch(); + + $request = Mockery::mock(Request::class); + $request->allows('getContent')->andReturns(json_encode($batch)); + + $provider = new SendGridProvider(''); + + $this->assertEquals($batch, $provider->getData($request)); + } + + protected function mockBatch(): array + { + return [ + [ + 'email' => 'example@test.com', + 'timestamp' => 1460565976, + 'event' => 'click', + 'url' => 'https://example.com', + ], + [ + 'email' => 'example@test.com', + 'timestamp' => 1460565977, + 'event' => 'open', + ], + ]; + } + + protected function generateEcKeyPair(): array + { + $key = openssl_pkey_new([ + 'private_key_bits' => 2048, + 'private_key_type' => OPENSSL_KEYTYPE_RSA, + ]); + + openssl_pkey_export($key, $privateKeyPem); + $publicKeyPem = openssl_pkey_get_details($key)['key']; + + return ['private' => $privateKeyPem, 'public' => $publicKeyPem]; + } +} diff --git a/tests/ShopifyProviderTest.php b/tests/ShopifyProviderTest.php new file mode 100644 index 0000000..4d5091d --- /dev/null +++ b/tests/ShopifyProviderTest.php @@ -0,0 +1,68 @@ + 123, 'email' => 'test@example.com']); + $signature = base64_encode(hash_hmac('sha256', $payload, $secret, true)); + + $request = Mockery::mock(Request::class); + $request->allows('getContent')->andReturns($payload); + $request->allows('header')->with('X-Shopify-Hmac-Sha256')->andReturns($signature); + $request->allows('header')->with('X-Shopify-Topic')->andReturns('orders/created'); + $request->allows('input')->with('id')->andReturns(123); + $request->allows('all')->andReturns(['id' => 123]); + + $provider = new ShopifyProvider($secret); + $provider->receive($request); + + $this->assertInstanceOf(Webhook::class, $provider->webhook()); + } + + #[Test] + public function it_denies_an_invalid_signature(): void + { + $this->expectException(HttpException::class); + + $request = Mockery::mock(Request::class); + $request->allows('getContent')->andReturns(json_encode(['id' => 123])); + $request->allows('header')->with('X-Shopify-Hmac-Sha256')->andReturns('invalidsignature'); + + $provider = new ShopifyProvider('shopify-webhook-secret'); + $provider->receive($request); + } + + #[Test] + public function it_converts_topic_slashes_to_underscores(): void + { + $request = Mockery::mock(Request::class); + $request->allows('header')->with('X-Shopify-Topic')->andReturns('orders/created'); + + $provider = new ShopifyProvider('secret'); + + $this->assertEquals('orders_created', $provider->getEvent($request)); + } + + #[Test] + public function it_returns_empty_string_when_no_topic(): void + { + $request = Mockery::mock(Request::class); + $request->allows('header')->with('X-Shopify-Topic')->andReturns(null); + + $provider = new ShopifyProvider('secret'); + + $this->assertEquals('', $provider->getEvent($request)); + } +} diff --git a/tests/SlackProviderTest.php b/tests/SlackProviderTest.php index 110fc76..82763d8 100644 --- a/tests/SlackProviderTest.php +++ b/tests/SlackProviderTest.php @@ -6,6 +6,7 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Mockery; +use PHPUnit\Framework\Attributes\Test; use Receiver\Providers\SlackProvider; use Receiver\Providers\Webhook; use Symfony\Component\HttpKernel\Exception\HttpException; @@ -14,10 +15,9 @@ class SlackProviderTest extends TestCase { /** * https://api.slack.com/apis/connections/events-api#the-events-api__subscribing-to-event-types__events-api-request-urls__request-url-configuration--verification. - * - * @return void */ - public function test_it_can_receive_slack_handshake() + #[Test] + public function it_can_receive_slack_handshake(): void { $request = Mockery::mock(Request::class); $request->allows('has')->with('challenge')->andReturns(true); @@ -34,7 +34,8 @@ public function test_it_can_receive_slack_handshake() $this->assertJson(json_encode(['challenge' => 'slack-challenge-token']), $response->content()); } - public function test_it_can_sign_and_verify_slack_webhook() + #[Test] + public function it_can_sign_and_verify_slack_webhook(): void { $valid_signature = 'v0=9cd89ead8bc70cf63775d36d04c592a4833c253d9f0f0c0b21762f0f6e9ae429'; $time = Carbon::parse('2022-08-01 12:00:00', 'America/Chicago'); @@ -57,7 +58,8 @@ public function test_it_can_sign_and_verify_slack_webhook() $this->assertInstanceOf(Webhook::class, $webhook); } - public function test_it_can_sign_and_deny_slack_webhook() + #[Test] + public function it_can_sign_and_deny_slack_webhook(): void { $this->expectException(HttpException::class); $this->expectExceptionMessage('Unauthorized'); @@ -76,12 +78,6 @@ public function test_it_can_sign_and_deny_slack_webhook() $provider->receive($request); } - /** - * @param Request $request - * @param int $timestamp - * @param string $signature - * @return Request - */ protected function signUsing(Request $request, int $timestamp, string $signature): Request { $request->allows('header')->with('X-Slack-Request-Timestamp')->andReturns($timestamp); @@ -93,11 +89,8 @@ protected function signUsing(Request $request, int $timestamp, string $signature /** * https://api.slack.com/apis/connections/events-api#the-events-api__receiving-events__event-type-structure. - * - * @param string|null $key - * @return mixed */ - protected function mockPayload(string $key = null): mixed + protected function mockPayload(?string $key = null): mixed { $data = [ 'token' => 'z26uFbvR1xHJEdHE1OQiO6t8', diff --git a/tests/StripeProviderTest.php b/tests/StripeProviderTest.php new file mode 100644 index 0000000..79ef2de --- /dev/null +++ b/tests/StripeProviderTest.php @@ -0,0 +1,108 @@ +mockPayload()); + + $signature = hash_hmac('sha256', "{$timestamp}.{$payload}", $secret); + $header = "t={$timestamp},v1={$signature}"; + + $request = Mockery::mock(Request::class); + $request->allows('has')->with('challenge')->andReturns(false); + $request->allows('only')->with('challenge')->andReturns([]); + $request->allows('getContent')->andReturns($payload); + $request->allows('header')->with('STRIPE_SIGNATURE')->andReturns($header); + $request->allows('input')->with('type')->andReturns($this->mockPayload('type')); + $request->allows('input')->with('data')->andReturns($this->mockPayload('data')); + $request->allows('all')->andReturns($this->mockPayload()); + + $provider = new StripeProvider($secret); + $provider->receive($request); + + $this->assertInstanceOf(Webhook::class, $provider->webhook()); + } + + #[Test] + public function it_denies_invalid_stripe_signature(): void + { + $this->expectException(HttpException::class); + $this->expectExceptionMessage('Unauthorized'); + + $request = Mockery::mock(Request::class); + $request->allows('has')->with('challenge')->andReturns(false); + $request->allows('only')->with('challenge')->andReturns([]); + $request->allows('getContent')->andReturns(json_encode($this->mockPayload())); + $request->allows('header')->with('STRIPE_SIGNATURE')->andReturns('t=1234,v1=invalidsig'); + + $provider = new StripeProvider('stripe-test-secret'); + $provider->receive($request); + } + + #[Test] + public function it_handles_stripe_handshake(): void + { + $request = Mockery::mock(Request::class); + $request->allows('has')->with('challenge')->andReturns(true); + $request->allows('only')->with('challenge')->andReturns(['challenge' => 'stripe-challenge-token']); + + $provider = new StripeProvider('stripe-test-secret'); + $provider->receive($request); + + $this->assertNull($provider->webhook()); + + $response = $provider->toResponse($request); + $this->assertInstanceOf(JsonResponse::class, $response); + } + + #[Test] + public function it_gets_event_from_type(): void + { + $request = Mockery::mock(Request::class); + $request->allows('input')->with('type')->andReturns('customer.created'); + + $provider = new StripeProvider('secret'); + + $this->assertEquals('customer.created', $provider->getEvent($request)); + } + + #[Test] + public function it_gets_data_from_data_key(): void + { + $request = Mockery::mock(Request::class); + $request->allows('input')->with('data')->andReturns(['object' => ['id' => 'cus_123']]); + + $provider = new StripeProvider('secret'); + + $this->assertEquals(['object' => ['id' => 'cus_123']], $provider->getData($request)); + } + + protected function mockPayload(?string $key = null): mixed + { + $data = [ + 'type' => 'customer.created', + 'data' => [ + 'object' => [ + 'id' => 'cus_123', + 'email' => 'test@example.com', + ], + ], + ]; + + return $key ? data_get($data, $key) : $data; + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 67036d2..32751c2 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -8,7 +8,6 @@ class TestCase extends \Orchestra\Testbench\TestCase { /** - * @param $app * @return void */ protected function getEnvironmentSetUp($app) @@ -26,6 +25,38 @@ protected function getEnvironmentSetUp($app) 'redirect' => 'http://your-callback-url', 'webhook_secret' => 'slack-webhook-secret', ]); + + $app['config']->set('services.shopify', [ + 'webhook_secret' => 'shopify-webhook-secret', + ]); + + $app['config']->set('services.twilio', [ + 'webhook_secret' => 'twilio-webhook-secret', + ]); + + $app['config']->set('services.mailchimp', [ + 'webhook_secret' => 'mailchimp-webhook-secret', + ]); + + $app['config']->set('services.sendgrid', [ + 'webhook_secret' => '', + ]); + + $app['config']->set('services.paddle', [ + 'webhook_secret' => 'paddle-webhook-secret', + ]); + + $app['config']->set('services.postmark.webhook', [ + 'headers' => [ + 'X-Custom-Header' => 'PostmarkExpected', + ], + 'ips' => [ + '3.134.147.250', + '50.31.156.6', + '50.31.156.77', + '18.217.206.57', + ], + ]); } /** diff --git a/tests/TwilioProviderTest.php b/tests/TwilioProviderTest.php new file mode 100644 index 0000000..55da59b --- /dev/null +++ b/tests/TwilioProviderTest.php @@ -0,0 +1,104 @@ + 'SM123', 'MessageStatus' => 'delivered']; + + ksort($params); + $data = $url; + foreach ($params as $key => $value) { + $data .= $key.$value; + } + $signature = base64_encode(hash_hmac('sha1', $data, $secret, true)); + + $request = Mockery::mock(Request::class); + $request->allows('header')->with('X-Twilio-Signature')->andReturns($signature); + $request->allows('fullUrl')->andReturns($url); + $request->allows('post')->andReturns($params); + $request->allows('input')->with('EventType')->andReturns(null); + $request->allows('input')->with('MessageStatus')->andReturns('delivered'); + $request->allows('all')->andReturns($params); + + $provider = new TwilioProvider($secret); + $provider->receive($request); + + $this->assertInstanceOf(Webhook::class, $provider->webhook()); + } + + #[Test] + public function it_denies_an_invalid_signature(): void + { + $this->expectException(HttpException::class); + + $request = Mockery::mock(Request::class); + $request->allows('header')->with('X-Twilio-Signature')->andReturns('invalidsig'); + $request->allows('fullUrl')->andReturns('https://example.com/webhook'); + $request->allows('post')->andReturns([]); + + $provider = new TwilioProvider('twilio-webhook-secret'); + $provider->receive($request); + } + + #[Test] + public function it_returns_false_when_signature_header_missing(): void + { + $request = Mockery::mock(Request::class); + $request->allows('header')->with('X-Twilio-Signature')->andReturns(null); + $request->allows('fullUrl')->andReturns('https://example.com/webhook'); + $request->allows('post')->andReturns([]); + + $provider = new TwilioProvider('secret'); + + $this->assertFalse($provider->verify($request)); + } + + #[Test] + public function it_returns_message_status_as_event(): void + { + $request = Mockery::mock(Request::class); + $request->allows('input')->with('EventType')->andReturns(null); + $request->allows('input')->with('MessageStatus')->andReturns('delivered'); + + $provider = new TwilioProvider('secret'); + + $this->assertEquals('delivered', $provider->getEvent($request)); + } + + #[Test] + public function it_returns_call_status_as_event(): void + { + $request = Mockery::mock(Request::class); + $request->allows('input')->with('EventType')->andReturns(null); + $request->allows('input')->with('MessageStatus')->andReturns(null); + $request->allows('input')->with('CallStatus')->andReturns('completed'); + + $provider = new TwilioProvider('secret'); + + $this->assertEquals('completed', $provider->getEvent($request)); + } + + #[Test] + public function it_prefers_event_type_over_status_fields(): void + { + $request = Mockery::mock(Request::class); + $request->allows('input')->with('EventType')->andReturns('com.twilio.messaging.message.delivered'); + + $provider = new TwilioProvider('secret'); + + $this->assertEquals('com.twilio.messaging.message.delivered', $provider->getEvent($request)); + } +}