<?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),
);
}
}