mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
fix: scope notification alerts by project
This commit is contained in:
@@ -441,6 +441,7 @@ jobs:
|
||||
Webhooks,
|
||||
VCS,
|
||||
Messaging,
|
||||
Notifications,
|
||||
Migrations,
|
||||
Project
|
||||
]
|
||||
|
||||
@@ -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],
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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')),
|
||||
]));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user