Summer Sale! All accounts are 50% off this week.

jesse_orange_newable's avatar

Issues with postJson

I am using the GoCardless Pro package to handle incoming webhooks from GoCardless. The controller method looks like so:

    /**
     * Handle an incoming webhook from GoCardless.
     */
    public function handleWebhook(Request $request)
    {
        $payload = $request->getContent();
        $signature_header = $request->header('Webhook-Signature');
        $webhook_endpoint_secret = config('gocardless.webhook_secret');

        try {
            $events = Webhook::parse(
                $payload,
                $signature_header,
                $webhook_endpoint_secret
            );

            event(new WebhookReceived(json_decode($payload, true), 'GoCardless'));

            foreach ($events as $event) {
                switch ($event->resource_type) {
                    case 'billing_requests':
                        $this->process_billing_requests($event);
                    break;
                    case 'payments':
                        $this->process_payments($event);
                    break;
                    default: Log::info('Ignoring Resource Type - ' . $event->resource_type);
                }
            }
        } catch (InvalidSignatureException $e) {
            throw new AccessDeniedException($e->getMessage());
        }
    }

This works as expected but when I run my test:

<?php
namespace Tests\Feature\Http\Controllers;

use GoCardlessPro\Webhook;
use Tests\TestCase;

class GocardlessWebhookControllerTest extends TestCase
{
    /**
     * The JSON payload we expect from the GoCardless webhook.
     */
    private $requestBody = '{
        "events": [
          {
            "id": "EV00PRB3XPQ6SM",
            "created_at": "2022-01-19T16:31:27.334Z",
            "resource_type": "billing_requests",
            "action": "collect_customer_details",
            "links": {
              "customer": "CU000KCYTG87AG",
              "billing_request_flow": "BRF00006MD132JNYB34090X5BY8MB7WH",
              "billing_request": "BRQ00006VYYE38D"
            },
            "details": {
              "origin": "api",
              "cause": "billing_request_collect_customer_details",
              "description": "Customer details have been collected for this billing request."
            },
            "metadata": {}
          },
          {
            "id": "EV00PRB4MN1FEW",
            "created_at": "2022-01-19T16:31:33.556Z",
            "resource_type": "billing_requests",
            "action": "collect_bank_account",
            "links": {
              "customer": "CU000KCYTG87AG",
              "customer_bank_account": "BA000JTPBSYCHH",
              "billing_request_flow": "BRF00006MD132JNYB34090X5BY8MB7WH",
              "billing_request": "BRQ00006VYYE38D"
            },
            "details": {
              "origin": "api",
              "cause": "billing_request_collect_bank_account",
              "description": "Bank account details have been collected for this billing request."
            },
            "metadata": {}
          },
          {
            "id": "EV00PRB5RN2WFM",
            "created_at": "2022-01-19T16:31:44.368Z",
            "resource_type": "billing_requests",
            "action": "payer_details_confirmed",
            "links": {
              "customer": "CU000KCYTG87AG",
              "customer_bank_account": "BA000JTPBSYCHH",
              "billing_request_flow": "BRF00006MD132JNYB34090X5BY8MB7WH",
              "billing_request": "BRQ00006VYYE38D"
            },
            "details": {
              "origin": "api",
              "cause": "billing_request_payer_details_confirmed",
              "description": "Payer has confirmed all their details for this billing request."
            },
            "metadata": {}
          },
          {
            "id": "EV00PRB5W4RKJD",
            "created_at": "2022-01-19T16:31:45.303Z",
            "resource_type": "billing_requests",
            "action": "bank_authorisation_authorised",
            "links": {
              "customer": "CU000KCYTG87AG",
              "customer_bank_account": "BA000JTPBSYCHH",
              "bank_authorisation": "BAU00003RY4GSJG",
              "institution_id": "payment_failed_sandbox_bank",
              "billing_request": "BRQ00006VYYE38D"
            },
            "details": {
              "origin": "payer",
              "cause": "billing_request_bank_authorisation_authorised",
              "bank_account_id": "BA000JTPBSYCHH",
              "description": "A bank authorisation for this billing request has been authorised by the payer."
            },
            "metadata": {}
          },
          {
            "id": "EV00PRB5WXS86W",
            "created_at": "2022-01-19T16:31:45.511Z",
            "resource_type": "mandates",
            "action": "active",
            "links": {
              "mandate": "MD000JTGT82N9J"
            },
            "details": {
              "origin": "gocardless",
              "cause": "mandate_activated",
              "description": "The time window after submission for the banks to refuse a mandate has ended without any errors being received, so this mandate is now active."
            },
            "metadata": {}
          },
          {
            "id": "EV00PRB5X2476R",
            "created_at": "2022-01-19T16:31:45.534Z",
            "resource_type": "mandates",
            "action": "created",
            "links": {
              "mandate": "MD000JTGT82N9J"
            },
            "details": {
              "origin": "api",
              "cause": "mandate_created",
              "description": "Mandate created via the API."
            },
            "metadata": {}
          },
          {
            "id": "EV00PRB5XH1YYY",
            "created_at": "2022-01-19T16:31:45.645Z",
            "resource_type": "payments",
            "action": "created",
            "links": {
              "payment": "PM004GQ682E7XH"
            },
            "details": {
              "origin": "payer",
              "cause": "payment_created",
              "description": "Payment created by payer (via a payment request)."
            },
            "metadata": {}
          },
          {
            "id": "EV00PRB5XWK9RP",
            "created_at": "2022-01-19T16:31:45.714Z",
            "resource_type": "mandates",
            "action": "consumed",
            "links": {
              "mandate": "MD000JTGT82N9J"
            },
            "details": {
              "origin": "gocardless",
              "cause": "mandate_consumed",
              "description": "The mandate has been used to create a payment, and has now been consumed. It cannot be used again."
            },
            "metadata": {}
          },
          {
            "id": "EV00PRB5YD6YFZ",
            "created_at": "2022-01-19T16:31:45.822Z",
            "resource_type": "mandates",
            "action": "created",
            "links": {
              "mandate": "MD000JTGT9SES7"
            },
            "details": {
              "origin": "api",
              "cause": "mandate_created",
              "description": "Mandate created via the API."
            },
            "metadata": {}
          },
          {
            "id": "EV00PRB5YTTYKR",
            "created_at": "2022-01-19T16:31:45.926Z",
            "resource_type": "billing_requests",
            "action": "fulfilled",
            "links": {
              "customer": "CU000KCYTG87AG",
              "customer_bank_account": "BA000JTPBSYCHH",
              "payment_request_payment": "PM004GQ682E7XH",
              "mandate_request_mandate": "MD000JTGT9SES7",
              "billing_request": "BRQ00006VYYE38D"
            },
            "details": {
              "origin": "gocardless",
              "cause": "billing_request_fulfilled",
              "description": "This billing request has been fulfilled, and the resources have been created."
            },
            "metadata": {}
          },
          {
            "id": "EV00PRB6BSRW67",
            "created_at": "2022-01-19T16:31:49.153Z",
            "resource_type": "payments",
            "action": "submitted",
            "links": {
              "payment": "PM004GQ682E7XH"
            },
            "details": {
              "origin": "gocardless",
              "cause": "payment_submitted",
              "bank_account_id": "BA000JTPBSYCHH",
              "description": "Payment submitted to the banks. As a result, it can no longer be cancelled."
            },
            "metadata": {}
          },
          {
            "id": "EV00PRB6S1A8BQ",
            "created_at": "2022-01-19T16:31:52.670Z",
            "resource_type": "payments",
            "action": "failed",
            "links": {
              "payment": "PM004GQ682E7XH"
            },
            "details": {
              "origin": "bank",
              "cause": "other",
              "scheme": "faster_payments",
              "reason_code": "FAILED",
              "will_attempt_retry": false,
              "not_retried_reason": "other",
              "bank_account_id": "BA000JTPBSYCHH",
              "description": "The payment failed but the reason for the failure was not provided, usually for regulatory reasons."
            },
            "metadata": {}
          }
        ],
        "meta": {
          "webhook_id": "WB0014APC38MQ4"
        }
      }';

    /**
     * A secret we would set for an endpoint within GoCardless
     */
    private $webhookEndpointSecret = 'secret-key-that-is-used';

    /**
     * Determine whether our webhook controller can handle a webhook.
     * For reference we expect our signature header to be: "8f8c9d66392b2e0a0cdd2a591e9ede806f1c12b873b350215af772e50e091ffa"
     *
     * @test
     */
    public function it_can_retrieve_a_webhook()
    {
        $expectedCompitedSignatureHeader = hash_hmac('sha256', $this->requestBody, $this->webhookEndpointSecret);

        // $this->assertTrue(Webhook::isSignatureValid($this->requestBody, $expectedCompitedSignatureHeader, $this->webhookEndpointSecret));

        $payload = json_decode($this->requestBody, true);

        $this->assertSame($this->webhookEndpointSecret, config('gocardless.webhook_secret'));

        $this->postJson(route('webhook-gocardless'), $payload, [
            'webhook-signature' => $expectedCompitedSignatureHeader
        ])
        ->assertSessionHasNoErrors()
        ->assertStatus(200);
    }
}

