diff --git a/.drone.star b/.drone.star index db572eae4..4e524cd7e 100644 --- a/.drone.star +++ b/.drone.star @@ -5,6 +5,7 @@ ALPINE_GIT = "alpine/git:latest" CHKO_DOCKER_PUSHRM = "chko/docker-pushrm:1" DRONE_CLI = "drone/cli:alpine" +INBUCKET_INBUCKET = "inbucket/inbucket" MINIO_MC = "minio/mc:RELEASE.2021-10-07T04-19-58Z" OC_CI_ALPINE = "owncloudci/alpine:latest" OC_CI_BAZEL_BUILDIFIER = "owncloudci/bazel-buildifier:latest" @@ -133,6 +134,23 @@ config = { "POSTPROCESSING_DELAY": "30s", }, }, + "apiEmailNotification": { + "suites": [ + "apiEmailNotification", + ], + "skip": False, + "earlyFail": True, + "emailNeeded": True, + "extraEnvironment": { + "EMAIL_HOST": "email", + "EMAIL_PORT": "9000", + }, + "extraServerEnvironment": { + "NOTIFICATIONS_SMTP_HOST": "email", + "NOTIFICATIONS_SMTP_PORT": "2500", + "NOTIFICATIONS_SMTP_INSECURE": "true", + }, + }, }, "apiTests": { "numberOfParts": 10, @@ -759,6 +777,7 @@ def localApiTestPipeline(ctx): "extraServerEnvironment": {}, "storages": ["ocis"], "accounts_hash_difficulty": 4, + "emailNeeded": False, } if "localApiTests" in config: @@ -781,9 +800,10 @@ def localApiTestPipeline(ctx): "steps": skipIfUnchanged(ctx, "acceptance-tests") + restoreBuildArtifactCache(ctx, "ocis-binary-amd64", "ocis/bin") + ocisServer(storage, params["accounts_hash_difficulty"], extra_server_environment = params["extraServerEnvironment"]) + + (waitForEmailService() if params["emailNeeded"] else []) + localApiTests(suite, storage, params["extraEnvironment"]) + failEarly(ctx, early_fail), - "services": redisForOCStorage(storage), + "services": emailService() if params["emailNeeded"] else [], "depends_on": getPipelineNames([buildOcisBinaryForTesting(ctx)]), "trigger": { "ref": [ @@ -2892,3 +2912,18 @@ def restoreWebPnpmCache(): "retry -t 3 'pnpm install'", ], }] + +def emailService(): + return [{ + "name": "email", + "image": INBUCKET_INBUCKET, + }] + +def waitForEmailService(): + return [{ + "name": "wait-for-email", + "image": OC_CI_WAIT_FOR, + "commands": [ + "wait-for -it email:9000 -t 600", + ], + }] diff --git a/docs/ocis/development/testing.md b/docs/ocis/development/testing.md index df76f02e6..5502e2342 100644 --- a/docs/ocis/development/testing.md +++ b/docs/ocis/development/testing.md @@ -260,3 +260,40 @@ make test-paralleldeployment-api \ ... \ BEHAT_FEATURE="tests/parallelDeployAcceptance/features/apiShareManagement/acceptShares.feature" ``` + +## Running test suite with email service (inbucket) + +### Setup inbucket + +Run the following command to setup inbucket + +```bash +docker run -d --name inbucket -p 9000:9000 -p 2500:2500 -p 1100:1100 inbucket/inbucket +``` + +### Run oCIS with following environment variables + +Documentation for environment variables is available [here](https://owncloud.dev/services/notifications/#environment-variables) + +```bash +OCIS_INSECURE=true \ +PROXY_ENABLE_BASIC_AUTH=true \ +NOTIFICATIONS_SMTP_HOST=localhost \ +NOTIFICATIONS_SMTP_INSECURE=true \ +NOTIFICATIONS_SMTP_PORT=2500 \ +OCIS_URL=https://localhost:9200 \ +/ocis/bin/ocis server +``` + +### Run the acceptance test + +Run the acceptance test with the following command: +```bash +make test-acceptance-api \ +TEST_SERVER_URL="https://localhost:9200" \ +TEST_OCIS=true \ +TEST_WITH_GRAPH_API=true \ +EMAIL_HOST="localhost" \ +EMAIL_PORT=9000 \ +BEHAT_FEATURE="tests/acceptance/features/apiEmailNotification/emailNotification.feature" +``` diff --git a/tests/TestHelpers/EmailHelper.php b/tests/TestHelpers/EmailHelper.php new file mode 100644 index 000000000..6f3e8a871 --- /dev/null +++ b/tests/TestHelpers/EmailHelper.php @@ -0,0 +1,173 @@ + + * @copyright Copyright (c) 2023 Prajwol Amatya prajwol@jankaritech.com + */ + +namespace TestHelpers; + +use Exception; +use GuzzleHttp\Exception\GuzzleException; +use Psr\Http\Message\ResponseInterface; + +/** + * A helper class for managing emails + */ +class EmailHelper { + /** + * @param string $emailAddress + * + * @return string + */ + public static function getMailBoxFromEmail(string $emailAddress):string { + return explode("@", $emailAddress)[0]; + } + + /** + * Returns the host and port where Email messages can be read and deleted + * by the test runner. + * + * @return string + */ + public static function getLocalEmailUrl():string { + $localEmailHost = self::getLocalEmailHost(); + $emailPort = \getenv('EMAIL_PORT'); + if ($emailPort === false) { + $emailPort = "9000"; + } + return "http://$localEmailHost:$emailPort"; + } + + /** + * Returns the host name or address of the Email server as seen from the + * point of view of the system-under-test. + * + * @return string + */ + public static function getEmailHost():string { + $emailHost = \getenv('EMAIL_HOST'); + if ($emailHost === false) { + $emailHost = "127.0.0.1"; + } + return $emailHost; + } + + /** + * Returns the host name or address of the Email server as seen from the + * point of view of the test runner. + * + * @return string + */ + public static function getLocalEmailHost():string { + $localEmailHost = \getenv('LOCAL_EMAIL_HOST'); + if ($localEmailHost === false) { + $localEmailHost = self::getEmailHost(); + } + return $localEmailHost; + } + + /** + * Returns general response information about the provided mailbox + * A mailbox is created automatically in InBucket for every unique email sender|receiver + * + * @param string $mailBox + * @param string|null $xRequestId + * + * @return array + * @throws GuzzleException + */ + public static function getMailBoxInformation(string $mailBox, ?string $xRequestId = null):array { + $response = HttpRequestHelper::get( + self::getLocalEmailUrl() . "/api/v1/mailbox/" . $mailBox, + $xRequestId, + null, + null, + ['Content-Type' => 'application/json'] + ); + return \json_decode($response->getBody()->getContents()); + } + + /** + * returns body content of a specific email (mailBox) with email ID (mailbox Id) + * + * @param string $mailBox + * @param string $mailboxId + * @param string|null $xRequestId + * + * @return object + * @throws GuzzleException + */ + public static function getBodyOfAnEmailById(string $mailBox, string $mailboxId, ?string $xRequestId = null):object { + $response = HttpRequestHelper::get( + self::getLocalEmailUrl() . "/api/v1/mailbox/" . $mailBox . "/" . $mailboxId, + $xRequestId, + null, + null, + ['Content-Type' => 'application/json'] + ); + return \json_decode($response->getBody()->getContents()); + } + + /** + * Returns the body of the last received email for the provided receiver according to the provided email address and the serial number + * For email number, 1 means the latest one + * + * @param string $emailAddress + * @param string|null $xRequestId + * @param int|null $emailNumber For email number, 1 means the latest one + * @param int|null $waitTimeSec Time to wait for the email if the email has been delivered + * + * @return string + * @throws GuzzleException + * @throws Exception + */ + public static function getBodyOfLastEmail( + string $emailAddress, + string $xRequestId, + ?int $emailNumber = 1, + ?int $waitTimeSec = EMAIL_WAIT_TIMEOUT_SEC + ):string { + $currentTime = \time(); + $endTime = $currentTime + $waitTimeSec; + $mailBox = self::getMailBoxFromEmail($emailAddress); + while ($currentTime <= $endTime) { + $mailboxResponse = self::getMailboxInformation($mailBox); + if (!empty($mailboxResponse) && \sizeof($mailboxResponse) >= $emailNumber) { + $mailboxId = $mailboxResponse[\sizeof($mailboxResponse) - $emailNumber]->id; + $response = self::getBodyOfAnEmailById($mailBox, $mailboxId, $xRequestId); + $body = \str_replace( + "\r\n", + "\n", + \quoted_printable_decode($response->body->text . "\n" . $response->body->html) + ); + return $body; + } + \usleep(STANDARD_SLEEP_TIME_MICROSEC * 50); + $currentTime = \time(); + } + throw new Exception("Could not find the email to the address: " . $emailAddress); + } + + /** + * Deletes all the emails for the provided mailbox + * + * @param string $localInbucketUrl + * @param string|null $xRequestId + * @param string $mailBox + * + * @return ResponseInterface + * @throws GuzzleException + */ + public static function deleteAllEmailsForAMailbox( + string $localInbucketUrl, + ?string $xRequestId, + string $mailBox + ):ResponseInterface { + return HttpRequestHelper::delete( + $localInbucketUrl . "/api/v1/mailbox/" . $mailBox, + $xRequestId + ); + } +} diff --git a/tests/acceptance/config/behat.yml b/tests/acceptance/config/behat.yml index 73aa64c21..0baeb28b7 100644 --- a/tests/acceptance/config/behat.yml +++ b/tests/acceptance/config/behat.yml @@ -168,6 +168,22 @@ default: - TrashbinContext: - GraphContext: + apiEmailNotification: + paths: + - '%paths.base%/../features/apiEmailNotification' + context: *common_ldap_suite_context + contexts: + - NotificationContext: + - SpacesContext: + - FeatureContext: *common_feature_context_params + - WebDavPropertiesContext: + - OCSContext: + - GraphContext: + - TrashbinContext: + - FavoritesContext: + - ChecksumContext: + - FilesVersionsContext: + - RoleAssignmentContext: extensions: rdx\behatvars\BehatVariablesExtension: ~ diff --git a/tests/acceptance/features/apiEmailNotification/emailNotification.feature b/tests/acceptance/features/apiEmailNotification/emailNotification.feature new file mode 100644 index 000000000..ccd0fae96 --- /dev/null +++ b/tests/acceptance/features/apiEmailNotification/emailNotification.feature @@ -0,0 +1,42 @@ +@api @email +Feature: Email notification + As a user + I want to get email notification of events related to me + So that I can stay updated about the events + + Background: + Given these users have been created with default attributes and without skeleton files: + | username | + | Alice | + | Brian | + + + Scenario: a user gets an email notification when someone shares a project space + Given the administrator has given "Alice" the role "Space Admin" using the settings api + And user "Alice" has created a space "new-space" with the default quota using the GraphApi + When user "Alice" shares a space "new-space" with settings: + | shareWith | Brian | + | role | Editor | + Then the HTTP status code should be "200" + And user "Brian" should have received the following email from user "Alice" about the share of project space "new-space" + """ + Hello Brian Murphy, + + %displayname% has invited you to join "new-space". + + Click here to view it: %base_url%/f/%space_id% + """ + + + Scenario: a user gets an email notification when someone shares a file + Given user "Alice" has uploaded file with content "sample text" to "lorem.txt" + When user "Alice" has shared file "lorem.txt" with user "Brian" with permissions "17" + Then the HTTP status code should be "200" + And user "Brian" should have received the following email from user "Alice" + """ + Hello Brian Murphy + + %displayname% has shared "lorem.txt" with you. + + Click here to view it: %base_url%/files/shares/with-me + """ diff --git a/tests/acceptance/features/bootstrap/NotificationContext.php b/tests/acceptance/features/bootstrap/NotificationContext.php index f236bd402..59af1f7d6 100644 --- a/tests/acceptance/features/bootstrap/NotificationContext.php +++ b/tests/acceptance/features/bootstrap/NotificationContext.php @@ -11,6 +11,9 @@ use Behat\Behat\Hook\Scope\BeforeScenarioScope; use TestHelpers\OcsApiHelper; use Behat\Gherkin\Node\PyStringNode; use Helmich\JsonAssert\JsonAssertions; +use TestHelpers\EmailHelper; +use PHPUnit\Framework\Assert; +use TestHelpers\GraphHelper; require_once 'bootstrap.php'; @@ -19,7 +22,11 @@ require_once 'bootstrap.php'; */ class NotificationContext implements Context { private FeatureContext $featureContext; + + private SpacesContext $spacesContext; + private string $notificationEndpointPath = '/apps/notifications/api/v1/notifications?format=json'; + private array $notificationIds; /** @@ -70,6 +77,7 @@ class NotificationContext implements Context { $environment = $scope->getEnvironment(); // Get all the contexts you need in this context $this->featureContext = $environment->getContext('FeatureContext'); + $this->spacesContext = $environment->getContext('SpacesContext'); } /** @@ -134,4 +142,105 @@ class NotificationContext implements Context { $this->featureContext->getJSONSchema($schemaString) ); } + + /** + * @Then user :user should have received the following email from user :sender about the share of project space :spaceName + * + * @param string $user + * @param string $sender + * @param string $spaceName + * @param PyStringNode $content + * + * @return void + * @throws Exception + */ + public function userShouldHaveReceivedTheFollowingEmailFromUserAboutTheShareOfProjectSpace(string $user, string $sender, string $spaceName, PyStringNode $content):void { + $rawExpectedEmailBodyContent = \str_replace("\r\n", "\n", $content->getRaw()); + $this->featureContext->setResponse( + GraphHelper::getMySpaces( + $this->featureContext->getBaseUrl(), + $user, + $this->featureContext->getPasswordForUser($user) + ) + ); + $expectedEmailBodyContent = $this->featureContext->substituteInLineCodes( + $rawExpectedEmailBodyContent, + $sender, + [], + [ + [ + "code" => "%space_id%", + "function" => + [$this->spacesContext, "getSpaceIdByNameFromResponse"], + "parameter" => [$spaceName] + ], + ], + null, + null + ); + $this->assertEmailContains($user, $expectedEmailBodyContent); + } + + /** + * @Then user :user should have received the following email from user :sender + * + * @param string $user + * @param string $sender + * @param PyStringNode $content + * + * @return void + * @throws Exception + */ + public function userShouldHaveReceivedTheFollowingEmailFromUser(string $user, string $sender, PyStringNode $content):void { + $rawExpectedEmailBodyContent = \str_replace("\r\n", "\n", $content->getRaw()); + $expectedEmailBodyContent = $this->featureContext->substituteInLineCodes( + $rawExpectedEmailBodyContent, + $sender + ); + $this->assertEmailContains($user, $expectedEmailBodyContent); + } + + /*** + * @param string $user + * @param string $expectedEmailBodyContent + * + * @return void + * @throws GuzzleException + */ + public function assertEmailContains(string $user, string $expectedEmailBodyContent):void { + $address = $this->featureContext->getEmailAddressForUser($user); + $this->featureContext->pushEmailRecipientAsMailBox($address); + $actualEmailBodyContent = EmailHelper::getBodyOfLastEmail($address, $this->featureContext->getStepLineRef()); + Assert::assertStringContainsString( + $expectedEmailBodyContent, + $actualEmailBodyContent, + "The email address '$address' should have received an email with the body containing $expectedEmailBodyContent + but the received email is $actualEmailBodyContent" + ); + } + + /** + * Delete all the inbucket emails + * + * @AfterScenario @email + * + * @return void + */ + public function clearInbucketMessages():void { + try { + if (!empty($this->featureContext->emailRecipients)) { + foreach ($this->featureContext->emailRecipients as $emailRecipent) { + EmailHelper::deleteAllEmailsForAMailbox( + EmailHelper::getLocalEmailUrl(), + $this->featureContext->getStepLineRef(), + $emailRecipent + ); + } + } + } catch (Exception $e) { + echo __METHOD__ . + " could not delete inbucket messages, is inbucket set up?\n" . + $e->getMessage(); + } + } }