Newer
Older
gn-synapse-client-php / tests / WebhookVerifierTest.php
<?php

declare(strict_types=1);

namespace GNexus\Synapse\Tests\Webhook;

use GNexus\Synapse\Exception\InvalidWebhookException;
use GNexus\Synapse\Webhook\WebhookVerifier;
use PHPUnit\Framework\TestCase;

final class WebhookVerifierTest extends TestCase
{
    private const SECRET = 's2s-secret-test';
    private const EVENT_ID = '11111111-1111-1111-1111-111111111111';
    private const TS = 1_000_000;

    private static function body(): string
    {
        return json_encode([
            'event_id' => self::EVENT_ID,
            'source' => 'monitoring',
            'subject' => 'container',
            'action' => 'down',
            'priority' => 'critical',
            'payload' => ['container' => 'api'],
        ], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
    }

    public function testHappyPathReturnsEnvelope(): void
    {
        $header = WebhookVerifier::makeSignature(self::body(), self::SECRET, self::TS);
        $envelope = WebhookVerifier::verify(
            self::body(),
            [WebhookVerifier::SIGNATURE_HEADER => $header],
            self::SECRET,
            WebhookVerifier::DEFAULT_MAX_SKEW,
            self::TS,
        );
        self::assertSame('monitoring', $envelope['source']);
        self::assertSame(self::EVENT_ID, $envelope['event_id']);
    }

    public function testHeaderValueMayBeList(): void
    {
        // Laravel Request::headers->all(): значение — массив заголовков с этим именем
        $header = WebhookVerifier::makeSignature(self::body(), self::SECRET, self::TS);
        $envelope = WebhookVerifier::verify(
            self::body(),
            ['X-Gnexus-Signature' => [$header], 'X-Synapse-Source' => ['monitoring']],
            self::SECRET,
            WebhookVerifier::DEFAULT_MAX_SKEW,
            self::TS,
        );
        self::assertSame('down', $envelope['action']);
    }

    public function testWrongSecretRejected(): void
    {
        $header = WebhookVerifier::makeSignature(self::body(), 'other-secret', self::TS);
        try {
            WebhookVerifier::verify(self::body(), ['x-gnexus-signature' => $header], self::SECRET,
                WebhookVerifier::DEFAULT_MAX_SKEW, self::TS);
            self::fail('чужой секрет принят');
        } catch (InvalidWebhookException $ex) {
            self::assertIsString($ex->detail);
            self::assertStringContainsString('не совпала', $ex->detail);
        }
    }

    public function testReplayRejected(): void
    {
        $header = WebhookVerifier::makeSignature(self::body(), self::SECRET, self::TS);
        try {
            WebhookVerifier::verify(self::body(), ['x-gnexus-signature' => $header], self::SECRET,
                WebhookVerifier::DEFAULT_MAX_SKEW, self::TS + WebhookVerifier::DEFAULT_MAX_SKEW + 1);
            self::fail('старая доставка принята');
        } catch (InvalidWebhookException $ex) {
            self::assertStringContainsString('replay', $ex->detail);
        }
    }

    public function testModifiedBodyRejected(): void
    {
        $header = WebhookVerifier::makeSignature(self::body(), self::SECRET, self::TS);
        try {
            WebhookVerifier::verify(self::body() . ' ', ['x-gnexus-signature' => $header], self::SECRET,
                WebhookVerifier::DEFAULT_MAX_SKEW, self::TS);
            self::fail('искажённое тело принято');
        } catch (InvalidWebhookException $ex) {
            self::assertIsString($ex->detail);
        }
    }

    public static function badHeadersProvider(): iterable
    {
        yield 'no header' => [[]];
        yield 'only t' => [['x-gnexus-signature' => 't=1']];
        yield 'garbage' => [['x-gnexus-signature' => 'garbage']];
        yield 'empty v1' => [['x-gnexus-signature' => 't=' . self::TS . ',v1=']];
        yield 't not int' => [['x-gnexus-signature' => 't=abc,v1=zz']];
    }

    #[\PHPUnit\Framework\Attributes\DataProvider('badHeadersProvider')]
    public function testBadHeadersRejected(array $headers): void
    {
        try {
            WebhookVerifier::verify(self::body(), $headers, self::SECRET, WebhookVerifier::DEFAULT_MAX_SKEW, self::TS);
            self::fail('битые заголовки приняты');
        } catch (InvalidWebhookException $ex) {
            self::assertIsString($ex->detail);
        }
    }

    public function testNonJsonBodyRejected(): void
    {
        $header = WebhookVerifier::makeSignature('not json', self::SECRET, self::TS);
        try {
            WebhookVerifier::verify('not json', ['x-gnexus-signature' => $header], self::SECRET,
                WebhookVerifier::DEFAULT_MAX_SKEW, self::TS);
            self::fail('не-JSON тело принято');
        } catch (InvalidWebhookException $ex) {
            self::assertIsString($ex->detail);
        }
    }

    public function testListBodyRejected(): void
    {
        $header = WebhookVerifier::makeSignature('[1,2]', self::SECRET, self::TS);
        try {
            WebhookVerifier::verify('[1,2]', ['x-gnexus-signature' => $header], self::SECRET,
                WebhookVerifier::DEFAULT_MAX_SKEW, self::TS);
            self::fail('JSON-массив принят как конверт');
        } catch (InvalidWebhookException $ex) {
            self::assertStringContainsString('не JSON-объект', $ex->detail);
        }
    }

    // -- хелперы контекста и ответа ------------------------------------

    public function testParseEventType(): void
    {
        self::assertSame(
            ['monitoring', 'container', 'down'],
            WebhookVerifier::parseEventType('monitoring.container.down'),
        );
        self::assertNull(WebhookVerifier::parseEventType('a.b'));
        self::assertNull(WebhookVerifier::parseEventType('..'));
        self::assertNull(WebhookVerifier::parseEventType(null));
    }

    public function testAck(): void
    {
        self::assertSame(['received' => true], WebhookVerifier::ack());
        self::assertSame(
            ['received' => true, 'event_id' => self::EVENT_ID],
            WebhookVerifier::ack(self::EVENT_ID),
        );
    }
}