diff --git a/csfunctions/devserver.py b/csfunctions/devserver.py new file mode 100644 index 0000000..bacf7ef --- /dev/null +++ b/csfunctions/devserver.py @@ -0,0 +1,188 @@ +""" +The development server looks for an environment.yaml in the given directory and reads the Functions from it. +The Functions are then available via HTTP requests to the server. + +The server will automatically restart if you make changes to your Functions code or to the `environment.yaml` file. + +Usage: + +```bash +python -m csfunctions.devserver +``` + +Optional arguments: + +--dir + The directory containing the environment.yaml file. + (default: current working directory) + +--secret + The secret token to use for the development server. + +--port + The port to run the development server on. + (default: 8000) + +--no-reload + Disable auto reloading of the server. +""" + +import argparse +import hashlib +import hmac +import json +import logging +import os +import time +from collections.abc import Iterable +from wsgiref.types import StartResponse, WSGIEnvironment + +from werkzeug.serving import run_simple +from werkzeug.wrappers import Request, Response + +from csfunctions.handler import FunctionNotRegistered, execute + + +def _is_error_response(function_response: str | dict): + """ + Try to figure out if the response from the function is an error response. + This is the same implementation as in the runtime, to ensure the behavior is the same. + """ + if isinstance(function_response, str): + # function response could be a json encoded dict, so we try to decode it first + try: + function_response = json.loads(function_response) + except json.JSONDecodeError: + # response is not json decoded, so it's not an error response + return False + + if isinstance(function_response, dict): + # check if the response dict is an error response + return function_response.get("response_type") == "error" + else: + # function response is neither a dict nor json encoded dict, so can't be an error response + return False + + +def _verify_hmac_signature( + signature: str | None, timestamp: str | None, body: str, secret_token: str, max_age: int = 60 +) -> bool: + """ + Verify the HMAC signature of the request. + If timestamp is older than max_age seconds, the request is rejected. (default: 60 seconds, disable with -1) + """ + if not secret_token: + # this should not happen, since this function should only be called if a secret token is set + raise ValueError("Missing secret token") + + if not signature: + logging.warning("Request does not contain a signature") + return False + + if not timestamp: + logging.warning("Request does not contain a timestamp") + return False + + if max_age >= 0 and int(timestamp) < time.time() - max_age: + logging.warning("Timestamp of request is older than %d seconds", max_age) + return False + + return hmac.compare_digest( + signature, + hmac.new( + secret_token.encode("utf-8"), + f"{timestamp}{body}".encode(), + hashlib.sha256, + ).hexdigest(), + ) + + +def handle_request(request: Request) -> Response: + """ + Handles a request to the development server. + Extracts the function name from the request path and executes the Function using the execute handler. + """ + function_name = request.path.strip("/") + if not function_name: + return Response("No function name provided", status=400) + body = request.get_data(as_text=True) + signature = request.headers.get("X-CON-Signature-256") + timestamp = request.headers.get("X-CON-Timestamp") + + secret_token = os.environ.get("CON_DEV_SECRET", "") + if secret_token and not _verify_hmac_signature(signature, timestamp, body, secret_token): + return Response("Invalid signature", status=401) + + try: + function_dir = os.environ.get("CON_DEV_DIR", "") + logging.info("Executing function: %s", function_name) + response = execute(function_name, body, function_dir=function_dir) + except FunctionNotRegistered as e: + logging.warning("Function not found: %s", function_name) + return Response(str(e), status=404) + + if _is_error_response(response): + logging.error("Function %s returned error response", function_name) + return Response(response, status=500, content_type="application/json") + + return Response(response, content_type="application/json") + + +def application(environ: WSGIEnvironment, start_response: StartResponse) -> Iterable[bytes]: + request = Request(environ) + response = handle_request(request) + return response(environ, start_response) + + +def run_server() -> None: + port = int(os.environ.get("CON_DEV_PORT", 8000)) + if not 1 <= port <= 65535: + raise ValueError(f"Invalid port number: {port}") + + logging.info("Starting development server on port %d", port) + # B104: binding to all interfaces is intentional - this is a development server + run_simple( + "0.0.0.0", # nosec: B104 + port, + application, + use_reloader=not bool(os.environ.get("CON_DEV_NO_RELOAD")), + extra_files=[os.path.join(os.environ.get("CON_DEV_DIR", ""), "environment.yaml")], + ) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) + + parser = argparse.ArgumentParser() + parser.add_argument( + "--dir", + type=str, + help="The directory containing the environment.yaml file. (default: current working directory)", + ) + parser.add_argument( + "--secret", + type=str, + help="The secret token to use for the development server.", + ) + parser.add_argument("--port", type=int, help="The port to run the development server on. (default: 8000)") + parser.add_argument("--no-reload", action="store_true", help="Disable auto reloading of the server.") + args = parser.parse_args() + + # Command line arguments take precedence over environment variables + if args.dir: + os.environ["CON_DEV_DIR"] = args.dir + if args.secret: + os.environ["CON_DEV_SECRET"] = args.secret + if args.port: + os.environ["CON_DEV_PORT"] = str(args.port) + if args.no_reload: + os.environ["CON_DEV_NO_RELOAD"] = "1" + + if not os.environ.get("CON_DEV_SECRET"): + logging.warning( + "\033[91m\033[1mNo secret token provided, development server is not secured!" + " It is recommended to provide a secret via --secret to" + " enable HMAC validation.\033[0m" + ) + + run_server() diff --git a/csfunctions/handler.py b/csfunctions/handler.py index 4487c42..8ef8a10 100644 --- a/csfunctions/handler.py +++ b/csfunctions/handler.py @@ -2,6 +2,7 @@ import os import sys import traceback +from functools import lru_cache from importlib import import_module from typing import Callable @@ -16,7 +17,14 @@ from csfunctions.service import Service -def _load_config(function_dir) -> ConfigModel: +class FunctionNotRegistered(ValueError): + """ + Raised when a function is not found in the environment.yaml. + """ + + +@lru_cache(maxsize=1) +def load_environment_config(function_dir: str) -> ConfigModel: path = os.path.join(function_dir, "environment.yaml") if not os.path.exists(path): raise OSError(f"environment file {path} does not exist") @@ -28,10 +36,10 @@ def _load_config(function_dir) -> ConfigModel: def _get_function(function_name: str, function_dir: str) -> FunctionModel: - config = _load_config(function_dir) + config = load_environment_config(function_dir) func = next(func for func in config.functions if func.name == function_name) if not func: - raise ValueError(f"Could not find function with name {function_name} in the environment.yaml.") + raise FunctionNotRegistered(f"Could not find function with name {function_name} in the environment.yaml.") return func diff --git a/docs/assets/codespace_port_visibility.png b/docs/assets/codespace_port_visibility.png new file mode 100644 index 0000000..d194d58 Binary files /dev/null and b/docs/assets/codespace_port_visibility.png differ diff --git a/docs/development_server.md b/docs/development_server.md new file mode 100644 index 0000000..805bd47 --- /dev/null +++ b/docs/development_server.md @@ -0,0 +1,82 @@ +The Functions SDK includes a development server that allows you to run your Functions in your development environment. The server reads Functions from the `environment.yaml` file and makes them available via HTTP endpoints. You can then connect these Functions to your CIM Database Cloud instance using webhooks. + +This speeds up the development of Functions, because you can instantly test your changes, without deploying them to the cloud infrastructure first. + +## Starting the Server + +You can start the development server using the following command: + +```bash +python -m csfunctions.devserver +``` + +You can set the port of the server using the `--port` flag (default is 8000), or by setting the `CON_DEV_PORT` environment variable: + +```bash +python -m csfunctions.devserver --port 8080 +``` + +You can set the directory containing the `environment.yaml` file using the `--dir` flag (by default the current working directory is used) or by setting the `CON_DEV_DIR` environment variable: + +```bash +python -m csfunctions.devserver --dir ./my_functions +``` + +You can enable HMAC verification of requests using the `--secret` flag, or by setting the `CON_DEV_SECRET` environment variable: + +```bash +python -m csfunctions.devserver --secret my_secret +``` + +## Autoreloading + +The development server will automatically restart if you make changes to your Functions code or to the `environment.yaml` file. + +## Exposing the server + +To enable your CIM Database Cloud instance to send webhook requests to your Functions, you need to make the server accessible from the internet. Here are several ways to do this: + +**GitHub Codespaces** + +If you are developing Functions in a GitHub Codespace, you can expose the server by right-clicking on the dev server's port in the "Ports" tab and changing the visibility to "Public": + +![GitHub Codespaces](./assets/codespace_port_visibility.png) + +You can then copy the URL of the server and use it to connect your Functions to your CIM Database Cloud instance using webhooks. + +**ngrok and Cloudflare** + +If you are developing Functions locally, you can use services like [ngrok](https://ngrok.com/) or [Cloudflare](https://cloudflare.com) to expose your server to the internet. + +Please refer to the documentation of the specific service for instructions on how to do this. + + +## Create a webhook in CIM Database Cloud + +To test your Functions locally, create a webhook in your CIM Database Cloud instance and point it to your development server. + +The webhook URL should combine your development server URL with the Function name from your `environment.yaml` file using this format: + +`https:///` + +For example the `example` function would be available at: + +```https://mycodespace-5g7grjgvrv9h4jrx-8000.app.github.dev/example``` + + +Make sure to set the webhooks event to the correct event you want to test with your Function. + +For more detailed information on how to create a webhook in CIM Database Cloud, please refer to the [CIM Database Cloud documentation](https://saas-docs.contact-cloud.com/2025.7.0-en/admin/admin-contact_cloud/saas_admin/webhooks). + + +## Securing the development server + +Since the development server is exposed to the outside world, you should secure it to prevent unauthorized access. + +You can enable HMAC verification of requests using the `--secret` flag, or by setting the `CON_DEV_SECRET` environment variable: + +```bash +python -m csfunctions.devserver --secret my_secret +``` + +Make sure to use the same secret in your CIM Database Cloud instance when setting up the webhook and enable HMAC signing. diff --git a/mkdocs.yml b/mkdocs.yml index cee3628..78586a5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -31,6 +31,7 @@ nav: - Home: index.md - Key concepts: key_concepts.md - Getting started: getting_started.md + - Development server: development_server.md - Reference: - reference/events.md - reference/objects.md diff --git a/poetry.lock b/poetry.lock index cba20aa..6f9f1dd 100644 --- a/poetry.lock +++ b/poetry.lock @@ -173,6 +173,76 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "markupsafe" +version = "3.0.2" +description = "Safely add untrusted strings to HTML/XML markup." +optional = false +python-versions = ">=3.9" +files = [ + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, + {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, + {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, + {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, + {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, + {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, + {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, + {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, +] + [[package]] name = "packaging" version = "24.2" @@ -522,7 +592,24 @@ h2 = ["h2 (>=4,<5)"] socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] zstd = ["zstandard (>=0.18.0)"] +[[package]] +name = "werkzeug" +version = "3.1.3" +description = "The comprehensive WSGI web application library." +optional = false +python-versions = ">=3.9" +files = [ + {file = "werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e"}, + {file = "werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746"}, +] + +[package.dependencies] +MarkupSafe = ">=2.1.1" + +[package.extras] +watchdog = ["watchdog (>=2.3)"] + [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "c4a2b2cdab5bcd39f8c4770f6c72638412bad45ca2805e5b37dcaef18e6817af" +content-hash = "d4a6b08e6492e15e68b80664e161bea0954afcdbf07835046729cf3899049d70" diff --git a/pyproject.toml b/pyproject.toml index 1859f66..c93a437 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "contactsoftware-functions" -version = "0.9.0" +version = "0.11.0.dev1" readme = "README.md" license = "MIT" @@ -22,6 +22,7 @@ python = "^3.10" requests = "^2.27.1" pydantic = ">=2.3,<3" pyyaml = "^6.0.2" +werkzeug = "^3.1.3" [tool.poetry.group.test.dependencies] pytest = "^8.3.4" diff --git a/tests/test_devserver.py b/tests/test_devserver.py new file mode 100644 index 0000000..a2e514f --- /dev/null +++ b/tests/test_devserver.py @@ -0,0 +1,118 @@ +import hashlib +import hmac +import json +import time +from unittest import TestCase +from unittest.mock import MagicMock, patch + +from werkzeug.datastructures import Headers +from werkzeug.wrappers import Request + +from csfunctions.devserver import _is_error_response, _verify_hmac_signature, handle_request +from csfunctions.handler import FunctionNotRegistered + + +class TestDevServer(TestCase): + def setUp(self): + self.secret_token = "test-secret-token" # nosec: B105 + self.function_name = "test-function" + self.request_body = "test-body" + self.timestamp = str(int(time.time())) + self.signature = hmac.new( + self.secret_token.encode("utf-8"), + f"{self.timestamp}{self.request_body}".encode(), + hashlib.sha256, + ).hexdigest() + + def create_request(self, path="/test-function", body="test-body", signature=None, timestamp=None): + environ = { + "PATH_INFO": path, + "wsgi.input": MagicMock(), + "REQUEST_METHOD": "POST", + } + request = Request(environ) + request.get_data = MagicMock(return_value=body) + + if signature: + request.headers = Headers({"X-CON-Signature-256": signature, "X-CON-Timestamp": timestamp}) + return request + + def test_handle_request_no_function_name(self): + """Test handling request with no function name""" + request = self.create_request(path="/") + response = handle_request(request) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.get_data(as_text=True), "No function name provided") + + @patch("csfunctions.devserver.execute") + @patch.dict("os.environ", {"CFC_SECRET_TOKEN": ""}) + def test_handle_request_success(self, mock_execute): + """Test successful request handling""" + expected_response = {"result": "success"} + mock_execute.return_value = json.dumps(expected_response) + + request = self.create_request() + response = handle_request(request) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.content_type, "application/json") + self.assertEqual(json.loads(response.get_data(as_text=True)), expected_response) + + @patch("csfunctions.devserver.execute") + @patch.dict("os.environ", {"CFC_SECRET_TOKEN": ""}) + def test_handle_request_function_not_registered(self, mock_execute): + """Test handling of non-existent function""" + mock_execute.side_effect = FunctionNotRegistered("Function not found") + + request = self.create_request() + response = handle_request(request) + + self.assertEqual(response.status_code, 404) + self.assertEqual(response.get_data(as_text=True), "Function not found") + + @patch("csfunctions.devserver.execute") + @patch.dict("os.environ", {"CFC_SECRET_TOKEN": ""}) + def test_handle_request_error_response(self, mock_execute): + """Test handling of error response from function""" + error_response = {"response_type": "error", "message": "Something went wrong"} + mock_execute.return_value = json.dumps(error_response) + + request = self.create_request() + response = handle_request(request) + + self.assertEqual(response.status_code, 500) + self.assertEqual(response.content_type, "application/json") + self.assertEqual(json.loads(response.get_data(as_text=True)), error_response) + + def test_verify_hmac_signature_valid(self): + """Test HMAC signature verification with valid signature""" + result = _verify_hmac_signature(self.signature, self.timestamp, self.request_body, self.secret_token) + self.assertTrue(result) + + def test_verify_hmac_signature_invalid(self): + """Test HMAC signature verification with invalid signature""" + result = _verify_hmac_signature("invalid-signature", self.timestamp, self.request_body, self.secret_token) + self.assertFalse(result) + + def test_verify_hmac_signature_expired(self): + """Test HMAC signature verification with expired timestamp""" + old_timestamp = str(int(time.time()) - 120) # 2 minutes old + result = _verify_hmac_signature(self.signature, old_timestamp, self.request_body, self.secret_token) + self.assertFalse(result) + + def test_is_error_response(self): + """Test error response detection""" + # Test string response + self.assertFalse(_is_error_response("not an error")) + + # Test JSON string response + error_response = json.dumps({"response_type": "error"}) + self.assertTrue(_is_error_response(error_response)) + + # Test dict response + self.assertTrue(_is_error_response({"response_type": "error"})) + self.assertFalse(_is_error_response({"response_type": "success"})) + + # Test invalid JSON string + self.assertFalse(_is_error_response("{invalid json}"))