It always fails as postJson converts some curly braces to square braces.

Here is the Webhook::parse method

<?php
namespace GoCardlessPro;

/**
 * Class containing a collection of functions for validating and parsing GoCardless webhooks
 */
class Webhook
{
    const INVALID_SIGNATURE_MESSAGE = "This webhook doesn't appear to be a genuine " .
                                      'webhook from GoCardless, because the signature ' .
                                      "header doesn't match the signature computed with" .
                                      ' your webhook endpoint secret.';

    /**
     * Validates that a webhook was genuinely sent by GoCardless using `isValidSignature`,
     * and then parses it into an array of `GoCardlessPro::Resources::Event`
     * objects representing each event included in the webhook.
     *
     * @param  string                          $request_body            the request body
     * @param  string                          $signature_header        the signature included in the request, found in the
     *                                                                  `Webhook-Signature` header
     * @param  string                          $webhook_endpoint_secret the webhook endpoint secret for your webhook
     *                                                                  endpoint, as configured in your GoCardless Dashboard
     * @return GoCardlessPro\Resources\Event[] the events included in the
     *                                                                 webhook
     * @raises GoCardlessPro\Core\Exception\InvalidSignatureException if the
     *     signature header specified does not match the signature computed using the
     *     request body and webhook endpoint secret
     */
    public static function parse($request_body, $signature_header, $webhook_endpoint_secret)
    {
        if (self::isSignatureValid($request_body, $signature_header, $webhook_endpoint_secret)) {
            $events = json_decode($request_body)->events;
            return array_map('self::buildEvent', $events);
        } else {
            throw new Core\Exception\InvalidSignatureException(self::INVALID_SIGNATURE_MESSAGE);
        }
    }

