diff --git a/ENV_LIST.md b/ENV_LIST.md index 852d19f48..651c3b04b 100644 --- a/ENV_LIST.md +++ b/ENV_LIST.md @@ -3,9 +3,9 @@ The list of environment variables that can be set for this system is as follows. ## Basic Settings -| Variable Name | Required | Details | Example | Default | +| Variable Name | Required | Details | Example | Default | |-------------------------|----------|------------------------------------------|-------------------------------------------------|-----------------------------------------------------------| -| APP_ENV | False | Running environment | local / dev / live | local | +| APP_ENV | False | Running environment | local / dev / live | local | | NETWORK | False | Running network | IBET / IBETFIN | IBET | | WEB3_CHAINID | False | Blockchain network ID | 1010032 | IBET: 1500002, IBETFIN: 1010032 | | WEB3_HTTP_PROVIDER | False | Web3 provider | http://localhost:8545 | http://localhost:8545 | @@ -18,7 +18,7 @@ The list of environment variables that can be set for this system is as follows. | APP_LOGFILE | False | Output location for application logs | /some/directory | /dev/stdout (standard output) | | ACCESS_LOGFILE | False | Output location for access logs | /some/directory | /dev/stdout (standard output) | | TZ | False | Time Zone | Europe/Berlin | Asia/Tokyo | -| DEFAULT_CURRENCY | False | Default currency code | EUR | JPY | +| DEFAULT_CURRENCY | False | Default currency code | EUR | JPY | ## API Server Settings @@ -27,9 +27,9 @@ The following parameters can be set as environment variables as startup paramete See [Gunicorn's official documentation](https://docs.gunicorn.org/en/stable/run.html#commonly-used-arguments) for details. -| Variable Name | Required | Details | Default | +| Variable Name | Required | Details | Default | |----------------------------|----------|--------------------------------------------------------------------------|---------| -| WORKER_COUNT | False | The number of worker processes. | 2 | +| WORKER_COUNT | False | The number of worker processes. | 2 | | WORKER_TIMEOUT | False | Workers silent for more than this many seconds are killed and restarted. | 60 | | WORKER_MAX_REQUESTS | False | The maximum number of requests a worker will process before restarting. | 0 | | WORKER_MAX_REQUESTS_JITTER | False | The maximum jitter to add to the max_requests setting. | 0 | @@ -38,7 +38,7 @@ See [Gunicorn's official documentation](https://docs.gunicorn.org/en/stable/run. ## Settings for each use case ### Token -| Variable Name | Required | Details | Example | Default | +| Variable Name | Required | Details | Example | Default | |--------------------------------|----------|--------------------------------------------------------------------|--------------------------------------------|---------| | BOND_TOKEN_ENABLED | False | Using ibet Bond token (security token) | 0 (not using) / 1 (using) | 0 | | SHARE_TOKEN_ENABLED | False | Using ibet Share token (security token) | 0 (not using) / 1 (using) | 0 | @@ -52,19 +52,19 @@ See [Gunicorn's official documentation](https://docs.gunicorn.org/en/stable/run. | TOKEN_SHORT_TERM_CACHE_TTL | False | Token attribute data cache (Short-Term) expiration time (seconds) | 60 | 40 | ### Token Escrow -| Variable Name | Required | Details | Example | Default | +| Variable Name | Required | Details | Example | Default | |---------------------------------------------|----------|---------------------------------------------|--------------------------------------------|---------| | IBET_ESCROW_CONTRACT_ADDRESS | False | Ibet Escrow contract address | 0x0000000000000000000000000000000000000000 | -- | | IBET_SECURITY_TOKEN_ESCROW_CONTRACT_ADDRESS | False | Ibet Security Token Escrow contract address | 0x0000000000000000000000000000000000000000 | -- | ### Token DVP -| Variable Name | Required | Details | Example | Default | +| Variable Name | Required | Details | Example | Default | |---------------------------------------------|----------|---------------------------------------------|--------------------------------------------|---------| | IBET_SECURITY_TOKEN_DVP_CONTRACT_ADDRESS | False | Ibet Security Token DVP contract address | 0x0000000000000000000000000000000000000000 | -- | ### On-chain Exchange (Only for utility tokens) -| Variable Name | Required | Details | Example | Default | +| Variable Name | Required | Details | Example | Default | |---------------------------------------------|----------|--------------------------------------------------------------------------|--------------------------------------------|---------| | PAYMENT_GATEWAY_CONTRACT_ADDRESS | False | PaymentGateway contract address | 0x0000000000000000000000000000000000000000 | -- | | IBET_MEMBERSHIP_EXCHANGE_CONTRACT_ADDRESS | False | IbetExchange contract address for Membership tokens | 0x0000000000000000000000000000000000000000 | -- | @@ -72,22 +72,23 @@ See [Gunicorn's official documentation](https://docs.gunicorn.org/en/stable/run. | EXCHANGE_NOTIFICATION_ENABLED | True* | Use of exchange-related notification (*Set only if you use IbetExchange) | 0 (not using) / 1 (using) | -- | ### Blockchain Explorer -| Variable Name | Required | Details | Example | Default | +| Variable Name | Required | Details | Example | Default | |---------------------|----------|-----------------------------------------------------|---------------------------|---------| | BC_EXPLORER_ENABLED | False | Parameter for starting the Blockchain Explorer | 0 (not using) / 1 (using) | 0 | ### Email Common -| Variable Name | Required | Details | Example | Default | +| Variable Name | Required | Details | Example | Default | |---------------------------------|----------|--------------------------------------------|---------------------------------|---------| | SMTP_METHOD | False | Email sending method | 0:SMTP server, 1:Amazon SES | 0 | +| SMTP_AUTH_METHOD | False | Authentication method | 0:PASSWORD, 1:XOAUTH2 | 0 | | SMTP_SENDER_NAME | False | Sender name | | -- | | SMTP_SENDER_EMAIL | False | Sender email address | example@example.com | -- | SMTP server -| Variable Name | Required | Details | Example | Default | +| Variable Name | Required | Details | Example | Default | |-------------------------------|----------|-------------------------------------------|---------------------------------|---------| | SMTP_SERVER_HOST | False | SMTP server name | smtp.office365.com | -- | | SMTP_SERVER_PORT | False | SMTP server port | 587 | -- | @@ -96,13 +97,21 @@ SMTP server Amazon SES -| Variable Name | Required | Details | Example | Default | +| Variable Name | Required | Details | Example | Default | |---------------------|----------|-----------------|-----------|---------| | AWS_SES_REGION_NAME | False | AWS region name | us-east-1 | -- | +SMTP XOAUTH2 (Microsoft) + +| Variable Name | Required | Details | Example | Default | +|-----------------------|----------|-------------------------|---------|---------| +| SMTP_MS_TENANT_ID | True | Microsoft Entra ID (Azure AD) Tenant ID | | -- | +| SMTP_MS_CLIENT_ID | True | Microsoft Entra ID (Azure AD) Client ID | | -- | +| SMTP_MS_CLIENT_SECRET | True* | Microsoft Entra ID (Azure AD) Client Secret (*Required if Client Certificate is unused) | | -- | + Send settings -| Variable Name | Required | Details | Example | Default | +| Variable Name | Required | Details | Example | Default | |--------------------------------------------|----------|---------------------------------------------------------------------------------|-------------------------------|---------| | ALLOWED_EMAIL_DESTINATION_DOMAIN_LIST | False | Domains allowed to send email. Not set if all domains are allowed. | example.com,example.net | -- | | DISALLOWED_DESTINATION_EMAIL_ADDRESS_REGEX | False | Regular expression for destination email addresses that are not allowed to send | ^[a-zA-Z0-9_.+-]+@example.com | -- | @@ -111,6 +120,31 @@ Send settings ### Chat Webhook -| Variable Name | Required | Details | Example | Default | +| Variable Name | Required | Details | Example | Default | |-------------------|----------|------------------|--------------------------------------------------------------------------------|---------| | CHAT_WEBHOOK_URL | False | Chat webhook url | https://hooks.slack.com/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX | -- | + + +## Appendix + +### Email Configuration Matrix + +| Variable / Pattern | SMTP Password | SMTP XOAUTH2 | Amazon SES | Details | +| :--- | :---: | :---: | :---: | :--- | +| **BASE SETTINGS** | | | | | +| `SMTP_METHOD` | `0` | `0` | `1` | 0:SMTP, 1:SES | +| `SMTP_SENDER_EMAIL` | Required | Required | Required | Sender definition | +| `SMTP_SENDER_NAME` | Optional | Optional | Optional | Sender display name | +| **SMTP AUTH** | | | | | +| `SMTP_AUTH_METHOD` | `0` | `1` | - | 0:Password, 1:XOAUTH2 | +| **SMTP SERVER** | | | | | +| `SMTP_SERVER_HOST` | Required | Required | - | e.g. smtp.office365.com | +| `SMTP_SERVER_PORT` | Required | Required | - | e.g. 587 | +| `SMTP_SERVER_ENCRYPTION_METHOD`| Optional | Optional | - | 0:STARTTLS (Default), 1:SSL, 2:None | +| `SMTP_SENDER_PASSWORD` | Required | - | - | For SMTP Auth | +| **MICROSOFT OAUTH** | | | | | +| `SMTP_MS_TENANT_ID` | - | Required | - | | +| `SMTP_MS_CLIENT_ID` | - | Required | - | | +| `SMTP_MS_CLIENT_SECRET` | - | Required | - | | +| **AMAZON SES** | | | | | +| `AWS_SES_REGION_NAME` | - | - | Required | | diff --git a/app/config.py b/app/config.py index b88530f91..098167517 100644 --- a/app/config.py +++ b/app/config.py @@ -20,6 +20,7 @@ import configparser import os from email.policy import HTTP, SMTP, SMTPUTF8 +from typing import Literal from dotenv import load_dotenv @@ -358,6 +359,19 @@ # Amazon SES AWS_SES_REGION_NAME = os.environ.get("AWS_SES_REGION_NAME") +# SMTP Auth Method (0:PASSWORD, 1:XOAUTH2) +SMTP_AUTH_METHOD: int = int(os.environ.get("SMTP_AUTH_METHOD", 0)) +# Token Provider (microsoft fixed) +SMTP_AUTH_PROVIDER: Literal["microsoft"] = "microsoft" + +# Microsoft OAuth settings +# - Tenant ID: Microsoft Entra ID (Azure AD) Tenant ID +SMTP_MS_TENANT_ID: str | None = os.environ.get("SMTP_MS_TENANT_ID") +# - Client ID: Microsoft Entra ID (Azure AD) Client ID +SMTP_MS_CLIENT_ID: str | None = os.environ.get("SMTP_MS_CLIENT_ID") +# - Client Secret: Microsoft Entra ID (Azure AD) Client Secret +SMTP_MS_CLIENT_SECRET: str | None = os.environ.get("SMTP_MS_CLIENT_SECRET") + # Send settings ALLOWED_EMAIL_DESTINATION_DOMAIN_LIST = ( os.environ.get("AUTHORIZED_EMAIL_DESTINATION_DOMAIN_LIST").split(",") diff --git a/app/model/db/messaging.py b/app/model/db/messaging.py index e69c08225..47a4d09c3 100644 --- a/app/model/db/messaging.py +++ b/app/model/db/messaging.py @@ -44,7 +44,7 @@ class Mail(Base): # file name file_name: Mapped[str | None] = mapped_column(String(255), nullable=True) # file content - file_content: Mapped[str | None] = mapped_column( + file_content: Mapped[bytes | None] = mapped_column( LargeBinary().with_variant(LONGBLOB, "mysql"), nullable=True ) diff --git a/app/model/mail/mail.py b/app/model/mail/mail.py index 698586a59..438435bc6 100644 --- a/app/model/mail/mail.py +++ b/app/model/mail/mail.py @@ -17,6 +17,7 @@ SPDX-License-Identifier: Apache-2.0 """ +import base64 import mimetypes import smtplib import ssl @@ -30,6 +31,8 @@ from app.config import ( AWS_SES_REGION_NAME, + SMTP_AUTH_METHOD, + SMTP_AUTH_PROVIDER, SMTP_METHOD, SMTP_POLICY, SMTP_SENDER_EMAIL, @@ -39,6 +42,7 @@ SMTP_SERVER_HOST, SMTP_SERVER_PORT, ) +from app.model.mail.token_provider import MicrosoftTokenProvider class File: @@ -59,13 +63,20 @@ def __init__( html_content: str, file: File | None, ): + if SMTP_SENDER_EMAIL is None: + raise RuntimeError("SMTP sender email is not set.") self.sender_email = SMTP_SENDER_EMAIL self.to_email = to_email if SMTP_METHOD == 0: # SMTP server + if SMTP_SERVER_HOST is None or SMTP_SERVER_PORT is None: + raise RuntimeError("SMTP server host or port is not set.") self.server_host = SMTP_SERVER_HOST self.server_port = SMTP_SERVER_PORT - self.sender_password = SMTP_SENDER_PASSWORD + if SMTP_AUTH_METHOD == 0: # PASSWORD + self.sender_password = SMTP_SENDER_PASSWORD + elif SMTP_AUTH_METHOD == 1: # XOAUTH2 + pass elif SMTP_METHOD == 1: # Amazon SES self.aws_region_name = AWS_SES_REGION_NAME self.msg = MIMEMultipart("alternative", policy=SMTP_POLICY) @@ -94,22 +105,43 @@ def send_mail(self): if SMTP_METHOD == 0: # SMTP server # Initialize a new smtp client if SMTP_SERVER_ENCRYPTION_METHOD == 0: # STARTTLS - smtp_client = smtplib.SMTP(host=self.server_host, port=self.server_port) + smtp_client = smtplib.SMTP( + host=self.server_host, port=int(self.server_port) + ) smtp_client.ehlo() smtp_client.starttls() smtp_client.ehlo() elif SMTP_SERVER_ENCRYPTION_METHOD == 1: # SSL smtp_client = smtplib.SMTP_SSL( host=self.server_host, - port=self.server_port, + port=int(self.server_port), context=ssl.create_default_context(), ) else: # NO-ENCRYPT - smtp_client = smtplib.SMTP(host=self.server_host, port=self.server_port) - + smtp_client = smtplib.SMTP( + host=self.server_host, port=int(self.server_port) + ) # LOGIN - if self.sender_password is not None: - smtp_client.login(self.sender_email, self.sender_password) + if SMTP_AUTH_METHOD == 0: # PASSWORD + if self.sender_password is not None: + smtp_client.login(self.sender_email, self.sender_password) + elif SMTP_AUTH_METHOD == 1: # XOAUTH2 + # Get Access Token + match SMTP_AUTH_PROVIDER: + case "microsoft": + token_provider = MicrosoftTokenProvider() + case _: + raise ValueError( + f"Unknown SMTP_AUTH_PROVIDER: {SMTP_AUTH_PROVIDER}" + ) + access_token = token_provider.get_access_token() + + # Auth + auth_str = ( + f"user={self.sender_email}\x01auth=Bearer {access_token}\x01\x01" + ) + auth_b64 = base64.b64encode(auth_str.encode("utf-8")).decode("utf-8") + smtp_client.docmd("AUTH", "XOAUTH2 " + auth_b64) # Send mail try: diff --git a/app/model/mail/token_provider.py b/app/model/mail/token_provider.py new file mode 100644 index 000000000..35ba916b5 --- /dev/null +++ b/app/model/mail/token_provider.py @@ -0,0 +1,115 @@ +""" +Copyright BOOSTRY Co., Ltd. + +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. + +SPDX-License-Identifier: Apache-2.0 +""" + +import abc +import base64 +import json +import time +import uuid + +import requests +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding, rsa +from pydantic import BaseModel, ValidationError +from requests.adapters import HTTPAdapter +from urllib3 import Retry + +from app.config import ( + SMTP_MS_CLIENT_ID, + SMTP_MS_CLIENT_SECRET, + SMTP_MS_TENANT_ID, +) + + +class TokenProvider(metaclass=abc.ABCMeta): + @abc.abstractmethod + def get_access_token(self) -> str: + """ + Get access token for authentication + """ + pass + + +class MicrosoftTokenResponse(BaseModel): + """ + Schema for Microsoft Identity Platform token response + """ + + access_token: str + expires_in: int + token_type: str + + +class MicrosoftTokenProvider(TokenProvider): + """ + Provider that fetches access token from Microsoft Identity Platform + using a Refresh Token. + """ + + _access_token: str | None = None + _token_expiry: float = 0.0 + + def get_access_token(self) -> str: + # Return cached token if valid (with 60 seconds safety buffer) + if self._access_token and time.time() < self._token_expiry - 60: + return self._access_token + + tenant_id = SMTP_MS_TENANT_ID + client_id = SMTP_MS_CLIENT_ID + client_secret = SMTP_MS_CLIENT_SECRET + + if tenant_id is None or client_id is None or (client_secret is None): + raise ValueError("Missing Microsoft OAuth configuration") + + token_url = f"https://login.microsoftonline.com/{tenant_id}/oauth2/v2.0/token" + + data = { + "grant_type": "client_credentials", + "client_id": client_id, + "client_secret": client_secret, + "scope": "https://outlook.office365.com/.default", + } + + try: + with requests.Session() as session: + retries = Retry( + total=3, + backoff_factor=1, + status_forcelist=[500, 502, 503, 504], + allowed_methods=["POST"], + ) + adapter = HTTPAdapter(max_retries=retries) + session.mount("https://", adapter) + + response = session.post(token_url, data=data, timeout=10) + response.raise_for_status() + + # Validate response schema + token_data = MicrosoftTokenResponse.model_validate(response.json()) + + # Update cache + self.__class__._access_token = token_data.access_token + # Set expiry time relative to now + self.__class__._token_expiry = time.time() + token_data.expires_in + + return token_data.access_token + + except (requests.exceptions.RequestException, ValidationError) as e: + # You might want to log this error in a real app + raise RuntimeError(f"Failed to refresh access token: {str(e)}") from e diff --git a/pyproject.toml b/pyproject.toml index 13ec616a9..dc5ce561f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,6 +40,7 @@ dependencies = [ "opentelemetry-exporter-otlp-proto-grpc>=1.33.1,<2.0.0", "requests>=2.32.3", "python-dotenv~=1.1.0", + "cryptography>=45.0.3", ] [tool.uv] diff --git a/tests/app/model/mail/test_mail_unit.py b/tests/app/model/mail/test_mail_unit.py new file mode 100644 index 000000000..7fade04ac --- /dev/null +++ b/tests/app/model/mail/test_mail_unit.py @@ -0,0 +1,131 @@ +""" +Copyright BOOSTRY Co., Ltd. + +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. + +SPDX-License-Identifier: Apache-2.0 +""" + +from unittest.mock import MagicMock, patch + +from app.model.mail import Mail + + +@patch( + "app.model.mail.mail.SMTP_SENDER_EMAIL", + "test@example.com", +) +@patch( + "app.model.mail.mail.SMTP_SERVER_HOST", + "example.com", +) +@patch( + "app.model.mail.mail.SMTP_SERVER_PORT", + 587, +) +class TestMailUnit: + """ + Unit tests for app.model.mail.Mail + Mocks smtplib to verify command interactions + """ + + def test_send_mail_password_auth(self): + """ + Verify that PASSWORD auth (default) calls login() + """ + # Arrange + with ( + patch("app.model.mail.mail.SMTP_METHOD", 0), + patch("app.model.mail.mail.SMTP_AUTH_METHOD", 0), + patch("app.model.mail.mail.SMTP_SENDER_NAME", "Sender Name"), + patch("app.model.mail.mail.SMTP_SENDER_EMAIL", "sender@example.com"), + patch("smtplib.SMTP") as mock_smtp_cls, + ): + mock_smtp = MagicMock() + mock_smtp_cls.return_value = mock_smtp + + mail = Mail( + to_email="test@example.com", + subject="Test Subject", + text_content="Body", + html_content="

