From 2499bd156b1c6d52e673f04c8f03b27cb6ca7b30 Mon Sep 17 00:00:00 2001 From: Priya Ananthasankar Date: Fri, 23 Jul 2021 13:27:01 -0700 Subject: [PATCH 1/3] unit testing sample --- samples/unit_testing/README.md | 166 +++++++++++++++++ samples/unit_testing/run_unit_tests.sh | 17 ++ .../AddSubscriptionToMgmtGroup/__init__.py | 5 + .../AddSubscriptionToMgmtGroup/function.json | 12 ++ .../Auth/authorization.py | 42 +++++ .../subscription-manager/Auth/knowngroups.py | 10 + .../subscription-manager/Auth/usertoken.py | 42 +++++ .../CreateEnvironmentHTTPStart/__init__.py | 45 +++++ .../CreateEnvironmentHTTPStart/function.json | 26 +++ .../CreateSubscription/__init__.py | 33 ++++ .../CreateSubscription/function.json | 12 ++ .../__init__.py | 44 +++++ .../function.json | 11 ++ .../MgmtGroupSubOrchestrator/__init__.py | 11 ++ .../MgmtGroupSubOrchestrator/function.json | 11 ++ .../RegisterPIM/__init__.py | 28 +++ .../RegisterPIM/function.json | 12 ++ .../RegisterPIMSubOrchestrator/__init__.py | 13 ++ .../RegisterPIMSubOrchestrator/function.json | 11 ++ .../subscription-manager/Status/__init__.py | 0 .../Status/inmemorystatusmanager.py | 27 +++ .../subscription-manager/Status/status.py | 54 ++++++ .../StatusCheck/__init__.py | 37 ++++ .../StatusCheck/function.json | 12 ++ .../Subscription/__init__.py | 0 .../Subscription/localsubscription.py | 50 +++++ .../Subscription/subscription.py | 76 ++++++++ .../Subscription/subscriptionmanager.py | 39 ++++ .../__init__.py | 25 +++ .../function.json | 11 ++ .../subscription-manager/host.json | 15 ++ .../subscription-manager/local.settings.json | 8 + .../local.settings.json.example | 11 ++ .../subscription-manager/proxies.json | 4 + .../subscription-manager/requirements.txt | 6 + .../subscription-manager/tests/__init__.py | 0 .../test_createenvhttpstart_invalidauth.py | 75 ++++++++ ...t_createenvhttpstart_invalidrouteparams.py | 55 ++++++ .../test_createenvhttpstart_validauth.py | 53 ++++++ .../tests/test_createsubscription_activity.py | 47 +++++ ...test_createsubscription_suborchestrator.py | 176 ++++++++++++++++++ .../tests/test_mgmtgroup_suborchestrator.py | 39 ++++ .../tests/test_registerpim_activity.py | 50 +++++ .../tests/test_registerpim_suborchestrator.py | 52 ++++++ .../subscription-manager/tests/test_status.py | 25 +++ .../test_status_inmemorystatusmanager.py | 37 ++++ .../tests/test_statuscheck_activity.py | 49 +++++ .../tests/test_sub_lifecycle_orchestrator.py | 47 +++++ samples/unit_testing/test_orchestration.http | 6 + 49 files changed, 1637 insertions(+) create mode 100644 samples/unit_testing/README.md create mode 100644 samples/unit_testing/run_unit_tests.sh create mode 100644 samples/unit_testing/subscription-manager/AddSubscriptionToMgmtGroup/__init__.py create mode 100644 samples/unit_testing/subscription-manager/AddSubscriptionToMgmtGroup/function.json create mode 100644 samples/unit_testing/subscription-manager/Auth/authorization.py create mode 100644 samples/unit_testing/subscription-manager/Auth/knowngroups.py create mode 100644 samples/unit_testing/subscription-manager/Auth/usertoken.py create mode 100644 samples/unit_testing/subscription-manager/CreateEnvironmentHTTPStart/__init__.py create mode 100644 samples/unit_testing/subscription-manager/CreateEnvironmentHTTPStart/function.json create mode 100644 samples/unit_testing/subscription-manager/CreateSubscription/__init__.py create mode 100644 samples/unit_testing/subscription-manager/CreateSubscription/function.json create mode 100644 samples/unit_testing/subscription-manager/CreateSubscriptionSubOrchestrator/__init__.py create mode 100644 samples/unit_testing/subscription-manager/CreateSubscriptionSubOrchestrator/function.json create mode 100644 samples/unit_testing/subscription-manager/MgmtGroupSubOrchestrator/__init__.py create mode 100644 samples/unit_testing/subscription-manager/MgmtGroupSubOrchestrator/function.json create mode 100644 samples/unit_testing/subscription-manager/RegisterPIM/__init__.py create mode 100644 samples/unit_testing/subscription-manager/RegisterPIM/function.json create mode 100644 samples/unit_testing/subscription-manager/RegisterPIMSubOrchestrator/__init__.py create mode 100644 samples/unit_testing/subscription-manager/RegisterPIMSubOrchestrator/function.json create mode 100644 samples/unit_testing/subscription-manager/Status/__init__.py create mode 100644 samples/unit_testing/subscription-manager/Status/inmemorystatusmanager.py create mode 100644 samples/unit_testing/subscription-manager/Status/status.py create mode 100644 samples/unit_testing/subscription-manager/StatusCheck/__init__.py create mode 100644 samples/unit_testing/subscription-manager/StatusCheck/function.json create mode 100644 samples/unit_testing/subscription-manager/Subscription/__init__.py create mode 100644 samples/unit_testing/subscription-manager/Subscription/localsubscription.py create mode 100644 samples/unit_testing/subscription-manager/Subscription/subscription.py create mode 100644 samples/unit_testing/subscription-manager/Subscription/subscriptionmanager.py create mode 100644 samples/unit_testing/subscription-manager/SubscriptionLifecycleOrchestrator/__init__.py create mode 100644 samples/unit_testing/subscription-manager/SubscriptionLifecycleOrchestrator/function.json create mode 100644 samples/unit_testing/subscription-manager/host.json create mode 100644 samples/unit_testing/subscription-manager/local.settings.json create mode 100644 samples/unit_testing/subscription-manager/local.settings.json.example create mode 100644 samples/unit_testing/subscription-manager/proxies.json create mode 100644 samples/unit_testing/subscription-manager/requirements.txt create mode 100644 samples/unit_testing/subscription-manager/tests/__init__.py create mode 100644 samples/unit_testing/subscription-manager/tests/test_createenvhttpstart_invalidauth.py create mode 100644 samples/unit_testing/subscription-manager/tests/test_createenvhttpstart_invalidrouteparams.py create mode 100644 samples/unit_testing/subscription-manager/tests/test_createenvhttpstart_validauth.py create mode 100644 samples/unit_testing/subscription-manager/tests/test_createsubscription_activity.py create mode 100644 samples/unit_testing/subscription-manager/tests/test_createsubscription_suborchestrator.py create mode 100644 samples/unit_testing/subscription-manager/tests/test_mgmtgroup_suborchestrator.py create mode 100644 samples/unit_testing/subscription-manager/tests/test_registerpim_activity.py create mode 100644 samples/unit_testing/subscription-manager/tests/test_registerpim_suborchestrator.py create mode 100644 samples/unit_testing/subscription-manager/tests/test_status.py create mode 100644 samples/unit_testing/subscription-manager/tests/test_status_inmemorystatusmanager.py create mode 100644 samples/unit_testing/subscription-manager/tests/test_statuscheck_activity.py create mode 100644 samples/unit_testing/subscription-manager/tests/test_sub_lifecycle_orchestrator.py create mode 100644 samples/unit_testing/test_orchestration.http diff --git a/samples/unit_testing/README.md b/samples/unit_testing/README.md new file mode 100644 index 00000000..37860c1a --- /dev/null +++ b/samples/unit_testing/README.md @@ -0,0 +1,166 @@ +# 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 \ 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..fbcc8ad3 --- /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/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.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 b/samples/unit_testing/subscription-manager/local.settings.json new file mode 100644 index 00000000..fa0d33d7 --- /dev/null +++ b/samples/unit_testing/subscription-manager/local.settings.json @@ -0,0 +1,8 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "FUNCTIONS_WORKER_RUNTIME": "python", + "SecurityGroups_SUBSCRIPTION_MANAGERS" : "ef6d2d1a-78eb-4bb1-97c7-bb8a8e509e9c" + } +} 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/tests/__init__.py b/samples/unit_testing/subscription-manager/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/samples/unit_testing/subscription-manager/tests/test_createenvhttpstart_invalidauth.py b/samples/unit_testing/subscription-manager/tests/test_createenvhttpstart_invalidauth.py new file mode 100644 index 00000000..40cc4ab0 --- /dev/null +++ b/samples/unit_testing/subscription-manager/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/tests/test_createenvhttpstart_invalidrouteparams.py b/samples/unit_testing/subscription-manager/tests/test_createenvhttpstart_invalidrouteparams.py new file mode 100644 index 00000000..632c8b4f --- /dev/null +++ b/samples/unit_testing/subscription-manager/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/tests/test_createenvhttpstart_validauth.py b/samples/unit_testing/subscription-manager/tests/test_createenvhttpstart_validauth.py new file mode 100644 index 00000000..513609c5 --- /dev/null +++ b/samples/unit_testing/subscription-manager/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/tests/test_createsubscription_activity.py b/samples/unit_testing/subscription-manager/tests/test_createsubscription_activity.py new file mode 100644 index 00000000..b6f9e3db --- /dev/null +++ b/samples/unit_testing/subscription-manager/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/tests/test_createsubscription_suborchestrator.py b/samples/unit_testing/subscription-manager/tests/test_createsubscription_suborchestrator.py new file mode 100644 index 00000000..641dbd15 --- /dev/null +++ b/samples/unit_testing/subscription-manager/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/tests/test_mgmtgroup_suborchestrator.py b/samples/unit_testing/subscription-manager/tests/test_mgmtgroup_suborchestrator.py new file mode 100644 index 00000000..a9427a65 --- /dev/null +++ b/samples/unit_testing/subscription-manager/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/tests/test_registerpim_activity.py b/samples/unit_testing/subscription-manager/tests/test_registerpim_activity.py new file mode 100644 index 00000000..eec6b2b0 --- /dev/null +++ b/samples/unit_testing/subscription-manager/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/tests/test_registerpim_suborchestrator.py b/samples/unit_testing/subscription-manager/tests/test_registerpim_suborchestrator.py new file mode 100644 index 00000000..1f48f5d8 --- /dev/null +++ b/samples/unit_testing/subscription-manager/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/tests/test_status.py b/samples/unit_testing/subscription-manager/tests/test_status.py new file mode 100644 index 00000000..becd6642 --- /dev/null +++ b/samples/unit_testing/subscription-manager/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/tests/test_status_inmemorystatusmanager.py b/samples/unit_testing/subscription-manager/tests/test_status_inmemorystatusmanager.py new file mode 100644 index 00000000..a0c4b800 --- /dev/null +++ b/samples/unit_testing/subscription-manager/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/tests/test_statuscheck_activity.py b/samples/unit_testing/subscription-manager/tests/test_statuscheck_activity.py new file mode 100644 index 00000000..c9cd746d --- /dev/null +++ b/samples/unit_testing/subscription-manager/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/tests/test_sub_lifecycle_orchestrator.py b/samples/unit_testing/subscription-manager/tests/test_sub_lifecycle_orchestrator.py new file mode 100644 index 00000000..971ca0f2 --- /dev/null +++ b/samples/unit_testing/subscription-manager/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 From 9d42e4276c29d179e49f969305dbdead8b0f5893 Mon Sep 17 00:00:00 2001 From: Priya Ananthasankar Date: Fri, 23 Jul 2021 13:28:02 -0700 Subject: [PATCH 2/3] unit testing sample --- .../unit_testing/subscription-manager/local.settings.json | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 samples/unit_testing/subscription-manager/local.settings.json diff --git a/samples/unit_testing/subscription-manager/local.settings.json b/samples/unit_testing/subscription-manager/local.settings.json deleted file mode 100644 index fa0d33d7..00000000 --- a/samples/unit_testing/subscription-manager/local.settings.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "IsEncrypted": false, - "Values": { - "AzureWebJobsStorage": "UseDevelopmentStorage=true", - "FUNCTIONS_WORKER_RUNTIME": "python", - "SecurityGroups_SUBSCRIPTION_MANAGERS" : "ef6d2d1a-78eb-4bb1-97c7-bb8a8e509e9c" - } -} From 5494962583ff12794d3c856f098e0bded8d74069 Mon Sep 17 00:00:00 2001 From: Priya Ananthasankar Date: Wed, 4 Aug 2021 13:17:04 -0700 Subject: [PATCH 3/3] changed tests directory --- samples/unit_testing/README.md | 16 +++++++++++++++- samples/unit_testing/run_unit_tests.sh | 4 ++-- .../{tests => unit_tests}/__init__.py | 0 .../test_createenvhttpstart_invalidauth.py | 0 ...test_createenvhttpstart_invalidrouteparams.py | 0 .../test_createenvhttpstart_validauth.py | 0 .../test_createsubscription_activity.py | 0 .../test_createsubscription_suborchestrator.py | 0 .../test_mgmtgroup_suborchestrator.py | 0 .../test_registerpim_activity.py | 0 .../test_registerpim_suborchestrator.py | 0 .../{tests => unit_tests}/test_status.py | 0 .../test_status_inmemorystatusmanager.py | 0 .../test_statuscheck_activity.py | 0 .../test_sub_lifecycle_orchestrator.py | 0 15 files changed, 17 insertions(+), 3 deletions(-) rename samples/unit_testing/subscription-manager/{tests => unit_tests}/__init__.py (100%) rename samples/unit_testing/subscription-manager/{tests => unit_tests}/test_createenvhttpstart_invalidauth.py (100%) rename samples/unit_testing/subscription-manager/{tests => unit_tests}/test_createenvhttpstart_invalidrouteparams.py (100%) rename samples/unit_testing/subscription-manager/{tests => unit_tests}/test_createenvhttpstart_validauth.py (100%) rename samples/unit_testing/subscription-manager/{tests => unit_tests}/test_createsubscription_activity.py (100%) rename samples/unit_testing/subscription-manager/{tests => unit_tests}/test_createsubscription_suborchestrator.py (100%) rename samples/unit_testing/subscription-manager/{tests => unit_tests}/test_mgmtgroup_suborchestrator.py (100%) rename samples/unit_testing/subscription-manager/{tests => unit_tests}/test_registerpim_activity.py (100%) rename samples/unit_testing/subscription-manager/{tests => unit_tests}/test_registerpim_suborchestrator.py (100%) rename samples/unit_testing/subscription-manager/{tests => unit_tests}/test_status.py (100%) rename samples/unit_testing/subscription-manager/{tests => unit_tests}/test_status_inmemorystatusmanager.py (100%) rename samples/unit_testing/subscription-manager/{tests => unit_tests}/test_statuscheck_activity.py (100%) rename samples/unit_testing/subscription-manager/{tests => unit_tests}/test_sub_lifecycle_orchestrator.py (100%) diff --git a/samples/unit_testing/README.md b/samples/unit_testing/README.md index 37860c1a..1f853a50 100644 --- a/samples/unit_testing/README.md +++ b/samples/unit_testing/README.md @@ -44,6 +44,8 @@ We are patching the group-id that gets base64 decoded and compared with the abov 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: @@ -79,6 +81,8 @@ 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. @@ -107,6 +111,8 @@ gen_orchestrator = orchestrator_fn(mock) 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. @@ -127,6 +133,7 @@ with patch("subscription-manager.StatusCheck.update_callback") as function_mock: patch_env_mock.stop() ``` +--- ## Unit testing internal Callback methods The subscription manager uses a custom callback that gets called from another method invoked @@ -156,11 +163,18 @@ def mock_callback(status: 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 \ No newline at end of file +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 index fbcc8ad3..f67230a6 100644 --- a/samples/unit_testing/run_unit_tests.sh +++ b/samples/unit_testing/run_unit_tests.sh @@ -2,7 +2,7 @@ # Bash script to run all unit tests from tests folder. # Make sure the test name is of the format "test_*.py" -TESTS="./subscription-manager/tests" +TESTS="./subscription-manager/unit_tests" for TEST_NAME in $TESTS/* do # Remove non-tests @@ -13,5 +13,5 @@ do # 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.tests.$SUFFIX_NAME + python -m unittest subscription-manager.unit_tests.$SUFFIX_NAME done \ No newline at end of file diff --git a/samples/unit_testing/subscription-manager/tests/__init__.py b/samples/unit_testing/subscription-manager/unit_tests/__init__.py similarity index 100% rename from samples/unit_testing/subscription-manager/tests/__init__.py rename to samples/unit_testing/subscription-manager/unit_tests/__init__.py diff --git a/samples/unit_testing/subscription-manager/tests/test_createenvhttpstart_invalidauth.py b/samples/unit_testing/subscription-manager/unit_tests/test_createenvhttpstart_invalidauth.py similarity index 100% rename from samples/unit_testing/subscription-manager/tests/test_createenvhttpstart_invalidauth.py rename to samples/unit_testing/subscription-manager/unit_tests/test_createenvhttpstart_invalidauth.py diff --git a/samples/unit_testing/subscription-manager/tests/test_createenvhttpstart_invalidrouteparams.py b/samples/unit_testing/subscription-manager/unit_tests/test_createenvhttpstart_invalidrouteparams.py similarity index 100% rename from samples/unit_testing/subscription-manager/tests/test_createenvhttpstart_invalidrouteparams.py rename to samples/unit_testing/subscription-manager/unit_tests/test_createenvhttpstart_invalidrouteparams.py diff --git a/samples/unit_testing/subscription-manager/tests/test_createenvhttpstart_validauth.py b/samples/unit_testing/subscription-manager/unit_tests/test_createenvhttpstart_validauth.py similarity index 100% rename from samples/unit_testing/subscription-manager/tests/test_createenvhttpstart_validauth.py rename to samples/unit_testing/subscription-manager/unit_tests/test_createenvhttpstart_validauth.py diff --git a/samples/unit_testing/subscription-manager/tests/test_createsubscription_activity.py b/samples/unit_testing/subscription-manager/unit_tests/test_createsubscription_activity.py similarity index 100% rename from samples/unit_testing/subscription-manager/tests/test_createsubscription_activity.py rename to samples/unit_testing/subscription-manager/unit_tests/test_createsubscription_activity.py diff --git a/samples/unit_testing/subscription-manager/tests/test_createsubscription_suborchestrator.py b/samples/unit_testing/subscription-manager/unit_tests/test_createsubscription_suborchestrator.py similarity index 100% rename from samples/unit_testing/subscription-manager/tests/test_createsubscription_suborchestrator.py rename to samples/unit_testing/subscription-manager/unit_tests/test_createsubscription_suborchestrator.py diff --git a/samples/unit_testing/subscription-manager/tests/test_mgmtgroup_suborchestrator.py b/samples/unit_testing/subscription-manager/unit_tests/test_mgmtgroup_suborchestrator.py similarity index 100% rename from samples/unit_testing/subscription-manager/tests/test_mgmtgroup_suborchestrator.py rename to samples/unit_testing/subscription-manager/unit_tests/test_mgmtgroup_suborchestrator.py diff --git a/samples/unit_testing/subscription-manager/tests/test_registerpim_activity.py b/samples/unit_testing/subscription-manager/unit_tests/test_registerpim_activity.py similarity index 100% rename from samples/unit_testing/subscription-manager/tests/test_registerpim_activity.py rename to samples/unit_testing/subscription-manager/unit_tests/test_registerpim_activity.py diff --git a/samples/unit_testing/subscription-manager/tests/test_registerpim_suborchestrator.py b/samples/unit_testing/subscription-manager/unit_tests/test_registerpim_suborchestrator.py similarity index 100% rename from samples/unit_testing/subscription-manager/tests/test_registerpim_suborchestrator.py rename to samples/unit_testing/subscription-manager/unit_tests/test_registerpim_suborchestrator.py diff --git a/samples/unit_testing/subscription-manager/tests/test_status.py b/samples/unit_testing/subscription-manager/unit_tests/test_status.py similarity index 100% rename from samples/unit_testing/subscription-manager/tests/test_status.py rename to samples/unit_testing/subscription-manager/unit_tests/test_status.py diff --git a/samples/unit_testing/subscription-manager/tests/test_status_inmemorystatusmanager.py b/samples/unit_testing/subscription-manager/unit_tests/test_status_inmemorystatusmanager.py similarity index 100% rename from samples/unit_testing/subscription-manager/tests/test_status_inmemorystatusmanager.py rename to samples/unit_testing/subscription-manager/unit_tests/test_status_inmemorystatusmanager.py diff --git a/samples/unit_testing/subscription-manager/tests/test_statuscheck_activity.py b/samples/unit_testing/subscription-manager/unit_tests/test_statuscheck_activity.py similarity index 100% rename from samples/unit_testing/subscription-manager/tests/test_statuscheck_activity.py rename to samples/unit_testing/subscription-manager/unit_tests/test_statuscheck_activity.py diff --git a/samples/unit_testing/subscription-manager/tests/test_sub_lifecycle_orchestrator.py b/samples/unit_testing/subscription-manager/unit_tests/test_sub_lifecycle_orchestrator.py similarity index 100% rename from samples/unit_testing/subscription-manager/tests/test_sub_lifecycle_orchestrator.py rename to samples/unit_testing/subscription-manager/unit_tests/test_sub_lifecycle_orchestrator.py