fix: scope notification alerts by project

This commit is contained in:
Jake Barnby
2026-05-16 21:58:45 +12:00
parent 70df121e6f
commit 62b0314bcb
27 changed files with 431 additions and 57 deletions
+1
View File
@@ -441,6 +441,7 @@ jobs:
Webhooks,
VCS,
Messaging,
Notifications,
Migrations,
Project
]
+51 -1
View File
@@ -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],
],
],
],
+9
View File
@@ -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);
@@ -0,0 +1 @@
Get the number of jobs in the notifications queue.
+1
View File
@@ -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')),
]));
+34
View File
@@ -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;
}
@@ -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()) {
@@ -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([
@@ -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.");
}
@@ -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,
@@ -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,
]))
@@ -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')));
@@ -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);
@@ -0,0 +1,60 @@
<?php
namespace Appwrite\Platform\Modules\Health\Http\Health\Queue\Notifications;
use Appwrite\Event\Notification;
use Appwrite\Platform\Modules\Health\Http\Health\Queue\Base;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Database\Document;
use Utopia\Validator\Integer;
class Get extends Base
{
public static function getName(): string
{
return 'getQueueNotifications';
}
public function __construct()
{
$this
->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);
}
}
@@ -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());
@@ -131,6 +131,7 @@ class Create extends Action
'resourceInternalId' => $resource->getSequence(),
'resourceUpdatedAt' => DateTime::now(),
'projectId' => $project->getId(),
'projectInternalId' => $project->getSequence(),
'schedule' => $schedule,
'active' => $active,
];
@@ -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);
}
@@ -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.");
}
@@ -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) {
+26 -18
View File
@@ -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<string>
*/
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',
]);
+16 -11
View File
@@ -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;
}
}
@@ -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<string>
*/
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;
}
@@ -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;
}
}
@@ -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',
]);
+102 -16
View File
@@ -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<int, string> */
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 </body>.
$lastBodyClose = \strripos($body, '</body>');
$pixelPosition = \strripos($body, '<img src=');
@@ -648,7 +659,7 @@ class NotificationsTest extends TestCase
// primary key -> DuplicateException -> branch returns the
// existing alertId without throwing.
$reflection = new \ReflectionMethod($worker, 'persistAlert');
$secondAlertId = $reflection->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'));
+5 -1
View File
@@ -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],
];
@@ -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);