Body

", + file=None, + ) + mail.sender_password = "password" + + # Act + mail.send_mail() + + # Assert + mock_smtp.login.assert_called_once() + mock_smtp.docmd.assert_not_called() + mock_smtp.sendmail.assert_called_once() + mock_smtp.quit.assert_called_once() + + def test_send_mail_xoauth2_auth(self): + """ + Verify that XOAUTH2 auth calls docmd("AUTH", ...) + """ + # Arrange + with ( + patch("app.model.mail.mail.SMTP_METHOD", 0), + patch("app.model.mail.mail.SMTP_AUTH_METHOD", 1), + patch("app.model.mail.mail.SMTP_SENDER_NAME", "Sender Name"), + patch("app.model.mail.mail.SMTP_SENDER_EMAIL", "sender@example.com"), + patch("app.config.SMTP_AUTH_PROVIDER", "microsoft"), + patch("app.model.mail.mail.MicrosoftTokenProvider") as MockTokenProvider, + patch("smtplib.SMTP") as mock_smtp_cls, + ): + mock_smtp = MagicMock() + mock_smtp_cls.return_value = mock_smtp + + mock_token_provider = MagicMock() + MockTokenProvider.return_value = mock_token_provider + mock_token_provider.get_access_token.return_value = "test_token" + + mail = Mail( + to_email="test@example.com", + subject="Test Subject", + text_content="Body", + html_content="

Body

", + file=None, + ) + + # Act + mail.send_mail() + + # Assert + mock_smtp.login.assert_not_called() + + # Verify AUTH XOAUTH2 command + # Expected auth string: user=\x01auth=Bearer \x01\x01 + # Note: config.SMTP_SENDER_EMAIL is mocked/defaulted during imports, + # ideally we should patch it if we want to be strict about the content. + # Here we just check if docmd was called with AUTH + mock_smtp.docmd.assert_called_once() + args, _ = mock_smtp.docmd.call_args + assert args[0] == "AUTH" + assert args[1].startswith("XOAUTH2 ") + + # Verify basic structure of the base64 encoded part + import base64 + + sent_b64 = args[1].split(" ")[1] + decoded_auth = base64.b64decode(sent_b64).decode("utf-8") + assert "user=sender@example.com" in decoded_auth + assert "auth=Bearer test_token" in decoded_auth + assert decoded_auth.endswith("\x01\x01") + + mock_smtp.sendmail.assert_called_once() + mock_smtp.quit.assert_called_once() diff --git a/tests/app/model/mail/test_token_provider.py b/tests/app/model/mail/test_token_provider.py new file mode 100644 index 000000000..c4ddd08b5 --- /dev/null +++ b/tests/app/model/mail/test_token_provider.py @@ -0,0 +1,240 @@ +""" +Copyright BOOSTRY Co., Ltd. + +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. + +SPDX-License-Identifier: Apache-2.0 +""" + +from unittest.mock import MagicMock, patch + +import pytest +import requests +from requests.adapters import HTTPAdapter + +from app.model.mail.token_provider import MicrosoftTokenProvider + + +class TestMicrosoftTokenProvider: + """ + Unit tests for app.model.mail.token_provider.MicrosoftTokenProvider + """ + + def test_get_access_token_success(self): + """ + Verify that get_access_token correctly parses successful response + """ + # Arrange + with ( + patch("app.model.mail.token_provider.SMTP_MS_TENANT_ID", "tenant-1"), + patch("app.model.mail.token_provider.SMTP_MS_CLIENT_ID", "client-1"), + patch("app.model.mail.token_provider.SMTP_MS_CLIENT_SECRET", "secret-1"), + patch("requests.Session.post") as mock_post, + ): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "token_type": "Bearer", + "scope": "https://outlook.office365.com/.default", + "expires_in": 3599, + "ext_expires_in": 3599, + "access_token": "valid_access_token", + } + mock_post.return_value = mock_response + + provider = MicrosoftTokenProvider() + + # Act + token = provider.get_access_token() + + # Assert + assert token == "valid_access_token" + + # Verify request data + mock_post.assert_called_once() + args, kwargs = mock_post.call_args + data = kwargs["data"] + assert data["grant_type"] == "client_credentials" + assert "refresh_token" not in data + + def test_get_access_token_missing_config(self): + """ + Verify that missing config raises ValueError + """ + # Ensure cache is empty + MicrosoftTokenProvider._access_token = None + MicrosoftTokenProvider._token_expiry = 0.0 + + with patch("app.model.mail.token_provider.SMTP_MS_TENANT_ID", None): + provider = MicrosoftTokenProvider() + with pytest.raises( + ValueError, match="Missing Microsoft OAuth configuration" + ): + provider.get_access_token() + + def test_get_access_token_api_error(self): + """ + Verify that API error raises RuntimeError + """ + # Ensure cache is empty + MicrosoftTokenProvider._access_token = None + MicrosoftTokenProvider._token_expiry = 0.0 + + # Arrange + with ( + patch("app.model.mail.token_provider.SMTP_MS_TENANT_ID", "tenant-1"), + patch("app.model.mail.token_provider.SMTP_MS_CLIENT_ID", "client-1"), + patch("app.model.mail.token_provider.SMTP_MS_CLIENT_SECRET", "secret-1"), + patch("requests.Session.post") as mock_post, + ): + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.raise_for_status.side_effect = requests.exceptions.HTTPError( + "400 Bad Request" + ) + mock_post.return_value = mock_response + + provider = MicrosoftTokenProvider() + + # Act & Assert + with pytest.raises(RuntimeError, match="Failed to refresh access token"): + provider.get_access_token() + + def test_get_access_token_validation_error(self): + """ + Verify that invalid response schema raises RuntimeError (wrapping ValidationError) + """ + # Ensure cache is empty + MicrosoftTokenProvider._access_token = None + MicrosoftTokenProvider._token_expiry = 0.0 + + # Arrange + with ( + patch("app.model.mail.token_provider.SMTP_MS_TENANT_ID", "tenant-1"), + patch("app.model.mail.token_provider.SMTP_MS_CLIENT_ID", "client-1"), + patch("app.model.mail.token_provider.SMTP_MS_CLIENT_SECRET", "secret-1"), + patch("requests.Session.post") as mock_post, + ): + mock_response = MagicMock() + mock_response.status_code = 200 + # Missing expires_in (and token_type) will cause validation error + mock_response.json.return_value = {"access_token": "valid_access_token"} + mock_post.return_value = mock_response + + provider = MicrosoftTokenProvider() + + # Act & Assert + with pytest.raises(RuntimeError, match="Failed to refresh access token"): + provider.get_access_token() + + def test_get_access_token_retry_logic(self): + """ + Verify that retries are configured + """ + # Ensure cache is empty + MicrosoftTokenProvider._access_token = None + MicrosoftTokenProvider._token_expiry = 0.0 + + # Arrange + with ( + patch("app.model.mail.token_provider.SMTP_MS_TENANT_ID", "tenant-1"), + patch("app.model.mail.token_provider.SMTP_MS_CLIENT_ID", "client-1"), + patch("app.model.mail.token_provider.SMTP_MS_CLIENT_SECRET", "secret-1"), + patch("requests.Session.post") as mock_post, + patch("requests.Session.mount") as mock_mount, + ): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "access_token": "retry_token", + "expires_in": 3599, + "token_type": "Bearer", + } + mock_post.return_value = mock_response + + provider = MicrosoftTokenProvider() + provider.get_access_token() + + # Verify adapter mount + mock_mount.assert_called() + args, _ = mock_mount.call_args + adapter = args[1] + assert isinstance(adapter, HTTPAdapter) + assert adapter.max_retries.total == 3 + assert adapter.max_retries.status_forcelist == [500, 502, 503, 504] + + def test_get_access_token_cache_hit(self): + """ + Verify that cached token is returned if valid + """ + import time + + # Manually set cache + future_time = time.time() + 3600 + MicrosoftTokenProvider._access_token = "cached_token" + MicrosoftTokenProvider._token_expiry = future_time + + provider = MicrosoftTokenProvider() + + with patch("requests.Session.post") as mock_post: + token = provider.get_access_token() + + assert token == "cached_token" + mock_post.assert_not_called() + + # Cleanup + MicrosoftTokenProvider._access_token = None + MicrosoftTokenProvider._token_expiry = 0.0 + + def test_get_access_token_cache_renew(self): + """ + Verify that token is renewed if cache is expired + """ + import time + from unittest.mock import MagicMock + + # Manually set expired cache + past_time = time.time() - 3600 + MicrosoftTokenProvider._access_token = "old_token" + MicrosoftTokenProvider._token_expiry = past_time + + provider = MicrosoftTokenProvider() + + with ( + patch("app.model.mail.token_provider.SMTP_MS_TENANT_ID", "tenant-1"), + patch("app.model.mail.token_provider.SMTP_MS_CLIENT_ID", "client-1"), + patch("app.model.mail.token_provider.SMTP_MS_CLIENT_SECRET", "secret-1"), + patch("requests.Session.post") as mock_post, + ): + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + "access_token": "new_token", + "expires_in": 3599, + "token_type": "Bearer", + } + mock_post.return_value = mock_response + + token = provider.get_access_token() + + assert token == "new_token" + mock_post.assert_called_once() + + # Verify cache updated + assert MicrosoftTokenProvider._access_token == "new_token" + assert MicrosoftTokenProvider._token_expiry > time.time() + 3500 + + # Cleanup + MicrosoftTokenProvider._access_token = None + MicrosoftTokenProvider._token_expiry = 0.0 diff --git a/tests/batch/processor_Send_Mail_test.py b/tests/batch/processor_Send_Mail_test.py index 4842fc889..f98ad5033 100644 --- a/tests/batch/processor_Send_Mail_test.py +++ b/tests/batch/processor_Send_Mail_test.py @@ -47,6 +47,18 @@ def caplog(caplog: pytest.LogCaptureFixture): LOG.setLevel(default_log_level) +@mock.patch( + "app.model.mail.mail.SMTP_SENDER_EMAIL", + "test@example.com", +) +@mock.patch( + "app.model.mail.mail.SMTP_SERVER_HOST", + "example.com", +) +@mock.patch( + "app.model.mail.mail.SMTP_SERVER_PORT", + 587, +) class TestProcessorSendMail: # Normal_1 # No unsent email exists diff --git a/uv.lock b/uv.lock index c95c741b3..a38bf1f49 100644 --- a/uv.lock +++ b/uv.lock @@ -809,6 +809,7 @@ dependencies = [ { name = "asyncpg" }, { name = "boto3" }, { name = "coincurve" }, + { name = "cryptography" }, { name = "eth-keyfile" }, { name = "eth-utils" }, { name = "fastapi" }, @@ -876,6 +877,7 @@ requires-dist = [ { name = "asyncpg", specifier = "~=0.30.0" }, { name = "boto3", specifier = "~=1.37.0" }, { name = "coincurve", specifier = "~=21.0.0" }, + { name = "cryptography", specifier = ">=45.0.3" }, { name = "eth-keyfile", specifier = "~=0.9.1" }, { name = "eth-utils", specifier = "~=5.2.0" }, { name = "fastapi", specifier = "~=0.116.1" },