diff --git a/samples/unit_testing/README.md b/samples/unit_testing/README.md new file mode 100644 index 00000000..1f853a50 --- /dev/null +++ b/samples/unit_testing/README.md @@ -0,0 +1,180 @@ +# Subscription Creation Workflow with Unit Testing + +This project demonstrates a durable workflow that manages a subscription creation long running lifecyle and is adapted from a canonical +real world example. +The durable orchestration, will create a subscription, wait for the subscription to be created (through the durable timer) +and update status of subscription creation in an in-memory status object. + +This also demonstrates usage of: + +- EasyAuth using decoraters +- Serialization of custom classes +- Unit Test Methodology + +# Durable Orchestration Patterns Used + +- Fan In/Fan Out +- Sub Orchestrations +- Function Chaining +- Durable Monitor + +# Unit Testing Guide + +This example shows how we can unit test durable function patterns using python unittest patch and mock constructs and some noteworthy mocks. + +## Unit Testing Durable HTTP Start Invocation with decorators for EasyAuth + +The Durable HTTP Starter is invoked with a `X-MS-CLIENT-PRINCIPAL` in the header of the HTTP request. When configuring EasyAuth, the function needs to be validated against the claims presented. This validation is done via an authorize decorator in this sample. + +When making an HTTP request to the service (GET or PUT), you can use the following token to act as both a SubscriptionManager (PUT) and a SubscriptionReader (GET): + +`ICAgICAgICB7CiAgICAgICAgICAgICJhdXRoX3R5cCI6ICJhYWQiLAogICAgICAgICAgICAiY2xhaW1zIjogW3sKICAgICAgICAgICAgICAgICJ0eXAiOiAiaHR0cDovL3NjaGVtYXMueG1sc29hcC5vcmcvd3MvMjAwNS8wNS9pZGVudGl0eS9jbGFpbXMvc3VybmFtZSIsCiAgICAgICAgICAgICAgICAidmFsIjogIlVzZXIiCiAgICAgICAgICAgIH0sIHsKICAgICAgICAgICAgICAgICJ0eXAiOiAiZ3JvdXBzIiwKICAgICAgICAgICAgICAgICJ2YWwiOiAiZWY2ZDJkMWEtNzhlYi00YmIxLTk3YzctYmI4YThlNTA5ZTljIgogICAgICAgICAgICB9LCB7CiAgICAgICAgICAgICAgICAidHlwIjogImdyb3VwcyIsCiAgICAgICAgICAgICAgICAidmFsIjogIjNiMjMxY2UxLTI5YzEtNDQxZS1iZGRiLTAzM2Y5NjQwMTg4OCIKICAgICAgICAgICAgfSwgewogICAgICAgICAgICAgICAgInR5cCI6ICJuYW1lIiwKICAgICAgICAgICAgICAgICJ2YWwiOiAiVGVzdCBVc2VyIgogICAgICAgICAgICB9XSwKICAgICAgICAgICAgIm5hbWVfdHlwIjogImh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL3dzLzIwMDUvMDUvaWRlbnRpdHkvY2xhaW1zL25hbWUiLAogICAgICAgICAgICAicm9sZV90eXAiOiAiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIgogICAgICAgIH0=` + +For unit testing the decorator (that get's initialized with a specific environment variable) we just patch in the variable before importing the Durable HTTP Start method like this: + +```python +with patch.dict(os.environ={"SecurityGroups_SUBSCRIPTION_MANAGERS":"ef6d2d1a-78eb-4bb1-97c7-bb8a8e509e9c"}): + from ..CreateEnvironmentHTTPStart import main +```` + +and make sure we are sending the right `X-MS-CLIENT-PRINCIPAL` in the header of the mocked HttpRequest as seen in [this](./subscription-manager/tests/test_createenvhttpstart_validauth.py) test. + + +We are patching the group-id that gets base64 decoded and compared with the above claims principal sent in the header of the http request + +Refer [Auth](./subscription-manager/Auth/authorization.py) for details on how this works + +--- + +## Unit Testing Orchestrator Function + +When mocking an orchestrator, the durable orchestration context is mocked like this: + +```python +with patch('azure.durable_functions.DurableOrchestrationContext',spec=df.DurableOrchestrationContext) as mock: + mock.get_input = MagicMock(return_value={'displayName' : 'test'}) + mock.call_sub_orchestrator.side_effect = sub_orc_mock + mock.task_all.side_effect = task_all_mock + + # To get generator results do a next. If orchestrator response is a list, then wrap the function call around a list + result = list(orchestrator_fn(mock)) + self.assertEqual('51ba2a78-bec0-4f31-83e7-58c64693a6dd',result[0]) +``` + +MagicMock can be used to return a set of canned values, for eg: `get_input` expects a specific dictionary as shown above. + +For intercepting any method calls on the mock, we define a `side_effect` that is a local method. For eg: `task_all_mock` side effect checks the list of tasks that it received + +```python +def task_all_mock(tasks:list): + assert len(tasks) == 2 + return list +``` + +Here we check if we received two tasks and we can go further and use `assertIsInstance` to check what classes the tasks belong to etc. + +Finally we invoke the orchestrator as + +```python +result = list(orchestrator_fn(mock)) +``` + +and further inspect the result + +--- + +## Unit Testing Durable Monitor Pattern + +Here the durable monitor calls an activity function to get the status of a subscription creation process. Depending upon the status, it will schedule a durable timer to poll again or will proceed further in the orchestration. + +To simulate this in the unit test, we might want to send back the results of `call_activity` into the orchestrator and so the orchestrator is invoked in a specific way taking advantage of the generators. + +```python +gen_orchestrator = orchestrator_fn(mock) + try: + # Make a call to Status check to see and if response is accepted (subscription is in process of being created) + next(gen_orchestrator) + + # Send back response to orchestrator + gen_orchestrator.send(accepted_response) + + # Timer is set and now the call succeeds + next(gen_orchestrator) + + # Send back success response to orchestrator + gen_orchestrator.send(succeeded_response) + + except StopIteration as e: + result = e.value + self.assertEqual('51ba2a78-bec0-4f31-83e7-58c64693a6dd',result) +``` + +For more details refer [this test that simulates the durable timer calls](./subscription-manager/tests/test_createsubscription_suborchestrator.py). + +--- + +## Unit Testing Callbacks and patching environment variables + +If your activity function or orchestrator or any helper methods use environment variables internally, this code below demonstrates how to patch these environment variables in an isolated manner. + +```python +# Patch environment variables +patch_env_mock = mock.patch.dict(os.environ, {"RUNTIME_ENVIRONMENT": "LOCAL", + "STATUS_STORAGE" : "IN-MEMORY"}) +patch_env_mock.start() + +# Patch the update callback to intercept and inspect status +with patch("subscription-manager.StatusCheck.update_callback") as function_mock: + function_mock.side_effect = mock_callback + result = await main(payload) + self.assertIsInstance(result,SubscriptionResponse) + self.assertEqual(result.properties.subscriptionId,"1111111-2222-3333-4444-ebc1b75b9d74") + self.assertEqual(result.properties.provisioningState,"NotFound") +patch_env_mock.stop() +``` + +--- +## Unit testing internal Callback methods + +The subscription manager uses a custom callback that gets called from another method invoked +inside of an activity function. The following code demonstrates how to patch these callbacks: + +### Assign a side-effect method that can intercept the call + +```python +# Patch the update callback to intercept and inspect status + with patch("subscription-manager.StatusCheck.update_callback") as function_mock: + function_mock.side_effect = mock_callback +``` + +### Call the actual callback within the side effect method + +```python +def mock_callback(status: Status): + updated_status = update_callback(status) +``` + +### Assert the response + +```python +def mock_callback(status: Status): + updated_status = update_callback(status) + + # NotFound is returned by LocalSubscription emulation and we expect the same to be set here + assert updated_status.creation_status == "NotFound" +``` +--- + +## Running Locally + +This example can be run locally the sample call, [test_orchestration.http](./test_orchestration.http) using the [REST Client for VS Code](https://marketplace.visualstudio.com/items?itemName=humao.rest-client) + +--- +## Running all unit tests + +The script [run_unit_tests.sh](./run_unit_tests.sh) can be used to invoke all the tests with the right module paths wired in. +- Create a python virtual environment `python3 -m venv env` +- Activate it `source env/bin/activate` +- Run unit tests `sh run_unit_tests.sh` + +--- \ No newline at end of file diff --git a/samples/unit_testing/run_unit_tests.sh b/samples/unit_testing/run_unit_tests.sh new file mode 100644 index 00000000..f67230a6 --- /dev/null +++ b/samples/unit_testing/run_unit_tests.sh @@ -0,0 +1,17 @@ +#! /usr/bin/bash + +# Bash script to run all unit tests from tests folder. +# Make sure the test name is of the format "test_*.py" +TESTS="./subscription-manager/unit_tests" +for TEST_NAME in $TESTS/* +do + # Remove non-tests + if [[ $TEST_NAME = *"__init__"* || $TEST_NAME = *"pycache"* ]]; then + continue + fi + echo "Running $TEST_NAME ..." + + # Cut out the directory names and trim .py extension + SUFFIX_NAME=$(echo $TEST_NAME | cut -d "/" -f 4 | cut -d "." -f 1) + python -m unittest subscription-manager.unit_tests.$SUFFIX_NAME +done \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/AddSubscriptionToMgmtGroup/__init__.py b/samples/unit_testing/subscription-manager/AddSubscriptionToMgmtGroup/__init__.py new file mode 100644 index 00000000..fbcf760a --- /dev/null +++ b/samples/unit_testing/subscription-manager/AddSubscriptionToMgmtGroup/__init__.py @@ -0,0 +1,5 @@ +""" +Demo Activity Function to add subscription to management group +""" +def main(name:str) -> str: + return f"Added subscription to management group" diff --git a/samples/unit_testing/subscription-manager/AddSubscriptionToMgmtGroup/function.json b/samples/unit_testing/subscription-manager/AddSubscriptionToMgmtGroup/function.json new file mode 100644 index 00000000..186f3e7e --- /dev/null +++ b/samples/unit_testing/subscription-manager/AddSubscriptionToMgmtGroup/function.json @@ -0,0 +1,12 @@ +{ + "scriptFile": "__init__.py", + "bindings": [ + { + "name": "name", + "type": "activityTrigger", + "direction": "in", + "datatype": "string" + } + ], + "disabled": false +} \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/Auth/authorization.py b/samples/unit_testing/subscription-manager/Auth/authorization.py new file mode 100644 index 00000000..fd277b82 --- /dev/null +++ b/samples/unit_testing/subscription-manager/Auth/authorization.py @@ -0,0 +1,42 @@ +import logging +import azure.functions as func +from .usertoken import UserToken +from functools import wraps + +""" +Decorator method that is called for authorization before the Durable HTTP start method is invoked. +It uses the X-MS-CLIENT-PRINCIPAL id in the request header to authenticate against a set of known +Subscription Managers/Readers +""" +def authorize(allowed_groups:list): + """Wrap the decorator to allow passing in a group name""" + def decorator_authorize(decorated_function): + """Decorator to handle authorization""" + + # Wraps ensures that the decorated function's parameters are exposed and not + # this function's parameters. + @wraps(decorated_function) + async def validate_authorization(*args, **kwargs): + """Check authorization of caller""" + logging.info("In the authorization decorator authorizing for %s" %(allowed_groups)) + + # Get 'req' parameter that was passed to the decorated function + request = kwargs['req'] + + # Get the claims token from the request header + token_b64 = request.headers.get('X-MS-CLIENT-PRINCIPAL', '') + + # Simulate 403 call if we don't pass in a header + if token_b64 == '': + return func.HttpResponse("", status_code=403) + user_token = UserToken(token_b64) + + for group_id in allowed_groups: + if user_token.is_member(group_id): + # Call the decorated function + return await decorated_function(*args, **kwargs) + else: + return func.HttpResponse("", status_code=403) + + return validate_authorization + return decorator_authorize \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/Auth/knowngroups.py b/samples/unit_testing/subscription-manager/Auth/knowngroups.py new file mode 100644 index 00000000..ab80f3b9 --- /dev/null +++ b/samples/unit_testing/subscription-manager/Auth/knowngroups.py @@ -0,0 +1,10 @@ +import os + +""" +Set of known groups represented as environment variables. +Subscription Managers: Service principals have permissions to create and delete subscriptions +Subscription Readers: Service principals have permissions to read subscription +""" +class SecurityGroups: + subscription_managers = os.getenv("SecurityGroups_SUBSCRIPTION_MANAGERS") + subscription_readers = os.getenv("SecurityGroups_SUBSCRIPTION_READERS") \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/Auth/usertoken.py b/samples/unit_testing/subscription-manager/Auth/usertoken.py new file mode 100644 index 00000000..678118a0 --- /dev/null +++ b/samples/unit_testing/subscription-manager/Auth/usertoken.py @@ -0,0 +1,42 @@ +import json,base64 + +""" +Represents a UserToken with a specific set of user id's and groups. +Validates token from Identifies if a token belongs to a specific group from the claims +""" +class UserToken: + id_token = [] + group_ids = [] + + def __init__(self,base64_token:str): + + decoded_token = base64.b64decode(base64_token) + id_token = json.loads(decoded_token) + + try: + UserToken.validate_token(id_token) + self.id_token = id_token + self.group_ids = UserToken.get_group_ids(self.id_token) + except Exception as e: + raise + + @staticmethod + def get_group_ids(id_token:str): + claims = id_token["claims"] + group_ids = [c["val"] for c in claims if c["typ"] == "groups"] + return group_ids + + @staticmethod + def validate_token(id_token:str): + try: + claims = id_token["claims"] + except Exception as e: + raise + + def is_member(self,group_id:str): + if group_id in self.group_ids: + return True + return False + + + diff --git a/samples/unit_testing/subscription-manager/CreateEnvironmentHTTPStart/__init__.py b/samples/unit_testing/subscription-manager/CreateEnvironmentHTTPStart/__init__.py new file mode 100644 index 00000000..350f5e69 --- /dev/null +++ b/samples/unit_testing/subscription-manager/CreateEnvironmentHTTPStart/__init__.py @@ -0,0 +1,45 @@ +import logging, json +import azure.durable_functions as df +import azure.functions as func +from ..Status.inmemorystatusmanager import InMemoryStatusManager +from ..Status.status import Status +from ..Auth import authorization +from ..Auth.knowngroups import SecurityGroups + +""" +Update callback that can be customized to make any changes to the status +""" +def update_callback(status: Status): + return status + +""" +Durable HTTP Start that kicks off orchestration for creating subscriptions + +Returns: + Response that contains status URL's to monitor the orchestration +""" +@authorization.authorize([SecurityGroups.subscription_managers]) +async def main(req: func.HttpRequest,starter:str) -> func.HttpResponse: + + client = df.DurableOrchestrationClient(starter) + + # Payload that contains how a subscription environment is created + payload: str = json.loads(req.get_body().decode()) + client_name: str = req.route_params.get('clientName') + orchestrator_name: str = "SubscriptionLifecycleOrchestrator" + + headers = {} + + if client_name is None: + return func.HttpResponse( + "Must include clientName (in the body of the http)", + headers=headers, status_code=400) + else: + # Initialize a new status for this client in-memory + status_mgr = InMemoryStatusManager(client_name) + await status_mgr.safe_update_status(update_callback) + payload["customerName"] = client_name + payload["subscriptionId"] = None + instance_id = await client.start_new(orchestration_function_name=orchestrator_name,instance_id=None,client_input=payload) + logging.info(f"Started orchestration with ID = '{instance_id}'.") + return client.create_check_status_response(req,instance_id) \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/CreateEnvironmentHTTPStart/function.json b/samples/unit_testing/subscription-manager/CreateEnvironmentHTTPStart/function.json new file mode 100644 index 00000000..2ba39e0d --- /dev/null +++ b/samples/unit_testing/subscription-manager/CreateEnvironmentHTTPStart/function.json @@ -0,0 +1,26 @@ +{ + "scriptFile": "__init__.py", + "bindings": [ + { + "authLevel": "anonymous", + "name": "req", + "type": "httpTrigger", + "direction": "in", + "route": "product/{clientName}", + "methods": [ + "post", + "get" + ] + }, + { + "name": "$return", + "type": "http", + "direction": "out" + }, + { + "name": "starter", + "type": "durableClient", + "direction": "in" + } + ] + } \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/CreateSubscription/__init__.py b/samples/unit_testing/subscription-manager/CreateSubscription/__init__.py new file mode 100644 index 00000000..4e49841c --- /dev/null +++ b/samples/unit_testing/subscription-manager/CreateSubscription/__init__.py @@ -0,0 +1,33 @@ +from ..Subscription.subscriptionmanager import SubscriptionManager +from ..Status.status import Status +from ..Status.inmemorystatusmanager import InMemoryStatusManager +from ..Subscription.subscription import SubscriptionResponse + +subscription_resource : SubscriptionResponse = None + +""" +Update callback that can be used to update the name, id and provisioning state of a subscription +on the status object +""" +def update_callback(status: Status): + status.name = subscription_resource.name + status.id = subscription_resource.id + status.creation_status = subscription_resource.properties.provisioningState + return status + +""" +Activity function that invokes the Azure Python ms-rest API's to create a subscription and updates +the status of subscription creation. + +SubscriptionResponse: contains details of the subscription whose status is being created +""" +async def main(payload: dict) -> str: + display_name = payload['subscriptionName'] + sm = SubscriptionManager() + + global subscription_resource + subscription_resource = await sm.create_subscription(payload['subscriptionName'],display_name) + status_mgr = InMemoryStatusManager(payload['customerName']) + await status_mgr.safe_update_status(update_callback) + return subscription_resource + diff --git a/samples/unit_testing/subscription-manager/CreateSubscription/function.json b/samples/unit_testing/subscription-manager/CreateSubscription/function.json new file mode 100644 index 00000000..b6364127 --- /dev/null +++ b/samples/unit_testing/subscription-manager/CreateSubscription/function.json @@ -0,0 +1,12 @@ +{ + "scriptFile": "__init__.py", + "bindings": [ + { + "name": "payload", + "type": "activityTrigger", + "direction": "in", + "datatype": "string" + } + ], + "disabled": false +} \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/CreateSubscriptionSubOrchestrator/__init__.py b/samples/unit_testing/subscription-manager/CreateSubscriptionSubOrchestrator/__init__.py new file mode 100644 index 00000000..bf622515 --- /dev/null +++ b/samples/unit_testing/subscription-manager/CreateSubscriptionSubOrchestrator/__init__.py @@ -0,0 +1,44 @@ +import azure.durable_functions as df +from datetime import datetime,timedelta +from ..Subscription.subscription import SubscriptionResponse + +def get_expiry_time(context: df.DurableOrchestrationContext): + return context.current_utc_datetime + timedelta(minutes=2) + +""" +Orchestrator function that checks the status of subscription +creation until it gets successfully created or errors out. + +context: DurableOrchestrationContext +Returns: Id of the subscription that got created +""" +def orchestrator_fn(context: df.DurableOrchestrationContext): + + payload_ = context.get_input() + customer_name = payload_["customerName"] + payload_["subscriptionName"] = f"{customer_name}" + + # Check upto 1 hour + expiry_time = get_expiry_time(context) + + subscription_details : SubscriptionResponse = None + + while context.current_utc_datetime < expiry_time: + subscription_details = yield context.call_activity("StatusCheck",payload_) + + # If subscription is not found call the activity function to create it + if subscription_details.properties.provisioningState == "NotFound": + yield context.call_activity("CreateSubscription",payload_) + elif subscription_details.properties.provisioningState == "Succeeded": + break + + # If neither it means the subscription creation request is accepted, wait for 60 seconds + # and poll again + next_checkpoint = context.current_utc_datetime + timedelta(seconds=60) + yield context.create_timer(next_checkpoint) + + if subscription_details.properties.provisioningState != "Succeeded": + raise Exception("Subscription creation ended without being successful") + return subscription_details.properties.subscriptionId + +main = df.Orchestrator.create(orchestrator_fn) \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/CreateSubscriptionSubOrchestrator/function.json b/samples/unit_testing/subscription-manager/CreateSubscriptionSubOrchestrator/function.json new file mode 100644 index 00000000..c4e53cfc --- /dev/null +++ b/samples/unit_testing/subscription-manager/CreateSubscriptionSubOrchestrator/function.json @@ -0,0 +1,11 @@ +{ + "scriptFile": "__init__.py", + "bindings": [ + { + "name": "context", + "type": "orchestrationTrigger", + "direction": "in" + } + ], + "disabled": false + } \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/MgmtGroupSubOrchestrator/__init__.py b/samples/unit_testing/subscription-manager/MgmtGroupSubOrchestrator/__init__.py new file mode 100644 index 00000000..8402067f --- /dev/null +++ b/samples/unit_testing/subscription-manager/MgmtGroupSubOrchestrator/__init__.py @@ -0,0 +1,11 @@ +import logging, json +import azure.durable_functions as df + +""" +Demo orchestrator function that can be used to add a subscription to a management group +""" +def orchestrator_fn(context: df.DurableOrchestrationContext): + output = yield context.call_activity("AddSubscriptionToMgmtGroup") + return output + +main = df.Orchestrator.create(orchestrator_fn) \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/MgmtGroupSubOrchestrator/function.json b/samples/unit_testing/subscription-manager/MgmtGroupSubOrchestrator/function.json new file mode 100644 index 00000000..45d96312 --- /dev/null +++ b/samples/unit_testing/subscription-manager/MgmtGroupSubOrchestrator/function.json @@ -0,0 +1,11 @@ +{ + "scriptFile": "__init__.py", + "bindings": [ + { + "name": "context", + "type": "orchestrationTrigger", + "direction": "in" + } + ], + "disabled": false +} \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/RegisterPIM/__init__.py b/samples/unit_testing/subscription-manager/RegisterPIM/__init__.py new file mode 100644 index 00000000..bee83c34 --- /dev/null +++ b/samples/unit_testing/subscription-manager/RegisterPIM/__init__.py @@ -0,0 +1,28 @@ +from ..Status.status import Status +from ..Status.inmemorystatusmanager import InMemoryStatusManager +from ..Subscription.subscription import SubscriptionResponse + +subscription_resource : SubscriptionResponse = None + +""" +Update callback that can be used to indicate that the privileged identity management is now enabled +for the subscription +""" +def update_callback(status: Status): + + # set privileged identity as true + status.pim_enabled = True + return status + +""" +Demo Activity Function that can be used to manage the privileged identity of the subscription +""" +async def main(payload: dict) -> str: + + client_name = payload["customerName"] + + # Register PIM + global subscription_resource + status_mgr = InMemoryStatusManager(client_name) + await status_mgr.safe_update_status(update_callback) + return "Elevated PIM" \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/RegisterPIM/function.json b/samples/unit_testing/subscription-manager/RegisterPIM/function.json new file mode 100644 index 00000000..b6364127 --- /dev/null +++ b/samples/unit_testing/subscription-manager/RegisterPIM/function.json @@ -0,0 +1,12 @@ +{ + "scriptFile": "__init__.py", + "bindings": [ + { + "name": "payload", + "type": "activityTrigger", + "direction": "in", + "datatype": "string" + } + ], + "disabled": false +} \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/RegisterPIMSubOrchestrator/__init__.py b/samples/unit_testing/subscription-manager/RegisterPIMSubOrchestrator/__init__.py new file mode 100644 index 00000000..ba816b68 --- /dev/null +++ b/samples/unit_testing/subscription-manager/RegisterPIMSubOrchestrator/__init__.py @@ -0,0 +1,13 @@ +import logging, json +import azure.durable_functions as df + +""" +Demo sub-orchestrator function that can be used to call the register PIM activity function +""" +def orchestrator_fn(context: df.DurableOrchestrationContext): + + payload_ = context.get_input() + output = yield context.call_activity("RegisterPIM",payload_) + return output + +main = df.Orchestrator.create(orchestrator_fn) \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/RegisterPIMSubOrchestrator/function.json b/samples/unit_testing/subscription-manager/RegisterPIMSubOrchestrator/function.json new file mode 100644 index 00000000..45d96312 --- /dev/null +++ b/samples/unit_testing/subscription-manager/RegisterPIMSubOrchestrator/function.json @@ -0,0 +1,11 @@ +{ + "scriptFile": "__init__.py", + "bindings": [ + { + "name": "context", + "type": "orchestrationTrigger", + "direction": "in" + } + ], + "disabled": false +} \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/Status/__init__.py b/samples/unit_testing/subscription-manager/Status/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/samples/unit_testing/subscription-manager/Status/inmemorystatusmanager.py b/samples/unit_testing/subscription-manager/Status/inmemorystatusmanager.py new file mode 100644 index 00000000..0f8fbc93 --- /dev/null +++ b/samples/unit_testing/subscription-manager/Status/inmemorystatusmanager.py @@ -0,0 +1,27 @@ +from ..Status.status import StatusManagerInterface, Status +from typing import Dict,Callable,Any +import datetime + +""" +In Memory implementation of status manager interface for local and unit testing +Retrieves and updates Status of a subscription in memory +""" +class InMemoryStatusManager(StatusManagerInterface): + status_entries: Dict[str,Status] = {} + + def __init__(self,client_name): + self.client_name: str = client_name + self.state_file_name: str = f"{self.client_name}.json" + + async def get_status(self) -> Status: + if self.state_file_name not in self.status_entries: + self.status_entries[self.state_file_name] = Status("") + return self.status_entries[self.state_file_name] + + async def safe_update_status(self,update_cb: Callable[[Status],Any]): + target_status = await self.get_status() + if target_status: + now = datetime.datetime.now() + target_status.last_updated_time = datetime.datetime.strftime(now,'%Y-%m-%d %H:%M') + updated_status = update_cb(target_status) + self.status_entries[self.state_file_name] = updated_status \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/Status/status.py b/samples/unit_testing/subscription-manager/Status/status.py new file mode 100644 index 00000000..7c43b464 --- /dev/null +++ b/samples/unit_testing/subscription-manager/Status/status.py @@ -0,0 +1,54 @@ +import json +from typing import Callable,Any + +""" +Status object that represents the state of subscription creation process +The from_json and to_json methods are implemented here for automatic +serialization by the Durable framework +""" +class Status(object): + + # Change to kwargs + def __init__(self,name=None,state_id=0,last_updated_time=None, + pim_enabled=False,creation_status=None): + self.name = str(name) + self.id = str(state_id) + self.last_updated_time = str(last_updated_time) + self.pim_enabled = str(pim_enabled) + self.creation_status = str(creation_status) + + @staticmethod + def to_json(obj : object) -> str: + str_obj = { + "name" : obj.name, + "id" : obj.id, + "last_updated_time" : obj.last_updated_time, + "pim_enabled" : obj.pim_enabled, + "creation_status" : obj.creation_status + } + return str(str_obj) + + @staticmethod + def from_json(json_str: str) -> object: + json_str = json_str.replace("\'", "\"") + obj_kv = json.loads(json_str) + status_obj = Status( obj_kv["name"],obj_kv["id"],obj_kv["last_updated_time"], + obj_kv["pim_enabled"],obj_kv["creation_status"]) + return status_obj + +""" +Status Manager Interface that can be implemented through: + +get_status: retrieving status from memory/storage +safe_update_status: update status safely by acquiring lease and providing a callback to the caller to update the status further. +""" +class StatusManagerInterface: + + def __init__(self,status): + self.status : Status = status + + async def get_status() -> Status: + pass + + async def safe_update_status(update_cb: Callable[[Status],Any]): + pass \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/StatusCheck/__init__.py b/samples/unit_testing/subscription-manager/StatusCheck/__init__.py new file mode 100644 index 00000000..4a956fc0 --- /dev/null +++ b/samples/unit_testing/subscription-manager/StatusCheck/__init__.py @@ -0,0 +1,37 @@ +from ..Subscription.subscriptionmanager import SubscriptionManager +from ..Status.status import Status +from ..Status.inmemorystatusmanager import InMemoryStatusManager +from ..Subscription.subscription import SubscriptionResponse +import os + +subscription_resource : SubscriptionResponse = None + +""" +Update callback that can be used to set the provisioning state in the subscription status +""" +def update_callback(status: Status): + status.creation_status = subscription_resource.properties.provisioningState + return status + +""" +Activity Function that performs a status check of the subscription creation process +by invoking the required Azure API's via Python ms-rest. +It also updates the status of the subscription before returning a SubscriptionResponse. + +SubscriptionResponse : contains details of the subscription whose status is being checked +""" +async def main(payload: dict) -> str: + + sm = SubscriptionManager() + + client_name = payload["customerName"] + subscription_name = payload["subscriptionName"] + + # Query for subscription status + global subscription_resource + subscription_resource = await sm.get_subscription_status(subscription_name) + status_mgr = InMemoryStatusManager(client_name) + + # Update status + await status_mgr.safe_update_status(update_callback) + return subscription_resource \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/StatusCheck/function.json b/samples/unit_testing/subscription-manager/StatusCheck/function.json new file mode 100644 index 00000000..b6364127 --- /dev/null +++ b/samples/unit_testing/subscription-manager/StatusCheck/function.json @@ -0,0 +1,12 @@ +{ + "scriptFile": "__init__.py", + "bindings": [ + { + "name": "payload", + "type": "activityTrigger", + "direction": "in", + "datatype": "string" + } + ], + "disabled": false +} \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/Subscription/__init__.py b/samples/unit_testing/subscription-manager/Subscription/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/samples/unit_testing/subscription-manager/Subscription/localsubscription.py b/samples/unit_testing/subscription-manager/Subscription/localsubscription.py new file mode 100644 index 00000000..356f55f4 --- /dev/null +++ b/samples/unit_testing/subscription-manager/Subscription/localsubscription.py @@ -0,0 +1,50 @@ +from .subscription import SubscriptionInterface, SubscriptionProperties +from .subscription import SubscriptionResponse +from .subscription import ManagementGroupMoveResponse + +""" +Simulates Azure API REST calls made via Python ms-rest SDK to manage subscription lifecycle +with canned response + +Returns: + SubscriptionResponse : Holds current state of Subscription +""" +class LocalSubscription(SubscriptionInterface): + + async def get_billing_account_id(self) -> str: + return "1234567" + + async def get_enrollment_account_id(self) -> str: + return "7654321" + + async def create_subscription(self,subscription_name:str,display_name:str) -> SubscriptionResponse: + id = "/providers/Microsoft.Subscription/aliases/" + subscription_name + props = SubscriptionProperties(subscription_id="1111111-2222-3333-4444-ebc1b75b9d74",provisioning_state="Succeeded") + response = SubscriptionResponse(sub_id=id,sub_name=display_name,sub_type="Microsoft.Subscription/aliases",sub_props=props) + return response + + async def get_subscription_status(self, subscription_name:str) -> SubscriptionResponse: + id = "/providers/Microsoft.Subscription/aliases/" + subscription_name + + props = SubscriptionProperties(subscription_id="1111111-2222-3333-4444-ebc1b75b9d74",provisioning_state="NotFound") + response = SubscriptionResponse(sub_id=id,sub_name="sampleAlias",sub_type="Microsoft.Subscription/aliases",sub_props=props) + return response + + async def move_subscription(self,subscription_id:str,management_group_id:str) -> ManagementGroupMoveResponse: + group_sub_id = "/providers/Microsoft.Management/managementGroups/Group/subscriptions/" + subscription_id + group_id = "/providers/Microsoft.Management/managementGroups/" + management_group_id + + response: ManagementGroupMoveResponse = { + "name": subscription_id, + "id" : group_sub_id, + "type" : "Microsoft.Management/managementGroups/subscriptions", + "properties" : { + "displayName" : "Group", + "parent" : { + "id" : group_id + }, + "state" : "Active", + "tenant" : "1111111-2222-3333-4444-ebc1b75b9d74" + } + } + return response \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/Subscription/subscription.py b/samples/unit_testing/subscription-manager/Subscription/subscription.py new file mode 100644 index 00000000..7a516530 --- /dev/null +++ b/samples/unit_testing/subscription-manager/Subscription/subscription.py @@ -0,0 +1,76 @@ +from typing import Dict +import json + +class ManagementGroupParent: + def __init__(self, id:str): + self.id = id + +class ManagementGroupMoveProperties: + def __init__(self,display_name:str,parent:ManagementGroupParent,state:str,tenant:str): + self.display_name = display_name + self.parent = parent + self.state = state + self.tenant = tenant + +class ManagementGroupMoveResponse: + def __init__(self,mgmt_name:str,mgmt_id:str,mgmt_type:str,props: ManagementGroupMoveProperties): + self.name = mgmt_name + self.id = mgmt_id + self.type = mgmt_type + self.properties = props + +class SubscriptionProperties: + def __init__(self,subscription_id: str,provisioning_state:str): + self.subscriptionId = subscription_id + self.provisioningState = provisioning_state +""" +Represents a Subscription State + +from_json and to_json methods exist to enable serialization through durable python framework +""" +class SubscriptionResponse: + def __init__(self, sub_id:str,sub_name:str,sub_type:str,sub_props: SubscriptionProperties): + self.id = sub_id + self.name = sub_name + self.type = sub_type + self.properties = sub_props + + @staticmethod + def to_json(obj : object) -> str: + str_obj = { + "name" : obj.name, + "id" : obj.id, + "type": obj.type, + "properties" : { "subscriptionId":obj.properties.subscriptionId,"provisioningState": obj.properties.provisioningState} + } + return str(str_obj) + + @staticmethod + def from_json(json_str: str) -> object: + json_str = json_str.replace("\'", "\"") + obj_kv = json.loads(json_str) + props : SubscriptionProperties = SubscriptionProperties(obj_kv["properties"]["subscriptionId"],obj_kv["properties"]["provisioningState"]) + sub_obj = SubscriptionResponse( obj_kv["id"],obj_kv["name"],obj_kv["type"],props) + return sub_obj + +""" +Represents Lifecycle of subscription interface +""" +class SubscriptionInterface: + def __init__(self): + pass + + async def get_billing_account_id(self) -> str: + pass + + async def get_enrollment_account_id(self) -> str: + pass + + async def create_subscription(self,subscription_name:str,display_name:str) -> SubscriptionResponse: + pass + + async def get_subscription_status(self, subscription_name:str) -> SubscriptionResponse: + pass + + async def move_subscription(self,subscription_id:str,management_group_id:str) -> ManagementGroupMoveResponse: + pass \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/Subscription/subscriptionmanager.py b/samples/unit_testing/subscription-manager/Subscription/subscriptionmanager.py new file mode 100644 index 00000000..26e61774 --- /dev/null +++ b/samples/unit_testing/subscription-manager/Subscription/subscriptionmanager.py @@ -0,0 +1,39 @@ +from .subscription import SubscriptionInterface,SubscriptionResponse,ManagementGroupMoveResponse +from .localsubscription import LocalSubscription + +""" +Returns an implementation of subscription interface depending on the runtime environment + +AzureSubscription: if runtime environment is production +LocalSubscription: if runtime environment is local + +""" +class SubscriptionManager: + + subscription : SubscriptionInterface + + def __init__(self): + self.subscription = LocalSubscription() + + async def get_subscription(self) -> SubscriptionInterface: + return self.subscription + + async def get_subscription_status(self, subscription_name:str) -> SubscriptionResponse: + sub_mgr = await self.get_subscription() + return await sub_mgr.get_subscription_status(subscription_name) + + async def get_enrollment_account_id(self) -> str: + sub_mgr = await self.get_subscription() + return await sub_mgr.get_enrollment_account_id() + + async def get_billing_account_id(self) -> str: + sub_mgr = await self.get_subscription() + return await sub_mgr.get_billing_account_id() + + async def move_subscription(self,subscription_id:str,management_group_id:str) -> ManagementGroupMoveResponse: + sub_mgr = await self.get_subscription() + return await sub_mgr.move_subscription(subscription_id,management_group_id) + + async def create_subscription(self,subscription_name:str,display_name:str) -> SubscriptionResponse: + sub_mgr = await self.get_subscription() + return await sub_mgr.create_subscription(subscription_name,display_name) \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/SubscriptionLifecycleOrchestrator/__init__.py b/samples/unit_testing/subscription-manager/SubscriptionLifecycleOrchestrator/__init__.py new file mode 100644 index 00000000..64febb06 --- /dev/null +++ b/samples/unit_testing/subscription-manager/SubscriptionLifecycleOrchestrator/__init__.py @@ -0,0 +1,25 @@ +import azure.durable_functions as df + +""" +Durable Orchestration function that calls a set of sub orchestrators through +- function chaining +- fan in/fan out patterns + +context: DurableOrchestrationContext +Returns: Id of the subscription that is created +""" +def orchestrator_fn(context: df.DurableOrchestrationContext): + + payload_: str = context.get_input() + + subscription_id = yield context.call_sub_orchestrator("CreateSubscriptionSubOrchestrator", payload_) + payload_["subscriptionId"] = subscription_id + + provisioning_tasks_ = [] + provisioning_tasks_.append(context.call_sub_orchestrator("RegisterPIMSubOrchestrator",payload_)) + provisioning_tasks_.append(context.call_sub_orchestrator("MgmtGroupSubOrchestrator",payload_)) + + yield context.task_all(provisioning_tasks_) + return subscription_id + +main = df.Orchestrator.create(orchestrator_fn) \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/SubscriptionLifecycleOrchestrator/function.json b/samples/unit_testing/subscription-manager/SubscriptionLifecycleOrchestrator/function.json new file mode 100644 index 00000000..c4e53cfc --- /dev/null +++ b/samples/unit_testing/subscription-manager/SubscriptionLifecycleOrchestrator/function.json @@ -0,0 +1,11 @@ +{ + "scriptFile": "__init__.py", + "bindings": [ + { + "name": "context", + "type": "orchestrationTrigger", + "direction": "in" + } + ], + "disabled": false + } \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/host.json b/samples/unit_testing/subscription-manager/host.json new file mode 100644 index 00000000..291065f8 --- /dev/null +++ b/samples/unit_testing/subscription-manager/host.json @@ -0,0 +1,15 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[2.*, 3.0.0)" + } +} \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/local.settings.json.example b/samples/unit_testing/subscription-manager/local.settings.json.example new file mode 100644 index 00000000..2978a89e --- /dev/null +++ b/samples/unit_testing/subscription-manager/local.settings.json.example @@ -0,0 +1,11 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "python", + "AzureWebJobsStorage": "", + "BILLING_ACCOUNT_ID": "", + "ENROLLMENT_ACCOUNT_ID": "", + "SecurityGroups_SUBSCRIPTION_MANAGERS": "ef6d2d1a-78eb-4bb1-97c7-bb8a8e509e9c", + "SecurityGroups_SUBSCRIPTION_READERS": "3b231ce1-29c1-441e-bddb-033f96401888" + } +} diff --git a/samples/unit_testing/subscription-manager/proxies.json b/samples/unit_testing/subscription-manager/proxies.json new file mode 100644 index 00000000..b385252f --- /dev/null +++ b/samples/unit_testing/subscription-manager/proxies.json @@ -0,0 +1,4 @@ +{ + "$schema": "http://json.schemastore.org/proxies", + "proxies": {} +} diff --git a/samples/unit_testing/subscription-manager/requirements.txt b/samples/unit_testing/subscription-manager/requirements.txt new file mode 100644 index 00000000..22a63ff4 --- /dev/null +++ b/samples/unit_testing/subscription-manager/requirements.txt @@ -0,0 +1,6 @@ +# Do not include azure-functions-worker as it may conflict with the Azure Functions platform + +azure-functions +azure-functions-durable +azure-identity +azure-storage-blob \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/unit_tests/__init__.py b/samples/unit_testing/subscription-manager/unit_tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/samples/unit_testing/subscription-manager/unit_tests/test_createenvhttpstart_invalidauth.py b/samples/unit_testing/subscription-manager/unit_tests/test_createenvhttpstart_invalidauth.py new file mode 100644 index 00000000..40cc4ab0 --- /dev/null +++ b/samples/unit_testing/subscription-manager/unit_tests/test_createenvhttpstart_invalidauth.py @@ -0,0 +1,75 @@ +import azure.functions as func +import azure.durable_functions as df +import unittest.main as unitmain +from unittest import IsolatedAsyncioTestCase,mock +from unittest.mock import AsyncMock, MagicMock, patch +from ..CreateEnvironmentHTTPStart import main + +""" +Test class for CreateEnvironmentHTTPStart Durable HTTP Starter Function that kicks off orchestrations +Mocks the HTTP Request and expected HTTP Response from the Durable HTTP start method and tests +- no header authorization +- header with invalid client principal + +Also tests Authorization decorator. +""" +class DurableFunctionsHttpStartTestCaseInvalidAuth(IsolatedAsyncioTestCase): + + async def test_durablefunctionsorchestrator_trigger_noheader(self): + function_name = 'DurableFunctionsOrchestrator' + instance_id = 'f86a9f49-ae1c-4c66-a60e-991c4c764fe5' + starter = MagicMock() + + mock_request = func.HttpRequest( + method='GET', + body=None, + url=f'http://localhost:7071/api/orchestrators{function_name}', + route_params={'functionName': function_name}, + ) + + mock_response = func.HttpResponse( + body = None, + status_code= 200, + headers={ + "Retry-After": 10 + } + ) + + with patch('azure.durable_functions.DurableOrchestrationClient',spec=df.DurableOrchestrationClient) as a_mock: + a_mock.start_new = AsyncMock() + a_mock().start_new.return_value = instance_id + a_mock().create_check_status_response.return_value = mock_response + response = await main(req=mock_request, starter=starter) + self.assertEqual(403,response.status_code) + + async def test_durablefunctionsorchestrator_trigger_no_allowed_groups(self): + function_name = 'DurableFunctionsOrchestrator' + instance_id = 'f86a9f49-ae1c-4c66-a60e-991c4c764fe5' + starter = MagicMock() + + mock_request = func.HttpRequest( + method='GET', + body=None, + url=f'http://localhost:7071/api/orchestrators{function_name}', + route_params={'functionName': function_name}, + headers={"X-MS-CLIENT-PRINCIPAL": "ICAgICAgICB7CiAgICAgICAgICAgICJhdXRoX3R5cCI6ICJhYWQiLAogICAgICAgICAgICAiY2xhaW1zIjogW3sKICAgICAgICAgICAgICAgICJ0eXAiOiAiaHR0cDovL3NjaGVtYXMueG1sc29hcC5vcmcvd3MvMjAwNS8wNS9pZGVudGl0eS9jbGFpbXMvc3VybmFtZSIsCiAgICAgICAgICAgICAgICAidmFsIjogIlVzZXIiCiAgICAgICAgICAgIH0sIHsKICAgICAgICAgICAgICAgICJ0eXAiOiAiZ3JvdXBzIiwKICAgICAgICAgICAgICAgICJ2YWwiOiAiZWY2ZDJkMWEtNzhlYi00YmIxLTk3YzctYmI4YThlNTA5ZTljIgogICAgICAgICAgICB9LCB7CiAgICAgICAgICAgICAgICAidHlwIjogImdyb3VwcyIsCiAgICAgICAgICAgICAgICAidmFsIjogIjNiMjMxY2UxLTI5YzEtNDQxZS1iZGRiLTAzM2Y5NjQwMTg4OCIKICAgICAgICAgICAgfSwgewogICAgICAgICAgICAgICAgInR5cCI6ICJuYW1lIiwKICAgICAgICAgICAgICAgICJ2YWwiOiAiVGVzdCBVc2VyIgogICAgICAgICAgICB9XSwKICAgICAgICAgICAgIm5hbWVfdHlwIjogImh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL3dzLzIwMDUvMDUvaWRlbnRpdHkvY2xhaW1zL25hbWUiLAogICAgICAgICAgICAicm9sZV90eXAiOiAiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIgogICAgICAgIH0="} + ) + + mock_response = func.HttpResponse( + body = None, + status_code= 200, + headers={ + "Retry-After": 10 + } + ) + + with patch('azure.durable_functions.DurableOrchestrationClient',spec=df.DurableOrchestrationClient) as a_mock: + a_mock.start_new = AsyncMock() + a_mock().start_new.return_value = instance_id + a_mock().create_check_status_response.return_value = mock_response + + response = await main(req=mock_request, starter=starter) + self.assertEqual(403,response.status_code) + +if __name__ == "__main__": + unitmain() \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/unit_tests/test_createenvhttpstart_invalidrouteparams.py b/samples/unit_testing/subscription-manager/unit_tests/test_createenvhttpstart_invalidrouteparams.py new file mode 100644 index 00000000..632c8b4f --- /dev/null +++ b/samples/unit_testing/subscription-manager/unit_tests/test_createenvhttpstart_invalidrouteparams.py @@ -0,0 +1,55 @@ +import os +import azure.functions as func +import azure.durable_functions as df +import unittest.main as unitmain +from unittest import IsolatedAsyncioTestCase,mock +from unittest.mock import AsyncMock, MagicMock, patch + +# Patch any required environment variable for the Auth decorator (authorization.py) just before importing +# for the patch to take effect +with patch.dict(os.environ,{"SecurityGroups_SUBSCRIPTION_MANAGERS":"ef6d2d1a-78eb-4bb1-97c7-bb8a8e509e9c"}): + from ..CreateEnvironmentHTTPStart import main + +""" +Test class for CreateEnvironmentHTTPStart Durable HTTP Starter Function that kicks off orchestrations +Mocks the HTTP Request and expected HTTP Response from the Durable HTTP start method and tests +- invalid route params +""" +class DurableFunctionsHttpStartTestCaseInvalidRouteParams(IsolatedAsyncioTestCase): + + async def test_durablefunctionsorchestrator_trigger_invalid_client_name(self): + function_name = 'DurableFunctionsOrchestrator' + instance_id = 'f86a9f49-ae1c-4c66-a60e-991c4c764fe5' + productName = 'test_product' + clientName = 'microsoft' + environmentName = 'production' + starter = MagicMock() + + mock_request = func.HttpRequest( + method='POST', + body=b'{"sub_product":"test"}', + url=f'http://localhost:7071/api/orchestrators/product/{productName}/clients/{clientName}/environments/{environmentName}/', + route_params={ + 'clientName' : None + }, + headers={"X-MS-CLIENT-PRINCIPAL": "ICAgICAgICB7CiAgICAgICAgICAgICJhdXRoX3R5cCI6ICJhYWQiLAogICAgICAgICAgICAiY2xhaW1zIjogW3sKICAgICAgICAgICAgICAgICJ0eXAiOiAiaHR0cDovL3NjaGVtYXMueG1sc29hcC5vcmcvd3MvMjAwNS8wNS9pZGVudGl0eS9jbGFpbXMvc3VybmFtZSIsCiAgICAgICAgICAgICAgICAidmFsIjogIlVzZXIiCiAgICAgICAgICAgIH0sIHsKICAgICAgICAgICAgICAgICJ0eXAiOiAiZ3JvdXBzIiwKICAgICAgICAgICAgICAgICJ2YWwiOiAiZWY2ZDJkMWEtNzhlYi00YmIxLTk3YzctYmI4YThlNTA5ZTljIgogICAgICAgICAgICB9LCB7CiAgICAgICAgICAgICAgICAidHlwIjogImdyb3VwcyIsCiAgICAgICAgICAgICAgICAidmFsIjogIjNiMjMxY2UxLTI5YzEtNDQxZS1iZGRiLTAzM2Y5NjQwMTg4OCIKICAgICAgICAgICAgfSwgewogICAgICAgICAgICAgICAgInR5cCI6ICJuYW1lIiwKICAgICAgICAgICAgICAgICJ2YWwiOiAiVGVzdCBVc2VyIgogICAgICAgICAgICB9XSwKICAgICAgICAgICAgIm5hbWVfdHlwIjogImh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL3dzLzIwMDUvMDUvaWRlbnRpdHkvY2xhaW1zL25hbWUiLAogICAgICAgICAgICAicm9sZV90eXAiOiAiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIgogICAgICAgIH0="} + ) + + mock_response = func.HttpResponse( + body = None, + status_code= 200, + headers={ + "Retry-After": 10 + } + ) + + with patch('azure.durable_functions.DurableOrchestrationClient',spec=df.DurableOrchestrationClient) as a_mock: + a_mock.start_new = AsyncMock() + a_mock().start_new.return_value = instance_id + a_mock().create_check_status_response.return_value = mock_response + + response = await main(req=mock_request, starter=starter) + self.assertEqual(400,response.status_code) + +if __name__ == "__main__": + unitmain() \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/unit_tests/test_createenvhttpstart_validauth.py b/samples/unit_testing/subscription-manager/unit_tests/test_createenvhttpstart_validauth.py new file mode 100644 index 00000000..513609c5 --- /dev/null +++ b/samples/unit_testing/subscription-manager/unit_tests/test_createenvhttpstart_validauth.py @@ -0,0 +1,53 @@ +import unittest.main as unitmain +import azure.functions as func +import azure.durable_functions as df +import os +from unittest import IsolatedAsyncioTestCase,mock +from unittest.mock import AsyncMock, MagicMock, patch + +with patch.dict(os.environ,{"SecurityGroups_SUBSCRIPTION_MANAGERS":"ef6d2d1a-78eb-4bb1-97c7-bb8a8e509e9c"}): + from ..CreateEnvironmentHTTPStart import main + +""" +Test class for CreateEnvironmentHTTPStart Durable HTTP Starter Function that kicks off orchestrations +Mocks the HTTP Request and expected HTTP Response from the Durable HTTP start method and tests +- Valid group claim +""" +class DurableFunctionsHttpStartTestCaseValidAuth(IsolatedAsyncioTestCase): + + async def test_durablefunctionsorchestrator_trigger_no_allowed_groups(self): + function_name = 'DurableFunctionsOrchestrator' + instance_id = 'f86a9f49-ae1c-4c66-a60e-991c4c764fe5' + productName = 'test_product' + clientName = 'microsoft' + environmentName = 'production' + starter = MagicMock() + + mock_request = func.HttpRequest( + method='POST', + body=b'{"sub_product":"test"}', + url=f'http://localhost:7071/api/orchestrators/product/{productName}/clients/{clientName}/environments/{environmentName}/', + route_params={ + 'clientName' : clientName + }, + headers={"X-MS-CLIENT-PRINCIPAL": "ICAgICAgICB7CiAgICAgICAgICAgICJhdXRoX3R5cCI6ICJhYWQiLAogICAgICAgICAgICAiY2xhaW1zIjogW3sKICAgICAgICAgICAgICAgICJ0eXAiOiAiaHR0cDovL3NjaGVtYXMueG1sc29hcC5vcmcvd3MvMjAwNS8wNS9pZGVudGl0eS9jbGFpbXMvc3VybmFtZSIsCiAgICAgICAgICAgICAgICAidmFsIjogIlVzZXIiCiAgICAgICAgICAgIH0sIHsKICAgICAgICAgICAgICAgICJ0eXAiOiAiZ3JvdXBzIiwKICAgICAgICAgICAgICAgICJ2YWwiOiAiZWY2ZDJkMWEtNzhlYi00YmIxLTk3YzctYmI4YThlNTA5ZTljIgogICAgICAgICAgICB9LCB7CiAgICAgICAgICAgICAgICAidHlwIjogImdyb3VwcyIsCiAgICAgICAgICAgICAgICAidmFsIjogIjNiMjMxY2UxLTI5YzEtNDQxZS1iZGRiLTAzM2Y5NjQwMTg4OCIKICAgICAgICAgICAgfSwgewogICAgICAgICAgICAgICAgInR5cCI6ICJuYW1lIiwKICAgICAgICAgICAgICAgICJ2YWwiOiAiVGVzdCBVc2VyIgogICAgICAgICAgICB9XSwKICAgICAgICAgICAgIm5hbWVfdHlwIjogImh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL3dzLzIwMDUvMDUvaWRlbnRpdHkvY2xhaW1zL25hbWUiLAogICAgICAgICAgICAicm9sZV90eXAiOiAiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIgogICAgICAgIH0="} + ) + + mock_response = func.HttpResponse( + body = None, + status_code= 200, + headers={ + "Retry-After": 10 + } + ) + + with patch('azure.durable_functions.DurableOrchestrationClient',spec=df.DurableOrchestrationClient) as a_mock: + a_mock.start_new = AsyncMock() + a_mock().start_new.return_value = instance_id + a_mock().create_check_status_response.return_value = mock_response + + response = await main(req=mock_request, starter=starter) + self.assertEqual(200,response.status_code) + +if __name__ == "__main__": + unitmain() \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/unit_tests/test_createsubscription_activity.py b/samples/unit_testing/subscription-manager/unit_tests/test_createsubscription_activity.py new file mode 100644 index 00000000..b6f9e3db --- /dev/null +++ b/samples/unit_testing/subscription-manager/unit_tests/test_createsubscription_activity.py @@ -0,0 +1,47 @@ +import os +import azure.durable_functions as df +from unittest import main,mock +from unittest import IsolatedAsyncioTestCase +from unittest.mock import patch +from ..Subscription.subscription import SubscriptionResponse +from ..Status.status import Status +from ..CreateSubscription import main,update_callback + +""" +Mock callback that intercepts the update_status call as a decorator +""" +def mock_callback(status:Status): + updated_status = update_callback(status) + assert updated_status.name == "python_subscription" + assert updated_status.id == "/providers/Microsoft.Subscription/aliases/python_subscription" + assert updated_status.creation_status == "Succeeded" + assert updated_status.pim_enabled == "False" + return updated_status + +""" +Test class for CreateSubscription Activity Function that mocks +the update callback and tests the Subscription Response +""" +class TestCreateSubscription(IsolatedAsyncioTestCase): + print(os.getcwd()) + async def test_status_check_activity_valid_inputs(self): + payload = { + 'customerName' : 'microsoft', + 'displayName' : 'test_ea_subscription', + 'subscriptionName' : 'python_subscription' + } + patch_env_mock = mock.patch.dict(os.environ, {"RUNTIME_ENVIRONMENT": "LOCAL", + "STATUS_STORAGE" : "IN-MEMORY"}) + patch_env_mock.start() + with patch("subscription-manager.CreateSubscription.update_callback") as function_mock: + function_mock.side_effect = mock_callback + result = await main(payload) + self.assertIsInstance(result,SubscriptionResponse) + self.assertEqual(result.properties.subscriptionId,"1111111-2222-3333-4444-ebc1b75b9d74") + self.assertEqual(result.properties.provisioningState,"Succeeded") + self.assertEqual(result.id,"/providers/Microsoft.Subscription/aliases/python_subscription") + self.assertEqual(result.name,payload['subscriptionName']) + patch_env_mock.stop() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/unit_tests/test_createsubscription_suborchestrator.py b/samples/unit_testing/subscription-manager/unit_tests/test_createsubscription_suborchestrator.py new file mode 100644 index 00000000..641dbd15 --- /dev/null +++ b/samples/unit_testing/subscription-manager/unit_tests/test_createsubscription_suborchestrator.py @@ -0,0 +1,176 @@ +import datetime +import azure.durable_functions as df +import azure.functions as func +from datetime import timedelta +from unittest import main +from unittest import TestCase +from unittest.mock import MagicMock, patch +from ..Subscription.subscription import SubscriptionProperties, SubscriptionResponse +from ..CreateSubscriptionSubOrchestrator import orchestrator_fn + +####################################################################### +#### Call Activity Mocks ############################################## +####################################################################### +def call_activity_create_subscription(activityName: str, payload): + assert activityName == "CreateSubscription" + mock.call_activity.side_effect = call_activity_mock_status_check_succeeded + +def call_activity_mock_status_check_accepted(activityName: str, payload): + assert activityName == "StatusCheck" + +def call_activity_mock_status_check_succeeded(activityName: str, payload): + assert activityName == "StatusCheck" + +def call_activity_mock_status_check_notfound(activityName:str, payload): + mock.call_activity.side_effect = call_activity_create_subscription + assert activityName == "StatusCheck" + +def call_activity_mock_status_check_error(activityName:str, payload): + assert activityName == "StatusCheck" + +####################################################################### +#### Create Timer Mocks ############################################## +####################################################################### + +def create_timer_mock(next_checkpoint): + # check if next_checkpoint got scheduled 1 min more than previous checkpoint + assert int(next_checkpoint.strftime("%M")) == int(mock.current_utc_datetime.strftime("%M")) + 1 + + # change the call_activity side effect to succeeded call + mock.call_activity.side_effect = call_activity_mock_status_check_succeeded + +def create_timer_error_mock(next_checkpoint): + # check if next_checkpoint got scheduled 1 min more than previous checkpoint + assert int(next_checkpoint.strftime("%M")) == int(mock.current_utc_datetime.strftime("%M")) + 1 + + # change the call_activity side effect to error call + mock.call_activity.side_effect = call_activity_mock_status_check_error + +""" +Test class for CreateSubscriptionSubOrchestrator Durable orchestrator that uses +- Monitor pattern +- Call Activity of StatusCheck and CreateSubscription +""" +class TestEASubscriptionSubOrchestrator(TestCase): + + ####################################################################### + #### Accepted -> Create Monitor -> Succeeded ########################## + ####################################################################### + def test_ea_subscription_sub_orchestrator_accepted_timer_succeeded(self): + global mock + with patch('azure.durable_functions.DurableOrchestrationContext',spec=df.DurableOrchestrationContext) as mock: + mock.get_input = MagicMock(return_value={ + 'productName' : 'test_product', + 'customerName' : 'microsoft', + 'envName' : 'production', + 'displayName' : 'test' + }) + + # mock call activity that returns subscription response as Accepted + mock.call_activity.side_effect = call_activity_mock_status_check_accepted + mock.create_timer.side_effect = create_timer_mock + mock.current_utc_datetime = datetime.datetime.utcnow() + + accepted_response = SubscriptionResponse("51ba2a78-bec0-4f31-83e7-58c64693a6dd","test","EA",SubscriptionProperties("51ba2a78-bec0-4f31-83e7-58c64693a6dd","Accepted")) + succeeded_response = SubscriptionResponse("51ba2a78-bec0-4f31-83e7-58c64693a6dd","test","EA",SubscriptionProperties("51ba2a78-bec0-4f31-83e7-58c64693a6dd","Succeeded")) + + gen_orchestrator = orchestrator_fn(mock) + try: + # Make a call to Status check to see and if response is accepted (subscription is in process of being created) + next(gen_orchestrator) + + # Send back response to orchestrator + gen_orchestrator.send(accepted_response) + + # Timer is set and now the call succeeds + next(gen_orchestrator) + + # Send back success response to orchestrator + gen_orchestrator.send(succeeded_response) + + except StopIteration as e: + result = e.value + self.assertEqual('51ba2a78-bec0-4f31-83e7-58c64693a6dd',result) + + ####################################################################### + #### Not Found -> Create Sub -> Create Monitor -> Succeeded ########### + ####################################################################### + def test_ea_subscription_sub_orchestrator_notfound_createsub_timer_succeeded(self): + global mock + with patch('azure.durable_functions.DurableOrchestrationContext',spec=df.DurableOrchestrationContext) as mock: + mock.get_input = MagicMock(return_value={ + 'productName' : 'test_product', + 'customerName' : 'microsoft', + 'envName' : 'production', + 'displayName' : 'test' + }) + + # mock call activity that returns subscription response as Accepted + mock.call_activity.side_effect = call_activity_mock_status_check_notfound + mock.create_timer.side_effect = create_timer_mock + mock.current_utc_datetime = datetime.datetime.utcnow() + + notfound_response = SubscriptionResponse("51ba2a78-bec0-4f31-83e7-58c64693a6dd","test","EA",SubscriptionProperties("51ba2a78-bec0-4f31-83e7-58c64693a6dd","NotFound")) + succeeded_response = SubscriptionResponse("51ba2a78-bec0-4f31-83e7-58c64693a6dd","test","EA",SubscriptionProperties("51ba2a78-bec0-4f31-83e7-58c64693a6dd","Succeeded")) + + gen_orchestrator = orchestrator_fn(mock) + try: + # Make a call to Status check to see and if response is accepted (subscription is in process of being created) + next(gen_orchestrator) + + # Send back response to orchestrator + gen_orchestrator.send(notfound_response) + + # Timer is set and now the call succeeds + next(gen_orchestrator) + + # Send back success response to orchestrator + gen_orchestrator.send(succeeded_response) + + except StopIteration as e: + result = e.value + self.assertEqual('51ba2a78-bec0-4f31-83e7-58c64693a6dd',result) + + + # The error case is written as an example here, it truly cannot be tested for the code in the orchestrator as + # the expiry time is not mockable. + def test_ea_subscription_sub_orchestrator_error_case(self): + global mock + with patch('azure.durable_functions.DurableOrchestrationContext',spec=df.DurableOrchestrationContext) as mock: + mock.get_input = MagicMock(return_value={ + 'productName' : 'test_product', + 'customerName' : 'microsoft', + 'envName' : 'production', + 'displayName' : 'test' + }) + + # mock call activity that returns subscription response as Accepted + mock.call_activity.side_effect = call_activity_mock_status_check_accepted + mock.create_timer.side_effect = create_timer_error_mock + mock.current_utc_datetime = datetime.datetime.utcnow() + + accepted_response = SubscriptionResponse("51ba2a78-bec0-4f31-83e7-58c64693a6dd","test","EA",SubscriptionProperties("51ba2a78-bec0-4f31-83e7-58c64693a6dd","Accepted")) + error_response = SubscriptionResponse("51ba2a78-bec0-4f31-83e7-58c64693a6dd","test","EA",SubscriptionProperties("51ba2a78-bec0-4f31-83e7-58c64693a6dd","Error")) + + gen_orchestrator = orchestrator_fn(mock) + try: + # Make a call to Status check to see and if response is accepted (subscription is in process of being created) + next(gen_orchestrator) + + # Send back response to orchestrator + gen_orchestrator.send(accepted_response) + + # Timer is set and now the call succeeds + next(gen_orchestrator) + + # Send back success response to orchestrator + gen_orchestrator.send(error_response) + + self.assertRaises(Exception,orchestrator_fn) + + except StopIteration as e: + pass + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/unit_tests/test_mgmtgroup_suborchestrator.py b/samples/unit_testing/subscription-manager/unit_tests/test_mgmtgroup_suborchestrator.py new file mode 100644 index 00000000..a9427a65 --- /dev/null +++ b/samples/unit_testing/subscription-manager/unit_tests/test_mgmtgroup_suborchestrator.py @@ -0,0 +1,39 @@ +import azure.durable_functions as df +from unittest import main +from unittest import TestCase +from unittest.mock import patch +from ..MgmtGroupSubOrchestrator import orchestrator_fn + +def call_activity_mock_add_sub_to_mgmt_group(activityName: str): + assert activityName == "AddSubscriptionToMgmtGroup" + +""" +Test class for MgmtGroupSubOrchestrator Durable orchestrator that kicks off an activity function +Mocks the DurableOrchestrationContext and checks the sequence of sub-orchestration calls +""" +class TestMgmtGroupSubOrchestrator(TestCase): + + def test_mgmt_group_sub_orchestrator(self): + global mock + with patch('azure.durable_functions.DurableOrchestrationContext',spec=df.DurableOrchestrationContext) as mock: + + # mock call activity that calls to add subscription to management group + mock.call_activity.side_effect = call_activity_mock_add_sub_to_mgmt_group + + generator_fn = orchestrator_fn(mock) + + # send mock response back to generator + mock_response = "Added subscription to management group" + + try: + next(generator_fn) + + # send back mock response so that we can verify in test + generator_fn.send(mock_response) + + except StopIteration as e: + result = e.value + self.assertEqual(result,'Added subscription to management group') + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/unit_tests/test_registerpim_activity.py b/samples/unit_testing/subscription-manager/unit_tests/test_registerpim_activity.py new file mode 100644 index 00000000..eec6b2b0 --- /dev/null +++ b/samples/unit_testing/subscription-manager/unit_tests/test_registerpim_activity.py @@ -0,0 +1,50 @@ +import os +import azure.durable_functions as df +from unittest import main,mock +from unittest import IsolatedAsyncioTestCase +from unittest.mock import patch +from ..RegisterPIM import main +from ..RegisterPIM import update_callback + +""" +Mock callback that intercepts the update callback to inspect the status +""" +def mock_callback(status): + update_callback(status) + + # check if pim_enabled was set to True by the activity function + assert status.pim_enabled == True + return status + +""" +Test class for CreateEASubscription activity function using Local in memory status storage +and canned REST API response from LocalSubscription +""" +class TestRegisterPIMActivity(IsolatedAsyncioTestCase): + + async def test_status_check_activity_valid_inputs(self): + payload = { + 'productName' : 'test_product', + 'customerName' : 'microsoft', + 'envName' : 'production', + 'displayName' : 'test_ea_subscription', + 'subscriptionName' : 'python_subscription' + } + + # Patch the environment variable here for it to take effect inside the durable function activity call + patch_env_mock = mock.patch.dict(os.environ, {"RUNTIME_ENVIRONMENT": "LOCAL", + "STATUS_STORAGE" : "IN-MEMORY"}) + # start mock context + patch_env_mock.start() + + # patch the update callback to intercept the status object and make a call to the actual + # update object to get back a response that can be asserted + with patch("subscription-manager.RegisterPIM.update_callback") as function_mock: + function_mock.side_effect = mock_callback + result = await main(payload) + + # stop mock context + patch_env_mock.stop() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/unit_tests/test_registerpim_suborchestrator.py b/samples/unit_testing/subscription-manager/unit_tests/test_registerpim_suborchestrator.py new file mode 100644 index 00000000..1f48f5d8 --- /dev/null +++ b/samples/unit_testing/subscription-manager/unit_tests/test_registerpim_suborchestrator.py @@ -0,0 +1,52 @@ +import azure.durable_functions as df +import azure.functions as func +from unittest import main +from unittest import TestCase +from unittest.mock import MagicMock, patch +from ..Subscription.subscription import SubscriptionProperties, SubscriptionResponse +from ..RegisterPIMSubOrchestrator import orchestrator_fn + +""" +Mocked Register PIM Activity function +""" +def call_activity_mock_register_pim(activityName: str, payload): + assert activityName == "RegisterPIM" + assert payload["productName"] != None + assert payload["customerName"] != None + assert payload["envName"] != None + +""" +Test class for RegisterPIMSubOrchestrator Durable orchestrator that calls the activity function +Mocks the DurableOrchestrationContext and checks the sequence of sub-orchestration calls +""" +class TestRegisterPIMSubOrchestrator(TestCase): + + def test_register_pim_sub_orchestrator(self): + global mock + with patch('azure.durable_functions.DurableOrchestrationContext',spec=df.DurableOrchestrationContext) as mock: + mock.get_input = MagicMock(return_value={ + 'productName' : 'test_product', + 'customerName' : 'microsoft', + 'envName' : 'production', + 'displayName' : 'test' + }) + + # mock call activity that calls to register PIM status + mock.call_activity.side_effect = call_activity_mock_register_pim + succeeded_response = SubscriptionResponse("51ba2a78-bec0-4f31-83e7-58c64693a6dd","test","EA",SubscriptionProperties("51ba2a78-bec0-4f31-83e7-58c64693a6dd","Succeeded")) + + gen_orchestrator = orchestrator_fn(mock) + try: + # Make a call to Register PIM activity + next(gen_orchestrator) + + # Send back subscription response to orchestrator + gen_orchestrator.send(succeeded_response) + + except StopIteration as e: + result = e.value + self.assertIsInstance(result,SubscriptionResponse) + self.assertEqual('51ba2a78-bec0-4f31-83e7-58c64693a6dd',result.id) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/unit_tests/test_status.py b/samples/unit_testing/subscription-manager/unit_tests/test_status.py new file mode 100644 index 00000000..becd6642 --- /dev/null +++ b/samples/unit_testing/subscription-manager/unit_tests/test_status.py @@ -0,0 +1,25 @@ +from ..Status.status import Status +from unittest import TestCase + +""" +Simple unit tests to make sure serialization methods work properly +as the custom implementations are internally used by the durable python framework +""" +class TestStatus(TestCase): + def test_status_to_json(self): + + my_obj = Status("test","Microsoft/test", + "2021-07-19 13:11:42.477660",False,"Succeeded") + + str_status = Status.to_json(my_obj) + self.assertEqual(str_status,"{'name': 'test', 'id': 'Microsoft/test', 'last_updated_time': '2021-07-19 13:11:42.477660', 'pim_enabled': 'False', 'creation_status': 'Succeeded'}") + + def test_status_from_json(self): + + status_obj_str = "{'name': 'test', 'id': 'Microsoft/test', 'last_updated_time': '2021-07-19 13:11:42.477660', 'pim_enabled': 'False', 'creation_status': 'Succeeded'}" + status_obj = Status.from_json(status_obj_str) + self.assertEqual(status_obj.name,"test") + self.assertEqual(status_obj.id, "Microsoft/test") + self.assertEqual(status_obj.last_updated_time,"2021-07-19 13:11:42.477660") + self.assertEqual(status_obj.pim_enabled, "False") + self.assertEqual(status_obj.creation_status,"Succeeded") \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/unit_tests/test_status_inmemorystatusmanager.py b/samples/unit_testing/subscription-manager/unit_tests/test_status_inmemorystatusmanager.py new file mode 100644 index 00000000..a0c4b800 --- /dev/null +++ b/samples/unit_testing/subscription-manager/unit_tests/test_status_inmemorystatusmanager.py @@ -0,0 +1,37 @@ +from ..Status.inmemorystatusmanager import InMemoryStatusManager +from unittest import IsolatedAsyncioTestCase + +""" +Test class for InMemoryStatusManager that tests status management +""" +class TestInMemoryStatusManager(IsolatedAsyncioTestCase): + + def update_callback(self,status): + status.name = "test_status" + status.creation_status = "Succeeded" + assert status.last_updated_time != None + return status + + async def test_get_new_status(self): + client_name = "test" + status_mgr : InMemoryStatusManager = InMemoryStatusManager(client_name) + self.assertEqual(status_mgr.state_file_name,f"{client_name}.json") + + # Get the status entry + status = await status_mgr.get_status() + self.assertEqual(status.name,"") + + async def test_update_status_with_callback(self): + client_name = "test" + status_mgr : InMemoryStatusManager = InMemoryStatusManager(client_name) + + # create a fresh status entry + status = await status_mgr.get_status() + await status_mgr.safe_update_status(self.update_callback) + updated_status = status_mgr.status_entries[status_mgr.state_file_name] + + # Check the updated status from status entries + self.assertEqual(updated_status.name,"test_status") + self.assertEqual(updated_status.creation_status,"Succeeded") + + diff --git a/samples/unit_testing/subscription-manager/unit_tests/test_statuscheck_activity.py b/samples/unit_testing/subscription-manager/unit_tests/test_statuscheck_activity.py new file mode 100644 index 00000000..c9cd746d --- /dev/null +++ b/samples/unit_testing/subscription-manager/unit_tests/test_statuscheck_activity.py @@ -0,0 +1,49 @@ +import azure.durable_functions as df +import os +from unittest import main,mock +from unittest import IsolatedAsyncioTestCase +from unittest.mock import patch,MagicMock +from ..Subscription.subscription import SubscriptionResponse +from ..StatusCheck import main +from ..StatusCheck import update_callback +from ..Status.status import Status + +""" +Mock callback that intercepts and inspects status +""" +def mock_callback(status: Status): + updated_status = update_callback(status) + + # NotFound is returned by LocalSubscription emulation and we expect the same to be set here + assert updated_status.creation_status == "NotFound" + +""" +Test class for Status Check activity function that checks for valid inputs +""" +class TestStatusCheckActivity(IsolatedAsyncioTestCase): + + async def test_status_check_activity_valid_inputs(self): + payload = { + 'productName' : 'test_product', + 'customerName' : 'microsoft', + 'envName' : 'production', + 'displayName' : 'test', + 'subscriptionName' : 'python_subscription' + } + + # Patch environment variables + patch_env_mock = mock.patch.dict(os.environ, {"RUNTIME_ENVIRONMENT": "LOCAL", + "STATUS_STORAGE" : "IN-MEMORY"}) + patch_env_mock.start() + + # Patch the update callback to intercept and inspect status + with patch("subscription-manager.StatusCheck.update_callback") as function_mock: + function_mock.side_effect = mock_callback + result = await main(payload) + self.assertIsInstance(result,SubscriptionResponse) + self.assertEqual(result.properties.subscriptionId,"1111111-2222-3333-4444-ebc1b75b9d74") + self.assertEqual(result.properties.provisioningState,"NotFound") + patch_env_mock.stop() + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/unit_tests/test_sub_lifecycle_orchestrator.py b/samples/unit_testing/subscription-manager/unit_tests/test_sub_lifecycle_orchestrator.py new file mode 100644 index 00000000..971ca0f2 --- /dev/null +++ b/samples/unit_testing/subscription-manager/unit_tests/test_sub_lifecycle_orchestrator.py @@ -0,0 +1,47 @@ +import azure.durable_functions as df +import azure.functions as func +from unittest import main +from unittest import TestCase +from unittest.mock import MagicMock, patch +from ..Subscription.subscription import SubscriptionProperties, SubscriptionResponse +from ..SubscriptionLifecycleOrchestrator import orchestrator_fn + +""" +Mocks fan in / fan out task_all method +""" +def task_all_mock(tasks:list): + assert len(tasks) == 2 + return list + +""" +Mocks Sub orchestrator calls and makes sure each sub orchestrator call returns the right response +""" +def sub_orc_mock(orchestrator_name:str, payload): + if orchestrator_name == "CreateSubscriptionSubOrchestrator": + return "51ba2a78-bec0-4f31-83e7-58c64693a6dd" + elif orchestrator_name == "RegisterPIMSubOrchestrator": + return SubscriptionResponse("51ba2a78-bec0-4f31-83e7-58c64693a6dd",payload['displayName'],"EA", + SubscriptionProperties("51ba2a78-bec0-4f31-83e7-58c64693a6dd","Accepted")) + else: + return "Added Subscription to Management Group" + +""" +Test class for CreateSubscriptionOrchestrator Durable orchestrator that kicks off sub orchestrations +Mocks the DurableOrchestrationContext and checks the sequence of sub-orchestration calls +""" +class TestCreateEASubscriptionOrchestrator(TestCase): + + def test_create_ea_orchestrator(self): + + global mock + with patch('azure.durable_functions.DurableOrchestrationContext',spec=df.DurableOrchestrationContext) as mock: + mock.get_input = MagicMock(return_value={'displayName' : 'test'}) + mock.call_sub_orchestrator.side_effect = sub_orc_mock + mock.task_all.side_effect = task_all_mock + + # To get generator results do a next. If orchestrator response is a list, then wrap the function call around a list + result = list(orchestrator_fn(mock)) + self.assertEqual('51ba2a78-bec0-4f31-83e7-58c64693a6dd',result[0]) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/samples/unit_testing/test_orchestration.http b/samples/unit_testing/test_orchestration.http new file mode 100644 index 00000000..2046e374 --- /dev/null +++ b/samples/unit_testing/test_orchestration.http @@ -0,0 +1,6 @@ +POST http://localhost:7071/api/product/microsoft +X-MS-CLIENT-PRINCIPAL:ICAgICAgICB7CiAgICAgICAgICAgICJhdXRoX3R5cCI6ICJhYWQiLAogICAgICAgICAgICAiY2xhaW1zIjogW3sKICAgICAgICAgICAgICAgICJ0eXAiOiAiaHR0cDovL3NjaGVtYXMueG1sc29hcC5vcmcvd3MvMjAwNS8wNS9pZGVudGl0eS9jbGFpbXMvc3VybmFtZSIsCiAgICAgICAgICAgICAgICAidmFsIjogIlVzZXIiCiAgICAgICAgICAgIH0sIHsKICAgICAgICAgICAgICAgICJ0eXAiOiAiZ3JvdXBzIiwKICAgICAgICAgICAgICAgICJ2YWwiOiAiZWY2ZDJkMWEtNzhlYi00YmIxLTk3YzctYmI4YThlNTA5ZTljIgogICAgICAgICAgICB9LCB7CiAgICAgICAgICAgICAgICAidHlwIjogImdyb3VwcyIsCiAgICAgICAgICAgICAgICAidmFsIjogIjNiMjMxY2UxLTI5YzEtNDQxZS1iZGRiLTAzM2Y5NjQwMTg4OCIKICAgICAgICAgICAgfSwgewogICAgICAgICAgICAgICAgInR5cCI6ICJuYW1lIiwKICAgICAgICAgICAgICAgICJ2YWwiOiAiVGVzdCBVc2VyIgogICAgICAgICAgICB9XSwKICAgICAgICAgICAgIm5hbWVfdHlwIjogImh0dHA6Ly9zY2hlbWFzLnhtbHNvYXAub3JnL3dzLzIwMDUvMDUvaWRlbnRpdHkvY2xhaW1zL25hbWUiLAogICAgICAgICAgICAicm9sZV90eXAiOiAiaHR0cDovL3NjaGVtYXMubWljcm9zb2Z0LmNvbS93cy8yMDA4LzA2L2lkZW50aXR5L2NsYWltcy9yb2xlIgogICAgICAgIH0= + +{ + "sub_product" : "test" +} \ No newline at end of file