diff --git a/.gitignore b/.gitignore index f1074a4..6e2a0a7 100644 --- a/.gitignore +++ b/.gitignore @@ -132,4 +132,7 @@ dmypy.json .DS_Store # VSCode -.vscode/ \ No newline at end of file +.vscode/ + +# PyCharm +.idea/ \ No newline at end of file diff --git a/Makefile b/Makefile index 5fdbbc7..928344c 100644 --- a/Makefile +++ b/Makefile @@ -22,13 +22,16 @@ update: install # update all python dependencies pipenv update --dev ## ---- Test commands ---- ## -test: # run tests and print coverage report - pipenv run coverage run --source=lambdas -m pytest -vv +test: # Run tests and print a coverage report + pipenv run coverage run --source=lambdas -m pytest -vv -m "not integration" pipenv run coverage report -m coveralls: test pipenv run coverage lcov -o ./coverage/lcov.info +test-integration: + pipenv run pytest -vv -s -m "integration" + ## ---- Code quality and safety commands ### # linting commands diff --git a/README.md b/README.md index 303ea4c..c71fb96 100644 --- a/README.md +++ b/README.md @@ -29,61 +29,33 @@ Required env variables: - `WORKSPACE=dev`: env for local development, set by Terraform in AWS environments. - `SENTRY_DSN`: only needed in production. -### To verify local changes in Dev1 +### Integration Tests for Dev1 -- Ensure your aws cli is configured with credentials for the Dev1 account. -- Ensure you have the above env variables set in your .env, matching those in our Dev1 environment. -- Add the following to your .env: `LAMBDA_FUNCTION_URL=` -- Publish the lambda function: +Some minimal integration tests are provided for checking the deployed webhook handling lambda, defined as `integration` type pytest tests. These tests check the following: + * lambda function URL is operational + * `GET` and `POST` requests are received + * deployed lambda has adequate permissions to communicate with S3, StepFunctions, etc. - ```bash - make publish-dev - make update-lambda-dev - ``` +Other notes about tests: + * tests are limited to `Dev1` environment + * AWS `AWSAdministratorAccess` role credentials must be set on developer machine + * environment variable `WORKSPACE=dev` must be set + * no other environment variables are required, these are all retrieved from deployed context -#### GET request example +#### Steps to run integration tests -- Send a GET request with challenge phrase to the lambda function URL: + 1. Update docker image to ensure local changes are deployed to `Dev1`: - ```bash - pipenv run python -c "from lambdas.helpers import send_get_to_lambda_function_url; print(send_get_to_lambda_function_url('your challenge phrase'))" - ``` - - Observe output: `your challenge phrase` - -#### POST request examples - -- Send a POST request mimicking a webhook POST (not a POD or TIMDEX export job) - - ```bash - pipenv run python -c "from lambdas.helpers import send_post_to_lambda_function_url, SAMPLE_WEBHOOK_POST_BODY; print(send_post_to_lambda_function_url(SAMPLE_WEBHOOK_POST_BODY))" - ``` - - Observe output: `Webhook POST request received and validated, no action taken.` - -- Send a POST request mimicking a POD export job webhook - _Note_: sending a request that mimics a POD export JOB_END will trigger the entire POD workflow, which is fine _in Dev1 only_ for testing. - - Add the following to your .env: - - `VALID_POD_EXPORT_DATE=` Note: if it's been a while since the last POD export from Alma sandbox, there may be no files in the Dev1 S3 export bucket and you may need to run the publishing job from the sandbox. - - ```bash - pipenv run python -c "from lambdas.helpers import send_post_to_lambda_function_url, SAMPLE_POD_EXPORT_JOB_END_WEBHOOK_POST_BODY; print(send_post_to_lambda_function_url(SAMPLE_POD_EXPORT_JOB_END_WEBHOOK_POST_BODY))" - ``` - - Observe output: `Webhook POST request received and validated, PPOD pipeline initiated.` and then check the Dev1 ppod state machine logs to confirm the entire process ran! - -- Send a POST request mimicking a TIMDEX export job webhook - _Note_: sending a request that mimics a TIMDEX export JOB_END will trigger the entire TIMDEX workflow, which is fine _in Dev1 only_ for testing. - - Add the following to your .env: - - `VALID_TIMDEX_EXPORT_DATE=` Note: if it's been a while since the last TIMDEX export from Alma sandbox, there may be no files in the Dev1 S3 export bucket and you may need to run the publishing job from the sandbox. +```shell +make publish-dev +make update-lambda-dev +``` - ```bash - pipenv run python -c "from lambdas.helpers import send_post_to_lambda_function_url, SAMPLE_TIMDEX_EXPORT_JOB_END_WEBHOOK_POST_BODY; print(send_post_to_lambda_function_url(SAMPLE_TIMDEX_EXPORT_JOB_END_WEBHOOK_POST_BODY))" - ``` + 2. Run tests against deployed assets: - Observe output: `Webhook POST request received and validated, TIMDEX pipeline initiated.` and then check the Dev1 timdex state machine logs to confirm the entire process ran! +```shell +make test-integration +``` ## Running locally with Docker @@ -91,13 +63,13 @@ Note: this is only useful for validating exceptions and error states, as success -### Build the container +1. Build the container: ```bash make dist-dev ``` -### Run the default handler for the container +2. Run the default handler for the container ```bash docker run -p 9000:8080 alma-webhook-lambdas-dev:latest @@ -105,12 +77,12 @@ docker run -p 9000:8080 alma-webhook-lambdas-dev:latest Depending on what you're testing, you may need to pass `-e WORKSPACE=dev` and/or other environment variables as options to the `docker run` command. -### POST to the container +3. POST to the container ```bash curl -XPOST "http://localhost:9000/2015-03-31/functions/function/invocations" -d "{}" ``` -### Observe output +4. Observe output Running the above with no env variables passed should result in an exception. diff --git a/lambdas/helpers.py b/lambdas/helpers.py index f7a58cf..16fc6bb 100644 --- a/lambdas/helpers.py +++ b/lambdas/helpers.py @@ -5,43 +5,6 @@ import requests -SAMPLE_WEBHOOK_POST_BODY = { - "action": "JOB_END", - "job_instance": { - "name": "Not a POD export job", - }, -} - -SAMPLE_POD_EXPORT_JOB_END_WEBHOOK_POST_BODY = { - "action": "JOB_END", - "job_instance": { - "name": os.getenv("ALMA_POD_EXPORT_JOB_NAME", "PPOD Export"), - "end_time": os.getenv("VALID_POD_EXPORT_DATE", "2022-05-23"), - "status": {"value": "COMPLETED_SUCCESS"}, - "counter": [ - { - "type": {"value": "label.new.records", "desc": "New Records"}, - "value": "1", - }, - ], - }, -} - -SAMPLE_TIMDEX_EXPORT_JOB_END_WEBHOOK_POST_BODY = { - "action": "JOB_END", - "job_instance": { - "name": "Publishing Platform Job TIMDEX EXPORT to Dev1 DAILY", - "status": {"value": "COMPLETED_SUCCESS"}, - "end_time": os.getenv("VALID_TIMDEX_EXPORT_DATE", "2022-10-24"), - "counter": [ - { - "type": {"value": "label.new.records", "desc": "New Records"}, - "value": "1", - }, - ], - }, -} - def generate_signature(message_body: dict) -> str: secret = os.environ["ALMA_CHALLENGE_SECRET"] diff --git a/pyproject.toml b/pyproject.toml index e7e49cc..b04836b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,10 @@ exclude = ["tests/"] [tool.pytest.ini_options] log_level = "INFO" +markers = [ + "unit: unit tests", + "integration: integration tests" +] [tool.ruff] target-version = "py311" @@ -25,7 +29,8 @@ ignore = [ # project-specific "D100", "D103", - "D104" + "D104", + "G002" ] # allow autofix behavior for specified rules diff --git a/tests/conftest.py b/tests/conftest.py index 204036d..1279a8b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,16 +1,41 @@ +# ruff: noqa: G004 import datetime import os import urllib +from contextlib import contextmanager from importlib import reload +import boto3 import botocore.session import pytest import requests_mock +from botocore.exceptions import ClientError from botocore.stub import Stubber +import lambdas.helpers import lambdas.webhook from lambdas.webhook import lambda_handler +ORIGINAL_ENV = os.environ.copy() + + +@contextmanager +def temp_environ(environ): + """Provide a temporary environ with automatic teardown. + + Tests that use fixtures, that use this, are all invoked as part of the 'yield' step + below. Therefore, the environment is reset via 'finally' regardless of their + success / fail / error results. + """ + previous_environ = os.environ.copy() + # ruff: noqa: B003 + os.environ = environ + try: + yield + finally: + # ruff: noqa: B003 + os.environ = previous_environ + @pytest.fixture(autouse=True) def _test_env(): @@ -154,3 +179,172 @@ def stubbed_bursar_sfn_client(): with Stubber(sfn) as stubber: stubber.add_response("start_execution", expected_response, expected_params) yield sfn + + +def set_env_vars_from_deployed_lambda_configurations(): + env = os.getenv("WORKSPACE").lower() + lambda_client = boto3.client("lambda") + lambda_function_config = lambda_client.get_function_configuration( + FunctionName=f"alma-webhook-lambdas-{env}" + ) + lambda_function_env_vars = lambda_function_config["Environment"]["Variables"] + lambda_function_url = lambda_client.get_function_url_config( + FunctionName=f"alma-webhook-lambdas-{env}" + )["FunctionUrl"] + + os.environ["LAMBDA_FUNCTION_URL"] = lambda_function_url + os.environ["ALMA_CHALLENGE_SECRET"] = lambda_function_env_vars[ + "ALMA_CHALLENGE_SECRET" + ] + + +def set_env_vars_from_ssm_parameters(): + ssm_client = boto3.client("ssm") + ppod_state_machine_arn = ssm_client.get_parameter( + Name="/apps/almahook/ppod-state-machine-arn" + )["Parameter"]["Value"] + timdex_state_machine_arn = ssm_client.get_parameter( + Name="/apps/almahook/timdex-ingest-state-machine-arn" + )["Parameter"]["Value"] + + os.environ["PPOD_STATE_MACHINE_ARN"] = ppod_state_machine_arn + os.environ["TIMDEX_STATE_MACHINE_ARN"] = timdex_state_machine_arn + + +@pytest.fixture +def _set_integration_test_environ() -> None: + """Fixture to bypass the auto used fixture '_test_env' for integration tests. + + Because mocked AWS credentials are set in the auto used testing environment, this + temporarily bypasses the testing environment and uses the calling environment + (e.g. developer machine) such that AWS credentials can be used for integration tests. + + When any test that uses this fixture is finished, the testing environment is + automatically reinstated. + """ + with temp_environ(ORIGINAL_ENV): + if os.getenv("WORKSPACE") != "dev": + # ruff: noqa: TRY301, TRY002, TRY003, EM101 + raise Exception("WORKSPACE env var must be 'dev' for integration tests") + + os.environ[ + "ALMA_POD_EXPORT_JOB_NAME" + ] = "Publishing Platform Job PPOD EXPORT to Dev1" + os.environ["VALID_POD_EXPORT_DATE"] = "2023-08-15" # matches fixture date + os.environ["VALID_TIMDEX_EXPORT_DATE"] = "2023-08-15" # matches fixture date + + set_env_vars_from_deployed_lambda_configurations() + + set_env_vars_from_ssm_parameters() + + # required to allow tests to run in this contextmanager + yield + + +@pytest.fixture +def _integration_tests_s3_fixtures(_set_integration_test_environ) -> None: + """Upload integration test fixtures to S3 if they don't already exist. + + These s3 files are used by deployed assets during integration tests. This fixture + relies on _set_integration_tests_env_vars as a dependency to ensure AWS credentials + have not been clobbered by testing env vars. + """ + fixtures = [ + ( + "dev-sftp-shared", + "exlibris/pod/POD_ALMA_EXPORT_20230815_220844[016]_new.tar.gz", + ), + ( + "dev-sftp-shared", + "exlibris/timdex/TIMDEX_ALMA_EXPORT_DAILY_20230815_220844[016]_new.tar.gz", + ), + ] + s3 = boto3.client("s3") + for bucket, key in fixtures: + try: + s3.head_object(Bucket=bucket, Key=key + "foo") + except ClientError as e: + error_code = int(e.response["Error"]["Code"]) + # ruff: noqa: PLR2004 + if error_code == 404: + local_file_path = os.path.join("tests", "fixtures", os.path.basename(key)) + if os.path.exists(local_file_path): + s3.upload_file(local_file_path, bucket, key) + else: + msg = f"Local file '{local_file_path}' does not exist." + raise FileNotFoundError(msg) from None + else: + raise + + +@pytest.fixture +def sample_webhook_post_body() -> dict: + return { + "action": "JOB_END", + "job_instance": { + "name": "Not a POD export job", + }, + } + + +@pytest.fixture +def sample_pod_export_job_end_webhook_post_body() -> dict: + return { + "action": "JOB_END", + "job_instance": { + "name": os.getenv("ALMA_POD_EXPORT_JOB_NAME", "PPOD Export"), + "end_time": os.getenv("VALID_POD_EXPORT_DATE", "2022-05-23"), + "status": {"value": "COMPLETED_SUCCESS"}, + "counter": [ + { + "type": {"value": "label.new.records", "desc": "New Records"}, + "value": "1", + }, + ], + }, + } + + +@pytest.fixture +def sample_timdex_export_job_end_webhook_post_body() -> dict: + return { + "action": "JOB_END", + "job_instance": { + "name": "Publishing Platform Job TIMDEX EXPORT to Dev1 DAILY", + "status": {"value": "COMPLETED_SUCCESS"}, + "end_time": os.getenv("VALID_TIMDEX_EXPORT_DATE", "2022-10-24"), + "counter": [ + { + "type": {"value": "label.new.records", "desc": "New Records"}, + "value": "1", + }, + ], + }, + } + + +def _skip_integration_tests_when_not_dev_workspace(items): + """Skip integration tests if WORKSPACE is not 'dev'.""" + allowed_test_environments = ["dev"] + for item in items: + if ( + item.get_closest_marker("integration") + and os.getenv("WORKSPACE") not in allowed_test_environments + ): + item.add_marker( + pytest.mark.skip( + reason="integration tests currently only support environments: %s" + % allowed_test_environments + ) + ) + + +def pytest_collection_modifyitems(config, items): + """Pytest hook that is run after all tests collected. + + https://docs.pytest.org/en/7.1.x/reference/reference.html#pytest.hookspec.pytest_collection_modifyitems + + It is preferred that any actions needed performed by this hook will have a dedicated + function, keeping this hook runner relatively simple. + """ + _skip_integration_tests_when_not_dev_workspace(items) diff --git a/tests/fixtures/POD_ALMA_EXPORT_20230815_220844[016]_new.tar.gz b/tests/fixtures/POD_ALMA_EXPORT_20230815_220844[016]_new.tar.gz new file mode 100644 index 0000000..66dce40 Binary files /dev/null and b/tests/fixtures/POD_ALMA_EXPORT_20230815_220844[016]_new.tar.gz differ diff --git a/tests/fixtures/TIMDEX_ALMA_EXPORT_DAILY_20230815_220844[016]_new.tar.gz b/tests/fixtures/TIMDEX_ALMA_EXPORT_DAILY_20230815_220844[016]_new.tar.gz new file mode 100644 index 0000000..66dce40 Binary files /dev/null and b/tests/fixtures/TIMDEX_ALMA_EXPORT_DAILY_20230815_220844[016]_new.tar.gz differ diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/test_webhook.py b/tests/integration/test_webhook.py new file mode 100644 index 0000000..2d96b2e --- /dev/null +++ b/tests/integration/test_webhook.py @@ -0,0 +1,137 @@ +"""tests/integration/test_webhook.py. + +NOTE: see conftest.pytest_collection_modifyitems for restrictions on when these + pytest.mark.integration tests are allowed to run + +Required .env variables: + - WORKSPACE=dev +""" +# ruff: noqa: TRY002, D415, TRY003, EM102 + +import json +import os +import time + +import boto3 +import pytest + +from lambdas.helpers import ( + send_get_to_lambda_function_url, + send_post_to_lambda_function_url, +) + + +def get_step_function_invocation_results( + step_function_arn: str, + timeout: int = 15, +) -> dict: + """Retrieve results from RUNNING StepFunction invocation and parse 0th event.""" + sfn_client = boto3.client("stepfunctions") + t0 = time.time() + while True: + # timeout + if time.time() - t0 > timeout: + raise Exception( + f"Timeout of {timeout}s exceeded, could not identify running StepFunction" + ) + + # get all running executions + running_executions = sfn_client.list_executions( + stateMachineArn=step_function_arn, statusFilter="RUNNING" + )["executions"] + + if len(running_executions) == 1: + execution = running_executions[0] + execution_results = sfn_client.get_execution_history( + executionArn=execution["executionArn"] + ) + return json.loads( + execution_results["events"][0]["executionStartedEventDetails"]["input"] + ) + + if len(running_executions) > 1: + raise Exception( + f"Found {len(running_executions)} RUNNING executions, cannot reliably " + f"determine if invoked by this webhook event." + ) + + time.sleep(0.5) + + +@pytest.mark.integration() +@pytest.mark.usefixtures("_set_integration_test_environ") +def test_integration_webhook_handles_get_request_success(): + """Test that deployed lambda can receive GET requsts""" + challenge_phrase = "challenge-accepted" + response_text = send_get_to_lambda_function_url(challenge_phrase) + assert response_text == challenge_phrase + + +@pytest.mark.integration() +@pytest.mark.usefixtures("_set_integration_test_environ") +def test_integration_webhook_handles_post_request_but_no_job_type_match(): + """Test that deployed lambda can receive POST requests and skips unknown job type""" + payload = {"action": "JOB_END", "job_instance": {"name": "Unhandled Job Name Here"}} + response_text = send_post_to_lambda_function_url(payload) + assert ( + response_text == "Webhook POST request received and validated in dev env for " + "job 'Unhandled Job Name Here', no action taken." + ) + + +@pytest.mark.integration() +@pytest.mark.usefixtures("_set_integration_test_environ") +@pytest.mark.usefixtures("_integration_tests_s3_fixtures") +def test_integration_webhook_handles_pod_step_function_trigger( + sample_pod_export_job_end_webhook_post_body, +): + """Test deployed lambda handles PPOD job type webhooks and invokes step function. + + Able to assert specific values in StepFunction execution results based on fixtures. + """ + response_text = send_post_to_lambda_function_url( + sample_pod_export_job_end_webhook_post_body + ) + + # assert lambda response that StepFunction invoked + assert ( + response_text == "Webhook POST request received and validated, PPOD pipeline " + "initiated." + ) + + # assert StepFunction running and with expected payload + step_function_arn = os.getenv("PPOD_STATE_MACHINE_ARN") + step_function_input_json = get_step_function_invocation_results(step_function_arn) + assert ( + step_function_input_json["filename-prefix"] + == "exlibris/pod/POD_ALMA_EXPORT_20230815" + ) + + +@pytest.mark.integration() +@pytest.mark.usefixtures("_set_integration_test_environ") +@pytest.mark.usefixtures("_integration_tests_s3_fixtures") +def test_integration_webhook_handles_timdex_step_function_trigger( + sample_timdex_export_job_end_webhook_post_body, +): + """Tests deployed lambda handles TIMDEX job type webhooks and invokes step function. + + Able to assert specific values in StepFunction execution results based on fixtures. + """ + response_text = send_post_to_lambda_function_url( + sample_timdex_export_job_end_webhook_post_body + ) + + # assert lambda response that StepFunction invoked + assert ( + response_text == "Webhook POST request received and validated, TIMDEX pipeline " + "initiated." + ) + + # assert StepFunction running and with expected payload + step_function_arn = os.getenv("TIMDEX_STATE_MACHINE_ARN") + step_function_input_json = get_step_function_invocation_results(step_function_arn) + assert step_function_input_json["next-step"] == "transform" + assert step_function_input_json["run-date"] == "2023-08-15" + assert step_function_input_json["run-type"] == "daily" + assert step_function_input_json["source"] == "alma" diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 9c7dad9..92653b9 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,5 +1,4 @@ from lambdas.helpers import ( - SAMPLE_WEBHOOK_POST_BODY, generate_signature, send_get_to_lambda_function_url, send_post_to_lambda_function_url, @@ -21,9 +20,11 @@ def test_send_get_to_lambda_function_url(mocked_lambda_function_url): assert send_get_to_lambda_function_url("hello, lambda!") == "hello, lambda!" -def test_send_post_to_lambda_function_url(mocked_lambda_function_url): +def test_send_post_to_lambda_function_url( + mocked_lambda_function_url, sample_webhook_post_body +): assert ( - send_post_to_lambda_function_url(SAMPLE_WEBHOOK_POST_BODY) + send_post_to_lambda_function_url(sample_webhook_post_body) == "Webhook POST request received and validated in test env for job 'Not a POD " "export job', no action taken." )