    /**
     * Validates that a webhook was genuinely sent by GoCardless by computing its
     * signature using the body and your webhook endpoint secret, and comparing that with
     * the signature included in the `Webhook-Signature` header.
     *
     * @param  string  $request_body            the request body
     * @param  string  $signature_header        the signature included in the request, found in the
     *                                          `Webhook-Signature` header
     * @param  string  $webhook_endpoint_secret the webhook endpoint secret for your webhook
     *                                          endpoint, as configured in your GoCardless Dashboard
     * @return boolean whether the webhook's signature is valid
     */
    public static function isSignatureValid($request_body, $signature_header, $webhook_endpoint_secret)
    {
        $computed_signature = hash_hmac('sha256', $request_body, $webhook_endpoint_secret);

        return hash_equals($computed_signature, $signature_header);
    }

    /**
     * Internal function for converting a parsed stdObject into an event resource
     */
    private static function buildEvent($event)
    {
        return new Resources\Event($event);
    }
}

Is there a way to pass raw JSON rather than having to convert back and forth?

0 likes
8 replies
rodrigo.pedra's avatar

Try changing the true parameter to false on json_decode call on your tests:

$payload = json_decode($this->requestBody, false);

From PHP docs:

associative When true, JSON objects will be returned as associative arrays; when false, JSON objects will be returned as objects. When null, JSON objects will be returned as associative arrays or objects depending on whether JSON_OBJECT_AS_ARRAY is set in the flags.

reference: https://www.php.net/manual/en/function.json-decode.php

rodrigo.pedra's avatar

@jesse_orange_newable yes.

The true on json_decode will convert objects to associative arrays which are easier to work with, but the drawback is that empty objects such the metadata properties are converted to an empty array, so when you json_encode it back they end up as an empty array.

The false on json_decode will convert objects to \stdClass instances, which accept dynamic properties,, but the drawback is that you cannot use array like notation to access its properties, so complex property name would be hard to access, but that is very unlikely to happen, but when you json_encode it back empty objects are encoded as empty objects.

jesse_orange_newable's avatar

@rodrigo.pedra in doing so I get the following error:

Tests\Feature\Http\Controllers\GocardlessWebhookControllerTest::it_can_retrieve_a_webhook
TypeError: Argument 2 passed to Illuminate\Foundation\Testing\TestCase::postJson() must be of the type array, object given, called in C:\laragon\www\newflex-gocardless-integration\tests\Feature\Http\Controllers\GocardlessWebhookControllerTest.php on line 245
jesse_orange_newable's avatar

@rodrigo.pedra thank you for all your help, it was the pretty formatting of the string in the test... as it took the spacing as additional characters.

1 like

Please or to participate in this conversation.