test(notifications): unit tests for worker and adapters

Covers the dedup short-circuit, per-channel dispatch routing, alert
persistence, error tagging, and legacy single-recipient fallback in the
Notifications worker, plus the Console adapter's permission shape and the
Webhook adapter's HMAC-SHA256 signing contract, header layout, response
handling, and unsigned-when-secret-missing behaviour.

Worker dispatch helpers move from private to protected so a test spy can
override them without monkey-patching. The Swoole runtime hook flag
mutation is now guarded by class_exists so the action can run under bare
PHPUnit (no Swoole extension on the test host).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jake Barnby
2026-05-01 15:51:24 +12:00
co-authored by Claude Opus 4.7
parent 90ef2d7487
commit 6bde54675e
4 changed files with 576 additions and 5 deletions
@@ -55,7 +55,9 @@ class Notifications extends Action
public function action(Message $message, Document $project, Registry $register, Database $dbForProject, Log $log): void
{
Runtime::setHookFlags(SWOOLE_HOOK_ALL ^ SWOOLE_HOOK_TCP);
if (\class_exists(Runtime::class)) {
Runtime::setHookFlags(SWOOLE_HOOK_ALL ^ SWOOLE_HOOK_TCP);
}
$payload = $message->getPayload();
if (empty($payload)) {
@@ -118,7 +120,7 @@ class Notifications extends Action
}
}
private function dispatch(string $channel, string $address, array $payload, Registry $register, Database $database, Log $log): void
protected function dispatch(string $channel, string $address, array $payload, Registry $register, Database $database, Log $log): void
{
switch ($channel) {
case NOTIFICATION_CHANNEL_EMAIL:
@@ -135,7 +137,7 @@ class Notifications extends Action
}
}
private function dispatchEmail(string $address, array $payload, Registry $register, Log $log): void
protected function dispatchEmail(string $address, array $payload, Registry $register, Log $log): void
{
$smtp = $payload['smtp'] ?? [];
if (empty($smtp) && empty(System::getEnv('_APP_SMTP_HOST'))) {
@@ -279,7 +281,7 @@ class Notifications extends Action
}
}
private function dispatchConsole(string $address, array $payload, Database $database): void
protected function dispatchConsole(string $address, array $payload, Database $database): void
{
$project = $payload['project'] ?? null;
$projectId = \is_array($project) ? ($project['$id'] ?? null) : null;
@@ -313,7 +315,7 @@ class Notifications extends Action
$adapter->send($consoleMessage);
}
private function dispatchWebhook(string $address, array $payload): void
protected function dispatchWebhook(string $address, array $payload): void
{
$body = [
'subject' => $payload['subject'] ?? '',
@@ -0,0 +1,125 @@
<?php
namespace Tests\Unit\Messaging\Adapter;
use Appwrite\Messaging\Adapter\Console;
use Appwrite\Messaging\Messages\Console as ConsoleMessage;
use PHPUnit\Framework\TestCase;
use Utopia\Cache\Adapter\None as NoCache;
use Utopia\Cache\Cache;
use Utopia\Database\Adapter\Memory;
use Utopia\Database\Database;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Validator\Authorization;
class ConsoleTest extends TestCase
{
private Database $database;
private Authorization $authorization;
protected function setUp(): void
{
$this->authorization = new Authorization();
$this->authorization->addRole(Role::any()->toString());
$this->database = new Database(new Memory(), new Cache(new NoCache()));
$this->database
->setAuthorization($this->authorization)
->setDatabase('alertsTests')
->setNamespace('alerts_' . \uniqid());
$this->database->create();
$this->database->createCollection('alerts', [], [], [Permission::create(Role::any()), Permission::read(Role::any())], false);
$this->database->createAttribute('alerts', 'messageId', Database::VAR_STRING, 255, false);
$this->database->createAttribute('alerts', 'type', Database::VAR_STRING, 64, false, 'info');
$this->database->createAttribute('alerts', 'channel', Database::VAR_STRING, 64, true);
$this->database->createAttribute('alerts', 'userId', Database::VAR_STRING, 255, false);
$this->database->createAttribute('alerts', 'teamId', Database::VAR_STRING, 255, false);
$this->database->createAttribute('alerts', 'projectId', Database::VAR_STRING, 255, false);
$this->database->createAttribute('alerts', 'title', Database::VAR_STRING, 256, true);
$this->database->createAttribute('alerts', 'body', Database::VAR_STRING, 16384, true);
}
protected function tearDown(): void
{
$this->authorization->cleanRoles();
$this->authorization->addRole(Role::any()->toString());
}
public function testWritesAlertWithCorrectSchema(): void
{
$message = new ConsoleMessage(
recipients: [['userId' => 'user-1']],
title: 'Hello',
body: 'World',
type: 'info',
messageId: ID::custom('msg-aaa'),
projectId: 'project-1',
);
$adapter = new Console($this->database);
$result = $adapter->send($message);
$this->assertSame(1, $result['deliveredTo']);
$stored = $this->database->getDocument('alerts', 'msg-aaa');
$this->assertFalse($stored->isEmpty());
$this->assertSame('msg-aaa', $stored->getAttribute('messageId'));
$this->assertSame('console', $stored->getAttribute('channel'));
$this->assertSame('user-1', $stored->getAttribute('userId'));
$this->assertSame('project-1', $stored->getAttribute('projectId'));
$this->assertSame('Hello', $stored->getAttribute('title'));
$this->assertSame('World', $stored->getAttribute('body'));
$this->assertSame('info', $stored->getAttribute('type'));
}
public function testUserPermissionsScopedToRecipient(): void
{
$message = new ConsoleMessage(
recipients: [['userId' => 'user-2']],
title: 'Title',
body: 'Body',
messageId: ID::custom('msg-perms-user'),
);
(new Console($this->database))->send($message);
$stored = $this->database->getDocument('alerts', 'msg-perms-user');
$permissions = $stored->getPermissions();
$this->assertContains(Permission::read(Role::user('user-2')), $permissions);
$this->assertContains(Permission::update(Role::user('user-2')), $permissions);
$this->assertContains(Permission::delete(Role::user('user-2')), $permissions);
}
public function testTeamRecipientGrantsTeamReadAndOwnerWrite(): void
{
$message = new ConsoleMessage(
recipients: [['teamId' => 'team-9']],
title: 'Heads up',
body: '...',
messageId: ID::custom('msg-team'),
);
(new Console($this->database))->send($message);
$stored = $this->database->getDocument('alerts', 'msg-team');
$permissions = $stored->getPermissions();
$this->assertContains(Permission::read(Role::team('team-9')), $permissions);
$this->assertContains(Permission::update(Role::team('team-9', 'owner')), $permissions);
$this->assertContains(Permission::delete(Role::team('team-9', 'owner')), $permissions);
}
public function testRejectsForeignMessageType(): void
{
$adapter = new Console($this->database);
$this->expectException(\Exception::class);
$this->expectExceptionMessage('Invalid message type.');
// ConsoleMessage extends nothing — pass an unrelated Message implementation
$adapter->send(new \Appwrite\Messaging\Messages\Webhook(urls: ['https://example.test'], payload: []));
}
}
@@ -0,0 +1,195 @@
<?php
namespace Tests\Unit\Messaging\Adapter;
use Appwrite\Messaging\Adapter\Webhook;
use Appwrite\Messaging\Messages\Webhook as WebhookMessage;
use PHPUnit\Framework\TestCase;
/**
* Test double that captures the curl request the adapter would issue and
* returns a scripted response, so we exercise the real signing/header logic
* without touching the network.
*/
class CapturingWebhook extends Webhook
{
/**
* @var array<int, array{method: string, url: string, headers: array<int, string>, body: string, timeout: int}>
*/
public array $captured = [];
/** @var array{statusCode: int, response: string|null, error: string|null} */
public array $response = ['statusCode' => 200, 'response' => 'OK', 'error' => null];
protected function dispatch(string $method, string $url, array $headers, string $body, int $timeout): array
{
$this->captured[] = [
'method' => $method,
'url' => $url,
'headers' => $headers,
'body' => $body,
'timeout' => $timeout,
];
return $this->response;
}
}
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\Messaging\Messages\Console(recipients: [['userId' => 'u']], title: 't', body: 'b'));
}
}
@@ -0,0 +1,249 @@
<?php
namespace Tests\Unit\Platform\Workers;
use Appwrite\Platform\Workers\Notifications;
use PHPUnit\Framework\TestCase;
use Utopia\Cache\Adapter\None as NoCache;
use Utopia\Cache\Cache;
use Utopia\Database\Adapter\Memory;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Helpers\Permission;
use Utopia\Database\Helpers\Role;
use Utopia\Database\Validator\Authorization;
use Utopia\Logger\Log;
use Utopia\Queue\Message;
use Utopia\Registry\Registry;
require_once __DIR__ . '/../../../../app/init.php';
/**
* Spy worker that records dispatch invocations instead of touching SMTP, the
* console alerts table, or external HTTP. Lets the worker tests assert
* routing, error handling, and alert persistence in isolation.
*/
class SpyNotifications extends Notifications
{
/** @var array<int, array{channel: string, address: string, payload: array<string, mixed>}> */
public array $dispatched = [];
/** @var array<string, \Throwable> */
public array $throwOn = [];
protected function dispatch(string $channel, string $address, array $payload, Registry $register, Database $database, Log $log): void
{
$this->dispatched[] = [
'channel' => $channel,
'address' => $address,
'payload' => $payload,
];
if (isset($this->throwOn[$channel])) {
throw $this->throwOn[$channel];
}
}
}
class NotificationsTest extends TestCase
{
private Database $database;
private Authorization $authorization;
private Registry $registry;
private Document $project;
private Log $log;
protected function setUp(): void
{
$this->authorization = new Authorization();
$this->authorization->addRole(Role::any()->toString());
$this->database = new Database(new Memory(), new Cache(new NoCache()));
$this->database
->setAuthorization($this->authorization)
->setDatabase('notifTests')
->setNamespace('notif_' . \uniqid());
$this->database->create();
$this->database->createCollection(
'alerts',
[],
[],
[Permission::create(Role::any()), Permission::read(Role::any()), Permission::update(Role::any()), Permission::delete(Role::any())],
false,
);
$this->database->createAttribute('alerts', 'messageId', Database::VAR_STRING, 255, false);
$this->database->createAttribute('alerts', 'type', Database::VAR_STRING, 64, false, 'info');
$this->database->createAttribute('alerts', 'channel', Database::VAR_STRING, 64, true);
$this->database->createAttribute('alerts', 'userId', Database::VAR_STRING, 255, false);
$this->database->createAttribute('alerts', 'teamId', Database::VAR_STRING, 255, false);
$this->database->createAttribute('alerts', 'projectId', Database::VAR_STRING, 255, false);
$this->database->createAttribute('alerts', 'title', Database::VAR_STRING, 256, true);
$this->database->createAttribute('alerts', 'body', Database::VAR_STRING, 16384, true);
$this->registry = new Registry();
$this->project = new Document(['$id' => 'project-x']);
$this->log = new Log();
}
protected function tearDown(): void
{
$this->authorization->cleanRoles();
$this->authorization->addRole(Role::any()->toString());
}
private function buildMessage(array $payload): Message
{
return new Message([
'pid' => 'pid',
'queue' => 'v1-notifications',
'timestamp' => \time(),
'payload' => $payload,
]);
}
public function testDispatchesPerChannelToCorrectAdapter(): void
{
$worker = new SpyNotifications();
$payload = [
'project' => ['$id' => 'project-x'],
'recipients' => [
['address' => 'user@example.test', 'channel' => NOTIFICATION_CHANNEL_EMAIL],
['address' => 'user-1', 'channel' => NOTIFICATION_CHANNEL_CONSOLE],
['address' => 'https://hooks.example.test/in', 'channel' => NOTIFICATION_CHANNEL_WEBHOOK],
],
'subject' => 'Hi',
'body' => 'Body',
'dedupKey' => 'event-1',
];
$worker->action($this->buildMessage($payload), $this->project, $this->registry, $this->database, $this->log);
$this->assertCount(3, $worker->dispatched);
$channels = \array_map(static fn ($d) => $d['channel'], $worker->dispatched);
$this->assertSame([NOTIFICATION_CHANNEL_EMAIL, NOTIFICATION_CHANNEL_CONSOLE, NOTIFICATION_CHANNEL_WEBHOOK], $channels);
}
public function testPersistsOneAlertPerRecipientChannel(): void
{
$worker = new SpyNotifications();
$payload = [
'project' => ['$id' => 'project-x'],
'recipients' => [
['address' => 'user-1', 'channel' => NOTIFICATION_CHANNEL_CONSOLE],
['address' => 'user-2', 'channel' => NOTIFICATION_CHANNEL_CONSOLE],
],
'subject' => 'Heads up',
'body' => 'Read me',
'dedupKey' => 'evt-multi',
'permissions' => [Permission::read(Role::any())],
];
$worker->action($this->buildMessage($payload), $this->project, $this->registry, $this->database, $this->log);
$rows = $this->database->find('alerts');
$this->assertCount(2, $rows);
$userIds = \array_map(static fn (Document $row) => $row->getAttribute('userId'), $rows);
\sort($userIds);
$this->assertSame(['user-1', 'user-2'], $userIds);
foreach ($rows as $row) {
$this->assertSame(\md5('evt-multi'), $row->getAttribute('messageId'));
$this->assertSame('console', $row->getAttribute('channel'));
$this->assertSame('project-x', $row->getAttribute('projectId'));
$this->assertSame('Heads up', $row->getAttribute('title'));
}
}
public function testDedupHitShortCircuitsBeforeDispatch(): void
{
$worker = new SpyNotifications();
$payload = [
'project' => ['$id' => 'project-x'],
'recipients' => [['address' => 'user-1', 'channel' => NOTIFICATION_CHANNEL_CONSOLE]],
'subject' => 'Sub',
'body' => 'B',
'dedupKey' => 'dup-key',
];
// First run delivers and persists.
$worker->action($this->buildMessage($payload), $this->project, $this->registry, $this->database, $this->log);
$this->assertCount(1, $worker->dispatched);
// Manually insert a row with the dedup messageId so alreadyDelivered() returns true.
$messageId = \md5('dup-key');
$this->database->createDocument('alerts', new Document([
'$id' => $messageId,
'$permissions' => [Permission::read(Role::any())],
'messageId' => $messageId,
'channel' => 'console',
'title' => 'x',
'body' => 'y',
]));
$worker->dispatched = [];
$worker->action($this->buildMessage($payload), $this->project, $this->registry, $this->database, $this->log);
$this->assertCount(0, $worker->dispatched, 'second invocation must short-circuit on dedup hit');
}
public function testMissingRecipientsAndAddressThrows(): void
{
$worker = new SpyNotifications();
$this->expectException(\Exception::class);
$this->expectExceptionMessage('No recipients in payload');
$worker->action(
$this->buildMessage(['project' => ['$id' => 'project-x'], 'subject' => '', 'body' => '']),
$this->project,
$this->registry,
$this->database,
$this->log,
);
}
public function testFallbackToLegacyRecipient(): void
{
$worker = new SpyNotifications();
$payload = [
'project' => ['$id' => 'project-x'],
'recipient' => 'legacy@example.test',
'subject' => 'X',
'body' => 'Y',
];
$worker->action($this->buildMessage($payload), $this->project, $this->registry, $this->database, $this->log);
$this->assertCount(1, $worker->dispatched);
$this->assertSame('legacy@example.test', $worker->dispatched[0]['address']);
$this->assertSame(NOTIFICATION_CHANNEL_EMAIL, $worker->dispatched[0]['channel']);
}
public function testDispatchErrorTagsLogAndPropagates(): void
{
$worker = new SpyNotifications();
$worker->throwOn[NOTIFICATION_CHANNEL_WEBHOOK] = new \RuntimeException('boom');
$payload = [
'project' => ['$id' => 'project-x'],
'recipients' => [['address' => 'https://h.example.test', 'channel' => NOTIFICATION_CHANNEL_WEBHOOK]],
'subject' => 's',
'body' => 'b',
'dedupKey' => 'err-1',
];
try {
$worker->action($this->buildMessage($payload), $this->project, $this->registry, $this->database, $this->log);
$this->fail('expected exception to propagate');
} catch (\Throwable $error) {
$this->assertSame('boom', $error->getMessage());
}
$tags = $this->log->getTags();
$this->assertSame(NOTIFICATION_CHANNEL_WEBHOOK, $tags['channel'] ?? null);
$this->assertSame('boom', $tags['error'] ?? null);
$rows = $this->database->find('alerts');
$this->assertCount(0, $rows, 'failed dispatch must not persist alert');
}
}