From 3a1fe3b2a7fbce059aa882d32056775a7476f1d9 Mon Sep 17 00:00:00 2001 From: Khushboo Verma <43381712+vermakhushboo@users.noreply.github.com> Date: Thu, 18 Jan 2024 15:19:57 +0530 Subject: [PATCH] Add webhook tests --- app/controllers/api/projects.php | 4 +- src/Appwrite/Platform/Workers/Webhooks.php | 103 ++++---- .../Utopia/Response/Model/Webhook.php | 2 +- tests/e2e/Services/Webhooks/WebhooksBase.php | 233 ++++++++++++++++++ 4 files changed, 294 insertions(+), 48 deletions(-) diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 8e035c4c32..4e13747a50 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -899,7 +899,7 @@ App::post('/v1/projects/:projectId/webhooks') ->label('sdk.response.model', Response::MODEL_WEBHOOK) ->param('projectId', '', new UID(), 'Project unique ID.') ->param('name', null, new Text(128), 'Webhook name. Max length: 128 chars.') - ->param('enabled', true, new Boolean(), 'Enable or disable a webhook.', true) + ->param('enabled', true, new Boolean(true), 'Enable or disable a webhook.', true) ->param('events', null, new ArrayList(new Event(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Events list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.') ->param('url', '', fn ($request) => new Multiple([new URL(['http', 'https']), new PublicDomain()], Multiple::TYPE_STRING), 'Webhook URL.', false, ['request']) ->param('security', false, new Boolean(true), 'Certificate verification, false for disabled or true for enabled.') @@ -1024,7 +1024,7 @@ App::put('/v1/projects/:projectId/webhooks/:webhookId') ->param('projectId', '', new UID(), 'Project unique ID.') ->param('webhookId', '', new UID(), 'Webhook unique ID.') ->param('name', null, new Text(128), 'Webhook name. Max length: 128 chars.') - ->param('enabled', true, new Boolean(), 'Enable or disable a webhook.', true) + ->param('enabled', true, new Boolean(true), 'Enable or disable a webhook.', true) ->param('events', null, new ArrayList(new Event(), APP_LIMIT_ARRAY_PARAMS_SIZE), 'Events list. Maximum of ' . APP_LIMIT_ARRAY_PARAMS_SIZE . ' events are allowed.') ->param('url', '', fn ($request) => new Multiple([new URL(['http', 'https']), new PublicDomain()], Multiple::TYPE_STRING), 'Webhook URL.', false, ['request']) ->param('security', false, new Boolean(true), 'Certificate verification, false for disabled or true for enabled.') diff --git a/src/Appwrite/Platform/Workers/Webhooks.php b/src/Appwrite/Platform/Workers/Webhooks.php index 61305fbcb3..459b1946c6 100644 --- a/src/Appwrite/Platform/Workers/Webhooks.php +++ b/src/Appwrite/Platform/Workers/Webhooks.php @@ -159,51 +159,7 @@ class Webhooks extends Action if ($attempts >= self::MAX_FAILED_ATTEMPTS) { $webhook->setAttribute('enabled', false); - - $memberships = $dbForConsole->find('memberships', [ - Query::equal('teamInternalId', [$project->getAttribute('teamInternalId')]), - Query::limit(APP_LIMIT_SUBQUERY) - ]); - - $userIds = array_column(\array_map(fn ($membership) => $membership->getArrayCopy(), $memberships), 'userId'); - - $users = $dbForConsole->find('users', [ - Query::equal('$id', $userIds), - ]); - - $protocol = App::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; - $hostname = App::getEnv('_APP_DOMAIN'); - $projectId = $project->getId(); - $webhookId = $webhook->getId(); - - $template = Template::fromFile(__DIR__ . '/../../../../app/config/locale/templates/email-webhook-failed.tpl'); - - $template->setParam('{{webhook}}', $webhook->getAttribute('name')); - $template->setParam('{{project}}', $project->getAttribute('name')); - $template->setParam('{{url}}', $webhook->getAttribute('url')); - $template->setParam('{{error}}', $curlError ?? 'The server returned ' . $statusCode . ' status code'); - $template->setParam('{{redirect}}', $protocol . '://' . $hostname . "/console/project-$projectId/settings/webhooks/$webhookId"); - $template->setParam('{{attempts}}', $attempts); - - $subject = 'Webhook deliveries have been paused'; - $body = Template::fromFile(__DIR__ . '/../../../../app/config/locale/templates/email-base-styled.tpl'); - - $body - ->setParam('{{subject}}', $subject) - ->setParam('{{message}}', $template->render()) - ->setParam('{{year}}', date("Y")); - - $queueForMails - ->setSubject($subject) - ->setBody($body->render()); - - foreach ($users as $user) { - $queueForMails - ->setVariables(['user' => $user->getAttribute('name', '')]) - ->setName($user->getAttribute('name', '')) - ->setRecipient($user->getAttribute('email')) - ->trigger(); - } + $this->sendEmailAlert($attempts, $statusCode, $webhook, $project, $dbForConsole, $queueForMails); } $dbForConsole->updateDocument('webhooks', $webhook->getId(), $webhook); @@ -216,4 +172,61 @@ class Webhooks extends Action $dbForConsole->deleteCachedDocument('projects', $project->getId()); } } + + /** + * @param int $attempts + * @param mixed $statusCode + * @param Document $webhook + * @param Document $project + * @param Database $dbForConsole + * @param Mail $queueForMails + * @return void + */ + public function sendEmailAlert(int $attempts, mixed $statusCode, Document $webhook, Document $project, Database $dbForConsole, Mail $queueForMails): void + { + $memberships = $dbForConsole->find('memberships', [ + Query::equal('teamInternalId', [$project->getAttribute('teamInternalId')]), + Query::limit(APP_LIMIT_SUBQUERY) + ]); + + $userIds = array_column(\array_map(fn ($membership) => $membership->getArrayCopy(), $memberships), 'userId'); + + $users = $dbForConsole->find('users', [ + Query::equal('$id', $userIds), + ]); + + $protocol = App::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https'; + $hostname = App::getEnv('_APP_DOMAIN'); + $projectId = $project->getId(); + $webhookId = $webhook->getId(); + + $template = Template::fromFile(__DIR__ . '/../../../../app/config/locale/templates/email-webhook-failed.tpl'); + + $template->setParam('{{webhook}}', $webhook->getAttribute('name')); + $template->setParam('{{project}}', $project->getAttribute('name')); + $template->setParam('{{url}}', $webhook->getAttribute('url')); + $template->setParam('{{error}}', $curlError ?? 'The server returned ' . $statusCode . ' status code'); + $template->setParam('{{redirect}}', $protocol . '://' . $hostname . "/console/project-$projectId/settings/webhooks/$webhookId"); + $template->setParam('{{attempts}}', $attempts); + + $subject = 'Webhook deliveries have been paused'; + $body = Template::fromFile(__DIR__ . '/../../../../app/config/locale/templates/email-base-styled.tpl'); + + $body + ->setParam('{{subject}}', $subject) + ->setParam('{{message}}', $template->render()) + ->setParam('{{year}}', date("Y")); + + $queueForMails + ->setSubject($subject) + ->setBody($body->render()); + + foreach ($users as $user) { + $queueForMails + ->setVariables(['user' => $user->getAttribute('name', '')]) + ->setName($user->getAttribute('name', '')) + ->setRecipient($user->getAttribute('email')) + ->trigger(); + } + } } diff --git a/src/Appwrite/Utopia/Response/Model/Webhook.php b/src/Appwrite/Utopia/Response/Model/Webhook.php index aea7c33e88..57abb4900d 100644 --- a/src/Appwrite/Utopia/Response/Model/Webhook.php +++ b/src/Appwrite/Utopia/Response/Model/Webhook.php @@ -84,7 +84,7 @@ class Webhook extends Model ]) ->addRule('logs', [ 'type' => self::TYPE_STRING, - 'description' => 'Webhooks last failed delivery attempt logs.', + 'description' => 'Webhook error logs from the most recent failure.', 'default' => '', 'example' => 'Failed to connect to remote server.', ]) diff --git a/tests/e2e/Services/Webhooks/WebhooksBase.php b/tests/e2e/Services/Webhooks/WebhooksBase.php index e2eb29c74d..2ad8c7f91e 100644 --- a/tests/e2e/Services/Webhooks/WebhooksBase.php +++ b/tests/e2e/Services/Webhooks/WebhooksBase.php @@ -997,4 +997,237 @@ trait WebhooksBase $this->assertEquals(true, (new DatetimeValidator())->isValid($webhook['data']['invited'])); $this->assertEquals(('server' === $this->getSide()), $webhook['data']['confirm']); } + + public function testCreateWebhookWithPrivateDomain(): void + { + /** + * Test for FAILURE + */ + $projectId = $this->getProject()['$id']; + $webhook = $this->client->call(Client::METHOD_POST, '/projects/' . $projectId . '/webhooks', [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => 'console', + ], [ + 'name' => 'Webhook Test', + 'enabled' => true, + 'events' => [ + 'databases.*', + 'functions.*', + 'buckets.*', + 'teams.*', + 'users.*' + ], + 'url' => 'http://localhost/webhook', // private domains not allowed + 'security' => false, + ]); + + $this->assertEquals(400, $webhook['headers']['status-code']); + } + + public function testUpdateWebhookWithPrivateDomain(): void + { + /** + * Test for FAILURE + */ + $projectId = $this->getProject()['$id']; + $webhookId = $this->getProject()['webhookId']; + $webhook = $this->client->call(Client::METHOD_PUT, '/projects/' . $projectId . '/webhooks/' . $webhookId, [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => 'console', + ], [ + 'name' => 'Webhook Test', + 'enabled' => true, + 'events' => [ + 'databases.*', + 'functions.*', + 'buckets.*', + 'teams.*', + 'users.*' + ], + 'url' => 'http://localhost/webhook', // private domains not allowed + 'security' => false, + ]); + + $this->assertEquals(400, $webhook['headers']['status-code']); + } + + /** + * @depends testCreateCollection + */ + public function testCreateDisabledWebhook(array $data): void + { + $projectId = $this->getProject()['$id']; + $webhookId = $this->getProject()['webhookId']; + $databaseId = $data['databaseId']; + + // create a new collection + $collection1 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Collection1', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + ]); + + $this->assertEquals($collection1['headers']['status-code'], 201); + $this->assertNotEmpty($collection1['body']['$id']); + + // update webhook and set it to disabled + $webhook = $this->client->call(Client::METHOD_PUT, '/projects/' . $projectId . '/webhooks/' . $webhookId, [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => 'console', + ], [ + 'name' => 'Webhook Test', + 'enabled' => false, + 'events' => [ + 'databases.*', + 'functions.*', + 'buckets.*', + 'teams.*', + 'users.*' + ], + 'url' => 'http://request-catcher:5000/webhook', + 'security' => false, + ]); + + $this->assertEquals(200, $webhook['headers']['status-code']); + $this->assertNotEmpty($webhook['body']); + + $webhook = $this->getLastRequest(); + $this->assertEquals($webhook['data']['name'], 'Collection1'); + + sleep(5); + + /** + * Test for FAILURE + */ + // create another collection: This event should not trigger the webhook. + $collection2 = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'Collection2', + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + ]); + + $this->assertEquals($collection2['headers']['status-code'], 201); + $this->assertNotEmpty($collection2['body']['$id']); + + $webhook = $this->getLastRequest(); + + $this->assertEquals($webhook['headers']['X-Appwrite-Webhook-Id'] ?? '', $webhookId); + $this->assertNotEquals($webhook['data']['name'], 'Collection2'); + + // re-enable the webhook again + $webhook = $this->client->call(Client::METHOD_PUT, '/projects/' . $projectId . '/webhooks/' . $webhookId, [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => 'console', + ], [ + 'name' => 'Webhook Test', + 'enabled' => true, // set webhook to enabled again + 'events' => [ + 'databases.*', + 'functions.*', + 'buckets.*', + 'teams.*', + 'users.*' + ], + 'url' => 'http://request-catcher:5000/webhook', + 'security' => false, + ]); + + $this->assertEquals(200, $webhook['headers']['status-code']); + $this->assertNotEmpty($webhook['body']); + } + + /** + * @depends testCreateCollection + */ + public function testWebhookAutoDisable(array $data): void + { + $projectId = $this->getProject()['$id']; + $webhookId = $this->getProject()['webhookId']; + $databaseId = $data['databaseId']; + + $webhook = $this->client->call(Client::METHOD_PUT, '/projects/' . $projectId . '/webhooks/' . $webhookId, [ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => 'console', + ], [ + 'name' => 'Webhook Test', + 'enabled' => true, + 'events' => [ + 'databases.*', + 'functions.*', + 'buckets.*', + 'teams.*', + 'users.*' + ], + 'url' => 'http://appwrite-non-existing-domain.com', // set non-existent URL + 'security' => false, + ]); + + $this->assertEquals(200, $webhook['headers']['status-code']); + $this->assertNotEmpty($webhook['body']); + + // trigger webhook for failure event 10 times + for ($i = 0; $i < 10; $i++) { + $newCollection = $this->client->call(Client::METHOD_POST, '/databases/' . $databaseId . '/collections', array_merge([ + 'content-type' => 'application/json', + 'x-appwrite-project' => $this->getProject()['$id'], + 'x-appwrite-key' => $this->getProject()['apiKey'] + ]), [ + 'collectionId' => ID::unique(), + 'name' => 'newCollection' . $i, + 'permissions' => [ + Permission::read(Role::any()), + Permission::create(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ], + 'documentSecurity' => true, + ]); + + $this->assertEquals($newCollection['headers']['status-code'], 201); + $this->assertNotEmpty($newCollection['body']['$id']); + } + + sleep(10); + + $webhook = $this->client->call(Client::METHOD_GET, '/projects/' . $projectId . '/webhooks/' . $webhookId, array_merge([ + 'origin' => 'http://localhost', + 'content-type' => 'application/json', + 'cookie' => 'a_session_console=' . $this->getRoot()['session'], + 'x-appwrite-project' => 'console', + ])); + + // assert that the webhook is now disabled after 10 consecutive failures + $this->assertEquals($webhook['body']['enabled'], false); + $this->assertEquals($webhook['body']['attempts'], 10); + } }