Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .github/workflows/automated-sonarqube-cloud-analysis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
run: |
make env

- name: Test with pytest
- name: Run tests with coverage
run: |
make test-coverage
cd ./lambdas
Expand All @@ -44,5 +44,3 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}

#
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@ lambdas/build
**/**/.terraform.*
venv/
__pycache__

.vscode/*
15 changes: 15 additions & 0 deletions lambdas/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# lambdas/tests/conftest.py
import os
import pytest

@pytest.fixture(autouse=True)
def _lambda_env(monkeypatch):
monkeypatch.setenv("AWS_DEFAULT_REGION", "eu-west-2")
monkeypatch.setenv("AWS_REGION", "eu-west-2")
monkeypatch.setenv("ENVIRONMENT", "dev")
monkeypatch.setenv("EMAIL_USER", "test-user")
@pytest.fixture(autouse=True)
def _aws_region_env(monkeypatch):
# boto3 will accept either of these
monkeypatch.setenv("AWS_DEFAULT_REGION", "eu-west-2")
monkeypatch.setenv("AWS_REGION", "eu-west-2")
145 changes: 145 additions & 0 deletions lambdas/tests/test_email_report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
from pathlib import Path
from unittest.mock import Mock

import pytest

from lambdas.email_report import main as email_report


def set_required_env(monkeypatch):
monkeypatch.setenv("EMAIL_REPORT_SENDER_EMAIL_PARAM_NAME", "/email/sender")
monkeypatch.setenv("EMAIL_REPORT_SENDER_EMAIL_KEY_PARAM_NAME", "/email/key")
monkeypatch.setenv("EMAIL_REPORT_RECIPIENT_EMAIL_PARAM_NAME", "/email/recipient")
monkeypatch.setenv("EMAIL_REPORT_RECIPIENT_INTERNAL_EMAIL_PARAM_NAME", "/email/recipient_internal")


def s3_event(bucket="my-bucket", key="reports/report.csv"):
return {"Records": [{"s3": {"bucket": {"name": bucket}, "object": {"key": key}}}]}


def report_metadata(send="true"):
return {
"technical-failures-percentage": "1.23",
"reporting-window-start-datetime": "2025-01-01T00:00:00+0000",
"reporting-window-end-datetime": "2025-01-08T00:00:00+0000",
"report-name": "weekly-transfer-report",
"config-cutoff-days": "7",
"total-technical-failures": "10",
"total-transfers": "1000",
"send-email-notification": send,
}


def mock_ssm(monkeypatch):
ssm = Mock()
ssm.get_parameter.side_effect = lambda Name, WithDecryption: {
"Parameter": {
"Value": {
"/email/sender": "sender@example.com",
"/email/key": "super-secret",
"/email/recipient": "recipient@example.com",
"/email/recipient_internal": "internal@example.com",
}[Name]
}
}
return ssm


def mock_s3(monkeypatch, *, metadata, file_bytes=b"col1,col2\n1,2\n"):
s3 = Mock()
s3.get_object.return_value = {"Metadata": metadata}

def download_file(Bucket, Key, Filename):
Path(Filename).write_bytes(file_bytes)

s3.download_file.side_effect = download_file
return s3


def mock_boto3(monkeypatch, *, ssm, s3):
def client(service):
if service == "ssm":
return ssm
if service == "s3":
return s3
raise AssertionError(f"Unexpected boto3 client: {service}")

monkeypatch.setattr(email_report.boto3, "client", client)


def mock_smtp(monkeypatch, *, explode_on_send=False):
smtp = Mock()
if explode_on_send:
smtp.sendmail.side_effect = RuntimeError("SMTP send failure")

monkeypatch.setattr(email_report.smtplib, "SMTP", lambda host, port: smtp)
return smtp

@pytest.mark.parametrize("val", ["true", "TRUE", "TrUe"])
def test_should_send_email_notification_true(val):
assert email_report._should_send_email_notification(report_metadata(val)) is True


@pytest.mark.parametrize("val", ["false", "FALSE", "FaLsE"])
def test_should_send_email_notification_false(val):
assert email_report._should_send_email_notification(report_metadata(val)) is False


def test_construct_subject_contains_dates_and_cutoff():
subject = email_report._construct_email_subject(report_metadata("true"))
assert "GP2GP Report:" in subject
assert "weekly-transfer-report" in subject
assert "Cutoff days: 7" in subject


def test_lambda_handler_sends_two_emails(monkeypatch):
set_required_env(monkeypatch)

ssm = mock_ssm(monkeypatch)
s3 = mock_s3(monkeypatch, metadata=report_metadata("true"))
mock_boto3(monkeypatch, ssm=ssm, s3=s3)

smtp = mock_smtp(monkeypatch)

email_report.lambda_handler(s3_event(), None)

# S3 interactions
s3.get_object.assert_called_once_with(Bucket="my-bucket", Key="reports/report.csv")
assert s3.download_file.call_count == 1

# SMTP interactions
smtp.starttls.assert_called_once()
smtp.login.assert_called_once_with("sender@example.com", "super-secret")
assert smtp.sendmail.call_count == 2

# recipients are internal then external
recipients = [c.args[1] for c in smtp.sendmail.call_args_list]
assert recipients == ["internal@example.com", "recipient@example.com"]


def test_lambda_handler_skips_when_notification_false(monkeypatch):
set_required_env(monkeypatch)

ssm = mock_ssm(monkeypatch)
s3 = mock_s3(monkeypatch, metadata=report_metadata("false"))
mock_boto3(monkeypatch, ssm=ssm, s3=s3)

smtp = mock_smtp(monkeypatch)

email_report.lambda_handler(s3_event(), None)

smtp.sendmail.assert_not_called()
smtp.login.assert_not_called()


def test_lambda_handler_catches_smtp_exception(monkeypatch):
set_required_env(monkeypatch)

ssm = mock_ssm(monkeypatch)
s3 = mock_s3(monkeypatch, metadata=report_metadata("true"))
mock_boto3(monkeypatch, ssm=ssm, s3=s3)

mock_smtp(monkeypatch, explode_on_send=True)

# Should not raise; handler catches Exception and returns
email_report.lambda_handler(s3_event(), None)
1 change: 0 additions & 1 deletion lambdas/tests/test_log_alerts_pipeline_error.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from lambdas.log_alerts_pipeline_error.main import create_slack_message, SsmSecretManager, send_slack_alert, lambda_handler



@pytest.fixture
def set_environment(monkeypatch):
monkeypatch.setenv("CLOUDWATCH_DASHBOARD_URL", "https:cloudwatch")
Expand Down
149 changes: 149 additions & 0 deletions lambdas/tests/test_log_alerts_technical_failures_above_threshold.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import base64
import json
import zlib
from unittest.mock import Mock

import pytest
from unittest.mock import ANY
from botocore.exceptions import ClientError

from lambdas.log_alerts_technical_failures_above_threshold import main as tf_alerts


def cw_event(message_dict: dict) -> dict:
payload = {
"logEvents": [{"message": json.dumps(message_dict)}],
}
raw = json.dumps(payload).encode("utf-8")

gz = zlib.compressobj(wbits=16 + zlib.MAX_WBITS)
compressed = gz.compress(raw) + gz.flush()

return {"awslogs": {"data": base64.b64encode(compressed).decode("utf-8")}}


def set_required_env(monkeypatch):
monkeypatch.setenv("LOG_ALERTS_TECHNICAL_FAILURES_WEBHOOK_URL_PARAM_NAME", "/webhook/daily")
monkeypatch.setenv("LOG_ALERTS_TECHNICAL_FAILURES_ABOVE_THRESHOLD_RATE_PARAM_NAME", "/threshold/rate")
monkeypatch.setenv("LOG_ALERTS_TECHNICAL_FAILURES_ABOVE_THRESHOLD_WEBHOOK_URL_PARAM_NAME", "/webhook/threshold")
monkeypatch.setenv("LOG_ALERTS_GENERAL_WEBHOOK_URL_PARAM_NAME", "/webhook/general")


def mock_ssm(monkeypatch, *, daily, threshold_rate, threshold, general):
ssm = Mock()
ssm.get_parameter.side_effect = lambda Name, WithDecryption: {
"Parameter": {
"Value": {
"/webhook/daily": daily,
"/threshold/rate": str(threshold_rate),
"/webhook/threshold": threshold,
"/webhook/general": general,
}[Name]
}
}

monkeypatch.setattr(tf_alerts.boto3, "client", lambda service: ssm)
return ssm


def test_secret_manager_get_secret():
ssm = Mock()
ssm.get_parameter.return_value = {"Parameter": {"Value": "x"}}
mgr = tf_alerts.SsmSecretManager(ssm)

assert mgr.get_secret("/a") == "x"
ssm.get_parameter.assert_called_once_with(Name="/a", WithDecryption=True)


def test_lambda_handler_below_threshold_sends_one_post(monkeypatch):
set_required_env(monkeypatch)
mock_ssm(
monkeypatch,
daily="https://example.com/daily",
threshold_rate=10,
threshold="https://example.com/threshold",
general="https://example.com/general",
)

http = Mock()
http.request.return_value = Mock(status=200, data=b"ok")
monkeypatch.setattr(tf_alerts, "http", http)

event = cw_event(
{
"percent-of-technical-failures": "5",
"total-technical-failures": "2",
"total-transfers": "40",
"reporting-window-start-datetime": "2025-01-01T00:00:00+0000",
}
)

tf_alerts.lambda_handler(event, None)

assert http.request.call_count == 1
http.request.assert_called_with(
"POST", url="https://example.com/daily", body=ANY
)


def test_lambda_handler_above_threshold_sends_three_posts(monkeypatch):
set_required_env(monkeypatch)
mock_ssm(
monkeypatch,
daily="https://example.com/daily",
threshold_rate=10,
threshold="https://example.com/threshold",
general="https://example.com/general",
)

http = Mock()
http.request.return_value = Mock(status=200, data=b"ok")
monkeypatch.setattr(tf_alerts, "http", http)

event = cw_event(
{
"percent-of-technical-failures": "15",
"total-technical-failures": "30",
"total-transfers": "200",
"reporting-window-start-datetime": "2025-01-01T00:00:00+0000",
}
)

tf_alerts.lambda_handler(event, None)

assert http.request.call_count == 3
urls = [c.kwargs["url"] for c in http.request.call_args_list]
assert urls == [
"https://example.com/daily",
"https://example.com/threshold",
"https://example.com/general",
]


def test_lambda_handler_catches_client_error(monkeypatch):
set_required_env(monkeypatch)
mock_ssm(
monkeypatch,
daily="https://example.com/daily",
threshold_rate=10,
threshold="https://example.com/threshold",
general="https://example.com/general",
)

http = Mock()
http.request.side_effect = ClientError(
error_response={"Error": {"Code": "X", "Message": "Boom"}},
operation_name="Request",
)
monkeypatch.setattr(tf_alerts, "http", http)

event = cw_event(
{
"percent-of-technical-failures": "5",
"total-technical-failures": "2",
"total-transfers": "40",
"reporting-window-start-datetime": "2025-01-01T00:00:00+0000",
}
)

tf_alerts.lambda_handler(event, None)
Loading
Loading