From 292f71a057b2d82f7ac918039d33cfd3f91baa2e Mon Sep 17 00:00:00 2001 From: Antonio Ramirez Date: Sun, 31 May 2026 08:01:01 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat(webhooks):=20univeros/webhooks=20?= =?UTF-8?q?=E2=80=94=20storage=20contracts=20+=20signers=20+=20adapters=20?= =?UTF-8?q?(#185)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Foundation sub-package for the webhook epic (#184): HMAC-SHA256/512 + Ed25519 signers (constant-time verify), SignerRegistry, EnvSecretResolver, inbound dedupe contract + InMemory/Redis adapters, and the outbound Delivery value object + DeliveryStatus enum + store contract with InMemory/Redis adapters. No coupling to univeros/http. --- .../Contracts/DeliveryStoreInterface.php | 36 ++++++ .../InboundDeduplicatorInterface.php | 28 ++++ .../Contracts/SecretResolverInterface.php | 24 ++++ .../Webhooks/Contracts/SignerInterface.php | 35 +++++ .../SignatureVerificationException.php | 20 +++ .../Webhooks/Exception/WebhookException.php | 37 ++++++ .../Webhooks/Signing/AbstractHmacSigner.php | 70 ++++++++++ src/Altair/Webhooks/Signing/Ed25519Signer.php | 90 +++++++++++++ .../Webhooks/Signing/EnvSecretResolver.php | 45 +++++++ .../Webhooks/Signing/HmacSha256Signer.php | 20 +++ .../Webhooks/Signing/HmacSha512Signer.php | 20 +++ .../Webhooks/Signing/SignerRegistry.php | 72 +++++++++++ src/Altair/Webhooks/Storage/Delivery.php | 121 ++++++++++++++++++ .../Webhooks/Storage/DeliveryStatus.php | 20 +++ .../Webhooks/Storage/InMemoryDeduplicator.php | 53 ++++++++ .../Storage/InMemoryDeliveryStore.php | 50 ++++++++ .../Webhooks/Storage/RedisDeduplicator.php | 41 ++++++ .../Webhooks/Storage/RedisDeliveryStore.php | 82 ++++++++++++ src/Altair/Webhooks/composer.json | 30 +++++ tests/Webhooks/Signing/Ed25519SignerTest.php | 73 +++++++++++ .../Signing/EnvSecretResolverTest.php | 60 +++++++++ .../Webhooks/Signing/HmacSha256SignerTest.php | 95 ++++++++++++++ .../Webhooks/Signing/HmacSha512SignerTest.php | 49 +++++++ tests/Webhooks/Signing/SignerRegistryTest.php | 59 +++++++++ tests/Webhooks/Storage/DeliveryTest.php | 86 +++++++++++++ .../Storage/InMemoryDeduplicatorTest.php | 48 +++++++ .../Storage/InMemoryDeliveryStoreTest.php | 76 +++++++++++ .../Storage/RedisDeduplicatorTest.php | 63 +++++++++ .../Storage/RedisDeliveryStoreTest.php | 99 ++++++++++++++ 29 files changed, 1602 insertions(+) create mode 100644 src/Altair/Webhooks/Contracts/DeliveryStoreInterface.php create mode 100644 src/Altair/Webhooks/Contracts/InboundDeduplicatorInterface.php create mode 100644 src/Altair/Webhooks/Contracts/SecretResolverInterface.php create mode 100644 src/Altair/Webhooks/Contracts/SignerInterface.php create mode 100644 src/Altair/Webhooks/Exception/SignatureVerificationException.php create mode 100644 src/Altair/Webhooks/Exception/WebhookException.php create mode 100644 src/Altair/Webhooks/Signing/AbstractHmacSigner.php create mode 100644 src/Altair/Webhooks/Signing/Ed25519Signer.php create mode 100644 src/Altair/Webhooks/Signing/EnvSecretResolver.php create mode 100644 src/Altair/Webhooks/Signing/HmacSha256Signer.php create mode 100644 src/Altair/Webhooks/Signing/HmacSha512Signer.php create mode 100644 src/Altair/Webhooks/Signing/SignerRegistry.php create mode 100644 src/Altair/Webhooks/Storage/Delivery.php create mode 100644 src/Altair/Webhooks/Storage/DeliveryStatus.php create mode 100644 src/Altair/Webhooks/Storage/InMemoryDeduplicator.php create mode 100644 src/Altair/Webhooks/Storage/InMemoryDeliveryStore.php create mode 100644 src/Altair/Webhooks/Storage/RedisDeduplicator.php create mode 100644 src/Altair/Webhooks/Storage/RedisDeliveryStore.php create mode 100644 src/Altair/Webhooks/composer.json create mode 100644 tests/Webhooks/Signing/Ed25519SignerTest.php create mode 100644 tests/Webhooks/Signing/EnvSecretResolverTest.php create mode 100644 tests/Webhooks/Signing/HmacSha256SignerTest.php create mode 100644 tests/Webhooks/Signing/HmacSha512SignerTest.php create mode 100644 tests/Webhooks/Signing/SignerRegistryTest.php create mode 100644 tests/Webhooks/Storage/DeliveryTest.php create mode 100644 tests/Webhooks/Storage/InMemoryDeduplicatorTest.php create mode 100644 tests/Webhooks/Storage/InMemoryDeliveryStoreTest.php create mode 100644 tests/Webhooks/Storage/RedisDeduplicatorTest.php create mode 100644 tests/Webhooks/Storage/RedisDeliveryStoreTest.php diff --git a/src/Altair/Webhooks/Contracts/DeliveryStoreInterface.php b/src/Altair/Webhooks/Contracts/DeliveryStoreInterface.php new file mode 100644 index 0000000..9e51348 --- /dev/null +++ b/src/Altair/Webhooks/Contracts/DeliveryStoreInterface.php @@ -0,0 +1,36 @@ + + */ + public function findFailed(int $limit = 100): array; +} diff --git a/src/Altair/Webhooks/Contracts/InboundDeduplicatorInterface.php b/src/Altair/Webhooks/Contracts/InboundDeduplicatorInterface.php new file mode 100644 index 0000000..f463d06 --- /dev/null +++ b/src/Altair/Webhooks/Contracts/InboundDeduplicatorInterface.php @@ -0,0 +1,28 @@ +algo(); + } + + public function sign(string $payload, string $secret): string + { + return hash_hmac($this->algo(), $payload, $secret); + } + + public function verify(string $payload, string $signature, string $secret): bool + { + $provided = $this->extractMac($signature); + if ($provided === '') { + return false; + } + + // Constant-time comparison. hash_equals never short-circuits on a + // partial match, which is the whole point for HMAC verification. + return hash_equals($this->sign($payload, $secret), $provided); + } + abstract protected function algo(): string; + + /** + * Accept either a bare hex MAC or Stripe's `t=,v1=` form (the v1= + * component is extracted). Keeps the middleware signer-agnostic. + */ + private function extractMac(string $signature): string + { + $trimmed = trim($signature); + if ($trimmed === '') { + return ''; + } + + if (!str_contains($trimmed, 'v1=')) { + return $trimmed; + } + + foreach (explode(',', $trimmed) as $part) { + [$key, $value] = array_pad(explode('=', trim($part), 2), 2, ''); + if ($key === 'v1') { + return trim($value); + } + } + + return ''; + } +} diff --git a/src/Altair/Webhooks/Signing/Ed25519Signer.php b/src/Altair/Webhooks/Signing/Ed25519Signer.php new file mode 100644 index 0000000..d7a1295 --- /dev/null +++ b/src/Altair/Webhooks/Signing/Ed25519Signer.php @@ -0,0 +1,90 @@ +decodeKey($secret, SODIUM_CRYPTO_SIGN_SECRETKEYBYTES, 'secret'); + + return sodium_bin2hex(sodium_crypto_sign_detached($payload, $key)); + } + + public function verify(string $payload, string $signature, string $secret): bool + { + try { + $sig = sodium_hex2bin(trim($signature)); + $publicKey = sodium_hex2bin(trim($secret)); + } catch (SodiumException) { + return false; + } + + if (\strlen($sig) !== SODIUM_CRYPTO_SIGN_BYTES) { + return false; + } + + if (\strlen($publicKey) !== SODIUM_CRYPTO_SIGN_PUBLICKEYBYTES) { + return false; + } + + try { + return sodium_crypto_sign_verify_detached($sig, $payload, $publicKey); + } catch (SodiumException) { + return false; + } + } + + /** + * @return non-empty-string + */ + private function decodeKey(string $hex, int $expectedBytes, string $label): string + { + try { + $key = sodium_hex2bin(trim($hex)); + } catch (SodiumException) { + throw WebhookException::signerUnavailable('ed25519', \sprintf('the %s key is not valid hex.', $label)); + } + + if ($key === '' || \strlen($key) !== $expectedBytes) { + throw WebhookException::signerUnavailable( + 'ed25519', + \sprintf('the %s key must be %d bytes (%d hex chars).', $label, $expectedBytes, $expectedBytes * 2), + ); + } + + return $key; + } +} diff --git a/src/Altair/Webhooks/Signing/EnvSecretResolver.php b/src/Altair/Webhooks/Signing/EnvSecretResolver.php new file mode 100644 index 0000000..e104a33 --- /dev/null +++ b/src/Altair/Webhooks/Signing/EnvSecretResolver.php @@ -0,0 +1,45 @@ + from the environment, where + * is the upper-cased secret name with non-alphanumerics folded to '_'. + * Hosts needing KMS / vault integration implement SecretResolverInterface + * themselves; this is the zero-config default. + */ +final readonly class EnvSecretResolver implements SecretResolverInterface +{ + public function __construct( + private string $prefix = 'WEBHOOK_SECRET_', + ) {} + + public function resolve(string $name): string + { + $envKey = $this->prefix . $this->normalise($name); + $value = getenv($envKey); + + if ($value === false || $value === '') { + throw WebhookException::missingSecret($name); + } + + return $value; + } + + private function normalise(string $name): string + { + return strtoupper((string) preg_replace('/[^A-Za-z0-9]+/', '_', $name)); + } +} diff --git a/src/Altair/Webhooks/Signing/HmacSha256Signer.php b/src/Altair/Webhooks/Signing/HmacSha256Signer.php new file mode 100644 index 0000000..69dbae2 --- /dev/null +++ b/src/Altair/Webhooks/Signing/HmacSha256Signer.php @@ -0,0 +1,20 @@ + */ + private array $signers = []; + + /** + * @param iterable $signers + */ + public function __construct(iterable $signers = []) + { + foreach ($signers as $signer) { + $this->register($signer); + } + } + + /** + * Build a registry with the always-available HMAC signers, plus Ed25519 + * when ext-sodium is loaded. + */ + public static function default(): self + { + $signers = [new HmacSha256Signer(), new HmacSha512Signer()]; + if (\extension_loaded('sodium')) { + $signers[] = new Ed25519Signer(); + } + + return new self($signers); + } + + public function register(SignerInterface $signer): void + { + $this->signers[$signer->name()] = $signer; + } + + public function has(string $name): bool + { + return isset($this->signers[$name]); + } + + public function get(string $name): SignerInterface + { + return $this->signers[$name] ?? throw WebhookException::unknownSigner($name); + } + + /** + * @return list + */ + public function names(): array + { + return array_keys($this->signers); + } +} diff --git a/src/Altair/Webhooks/Storage/Delivery.php b/src/Altair/Webhooks/Storage/Delivery.php new file mode 100644 index 0000000..5dc6824 --- /dev/null +++ b/src/Altair/Webhooks/Storage/Delivery.php @@ -0,0 +1,121 @@ +with(status: $status); + } + + public function withAttempts(int $attempts): self + { + return $this->with(attempts: $attempts); + } + + public function withLastAttemptAt(int $lastAttemptAt): self + { + return $this->with(lastAttemptAt: $lastAttemptAt); + } + + public function withNextAttemptAt(?int $nextAttemptAt): self + { + return $this->with(nextAttemptAtProvided: true, nextAttemptAt: $nextAttemptAt); + } + + public function withLastResponse(?string $lastResponse): self + { + return $this->with(lastResponseProvided: true, lastResponse: $lastResponse); + } + + /** + * Reset for replay: status back to Pending, attempt counter to zero, and + * any scheduled next-attempt cleared. Preserves identity + payload. + */ + public function reset(): self + { + return $this->with( + status: DeliveryStatus::Pending, + attempts: 0, + nextAttemptAtProvided: true, + nextAttemptAt: null, + ); + } + + private function with( + ?DeliveryStatus $status = null, + ?int $attempts = null, + ?int $lastAttemptAt = null, + bool $nextAttemptAtProvided = false, + ?int $nextAttemptAt = null, + bool $lastResponseProvided = false, + ?string $lastResponse = null, + ): self { + return new self( + id: $this->id, + eventName: $this->eventName, + subscriberUrl: $this->subscriberUrl, + payload: $this->payload, + secretName: $this->secretName, + signerName: $this->signerName, + status: $status ?? $this->status, + attempts: $attempts ?? $this->attempts, + createdAt: $this->createdAt, + lastAttemptAt: $lastAttemptAt ?? $this->lastAttemptAt, + nextAttemptAt: $nextAttemptAtProvided ? $nextAttemptAt : $this->nextAttemptAt, + lastResponse: $lastResponseProvided ? $lastResponse : $this->lastResponse, + ); + } +} diff --git a/src/Altair/Webhooks/Storage/DeliveryStatus.php b/src/Altair/Webhooks/Storage/DeliveryStatus.php new file mode 100644 index 0000000..91ea62d --- /dev/null +++ b/src/Altair/Webhooks/Storage/DeliveryStatus.php @@ -0,0 +1,20 @@ + eventId => expiresAt (epoch seconds) */ + private array $claims = []; + + public function claim(string $eventId, int $ttlSeconds): bool + { + $this->purgeExpired(); + + if (isset($this->claims[$eventId])) { + return false; + } + + $this->claims[$eventId] = $this->now() + $ttlSeconds; + + return true; + } + + public function release(string $eventId): void + { + unset($this->claims[$eventId]); + } + + private function purgeExpired(): void + { + $now = $this->now(); + foreach ($this->claims as $eventId => $expiresAt) { + if ($expiresAt <= $now) { + unset($this->claims[$eventId]); + } + } + } + + private function now(): int + { + return time(); + } +} diff --git a/src/Altair/Webhooks/Storage/InMemoryDeliveryStore.php b/src/Altair/Webhooks/Storage/InMemoryDeliveryStore.php new file mode 100644 index 0000000..c61c13e --- /dev/null +++ b/src/Altair/Webhooks/Storage/InMemoryDeliveryStore.php @@ -0,0 +1,50 @@ + */ + private array $deliveries = []; + + public function record(Delivery $delivery): void + { + $this->deliveries[$delivery->id] = $delivery; + } + + public function update(Delivery $delivery): void + { + $this->deliveries[$delivery->id] = $delivery; + } + + public function findById(string $deliveryId): ?Delivery + { + return $this->deliveries[$deliveryId] ?? null; + } + + public function findFailed(int $limit = 100): array + { + $failed = array_values(array_filter( + $this->deliveries, + static fn(Delivery $delivery): bool => $delivery->status === DeliveryStatus::DeadLettered, + )); + + usort( + $failed, + static fn(Delivery $a, Delivery $b): int => [$a->createdAt, $a->id] <=> [$b->createdAt, $b->id], + ); + + return \array_slice($failed, 0, $limit); + } +} diff --git a/src/Altair/Webhooks/Storage/RedisDeduplicator.php b/src/Altair/Webhooks/Storage/RedisDeduplicator.php new file mode 100644 index 0000000..500ee0e --- /dev/null +++ b/src/Altair/Webhooks/Storage/RedisDeduplicator.php @@ -0,0 +1,41 @@ +redis->set( + $this->prefix . $eventId, + '1', + ['nx', 'ex' => $ttlSeconds], + ); + + return $result !== false; + } + + public function release(string $eventId): void + { + $this->redis->del($this->prefix . $eventId); + } +} diff --git a/src/Altair/Webhooks/Storage/RedisDeliveryStore.php b/src/Altair/Webhooks/Storage/RedisDeliveryStore.php new file mode 100644 index 0000000..cb1a01b --- /dev/null +++ b/src/Altair/Webhooks/Storage/RedisDeliveryStore.php @@ -0,0 +1,82 @@ +"; dead-lettered ids are also tracked in a sorted set scored by + * createdAt so findFailed() can return them oldest-first without scanning. + */ +final readonly class RedisDeliveryStore implements DeliveryStoreInterface +{ + public function __construct( + private Redis $redis, + private string $prefix = 'webhook:delivery:', + private string $deadLetterIndex = 'webhook:deliveries:deadletter', + ) {} + + public function record(Delivery $delivery): void + { + $this->persist($delivery); + } + + public function update(Delivery $delivery): void + { + $this->persist($delivery); + } + + public function findById(string $deliveryId): ?Delivery + { + $raw = $this->redis->get($this->prefix . $deliveryId); + if (!\is_string($raw)) { + return null; + } + + $value = unserialize($raw, ['allowed_classes' => [Delivery::class, DeliveryStatus::class]]); + + return $value instanceof Delivery ? $value : null; + } + + public function findFailed(int $limit = 100): array + { + if ($limit < 1) { + return []; + } + + /** @var list $ids */ + $ids = $this->redis->zRange($this->deadLetterIndex, 0, $limit - 1); + + $deliveries = []; + foreach ($ids as $id) { + $delivery = $this->findById($id); + if ($delivery instanceof Delivery) { + $deliveries[] = $delivery; + } + } + + return $deliveries; + } + + private function persist(Delivery $delivery): void + { + $this->redis->set($this->prefix . $delivery->id, serialize($delivery)); + + if ($delivery->status === DeliveryStatus::DeadLettered) { + $this->redis->zAdd($this->deadLetterIndex, $delivery->createdAt, $delivery->id); + } else { + $this->redis->zRem($this->deadLetterIndex, $delivery->id); + } + } +} diff --git a/src/Altair/Webhooks/composer.json b/src/Altair/Webhooks/composer.json new file mode 100644 index 0000000..8d86089 --- /dev/null +++ b/src/Altair/Webhooks/composer.json @@ -0,0 +1,30 @@ +{ + "name": "univeros/webhooks", + "description": "First-class webhook framework for Univeros: signing primitives, inbound verify middleware, and an outbound dispatcher with retry / dead-letter / replay.", + "license": "MIT", + "homepage": "https://univeros.io", + "support": { + "issues": "https://github.com/univeros/framework/issues", + "source": "https://github.com/univeros/framework" + }, + "require": { + "php": ">=8.3", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.1", + "psr/http-message": "^1.1 || ^2.0", + "psr/http-server-handler": "^1.0", + "psr/http-server-middleware": "^1.0", + "univeros/configuration": "^2.0", + "univeros/container": "^2.0", + "univeros/messaging": "self.version" + }, + "suggest": { + "ext-sodium": "Required for the Ed25519Signer.", + "ext-redis": "Required for the multi-host RedisDeduplicator / RedisDeliveryStore adapters." + }, + "autoload": { + "psr-4": { + "Altair\\Webhooks\\": "" + } + } +} diff --git a/tests/Webhooks/Signing/Ed25519SignerTest.php b/tests/Webhooks/Signing/Ed25519SignerTest.php new file mode 100644 index 0000000..8630903 --- /dev/null +++ b/tests/Webhooks/Signing/Ed25519SignerTest.php @@ -0,0 +1,73 @@ +name()); + } + + public function testSignThenVerifyRoundTrip(): void + { + $signer = new Ed25519Signer(); + [$secretKey, $publicKey] = $this->keypair(); + + $signature = $signer->sign(self::PAYLOAD, $secretKey); + + self::assertTrue($signer->verify(self::PAYLOAD, $signature, $publicKey)); + } + + public function testVerifyRejectsTamperedPayload(): void + { + $signer = new Ed25519Signer(); + [$secretKey, $publicKey] = $this->keypair(); + $signature = $signer->sign(self::PAYLOAD, $secretKey); + + self::assertFalse($signer->verify(self::PAYLOAD . 'x', $signature, $publicKey)); + } + + public function testVerifyRejectsSignatureFromAnotherKey(): void + { + $signer = new Ed25519Signer(); + [$secretKey] = $this->keypair(); + [, $otherPublicKey] = $this->keypair(); + $signature = $signer->sign(self::PAYLOAD, $secretKey); + + self::assertFalse($signer->verify(self::PAYLOAD, $signature, $otherPublicKey)); + } + + public function testVerifyRejectsMalformedSignature(): void + { + $signer = new Ed25519Signer(); + [, $publicKey] = $this->keypair(); + + self::assertFalse($signer->verify(self::PAYLOAD, 'not-hex!!', $publicKey)); + self::assertFalse($signer->verify(self::PAYLOAD, 'abcd', $publicKey)); + } + + /** + * @return array{0: string, 1: string} hex-encoded [secretKey, publicKey] + */ + private function keypair(): array + { + $keypair = sodium_crypto_sign_keypair(); + + return [ + sodium_bin2hex(sodium_crypto_sign_secretkey($keypair)), + sodium_bin2hex(sodium_crypto_sign_publickey($keypair)), + ]; + } +} diff --git a/tests/Webhooks/Signing/EnvSecretResolverTest.php b/tests/Webhooks/Signing/EnvSecretResolverTest.php new file mode 100644 index 0000000..ad381b5 --- /dev/null +++ b/tests/Webhooks/Signing/EnvSecretResolverTest.php @@ -0,0 +1,60 @@ + */ + private array $touchedKeys = []; + + protected function tearDown(): void + { + foreach ($this->touchedKeys as $key) { + putenv($key); + } + $this->touchedKeys = []; + } + + public function testResolvesSecretFromEnv(): void + { + $this->setEnv('WEBHOOK_SECRET_STRIPE', 'whsec_abc'); + + self::assertSame('whsec_abc', (new EnvSecretResolver())->resolve('stripe')); + } + + public function testNormalisesNonAlphanumericsInName(): void + { + $this->setEnv('WEBHOOK_SECRET_PARTNER_X', 'sk_xyz'); + + self::assertSame('sk_xyz', (new EnvSecretResolver())->resolve('partner-x')); + } + + public function testThrowsWhenSecretMissing(): void + { + $this->expectException(WebhookException::class); + $this->expectExceptionMessage('Webhook secret "ghost" is not configured.'); + + (new EnvSecretResolver())->resolve('ghost'); + } + + public function testHonoursCustomPrefix(): void + { + $this->setEnv('HOOK_GITHUB', 'gh_secret'); + + self::assertSame('gh_secret', (new EnvSecretResolver('HOOK_'))->resolve('github')); + } + + private function setEnv(string $key, string $value): void + { + putenv($key . '=' . $value); + $this->touchedKeys[] = $key; + } +} diff --git a/tests/Webhooks/Signing/HmacSha256SignerTest.php b/tests/Webhooks/Signing/HmacSha256SignerTest.php new file mode 100644 index 0000000..7b58697 --- /dev/null +++ b/tests/Webhooks/Signing/HmacSha256SignerTest.php @@ -0,0 +1,95 @@ +name()); + } + + public function testSignProducesHexEncodedMac(): void + { + $signer = new HmacSha256Signer(); + + $signature = $signer->sign(self::PAYLOAD, self::SECRET); + + self::assertSame(hash_hmac('sha256', self::PAYLOAD, self::SECRET), $signature); + self::assertSame(64, strlen($signature)); + self::assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $signature); + } + + public function testVerifyAcceptsAValidBareHexSignature(): void + { + $signer = new HmacSha256Signer(); + $signature = $signer->sign(self::PAYLOAD, self::SECRET); + + self::assertTrue($signer->verify(self::PAYLOAD, $signature, self::SECRET)); + } + + public function testVerifyRejectsACorruptedSignature(): void + { + $signer = new HmacSha256Signer(); + $signature = $signer->sign(self::PAYLOAD, self::SECRET); + + // Flip the first hex char — verify must reject rather than partial-match. + $corrupted = ($signature[0] === 'a' ? 'b' : 'a') . substr($signature, 1); + + self::assertFalse($signer->verify(self::PAYLOAD, $corrupted, self::SECRET)); + } + + public function testVerifyRejectsTamperedPayload(): void + { + $signer = new HmacSha256Signer(); + $signature = $signer->sign(self::PAYLOAD, self::SECRET); + + self::assertFalse($signer->verify(self::PAYLOAD . 'x', $signature, self::SECRET)); + } + + public function testVerifyRejectsWrongSecret(): void + { + $signer = new HmacSha256Signer(); + $signature = $signer->sign(self::PAYLOAD, self::SECRET); + + self::assertFalse($signer->verify(self::PAYLOAD, $signature, 'wrong_secret')); + } + + public function testVerifyRejectsEmptySignature(): void + { + $signer = new HmacSha256Signer(); + + self::assertFalse($signer->verify(self::PAYLOAD, '', self::SECRET)); + self::assertFalse($signer->verify(self::PAYLOAD, ' ', self::SECRET)); + } + + public function testVerifyExtractsStripeStyleV1Component(): void + { + $signer = new HmacSha256Signer(); + $mac = $signer->sign(self::PAYLOAD, self::SECRET); + + $header = 't=1700000000,v1=' . $mac; + + self::assertTrue($signer->verify(self::PAYLOAD, $header, self::SECRET)); + } + + public function testVerifyRejectsStripeHeaderWithBadV1(): void + { + $signer = new HmacSha256Signer(); + + $header = 't=1700000000,v1=' . str_repeat('0', 64); + + self::assertFalse($signer->verify(self::PAYLOAD, $header, self::SECRET)); + } +} diff --git a/tests/Webhooks/Signing/HmacSha512SignerTest.php b/tests/Webhooks/Signing/HmacSha512SignerTest.php new file mode 100644 index 0000000..d23dfbf --- /dev/null +++ b/tests/Webhooks/Signing/HmacSha512SignerTest.php @@ -0,0 +1,49 @@ +name()); + } + + public function testSignProducesHexEncodedMac(): void + { + $signer = new HmacSha512Signer(); + + $signature = $signer->sign(self::PAYLOAD, self::SECRET); + + self::assertSame(hash_hmac('sha512', self::PAYLOAD, self::SECRET), $signature); + self::assertSame(128, strlen($signature)); + } + + public function testVerifyRoundTrip(): void + { + $signer = new HmacSha512Signer(); + $signature = $signer->sign(self::PAYLOAD, self::SECRET); + + self::assertTrue($signer->verify(self::PAYLOAD, $signature, self::SECRET)); + } + + public function testVerifyRejectsCorruptedSignature(): void + { + $signer = new HmacSha512Signer(); + $signature = $signer->sign(self::PAYLOAD, self::SECRET); + $corrupted = ($signature[0] === 'a' ? 'b' : 'a') . substr($signature, 1); + + self::assertFalse($signer->verify(self::PAYLOAD, $corrupted, self::SECRET)); + } +} diff --git a/tests/Webhooks/Signing/SignerRegistryTest.php b/tests/Webhooks/Signing/SignerRegistryTest.php new file mode 100644 index 0000000..e3a279b --- /dev/null +++ b/tests/Webhooks/Signing/SignerRegistryTest.php @@ -0,0 +1,59 @@ +get('hmac-sha256')); + self::assertInstanceOf(HmacSha512Signer::class, $registry->get('hmac-sha512')); + } + + public function testHasReportsMembership(): void + { + $registry = new SignerRegistry([new HmacSha256Signer()]); + + self::assertTrue($registry->has('hmac-sha256')); + self::assertFalse($registry->has('ed25519')); + } + + public function testGetThrowsForUnknownSigner(): void + { + $registry = new SignerRegistry([new HmacSha256Signer()]); + + $this->expectException(WebhookException::class); + $this->expectExceptionMessage('Unknown webhook signer "nope".'); + + $registry->get('nope'); + } + + public function testDefaultRegistryIncludesHmacSigners(): void + { + $registry = SignerRegistry::default(); + + self::assertTrue($registry->has('hmac-sha256')); + self::assertTrue($registry->has('hmac-sha512')); + self::assertContains('hmac-sha256', $registry->names()); + } + + public function testRegisterOverwritesByName(): void + { + $registry = new SignerRegistry(); + $registry->register(new HmacSha256Signer()); + + self::assertSame(['hmac-sha256'], $registry->names()); + } +} diff --git a/tests/Webhooks/Storage/DeliveryTest.php b/tests/Webhooks/Storage/DeliveryTest.php new file mode 100644 index 0000000..07858e2 --- /dev/null +++ b/tests/Webhooks/Storage/DeliveryTest.php @@ -0,0 +1,86 @@ +delivery(); + + self::assertSame('dlv_1', $delivery->id); + self::assertSame(DeliveryStatus::Pending, $delivery->status); + self::assertSame(0, $delivery->attempts); + self::assertNull($delivery->lastAttemptAt); + self::assertNull($delivery->nextAttemptAt); + self::assertNull($delivery->lastResponse); + } + + public function testWithStatusReturnsNewCopy(): void + { + $delivery = $this->delivery(); + + $delivered = $delivery->withStatus(DeliveryStatus::Delivered); + + self::assertSame(DeliveryStatus::Pending, $delivery->status, 'original is untouched'); + self::assertSame(DeliveryStatus::Delivered, $delivered->status); + } + + public function testWithAttemptsAndTimestamps(): void + { + $delivery = $this->delivery() + ->withAttempts(2) + ->withLastAttemptAt(1_700_000_100) + ->withNextAttemptAt(1_700_000_160) + ->withLastResponse('502 Bad Gateway'); + + self::assertSame(2, $delivery->attempts); + self::assertSame(1_700_000_100, $delivery->lastAttemptAt); + self::assertSame(1_700_000_160, $delivery->nextAttemptAt); + self::assertSame('502 Bad Gateway', $delivery->lastResponse); + } + + public function testWithNextAttemptAtCanClearToNull(): void + { + $delivery = $this->delivery()->withNextAttemptAt(1_700_000_160)->withNextAttemptAt(null); + + self::assertNull($delivery->nextAttemptAt); + } + + public function testResetRestoresPendingAndZeroAttempts(): void + { + $delivery = $this->delivery() + ->withStatus(DeliveryStatus::DeadLettered) + ->withAttempts(5) + ->withNextAttemptAt(1_700_000_160); + + $reset = $delivery->reset(); + + self::assertSame(DeliveryStatus::Pending, $reset->status); + self::assertSame(0, $reset->attempts); + self::assertNull($reset->nextAttemptAt); + self::assertSame('dlv_1', $reset->id, 'identity preserved'); + self::assertSame($delivery->payload, $reset->payload, 'payload preserved'); + } + + private function delivery(): Delivery + { + return Delivery::create( + id: 'dlv_1', + eventName: 'order.created', + subscriberUrl: 'https://example.test/hook', + payload: '{"id":"order_1"}', + secretName: 'partner-x', + signerName: 'hmac-sha256', + createdAt: 1_700_000_000, + ); + } +} diff --git a/tests/Webhooks/Storage/InMemoryDeduplicatorTest.php b/tests/Webhooks/Storage/InMemoryDeduplicatorTest.php new file mode 100644 index 0000000..f410611 --- /dev/null +++ b/tests/Webhooks/Storage/InMemoryDeduplicatorTest.php @@ -0,0 +1,48 @@ +claim('evt_1', 60)); + self::assertFalse($dedupe->claim('evt_1', 60)); + } + + public function testDistinctEventIdsClaimIndependently(): void + { + $dedupe = new InMemoryDeduplicator(); + + self::assertTrue($dedupe->claim('evt_1', 60)); + self::assertTrue($dedupe->claim('evt_2', 60)); + } + + public function testReleaseAllowsReclaim(): void + { + $dedupe = new InMemoryDeduplicator(); + $dedupe->claim('evt_1', 60); + + $dedupe->release('evt_1'); + + self::assertTrue($dedupe->claim('evt_1', 60)); + } + + public function testExpiredClaimCanBeReclaimed(): void + { + $dedupe = new InMemoryDeduplicator(); + + // TTL of 0 expires immediately (expiresAt <= now on the next purge). + self::assertTrue($dedupe->claim('evt_1', 0)); + self::assertTrue($dedupe->claim('evt_1', 60)); + } +} diff --git a/tests/Webhooks/Storage/InMemoryDeliveryStoreTest.php b/tests/Webhooks/Storage/InMemoryDeliveryStoreTest.php new file mode 100644 index 0000000..e990703 --- /dev/null +++ b/tests/Webhooks/Storage/InMemoryDeliveryStoreTest.php @@ -0,0 +1,76 @@ +delivery('dlv_1', 1_700_000_000); + + $store->record($delivery); + + self::assertEquals($delivery, $store->findById('dlv_1')); + } + + public function testFindByIdReturnsNullWhenMissing(): void + { + self::assertNull((new InMemoryDeliveryStore())->findById('missing')); + } + + public function testUpdateOverwritesExisting(): void + { + $store = new InMemoryDeliveryStore(); + $store->record($this->delivery('dlv_1', 1_700_000_000)); + + $store->update($this->delivery('dlv_1', 1_700_000_000)->withStatus(DeliveryStatus::Delivered)); + + self::assertSame(DeliveryStatus::Delivered, $store->findById('dlv_1')?->status); + } + + public function testFindFailedReturnsOnlyDeadLetteredOldestFirst(): void + { + $store = new InMemoryDeliveryStore(); + $store->record($this->delivery('dlv_new', 3_000)->withStatus(DeliveryStatus::DeadLettered)); + $store->record($this->delivery('dlv_old', 1_000)->withStatus(DeliveryStatus::DeadLettered)); + $store->record($this->delivery('dlv_ok', 2_000)->withStatus(DeliveryStatus::Delivered)); + + $failed = $store->findFailed(); + + self::assertCount(2, $failed); + self::assertSame('dlv_old', $failed[0]->id); + self::assertSame('dlv_new', $failed[1]->id); + } + + public function testFindFailedHonoursLimit(): void + { + $store = new InMemoryDeliveryStore(); + $store->record($this->delivery('dlv_1', 1_000)->withStatus(DeliveryStatus::DeadLettered)); + $store->record($this->delivery('dlv_2', 2_000)->withStatus(DeliveryStatus::DeadLettered)); + + self::assertCount(1, $store->findFailed(1)); + } + + private function delivery(string $id, int $createdAt): Delivery + { + return Delivery::create( + id: $id, + eventName: 'order.created', + subscriberUrl: 'https://example.test/hook', + payload: '{"id":"order_1"}', + secretName: 'partner-x', + signerName: 'hmac-sha256', + createdAt: $createdAt, + ); + } +} diff --git a/tests/Webhooks/Storage/RedisDeduplicatorTest.php b/tests/Webhooks/Storage/RedisDeduplicatorTest.php new file mode 100644 index 0000000..a056182 --- /dev/null +++ b/tests/Webhooks/Storage/RedisDeduplicatorTest.php @@ -0,0 +1,63 @@ +connect($host, (int) (getenv('REDIS_PORT') ?: 6379)); + $this->redis = $redis; + $redis->flushDB(); + } + + protected function tearDown(): void + { + $this->redis?->flushDB(); + } + + public function testClaimSucceedsOnceThenFails(): void + { + $dedupe = new RedisDeduplicator($this->redis()); + + self::assertTrue($dedupe->claim('evt_1', 60)); + self::assertFalse($dedupe->claim('evt_1', 60)); + } + + public function testReleaseAllowsReclaim(): void + { + $dedupe = new RedisDeduplicator($this->redis()); + $dedupe->claim('evt_1', 60); + + $dedupe->release('evt_1'); + + self::assertTrue($dedupe->claim('evt_1', 60)); + } + + private function redis(): Redis + { + self::assertInstanceOf(Redis::class, $this->redis); + + return $this->redis; + } +} diff --git a/tests/Webhooks/Storage/RedisDeliveryStoreTest.php b/tests/Webhooks/Storage/RedisDeliveryStoreTest.php new file mode 100644 index 0000000..e17e7df --- /dev/null +++ b/tests/Webhooks/Storage/RedisDeliveryStoreTest.php @@ -0,0 +1,99 @@ +connect($host, (int) (getenv('REDIS_PORT') ?: 6379)); + $this->redis = $redis; + $redis->flushDB(); + } + + protected function tearDown(): void + { + $this->redis?->flushDB(); + } + + public function testRecordAndFindByIdRoundTrip(): void + { + $store = new RedisDeliveryStore($this->redis()); + $delivery = $this->delivery('dlv_1', 1_700_000_000); + + $store->record($delivery); + + self::assertEquals($delivery, $store->findById('dlv_1')); + } + + public function testFindByIdReturnsNullWhenMissing(): void + { + self::assertNull((new RedisDeliveryStore($this->redis()))->findById('missing')); + } + + public function testFindFailedReturnsDeadLetteredOldestFirst(): void + { + $store = new RedisDeliveryStore($this->redis()); + $store->record($this->delivery('dlv_new', 3_000)->withStatus(DeliveryStatus::DeadLettered)); + $store->record($this->delivery('dlv_old', 1_000)->withStatus(DeliveryStatus::DeadLettered)); + $store->record($this->delivery('dlv_ok', 2_000)->withStatus(DeliveryStatus::Delivered)); + + $failed = $store->findFailed(); + + self::assertCount(2, $failed); + self::assertSame('dlv_old', $failed[0]->id); + self::assertSame('dlv_new', $failed[1]->id); + } + + public function testUpdateOutOfDeadLetterRemovesFromFailedIndex(): void + { + $store = new RedisDeliveryStore($this->redis()); + $store->record($this->delivery('dlv_1', 1_000)->withStatus(DeliveryStatus::DeadLettered)); + + $store->update($this->delivery('dlv_1', 1_000)->withStatus(DeliveryStatus::Delivered)); + + self::assertCount(0, $store->findFailed()); + } + + private function delivery(string $id, int $createdAt): Delivery + { + return Delivery::create( + id: $id, + eventName: 'order.created', + subscriberUrl: 'https://example.test/hook', + payload: '{"id":"order_1"}', + secretName: 'partner-x', + signerName: 'hmac-sha256', + createdAt: $createdAt, + ); + } + + private function redis(): Redis + { + self::assertInstanceOf(Redis::class, $this->redis); + + return $this->redis; + } +} From ceba62a67a52d01dca6ddfc9fa6aa627aafda983 Mon Sep 17 00:00:00 2001 From: Antonio Ramirez Date: Sun, 31 May 2026 08:07:24 +0200 Subject: [PATCH 2/6] feat(webhooks): WebhookVerifyMiddleware + ActionAware variant (#186) --- .../ActionAwareWebhookVerifyMiddleware.php | 126 ++++++++ .../Middleware/WebhookVerifyMiddleware.php | 159 ++++++++++ .../Webhooks/Support/DurationParser.php | 49 ++++ ...ActionAwareWebhookVerifyMiddlewareTest.php | 143 +++++++++ tests/Webhooks/Middleware/CountingHandler.php | 36 +++ .../WebhookVerifyMiddlewareTest.php | 276 ++++++++++++++++++ 6 files changed, 789 insertions(+) create mode 100644 src/Altair/Webhooks/Middleware/ActionAwareWebhookVerifyMiddleware.php create mode 100644 src/Altair/Webhooks/Middleware/WebhookVerifyMiddleware.php create mode 100644 src/Altair/Webhooks/Support/DurationParser.php create mode 100644 tests/Webhooks/Middleware/ActionAwareWebhookVerifyMiddlewareTest.php create mode 100644 tests/Webhooks/Middleware/CountingHandler.php create mode 100644 tests/Webhooks/Middleware/WebhookVerifyMiddlewareTest.php diff --git a/src/Altair/Webhooks/Middleware/ActionAwareWebhookVerifyMiddleware.php b/src/Altair/Webhooks/Middleware/ActionAwareWebhookVerifyMiddleware.php new file mode 100644 index 0000000..e995657 --- /dev/null +++ b/src/Altair/Webhooks/Middleware/ActionAwareWebhookVerifyMiddleware.php @@ -0,0 +1,126 @@ +resolvePolicy($request); + if ($policy === null) { + return $handler->handle($request); + } + + $middleware = new WebhookVerifyMiddleware( + signer: $this->signers->get($policy['signing']), + secrets: $this->secrets, + deduplicator: $this->deduplicator, + responseFactory: $this->responseFactory, + streamFactory: $this->streamFactory, + secretName: $policy['secret_name'], + dedupeTtlSeconds: $this->durations->toSeconds($policy['dedupe_ttl']), + timestampWindowSeconds: $this->durations->toSeconds($policy['timestamp_window']), + signatureHeader: $policy['signature_header'], + timestampHeader: $policy['timestamp_header'], + eventIdHeader: $policy['event_id_header'], + ); + + return $middleware->process($request, $handler); + } + + /** + * @return ?array{signing: string, secret_name: string, dedupe_ttl: string, timestamp_window: string, signature_header: string, timestamp_header: string, event_id_header: string} + */ + private function resolvePolicy(ServerRequestInterface $request): ?array + { + $action = $request->getAttribute($this->actionAttribute); + if (!\is_object($action) || !method_exists($action, 'webhook')) { + return null; + } + + /** @var mixed $policy */ + $policy = $action::webhook(); + if (!\is_array($policy)) { + return null; + } + + if (($policy['direction'] ?? null) !== 'in') { + return null; + } + + $signing = $this->stringField($policy, 'signing'); + $secretName = $this->stringField($policy, 'secret_name'); + if ($signing === null || $secretName === null) { + return null; + } + + return [ + 'signing' => $signing, + 'secret_name' => $secretName, + 'dedupe_ttl' => $this->stringField($policy, 'dedupe_ttl') ?? '1h', + 'timestamp_window' => $this->stringField($policy, 'timestamp_window') ?? '5m', + 'signature_header' => $this->stringField($policy, 'signature_header') ?? 'X-Signature', + 'timestamp_header' => $this->stringField($policy, 'timestamp_header') ?? 'X-Timestamp', + 'event_id_header' => $this->stringField($policy, 'event_id_header') ?? 'X-Event-Id', + ]; + } + + /** + * @param array $policy + */ + private function stringField(array $policy, string $key): ?string + { + $value = $policy[$key] ?? null; + + return \is_string($value) && $value !== '' ? $value : null; + } +} diff --git a/src/Altair/Webhooks/Middleware/WebhookVerifyMiddleware.php b/src/Altair/Webhooks/Middleware/WebhookVerifyMiddleware.php new file mode 100644 index 0000000..e8437c6 --- /dev/null +++ b/src/Altair/Webhooks/Middleware/WebhookVerifyMiddleware.php @@ -0,0 +1,159 @@ +getBody(); + // Rebuild the body so downstream handlers read it from position 0. + $request = $request->withBody($this->streamFactory->createStream($body)); + + $signature = $request->getHeaderLine($this->signatureHeader); + if ($signature === '') { + return $this->error(401, self::SIGNATURE_ERROR); + } + + $secret = $this->secrets->resolve($this->secretName); + if (!$this->signer->verify($body, $signature, $secret)) { + return $this->error(401, self::SIGNATURE_ERROR); + } + + $timestamp = $request->getHeaderLine($this->timestampHeader); + $timestampError = $this->checkTimestamp($timestamp); + if ($timestampError !== null) { + return $this->error(400, $timestampError); + } + + $eventId = $this->resolveEventId($request, $body, $timestamp); + if (!$this->deduplicator->claim($eventId, $this->dedupeTtlSeconds)) { + return $this->replayed(); + } + + try { + $response = $handler->handle($request); + } catch (Throwable $throwable) { + $this->deduplicator->release($eventId); + + throw $throwable; + } + + // A failed handler (5xx) should let the sender retry rather than be + // absorbed as a duplicate, so the claim is dropped. + if ($response->getStatusCode() >= 500) { + $this->deduplicator->release($eventId); + } + + return $response; + } + + private function checkTimestamp(string $timestamp): ?string + { + if ($timestamp === '') { + return $this->requireTimestamp ? 'missing timestamp' : null; + } + + if (preg_match('/^\d+$/', $timestamp) !== 1) { + return 'invalid timestamp'; + } + + $delta = abs(time() - (int) $timestamp); + if ($delta > $this->timestampWindowSeconds) { + return 'outside replay window'; + } + + return null; + } + + private function resolveEventId(ServerRequestInterface $request, string $body, string $timestamp): string + { + $eventId = $request->getHeaderLine($this->eventIdHeader); + if ($eventId !== '') { + return $eventId; + } + + // No stable id supplied — synthesise one from the body + timestamp. + return hash('sha256', $body . '|' . $timestamp); + } + + private function replayed(): ResponseInterface + { + return $this->responseFactory->createResponse(200) + ->withHeader(self::HEADER_REPLAYED, 'true') + ->withBody($this->streamFactory->createStream('')); + } + + private function error(int $status, string $message): ResponseInterface + { + $body = json_encode(['error' => $message], JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + if ($body === false) { + $body = '{"error":"webhook error"}'; + } + + return $this->responseFactory->createResponse($status) + ->withHeader('Content-Type', 'application/json') + ->withBody($this->streamFactory->createStream($body)); + } +} diff --git a/src/Altair/Webhooks/Support/DurationParser.php b/src/Altair/Webhooks/Support/DurationParser.php new file mode 100644 index 0000000..d2f02f7 --- /dev/null +++ b/src/Altair/Webhooks/Support/DurationParser.php @@ -0,0 +1,49 @@ +`) into whole seconds. Pure, deterministic, no clock. + * Mirrors the pattern the idempotency spec validator enforces. + */ +final readonly class DurationParser +{ + private const array MULTIPLIERS = [ + 's' => 1, + 'm' => 60, + 'h' => 3_600, + 'd' => 86_400, + ]; + + public function toSeconds(string $duration): int + { + if (preg_match('/^(\d+)(ms|s|m|h|d)$/', $duration, $match) !== 1) { + throw new WebhookException(\sprintf( + "Duration '%s' must match '' (e.g. '1h', '5m', '500ms').", + $duration, + )); + } + + $value = (int) $match[1]; + $unit = $match[2]; + + if ($unit === 'ms') { + // Sub-second durations round up to at least 1 second on the wire. + return $value > 0 ? max(1, (int) ceil($value / 1000)) : 0; + } + + return $value * self::MULTIPLIERS[$unit]; + } +} diff --git a/tests/Webhooks/Middleware/ActionAwareWebhookVerifyMiddlewareTest.php b/tests/Webhooks/Middleware/ActionAwareWebhookVerifyMiddlewareTest.php new file mode 100644 index 0000000..b297ef2 --- /dev/null +++ b/tests/Webhooks/Middleware/ActionAwareWebhookVerifyMiddlewareTest.php @@ -0,0 +1,143 @@ +middleware()->process($this->request(), $handler); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame(1, $handler->calls); + } + + public function testPassesThroughWhenActionHasNoWebhookMethod(): void + { + $action = new class {}; + $handler = new CountingHandler(200); + + $response = $this->middleware()->process($this->request($action), $handler); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame(1, $handler->calls); + } + + public function testPassesThroughWhenDirectionIsOutbound(): void + { + $action = new class { + /** @return array */ + public static function webhook(): array + { + return ['direction' => 'out', 'signing' => 'hmac-sha256']; + } + }; + $handler = new CountingHandler(200); + + $response = $this->middleware()->process($this->request($action), $handler); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame(1, $handler->calls); + } + + public function testVerifiesInboundAndRejectsMissingSignature(): void + { + $action = $this->inboundAction(); + $handler = new CountingHandler(201); + + $response = $this->middleware()->process($this->request($action), $handler); + + self::assertSame(401, $response->getStatusCode()); + self::assertSame(0, $handler->calls, 'handler not reached on a rejected webhook'); + } + + public function testVerifiesInboundAndPassesValidSignature(): void + { + $action = $this->inboundAction(); + $handler = new CountingHandler(201); + $headers = [ + 'X-Signature' => (new HmacSha256Signer())->sign(self::BODY, self::SECRET), + 'X-Timestamp' => (string) time(), + 'X-Event-Id' => 'evt_1', + ]; + + $response = $this->middleware()->process($this->request($action, $headers), $handler); + + self::assertSame(201, $response->getStatusCode()); + self::assertSame(1, $handler->calls); + } + + private function inboundAction(): object + { + return new class { + /** @return array */ + public static function webhook(): array + { + return [ + 'direction' => 'in', + 'signing' => 'hmac-sha256', + 'secret_name' => 'stripe', + 'dedupe_ttl' => '1h', + 'timestamp_window' => '5m', + 'signature_header' => 'X-Signature', + 'timestamp_header' => 'X-Timestamp', + 'event_id_header' => 'X-Event-Id', + ]; + } + }; + } + + private function middleware(): ActionAwareWebhookVerifyMiddleware + { + return new ActionAwareWebhookVerifyMiddleware( + signers: SignerRegistry::default(), + secrets: $this->secrets(), + deduplicator: new InMemoryDeduplicator(), + responseFactory: new ResponseFactory(), + streamFactory: new StreamFactory(), + ); + } + + private function secrets(): SecretResolverInterface + { + return new StaticSecretResolver(self::SECRET); + } + + /** + * @param array $headers + */ + private function request(?object $action = null, array $headers = []): ServerRequestInterface + { + $request = new ServerRequest(serverParams: [], uploadedFiles: [], uri: '/webhooks/stripe', method: 'POST'); + if ($action !== null) { + $request = $request->withAttribute('altair:http:action', $action); + } + foreach ($headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + + return $request->withBody((new StreamFactory())->createStream(self::BODY)); + } +} diff --git a/tests/Webhooks/Middleware/CountingHandler.php b/tests/Webhooks/Middleware/CountingHandler.php new file mode 100644 index 0000000..6b66033 --- /dev/null +++ b/tests/Webhooks/Middleware/CountingHandler.php @@ -0,0 +1,36 @@ +calls; + + return (new Response('php://memory', $this->status)) + ->withBody((new StreamFactory())->createStream($this->body)); + } +} diff --git a/tests/Webhooks/Middleware/WebhookVerifyMiddlewareTest.php b/tests/Webhooks/Middleware/WebhookVerifyMiddlewareTest.php new file mode 100644 index 0000000..43ab6eb --- /dev/null +++ b/tests/Webhooks/Middleware/WebhookVerifyMiddlewareTest.php @@ -0,0 +1,276 @@ +middleware()->process($this->request(headers: []), $this->handler()); + + self::assertSame(401, $response->getStatusCode()); + self::assertStringContainsString('verification failed', (string) $response->getBody()); + } + + public function testRejectsWhenSignatureMismatch(): void + { + $headers = ['X-Signature' => 'deadbeef', 'X-Timestamp' => (string) time(), 'X-Event-Id' => 'evt_1']; + + $response = $this->middleware()->process($this->request(headers: $headers), $this->handler()); + + self::assertSame(401, $response->getStatusCode()); + } + + public function testRejectsWhenTimestampMissingAndRequired(): void + { + $headers = ['X-Signature' => $this->sign(self::BODY), 'X-Event-Id' => 'evt_1']; + + $response = $this->middleware()->process($this->request(headers: $headers), $this->handler()); + + self::assertSame(400, $response->getStatusCode()); + } + + public function testAllowsMissingTimestampWhenNotRequired(): void + { + $headers = ['X-Signature' => $this->sign(self::BODY), 'X-Event-Id' => 'evt_1']; + $handler = $this->handler(); + + $response = $this->middleware(requireTimestamp: false)->process($this->request(headers: $headers), $handler); + + self::assertSame(201, $response->getStatusCode()); + self::assertSame(1, $handler->calls); + } + + public function testRejectsTimestampOutsideWindowInThePast(): void + { + $headers = [ + 'X-Signature' => $this->sign(self::BODY), + 'X-Timestamp' => (string) (time() - 1000), + 'X-Event-Id' => 'evt_1', + ]; + + $response = $this->middleware()->process($this->request(headers: $headers), $this->handler()); + + self::assertSame(400, $response->getStatusCode()); + self::assertStringContainsString('outside replay window', (string) $response->getBody()); + } + + public function testRejectsTimestampOutsideWindowInTheFuture(): void + { + $headers = [ + 'X-Signature' => $this->sign(self::BODY), + 'X-Timestamp' => (string) (time() + 1000), + 'X-Event-Id' => 'evt_1', + ]; + + $response = $this->middleware()->process($this->request(headers: $headers), $this->handler()); + + self::assertSame(400, $response->getStatusCode()); + } + + public function testRejectsNonNumericTimestamp(): void + { + $headers = [ + 'X-Signature' => $this->sign(self::BODY), + 'X-Timestamp' => 'not-a-number', + 'X-Event-Id' => 'evt_1', + ]; + + $response = $this->middleware()->process($this->request(headers: $headers), $this->handler()); + + self::assertSame(400, $response->getStatusCode()); + } + + public function testFreshEventPassesThroughToHandler(): void + { + $handler = $this->handler(); + + $response = $this->middleware()->process($this->request(headers: $this->validHeaders()), $handler); + + self::assertSame(201, $response->getStatusCode()); + self::assertSame(1, $handler->calls); + } + + public function testReplayReturns200WithReplayedHeaderAndSkipsHandler(): void + { + $middleware = $this->middleware(); + $dedupeAwareHandler = $this->handler(); + + $first = $middleware->process($this->request(headers: $this->validHeaders()), $dedupeAwareHandler); + $second = $middleware->process($this->request(headers: $this->validHeaders()), $dedupeAwareHandler); + + self::assertSame(201, $first->getStatusCode()); + self::assertSame(200, $second->getStatusCode()); + self::assertSame('true', $second->getHeaderLine('Webhook-Replayed')); + self::assertSame('', (string) $second->getBody()); + self::assertSame(1, $dedupeAwareHandler->calls, 'handler invoked exactly once'); + } + + public function testHandlerThrowReleasesClaimSoRetryReprocesses(): void + { + $deduplicator = new InMemoryDeduplicator(); + $middleware = $this->middleware(deduplicator: $deduplicator); + + $throwing = new class implements RequestHandlerInterface { + #[Override] + public function handle(ServerRequestInterface $request): ResponseInterface + { + throw new RuntimeException('boom'); + } + }; + + try { + $middleware->process($this->request(headers: $this->validHeaders()), $throwing); + self::fail('expected exception to propagate'); + } catch (RuntimeException) { + // expected + } + + // Claim released: a retry with the same event id must reach a fresh handler. + $handler = $this->handler(); + $response = $this->middleware(deduplicator: $deduplicator)->process( + $this->request(headers: $this->validHeaders()), + $handler, + ); + + self::assertSame(201, $response->getStatusCode()); + self::assertSame(1, $handler->calls); + } + + public function testHandlerReturning5xxReleasesClaim(): void + { + $deduplicator = new InMemoryDeduplicator(); + + $first = $this->middleware(deduplicator: $deduplicator)->process( + $this->request(headers: $this->validHeaders()), + $this->handler(status: 503), + ); + self::assertSame(503, $first->getStatusCode()); + + $retryHandler = $this->handler(); + $second = $this->middleware(deduplicator: $deduplicator)->process( + $this->request(headers: $this->validHeaders()), + $retryHandler, + ); + + self::assertSame(201, $second->getStatusCode()); + self::assertSame(1, $retryHandler->calls); + } + + public function testSyntheticEventIdDedupesWhenHeaderAbsent(): void + { + $middleware = $this->middleware(); + $ts = (string) time(); + $headers = ['X-Signature' => $this->sign(self::BODY), 'X-Timestamp' => $ts]; + $handler = $this->handler(); + + $first = $middleware->process($this->request(headers: $headers), $handler); + $second = $middleware->process($this->request(headers: $headers), $handler); + + self::assertSame(201, $first->getStatusCode()); + self::assertSame(200, $second->getStatusCode()); + self::assertSame(1, $handler->calls); + } + + public function testBodyIsReadableByDownstreamHandler(): void + { + $capturing = new class implements RequestHandlerInterface { + public string $seenBody = ''; + + #[Override] + public function handle(ServerRequestInterface $request): ResponseInterface + { + $this->seenBody = (string) $request->getBody(); + + return new Response(); + } + }; + + $this->middleware()->process($this->request(headers: $this->validHeaders()), $capturing); + + self::assertSame(self::BODY, $capturing->seenBody); + } + + /** + * @param array $headers + */ + private function middleware( + array $headers = [], + bool $requireTimestamp = true, + ?InMemoryDeduplicator $deduplicator = null, + ): WebhookVerifyMiddleware { + return new WebhookVerifyMiddleware( + signer: new HmacSha256Signer(), + secrets: $this->secrets(), + deduplicator: $deduplicator ?? new InMemoryDeduplicator(), + responseFactory: new ResponseFactory(), + streamFactory: new StreamFactory(), + secretName: self::SECRET_NAME, + requireTimestamp: $requireTimestamp, + ); + } + + private function secrets(): SecretResolverInterface + { + return new StaticSecretResolver(self::SECRET); + } + + private function sign(string $body): string + { + return (new HmacSha256Signer())->sign($body, self::SECRET); + } + + /** + * @return array + */ + private function validHeaders(): array + { + return [ + 'X-Signature' => $this->sign(self::BODY), + 'X-Timestamp' => (string) time(), + 'X-Event-Id' => 'evt_1', + ]; + } + + /** + * @param array $headers + */ + private function request(array $headers): ServerRequestInterface + { + $request = new ServerRequest(serverParams: [], uploadedFiles: [], uri: '/webhooks/stripe', method: 'POST'); + foreach ($headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + + return $request->withBody((new StreamFactory())->createStream(self::BODY)); + } + + private function handler(int $status = 201): CountingHandler + { + return new CountingHandler($status); + } +} From 81edb4fb3fa1d9db794d21e9692b64337d72345d Mon Sep 17 00:00:00 2001 From: Antonio Ramirez Date: Sun, 31 May 2026 08:07:27 +0200 Subject: [PATCH 3/6] feat(webhooks): WebhookDispatcher + Messenger handler + retry/DLQ + replay CLI (#187) --- .../Webhooks/Cli/WebhookReplayCommand.php | 82 ++++++++ .../Webhooks/Cli/WebhookShowFailedCommand.php | 70 +++++++ .../Webhooks/Dispatcher/RetryPolicy.php | 44 +++++ .../Webhooks/Dispatcher/WebhookDispatcher.php | 106 ++++++++++ .../Webhooks/Dispatcher/WebhookHandler.php | 164 ++++++++++++++++ .../Webhooks/Dispatcher/WebhookMessage.php | 29 +++ .../Webhooks/Cli/WebhookReplayCommandTest.php | 71 +++++++ .../Cli/WebhookShowFailedCommandTest.php | 56 ++++++ tests/Webhooks/Dispatcher/FakeHttpClient.php | 53 +++++ .../Dispatcher/RecordingMessageBus.php | 39 ++++ tests/Webhooks/Dispatcher/RetryPolicyTest.php | 58 ++++++ .../Dispatcher/WebhookDispatcherTest.php | 97 +++++++++ .../Dispatcher/WebhookHandlerTest.php | 184 ++++++++++++++++++ .../Fixtures/StaticSecretResolver.php | 22 +++ 14 files changed, 1075 insertions(+) create mode 100644 src/Altair/Webhooks/Cli/WebhookReplayCommand.php create mode 100644 src/Altair/Webhooks/Cli/WebhookShowFailedCommand.php create mode 100644 src/Altair/Webhooks/Dispatcher/RetryPolicy.php create mode 100644 src/Altair/Webhooks/Dispatcher/WebhookDispatcher.php create mode 100644 src/Altair/Webhooks/Dispatcher/WebhookHandler.php create mode 100644 src/Altair/Webhooks/Dispatcher/WebhookMessage.php create mode 100644 tests/Webhooks/Cli/WebhookReplayCommandTest.php create mode 100644 tests/Webhooks/Cli/WebhookShowFailedCommandTest.php create mode 100644 tests/Webhooks/Dispatcher/FakeHttpClient.php create mode 100644 tests/Webhooks/Dispatcher/RecordingMessageBus.php create mode 100644 tests/Webhooks/Dispatcher/RetryPolicyTest.php create mode 100644 tests/Webhooks/Dispatcher/WebhookDispatcherTest.php create mode 100644 tests/Webhooks/Dispatcher/WebhookHandlerTest.php create mode 100644 tests/Webhooks/Fixtures/StaticSecretResolver.php diff --git a/src/Altair/Webhooks/Cli/WebhookReplayCommand.php b/src/Altair/Webhooks/Cli/WebhookReplayCommand.php new file mode 100644 index 0000000..a068ff9 --- /dev/null +++ b/src/Altair/Webhooks/Cli/WebhookReplayCommand.php @@ -0,0 +1,82 @@ +addArgument('delivery-id', InputArgument::REQUIRED, 'Delivery id (or an unambiguous prefix)'); + } + + #[Override] + protected function execute(InputInterface $input, OutputInterface $output): int + { + $style = new SymfonyStyle($input, $output); + + $argument = $input->getArgument('delivery-id'); + $id = \is_string($argument) ? $argument : ''; + + $delivery = $this->resolve($id); + if (!$delivery instanceof Delivery) { + $style->error(\sprintf('No delivery matching "%s".', $id)); + + return Command::FAILURE; + } + + $reset = $this->dispatcher->redispatch($delivery); + $style->success(\sprintf('Re-dispatched delivery %s (reset to pending).', $reset->id)); + + return Command::SUCCESS; + } + + private function resolve(string $id): ?Delivery + { + if ($id === '') { + return null; + } + + $exact = $this->deliveries->findById($id); + if ($exact instanceof Delivery) { + return $exact; + } + + // Fall back to a unique-prefix match among dead-lettered deliveries — + // the realistic replay target. + $matches = array_values(array_filter( + $this->deliveries->findFailed(1000), + static fn(Delivery $delivery): bool => str_starts_with($delivery->id, $id), + )); + + return \count($matches) === 1 ? $matches[0] : null; + } +} diff --git a/src/Altair/Webhooks/Cli/WebhookShowFailedCommand.php b/src/Altair/Webhooks/Cli/WebhookShowFailedCommand.php new file mode 100644 index 0000000..2273d4e --- /dev/null +++ b/src/Altair/Webhooks/Cli/WebhookShowFailedCommand.php @@ -0,0 +1,70 @@ +addOption('limit', null, InputOption::VALUE_REQUIRED, 'Maximum number of deliveries to list', '100'); + } + + #[Override] + protected function execute(InputInterface $input, OutputInterface $output): int + { + $style = new SymfonyStyle($input, $output); + + $limitOption = $input->getOption('limit'); + $limit = is_numeric($limitOption) ? max(1, (int) $limitOption) : 100; + + $failed = $this->deliveries->findFailed($limit); + if ($failed === []) { + $style->success('No dead-lettered deliveries.'); + + return Command::SUCCESS; + } + + $style->table( + ['Delivery', 'Event', 'Subscriber', 'Attempts', 'Last response'], + array_map( + static fn(Delivery $delivery): array => [ + $delivery->id, + $delivery->eventName, + $delivery->subscriberUrl, + (string) $delivery->attempts, + $delivery->lastResponse ?? '', + ], + $failed, + ), + ); + + return Command::SUCCESS; + } +} diff --git a/src/Altair/Webhooks/Dispatcher/RetryPolicy.php b/src/Altair/Webhooks/Dispatcher/RetryPolicy.php new file mode 100644 index 0000000..73cb2fc --- /dev/null +++ b/src/Altair/Webhooks/Dispatcher/RetryPolicy.php @@ -0,0 +1,44 @@ +backoff) { + self::LINEAR => $this->baseDelaySeconds * $attempt, + self::EXPONENTIAL => $this->baseDelaySeconds * (2 ** ($attempt - 1)), + default => $this->baseDelaySeconds, + }; + } +} diff --git a/src/Altair/Webhooks/Dispatcher/WebhookDispatcher.php b/src/Altair/Webhooks/Dispatcher/WebhookDispatcher.php new file mode 100644 index 0000000..98aa665 --- /dev/null +++ b/src/Altair/Webhooks/Dispatcher/WebhookDispatcher.php @@ -0,0 +1,106 @@ +|string $payload + */ + public function dispatch( + string $eventName, + array|string $payload, + string $subscriberUrl, + string $secretName, + ?string $signerName = null, + ): Delivery { + $delivery = Delivery::create( + id: (string) new Ulid(), + eventName: $eventName, + subscriberUrl: $subscriberUrl, + payload: $this->normalisePayload($payload), + secretName: $secretName, + signerName: $signerName ?? $this->defaultSignerName, + createdAt: time(), + ); + + $this->deliveries->record($delivery); + $this->bus->dispatch($this->messageFor($delivery)); + + return $delivery; + } + + /** + * Re-dispatch an existing delivery (used by webhook:replay). Resets the + * attempt counter + status to Pending and puts the same payload back on the + * bus. + */ + public function redispatch(Delivery $delivery): Delivery + { + $reset = $delivery->reset(); + $this->deliveries->update($reset); + $this->bus->dispatch($this->messageFor($reset)); + + return $reset; + } + + private function messageFor(Delivery $delivery): WebhookMessage + { + return new WebhookMessage( + deliveryId: $delivery->id, + eventName: $delivery->eventName, + payload: $delivery->payload, + subscriberUrl: $delivery->subscriberUrl, + secretName: $delivery->secretName, + signerName: $delivery->signerName, + ); + } + + /** + * @param array|string $payload + */ + private function normalisePayload(array|string $payload): string + { + if (\is_string($payload)) { + return $payload; + } + + try { + return json_encode($payload, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } catch (JsonException $jsonException) { + throw new WebhookException('Webhook payload is not JSON-encodable: ' . $jsonException->getMessage(), 0, $jsonException); + } + } +} diff --git a/src/Altair/Webhooks/Dispatcher/WebhookHandler.php b/src/Altair/Webhooks/Dispatcher/WebhookHandler.php new file mode 100644 index 0000000..1c6a27e --- /dev/null +++ b/src/Altair/Webhooks/Dispatcher/WebhookHandler.php @@ -0,0 +1,164 @@ +deliveries->findById($message->deliveryId); + if (!$delivery instanceof Delivery) { + // The delivery row vanished — nothing actionable; do not retry. + throw new UnrecoverableMessageHandlingException( + \sprintf('Webhook delivery "%s" is no longer in the store.', $message->deliveryId), + ); + } + + $attempt = $delivery->attempts + 1; + $now = time(); + + $request = $this->buildRequest($message, $now); + + try { + $status = $this->httpClient->sendRequest($request)->getStatusCode(); + } catch (ClientExceptionInterface $exception) { + $this->onTransientFailure($delivery, $attempt, $now, 'network: ' . $exception->getMessage()); + + return; + } + + if ($status >= 200 && $status < 300) { + $this->markDelivered($delivery, $attempt, $now, (string) $status); + + return; + } + + if ($status >= 500) { + $this->onTransientFailure($delivery, $attempt, $now, 'HTTP ' . $status); + + return; + } + + // 4xx — the subscriber rejected the payload; retrying will not help. + $this->deadLetter($delivery, $attempt, $now, 'HTTP ' . $status); + } + + private function buildRequest(WebhookMessage $message, int $now): RequestInterface + { + $signer = $this->signers->get($message->signerName); + $secret = $this->secrets->resolve($message->secretName); + $signature = $signer->sign($message->payload, $secret); + + return $this->requestFactory->createRequest('POST', $message->subscriberUrl) + ->withHeader('Content-Type', 'application/json') + ->withHeader('X-Signature', $signature) + ->withHeader('X-Timestamp', (string) $now) + ->withHeader('X-Event-Id', $message->deliveryId) + ->withHeader('X-Delivery-Id', $message->deliveryId) + ->withBody($this->streamFactory->createStream($message->payload)); + } + + private function markDelivered(Delivery $delivery, int $attempt, int $now, string $response): void + { + $this->deliveries->update( + $delivery + ->withStatus(DeliveryStatus::Delivered) + ->withAttempts($attempt) + ->withLastAttemptAt($now) + ->withNextAttemptAt(null) + ->withLastResponse($response), + ); + } + + private function onTransientFailure(Delivery $delivery, int $attempt, int $now, string $response): void + { + if ($attempt >= $this->retryPolicy->maxAttempts) { + $this->deadLetter($delivery, $attempt, $now, $response); + + return; + } + + $this->deliveries->update( + $delivery + ->withStatus(DeliveryStatus::Failed) + ->withAttempts($attempt) + ->withLastAttemptAt($now) + ->withNextAttemptAt($now + $this->retryPolicy->delayFor($attempt)) + ->withLastResponse($response), + ); + + throw new RecoverableMessageHandlingException(\sprintf( + 'Webhook delivery "%s" failed (attempt %d/%d): %s', + $delivery->id, + $attempt, + $this->retryPolicy->maxAttempts, + $response, + )); + } + + private function deadLetter(Delivery $delivery, int $attempt, int $now, string $response): void + { + $this->deliveries->update( + $delivery + ->withStatus(DeliveryStatus::DeadLettered) + ->withAttempts($attempt) + ->withLastAttemptAt($now) + ->withNextAttemptAt(null) + ->withLastResponse($response), + ); + + throw new UnrecoverableMessageHandlingException(\sprintf( + 'Webhook delivery "%s" dead-lettered after %d attempt(s): %s', + $delivery->id, + $attempt, + $response, + )); + } +} diff --git a/src/Altair/Webhooks/Dispatcher/WebhookMessage.php b/src/Altair/Webhooks/Dispatcher/WebhookMessage.php new file mode 100644 index 0000000..e21f507 --- /dev/null +++ b/src/Altair/Webhooks/Dispatcher/WebhookMessage.php @@ -0,0 +1,29 @@ +record($this->deadLettered('01HZZZAAAA0000000000000001')); + $bus = new RecordingMessageBus(); + $tester = new CommandTester(new WebhookReplayCommand($store, new WebhookDispatcher($bus, $store))); + + $exit = $tester->execute(['delivery-id' => '01HZZZAAAA0000000000000001']); + + self::assertSame(Command::SUCCESS, $exit); + self::assertSame(DeliveryStatus::Pending, $store->findById('01HZZZAAAA0000000000000001')?->status); + self::assertSame('01HZZZAAAA0000000000000001', $bus->lastWebhookMessage()?->deliveryId); + } + + public function testReplaysByUnambiguousPrefix(): void + { + $store = new InMemoryDeliveryStore(); + $store->record($this->deadLettered('01HZZZAAAA0000000000000001')); + $bus = new RecordingMessageBus(); + $tester = new CommandTester(new WebhookReplayCommand($store, new WebhookDispatcher($bus, $store))); + + $exit = $tester->execute(['delivery-id' => '01HZZZAAAA']); + + self::assertSame(Command::SUCCESS, $exit); + self::assertSame('01HZZZAAAA0000000000000001', $bus->lastWebhookMessage()?->deliveryId); + } + + public function testFailsForUnknownDelivery(): void + { + $store = new InMemoryDeliveryStore(); + $tester = new CommandTester(new WebhookReplayCommand($store, new WebhookDispatcher(new RecordingMessageBus(), $store))); + + $exit = $tester->execute(['delivery-id' => 'nope']); + + self::assertSame(Command::FAILURE, $exit); + self::assertStringContainsString('No delivery matching', $tester->getDisplay()); + } + + private function deadLettered(string $id): Delivery + { + return Delivery::create( + id: $id, + eventName: 'order.created', + subscriberUrl: 'https://example.test/hook', + payload: '{"id":"order_1"}', + secretName: 'partner-x', + signerName: 'hmac-sha256', + createdAt: 1_700_000_000, + )->withStatus(DeliveryStatus::DeadLettered)->withAttempts(5); + } +} diff --git a/tests/Webhooks/Cli/WebhookShowFailedCommandTest.php b/tests/Webhooks/Cli/WebhookShowFailedCommandTest.php new file mode 100644 index 0000000..95a05cf --- /dev/null +++ b/tests/Webhooks/Cli/WebhookShowFailedCommandTest.php @@ -0,0 +1,56 @@ +execute([]); + + self::assertSame(Command::SUCCESS, $exit); + self::assertStringContainsString('No dead-lettered deliveries.', $tester->getDisplay()); + } + + public function testListsDeadLetteredDeliveries(): void + { + $store = new InMemoryDeliveryStore(); + $store->record($this->deadLettered('dlv_old', 1_000)); + $store->record($this->deadLettered('dlv_new', 2_000)); + $tester = new CommandTester(new WebhookShowFailedCommand($store)); + + $exit = $tester->execute([]); + $display = $tester->getDisplay(); + + self::assertSame(Command::SUCCESS, $exit); + self::assertStringContainsString('dlv_old', $display); + self::assertStringContainsString('dlv_new', $display); + } + + private function deadLettered(string $id, int $createdAt): Delivery + { + return Delivery::create( + id: $id, + eventName: 'order.created', + subscriberUrl: 'https://example.test/hook', + payload: '{}', + secretName: 'partner-x', + signerName: 'hmac-sha256', + createdAt: $createdAt, + )->withStatus(DeliveryStatus::DeadLettered)->withAttempts(5)->withLastResponse('HTTP 500'); + } +} diff --git a/tests/Webhooks/Dispatcher/FakeHttpClient.php b/tests/Webhooks/Dispatcher/FakeHttpClient.php new file mode 100644 index 0000000..8dbdf9a --- /dev/null +++ b/tests/Webhooks/Dispatcher/FakeHttpClient.php @@ -0,0 +1,53 @@ +calls; + $this->lastRequest = $request; + + if ($this->throwNetworkError) { + throw new class('connection refused') extends RuntimeException implements ClientExceptionInterface {}; + } + + return new Response('php://memory', $this->status); + } +} diff --git a/tests/Webhooks/Dispatcher/RecordingMessageBus.php b/tests/Webhooks/Dispatcher/RecordingMessageBus.php new file mode 100644 index 0000000..f555258 --- /dev/null +++ b/tests/Webhooks/Dispatcher/RecordingMessageBus.php @@ -0,0 +1,39 @@ + */ + public array $dispatched = []; + + #[Override] + public function dispatch(object $message, array $stamps = []): Envelope + { + $this->dispatched[] = $message; + + return new Envelope($message, $stamps); + } + + public function lastWebhookMessage(): ?WebhookMessage + { + for ($i = count($this->dispatched) - 1; $i >= 0; --$i) { + $message = $this->dispatched[$i]; + if ($message instanceof WebhookMessage) { + return $message; + } + } + + return null; + } +} diff --git a/tests/Webhooks/Dispatcher/RetryPolicyTest.php b/tests/Webhooks/Dispatcher/RetryPolicyTest.php new file mode 100644 index 0000000..7296b69 --- /dev/null +++ b/tests/Webhooks/Dispatcher/RetryPolicyTest.php @@ -0,0 +1,58 @@ +maxAttempts); + self::assertSame(RetryPolicy::EXPONENTIAL, $policy->backoff); + self::assertSame(30, $policy->baseDelaySeconds); + } + + #[DataProvider('exponentialCases')] + public function testExponentialBackoff(int $attempt, int $expected): void + { + $policy = new RetryPolicy(baseDelaySeconds: 30, backoff: RetryPolicy::EXPONENTIAL); + + self::assertSame($expected, $policy->delayFor($attempt)); + } + + /** + * @return iterable + */ + public static function exponentialCases(): iterable + { + yield 'attempt 1' => [1, 30]; + yield 'attempt 2' => [2, 60]; + yield 'attempt 3' => [3, 120]; + yield 'attempt 4' => [4, 240]; + } + + public function testLinearBackoff(): void + { + $policy = new RetryPolicy(baseDelaySeconds: 10, backoff: RetryPolicy::LINEAR); + + self::assertSame(10, $policy->delayFor(1)); + self::assertSame(20, $policy->delayFor(2)); + self::assertSame(30, $policy->delayFor(3)); + } + + public function testDelayForClampsAttemptToAtLeastOne(): void + { + $policy = new RetryPolicy(baseDelaySeconds: 30, backoff: RetryPolicy::EXPONENTIAL); + + self::assertSame(30, $policy->delayFor(0)); + } +} diff --git a/tests/Webhooks/Dispatcher/WebhookDispatcherTest.php b/tests/Webhooks/Dispatcher/WebhookDispatcherTest.php new file mode 100644 index 0000000..8b7749d --- /dev/null +++ b/tests/Webhooks/Dispatcher/WebhookDispatcherTest.php @@ -0,0 +1,97 @@ +dispatch( + eventName: 'order.created', + payload: '{"id":"order_1"}', + subscriberUrl: 'https://example.test/hook', + secretName: 'partner-x', + ); + + self::assertSame(DeliveryStatus::Pending, $delivery->status); + self::assertSame('hmac-sha256', $delivery->signerName); + self::assertEquals($delivery, $store->findById($delivery->id)); + + $message = $bus->lastWebhookMessage(); + self::assertInstanceOf(WebhookMessage::class, $message); + self::assertSame($delivery->id, $message->deliveryId); + self::assertSame('order.created', $message->eventName); + self::assertSame('{"id":"order_1"}', $message->payload); + } + + public function testDispatchEncodesArrayPayloadToJson(): void + { + $store = new InMemoryDeliveryStore(); + $dispatcher = new WebhookDispatcher(new RecordingMessageBus(), $store); + + $delivery = $dispatcher->dispatch( + eventName: 'order.created', + payload: ['id' => 'order_1', 'total' => 42], + subscriberUrl: 'https://example.test/hook', + secretName: 'partner-x', + ); + + self::assertSame('{"id":"order_1","total":42}', $delivery->payload); + } + + public function testDispatchHonoursExplicitSigner(): void + { + $dispatcher = new WebhookDispatcher(new RecordingMessageBus(), new InMemoryDeliveryStore()); + + $delivery = $dispatcher->dispatch( + eventName: 'order.created', + payload: '{}', + subscriberUrl: 'https://example.test/hook', + secretName: 'partner-x', + signerName: 'ed25519', + ); + + self::assertSame('ed25519', $delivery->signerName); + } + + public function testRedispatchResetsAndRedispatches(): void + { + $store = new InMemoryDeliveryStore(); + $bus = new RecordingMessageBus(); + $dispatcher = new WebhookDispatcher($bus, $store); + + $deadLettered = Delivery::create( + id: 'dlv_1', + eventName: 'order.created', + subscriberUrl: 'https://example.test/hook', + payload: '{"id":"order_1"}', + secretName: 'partner-x', + signerName: 'hmac-sha256', + createdAt: 1_700_000_000, + )->withStatus(DeliveryStatus::DeadLettered)->withAttempts(5); + $store->record($deadLettered); + + $reset = $dispatcher->redispatch($deadLettered); + + self::assertSame(DeliveryStatus::Pending, $reset->status); + self::assertSame(0, $reset->attempts); + self::assertSame(DeliveryStatus::Pending, $store->findById('dlv_1')?->status); + self::assertSame('dlv_1', $bus->lastWebhookMessage()?->deliveryId); + } +} diff --git a/tests/Webhooks/Dispatcher/WebhookHandlerTest.php b/tests/Webhooks/Dispatcher/WebhookHandlerTest.php new file mode 100644 index 0000000..87bae0d --- /dev/null +++ b/tests/Webhooks/Dispatcher/WebhookHandlerTest.php @@ -0,0 +1,184 @@ +record($this->pendingDelivery()); + $client = FakeHttpClient::returning(200); + + ($this->handler($client, $store))(($this->message())); + + $delivery = $store->findById('dlv_1'); + self::assertSame(DeliveryStatus::Delivered, $delivery?->status); + self::assertSame(1, $delivery->attempts); + self::assertSame('200', $delivery->lastResponse); + self::assertNull($delivery->nextAttemptAt); + } + + public function testTransient5xxBelowThresholdThrowsRecoverableAndMarksFailed(): void + { + $store = new InMemoryDeliveryStore(); + $store->record($this->pendingDelivery()); + $handler = $this->handler(FakeHttpClient::returning(503), $store, new RetryPolicy(maxAttempts: 3)); + + try { + $handler($this->message()); + self::fail('expected RecoverableMessageHandlingException'); + } catch (RecoverableMessageHandlingException) { + // expected — Messenger will redeliver + } + + $delivery = $store->findById('dlv_1'); + self::assertSame(DeliveryStatus::Failed, $delivery?->status); + self::assertSame(1, $delivery->attempts); + self::assertNotNull($delivery->nextAttemptAt); + } + + public function testTransientFailureAtThresholdDeadLetters(): void + { + $store = new InMemoryDeliveryStore(); + // Already attempted twice; with maxAttempts=3 the next failure dead-letters. + $store->record($this->pendingDelivery()->withAttempts(2)); + $handler = $this->handler(FakeHttpClient::returning(500), $store, new RetryPolicy(maxAttempts: 3)); + + try { + $handler($this->message()); + self::fail('expected UnrecoverableMessageHandlingException'); + } catch (UnrecoverableMessageHandlingException) { + // expected — routed to the failure transport + } + + $delivery = $store->findById('dlv_1'); + self::assertSame(DeliveryStatus::DeadLettered, $delivery?->status); + self::assertSame(3, $delivery->attempts); + } + + public function testNetworkFailureIsTreatedAsTransient(): void + { + $store = new InMemoryDeliveryStore(); + $store->record($this->pendingDelivery()); + $handler = $this->handler(FakeHttpClient::networkError(), $store, new RetryPolicy(maxAttempts: 3)); + + $this->expectException(RecoverableMessageHandlingException::class); + + try { + $handler($this->message()); + } finally { + self::assertSame(DeliveryStatus::Failed, $store->findById('dlv_1')?->status); + } + } + + public function testClientErrorDeadLettersImmediately(): void + { + $store = new InMemoryDeliveryStore(); + $store->record($this->pendingDelivery()); + $handler = $this->handler(FakeHttpClient::returning(400), $store); + + try { + $handler($this->message()); + self::fail('expected UnrecoverableMessageHandlingException'); + } catch (UnrecoverableMessageHandlingException) { + // expected + } + + self::assertSame(DeliveryStatus::DeadLettered, $store->findById('dlv_1')?->status); + } + + public function testMissingDeliveryThrowsUnrecoverable(): void + { + $handler = $this->handler(FakeHttpClient::returning(200), new InMemoryDeliveryStore()); + + $this->expectException(UnrecoverableMessageHandlingException::class); + + $handler($this->message()); + } + + public function testRequestIsSignedWithExpectedHeaders(): void + { + $store = new InMemoryDeliveryStore(); + $store->record($this->pendingDelivery()); + $client = FakeHttpClient::returning(200); + + ($this->handler($client, $store))($this->message()); + + $request = $client->lastRequest; + self::assertNotNull($request); + self::assertSame('POST', $request->getMethod()); + self::assertSame( + (new HmacSha256Signer())->sign(self::PAYLOAD, self::SECRET), + $request->getHeaderLine('X-Signature'), + ); + self::assertSame('dlv_1', $request->getHeaderLine('X-Delivery-Id')); + self::assertSame('dlv_1', $request->getHeaderLine('X-Event-Id')); + self::assertNotSame('', $request->getHeaderLine('X-Timestamp')); + self::assertSame(self::PAYLOAD, (string) $request->getBody()); + } + + private function handler( + FakeHttpClient $client, + DeliveryStoreInterface $store, + RetryPolicy $policy = new RetryPolicy(), + ): WebhookHandler { + return new WebhookHandler( + httpClient: $client, + requestFactory: new RequestFactory(), + streamFactory: new StreamFactory(), + signers: SignerRegistry::default(), + secrets: new StaticSecretResolver(self::SECRET), + deliveries: $store, + retryPolicy: $policy, + ); + } + + private function pendingDelivery(): Delivery + { + return Delivery::create( + id: 'dlv_1', + eventName: 'order.created', + subscriberUrl: 'https://example.test/hook', + payload: self::PAYLOAD, + secretName: 'partner-x', + signerName: 'hmac-sha256', + createdAt: 1_700_000_000, + ); + } + + private function message(): WebhookMessage + { + return new WebhookMessage( + deliveryId: 'dlv_1', + eventName: 'order.created', + payload: self::PAYLOAD, + subscriberUrl: 'https://example.test/hook', + secretName: 'partner-x', + signerName: 'hmac-sha256', + ); + } +} diff --git a/tests/Webhooks/Fixtures/StaticSecretResolver.php b/tests/Webhooks/Fixtures/StaticSecretResolver.php new file mode 100644 index 0000000..f1d0a0f --- /dev/null +++ b/tests/Webhooks/Fixtures/StaticSecretResolver.php @@ -0,0 +1,22 @@ +secret; + } +} From b8df5376cf11bca700620ef2abb9a5775107671b Mon Sep 17 00:00:00 2001 From: Antonio Ramirez Date: Sun, 31 May 2026 08:07:27 +0200 Subject: [PATCH 4/6] feat(scaffold): webhook: spec block + scaffolder integration (#188) --- src/Altair/Scaffold/Emitter/ActionEmitter.php | 41 +++- src/Altair/Scaffold/Emitter/EmissionPlan.php | 6 + .../Scaffold/Emitter/EmittedFileKind.php | 1 + src/Altair/Scaffold/Emitter/Naming.php | 15 ++ .../WebhookDispatcherBindingEmitter.php | 114 +++++++++++ src/Altair/Scaffold/Spec/Ast/Spec.php | 1 + src/Altair/Scaffold/Spec/Ast/WebhookSpec.php | 55 ++++++ src/Altair/Scaffold/Spec/Parser.php | 74 ++++++++ src/Altair/Scaffold/Spec/Validator.php | 47 +++++ .../Emitter/ActionEmitterWebhookTest.php | 75 ++++++++ .../WebhookDispatcherBindingEmitterTest.php | 65 +++++++ tests/Scaffold/Spec/WebhookParserTest.php | 178 ++++++++++++++++++ 12 files changed, 671 insertions(+), 1 deletion(-) create mode 100644 src/Altair/Scaffold/Emitter/WebhookDispatcherBindingEmitter.php create mode 100644 src/Altair/Scaffold/Spec/Ast/WebhookSpec.php create mode 100644 tests/Scaffold/Emitter/ActionEmitterWebhookTest.php create mode 100644 tests/Scaffold/Emitter/WebhookDispatcherBindingEmitterTest.php create mode 100644 tests/Scaffold/Spec/WebhookParserTest.php diff --git a/src/Altair/Scaffold/Emitter/ActionEmitter.php b/src/Altair/Scaffold/Emitter/ActionEmitter.php index 73e7d4d..0bdb955 100644 --- a/src/Altair/Scaffold/Emitter/ActionEmitter.php +++ b/src/Altair/Scaffold/Emitter/ActionEmitter.php @@ -13,6 +13,7 @@ use Altair\Scaffold\Spec\Ast\IdempotencySpec; use Altair\Scaffold\Spec\Ast\Spec; +use Altair\Scaffold\Spec\Ast\WebhookSpec; use Altair\Scaffold\Templating\PhpHeader; /** @@ -39,6 +40,7 @@ public function emit(Spec $spec): EmittedFile $responderFqcn = $this->naming->responderFqcn($spec); $domainFqcn = $spec->domain->class; $idempotencyAccessor = $this->renderIdempotencyAccessor($spec->idempotency); + $webhookAccessor = $this->renderWebhookAccessor($spec->webhook); $header = PhpHeader::render($namespace); $body = <<direction !== WebhookSpec::DIRECTION_IN) { + return ''; + } + + $direction = var_export($webhook->direction, true); + $signing = var_export($webhook->signing, true); + $secret = var_export($webhook->secretName, true); + $signatureHeader = var_export($webhook->signatureHeader, true); + $timestampHeader = var_export($webhook->timestampHeader, true); + $eventIdHeader = var_export($webhook->eventIdHeader, true); + $dedupeTtl = var_export($webhook->dedupeTtl, true); + $timestampWindow = var_export($webhook->timestampWindow, true); + + return << {$direction}, 'signing' => {$signing}, 'secret_name' => {$secret}, 'signature_header' => {$signatureHeader}, 'timestamp_header' => {$timestampHeader}, 'event_id_header' => {$eventIdHeader}, 'dedupe_ttl' => {$dedupeTtl}, 'timestamp_window' => {$timestampWindow}]; + } + + PHP; + } } diff --git a/src/Altair/Scaffold/Emitter/EmissionPlan.php b/src/Altair/Scaffold/Emitter/EmissionPlan.php index f750982..157368d 100644 --- a/src/Altair/Scaffold/Emitter/EmissionPlan.php +++ b/src/Altair/Scaffold/Emitter/EmissionPlan.php @@ -13,6 +13,7 @@ use Altair\Scaffold\Spec\Ast\PersistenceSpec; use Altair\Scaffold\Spec\Ast\Spec; +use Altair\Scaffold\Spec\Ast\WebhookSpec; /** * Runs every emitter against a Spec and returns the list of EmittedFile @@ -37,6 +38,7 @@ public function __construct( private readonly MessageEmitter $messageEmitter = new MessageEmitter(), private readonly HandlerEmitter $handlerEmitter = new HandlerEmitter(), private readonly HandlerTestEmitter $handlerTestEmitter = new HandlerTestEmitter(), + private readonly WebhookDispatcherBindingEmitter $webhookDispatcherBindingEmitter = new WebhookDispatcherBindingEmitter(), ) {} /** @@ -69,6 +71,10 @@ public function build(Spec $spec): array $files[] = $this->handlerTestEmitter->emit($queue); } + if ($spec->webhook instanceof WebhookSpec && $spec->webhook->isOutbound()) { + $files[] = $this->webhookDispatcherBindingEmitter->emit($spec); + } + return $files; } } diff --git a/src/Altair/Scaffold/Emitter/EmittedFileKind.php b/src/Altair/Scaffold/Emitter/EmittedFileKind.php index 2264d4b..ad6bf73 100644 --- a/src/Altair/Scaffold/Emitter/EmittedFileKind.php +++ b/src/Altair/Scaffold/Emitter/EmittedFileKind.php @@ -27,4 +27,5 @@ enum EmittedFileKind: string case Handler = 'handler'; case HandlerTest = 'handler-test'; case Spec = 'spec'; + case WebhookDispatcher = 'webhook-dispatcher'; } diff --git a/src/Altair/Scaffold/Emitter/Naming.php b/src/Altair/Scaffold/Emitter/Naming.php index 6290838..84a2f24 100644 --- a/src/Altair/Scaffold/Emitter/Naming.php +++ b/src/Altair/Scaffold/Emitter/Naming.php @@ -176,6 +176,21 @@ public function messagePath(string $messageFqcn): string return $this->classFileRelativePath($messageFqcn); } + public function webhookDispatcherShortName(Spec $spec): string + { + return $spec->artifactName() . 'WebhookDispatcher'; + } + + public function webhookDispatcherFqcn(Spec $spec): string + { + return $this->appNamespace . '\\Webhooks\\' . $this->webhookDispatcherShortName($spec); + } + + public function webhookDispatcherPath(Spec $spec): string + { + return $this->classFileRelativePath($this->webhookDispatcherFqcn($spec)); + } + public function handlerFqcn(string $messageFqcn): string { $namespace = $this->namespaceOf($messageFqcn); diff --git a/src/Altair/Scaffold/Emitter/WebhookDispatcherBindingEmitter.php b/src/Altair/Scaffold/Emitter/WebhookDispatcherBindingEmitter.php new file mode 100644 index 0000000..e8a12b2 --- /dev/null +++ b/src/Altair/Scaffold/Emitter/WebhookDispatcherBindingEmitter.php @@ -0,0 +1,114 @@ +webhook; + if (!$webhook instanceof WebhookSpec) { + throw new LogicException('WebhookDispatcherBindingEmitter requires a webhook block.'); + } + + $shortName = $this->naming->webhookDispatcherShortName($spec); + $namespace = $this->namespaceOf($this->naming->webhookDispatcherFqcn($spec)); + + $signing = var_export($webhook->signing, true); + $backoff = var_export($webhook->retryBackoff, true); + $deadLetter = var_export($webhook->deadLetterTransport, true); + $maxAttempts = $webhook->retryMaxAttempts; + $baseDelaySeconds = $this->toSeconds($webhook->retryBaseDelay); + + $header = PhpHeader::render($namespace); + $body = <<endpoint->method} {$spec->endpoint->path}. + * + * Wraps WebhookDispatcher with the signing scheme + retry policy the + * spec declared; the host dispatches through this binding. + */ + final readonly class {$shortName} + { + public const string SIGNING = {$signing}; + + public const ?string DEAD_LETTER_TRANSPORT = {$deadLetter}; + + public function __construct(private WebhookDispatcher \$dispatcher) {} + + public function retryPolicy(): RetryPolicy + { + return new RetryPolicy(maxAttempts: {$maxAttempts}, backoff: {$backoff}, baseDelaySeconds: {$baseDelaySeconds}); + } + + /** + * @param array|string \$payload + */ + public function dispatch(string \$eventName, array|string \$payload, string \$subscriberUrl, string \$secretName): Delivery + { + return \$this->dispatcher->dispatch(\$eventName, \$payload, \$subscriberUrl, \$secretName, self::SIGNING); + } + } + + PHP; + + return new EmittedFile( + relativePath: $this->naming->webhookDispatcherPath($spec), + contents: $header . $body, + kind: EmittedFileKind::WebhookDispatcher, + ); + } + + private function toSeconds(string $duration): int + { + if (preg_match('/^(\d+)(ms|s|m|h|d)$/', $duration, $match) !== 1) { + return 30; + } + + $value = (int) $match[1]; + + return match ($match[2]) { + 'ms' => $value > 0 ? max(1, (int) ceil($value / 1000)) : 0, + 'm' => $value * 60, + 'h' => $value * 3_600, + 'd' => $value * 86_400, + default => $value, + }; + } + + private function namespaceOf(string $fqcn): string + { + $pos = strrpos($fqcn, '\\'); + + return $pos === false ? '' : substr($fqcn, 0, $pos); + } +} diff --git a/src/Altair/Scaffold/Spec/Ast/Spec.php b/src/Altair/Scaffold/Spec/Ast/Spec.php index 9e68d69..936f1e9 100644 --- a/src/Altair/Scaffold/Spec/Ast/Spec.php +++ b/src/Altair/Scaffold/Spec/Ast/Spec.php @@ -30,6 +30,7 @@ public function __construct( public ?PersistenceSpec $persistence = null, public array $queue = [], public ?IdempotencySpec $idempotency = null, + public ?WebhookSpec $webhook = null, ) {} /** diff --git a/src/Altair/Scaffold/Spec/Ast/WebhookSpec.php b/src/Altair/Scaffold/Spec/Ast/WebhookSpec.php new file mode 100644 index 0000000..e7cc7a4 --- /dev/null +++ b/src/Altair/Scaffold/Spec/Ast/WebhookSpec.php @@ -0,0 +1,55 @@ +direction === self::DIRECTION_IN; + } + + public function isOutbound(): bool + { + return $this->direction === self::DIRECTION_OUT; + } +} diff --git a/src/Altair/Scaffold/Spec/Parser.php b/src/Altair/Scaffold/Spec/Parser.php index 0e22911..4f9eb4c 100644 --- a/src/Altair/Scaffold/Spec/Parser.php +++ b/src/Altair/Scaffold/Spec/Parser.php @@ -22,6 +22,7 @@ use Altair\Scaffold\Spec\Ast\PersistenceSpec; use Altair\Scaffold\Spec\Ast\QueueDispatchSpec; use Altair\Scaffold\Spec\Ast\Spec; +use Altair\Scaffold\Spec\Ast\WebhookSpec; use Symfony\Component\Yaml\Exception\ParseException; use Symfony\Component\Yaml\Yaml; @@ -69,6 +70,9 @@ public function parseString(string $yaml, string $sourcePath = ''): Spec idempotency: isset($data['idempotency']) ? $this->parseIdempotency($this->requireMap($data, 'idempotency', $sourcePath)) : null, + webhook: isset($data['webhook']) + ? $this->parseWebhook($this->requireMap($data, 'webhook', $sourcePath)) + : null, ); } @@ -92,6 +96,76 @@ private function parseIdempotency(array $data): IdempotencySpec ); } + /** + * @param array $data + */ + private function parseWebhook(array $data): WebhookSpec + { + $direction = $data['direction'] ?? null; + $signing = $data['signing'] ?? null; + if (!\is_string($direction) || !\is_string($signing)) { + throw new SpecParseException("'webhook' requires string 'direction' and 'signing'."); + } + + $retry = $data['retry'] ?? []; + if (!\is_array($retry)) { + throw new SpecParseException("'webhook.retry' must be a map."); + } + + $maxAttempts = $retry['max_attempts'] ?? 5; + if (!\is_int($maxAttempts)) { + throw new SpecParseException("'webhook.retry.max_attempts' must be an integer."); + } + + /** @var array $retry */ + return new WebhookSpec( + direction: $direction, + signing: $signing, + secretName: $this->webhookOptionalString($data, 'secret_name'), + signatureHeader: $this->webhookString($data, 'header', 'X-Signature'), + timestampHeader: $this->webhookString($data, 'timestamp_header', 'X-Timestamp'), + eventIdHeader: $this->webhookString($data, 'event_id_header', 'X-Event-Id'), + dedupeTtl: $this->webhookString($data, 'dedupe_ttl', '1h'), + timestampWindow: $this->webhookString($data, 'timestamp_window', '5m'), + retryMaxAttempts: $maxAttempts, + retryBackoff: $this->webhookString($retry, 'backoff', 'exponential'), + retryBaseDelay: $this->webhookString($retry, 'base_delay', '30s'), + deadLetterTransport: $this->webhookOptionalString($data, 'dead_letter'), + ); + } + + /** + * @param array $data + */ + private function webhookString(array $data, string $key, string $default): string + { + if (!isset($data[$key])) { + return $default; + } + + if (!\is_string($data[$key])) { + throw new SpecParseException(\sprintf("'webhook.%s' must be a string.", $key)); + } + + return $data[$key]; + } + + /** + * @param array $data + */ + private function webhookOptionalString(array $data, string $key): ?string + { + if (!isset($data[$key])) { + return null; + } + + if (!\is_string($data[$key])) { + throw new SpecParseException(\sprintf("'webhook.%s' must be a string.", $key)); + } + + return $data[$key]; + } + /** * @param array $data */ diff --git a/src/Altair/Scaffold/Spec/Validator.php b/src/Altair/Scaffold/Spec/Validator.php index 782ddf8..ff2d293 100644 --- a/src/Altair/Scaffold/Spec/Validator.php +++ b/src/Altair/Scaffold/Spec/Validator.php @@ -17,6 +17,7 @@ use Altair\Scaffold\Spec\Ast\PersistenceSpec; use Altair\Scaffold\Spec\Ast\QueueDispatchSpec; use Altair\Scaffold\Spec\Ast\Spec; +use Altair\Scaffold\Spec\Ast\WebhookSpec; /** * Performs semantic validation on a parsed spec. @@ -95,6 +96,10 @@ public function collectErrors(Spec $spec): array array_push($errors, ...$this->validateIdempotency($spec->idempotency)); } + if ($spec->webhook instanceof WebhookSpec) { + array_push($errors, ...$this->validateWebhook($spec->webhook)); + } + return $errors; } @@ -130,6 +135,48 @@ private function validateIdempotency(IdempotencySpec $idempotency): array return $errors; } + /** + * @return list + */ + private function validateWebhook(WebhookSpec $webhook): array + { + $errors = []; + + if (!\in_array($webhook->direction, [WebhookSpec::DIRECTION_IN, WebhookSpec::DIRECTION_OUT], true)) { + $errors[] = \sprintf("webhook.direction '%s' must be 'in' or 'out'.", $webhook->direction); + } + + $validSigners = ['hmac-sha256', 'hmac-sha512', 'ed25519']; + if (!\in_array($webhook->signing, $validSigners, true)) { + $errors[] = \sprintf('webhook.signing %s must be one of: %s.', $webhook->signing, implode(', ', $validSigners)); + } + + if ($webhook->direction === WebhookSpec::DIRECTION_IN && ($webhook->secretName === null || $webhook->secretName === '')) { + $errors[] = "webhook.secret_name is required when direction is 'in'."; + } + + $durations = [ + 'dedupe_ttl' => $webhook->dedupeTtl, + 'timestamp_window' => $webhook->timestampWindow, + 'retry.base_delay' => $webhook->retryBaseDelay, + ]; + foreach ($durations as $label => $value) { + if (preg_match('/^\d+(ms|s|m|h|d)$/', $value) !== 1) { + $errors[] = \sprintf("webhook.%s '%s' must match ''.", $label, $value); + } + } + + if ($webhook->retryMaxAttempts < 1) { + $errors[] = 'webhook.retry.max_attempts must be a positive integer.'; + } + + if (!\in_array($webhook->retryBackoff, [WebhookSpec::BACKOFF_EXPONENTIAL, WebhookSpec::BACKOFF_LINEAR], true)) { + $errors[] = \sprintf("webhook.retry.backoff '%s' must be 'exponential' or 'linear'.", $webhook->retryBackoff); + } + + return $errors; + } + /** * @return list */ diff --git a/tests/Scaffold/Emitter/ActionEmitterWebhookTest.php b/tests/Scaffold/Emitter/ActionEmitterWebhookTest.php new file mode 100644 index 0000000..c918f2d --- /dev/null +++ b/tests/Scaffold/Emitter/ActionEmitterWebhookTest.php @@ -0,0 +1,75 @@ +emit(<<<'YAML' + webhook: + direction: in + signing: hmac-sha256 + secret_name: stripe + dedupe_ttl: 2h + YAML); + + self::assertStringContainsString('public static function webhook(): array', $contents); + self::assertStringContainsString("'direction' => 'in'", $contents); + self::assertStringContainsString("'signing' => 'hmac-sha256'", $contents); + self::assertStringContainsString("'secret_name' => 'stripe'", $contents); + self::assertStringContainsString("'dedupe_ttl' => '2h'", $contents); + } + + public function testOutboundSpecDoesNotEmitWebhookAccessor(): void + { + $contents = $this->emit(<<<'YAML' + webhook: + direction: out + signing: hmac-sha256 + YAML); + + self::assertStringNotContainsString('public static function webhook()', $contents); + } + + public function testAbsentWebhookLeavesActionByteIdentical(): void + { + $withoutBlock = $this->emit(''); + + self::assertStringNotContainsString('public static function webhook', $withoutBlock); + } + + public function testDeterministicOutput(): void + { + $block = <<<'YAML' + webhook: + direction: in + signing: hmac-sha256 + secret_name: stripe + YAML; + + self::assertSame($this->emit($block), $this->emit($block)); + } + + private function emit(string $webhookBlock): string + { + $spec = (new Parser())->parseString(<<emit($spec)->contents; + } +} diff --git a/tests/Scaffold/Emitter/WebhookDispatcherBindingEmitterTest.php b/tests/Scaffold/Emitter/WebhookDispatcherBindingEmitterTest.php new file mode 100644 index 0000000..785954b --- /dev/null +++ b/tests/Scaffold/Emitter/WebhookDispatcherBindingEmitterTest.php @@ -0,0 +1,65 @@ +emit($this->outboundSpec()); + + self::assertSame(EmittedFileKind::WebhookDispatcher, $file->kind); + self::assertSame('app/Webhooks/PublishPostWebhookDispatcher.php', $file->relativePath); + self::assertStringContainsString('final readonly class PublishPostWebhookDispatcher', $file->contents); + self::assertStringContainsString("public const string SIGNING = 'hmac-sha256';", $file->contents); + self::assertStringContainsString("public const ?string DEAD_LETTER_TRANSPORT = 'webhook.dlq';", $file->contents); + self::assertStringContainsString('use Altair\\Webhooks\\Dispatcher\\WebhookDispatcher;', $file->contents); + } + + public function testRetryPolicyReflectsSpecAndConvertsBaseDelay(): void + { + $file = (new WebhookDispatcherBindingEmitter())->emit($this->outboundSpec()); + + // base_delay: 2m -> 120 seconds, max_attempts: 4, backoff: linear + self::assertStringContainsString( + 'new RetryPolicy(maxAttempts: 4, backoff: \'linear\', baseDelaySeconds: 120)', + $file->contents, + ); + } + + public function testDeterministicOutput(): void + { + $first = (new WebhookDispatcherBindingEmitter())->emit($this->outboundSpec()); + $second = (new WebhookDispatcherBindingEmitter())->emit($this->outboundSpec()); + + self::assertSame($first->contents, $second->contents); + } + + private function outboundSpec(): \Altair\Scaffold\Spec\Ast\Spec + { + return (new Parser())->parseString(<<<'YAML' + endpoint: + method: POST + path: /posts + domain: + class: App\Posts\PublishPost + webhook: + direction: out + signing: hmac-sha256 + retry: + max_attempts: 4 + backoff: linear + base_delay: 2m + dead_letter: webhook.dlq + YAML); + } +} diff --git a/tests/Scaffold/Spec/WebhookParserTest.php b/tests/Scaffold/Spec/WebhookParserTest.php new file mode 100644 index 0000000..39b0074 --- /dev/null +++ b/tests/Scaffold/Spec/WebhookParserTest.php @@ -0,0 +1,178 @@ +parseString($this->spec('')); + + self::assertNull($spec->webhook); + } + + public function testParsesInboundBlock(): void + { + $spec = (new Parser())->parseString($this->spec(<<<'YAML' + webhook: + direction: in + signing: hmac-sha256 + secret_name: stripe + header: X-Stripe-Signature + dedupe_ttl: 2h + timestamp_window: 10m + YAML)); + + $webhook = $spec->webhook; + self::assertInstanceOf(WebhookSpec::class, $webhook); + self::assertTrue($webhook->isInbound()); + self::assertSame('hmac-sha256', $webhook->signing); + self::assertSame('stripe', $webhook->secretName); + self::assertSame('X-Stripe-Signature', $webhook->signatureHeader); + self::assertSame('2h', $webhook->dedupeTtl); + self::assertSame('10m', $webhook->timestampWindow); + } + + public function testParsesOutboundBlockWithRetry(): void + { + $spec = (new Parser())->parseString($this->spec(<<<'YAML' + webhook: + direction: out + signing: hmac-sha512 + retry: + max_attempts: 7 + backoff: linear + base_delay: 15s + dead_letter: webhook.dlq + YAML)); + + $webhook = $spec->webhook; + self::assertInstanceOf(WebhookSpec::class, $webhook); + self::assertTrue($webhook->isOutbound()); + self::assertSame('hmac-sha512', $webhook->signing); + self::assertSame(7, $webhook->retryMaxAttempts); + self::assertSame('linear', $webhook->retryBackoff); + self::assertSame('15s', $webhook->retryBaseDelay); + self::assertSame('webhook.dlq', $webhook->deadLetterTransport); + } + + public function testInboundDefaultsApply(): void + { + $spec = (new Parser())->parseString($this->spec(<<<'YAML' + webhook: + direction: in + signing: hmac-sha256 + secret_name: github + YAML)); + + $webhook = $spec->webhook; + self::assertInstanceOf(WebhookSpec::class, $webhook); + self::assertSame('X-Signature', $webhook->signatureHeader); + self::assertSame('1h', $webhook->dedupeTtl); + self::assertSame('5m', $webhook->timestampWindow); + } + + public function testValidatorAcceptsValidInbound(): void + { + $spec = (new Parser())->parseString($this->spec(<<<'YAML' + webhook: + direction: in + signing: hmac-sha256 + secret_name: stripe + YAML)); + + self::assertSame([], (new Validator())->collectErrors($spec)); + } + + public function testValidatorRejectsUnknownDirection(): void + { + $errors = $this->errorsFor(<<<'YAML' + webhook: + direction: sideways + signing: hmac-sha256 + secret_name: stripe + YAML); + + self::assertNotEmpty(array_filter($errors, static fn (string $e): bool => str_contains($e, 'webhook.direction'))); + } + + public function testValidatorRejectsUnknownSigner(): void + { + $errors = $this->errorsFor(<<<'YAML' + webhook: + direction: in + signing: rot13 + secret_name: stripe + YAML); + + self::assertNotEmpty(array_filter($errors, static fn (string $e): bool => str_contains($e, 'webhook.signing'))); + } + + public function testValidatorRequiresSecretNameForInbound(): void + { + $errors = $this->errorsFor(<<<'YAML' + webhook: + direction: in + signing: hmac-sha256 + YAML); + + self::assertNotEmpty(array_filter($errors, static fn (string $e): bool => str_contains($e, 'secret_name'))); + } + + public function testValidatorRejectsMalformedDuration(): void + { + $errors = $this->errorsFor(<<<'YAML' + webhook: + direction: in + signing: hmac-sha256 + secret_name: stripe + dedupe_ttl: forever + YAML); + + self::assertNotEmpty(array_filter($errors, static fn (string $e): bool => str_contains($e, 'dedupe_ttl'))); + } + + public function testValidatorRejectsBadBackoff(): void + { + $errors = $this->errorsFor(<<<'YAML' + webhook: + direction: out + signing: hmac-sha256 + retry: + backoff: fibonacci + YAML); + + self::assertNotEmpty(array_filter($errors, static fn (string $e): bool => str_contains($e, 'backoff'))); + } + + /** + * @return list + */ + private function errorsFor(string $webhookBlock): array + { + return (new Validator())->collectErrors((new Parser())->parseString($this->spec($webhookBlock))); + } + + private function spec(string $webhookBlock): string + { + return << Date: Sun, 31 May 2026 08:18:30 +0200 Subject: [PATCH 5/6] chore(webhooks): rector/cs autofixes + regenerate .agent manifests Greens the PR's Static Analysis and Determinism gates: - Rector: catch-var rename, never return types on always-throwing helpers, import ordering / quote-escape autofixes across the webhooks code + tests. - Determinism (#74): regenerate .agent/ on PHP 8.3 (the gate's minor) so the new univeros/webhooks package + scaffold additions are indexed (codemaps/webhooks.md, packages/webhooks.md, manifest.json, scaffold codemap). --- src/Altair/Webhooks/Dispatcher/WebhookHandler.php | 6 +++--- src/Altair/Webhooks/Signing/AbstractHmacSigner.php | 1 + .../Emitter/WebhookDispatcherBindingEmitterTest.php | 5 +++-- tests/Webhooks/Cli/WebhookReplayCommandTest.php | 2 ++ tests/Webhooks/Cli/WebhookShowFailedCommandTest.php | 1 + tests/Webhooks/Dispatcher/RetryPolicyTest.php | 6 +++--- tests/Webhooks/Dispatcher/WebhookHandlerTest.php | 7 +++++++ .../Middleware/ActionAwareWebhookVerifyMiddlewareTest.php | 3 ++- tests/Webhooks/Middleware/WebhookVerifyMiddlewareTest.php | 2 ++ tests/Webhooks/Signing/EnvSecretResolverTest.php | 1 + tests/Webhooks/Signing/HmacSha256SignerTest.php | 4 +++- tests/Webhooks/Signing/HmacSha512SignerTest.php | 4 +++- 12 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/Altair/Webhooks/Dispatcher/WebhookHandler.php b/src/Altair/Webhooks/Dispatcher/WebhookHandler.php index 1c6a27e..6abed09 100644 --- a/src/Altair/Webhooks/Dispatcher/WebhookHandler.php +++ b/src/Altair/Webhooks/Dispatcher/WebhookHandler.php @@ -68,8 +68,8 @@ public function __invoke(WebhookMessage $message): void try { $status = $this->httpClient->sendRequest($request)->getStatusCode(); - } catch (ClientExceptionInterface $exception) { - $this->onTransientFailure($delivery, $attempt, $now, 'network: ' . $exception->getMessage()); + } catch (ClientExceptionInterface $clientException) { + $this->onTransientFailure($delivery, $attempt, $now, 'network: ' . $clientException->getMessage()); return; } @@ -143,7 +143,7 @@ private function onTransientFailure(Delivery $delivery, int $attempt, int $now, )); } - private function deadLetter(Delivery $delivery, int $attempt, int $now, string $response): void + private function deadLetter(Delivery $delivery, int $attempt, int $now, string $response): never { $this->deliveries->update( $delivery diff --git a/src/Altair/Webhooks/Signing/AbstractHmacSigner.php b/src/Altair/Webhooks/Signing/AbstractHmacSigner.php index 39fa280..4071e34 100644 --- a/src/Altair/Webhooks/Signing/AbstractHmacSigner.php +++ b/src/Altair/Webhooks/Signing/AbstractHmacSigner.php @@ -41,6 +41,7 @@ public function verify(string $payload, string $signature, string $secret): bool // partial match, which is the whole point for HMAC verification. return hash_equals($this->sign($payload, $secret), $provided); } + abstract protected function algo(): string; /** diff --git a/tests/Scaffold/Emitter/WebhookDispatcherBindingEmitterTest.php b/tests/Scaffold/Emitter/WebhookDispatcherBindingEmitterTest.php index 785954b..2d0b428 100644 --- a/tests/Scaffold/Emitter/WebhookDispatcherBindingEmitterTest.php +++ b/tests/Scaffold/Emitter/WebhookDispatcherBindingEmitterTest.php @@ -4,6 +4,7 @@ namespace Altair\Tests\Scaffold\Emitter; +use Altair\Scaffold\Spec\Ast\Spec; use Altair\Scaffold\Emitter\EmittedFileKind; use Altair\Scaffold\Emitter\WebhookDispatcherBindingEmitter; use Altair\Scaffold\Spec\Parser; @@ -31,7 +32,7 @@ public function testRetryPolicyReflectsSpecAndConvertsBaseDelay(): void // base_delay: 2m -> 120 seconds, max_attempts: 4, backoff: linear self::assertStringContainsString( - 'new RetryPolicy(maxAttempts: 4, backoff: \'linear\', baseDelaySeconds: 120)', + "new RetryPolicy(maxAttempts: 4, backoff: 'linear', baseDelaySeconds: 120)", $file->contents, ); } @@ -44,7 +45,7 @@ public function testDeterministicOutput(): void self::assertSame($first->contents, $second->contents); } - private function outboundSpec(): \Altair\Scaffold\Spec\Ast\Spec + private function outboundSpec(): Spec { return (new Parser())->parseString(<<<'YAML' endpoint: diff --git a/tests/Webhooks/Cli/WebhookReplayCommandTest.php b/tests/Webhooks/Cli/WebhookReplayCommandTest.php index de39247..624da1b 100644 --- a/tests/Webhooks/Cli/WebhookReplayCommandTest.php +++ b/tests/Webhooks/Cli/WebhookReplayCommandTest.php @@ -22,6 +22,7 @@ public function testReplaysADeadLetteredDeliveryByFullId(): void { $store = new InMemoryDeliveryStore(); $store->record($this->deadLettered('01HZZZAAAA0000000000000001')); + $bus = new RecordingMessageBus(); $tester = new CommandTester(new WebhookReplayCommand($store, new WebhookDispatcher($bus, $store))); @@ -36,6 +37,7 @@ public function testReplaysByUnambiguousPrefix(): void { $store = new InMemoryDeliveryStore(); $store->record($this->deadLettered('01HZZZAAAA0000000000000001')); + $bus = new RecordingMessageBus(); $tester = new CommandTester(new WebhookReplayCommand($store, new WebhookDispatcher($bus, $store))); diff --git a/tests/Webhooks/Cli/WebhookShowFailedCommandTest.php b/tests/Webhooks/Cli/WebhookShowFailedCommandTest.php index 95a05cf..afc92c3 100644 --- a/tests/Webhooks/Cli/WebhookShowFailedCommandTest.php +++ b/tests/Webhooks/Cli/WebhookShowFailedCommandTest.php @@ -31,6 +31,7 @@ public function testListsDeadLetteredDeliveries(): void $store = new InMemoryDeliveryStore(); $store->record($this->deadLettered('dlv_old', 1_000)); $store->record($this->deadLettered('dlv_new', 2_000)); + $tester = new CommandTester(new WebhookShowFailedCommand($store)); $exit = $tester->execute([]); diff --git a/tests/Webhooks/Dispatcher/RetryPolicyTest.php b/tests/Webhooks/Dispatcher/RetryPolicyTest.php index 7296b69..7c8a54b 100644 --- a/tests/Webhooks/Dispatcher/RetryPolicyTest.php +++ b/tests/Webhooks/Dispatcher/RetryPolicyTest.php @@ -24,7 +24,7 @@ public function testDefaults(): void #[DataProvider('exponentialCases')] public function testExponentialBackoff(int $attempt, int $expected): void { - $policy = new RetryPolicy(baseDelaySeconds: 30, backoff: RetryPolicy::EXPONENTIAL); + $policy = new RetryPolicy(backoff: RetryPolicy::EXPONENTIAL, baseDelaySeconds: 30); self::assertSame($expected, $policy->delayFor($attempt)); } @@ -42,7 +42,7 @@ public static function exponentialCases(): iterable public function testLinearBackoff(): void { - $policy = new RetryPolicy(baseDelaySeconds: 10, backoff: RetryPolicy::LINEAR); + $policy = new RetryPolicy(backoff: RetryPolicy::LINEAR, baseDelaySeconds: 10); self::assertSame(10, $policy->delayFor(1)); self::assertSame(20, $policy->delayFor(2)); @@ -51,7 +51,7 @@ public function testLinearBackoff(): void public function testDelayForClampsAttemptToAtLeastOne(): void { - $policy = new RetryPolicy(baseDelaySeconds: 30, backoff: RetryPolicy::EXPONENTIAL); + $policy = new RetryPolicy(backoff: RetryPolicy::EXPONENTIAL, baseDelaySeconds: 30); self::assertSame(30, $policy->delayFor(0)); } diff --git a/tests/Webhooks/Dispatcher/WebhookHandlerTest.php b/tests/Webhooks/Dispatcher/WebhookHandlerTest.php index 87bae0d..c578121 100644 --- a/tests/Webhooks/Dispatcher/WebhookHandlerTest.php +++ b/tests/Webhooks/Dispatcher/WebhookHandlerTest.php @@ -25,12 +25,14 @@ final class WebhookHandlerTest extends TestCase { private const string SECRET = 'whsec_test'; + private const string PAYLOAD = '{"id":"order_1"}'; public function testSuccessfulPostMarksDelivered(): void { $store = new InMemoryDeliveryStore(); $store->record($this->pendingDelivery()); + $client = FakeHttpClient::returning(200); ($this->handler($client, $store))(($this->message())); @@ -46,6 +48,7 @@ public function testTransient5xxBelowThresholdThrowsRecoverableAndMarksFailed(): { $store = new InMemoryDeliveryStore(); $store->record($this->pendingDelivery()); + $handler = $this->handler(FakeHttpClient::returning(503), $store, new RetryPolicy(maxAttempts: 3)); try { @@ -66,6 +69,7 @@ public function testTransientFailureAtThresholdDeadLetters(): void $store = new InMemoryDeliveryStore(); // Already attempted twice; with maxAttempts=3 the next failure dead-letters. $store->record($this->pendingDelivery()->withAttempts(2)); + $handler = $this->handler(FakeHttpClient::returning(500), $store, new RetryPolicy(maxAttempts: 3)); try { @@ -84,6 +88,7 @@ public function testNetworkFailureIsTreatedAsTransient(): void { $store = new InMemoryDeliveryStore(); $store->record($this->pendingDelivery()); + $handler = $this->handler(FakeHttpClient::networkError(), $store, new RetryPolicy(maxAttempts: 3)); $this->expectException(RecoverableMessageHandlingException::class); @@ -99,6 +104,7 @@ public function testClientErrorDeadLettersImmediately(): void { $store = new InMemoryDeliveryStore(); $store->record($this->pendingDelivery()); + $handler = $this->handler(FakeHttpClient::returning(400), $store); try { @@ -124,6 +130,7 @@ public function testRequestIsSignedWithExpectedHeaders(): void { $store = new InMemoryDeliveryStore(); $store->record($this->pendingDelivery()); + $client = FakeHttpClient::returning(200); ($this->handler($client, $store))($this->message()); diff --git a/tests/Webhooks/Middleware/ActionAwareWebhookVerifyMiddlewareTest.php b/tests/Webhooks/Middleware/ActionAwareWebhookVerifyMiddlewareTest.php index b297ef2..395e952 100644 --- a/tests/Webhooks/Middleware/ActionAwareWebhookVerifyMiddlewareTest.php +++ b/tests/Webhooks/Middleware/ActionAwareWebhookVerifyMiddlewareTest.php @@ -13,7 +13,6 @@ use Laminas\Diactoros\ResponseFactory; use Laminas\Diactoros\ServerRequest; use Laminas\Diactoros\StreamFactory; -use Override; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ServerRequestInterface; @@ -22,6 +21,7 @@ final class ActionAwareWebhookVerifyMiddlewareTest extends TestCase { private const string SECRET = 'whsec_test'; + private const string BODY = '{"id":"evt_1"}'; public function testPassesThroughWhenNoActionAttribute(): void @@ -134,6 +134,7 @@ private function request(?object $action = null, array $headers = []): ServerReq if ($action !== null) { $request = $request->withAttribute('altair:http:action', $action); } + foreach ($headers as $name => $value) { $request = $request->withHeader($name, $value); } diff --git a/tests/Webhooks/Middleware/WebhookVerifyMiddlewareTest.php b/tests/Webhooks/Middleware/WebhookVerifyMiddlewareTest.php index 43ab6eb..a843944 100644 --- a/tests/Webhooks/Middleware/WebhookVerifyMiddlewareTest.php +++ b/tests/Webhooks/Middleware/WebhookVerifyMiddlewareTest.php @@ -25,7 +25,9 @@ final class WebhookVerifyMiddlewareTest extends TestCase { private const string SECRET = 'whsec_test'; + private const string SECRET_NAME = 'stripe'; + private const string BODY = '{"id":"evt_1","type":"order.created"}'; public function testRejectsWhenSignatureHeaderAbsent(): void diff --git a/tests/Webhooks/Signing/EnvSecretResolverTest.php b/tests/Webhooks/Signing/EnvSecretResolverTest.php index ad381b5..b45e8c1 100644 --- a/tests/Webhooks/Signing/EnvSecretResolverTest.php +++ b/tests/Webhooks/Signing/EnvSecretResolverTest.php @@ -20,6 +20,7 @@ protected function tearDown(): void foreach ($this->touchedKeys as $key) { putenv($key); } + $this->touchedKeys = []; } diff --git a/tests/Webhooks/Signing/HmacSha256SignerTest.php b/tests/Webhooks/Signing/HmacSha256SignerTest.php index 7b58697..3622a21 100644 --- a/tests/Webhooks/Signing/HmacSha256SignerTest.php +++ b/tests/Webhooks/Signing/HmacSha256SignerTest.php @@ -4,15 +4,17 @@ namespace Altair\Tests\Webhooks\Signing; +use Altair\Webhooks\Signing\AbstractHmacSigner; use Altair\Webhooks\Signing\HmacSha256Signer; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; #[CoversClass(HmacSha256Signer::class)] -#[CoversClass(\Altair\Webhooks\Signing\AbstractHmacSigner::class)] +#[CoversClass(AbstractHmacSigner::class)] final class HmacSha256SignerTest extends TestCase { private const string SECRET = 'whsec_test_secret'; + private const string PAYLOAD = '{"id":"evt_1","type":"order.created"}'; public function testNameIsWireScheme(): void diff --git a/tests/Webhooks/Signing/HmacSha512SignerTest.php b/tests/Webhooks/Signing/HmacSha512SignerTest.php index d23dfbf..3d77c8d 100644 --- a/tests/Webhooks/Signing/HmacSha512SignerTest.php +++ b/tests/Webhooks/Signing/HmacSha512SignerTest.php @@ -4,15 +4,17 @@ namespace Altair\Tests\Webhooks\Signing; +use Altair\Webhooks\Signing\AbstractHmacSigner; use Altair\Webhooks\Signing\HmacSha512Signer; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; #[CoversClass(HmacSha512Signer::class)] -#[CoversClass(\Altair\Webhooks\Signing\AbstractHmacSigner::class)] +#[CoversClass(AbstractHmacSigner::class)] final class HmacSha512SignerTest extends TestCase { private const string SECRET = 'whsec_test_secret'; + private const string PAYLOAD = '{"id":"evt_1"}'; public function testNameIsWireScheme(): void From e5a741e4e3d25f97b67044d13bd83b1271856b2f Mon Sep 17 00:00:00 2001 From: Antonio Ramirez Date: Sun, 31 May 2026 08:31:28 +0200 Subject: [PATCH 6/6] fix(webhooks): resolve never-type unreachable return + regenerate .agent - WebhookHandler: onTransientFailure/deadLetter always throw, so they're typed never; drop the now-unreachable return statements PHPStan flagged (deadCode.unreachable at WebhookHandler.php:125). - Regenerate .agent/ manifests so the determinism gate sees the new univeros/webhooks package (MANIFEST.md, packages/webhooks.md, packages/scaffold.md). --- .agent/MANIFEST.md | 1 + .agent/packages/scaffold.md | 5 ++ .agent/packages/webhooks.md | 73 +++++++++++++++++++ .../Webhooks/Dispatcher/WebhookHandler.php | 8 +- 4 files changed, 80 insertions(+), 7 deletions(-) create mode 100644 .agent/packages/webhooks.md diff --git a/.agent/MANIFEST.md b/.agent/MANIFEST.md index 8a1e238..1fbc9f8 100644 --- a/.agent/MANIFEST.md +++ b/.agent/MANIFEST.md @@ -41,3 +41,4 @@ Machine-readable descriptions of every framework sub-package, generated by `univ | [univeros/test-reporter](packages/test-reporter.md) | `Altair\TestReporter` | AI-native PHPUnit reporter: structured JSON output mapped to the production source under test. | | [univeros/tinker](packages/tinker.md) | `Altair\Tinker` | bin/altair tinker — an interactive PsySH REPL with the DI container in scope and a doctor-style preamble of what's wired. A local debugging tool for developers, not an agent surface. | | [univeros/validation](packages/validation.md) | `Altair\Validation` | The Altair Validation package. | +| [univeros/webhooks](packages/webhooks.md) | `Altair\Webhooks` | First-class webhook framework for Univeros: signing primitives, inbound verify middleware, and an outbound dispatcher with retry / dead-letter / replay. | diff --git a/.agent/packages/scaffold.md b/.agent/packages/scaffold.md index 8647180..96cd33a 100644 --- a/.agent/packages/scaffold.md +++ b/.agent/packages/scaffold.md @@ -88,6 +88,8 @@ - `TypeScriptEmitter` _(final)_ — implements `EmitterInterface` - `UnmappableSchemaException` _(final)_ — implements `Stringable`, `Throwable` - `Validator` +- `WebhookDispatcherBindingEmitter` +- `WebhookSpec` _(final)_ - `WriteOutcome` _(final)_ - `WriteStatus` _(final)_ — implements `BackedEnum`, `UnitEnum` @@ -108,6 +110,7 @@ - `tests/Scaffold/Determinism/SdkEmitterDeterminismTest.php` - `tests/Scaffold/Emitter/ActionEmitterIdempotencyTest.php` - `tests/Scaffold/Emitter/ActionEmitterTest.php` +- `tests/Scaffold/Emitter/ActionEmitterWebhookTest.php` - `tests/Scaffold/Emitter/DomainStubEmitterTest.php` - `tests/Scaffold/Emitter/EntityEmitterTest.php` - `tests/Scaffold/Emitter/HandlerEmitterTest.php` @@ -122,6 +125,7 @@ - `tests/Scaffold/Emitter/ResponderEmitterTest.php` - `tests/Scaffold/Emitter/RouteEmitterTest.php` - `tests/Scaffold/Emitter/TestEmitterTest.php` +- `tests/Scaffold/Emitter/WebhookDispatcherBindingEmitterTest.php` - `tests/Scaffold/Journal/Cli/CommandsTest.php` - `tests/Scaffold/Journal/Differ/FileDifferTest.php` - `tests/Scaffold/Journal/JournalEntryTest.php` @@ -146,6 +150,7 @@ - `tests/Scaffold/Spec/PersistenceParserTest.php` - `tests/Scaffold/Spec/PersistenceValidatorTest.php` - `tests/Scaffold/Spec/ValidatorTest.php` +- `tests/Scaffold/Spec/WebhookParserTest.php` ## Related packages diff --git a/.agent/packages/webhooks.md b/.agent/packages/webhooks.md new file mode 100644 index 0000000..45baa0f --- /dev/null +++ b/.agent/packages/webhooks.md @@ -0,0 +1,73 @@ +# univeros/webhooks · Altair\Webhooks + +**Purpose:** First-class webhook framework for Univeros: signing primitives, inbound verify middleware, and an outbound dispatcher with retry / dead-letter / replay. + +## Public contracts + +| Interface | Method | Returns | Notes | +|---|---|---|---| +| `DeliveryStoreInterface` | `findById(string)` | `Delivery\|null` | | +| | `findFailed(int)` | `array` | | +| | `record(Delivery)` | `void` | | +| | `update(Delivery)` | `void` | | +| `InboundDeduplicatorInterface` | `claim(string, int)` | `bool` | | +| | `release(string)` | `void` | | +| `SecretResolverInterface` | `resolve(string)` | `string` | | +| `SignerInterface` | `name()` | `string` | | +| | `sign(string, string)` | `string` | | +| | `verify(string, string, string)` | `bool` | | + +## Concrete classes + +- `AbstractHmacSigner` _(abstract)_ — implements `SignerInterface` +- `ActionAwareWebhookVerifyMiddleware` _(final)_ — implements `MiddlewareInterface` +- `Delivery` _(final)_ +- `DeliveryStatus` _(final)_ — implements `BackedEnum`, `UnitEnum` +- `DurationParser` _(final)_ +- `Ed25519Signer` _(final)_ — implements `SignerInterface` +- `EnvSecretResolver` _(final)_ — implements `SecretResolverInterface` +- `HmacSha256Signer` _(final)_ — implements `SignerInterface` +- `HmacSha512Signer` _(final)_ — implements `SignerInterface` +- `InMemoryDeduplicator` _(final)_ — implements `InboundDeduplicatorInterface` +- `InMemoryDeliveryStore` _(final)_ — implements `DeliveryStoreInterface` +- `RedisDeduplicator` _(final)_ — implements `InboundDeduplicatorInterface` +- `RedisDeliveryStore` _(final)_ — implements `DeliveryStoreInterface` +- `RetryPolicy` _(final)_ +- `SignerRegistry` _(final)_ +- `WebhookDispatcher` _(final)_ +- `WebhookHandler` _(final)_ +- `WebhookMessage` _(final)_ +- `WebhookReplayCommand` _(final)_ — implements `SignalableCommandInterface` +- `WebhookShowFailedCommand` _(final)_ — implements `SignalableCommandInterface` +- `WebhookVerifyMiddleware` _(final)_ — implements `MiddlewareInterface` + +## Tests as documentation + +- `tests/Webhooks/Cli/WebhookReplayCommandTest.php` +- `tests/Webhooks/Cli/WebhookShowFailedCommandTest.php` +- `tests/Webhooks/Dispatcher/RetryPolicyTest.php` +- `tests/Webhooks/Dispatcher/WebhookDispatcherTest.php` +- `tests/Webhooks/Dispatcher/WebhookHandlerTest.php` +- `tests/Webhooks/Middleware/ActionAwareWebhookVerifyMiddlewareTest.php` +- `tests/Webhooks/Middleware/WebhookVerifyMiddlewareTest.php` +- `tests/Webhooks/Signing/Ed25519SignerTest.php` +- `tests/Webhooks/Signing/EnvSecretResolverTest.php` +- `tests/Webhooks/Signing/HmacSha256SignerTest.php` +- `tests/Webhooks/Signing/HmacSha512SignerTest.php` +- `tests/Webhooks/Signing/SignerRegistryTest.php` +- `tests/Webhooks/Storage/DeliveryTest.php` +- `tests/Webhooks/Storage/InMemoryDeduplicatorTest.php` +- `tests/Webhooks/Storage/InMemoryDeliveryStoreTest.php` +- `tests/Webhooks/Storage/RedisDeduplicatorTest.php` +- `tests/Webhooks/Storage/RedisDeliveryStoreTest.php` + +## Related packages + +- `psr/http-client` +- `psr/http-factory` +- `psr/http-message` +- `psr/http-server-handler` +- `psr/http-server-middleware` +- `univeros/configuration` +- `univeros/container` +- `univeros/messaging` diff --git a/src/Altair/Webhooks/Dispatcher/WebhookHandler.php b/src/Altair/Webhooks/Dispatcher/WebhookHandler.php index 6abed09..18b2c3e 100644 --- a/src/Altair/Webhooks/Dispatcher/WebhookHandler.php +++ b/src/Altair/Webhooks/Dispatcher/WebhookHandler.php @@ -70,8 +70,6 @@ public function __invoke(WebhookMessage $message): void $status = $this->httpClient->sendRequest($request)->getStatusCode(); } catch (ClientExceptionInterface $clientException) { $this->onTransientFailure($delivery, $attempt, $now, 'network: ' . $clientException->getMessage()); - - return; } if ($status >= 200 && $status < 300) { @@ -82,8 +80,6 @@ public function __invoke(WebhookMessage $message): void if ($status >= 500) { $this->onTransientFailure($delivery, $attempt, $now, 'HTTP ' . $status); - - return; } // 4xx — the subscriber rejected the payload; retrying will not help. @@ -117,12 +113,10 @@ private function markDelivered(Delivery $delivery, int $attempt, int $now, strin ); } - private function onTransientFailure(Delivery $delivery, int $attempt, int $now, string $response): void + private function onTransientFailure(Delivery $delivery, int $attempt, int $now, string $response): never { if ($attempt >= $this->retryPolicy->maxAttempts) { $this->deadLetter($delivery, $attempt, $now, $response); - - return; } $this->deliveries->update(