From dbecdbe830731963d5580d5c7300ca9a140c9126 Mon Sep 17 00:00:00 2001 From: Byron Himes Date: Fri, 21 Apr 2023 12:57:27 +0200 Subject: [PATCH] Send email notifications with automated testing (GSI 9) (#4) * Add test server with authentication Update config to include new params Update dev reqs to include aiosmtpd * Add more errors to Notifier port * Create smtp client port * Implement SMTP client adapter * Update notifier to use smtp client * Update tests to use new dummy server * Silence mypy error on message_from_bytes * Tweak host values in config * Add random free port testing util Update smtp client and dummy server with methods to set hostname/port * Update example config * Connect consumer to notifier * Prefix record_email() with underscore Co-authored-by: Kersten Breuer * Remove setters from DummyServer/make values public Update use of record_email to _record_email * Reorder tests so quicker ones run first * Move failed auth test to own test function DRY out parametrized notification * Remove DummySmtpClient entirely * Reflect in tests that HTML template is mandatory * Add SmtpClientConfig * Add NotifierConfig * Make BadTemplateFormat error more specific Remove condition for using html template * Add test config with dynamic port Remove port modification for dummy server/smtp client * Expand error handling for other SMTP exceptions --------- Co-authored-by: Byron Himes Co-authored-by: Kersten Breuer --- .devcontainer/.dev_config.yaml | 9 +- config_schema.json | 82 +++++++++++++---- example_config.yaml | 5 + ns/adapters/inbound/akafka.py | 12 ++- ns/adapters/outbound/__init__.py | 15 +++ ns/adapters/outbound/smtp_client.py | 62 +++++++++++++ ns/config.py | 9 +- ns/container.py | 10 +- ns/core/notifier.py | 34 +++++-- ns/ports/inbound/notifier.py | 10 +- ns/ports/outbound/__init__.py | 15 +++ ns/ports/outbound/smtp_client.py | 50 ++++++++++ requirements-dev.txt | 1 + tests/fixtures/config.py | 10 +- tests/fixtures/joint.py | 2 +- tests/fixtures/server.py | 137 ++++++++++++++++++++++++++++ tests/fixtures/test_config.yaml | 9 +- tests/fixtures/utils.py | 17 ++++ tests/test_basic.py | 132 +++++++++++++++++++-------- 19 files changed, 539 insertions(+), 82 deletions(-) create mode 100644 ns/adapters/outbound/__init__.py create mode 100644 ns/adapters/outbound/smtp_client.py create mode 100644 ns/ports/outbound/__init__.py create mode 100644 ns/ports/outbound/smtp_client.py create mode 100644 tests/fixtures/server.py diff --git a/.devcontainer/.dev_config.yaml b/.devcontainer/.dev_config.yaml index f07f870..85959dd 100644 --- a/.devcontainer/.dev_config.yaml +++ b/.devcontainer/.dev_config.yaml @@ -1,8 +1,13 @@ # Please only mention the non-default settings here: -notification_event_topic: "notifications" -notification_event_type: "notification" +notification_event_topic: notifications +notification_event_type: notification service_instance_id: 001 kafka_servers: ["kafka:9092"] plaintext_email_template: "Dear $recipient_name,\n\n$plaintext_body\n\nWarm regards,\n\nThe GHGA Team" html_email_template: '

Dear $recipient_name,

$plaintext_body

Warm regards,

The GHGA Team

' +smtp_host: 127.0.0.1 +smtp_port: 587 +login_user: "test@test.com" +login_password: test +from_address: "test@test.com" diff --git a/config_schema.json b/config_schema.json index 76a864a..4d5b453 100644 --- a/config_schema.json +++ b/config_schema.json @@ -3,6 +3,63 @@ "description": "Modifies the orginal Settings class provided by the user", "type": "object", "properties": { + "plaintext_email_template": { + "title": "Plaintext Email Template", + "description": "The plaintext template to use for email notifications", + "env_names": [ + "ns_plaintext_email_template" + ], + "type": "string" + }, + "html_email_template": { + "title": "Html Email Template", + "description": "The HTML template to use for email notifications", + "env_names": [ + "ns_html_email_template" + ], + "type": "string" + }, + "from_address": { + "title": "From Address", + "description": "The sender's address.", + "env_names": [ + "ns_from_address" + ], + "type": "string", + "format": "email" + }, + "smtp_host": { + "title": "Smtp Host", + "description": "The mail server host to connect to", + "env_names": [ + "ns_smtp_host" + ], + "type": "string" + }, + "smtp_port": { + "title": "Smtp Port", + "description": "The port for the mail server connection", + "env_names": [ + "ns_smtp_port" + ], + "type": "integer" + }, + "login_user": { + "title": "Login User", + "description": "The login username or email", + "env_names": [ + "ns_login_user" + ], + "type": "string" + }, + "login_password": { + "title": "Login Password", + "description": "The login password", + "env_names": [ + "ns_login_password" + ], + "type": "string" + }, "notification_event_topic": { "title": "Notification Event Topic", "description": "Name of the event topic used to track notification events", @@ -51,29 +108,20 @@ "items": { "type": "string" } - }, - "plaintext_email_template": { - "title": "Plaintext Email Template", - "env_names": [ - "ns_plaintext_email_template" - ], - "type": "string" - }, - "html_email_template": { - "title": "Html Email Template", - "env_names": [ - "ns_html_email_template" - ], - "type": "string" } }, "required": [ + "plaintext_email_template", + "html_email_template", + "from_address", + "smtp_host", + "smtp_port", + "login_user", + "login_password", "notification_event_topic", "notification_event_type", "service_instance_id", - "kafka_servers", - "plaintext_email_template", - "html_email_template" + "kafka_servers" ], "additionalProperties": false } \ No newline at end of file diff --git a/example_config.yaml b/example_config.yaml index d232ee3..4bb6316 100644 --- a/example_config.yaml +++ b/example_config.yaml @@ -1,8 +1,11 @@ +from_address: test@test.com html_email_template: '

Dear $recipient_name,

$plaintext_body

Warm regards,

The GHGA Team

' kafka_servers: - kafka:9092 +login_password: test +login_user: test@test.com notification_event_topic: notifications notification_event_type: notification plaintext_email_template: 'Dear $recipient_name, @@ -17,3 +20,5 @@ plaintext_email_template: 'Dear $recipient_name, The GHGA Team' service_instance_id: '1' service_name: ns +smtp_host: 127.0.0.1 +smtp_port: 587 diff --git a/ns/adapters/inbound/akafka.py b/ns/adapters/inbound/akafka.py index f0570ec..d2f4cda 100644 --- a/ns/adapters/inbound/akafka.py +++ b/ns/adapters/inbound/akafka.py @@ -14,10 +14,14 @@ # limitations under the License. # """Event subscriber details for notification events""" +import ghga_event_schemas.pydantic_ as event_schemas +from ghga_event_schemas.validation import get_validated_payload from hexkit.custom_types import Ascii, JsonObject from hexkit.protocols.eventsub import EventSubscriberProtocol from pydantic import BaseSettings, Field +from ns.ports.inbound.notifier import NotifierPort + class EventSubTranslatorConfig(BaseSettings): """Config for the event subscriber""" @@ -37,14 +41,18 @@ class EventSubTranslatorConfig(BaseSettings): class EventSubTranslator(EventSubscriberProtocol): """A translator that can consume Notification events""" - def __init__(self, *, config: EventSubTranslatorConfig): + def __init__(self, *, config: EventSubTranslatorConfig, notifier: NotifierPort): self.topics_of_interest = [config.notification_event_topic] self.types_of_interest = [config.notification_event_type] self._config = config + self._notifier = notifier async def _send_notification(self, *, payload: JsonObject): """Validates the schema, then makes a call to the notifier with the payload""" - raise NotImplementedError() + validated_payload = get_validated_payload( + payload=payload, schema=event_schemas.Notification + ) + await self._notifier.send_notification(notification=validated_payload) async def _consume_validated( self, *, payload: JsonObject, type_: Ascii, topic: Ascii diff --git a/ns/adapters/outbound/__init__.py b/ns/adapters/outbound/__init__.py new file mode 100644 index 0000000..a25a8e1 --- /dev/null +++ b/ns/adapters/outbound/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2021 - 2023 Universität Tübingen, DKFZ, EMBL, and Universität zu Köln +# for the German Human Genome-Phenome Archive (GHGA) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# diff --git a/ns/adapters/outbound/smtp_client.py b/ns/adapters/outbound/smtp_client.py new file mode 100644 index 0000000..05d3109 --- /dev/null +++ b/ns/adapters/outbound/smtp_client.py @@ -0,0 +1,62 @@ +# Copyright 2021 - 2023 Universität Tübingen, DKFZ, EMBL, and Universität zu Köln +# for the German Human Genome-Phenome Archive (GHGA) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +"""Contains the smtp client adapter""" +import smtplib +import ssl +from email.message import EmailMessage + +from pydantic import BaseSettings, Field + +from ns.ports.outbound.smtp_client import SmtpClientPort + + +class SmtpClientConfig(BaseSettings): + """Configuration details for the SmtpClient""" + + smtp_host: str = Field(..., description="The mail server host to connect to") + smtp_port: int = Field(..., description="The port for the mail server connection") + login_user: str = Field(..., description="The login username or email") + login_password: str = Field(..., description="The login password") + + +class SmtpClient(SmtpClientPort): + """Concrete implementation of an SmtpClientPort""" + + def __init__(self, config: SmtpClientConfig, debugging: bool = False): + """Assign config, which should contain all needed info""" + self._config = config + self._debugging = debugging + + def send_email_message(self, message: EmailMessage): + # create ssl security context per Python's Security considerations + context = ssl.create_default_context() + + try: + with smtplib.SMTP(self._config.smtp_host, self._config.smtp_port) as server: + if not self._debugging: + server.starttls(context=context) + try: + server.login(self._config.login_user, self._config.login_password) + except smtplib.SMTPAuthenticationError as err: + raise self.FailedLoginError() from err + + # check for a connection + if server.noop()[0] != 250: + raise self.ConnectionError() + server.send_message(msg=message) + except smtplib.SMTPException as exc: + raise self.GeneralSmtpException(error_info=exc.args[0]) diff --git a/ns/config.py b/ns/config.py index 671f727..3561c8f 100644 --- a/ns/config.py +++ b/ns/config.py @@ -19,15 +19,12 @@ from hexkit.providers.akafka import KafkaConfig from ns.adapters.inbound.akafka import EventSubTranslatorConfig +from ns.adapters.outbound.smtp_client import SmtpClientConfig +from ns.core.notifier import NotifierConfig @config_from_yaml(prefix="ns") -class Config(KafkaConfig, EventSubTranslatorConfig): +class Config(KafkaConfig, EventSubTranslatorConfig, SmtpClientConfig, NotifierConfig): """Config parameters and their defaults.""" service_name: str = "ns" - plaintext_email_template: str - html_email_template: str - - -CONFIG = Config() diff --git a/ns/container.py b/ns/container.py index 10067ad..804835c 100644 --- a/ns/container.py +++ b/ns/container.py @@ -18,6 +18,7 @@ from hexkit.providers.akafka.provider import KafkaEventSubscriber from ns.adapters.inbound.akafka import EventSubTranslator +from ns.adapters.outbound.smtp_client import SmtpClient from ns.config import Config from ns.core.notifier import Notifier @@ -27,11 +28,16 @@ class Container(ContainerBase): config = get_configurator(Config) + # outbound translator + smtp_client = get_constructor(SmtpClient, config=config) + # domain components - notifier = get_constructor(Notifier, config=config) + notifier = get_constructor(Notifier, config=config, smtp_client=smtp_client) # inbound translators - event_sub_translator = get_constructor(EventSubTranslator, config=config) + event_sub_translator = get_constructor( + EventSubTranslator, config=config, notifier=notifier + ) # inbound providers kafka_event_subscriber = get_constructor( diff --git a/ns/core/notifier.py b/ns/core/notifier.py index bf73e23..7d6297d 100644 --- a/ns/core/notifier.py +++ b/ns/core/notifier.py @@ -14,26 +14,41 @@ # limitations under the License. # """Contains the concrete implementation of a NotifierPort""" - from email.message import EmailMessage from string import Template from ghga_event_schemas import pydantic_ as event_schemas +from pydantic import BaseSettings, EmailStr, Field -from ns.config import Config from ns.ports.inbound.notifier import NotifierPort +from ns.ports.outbound.smtp_client import SmtpClientPort + + +class NotifierConfig(BaseSettings): + """Config details for the notifier""" + + plaintext_email_template: str = Field( + ..., description="The plaintext template to use for email notifications" + ) + html_email_template: str = Field( + ..., description="The HTML template to use for email notifications" + ) + from_address: EmailStr = Field(..., description="The sender's address.") class Notifier(NotifierPort): """Implementation of the Notifier Port""" - def __init__(self, *, config: Config): - """Initialize the Notifier with configured host, port, and so on""" + def __init__(self, *, config: NotifierConfig, smtp_client: SmtpClientPort): + """Initialize the Notifier with configuration and smtp client""" self._config = config + self._smtp_client = smtp_client async def send_notification(self, *, notification: event_schemas.Notification): """Sends notifications based on the channel info provided (e.g. email addresses)""" - raise NotImplementedError + if len(notification.recipient_email) > 0: + message = self._construct_email(notification=notification) + self._smtp_client.send_email_message(message) def _construct_email( self, *, notification: event_schemas.Notification @@ -44,6 +59,7 @@ def _construct_email( message["Cc"] = notification.email_cc message["Bcc"] = notification.email_bcc message["Subject"] = notification.subject + message["From"] = self._config.from_address payload_as_dict = notification.dict() @@ -54,7 +70,9 @@ def _construct_email( except KeyError as err: raise self.VariableNotSuppliedError(variable=err.args[0]) from err except ValueError as err: - raise self.BadTemplateFormat(problem=err.args[0]) from err + raise self.BadTemplateFormat( + template_type="plaintext", problem=err.args[0] + ) from err message.set_content(plaintext_email) @@ -66,7 +84,9 @@ def _construct_email( except KeyError as err: raise self.VariableNotSuppliedError(variable=err.args[0]) from err except ValueError as err: - raise self.BadTemplateFormat(problem=err.args[0]) from err + raise self.BadTemplateFormat( + template_type="html", problem=err.args[0] + ) from err # add the html version to the EmailMessage object message.add_alternative(html_email, subtype="html") diff --git a/ns/ports/inbound/notifier.py b/ns/ports/inbound/notifier.py index 65c3c97..ecfc6ad 100644 --- a/ns/ports/inbound/notifier.py +++ b/ns/ports/inbound/notifier.py @@ -12,7 +12,7 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -# + """Contains a port for the notifier""" from abc import ABC, abstractmethod @@ -23,17 +23,17 @@ class NotifierPort(ABC): """Describes a notifier service in basic detail""" class VariableNotSuppliedError(KeyError): - """Raised when an html template contains a variable that isn't supplied""" + """Raised when a template references a variable that isn't supplied""" def __init__(self, *, variable: str): message = f"Nothing supplied for template variable {variable}" super().__init__(message) class BadTemplateFormat(ValueError): - """Raised when the html template contains improperly formatted content""" + """Raised when the template contains improperly formatted content""" - def __init__(self, *, problem: str): - message = f"Problem with HTML template: {problem}" + def __init__(self, *, template_type: str, problem: str): + message = f"Problem with the {template_type} template: {problem}" super().__init__(message) @abstractmethod diff --git a/ns/ports/outbound/__init__.py b/ns/ports/outbound/__init__.py new file mode 100644 index 0000000..a25a8e1 --- /dev/null +++ b/ns/ports/outbound/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2021 - 2023 Universität Tübingen, DKFZ, EMBL, and Universität zu Köln +# for the German Human Genome-Phenome Archive (GHGA) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# diff --git a/ns/ports/outbound/smtp_client.py b/ns/ports/outbound/smtp_client.py new file mode 100644 index 0000000..86a5eb5 --- /dev/null +++ b/ns/ports/outbound/smtp_client.py @@ -0,0 +1,50 @@ +# Copyright 2021 - 2023 Universität Tübingen, DKFZ, EMBL, and Universität zu Köln +# for the German Human Genome-Phenome Archive (GHGA) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Contains the smtp client port""" +from abc import ABC, abstractmethod +from email.message import EmailMessage + + +class SmtpClientPort(ABC): + """Abstract description of an SMTP client that can send email""" + + class ConnectionError(RuntimeError): + """To be raised when testing the connection fails""" + + def __init__(self): + message = "Did not receive 250 status from connection test check." + super().__init__(message) + + class FailedLoginError(RuntimeError): + """Raised when we fail to log in""" + + def __init__(self): + message = "Failed to log in." + super().__init__(message) + + class GeneralSmtpException(Exception): + """Raised by other errors (not failed connection or failed login)""" + + def __init__(self, error_info: str): + message = ( + f"Encountered an issue while attempting to send email: {error_info}" + ) + super().__init__(message) + + @abstractmethod + def send_email_message(self, message: EmailMessage): + """Sends an email message""" + ... diff --git a/requirements-dev.txt b/requirements-dev.txt index c022918..cb4d2e3 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -4,3 +4,4 @@ # additional requirements can be listed here testcontainers[kafka]==3.4.1 +aiosmtpd==1.4.4.post2 diff --git a/tests/fixtures/config.py b/tests/fixtures/config.py index 0e49d74..b6caad1 100644 --- a/tests/fixtures/config.py +++ b/tests/fixtures/config.py @@ -20,8 +20,9 @@ from pydantic.env_settings import BaseSettings +from ns.adapters.outbound.smtp_client import SmtpClientConfig from ns.config import Config -from tests.fixtures.utils import BASE_DIR +from tests.fixtures.utils import BASE_DIR, get_free_port TEST_CONFIG_YAML = BASE_DIR / "test_config.yaml" @@ -41,4 +42,9 @@ def get_config( return Config(config_yaml=default_config_yaml, **sources_dict) # type: ignore -DEFAULT_CONFIG = get_config() +SMTP_TEST_CONFIG = SmtpClientConfig( + smtp_host="127.0.0.1", + smtp_port=get_free_port(), + login_user="test@example.com", + login_password="test123", +) diff --git a/tests/fixtures/joint.py b/tests/fixtures/joint.py index b6b9d8a..1a420ac 100644 --- a/tests/fixtures/joint.py +++ b/tests/fixtures/joint.py @@ -39,7 +39,7 @@ class JointFixture: async def joint_fixture( kafka_fixture: KafkaFixture, # noqa: F811 ) -> AsyncGenerator[JointFixture, None]: - """A fixture that embeds all other fixtures for API-level integration testing""" + """A fixture that embeds all other fixtures for integration testing""" # merge configs from different sources with the default one: config = get_config(sources=[kafka_fixture.config]) diff --git a/tests/fixtures/server.py b/tests/fixtures/server.py new file mode 100644 index 0000000..1c9a884 --- /dev/null +++ b/tests/fixtures/server.py @@ -0,0 +1,137 @@ +# Copyright 2021 - 2023 Universität Tübingen, DKFZ, EMBL, and Universität zu Köln +# for the German Human Genome-Phenome Archive (GHGA) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +"""Contains a limited test server and testing functionality for local email verification.""" +from contextlib import asynccontextmanager +from email import message_from_bytes +from email.message import EmailMessage + +from aiosmtpd.controller import Controller +from aiosmtpd.handlers import Sink +from aiosmtpd.smtp import AuthResult, Envelope + +from ns.config import Config + + +class Authenticator: + """Basic authenticator so we can test error handling for failed authentication""" + + def __init__(self, user: str, password: str): + self._user = user + self._password = password + + def __call__(self, server, session, envelope, mechanism, auth_data): + login = str(auth_data.login, encoding="utf-8") + password = str(auth_data.password, encoding="utf-8") + + if login == self._user and password == self._password: + return AuthResult(success=True) + + return AuthResult(success=False, handled=False) + + +class CustomHandler(Sink): + """Single use handler""" + + def __init__(self): + self.email_received: Envelope + super().__init__() + + async def handle_DATA(self, server, session, envelope): + """Handler function for email message which closes controller upon use""" + self.email_received = envelope + + return "250 Ok" + + +def check_emails(received: Envelope, expected: EmailMessage): + """Compares two emails""" + message_received = message_from_bytes(received.content) # type: ignore + assert message_received["To"] == expected["To"] + assert message_received["Cc"] == expected["Cc"] + assert message_received["From"] == expected["From"] + assert message_received["Subject"] == expected["Subject"] + assert message_received.preamble == expected.preamble + assert ( + message_received.get_content_disposition() == expected.get_content_disposition() + ) + assert expected.is_multipart() == message_received.is_multipart() + expected_payload = expected.get_payload() + received_payload = message_received.get_payload() + # I don't think we'll have an issue with non-unique content types. + # If we do, you could look for something like the content-id as a UID + for part in expected_payload: + content_type = part.get_content_type() + for corresponding in received_payload: + if corresponding.get_content_type() == content_type: + assert part.as_bytes() == corresponding.as_bytes() + + +class EmailRecorder: + """Listens for one email""" + + def __init__(self, *, expected_email: EmailMessage, controller: Controller): + self._expected_email = expected_email + self._controller = controller + + async def __aenter__(self): + try: + self._controller.start() + + except RuntimeError as err: + self._controller.stop() + raise RuntimeError(err.args[0]) from err + + async def __aexit__(self, *args): + if self._controller.loop.is_running(): + self._controller.stop() + + +class DummyServer: + """Test server for making sure emails are received as intended""" + + def __init__(self, *, config: Config): + """assign config""" + self._config = config + self.login = self._config.login_user + self.password = self._config.login_password + + def _record_email( + self, *, expected_email: EmailMessage, controller: Controller + ) -> EmailRecorder: + """Returns an async context manager with custom controller/handler""" + return EmailRecorder(expected_email=expected_email, controller=controller) + + @asynccontextmanager + async def expect_email(self, expected_email: EmailMessage): + """Yields an async context manager with a single-use SMTP message handler, + and compares the received message envelope with the original EmailMessage""" + handler = CustomHandler() + controller = Controller( + handler, + self._config.smtp_host, + self._config.smtp_port, + auth_require_tls=False, + authenticator=Authenticator(self.login, self.password), + ) + + async with self._record_email( + expected_email=expected_email, controller=controller + ) as email_recorder: + yield email_recorder + + check_emails(received=handler.email_received, expected=expected_email) + if controller.loop.is_running(): + controller.stop() diff --git a/tests/fixtures/test_config.yaml b/tests/fixtures/test_config.yaml index 2c23dac..452e76e 100644 --- a/tests/fixtures/test_config.yaml +++ b/tests/fixtures/test_config.yaml @@ -14,9 +14,14 @@ # limitations under the License. # -notification_event_topic: "notifications" -notification_event_type: "notification" +notification_event_topic: notifications +notification_event_type: notification service_instance_id: 001 kafka_servers: ["kafka:9092"] plaintext_email_template: "Dear $recipient_name,\n\n$plaintext_body\n\nWarm regards,\n\nThe GHGA Team" html_email_template: '

Dear $recipient_name,

$plaintext_body

Warm regards,

The GHGA Team

' +smtp_host: 127.0.0.1 +smtp_port: 587 +login_user: "test@test.com" +login_password: test +from_address: "test@test.com" diff --git a/tests/fixtures/utils.py b/tests/fixtures/utils.py index cbed094..231ef5b 100644 --- a/tests/fixtures/utils.py +++ b/tests/fixtures/utils.py @@ -15,6 +15,23 @@ """Utils for Fixture handling""" +import socket from pathlib import Path +from ghga_event_schemas import pydantic_ as event_schemas +from ghga_event_schemas.validation import get_validated_payload +from hexkit.custom_types import JsonObject + BASE_DIR = Path(__file__).parent.resolve() + + +def make_notification(payload: JsonObject): + """Convenience method to produce a validated notification object""" + return get_validated_payload(payload=payload, schema=event_schemas.Notification) + + +def get_free_port() -> int: + """Finds and returns a free port on localhost.""" + sock = socket.socket() + sock.bind(("", 0)) + return int(sock.getsockname()[1]) diff --git a/tests/test_basic.py b/tests/test_basic.py index 4004e1a..4a5df9c 100644 --- a/tests/test_basic.py +++ b/tests/test_basic.py @@ -14,63 +14,52 @@ # limitations under the License. """Test basic event consumption""" +import asyncio + import pytest -from ghga_event_schemas import pydantic_ as event_schemas from hexkit.providers.akafka.testutils import kafka_fixture # noqa: F401 +from ns.adapters.outbound.smtp_client import SmtpClient from ns.core.notifier import Notifier -from tests.fixtures.config import get_config +from tests.fixtures.config import SMTP_TEST_CONFIG, get_config from tests.fixtures.joint import JointFixture, joint_fixture # noqa: F401 +from tests.fixtures.server import DummyServer +from tests.fixtures.utils import make_notification - -@pytest.mark.asyncio -async def test_basic_path(joint_fixture: JointFixture): # noqa: F811 - """Verify that the event is correctly translated into a basic email object""" - await joint_fixture.kafka.publish_event( - payload={ - "recipient_email": "test@example.com", - "email_cc": [], - "email_bcc": [], - "subject": "Test123", - "recipient_name": "Yolanda Martinez", - "plaintext_body": "Where are you, where are you, Yolanda?", - }, - type_=joint_fixture.config.notification_event_type, - topic=joint_fixture.config.notification_event_topic, - ) - - event_subscriber = await joint_fixture.container.kafka_event_subscriber() - with pytest.raises(NotImplementedError): - await event_subscriber.run(forever=False) +sample_notification = { + "recipient_email": "test@example.com", + "email_cc": ["test2@test.com", "test3@test.com"], + "email_bcc": ["test4@test.com", "test5@test.com"], + "subject": "Test123", + "recipient_name": "Yolanda Martinez", + "plaintext_body": "Where are you, where are you, Yolanda?", +} -@pytest.mark.asyncio -async def test_email_construction(): +@pytest.mark.parametrize( + "notification_details", + [sample_notification], +) +def test_email_construction(notification_details): """Verify that the email is getting constructed properly from the template.""" - notification = event_schemas.Notification( - recipient_email="test@example.com", - email_cc=[], - email_bcc=[], - subject="Test123", - recipient_name="Yolanda Martinez", - plaintext_body="Where are you, where are you, Yolanda?", - ) - - notifier = Notifier(config=get_config()) + config = get_config([SMTP_TEST_CONFIG]) + notification = make_notification(notification_details) + smtp_client = SmtpClient(config=config) + notifier = Notifier(config=config, smtp_client=smtp_client) msg = notifier._construct_email( notification=notification ) # pylint: disable=protected-access assert msg is not None - plaintext_body = msg.get_body(preferencelist=("plain")) + plaintext_body = msg.get_body(preferencelist="plain") assert plaintext_body is not None plaintext_content = plaintext_body.get_content() # type: ignore[attr-defined] expected_plaintext = "Dear Yolanda Martinez,\n\nWhere are you, where are you, Yolanda?\n\nWarm regards,\n\nThe GHGA Team" assert plaintext_content.strip() == expected_plaintext - html_body = msg.get_body(preferencelist=("html")) + html_body = msg.get_body(preferencelist="html") assert html_body is not None html_content = html_body.get_content() # type: ignore[attr-defined] @@ -78,3 +67,74 @@ async def test_email_construction(): expected_html = '

Dear Yolanda Martinez,

Where are you, where are you, Yolanda?

Warm regards,

The GHGA Team

' assert html_content.strip() == expected_html + + +@pytest.mark.parametrize( + "notification_details", + [sample_notification], +) +@pytest.mark.asyncio +async def test_transmission(notification_details): + """Test that the email that the test server gets is what we expect""" + config = get_config([SMTP_TEST_CONFIG]) + notification = make_notification(notification_details) + + smtp_client = SmtpClient(config=config, debugging=True) + server = DummyServer(config=config) + + notifier = Notifier(config=config, smtp_client=smtp_client) + + # send the notification so it gets intercepted by the dummy client + expected_email = notifier._construct_email( + notification=notification + ) # pylint: disable=protected-access + + # tell the smtp client to send the message and compare that with what is received + async with server.expect_email(expected_email=expected_email): + await notifier.send_notification(notification=notification) + asyncio.get_running_loop().stop() + + +@pytest.mark.asyncio +async def test_failed_authentication(): + """Change login credentials so authentication fails.""" + config = get_config([SMTP_TEST_CONFIG]) + server = DummyServer(config=config) + server.login = "bob@bobswebsite.com" + server.password = "notCorrect" + notification = make_notification(sample_notification) + smtp_client = SmtpClient(config=config, debugging=True) + notifier = Notifier(config=config, smtp_client=smtp_client) + expected_email = notifier._construct_email( + notification=notification + ) # pylint: disable=protected-access + + # send the notification so it gets intercepted by the dummy client + with pytest.raises(SmtpClient.FailedLoginError): + async with server.expect_email(expected_email=expected_email): + await notifier.send_notification(notification=notification) + asyncio.get_running_loop().stop() + + +@pytest.mark.asyncio +async def test_consume_thru_send(joint_fixture: JointFixture): # noqa: F811 + """Verify that the event is correctly translated into a basic email object""" + await joint_fixture.kafka.publish_event( + payload={ + "recipient_email": "test@example.com", + "email_cc": [], + "email_bcc": [], + "subject": "Test123", + "recipient_name": "Yolanda Martinez", + "plaintext_body": "Where are you, where are you, Yolanda?", + }, + type_=joint_fixture.config.notification_event_type, + topic=joint_fixture.config.notification_event_topic, + ) + + event_subscriber = await joint_fixture.container.kafka_event_subscriber() + with pytest.raises(ConnectionRefusedError): + # the connection error tells us that the smtp_client tried to connect, which + # means that the consumer successfully passed the event through the notifier + # and on to the client for emailing. + await event_subscriber.run(forever=False)