diff --git a/src/Appwrite/Platform/Workers/Notifications.php b/src/Appwrite/Platform/Workers/Notifications.php index b38f3dde1c..c50e61a323 100644 --- a/src/Appwrite/Platform/Workers/Notifications.php +++ b/src/Appwrite/Platform/Workers/Notifications.php @@ -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'] ?? '', diff --git a/tests/unit/Messaging/Adapter/ConsoleTest.php b/tests/unit/Messaging/Adapter/ConsoleTest.php new file mode 100644 index 0000000000..1f5ed96ff4 --- /dev/null +++ b/tests/unit/Messaging/Adapter/ConsoleTest.php @@ -0,0 +1,125 @@ +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: [])); + } +} diff --git a/tests/unit/Messaging/Adapter/WebhookTest.php b/tests/unit/Messaging/Adapter/WebhookTest.php new file mode 100644 index 0000000000..5ff7db987f --- /dev/null +++ b/tests/unit/Messaging/Adapter/WebhookTest.php @@ -0,0 +1,195 @@ +, 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')); + } +} diff --git a/tests/unit/Platform/Workers/NotificationsTest.php b/tests/unit/Platform/Workers/NotificationsTest.php new file mode 100644 index 0000000000..c7889851a8 --- /dev/null +++ b/tests/unit/Platform/Workers/NotificationsTest.php @@ -0,0 +1,249 @@ +}> */ + public array $dispatched = []; + + /** @var array */ + 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'); + } +}