diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index cc75fc687..bbc226dec 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -19,6 +19,10 @@ }, "node": { "version": "lts" + }, + "ghcr.io/devcontainers/features/python:1": { + "version": "3.12", + "installTools": false } }, diff --git a/.github/workflows/test-lambda-code/action.yml b/.github/workflows/test-lambda-code/action.yml index e4e72c21d..afafd73c8 100644 --- a/.github/workflows/test-lambda-code/action.yml +++ b/.github/workflows/test-lambda-code/action.yml @@ -10,7 +10,7 @@ runs: using: "composite" steps: - run: | - if [ -d tests ]; then + if [ -d tests ] && [ -f yarn.lock ]; then yarn install yarn test else diff --git a/.gitignore b/.gitignore index 586eabfd8..e920c33a9 100644 --- a/.gitignore +++ b/.gitignore @@ -57,6 +57,11 @@ tf.plan # Ignore coverage reports **/coverage/ -# Ignore IdP certificates +# Ignore IdP certificates and private keys idp/docker/*.crt -idp/docker/*.key \ No newline at end of file +idp/docker/*.key +*_private_api_key.json + +# Python +*.pyc +__pychache__ \ No newline at end of file diff --git a/lambda-code/load-testing/Makefile b/lambda-code/load-testing/Makefile new file mode 100644 index 000000000..5f2e11439 --- /dev/null +++ b/lambda-code/load-testing/Makefile @@ -0,0 +1,13 @@ +fmt: + black . + +install: + pip3 install --user -r requirements.txt + +locust: + locust -f tests/locust_test_file.py --host=https://forms-staging.cdssandbox.xyz + +.PHONY: \ + fmt \ + install \ + locust diff --git a/lambda-code/load-testing/main.py b/lambda-code/load-testing/main.py index f232b0b37..9b27820f3 100644 --- a/lambda-code/load-testing/main.py +++ b/lambda-code/load-testing/main.py @@ -1,47 +1,51 @@ import logging -import json import os +import boto3 from invokust.aws_lambda import get_lambda_runtime_info from invokust import LocustLoadTest, create_settings logging.basicConfig(level=logging.INFO) -class LoadTest(LocustLoadTest): - def getFormInfo(self): - for cls in self.env.user_classes: - cls.on_test_stop() +ssm_client = boto3.client("ssm") + + +def get_ssm_parameter(client, parameter_name): + response = client.get_parameter(Name=parameter_name, WithDecryption=True) + return response["Parameter"]["Value"] + +# Load required environment variables from AWS SSM +os.environ["FORM_ID"] = get_ssm_parameter(ssm_client, "load-testing/form-id") +os.environ["PRIVATE_API_KEY_APP_JSON"] = get_ssm_parameter( + ssm_client, "load-testing/private-api-key-app" +) +os.environ["PRIVATE_API_KEY_USER_JSON"] = get_ssm_parameter( + ssm_client, "load-testing/private-api-key-user" +) def handler(event=None, context=None): - try: - if event: - settings = create_settings(**event) - else: - settings = create_settings(from_environment=True) - if os.path.exists("/tmp/form_completion.json"): - os.remove("/tmp/form_completion.json") + # Check for required environment variables + required_env_vars = [ + "FORM_ID", + "PRIVATE_API_KEY_APP_JSON", + "PRIVATE_API_KEY_USER_JSON", + ] + for env_var in required_env_vars: + if env_var not in os.environ: + raise ValueError(f"Missing required environment variable: {env_var}") - loadtest = LoadTest(settings) + try: + settings = ( + create_settings(**event) + if event + else create_settings(from_environment=True) + ) + loadtest = LocustLoadTest(settings) loadtest.run() - except Exception as e: - logging.error("Locust exception {0}".format(repr(e))) - + logging.error("Exception running locust tests {0}".format(repr(e))) else: - loadtest.getFormInfo() locust_stats = loadtest.stats() - lambda_runtime_info = get_lambda_runtime_info(context) - loadtest_results = locust_stats.copy() - loadtest_results.update(lambda_runtime_info) - - form_input_file = open("/tmp/form_completion.json", "r") - form_input = json.load(form_input_file) - loadtest_results.update({"form_input":form_input}) - json_results = json.dumps(loadtest_results) - - ### Clean up - if os.path.exists("/tmp/form_completion.json"): - os.remove("/tmp/form_completion.json") - - return json_results \ No newline at end of file + locust_stats.update(get_lambda_runtime_info(context)) + return locust_stats diff --git a/lambda-code/load-testing/requirements.txt b/lambda-code/load-testing/requirements.txt index b7025108d..d0db3b6cb 100644 --- a/lambda-code/load-testing/requirements.txt +++ b/lambda-code/load-testing/requirements.txt @@ -1 +1,7 @@ +black==24.10.0 +boto3==1.35.39 +cryptography==43.0.1 +httpx==0.27.2 invokust==0.77 +locust==2.31.7 +PyJWT==2.9.0 \ No newline at end of file diff --git a/lambda-code/load-testing/tests/behaviours/api.py b/lambda-code/load-testing/tests/behaviours/api.py new file mode 100644 index 000000000..33a1ec72a --- /dev/null +++ b/lambda-code/load-testing/tests/behaviours/api.py @@ -0,0 +1,89 @@ +""" +Tests the API's retrieval of new and specific responses. +""" + +import os +import json + +from locust import HttpUser, task + +from utils.data_structures import EncryptedFormSubmission +from utils.form_submission_decrypter import FormSubmissionDecrypter +from utils.jwt_generator import JwtGenerator +from utils.task_set import SequentialTaskSetWithFailure + + +class RetrieveResponseBehaviour(SequentialTaskSetWithFailure): + def __init__(self, parent: HttpUser) -> None: + super().__init__(parent) + self.form_id = os.getenv("FORM_ID") + self.form_decrypted_submissions = {} + self.form_new_submissions = None + self.headers = None + self.jwt_user = None + + def on_start(self) -> None: + self.jwt_user = JwtGenerator.generate(self.idp_url, self.private_api_key_user) + data = { + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": self.jwt_user, + "scope": f"openid profile urn:zitadel:iam:org:project:id:{self.idp_project_id}:aud", + } + response = self.request_with_failure_check( + "post", f"{self.idp_url}/oauth/v2/token", 200, data=data + ) + self.headers = { + "Authorization": f"Bearer {response["access_token"]}", + "Content-Type": "application/json", + } + + @task + def get_new_submissions(self) -> None: + self.form_new_submissions = self.request_with_failure_check( + "get", + f"{self.api_url}/forms/{self.form_id}/submission/new", + 200, + headers=self.headers, + name=f"/forms/submission/new", + ) + + @task + def get_form_template(self) -> None: + self.request_with_failure_check( + "get", + f"{self.api_url}/forms/{self.form_id}/template", + 200, + headers=self.headers, + name=f"/forms/template", + ) + + @task + def get_submission_by_name(self) -> None: + submission = self.form_new_submissions.pop() + response = self.request_with_failure_check( + "get", + f"{self.api_url}/forms/{self.form_id}/submission/{submission["name"]}", + 200, + headers=self.headers, + name=f"/forms/submission/retrieve", + ) + encrypted_submission = EncryptedFormSubmission.from_json(response) + decrypted_submission = FormSubmissionDecrypter.decrypt( + encrypted_submission, self.private_api_key_user + ) + self.form_decrypted_submissions[submission["name"]] = json.loads( + decrypted_submission + ) + + @task + def confirm_submission(self) -> None: + submission = self.form_decrypted_submissions.popitem() + submission_name = submission[0] + submission_data = submission[1] + self.request_with_failure_check( + "put", + f"{self.api_url}/forms/{self.form_id}/submission/{submission_name}/confirm/{submission_data['confirmationCode']}", + 200, + headers=self.headers, + name=f"/forms/submission/confirm", + ) diff --git a/lambda-code/load-testing/tests/behaviours/idp.py b/lambda-code/load-testing/tests/behaviours/idp.py new file mode 100644 index 000000000..999c3eb5b --- /dev/null +++ b/lambda-code/load-testing/tests/behaviours/idp.py @@ -0,0 +1,42 @@ +""" +Tests the IdP's access token generation and introspection endpoints. +""" + +from locust import HttpUser, task +from utils.jwt_generator import JwtGenerator +from utils.task_set import SequentialTaskSetWithFailure + + +class AccessTokenBehaviour(SequentialTaskSetWithFailure): + def __init__(self, parent: HttpUser) -> None: + super().__init__(parent) + self.jwt_app = None + self.jwt_user = None + self.access_token = None + + def on_start(self) -> None: + self.jwt_app = JwtGenerator.generate(self.idp_url, self.private_api_key_app) + self.jwt_user = JwtGenerator.generate(self.idp_url, self.private_api_key_user) + + @task + def request_access_token(self) -> None: + data = { + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": self.jwt_user, + "scope": f"openid profile urn:zitadel:iam:org:project:id:{self.idp_project_id}:aud", + } + response = self.request_with_failure_check( + "post", f"{self.idp_url}/oauth/v2/token", 200, data=data + ) + self.access_token = response["access_token"] + + @task + def introspect_access_token(self) -> None: + data = { + "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + "client_assertion": self.jwt_app, + "token": self.access_token, + } + self.request_with_failure_check( + "post", f"{self.idp_url}/oauth/v2/introspect", 200, data=data + ) diff --git a/lambda-code/load-testing/tests/behaviours/submit.py b/lambda-code/load-testing/tests/behaviours/submit.py new file mode 100644 index 000000000..bcc249e6f --- /dev/null +++ b/lambda-code/load-testing/tests/behaviours/submit.py @@ -0,0 +1,54 @@ +""" +Tests the Form submission flow through the reliability queue +""" + +import os + +from locust import HttpUser, task + +from utils.form_submission_generator import FormSubmissionGenerator +from utils.jwt_generator import JwtGenerator +from utils.task_set import SequentialTaskSetWithFailure + + +class FormSubmitBehaviour(SequentialTaskSetWithFailure): + def __init__(self, parent: HttpUser) -> None: + super().__init__(parent) + self.access_token = None + self.jwt_user = None + self.form_id = os.getenv("FORM_ID") + self.form_template = None + self.form_submission_generator = None + + def on_start(self) -> None: + self.jwt_user = JwtGenerator.generate(self.idp_url, self.private_api_key_user) + data = { + "grant_type": "urn:ietf:params:oauth:grant-type:jwt-bearer", + "assertion": self.jwt_user, + "scope": f"openid profile urn:zitadel:iam:org:project:id:{self.idp_project_id}:aud", + } + response = self.request_with_failure_check( + "post", f"{self.idp_url}/oauth/v2/token", 200, data=data + ) + self.access_token = response["access_token"] + headers = { + "Authorization": f"Bearer {self.access_token}", + "Content-Type": "application/json", + } + self.form_template = self.request_with_failure_check( + "get", + f"{self.api_url}/forms/{self.form_id}/template", + 200, + headers=headers, + name=f"/forms/template", + ) + self.form_submission_generator = FormSubmissionGenerator( + self.form_id, self.form_template + ) + + @task + def submit_response(self) -> None: + with self.parent.environment.events.request.measure( + "task", "/forms/submit" + ) as request_meta: + self.form_submission_generator.submit_response() diff --git a/lambda-code/load-testing/tests/locust_test_file.py b/lambda-code/load-testing/tests/locust_test_file.py index fd606fc51..63272fe67 100644 --- a/lambda-code/load-testing/tests/locust_test_file.py +++ b/lambda-code/load-testing/tests/locust_test_file.py @@ -1,116 +1,23 @@ -import logging -import random -import uuid -import json -from json import JSONDecodeError -from locust import HttpUser, task, between, events +from locust import HttpUser, between -logging.basicConfig(level=logging.INFO) +from behaviours.api import RetrieveResponseBehaviour +from behaviours.idp import AccessTokenBehaviour +from behaviours.submit import FormSubmitBehaviour -formIDs = ["28","29","30","31"] -formSubmissions ={ - "28":{ - "2": "Performance Testing", - "3": "performance.testing@cds-snc.ca", - "4": "Ontario", - "formID": "28" - }, - "29":{ - "2": "Performance Testing", - "3": "performance.testing@cds-snc.ca", - "4": "Alberta", - "formID": "29" - }, - "30":{ - "2": "Performance Testing", - "3": "performance.testing@cds-snc.ca", - "4": "New Brunswick", - "formID":"30" - }, - "31":{ - "2": "Performance Testing", - "3": "performance.testing@cds-snc.ca", - "4": "British Columbia", - "formID": "31" - } -} +class ApiUser(HttpUser): + tasks = [RetrieveResponseBehaviour] + wait_time = between(1, 5) + weight = 6 +class FormSubmitUser(HttpUser): + tasks = [FormSubmitBehaviour] + wait_time = between(1, 5) + weight = 2 -class FormUser(HttpUser): - wait_time = between(3,10) - host = "https://forms-staging.cdssandbox.xyz" - formDataSubmissions = {"success":[], "failed":[]} - - # Test 1: High hit count - # Hit form builder pages and load a test form - - @classmethod - def on_test_stop(self): - output_file = open("/tmp/form_completion.json", "w") - json.dump(self.formDataSubmissions, output_file) - output_file.close() - - @task - def formFill(self): - lang = random.choice(["en", "fr"]) - # Browser form builder pages - self.client.get(f"/{lang}/form-builder") - self.client.get(f"/{lang}/form-builder/edit") - self.client.get(f"/{lang}/form-builder/preview") - self.client.get(f"/{lang}/form-builder/settings") - - # Load one of the test forms - formID = random.choice(formIDs) - self.client.get(f"/{lang}/id/{formID}") - - # TODO: determine if automated load test form submission can work around the CSRF token and recaptcha verification - # uniqueFormData = formSubmissions[formID] - # uniqueFormData["2"] = uuid.uuid4().hex - - # # Submit the form - # with self.client.post("/api/submit", json=uniqueFormData, name=f"/api/submit?{formID}", catch_response=True) as response: - # try: - - # if response.json()["received"] != True : - # self.formDataSubmissions["failed"].append(uniqueFormData["2"]) - # response.failure(f"Submission failed for formID {formID}") - # else: - # self.formDataSubmissions["success"].append(uniqueFormData["2"]) - # except JSONDecodeError: - # self.formDataSubmissions["failed"].append(uniqueFormData["2"]) - # response.failure("Response could not be decoded as JSON") - # except KeyError: - # self.formDataSubmissions["failed"].append(uniqueFormData["2"]) - # response.failure(f"Response did not have the expected receive key") - - # # Go to confirmation page - # self.client.get(f"/{lang}/id/{formID}/confirmation") - - - - # Admin Users tests: - # - # Test 1: Low hit count - # Login to Admin - # Create form (upload) - # Update form text (id/settings/) - # Delete form (id/settings) - # - # Test 2: Med hit count - # Login to Admin - # Go to Form Templates list (view-templates) - # - # Test 3: Low hit count - # Login to Admin - # Go to Feature Flags - # - # Test 4: High hit count - # Login to Admin - # Retrieve responses for form - # - # Test 5: Hight hit count - # Login to Admin - # Got to dashboard +class IdpUser(HttpUser): + tasks = [AccessTokenBehaviour] + wait_time = between(1, 5) + weight = 1 diff --git a/lambda-code/load-testing/tests/utils/data_structures.py b/lambda-code/load-testing/tests/utils/data_structures.py new file mode 100644 index 000000000..a1178d07b --- /dev/null +++ b/lambda-code/load-testing/tests/utils/data_structures.py @@ -0,0 +1,34 @@ +from dataclasses import dataclass +from enum import Enum + + +@dataclass +class PrivateApiKey: + key_id: str + key: str + user_or_client_id: str + + @staticmethod + def from_json(json_object: dict) -> "PrivateApiKey": + return PrivateApiKey( + key_id=json_object["keyId"], + key=json_object["key"], + user_or_client_id=json_object.get("userId") or json_object.get("clientId"), + ) + + +@dataclass +class EncryptedFormSubmission: + encrypted_responses: str + encrypted_key: str + encrypted_nonce: str + encrypted_auth_tag: str + + @staticmethod + def from_json(json_object: dict) -> "EncryptedFormSubmission": + return EncryptedFormSubmission( + encrypted_responses=json_object["encryptedResponses"], + encrypted_key=json_object["encryptedKey"], + encrypted_nonce=json_object["encryptedNonce"], + encrypted_auth_tag=json_object["encryptedAuthTag"], + ) diff --git a/lambda-code/load-testing/tests/utils/form_submission_decrypter.py b/lambda-code/load-testing/tests/utils/form_submission_decrypter.py new file mode 100644 index 000000000..2806fe4f4 --- /dev/null +++ b/lambda-code/load-testing/tests/utils/form_submission_decrypter.py @@ -0,0 +1,55 @@ +import base64 +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.primitives.hashes import SHA256 +from utils.data_structures import PrivateApiKey, EncryptedFormSubmission + + +class FormSubmissionDecrypter: + + def decrypt( + encrypted_submission: EncryptedFormSubmission, private_api_key: PrivateApiKey + ) -> str: + try: + private_key = serialization.load_pem_private_key( + private_api_key.key.encode(), password=None, backend=default_backend() + ) + + oaep_padding = padding.OAEP( + mgf=padding.MGF1(algorithm=SHA256()), + algorithm=SHA256(), + label=None, + ) + + decrypted_key = private_key.decrypt( + base64.b64decode(encrypted_submission.encrypted_key), + oaep_padding, + ) + + decrypted_nonce = private_key.decrypt( + base64.b64decode(encrypted_submission.encrypted_nonce), + oaep_padding, + ) + + decrypted_auth_tag = private_key.decrypt( + base64.b64decode(encrypted_submission.encrypted_auth_tag), + oaep_padding, + ) + + encrypted_data = base64.b64decode(encrypted_submission.encrypted_responses) + + cipher = Cipher( + algorithms.AES(decrypted_key), + modes.GCM(decrypted_nonce, decrypted_auth_tag), + backend=default_backend(), + ) + + decryptor = cipher.decryptor() + + decrypted_data = decryptor.update(encrypted_data) + decryptor.finalize() + + return decrypted_data.decode("utf-8") + except Exception as exception: + raise Exception("Failed to decrypt form submission") from exception diff --git a/lambda-code/load-testing/tests/utils/form_submission_generator.py b/lambda-code/load-testing/tests/utils/form_submission_generator.py new file mode 100644 index 000000000..b75b1cfac --- /dev/null +++ b/lambda-code/load-testing/tests/utils/form_submission_generator.py @@ -0,0 +1,92 @@ +import os +import json +import random +from typing import Any, Dict, List, Union +from botocore.config import Config +from boto3 import client + +Response = Union[str, List[str], int, List[Dict[str, Any]], Dict[str, Any]] +SubmissionRequestBody = Dict[int, Response] + +AWS_REGION = os.getenv("AWS_REGION", "ca-central-1") + + +class FormSubmissionGenerator: + """Generate and submit form responses to the Submission Lambda function.""" + + form_id: str = None + form_template: Dict[str, Any] = None + lambda_client = None + lipsum_words = ( + "adipisci aliquam amet consectetur dolor dolore dolorem eius est et" + "incidunt ipsum labore lorem magnam modi neque non numquam porro quaerat qui" + "quia quisquam sed sit tempora ut velit voluptatem" + ).split() + + def __init__(self, form_id: str, form_template: Dict[str, Any]) -> None: + self.form_id = form_id + self.form_template = form_template + self.lambda_client = client( + "lambda", + region_name=AWS_REGION, + config=Config(retries={"max_attempts": 10}), + ) + + def generate_response(self) -> SubmissionRequestBody: + """Generate a response based on the form template.""" + response: SubmissionRequestBody = {} + language: str = random.choice(["en", "fr"]) + + # For each question in the form template, generate a random response + for question_id in self.form_template["layout"]: + question = next( + ( + elem + for elem in self.form_template["elements"] + if elem["id"] == question_id + ), + None, + ) + if not question: + raise ValueError("Could not find question in form template") + + question_type: str = question["type"] + if question_type == "textField": + response[question_id] = self.lipsum(random.randint(5, 10)) + elif question_type in ["textArea", "richText"]: + response[question_id] = self.lipsum(random.randint(10, 20)) + elif question_type in [ + "dropdown", + "radio", + "checkbox", + "attestation", + "combobox", + ]: + choices = question["properties"]["choices"] + response[question_id] = random.choice(choices)[language] + else: + raise ValueError("Unsupported question type") + + return response + + def lipsum(self, length: int) -> str: + """Generate a random string of lorem ipsum.""" + return " ".join(random.choices(self.lipsum_words, k=length)).capitalize() + + def submit_response(self) -> None: + """Submit a response to the Lambda Submission function.""" + submission = { + "FunctionName": "Submission", + "Payload": json.dumps( + { + "formID": self.form_id, + "responses": self.generate_response(), + "language": "en", + "securityAttribute": "Protected A", + } + ).encode("utf-8"), + } + result = self.lambda_client.invoke(**submission) + payload = json.loads(result["Payload"].read().decode()) + if result.get("FunctionError") or not payload.get("status"): + raise ValueError("Submission Lambda could not process form response") diff --git a/lambda-code/load-testing/tests/utils/jwt_generator.py b/lambda-code/load-testing/tests/utils/jwt_generator.py new file mode 100644 index 000000000..f32917099 --- /dev/null +++ b/lambda-code/load-testing/tests/utils/jwt_generator.py @@ -0,0 +1,40 @@ +import jwt +import time + +from dataclasses import dataclass +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives import serialization +from utils.data_structures import PrivateApiKey + + +class JwtGenerator: + + @staticmethod + def generate( + identity_provider_url: str, + private_api_key: PrivateApiKey, + ) -> str: + try: + current_time = int(time.time()) + private_key = serialization.load_pem_private_key( + private_api_key.key.encode(), password=None, backend=default_backend() + ) + + headers = {"kid": private_api_key.key_id, "alg": "RS256"} + + claims = { + "iat": current_time, + "iss": private_api_key.user_or_client_id, + "sub": private_api_key.user_or_client_id, + "aud": identity_provider_url, + "exp": current_time + 3600, + } + + jwt_signed_token = jwt.encode( + claims, private_key, algorithm="RS256", headers=headers + ) + + return jwt_signed_token + + except Exception as exception: + raise Exception("Failed to generate signed JWT") from exception diff --git a/lambda-code/load-testing/tests/utils/task_set.py b/lambda-code/load-testing/tests/utils/task_set.py new file mode 100644 index 000000000..adb669e25 --- /dev/null +++ b/lambda-code/load-testing/tests/utils/task_set.py @@ -0,0 +1,42 @@ +import os +import json + +from urllib.parse import urlparse +from locust import SequentialTaskSet +from typing import Any, Dict + +from utils.data_structures import PrivateApiKey + + +class SequentialTaskSetWithFailure(SequentialTaskSet): + + def __init__(self, parent) -> None: + super().__init__(parent) + parsed_url = urlparse(parent.host) + self.api_url = f"{parsed_url.scheme}://api.{parsed_url.netloc}" + self.idp_url = f"{parsed_url.scheme}://auth.{parsed_url.netloc}" + self.idp_project_id = os.getenv("IDP_PROJECT_ID", "275372254274006635") + self.private_api_key_app = PrivateApiKey.from_json( + json.loads(os.getenv("PRIVATE_API_KEY_APP_JSON")) + ) + self.private_api_key_user = PrivateApiKey.from_json( + json.loads(os.getenv("PRIVATE_API_KEY_USER_JSON")) + ) + + def request_with_failure_check( + self, method: str, url: str, status_code: int, **kwargs: Dict[str, Any] + ) -> dict: + kwargs["catch_response"] = True + with self.client.request(method, url, **kwargs) as response: + if response.status_code != status_code: + response.failure( + f"Request failed: {response.status_code} {response.text}" + ) + raise ValueError( + f"Request failed: {response.status_code} {response.text}" + ) + return ( + response.json() + if "application/json" in response.headers.get("Content-Type", "") + else response.text + )