mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Locks the bug-fix invariants from PR #12195's last review pass and rounds out worker channel coverage: C1 testEmailSendFailureDoesNotPersistAlert — SMTP throw must NOT leave a dedup row behind, retry must deliver and persist exactly once. C2 testNotificationEventResetClearsAllState — reset() drops every state-bearing field including preview (regression: missed in original reset body). C3 testWebhookSendAlertResetsBetweenCalls — Webhooks::sendAlert must reset() the DI-shared Notification event so two paused-webhook alerts in one worker pass do not bleed recipients/subject/body into each other. C4 testConsoleAdapterTreatsDuplicateAsDelivered — Duplicate on createDocument must surface as a successful idempotent send, not a per-recipient error. M7 testTrackingPixelRejectsJwtWithoutPurposeClaim — Track endpoint silently ignores JWTs missing or with the wrong purpose claim (defends against replaying session/reset JWTs to mark alerts read). Worker happy-path tests: testEmailChannelHappyPath, testConsoleChannelHappyPath, testWebhookChannelHappyPath cover the full per-channel dispatch contract end to end, including HMAC signing for webhooks and the tracking pixel injection + post-send persistence for email. Also extracts CapturingWebhook into its own PSR-4 file so reused across tests. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
167 lines
5.7 KiB
PHP
167 lines
5.7 KiB
PHP
<?php
|
|
|
|
namespace Tests\Unit\Utopia\Messaging\Adapter;
|
|
|
|
use Appwrite\Utopia\Messaging\Messages\Webhook as WebhookMessage;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
class WebhookTest extends TestCase
|
|
{
|
|
public function testPostsExpectedBodyShape(): void
|
|
{
|
|
$adapter = new CapturingWebhook();
|
|
$payload = [
|
|
'subject' => 'Hello',
|
|
'body' => 'World',
|
|
'recipient' => 'ops@example.test',
|
|
'metadata' => ['foo' => 'bar'],
|
|
];
|
|
|
|
$message = new WebhookMessage(
|
|
urls: ['https://hooks.example.test/notify'],
|
|
payload: $payload,
|
|
);
|
|
|
|
$result = $adapter->send($message);
|
|
|
|
$this->assertSame(1, $result['deliveredTo']);
|
|
$this->assertCount(1, $adapter->captured);
|
|
|
|
$request = $adapter->captured[0];
|
|
$this->assertSame('POST', $request['method']);
|
|
$this->assertSame('https://hooks.example.test/notify', $request['url']);
|
|
|
|
$sent = \json_decode($request['body'], true);
|
|
$this->assertSame($payload, $sent);
|
|
|
|
$headerLine = \implode("\n", $request['headers']);
|
|
$this->assertStringContainsString('Content-Type: application/json', $headerLine);
|
|
$this->assertStringContainsString('X-Appwrite-Webhook-Timestamp:', $headerLine);
|
|
}
|
|
|
|
public function testSigningSecretProducesHmacSha256Signature(): void
|
|
{
|
|
$adapter = new CapturingWebhook();
|
|
$payload = ['subject' => 'Signed', 'body' => 'B'];
|
|
$secret = 'super-secret';
|
|
|
|
$message = new WebhookMessage(
|
|
urls: ['https://hooks.example.test/signed'],
|
|
payload: $payload,
|
|
signingSecret: $secret,
|
|
);
|
|
|
|
$adapter->send($message);
|
|
|
|
$headers = $adapter->captured[0]['headers'];
|
|
$body = $adapter->captured[0]['body'];
|
|
|
|
$timestamp = null;
|
|
$signature = null;
|
|
foreach ($headers as $header) {
|
|
if (\str_starts_with($header, 'X-Appwrite-Webhook-Timestamp: ')) {
|
|
$timestamp = \substr($header, \strlen('X-Appwrite-Webhook-Timestamp: '));
|
|
} elseif (\str_starts_with($header, 'X-Appwrite-Webhook-Signature: ')) {
|
|
$signature = \substr($header, \strlen('X-Appwrite-Webhook-Signature: '));
|
|
}
|
|
}
|
|
|
|
$this->assertNotNull($timestamp, 'timestamp header must be present');
|
|
$this->assertNotNull($signature, 'signature header must be present when secret is set');
|
|
$this->assertStringStartsWith('sha256=', $signature);
|
|
|
|
$expected = 'sha256=' . \hash_hmac('sha256', $timestamp . '.' . $body, $secret);
|
|
$this->assertSame($expected, $signature);
|
|
}
|
|
|
|
public function testNoSecretLeavesPayloadUnsigned(): void
|
|
{
|
|
$adapter = new CapturingWebhook();
|
|
$message = new WebhookMessage(
|
|
urls: ['https://hooks.example.test/unsigned'],
|
|
payload: ['x' => 1],
|
|
signingSecret: null,
|
|
);
|
|
|
|
$adapter->send($message);
|
|
|
|
$headerLine = \implode("\n", $adapter->captured[0]['headers']);
|
|
$this->assertStringNotContainsString('X-Appwrite-Webhook-Signature', $headerLine);
|
|
}
|
|
|
|
public function testEmptySecretIsTreatedAsUnsigned(): void
|
|
{
|
|
$adapter = new CapturingWebhook();
|
|
$message = new WebhookMessage(
|
|
urls: ['https://hooks.example.test/empty-secret'],
|
|
payload: ['x' => 1],
|
|
signingSecret: '',
|
|
);
|
|
|
|
$adapter->send($message);
|
|
|
|
$headerLine = \implode("\n", $adapter->captured[0]['headers']);
|
|
$this->assertStringNotContainsString('X-Appwrite-Webhook-Signature', $headerLine);
|
|
}
|
|
|
|
public function testTwoXxIsSuccess(): void
|
|
{
|
|
$adapter = new CapturingWebhook();
|
|
$adapter->response = ['statusCode' => 204, 'response' => '', 'error' => null];
|
|
$message = new WebhookMessage(urls: ['https://hooks.example.test/ok'], payload: []);
|
|
|
|
$result = $adapter->send($message);
|
|
|
|
$this->assertSame(1, $result['deliveredTo']);
|
|
}
|
|
|
|
public function testNonTwoXxSurfacesError(): void
|
|
{
|
|
$adapter = new CapturingWebhook();
|
|
$adapter->response = ['statusCode' => 503, 'response' => 'Server', 'error' => null];
|
|
$message = new WebhookMessage(urls: ['https://hooks.example.test/fail'], payload: []);
|
|
|
|
$result = $adapter->send($message);
|
|
|
|
$this->assertSame(0, $result['deliveredTo']);
|
|
$error = $result['results'][0]['error'] ?? null;
|
|
$this->assertSame('HTTP 503', $error);
|
|
}
|
|
|
|
public function testCurlErrorSurfacesAsResultError(): void
|
|
{
|
|
$adapter = new CapturingWebhook();
|
|
$adapter->response = ['statusCode' => 0, 'response' => null, 'error' => 'connection refused'];
|
|
$message = new WebhookMessage(urls: ['https://hooks.example.test/down'], payload: []);
|
|
|
|
$result = $adapter->send($message);
|
|
|
|
$this->assertSame(0, $result['deliveredTo']);
|
|
$this->assertSame('connection refused', $result['results'][0]['error']);
|
|
}
|
|
|
|
public function testCustomHeadersForwarded(): void
|
|
{
|
|
$adapter = new CapturingWebhook();
|
|
$message = new WebhookMessage(
|
|
urls: ['https://hooks.example.test/with-headers'],
|
|
payload: [],
|
|
headers: ['X-Custom' => 'value'],
|
|
);
|
|
|
|
$adapter->send($message);
|
|
|
|
$headerLine = \implode("\n", $adapter->captured[0]['headers']);
|
|
$this->assertStringContainsString('X-Custom: value', $headerLine);
|
|
}
|
|
|
|
public function testRejectsForeignMessageType(): void
|
|
{
|
|
$adapter = new CapturingWebhook();
|
|
$this->expectException(\Exception::class);
|
|
$this->expectExceptionMessage('Invalid message type.');
|
|
|
|
$adapter->send(new \Appwrite\Utopia\Messaging\Messages\Console(recipients: [['userId' => 'u']], title: 't', body: 'b'));
|
|
}
|
|
}
|