diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ac16c80264..abd2c50714 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -441,6 +441,7 @@ jobs: Webhooks, VCS, Messaging, + Notifications, Migrations, Project ] diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index d1a5a9b518..01f6acef95 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -474,6 +474,17 @@ $platformCollections = [ 'array' => false, 'filters' => [], ], + [ + '$id' => ID::custom('projectInternalId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => false, + 'default' => null, + 'array' => false, + 'filters' => [], + ], [ '$id' => ID::custom('schedule'), 'type' => Database::VAR_STRING, @@ -534,6 +545,13 @@ $platformCollections = [ 'lengths' => [], 'orders' => [], ], + [ + '$id' => ID::custom('_key_region_resourceType_projectInternalId_resourceId'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['region', 'resourceType', 'projectInternalId', 'resourceId'], + 'lengths' => [], + 'orders' => [], + ], [ '$id' => ID::custom('_key_project_id_region'), 'type' => Database::INDEX_KEY, @@ -541,6 +559,13 @@ $platformCollections = [ 'lengths' => [], 'orders' => [], ], + [ + '$id' => ID::custom('_key_project_internal_id_region'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectInternalId', 'region'], + 'lengths' => [], + 'orders' => [], + ], [ '$id' => ID::custom('_key_region_rt_active'), 'type' => Database::INDEX_KEY, @@ -1109,7 +1134,18 @@ $platformCollections = [ 'format' => '', 'size' => Database::LENGTH_KEY, 'signed' => true, - 'required' => false, + 'required' => true, + 'default' => null, + 'array' => false, + 'filters' => [], + ], + [ + '$id' => ID::custom('projectInternalId'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => Database::LENGTH_KEY, + 'signed' => true, + 'required' => true, 'default' => null, 'array' => false, 'filters' => [], @@ -1184,6 +1220,13 @@ $platformCollections = [ 'lengths' => [Database::LENGTH_KEY], 'orders' => [Database::ORDER_ASC], ], + [ + '$id' => ID::custom('_key_projectInternalId'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['projectInternalId'], + 'lengths' => [Database::LENGTH_KEY], + 'orders' => [Database::ORDER_ASC], + ], [ '$id' => ID::custom('_key_userId_type'), 'type' => Database::INDEX_KEY, @@ -1212,6 +1255,13 @@ $platformCollections = [ 'lengths' => [Database::LENGTH_KEY, Database::LENGTH_KEY], 'orders' => [Database::ORDER_ASC, Database::ORDER_ASC], ], + [ + '$id' => ID::custom('_key_userId_projectInternalId'), + 'type' => Database::INDEX_KEY, + 'attributes' => ['userId', 'projectInternalId'], + 'lengths' => [Database::LENGTH_KEY, Database::LENGTH_KEY], + 'orders' => [Database::ORDER_ASC, Database::ORDER_ASC], + ], ], ], diff --git a/app/controllers/api/messaging.php b/app/controllers/api/messaging.php index d1ffa2e478..4d16219864 100644 --- a/app/controllers/api/messaging.php +++ b/app/controllers/api/messaging.php @@ -3292,6 +3292,7 @@ Http::post('/v1/messaging/messages/email') 'resourceInternalId' => $message->getSequence(), 'resourceUpdatedAt' => DateTime::now(), 'projectId' => $project->getId(), + 'projectInternalId' => $project->getSequence(), 'schedule' => $scheduledAt, 'active' => true, ])); @@ -3438,6 +3439,7 @@ Http::post('/v1/messaging/messages/sms') 'resourceInternalId' => $message->getSequence(), 'resourceUpdatedAt' => DateTime::now(), 'projectId' => $project->getId(), + 'projectInternalId' => $project->getSequence(), 'schedule' => $scheduledAt, 'active' => true, ])); @@ -3660,6 +3662,7 @@ Http::post('/v1/messaging/messages/push') 'resourceInternalId' => $message->getSequence(), 'resourceUpdatedAt' => DateTime::now(), 'projectId' => $project->getId(), + 'projectInternalId' => $project->getSequence(), 'schedule' => $scheduledAt, 'active' => true, ])); @@ -4054,6 +4057,7 @@ Http::patch('/v1/messaging/messages/email/:messageId') 'resourceInternalId' => $message->getSequence(), 'resourceUpdatedAt' => DateTime::now(), 'projectId' => $project->getId(), + 'projectInternalId' => $project->getSequence(), 'schedule' => $scheduledAt, 'active' => $status === MessageStatus::SCHEDULED, ])); @@ -4070,6 +4074,7 @@ Http::patch('/v1/messaging/messages/email/:messageId') } $schedule + ->setAttribute('projectInternalId', $project->getSequence()) ->setAttribute('resourceUpdatedAt', DateTime::now()) ->setAttribute('active', $scheduledStatus); @@ -4278,6 +4283,7 @@ Http::patch('/v1/messaging/messages/sms/:messageId') 'resourceInternalId' => $message->getSequence(), 'resourceUpdatedAt' => DateTime::now(), 'projectId' => $project->getId(), + 'projectInternalId' => $project->getSequence(), 'schedule' => $scheduledAt, 'active' => $status === MessageStatus::SCHEDULED, ])); @@ -4294,6 +4300,7 @@ Http::patch('/v1/messaging/messages/sms/:messageId') } $schedule + ->setAttribute('projectInternalId', $project->getSequence()) ->setAttribute('resourceUpdatedAt', DateTime::now()) ->setAttribute('active', $scheduledStatus); @@ -4455,6 +4462,7 @@ Http::patch('/v1/messaging/messages/push/:messageId') 'resourceInternalId' => $message->getSequence(), 'resourceUpdatedAt' => DateTime::now(), 'projectId' => $project->getId(), + 'projectInternalId' => $project->getSequence(), 'schedule' => $scheduledAt, 'active' => $status === MessageStatus::SCHEDULED, ])); @@ -4471,6 +4479,7 @@ Http::patch('/v1/messaging/messages/push/:messageId') } $schedule + ->setAttribute('projectInternalId', $project->getSequence()) ->setAttribute('resourceUpdatedAt', DateTime::now()) ->setAttribute('active', $scheduledStatus); diff --git a/docs/references/health/get-queue-notifications.md b/docs/references/health/get-queue-notifications.md new file mode 100644 index 0000000000..529d1056e9 --- /dev/null +++ b/docs/references/health/get-queue-notifications.md @@ -0,0 +1 @@ +Get the number of jobs in the notifications queue. diff --git a/src/Appwrite/Migration/Version/V19.php b/src/Appwrite/Migration/Version/V19.php index 2526d03009..6f939b4ce4 100644 --- a/src/Appwrite/Migration/Version/V19.php +++ b/src/Appwrite/Migration/Version/V19.php @@ -736,6 +736,7 @@ class V19 extends Migration 'resourceInternalId' => $document->getSequence(), 'resourceUpdatedAt' => DateTime::now(), 'projectId' => $this->project->getId(), + 'projectInternalId' => $this->project->getSequence(), 'schedule' => $document->getAttribute('schedule'), 'active' => !empty($document->getAttribute('schedule')) && !empty($document->getAttribute('deployment')), ])); diff --git a/src/Appwrite/Migration/Version/V24.php b/src/Appwrite/Migration/Version/V24.php index a2d9d7907b..204a7a22e4 100644 --- a/src/Appwrite/Migration/Version/V24.php +++ b/src/Appwrite/Migration/Version/V24.php @@ -144,6 +144,29 @@ class V24 extends Migration $this->dbForProject->purgeCachedCollection($id); break; + case 'schedules': + if ($collectionType === 'console') { + try { + $this->createAttributeFromCollection($this->dbForProject, $id, 'projectInternalId'); + } catch (Throwable $th) { + Console::warning("Failed to create attribute \"projectInternalId\" in collection {$id}: {$th->getMessage()}"); + } + + $indexes = [ + '_key_region_resourceType_projectInternalId_resourceId', + '_key_project_internal_id_region', + ]; + foreach ($indexes as $index) { + try { + $this->createIndexFromCollection($this->dbForProject, $id, $index); + } catch (Throwable $th) { + Console::warning("Failed to create index \"{$index}\" from {$id}: {$th->getMessage()}"); + } + } + } + $this->dbForProject->purgeCachedCollection($id); + break; + case 'rules': if ($collectionType === 'console') { try { @@ -372,6 +395,17 @@ class V24 extends Migration $document->setAttribute('resourceInternalId', $projectInternalId); } break; + case 'schedules': + if (empty($document->getAttribute('projectInternalId'))) { + $projectId = $document->getAttribute('projectId', ''); + if ($projectId !== '') { + $project = $this->dbForProject->getDocument('projects', $projectId); + if (!$project->isEmpty()) { + $document->setAttribute('projectInternalId', $project->getSequence()); + } + } + } + break; default: break; } diff --git a/src/Appwrite/Platform/Modules/Account/Http/Alerts/Read/Update.php b/src/Appwrite/Platform/Modules/Account/Http/Alerts/Read/Update.php index 1aa71013fd..3909c92834 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Alerts/Read/Update.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Alerts/Read/Update.php @@ -46,6 +46,7 @@ class Update extends Action ->param('alertId', '', new UID(), 'Alert ID.') ->inject('response') ->inject('dbForPlatform') + ->inject('project') ->inject('user') ->callback($this->action(...)); } @@ -54,8 +55,13 @@ class Update extends Action string $alertId, Response $response, Database $dbForPlatform, + Document $project, Document $user, ): void { + if ($project->getId() !== 'console') { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + $alert = $dbForPlatform->getDocument('alerts', $alertId); if ($alert->isEmpty()) { diff --git a/src/Appwrite/Platform/Modules/Account/Http/Alerts/Track/Get.php b/src/Appwrite/Platform/Modules/Account/Http/Alerts/Track/Get.php index 73cb857cda..9f84ef1679 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Alerts/Track/Get.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Alerts/Track/Get.php @@ -64,13 +64,13 @@ class Get extends Action ): void { $secret = System::getEnv('_APP_OPENSSL_KEY_V1'); - if ($secret !== '' && $jwt !== '') { + if (!empty($secret) && $jwt !== '') { try { $decoder = new JWT($secret, 'HS256', ALERT_TRACKING_JWT_TTL, 0); $decoded = $decoder->decode($jwt); if ( - isset($decoded['alertId'], $decoded['userId'], $decoded['purpose']) + isset($decoded['alertId'], $decoded['userId'], $decoded['projectId'], $decoded['purpose']) && $decoded['purpose'] === 'alert_track' && $decoded['alertId'] === $alertId ) { @@ -80,6 +80,8 @@ class Get extends Action if ( !$alert->isEmpty() && $alert->getAttribute('userId') === $decoded['userId'] + && $alert->getAttribute('projectId') === $decoded['projectId'] + && (!isset($decoded['projectInternalId']) || $alert->getAttribute('projectInternalId') === $decoded['projectInternalId']) && $alert->getAttribute('read') !== true ) { $dbForPlatform->updateDocument('alerts', $alertId, new Document([ diff --git a/src/Appwrite/Platform/Modules/Account/Http/Alerts/XList.php b/src/Appwrite/Platform/Modules/Account/Http/Alerts/XList.php index c91ec1232d..820cb7061d 100644 --- a/src/Appwrite/Platform/Modules/Account/Http/Alerts/XList.php +++ b/src/Appwrite/Platform/Modules/Account/Http/Alerts/XList.php @@ -50,6 +50,7 @@ class XList extends Action ->param('queries', [], new Alerts(), 'Array of query strings generated using the Query class provided by the SDK. [Learn more about queries](https://appwrite.io/docs/queries). Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' queries are allowed, each ' . APP_LIMIT_ARRAY_ELEMENT_SIZE . ' characters long. You may filter on the following attributes: ' . implode(', ', Alerts::ALLOWED_ATTRIBUTES), true) ->inject('response') ->inject('dbForPlatform') + ->inject('project') ->inject('user') ->callback($this->action(...)); } @@ -61,8 +62,13 @@ class XList extends Action array $queries, Response $response, Database $dbForPlatform, + Document $project, Document $user ): void { + if ($project->getId() !== 'console') { + throw new Exception(Exception::USER_UNAUTHORIZED); + } + try { $queries = Query::parseQueries($queries); } catch (QueryException $e) { @@ -89,7 +95,7 @@ class XList extends Action $alertId = $cursor->getValue(); $cursorDocument = $dbForPlatform->getDocument('alerts', $alertId); - if ($cursorDocument->isEmpty()) { + if ($cursorDocument->isEmpty() || $cursorDocument->getAttribute('userId') !== $user->getId()) { throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Alert '{$alertId}' for the 'cursor' value not found."); } diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php index 35264730f8..24684a3a51 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Executions/Create.php @@ -325,6 +325,7 @@ class Create extends Base 'resourceInternalId' => $execution->getSequence(), 'resourceUpdatedAt' => DateTime::now(), 'projectId' => $project->getId(), + 'projectInternalId' => $project->getSequence(), 'schedule' => $scheduledAt, 'data' => $data, 'active' => true, diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php index 148f0945ac..1799e51a12 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Create.php @@ -263,6 +263,7 @@ class Create extends Base 'resourceInternalId' => $function->getSequence(), 'resourceUpdatedAt' => DateTime::now(), 'projectId' => $project->getId(), + 'projectInternalId' => $project->getSequence(), 'schedule' => $function->getAttribute('schedule'), 'active' => false, ])) diff --git a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php index e8713a179d..ee9e930e16 100644 --- a/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php +++ b/src/Appwrite/Platform/Modules/Functions/Http/Functions/Update.php @@ -299,6 +299,7 @@ class Update extends Base 'resourceInternalId' => $function->getSequence(), 'resourceUpdatedAt' => DateTime::now(), 'projectId' => $project->getId(), + 'projectInternalId' => $project->getSequence(), 'schedule' => $function->getAttribute('schedule'), 'active' => false, ])) @@ -311,6 +312,7 @@ class Update extends Base } $schedule + ->setAttribute('projectInternalId', $project->getSequence()) ->setAttribute('resourceUpdatedAt', DateTime::now()) ->setAttribute('schedule', $function->getAttribute('schedule')) ->setAttribute('active', !empty($function->getAttribute('schedule')) && !empty($function->getAttribute('deploymentId'))); diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php index d3b760d01b..f1c6867379 100644 --- a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Failed/Get.php @@ -3,6 +3,7 @@ namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Failed; use Appwrite\Event\Event; +use Appwrite\Event\Notification; use Appwrite\Event\Publisher\Audit; use Appwrite\Event\Publisher\Build as BuildPublisher; use Appwrite\Event\Publisher\Certificate; @@ -71,6 +72,7 @@ class Get extends Base System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME), System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME), System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME), + System::getEnv('_APP_NOTIFICATIONS_QUEUE_NAME', Event::NOTIFICATIONS_QUEUE_NAME), ]), 'The name of the queue') ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) ->inject('response') @@ -87,6 +89,7 @@ class Get extends Base ->inject('publisherForMessaging') ->inject('publisherForMigrations') ->inject('publisherForScreenshots') + ->inject('queueForNotifications') ->callback($this->action(...)); } @@ -107,6 +110,7 @@ class Get extends Base MessagingPublisher $publisherForMessaging, MigrationPublisher $publisherForMigrations, Screenshot $publisherForScreenshots, + Notification $queueForNotifications, ): void { $threshold = (int) $threshold; @@ -124,6 +128,7 @@ class Get extends Base System::getEnv('_APP_SCREENSHOTS_QUEUE_NAME', Event::SCREENSHOTS_QUEUE_NAME) => $publisherForScreenshots, System::getEnv('_APP_MESSAGING_QUEUE_NAME', Event::MESSAGING_QUEUE_NAME) => $publisherForMessaging, System::getEnv('_APP_MIGRATIONS_QUEUE_NAME', Event::MIGRATIONS_QUEUE_NAME) => $publisherForMigrations, + System::getEnv('_APP_NOTIFICATIONS_QUEUE_NAME', Event::NOTIFICATIONS_QUEUE_NAME) => $queueForNotifications, default => throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Unknown queue name: ' . $name), }; $failed = $queue->getSize(failed: true); diff --git a/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Notifications/Get.php b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Notifications/Get.php new file mode 100644 index 0000000000..9377f8852a --- /dev/null +++ b/src/Appwrite/Platform/Modules/Health/Http/Health/Queue/Notifications/Get.php @@ -0,0 +1,60 @@ +setHttpMethod(Base::HTTP_REQUEST_METHOD_GET) + ->setHttpPath('/v1/health/queue/notifications') + ->desc('Get notifications queue') + ->groups(['api', 'health']) + ->label('scope', 'health.read') + ->label('sdk', new Method( + namespace: 'health', + group: 'queue', + name: 'getQueueNotifications', + description: '/docs/references/health/get-queue-notifications.md', + auth: [AuthType::ADMIN, AuthType::KEY], + responses: [ + new SDKResponse( + code: Response::STATUS_CODE_OK, + model: Response::MODEL_HEALTH_QUEUE, + ) + ], + contentType: ContentType::JSON + )) + ->param('threshold', 5000, new Integer(true), 'Queue size threshold. When hit (equal or higher), endpoint returns server error. Default value is 5000.', true) + ->inject('queueForNotifications') + ->inject('response') + ->callback($this->action(...)); + } + + public function action(int|string $threshold, Notification $queueForNotifications, Response $response): void + { + $threshold = (int) $threshold; + + $size = $queueForNotifications->getSize(); + + $this->assertQueueThreshold($size, $threshold); + + $response->dynamic(new Document(['size' => $size]), Response::MODEL_HEALTH_QUEUE); + } +} diff --git a/src/Appwrite/Platform/Modules/Health/Services/Http.php b/src/Appwrite/Platform/Modules/Health/Services/Http.php index 54c6f9ad6d..a6acbcb4d4 100644 --- a/src/Appwrite/Platform/Modules/Health/Services/Http.php +++ b/src/Appwrite/Platform/Modules/Health/Services/Http.php @@ -19,6 +19,7 @@ use Appwrite\Platform\Modules\Health\Http\Health\Queue\Logs\Get as GetQueueLogs; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Mails\Get as GetQueueMails; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Messaging\Get as GetQueueMessaging; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Migrations\Get as GetQueueMigrations; +use Appwrite\Platform\Modules\Health\Http\Health\Queue\Notifications\Get as GetQueueNotifications; use Appwrite\Platform\Modules\Health\Http\Health\Queue\StatsResources\Get as GetQueueStatsResources; use Appwrite\Platform\Modules\Health\Http\Health\Queue\StatsUsage\Get as GetQueueUsage; use Appwrite\Platform\Modules\Health\Http\Health\Queue\Webhooks\Get as GetQueueWebhooks; @@ -56,6 +57,7 @@ class Http extends Service $this->addAction(GetQueueMails::getName(), new GetQueueMails()); $this->addAction(GetQueueMessaging::getName(), new GetQueueMessaging()); $this->addAction(GetQueueMigrations::getName(), new GetQueueMigrations()); + $this->addAction(GetQueueNotifications::getName(), new GetQueueNotifications()); $this->addAction(GetQueueFunctions::getName(), new GetQueueFunctions()); $this->addAction(GetQueueStatsResources::getName(), new GetQueueStatsResources()); $this->addAction(GetQueueUsage::getName(), new GetQueueUsage()); diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Create.php b/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Create.php index e00809300d..2380af8997 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Create.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Create.php @@ -131,6 +131,7 @@ class Create extends Action 'resourceInternalId' => $resource->getSequence(), 'resourceUpdatedAt' => DateTime::now(), 'projectId' => $project->getId(), + 'projectInternalId' => $project->getSequence(), 'schedule' => $schedule, 'active' => $active, ]; diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Get.php b/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Get.php index d14d4c361d..f89eecd1a0 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Get.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Get.php @@ -67,7 +67,10 @@ class Get extends Action throw new Exception(Exception::SCHEDULE_NOT_FOUND); } - if ($schedule->getAttribute('projectId') !== $project->getId()) { + if ( + $schedule->getAttribute('projectId') !== $project->getId() + || $schedule->getAttribute('projectInternalId') !== $project->getSequence() + ) { throw new Exception(Exception::SCHEDULE_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Schedules/XList.php b/src/Appwrite/Platform/Modules/Projects/Http/Schedules/XList.php index 66879f798f..d0e6ad3036 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Schedules/XList.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Schedules/XList.php @@ -77,6 +77,7 @@ class XList extends Action } $queries[] = Query::equal('projectId', [$project->getId()]); + $queries[] = Query::equal('projectInternalId', [$project->getSequence()]); $cursor = Query::getCursorQueries($queries, false); $cursor = \reset($cursor); @@ -90,7 +91,11 @@ class XList extends Action $scheduleId = $cursor->getValue(); $cursorDocument = $dbForPlatform->getDocument('schedules', $scheduleId); - if ($cursorDocument->isEmpty()) { + if ( + $cursorDocument->isEmpty() + || $cursorDocument->getAttribute('projectId') !== $project->getId() + || $cursorDocument->getAttribute('projectInternalId') !== $project->getSequence() + ) { throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Schedule '{$scheduleId}' for the 'cursor' value not found."); } diff --git a/src/Appwrite/Platform/Workers/Deletes.php b/src/Appwrite/Platform/Workers/Deletes.php index a58fc48098..a5b1e6578f 100644 --- a/src/Appwrite/Platform/Workers/Deletes.php +++ b/src/Appwrite/Platform/Workers/Deletes.php @@ -726,6 +726,7 @@ class Deletes extends Action try { $this->deleteByGroup('schedules', [ Query::equal('projectId', [$projectId]), + Query::equal('projectInternalId', [$projectInternalId]), Query::orderAsc() ], $dbForPlatform); } catch (Throwable $th) { diff --git a/src/Appwrite/Platform/Workers/Notifications.php b/src/Appwrite/Platform/Workers/Notifications.php index fc3225d256..be6e9a5061 100644 --- a/src/Appwrite/Platform/Workers/Notifications.php +++ b/src/Appwrite/Platform/Workers/Notifications.php @@ -76,6 +76,7 @@ class Notifications extends Action throw new Exception('No recipients in payload'); } + $failure = null; foreach ($recipients as $recipient) { $recipient = $this->normalizeRecipient($recipient); $channel = $recipient['channel']; @@ -89,14 +90,18 @@ class Notifications extends Action try { $alertId = $this->dispatch($recipient, $messageId, $payload, $project, $register, $dbForPlatform, $log); if ($messageId !== '' && $channel === NOTIFICATION_TYPE_WEBHOOK && $alertId === null) { - $this->persistAlert($dbForPlatform, $messageId, $recipient, $payload); + $this->persistAlert($dbForPlatform, $messageId, $recipient, $payload, $project); } } catch (Throwable $error) { $log->addTag('channel', $channel); $log->addTag('error', $error->getMessage()); - throw $error; + $failure ??= $error; } } + + if ($failure !== null) { + throw $failure; + } } /** @@ -164,7 +169,7 @@ class Notifications extends Action return match ($channel) { NOTIFICATION_TYPE_EMAIL => $this->dispatchEmail($recipient, $messageId, $payload, $project, $register, $dbForPlatform, $log), - NOTIFICATION_TYPE_CONSOLE => $this->dispatchConsole($recipient, $messageId, $payload, $dbForPlatform), + NOTIFICATION_TYPE_CONSOLE => $this->dispatchConsole($recipient, $messageId, $payload, $project, $dbForPlatform), NOTIFICATION_TYPE_WEBHOOK => $this->dispatchWebhook($recipient, $payload, $log), default => throw new Exception('Unsupported notification channel: ' . $channel), }; @@ -258,7 +263,7 @@ class Notifications extends Action $userId = $recipient['userId'] ?? ''; $opensslKey = System::getEnv('_APP_OPENSSL_KEY_V1'); if ($deterministicAlertId !== null && $userId !== '' && !empty($opensslKey)) { - $body = $this->injectTrackingPixel($body, $deterministicAlertId, $userId, $opensslKey); + $body = $this->injectTrackingPixel($body, $deterministicAlertId, $userId, $project, $opensslKey); } /** @var EmailAdapter $adapter */ @@ -322,7 +327,7 @@ class Notifications extends Action } if ($messageId !== '') { - return $this->persistAlert($dbForPlatform, $messageId, $recipient, $payload); + return $this->persistAlert($dbForPlatform, $messageId, $recipient, $payload, $project); } return null; @@ -331,13 +336,10 @@ class Notifications extends Action /** * @param array{address: string, channel: string, signatureKey?: string, userId?: string, teamId?: string} $recipient */ - protected function dispatchConsole(array $recipient, string $messageId, array $payload, Database $dbForPlatform): ?string + protected function dispatchConsole(array $recipient, string $messageId, array $payload, Document $project, Database $dbForPlatform): ?string { $this->validateConsoleRecipient($recipient); - $project = $payload['project'] ?? null; - $projectId = \is_array($project) ? ($project['$id'] ?? null) : null; - $params = $payload['templateParams'] ?? ($payload['variables'] ?? []); $title = self::renderText($payload['subject'] ?? '', $params); $body = self::renderText($payload['preview'] ?? '', $params); @@ -368,7 +370,8 @@ class Notifications extends Action body: $body, type: $payload['type'] ?? 'info', messageId: $messageId !== '' ? $messageId : null, - projectId: $projectId, + projectId: $project->getId(), + projectInternalId: $project->getSequence(), ); $adapter = new ConsoleAdapter($dbForPlatform); @@ -431,13 +434,10 @@ class Notifications extends Action * * @param array{address: string, channel: string, signatureKey?: string, userId?: string, teamId?: string} $recipient */ - protected function persistAlert(Database $dbForPlatform, string $messageId, array $recipient, array $payload): string + protected function persistAlert(Database $dbForPlatform, string $messageId, array $recipient, array $payload, Document $project): string { $recipient = $this->normalizeRecipient($recipient); - $project = $payload['project'] ?? null; - $projectId = \is_array($project) ? ($project['$id'] ?? null) : null; - $channel = $recipient['channel']; $userId = $recipient['userId'] ?? ''; $teamId = $recipient['teamId'] ?? ''; @@ -445,7 +445,7 @@ class Notifications extends Action $alertId = self::buildAlertId($messageId, $recipient); $recipientHash = self::buildRecipientHash($recipient); - $permissions = $this->buildAlertPermissions($userId, $teamId); + $permissions = $this->buildAlertPermissions($userId, $teamId, $project->getId()); if (empty($permissions)) { $permissions = $payload['permissions'] ?? []; } @@ -465,7 +465,8 @@ class Notifications extends Action 'channel' => $channel, 'userId' => $userId, 'teamId' => $teamId, - 'projectId' => $projectId, + 'projectId' => $project->getId(), + 'projectInternalId' => $project->getSequence(), 'title' => self::renderText($payload['subject'] ?? '', $params), 'body' => $body, 'read' => false, @@ -509,7 +510,7 @@ class Notifications extends Action /** * @return array */ - private function buildAlertPermissions(string $userId, string $teamId): array + private function buildAlertPermissions(string $userId, string $teamId, string $projectId): array { $permissions = []; if ($userId !== '') { @@ -521,6 +522,11 @@ class Notifications extends Action $permissions[] = Permission::read(Role::team($teamId)); $permissions[] = Permission::update(Role::team($teamId, 'owner')); $permissions[] = Permission::delete(Role::team($teamId, 'owner')); + if ($projectId !== '') { + $permissions[] = Permission::read(Role::team($teamId, 'project-' . $projectId . '-owner')); + $permissions[] = Permission::update(Role::team($teamId, 'project-' . $projectId . '-owner')); + $permissions[] = Permission::delete(Role::team($teamId, 'project-' . $projectId . '-owner')); + } } return $permissions; } @@ -586,12 +592,14 @@ class Notifications extends Action * `/v1/account/alerts/:alertId/track` endpoint verifies before * marking the alert as read. */ - private function injectTrackingPixel(string $body, string $alertId, string $userId, string $opensslKey): string + private function injectTrackingPixel(string $body, string $alertId, string $userId, Document $project, string $opensslKey): string { $jwt = (new JWT($opensslKey, 'HS256', ALERT_TRACKING_JWT_TTL, 0)) ->encode([ 'alertId' => $alertId, 'userId' => $userId, + 'projectId' => $project->getId(), + 'projectInternalId' => $project->getSequence(), 'purpose' => 'alert_track', ]); diff --git a/src/Appwrite/Platform/Workers/Webhooks.php b/src/Appwrite/Platform/Workers/Webhooks.php index b46de5a49a..a5e6cfc672 100644 --- a/src/Appwrite/Platform/Workers/Webhooks.php +++ b/src/Appwrite/Platform/Workers/Webhooks.php @@ -221,7 +221,7 @@ class Webhooks extends Action $ownerMemberships = \array_filter( $memberships, - fn (Document $membership) => self::hasOwnerRole($membership) + fn (Document $membership) => self::hasOwnerRole($membership, $project->getId()) ); if (empty($ownerMemberships)) { @@ -288,6 +288,14 @@ class Webhooks extends Action $email = $user->getAttribute('email'); $userId = $user->getId(); + $queueForNotifications->addRecipient( + $userId, + NOTIFICATION_TYPE_CONSOLE, + null, + $userId, + $teamId, + ); + if (!empty($email)) { $queueForNotifications->addRecipient( $email, @@ -297,20 +305,12 @@ class Webhooks extends Action $teamId, ); } - - $queueForNotifications->addRecipient( - $userId, - NOTIFICATION_TYPE_CONSOLE, - null, - $userId, - $teamId, - ); } $queueForNotifications->trigger(); } - private static function hasOwnerRole(Document $membership): bool + private static function hasOwnerRole(Document $membership, string $projectId): bool { $roles = $membership->getAttribute('roles', []); if (\is_string($roles)) { @@ -321,7 +321,12 @@ class Webhooks extends Action } foreach ($roles as $role) { - if (\is_string($role) && \strtolower($role) === 'owner') { + if (!\is_string($role)) { + continue; + } + + $role = \strtolower($role); + if ($role === 'owner' || $role === 'project-' . \strtolower($projectId) . '-owner') { return true; } } diff --git a/src/Appwrite/Utopia/Messaging/Adapter/Console.php b/src/Appwrite/Utopia/Messaging/Adapter/Console.php index 8e93f6557e..f3aa4332ed 100644 --- a/src/Appwrite/Utopia/Messaging/Adapter/Console.php +++ b/src/Appwrite/Utopia/Messaging/Adapter/Console.php @@ -72,7 +72,7 @@ class Console extends Adapter try { $document = new Document([ '$id' => $documentId, - '$permissions' => $this->buildPermissions($userId, $teamId), + '$permissions' => $this->buildPermissions($userId, $teamId, $message->getProjectId() ?? ''), 'messageId' => $messageId, 'recipientHash' => $recipientHash, 'type' => $message->getType(), @@ -80,6 +80,7 @@ class Console extends Adapter 'userId' => $userId, 'teamId' => $teamId, 'projectId' => $message->getProjectId(), + 'projectInternalId' => $message->getProjectInternalId(), 'title' => $message->getTitle(), 'body' => $message->getBody(), ]); @@ -105,7 +106,7 @@ class Console extends Adapter /** * @return array */ - private function buildPermissions(string $userId, string $teamId): array + private function buildPermissions(string $userId, string $teamId, string $projectId): array { $permissions = []; if ($userId !== '') { @@ -117,6 +118,11 @@ class Console extends Adapter $permissions[] = Permission::read(Role::team($teamId)); $permissions[] = Permission::update(Role::team($teamId, 'owner')); $permissions[] = Permission::delete(Role::team($teamId, 'owner')); + if ($projectId !== '') { + $permissions[] = Permission::read(Role::team($teamId, 'project-' . $projectId . '-owner')); + $permissions[] = Permission::update(Role::team($teamId, 'project-' . $projectId . '-owner')); + $permissions[] = Permission::delete(Role::team($teamId, 'project-' . $projectId . '-owner')); + } } return $permissions; } diff --git a/src/Appwrite/Utopia/Messaging/Messages/Console.php b/src/Appwrite/Utopia/Messaging/Messages/Console.php index 23fc791233..70573613dc 100644 --- a/src/Appwrite/Utopia/Messaging/Messages/Console.php +++ b/src/Appwrite/Utopia/Messaging/Messages/Console.php @@ -16,6 +16,7 @@ class Console implements Message protected string $type = 'info', protected ?string $messageId = null, protected ?string $projectId = null, + protected ?string $projectInternalId = null, ) { } @@ -59,4 +60,9 @@ class Console implements Message { return $this->projectId; } + + public function getProjectInternalId(): ?string + { + return $this->projectInternalId; + } } diff --git a/tests/e2e/Services/Notifications/NotificationsBase.php b/tests/e2e/Services/Notifications/NotificationsBase.php index ba4ee6fb26..5891df8e4b 100644 --- a/tests/e2e/Services/Notifications/NotificationsBase.php +++ b/tests/e2e/Services/Notifications/NotificationsBase.php @@ -185,6 +185,54 @@ trait NotificationsBase self::$seededAlertId = $alertId; } + public function testProjectAccountCannotListPlatformAlertsWithCollidingUserId(): void + { + $alertId = $this->seedWebhookFailureAlert(); + $this->assertNotEmpty($alertId); + + $attackerProject = $this->getProject(true); + $projectId = $attackerProject['$id']; + $email = \uniqid('colliding-alert-user-', true) . '@localhost.test'; + $password = 'password'; + + $created = $this->client->call(Client::METHOD_POST, '/users', [ + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + 'x-appwrite-key' => $attackerProject['apiKey'], + ], [ + 'userId' => $this->getRoot()['$id'], + 'email' => $email, + 'password' => $password, + 'name' => 'Colliding Alert User', + ]); + + $this->assertSame(201, $created['headers']['status-code']); + + $session = $this->client->call(Client::METHOD_POST, '/account/sessions/email', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'x-appwrite-project' => $projectId, + ], [ + 'email' => $email, + 'password' => $password, + ]); + + $this->assertSame(201, $session['headers']['status-code']); + $this->assertNotEmpty($session['cookies']['a_session_' . $projectId] ?? ''); + + $response = $this->client->call(Client::METHOD_GET, '/account/alerts', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_' . $projectId . '=' . $session['cookies']['a_session_' . $projectId], + 'x-appwrite-project' => $projectId, + ]); + + $this->assertSame(401, $response['headers']['status-code']); + $this->assertSame('user_unauthorized', $response['body']['type'] ?? ''); + + self::$seededAlertId = $alertId; + } + public function testTrackingPixelTogglesRead(): void { $alertId = self::$seededAlertId ?? $this->seedWebhookFailureAlert(); @@ -200,6 +248,7 @@ trait NotificationsBase $jwt = (new JWT($secret, 'HS256', ALERT_TRACKING_JWT_TTL, 0))->encode([ 'alertId' => $alertId, 'userId' => $userId, + 'projectId' => $this->getProject()['$id'], 'purpose' => 'alert_track', ]); @@ -255,6 +304,7 @@ trait NotificationsBase $jwtNoPurpose = (new JWT($secret, 'HS256', ALERT_TRACKING_JWT_TTL, 0))->encode([ 'alertId' => $alertId, 'userId' => $userId, + 'projectId' => $this->getProject()['$id'], ]); $response = $this->client->call( @@ -282,6 +332,7 @@ trait NotificationsBase $jwtWrongPurpose = (new JWT($secret, 'HS256', ALERT_TRACKING_JWT_TTL, 0))->encode([ 'alertId' => $alertId, 'userId' => $userId, + 'projectId' => $this->getProject()['$id'], 'purpose' => 'something_else', ]); diff --git a/tests/unit/Platform/Workers/NotificationsTest.php b/tests/unit/Platform/Workers/NotificationsTest.php index 5f147f3de5..f20b2100cc 100644 --- a/tests/unit/Platform/Workers/NotificationsTest.php +++ b/tests/unit/Platform/Workers/NotificationsTest.php @@ -2,6 +2,7 @@ namespace Tests\Unit\Platform\Workers; +use Ahc\Jwt\JWT; use Appwrite\Event\Notification; use Appwrite\Platform\Workers\Notifications; use PHPUnit\Framework\TestCase; @@ -13,6 +14,7 @@ use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; +use Utopia\Database\Query; use Utopia\Database\Validator\Authorization; use Utopia\Logger\Log; use Utopia\Messaging\Adapter\Email as EmailAdapter; @@ -64,7 +66,7 @@ class SpyNotifications extends Notifications } if ($channel === NOTIFICATION_TYPE_CONSOLE || $channel === NOTIFICATION_TYPE_EMAIL) { - return $this->persistAlert($dbForPlatform, $messageId, $recipient, $payload); + return $this->persistAlert($dbForPlatform, $messageId, $recipient, $payload, $project); } return null; @@ -84,10 +86,10 @@ class CountingPersistAlertNotifications extends Notifications /** @var array */ public array $persistedIds = []; - protected function persistAlert(Database $dbForPlatform, string $messageId, array $recipient, array $payload): string + protected function persistAlert(Database $dbForPlatform, string $messageId, array $recipient, array $payload, Document $project): string { $this->persistAlertCalls++; - $alertId = parent::persistAlert($dbForPlatform, $messageId, $recipient, $payload); + $alertId = parent::persistAlert($dbForPlatform, $messageId, $recipient, $payload, $project); $this->persistedIds[] = $alertId; return $alertId; } @@ -100,7 +102,7 @@ class CountingPersistAlertNotifications extends Notifications */ class ZeroDeliveryConsoleNotifications extends Notifications { - protected function dispatchConsole(array $recipient, string $messageId, array $payload, Database $dbForPlatform): ?string + protected function dispatchConsole(array $recipient, string $messageId, array $payload, Document $project, Database $dbForPlatform): ?string { // Simulate the Console adapter swallowing a per-recipient // exception and reporting zero deliveries. @@ -182,7 +184,8 @@ class NotificationsTest extends TestCase $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', 'projectId', Database::VAR_STRING, 255, true); + $this->database->createAttribute('alerts', 'projectInternalId', Database::VAR_STRING, 255, true); $this->database->createAttribute('alerts', 'title', Database::VAR_STRING, 256, true); $this->database->createAttribute('alerts', 'body', Database::VAR_STRING, 16384, true); $this->database->createAttribute('alerts', 'read', Database::VAR_BOOLEAN, 0, false, false); @@ -200,7 +203,7 @@ class NotificationsTest extends TestCase ); $this->registry = new Registry(); - $this->project = new Document(['$id' => 'project-x']); + $this->project = new Document(['$id' => 'project-x', '$sequence' => 'project-internal-x']); $this->log = new Log(); } @@ -415,7 +418,7 @@ class NotificationsTest extends TestCase } $rows = $this->database->find('alerts', [ - \Utopia\Database\Query::equal('messageId', [$messageId]), + Query::equal('messageId', [$messageId]), ]); $this->assertCount(1, $rows, 'first attempt should persist only the successful console recipient'); $this->assertSame(NOTIFICATION_TYPE_CONSOLE, $rows[0]->getAttribute('channel')); @@ -427,7 +430,7 @@ class NotificationsTest extends TestCase $this->assertSame(NOTIFICATION_TYPE_WEBHOOK, $retry->dispatched[0]['channel']); $rows = $this->database->find('alerts', [ - \Utopia\Database\Query::equal('messageId', [$messageId]), + Query::equal('messageId', [$messageId]), ]); $this->assertCount(2, $rows, 'retry must complete the missing recipient without duplicating console'); } @@ -452,7 +455,7 @@ class NotificationsTest extends TestCase // must NOT have called persistAlert (otherwise we'd see 2 rows or // a duplicate-key swallow plus a non-zero counter). $consoleRows = $this->database->find('alerts', [ - \Utopia\Database\Query::equal('channel', ['console']), + Query::equal('channel', ['console']), ]); $this->assertCount(1, $consoleRows); $this->assertSame(0, $worker->persistAlertCalls, 'console channel must NOT trigger action-loop persistAlert'); @@ -588,6 +591,14 @@ class NotificationsTest extends TestCase $this->assertStringContainsString('http://api.example.test/v1/account/alerts/', \html_entity_decode($body)); $this->assertStringNotContainsString('console.example.test/v1/account/alerts/', \html_entity_decode($body)); + \preg_match('/track\?jwt=([^"&]+)/', \html_entity_decode($body), $matches); + $this->assertNotEmpty($matches[1] ?? ''); + + $claims = (new JWT('test-key-32bytes-min-aaaaaaaaaaaaaa', 'HS256', ALERT_TRACKING_JWT_TTL, 0)) + ->decode(\urldecode($matches[1])); + $this->assertSame('project-x', $claims['projectId'] ?? null); + $this->assertSame('project-internal-x', $claims['projectInternalId'] ?? null); + // The pixel must sit BEFORE the last . $lastBodyClose = \strripos($body, ''); $pixelPosition = \strripos($body, 'invoke($worker, $this->database, $messageId, $recipient, $payload); + $secondAlertId = $reflection->invoke($worker, $this->database, $messageId, $recipient, $payload, $this->project); $this->assertSame($firstAlertId, $secondAlertId, 'duplicate persist must return the existing alertId'); @@ -666,6 +677,7 @@ class NotificationsTest extends TestCase 'userId' => 'user-7', 'teamId' => 'team-7', 'projectId' => 'project-x', + 'projectInternalId' => 'project-internal-x', 'title' => 'sibling', 'body' => 'sibling', 'read' => false, @@ -680,7 +692,7 @@ class NotificationsTest extends TestCase $this->assertTrue($threw, 'unique-index `_key_recipient` must reject a second row sharing the recipient tuple'); $rows = $this->database->find('alerts', [ - \Utopia\Database\Query::equal('messageId', [$messageId]), + Query::equal('messageId', [$messageId]), ]); $this->assertCount(1, $rows, 'unique-index must prevent a second row from being persisted'); } finally { @@ -779,7 +791,7 @@ class NotificationsTest extends TestCase // Critical: no orphan dedup row. If there is one, the retry below // will short-circuit and the user never gets the email. $orphans = $this->database->find('alerts', [ - \Utopia\Database\Query::equal('messageId', [$messageId]), + Query::equal('messageId', [$messageId]), ]); $this->assertCount(0, $orphans, 'failed SMTP send must not leave a dedup row behind'); @@ -794,7 +806,7 @@ class NotificationsTest extends TestCase $this->assertSame(1, $working->sendCount, 'retry must invoke the working adapter'); $rows = $this->database->find('alerts', [ - \Utopia\Database\Query::equal('messageId', [$messageId]), + Query::equal('messageId', [$messageId]), ]); $this->assertCount(1, $rows, 'retry must persist exactly one alert row'); $this->assertSame('user-9', $rows[0]->getAttribute('userId')); @@ -804,6 +816,80 @@ class NotificationsTest extends TestCase } } + public function testEmailFailureDoesNotBlockConsoleRecipient(): void + { + $failing = new SpyEmailAdapter(); + $failing->throwOnSend = true; + $this->registry->set('smtp', static fn () => $failing); + + $previousSmtpHost = \getenv('_APP_SMTP_HOST'); + \putenv('_APP_SMTP_HOST=spy.smtp.test'); + + $payload = [ + 'project' => ['$id' => 'project-x', '$sequence' => 'project-internal-x'], + 'recipients' => [ + [ + 'address' => 'user@example.test', + 'channel' => NOTIFICATION_TYPE_EMAIL, + 'userId' => 'user-9', + 'teamId' => 'team-9', + ], + [ + 'address' => 'user-9', + 'channel' => NOTIFICATION_TYPE_CONSOLE, + 'userId' => 'user-9', + 'teamId' => 'team-9', + ], + ], + 'subject' => 'Subj', + 'body' => 'Body', + 'deduplicationKey' => 'smtp-fail-console-key', + ]; + + $messageId = \md5('smtp-fail-console-key'); + + try { + $worker = new Notifications(); + + $threw = false; + try { + $worker->action($this->buildMessage($payload), $this->project, $this->registry, $this->database, $this->log); + } catch (\Throwable $error) { + $threw = true; + $this->assertStringContainsString('SMTP unavailable', $error->getMessage()); + } + $this->assertTrue($threw, 'SMTP failure must still propagate so the email recipient is retried'); + + $consoleRows = $this->database->find('alerts', [ + Query::equal('messageId', [$messageId]), + Query::equal('channel', [NOTIFICATION_TYPE_CONSOLE]), + ]); + $this->assertCount(1, $consoleRows, 'console recipient must be persisted even when email fails first'); + $this->assertSame('project-x', $consoleRows[0]->getAttribute('projectId')); + $this->assertSame('project-internal-x', $consoleRows[0]->getAttribute('projectInternalId')); + + $emailRows = $this->database->find('alerts', [ + Query::equal('messageId', [$messageId]), + Query::equal('channel', [NOTIFICATION_TYPE_EMAIL]), + ]); + $this->assertCount(0, $emailRows, 'failed email recipient must not leave an orphan dedup row'); + + $working = new SpyEmailAdapter(); + $this->registry->set('smtp', static fn () => $working); + + $retryWorker = new Notifications(); + $retryWorker->action($this->buildMessage($payload), $this->project, $this->registry, $this->database, $this->log); + + $this->assertSame(1, $working->sendCount, 'retry must still deliver the email recipient'); + $rows = $this->database->find('alerts', [ + Query::equal('messageId', [$messageId]), + ]); + $this->assertCount(2, $rows, 'retry must add email without duplicating the already-delivered console alert'); + } finally { + \putenv($previousSmtpHost === false ? '_APP_SMTP_HOST' : '_APP_SMTP_HOST=' . $previousSmtpHost); + } + } + /** * Reviewer C2: `Notification::reset()` must clear EVERY state-bearing * field — `$preview` was missing from the original reset() body and @@ -996,7 +1082,7 @@ class NotificationsTest extends TestCase $this->assertSame(1, $worker->persistAlertCalls, 'email channel must persist exactly once after a successful send'); $messageId = \md5('happy-email'); $rows = $this->database->find('alerts', [ - \Utopia\Database\Query::equal('messageId', [$messageId]), + Query::equal('messageId', [$messageId]), ]); $this->assertCount(1, $rows); $row = $rows[0]; @@ -1039,7 +1125,7 @@ class NotificationsTest extends TestCase $worker->action($this->buildMessage($payload), $this->project, $this->registry, $this->database, $this->log); $rows = $this->database->find('alerts', [ - \Utopia\Database\Query::equal('channel', ['console']), + Query::equal('channel', ['console']), ]); $this->assertCount(1, $rows, 'console adapter must write exactly one alert'); @@ -1243,7 +1329,7 @@ class NotificationsTest extends TestCase // Action loop must persist exactly one webhook alert row AFTER the send. $this->assertSame(1, $worker->persistAlertCalls); $rows = $this->database->find('alerts', [ - \Utopia\Database\Query::equal('messageId', [\md5('happy-webhook')]), + Query::equal('messageId', [\md5('happy-webhook')]), ]); $this->assertCount(1, $rows); $this->assertSame(NOTIFICATION_TYPE_WEBHOOK, $rows[0]->getAttribute('channel')); diff --git a/tests/unit/Platform/Workers/WebhooksTest.php b/tests/unit/Platform/Workers/WebhooksTest.php index 00fbd7878e..86f23fd417 100644 --- a/tests/unit/Platform/Workers/WebhooksTest.php +++ b/tests/unit/Platform/Workers/WebhooksTest.php @@ -20,7 +20,7 @@ class WebhooksTest extends TestCase 'roles' => $roles, ]); - $this->assertSame($expected, $method->invoke(null, $membership)); + $this->assertSame($expected, $method->invoke(null, $membership, 'project-1')); } public static function ownerRoleProvider(): array @@ -28,7 +28,11 @@ class WebhooksTest extends TestCase return [ 'array owner' => [['owner'], true], 'array mixed case owner' => [['Owner'], true], + 'project owner' => [['project-project-1-owner'], true], + 'mixed case project owner' => [['Project-Project-1-Owner'], true], 'comma string owner' => ['developer, owner', true], + 'comma string project owner' => ['developer, project-project-1-owner', true], + 'other project owner' => [['project-project-2-owner'], false], 'non owner' => [['developer'], false], 'invalid roles' => [null, false], ]; diff --git a/tests/unit/Utopia/Messaging/Adapter/ConsoleTest.php b/tests/unit/Utopia/Messaging/Adapter/ConsoleTest.php index fe21f2d261..0bb6b4574d 100644 --- a/tests/unit/Utopia/Messaging/Adapter/ConsoleTest.php +++ b/tests/unit/Utopia/Messaging/Adapter/ConsoleTest.php @@ -4,11 +4,13 @@ namespace Tests\Unit\Utopia\Messaging\Adapter; use Appwrite\Utopia\Messaging\Adapter\Console; use Appwrite\Utopia\Messaging\Messages\Console as ConsoleMessage; +use Appwrite\Utopia\Messaging\Messages\Webhook as WebhookMessage; 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\ID; use Utopia\Database\Helpers\Permission; use Utopia\Database\Helpers\Role; @@ -38,7 +40,8 @@ class ConsoleTest extends TestCase $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', 'projectId', Database::VAR_STRING, 255, true); + $this->database->createAttribute('alerts', 'projectInternalId', Database::VAR_STRING, 255, true); $this->database->createAttribute('alerts', 'title', Database::VAR_STRING, 256, true); $this->database->createAttribute('alerts', 'body', Database::VAR_STRING, 16384, true); } @@ -68,6 +71,7 @@ class ConsoleTest extends TestCase type: 'info', messageId: ID::custom('msg-aaa'), projectId: 'project-1', + projectInternalId: 'project-internal-1', ); $adapter = new Console($this->database); @@ -81,6 +85,7 @@ class ConsoleTest extends TestCase $this->assertSame('console', $stored->getAttribute('channel')); $this->assertSame('user-1', $stored->getAttribute('userId')); $this->assertSame('project-1', $stored->getAttribute('projectId')); + $this->assertSame('project-internal-1', $stored->getAttribute('projectInternalId')); $this->assertSame('Hello', $stored->getAttribute('title')); $this->assertSame('World', $stored->getAttribute('body')); $this->assertSame('info', $stored->getAttribute('type')); @@ -93,6 +98,8 @@ class ConsoleTest extends TestCase title: 'Title', body: 'Body', messageId: ID::custom('msg-perms-user'), + projectId: 'project-1', + projectInternalId: 'project-internal-1', ); (new Console($this->database))->send($message); @@ -112,6 +119,8 @@ class ConsoleTest extends TestCase title: 'Heads up', body: '...', messageId: ID::custom('msg-team'), + projectId: 'project-1', + projectInternalId: 'project-internal-1', ); (new Console($this->database))->send($message); @@ -122,6 +131,9 @@ class ConsoleTest extends TestCase $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); + $this->assertContains(Permission::read(Role::team('team-9', 'project-project-1-owner')), $permissions); + $this->assertContains(Permission::update(Role::team('team-9', 'project-project-1-owner')), $permissions); + $this->assertContains(Permission::delete(Role::team('team-9', 'project-project-1-owner')), $permissions); } public function testMultiRecipientWithSameMessageIdGeneratesDistinctIds(): void @@ -134,6 +146,8 @@ class ConsoleTest extends TestCase title: 'Heads up', body: 'multi', messageId: ID::custom('same-msg'), + projectId: 'project-1', + projectInternalId: 'project-internal-1', ); $adapter = new Console($this->database); @@ -167,7 +181,7 @@ class ConsoleTest extends TestCase $this->expectExceptionMessage('Invalid message type.'); // ConsoleMessage extends nothing; pass an unrelated Message implementation - $adapter->send(new \Appwrite\Utopia\Messaging\Messages\Webhook(urls: ['https://example.test'], payload: [])); + $adapter->send(new WebhookMessage(urls: ['https://example.test'], payload: [])); } /** @@ -186,13 +200,15 @@ class ConsoleTest extends TestCase // Pre-insert an alert with the SAME id the adapter will compute. The // adapter's createDocument will hit the primary-key DuplicateException // and must treat it as a successful (idempotent) send. - $this->database->createDocument('alerts', new \Utopia\Database\Document([ + $this->database->createDocument('alerts', new Document([ '$id' => $documentId, '$permissions' => [Permission::read(Role::any())], 'messageId' => $messageId, 'recipientHash' => \substr(\md5('user:' . $userId), 0, 16), 'channel' => 'console', 'userId' => $userId, + 'projectId' => 'project-x', + 'projectInternalId' => 'project-internal-x', 'title' => 'pre-existing', 'body' => 'pre-existing', ])); @@ -203,6 +219,7 @@ class ConsoleTest extends TestCase body: 'b', messageId: ID::custom($messageId), projectId: 'project-x', + projectInternalId: 'project-internal-x', ); $adapter = new Console($this->database);