From bd86e3be9d88f41835dbe23f97dc22d9621cfd91 Mon Sep 17 00:00:00 2001
From: stealthycoin <stealthycoin@users.noreply.github.com>
Date: Tue, 22 Aug 2017 18:04:16 -0700
Subject: [PATCH] Add a bunch of tests and fixed the matcher

---
 chalice/local.py         | 124 ++++++++++++++++++----------
 tests/unit/test_local.py | 170 +++++++++++++++++++++++++++++++++++++--
 2 files changed, 246 insertions(+), 48 deletions(-)

diff --git a/chalice/local.py b/chalice/local.py
index 55e92935e..740b80f66 100644
--- a/chalice/local.py
+++ b/chalice/local.py
@@ -20,9 +20,11 @@
 from chalice.app import CORSConfig  # noqa
 from chalice.app import ChaliceAuthorizer  # noqa
 from chalice.app import RouteEntry  # noqa
+from chalice.app import Request  # noqa
 from chalice.app import AuthResponse  # noqa
 from chalice.app import BuiltinAuthConfig  # noqa
 from chalice.config import Config  # noqa
+
 from chalice.compat import urlparse, parse_qs
 
 
@@ -46,6 +48,57 @@ def create_local_server(app_obj, config, port):
     return LocalDevServer(app_obj, config, port)
 
 
+class LocalArnBuilder(object):
+    ARN_FORMAT = ('arn:aws:execute-api:{region}:{account_id}'
+                  ':{api_id}/{stage}/{method}/{resource_path}')
+    LOCAL_REGION = 'mars-west-1'
+    LOCAL_ACCOUNT_ID = '123456789012'
+    LOCAL_API_ID = 'ymy8tbxw7b'
+    LOCAL_STAGE = 'api'
+
+    def build_arn(self, method, path):
+        # type: (str, str) -> str
+        # In API Gateway the method and URI are separated by a / so typically
+        # the uri portion omits the leading /. In the case where the entire
+        # url is just '/' API Gateway adds a / to the end so that the arn end
+        # with a '//'.
+        if path != '/':
+            path = path[1:]
+        return self.ARN_FORMAT.format(
+            region=self.LOCAL_REGION,
+            account_id=self.LOCAL_ACCOUNT_ID,
+            api_id=self.LOCAL_API_ID,
+            stage=self.LOCAL_STAGE,
+            method=method,
+            resource_path=path
+        )
+
+
+class ArnMatcher(object):
+    def __init__(self, target_arn):
+        # type: (str) -> None
+        self._arn = target_arn
+
+    def _resource_match(self, resource):
+        # type: (str) -> bool
+        # Arn matching supports two special case characetrs that are not
+        # escapable. * represents a glob which translates to a non-greedy
+        # match of any number of characters. ? which is any single character.
+        # These are easy to translate to a regex using .*? and . respectivly.
+        escaped_resource = re.escape(resource)
+        resource_regex = escaped_resource.replace(r'\?', '.').replace(
+            r'\*', '.*?')
+        matcher = re.compile(resource_regex)
+        return matcher.match(self._arn) is not None
+
+    def does_any_resource_match(self, resources):
+        # type: (List[str]) -> bool
+        for resource in resources:
+            if self._resource_match(resource):
+                return True
+        return False
+
+
 class RouteMatcher(object):
     def __init__(self, route_urls):
         # type: (List[str]) -> None
@@ -149,12 +202,10 @@ def __init__(self, function_name, memory_size,
         self._time_source = time_source
         self._start_time = self._current_time_millis()
         self._max_runtime = runtime_millis
-        self._initialize_lambda_context_attributes(
-            function_name, memory_size)
 
-    def _initialize_lambda_context_attributes(self, function_name,
-                                              memory_size):
-        # type: (str, int) -> None
+        # Below are properties that are found on the real LambdaContext passed
+        # by lambda and their associated documentation.
+
         # Name of the Lambda function that is executing.
         self.function_name = function_name
 
@@ -216,10 +267,14 @@ class LocalGatewayAuthorizer(object):
     def __init__(self, app_object):
         # type: (Chalice) -> None
         self._app_object = app_object
+        self._arn_builder = LocalArnBuilder()
 
     def authorize(self, lambda_event, lambda_context):
         # type: (EventType, LambdaContext) -> Tuple[EventType, LambdaContext]
-        auth_event = self._prepare_authorizer_event(lambda_event)
+        method = lambda_event['requestContext']['httpMethod']
+        path = lambda_event['requestContext']['resourcePath']
+        arn = self._arn_builder.build_arn(method, path)
+        auth_event = self._prepare_authorizer_event(arn, lambda_event)
         route_entry = self._route_for_event(lambda_event)
         if not route_entry:
             return lambda_event, lambda_context
@@ -230,16 +285,15 @@ def authorize(self, lambda_event, lambda_context):
             # Currently the only supported local authorizer is the
             # BuiltinAuthConfig type. Anything else we will err on the side of
             # allowing local testing by simply admiting the request. Otherwise
-            # there is no way for users to test their code in local mode
-            warnings.warn((
+            # there is no way for users to test their code in local mode.
+            warnings.warn(
                 '%s is not a supported in local mode. All requests made '
-                'against a route will be authorized to allow local testing.')
+                'against a route will be authorized to allow local testing.'
                 % authorizer.__class__.__name__
             )
             return lambda_event, lambda_context
         auth_result = authorizer(auth_event, lambda_context)
-        authed = self._check_can_invoke_view_function(
-            route_entry, auth_result)
+        authed = self._check_can_invoke_view_function(arn, auth_result)
         if authed:
             lambda_event = self._update_lambda_event(lambda_event, auth_result)
         else:
@@ -248,37 +302,19 @@ def authorize(self, lambda_event, lambda_context):
                  'x-amzn-ErrorType': 'UnauthorizedException'})
         return lambda_event, lambda_context
 
-    def _check_can_invoke_view_function(self, route_entry, auth_result):
-        # type: (RouteEntry, ResponseType) -> bool
+    def _check_can_invoke_view_function(self, arn, auth_result):
+        # type: (str, ResponseType) -> bool
         policy = auth_result.get('policyDocument', {})
         statements = policy.get('Statement', [])
-        # Look for a statement that allows us to invoke the route_entry
-        # it will be in the form local:local/METHOD/uri the URI is always
-        # prefixed with / so we do not need to explicitly include it in our
-        # pattern.
-        # The method can be either an explicit method such as POST, GET, etc...
-        # or it can be a *. We also need to match for any prefix since the
-        # user can return any policy at all from the custom authorizer.
-        pattern_suffix = ''
-        if route_entry.uri_pattern == '/':
-            # There is a special case when the / is used since the API Gateway
-            # arn will have the / duplicated. On all other routes it will
-            # omit the leading /.
-            pattern_suffix = '/'
-        method_pattern = r'(?:\*|%s)' % route_entry.method
-        route_entry_pattern = r'^.*?/%s%s%s$' % (method_pattern,
-                                                 route_entry.uri_pattern,
-                                                 pattern_suffix)
-        route_entry_re = re.compile(route_entry_pattern)
-        # Look through all statements for one that allows us to invoke this
-        # particular view function.
+        allow_resource_statements = []
         for statement in statements:
             if statement.get('Effect') == 'Allow' and \
                statement.get('Action') == 'execute-api:Invoke':
                 for resource in statement.get('Resource'):
-                    if route_entry_re.match(resource):
-                        return True
-        return False
+                    allow_resource_statements.append(resource)
+
+        arn_matcher = ArnMatcher(arn)
+        return arn_matcher.does_any_resource_match(allow_resource_statements)
 
     def _route_for_event(self, lambda_event):
         # type: (EventType) -> Optional[RouteEntry]
@@ -306,14 +342,14 @@ def _update_lambda_event(self, lambda_event, auth_result):
         lambda_event['requestContext']['authorizer'] = auth_context
         return lambda_event
 
-    def _prepare_authorizer_event(self, lambda_event):
-        # type: (EventType) -> EventType
+    def _prepare_authorizer_event(self, arn, lambda_event):
+        # type: (str, EventType) -> EventType
         """Translate event for an authorizer input."""
         authorizer_event = lambda_event.copy()
         authorizer_event['type'] = 'TOKEN'
         authorizer_event['authorizationToken'] = authorizer_event.get(
             'headers', {}).get('authorization', '')
-        authorizer_event['methodArn'] = 'local'
+        authorizer_event['methodArn'] = arn
         return authorizer_event
 
 
@@ -352,7 +388,14 @@ def _has_user_defined_options_method(self, lambda_event):
     def handle_request(self, method, path, headers, body):
         # type: (str, str, HeaderType, str) -> ResponseType
         lambda_context = self._generate_lambda_context()
-        lambda_event = self._generate_lambda_event(method, path, headers, body)
+        try:
+            lambda_event = self._generate_lambda_event(
+                method, path, headers, body)
+        except ValueError:
+            raise NotAuthorizedError(
+                {'x-amzn-RequestId': lambda_context.aws_request_id,
+                 'x-amzn-ErrorType': 'UnauthorizedException'})
+
         # This can either be because the user's provided an OPTIONS method
         # *or* this is a preflight request, which chalice automatically
         # responds to without invoking a user defined route.
@@ -389,7 +432,6 @@ def _autogen_options_headers(self, lambda_event):
         # So our local version needs to add this manually to our set of allowed
         # headers.
         route_methods.append('OPTIONS')
-        route_methods = route_methods
 
         # The Access-Control-Allow-Methods header is not added by the
         # CORSConfig object it is added to the API Gateway route during
diff --git a/tests/unit/test_local.py b/tests/unit/test_local.py
index 77707ce94..0fdb5e699 100644
--- a/tests/unit/test_local.py
+++ b/tests/unit/test_local.py
@@ -11,13 +11,14 @@
 from chalice import IAMAuthorizer
 from chalice.config import Config
 from chalice.local import LambdaContext
+from chalice.local import LocalArnBuilder
 from chalice.local import LocalGateway
 from chalice.local import LocalGatewayAuthorizer
 from chalice.local import NotAuthorizedError
 from tests.unit.conftest import create_event
 
 
-AWS_REQUEST_PATTERN = re.compile(
+AWS_REQUEST_ID_PATTERN = re.compile(
     '^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$',
     re.I)
 
@@ -35,6 +36,11 @@ def finish(self):
         pass
 
 
+@pytest.fixture
+def arn_builder():
+    return LocalArnBuilder()
+
+
 @pytest.fixture
 def lambda_context_args():
     # LambdaContext has several positional args before the ones that we
@@ -132,8 +138,8 @@ def auth_with_explicit_policy(auth_request):
                             'Action': 'execute-api:Invoke',
                             'Effect': 'Allow',
                             'Resource':
-                            ["arn:aws:execute-api:us-west-2:123456789011:"
-                             "0123456abc/api/GET/explicit"]
+                            ["arn:aws:execute-api:mars-west-1:123456789012:"
+                             "ymy8tbxw7b/api/GET/explicit"]
                         }
                     ]
                 }
@@ -149,8 +155,8 @@ def auth_with_explicit_policy(auth_request):
                             'Action': 'execute-api:Invoke',
                             'Effect': 'Deny',
                             'Resource':
-                            ["arn:aws:execute-api:us-west-2:123456789011:"
-                             "012345 6abc/api/GET/explicit"]
+                            ["arn:aws:execute-api:mars-west-1:123456789012:"
+                             "ymy8tbxw7b/api/GET/explicit"]
                         }
                     ]
                 }
@@ -164,6 +170,14 @@ def demo_auth(auth_request):
         else:
             return app.AuthResponse(routes=[], principal_id='user')
 
+    @demo.authorizer()
+    def all_auth(auth_request):
+        token = auth_request.token
+        if token == 'allow':
+            return app.AuthResponse(routes=['*'], principal_id='user')
+        else:
+            return app.AuthResponse(routes=[], principal_id='user')
+
     @demo.authorizer()
     def landing_page_auth(auth_request):
         token = auth_request.token
@@ -186,6 +200,10 @@ def index_view():
     def secret_view():
         return {}
 
+    @demo.route('/secret/{value}', authorizer=all_auth)
+    def secret_view_value(value):
+        return {'secret': value}
+
     @demo.route('/explicit', authorizer=auth_with_explicit_policy)
     def explicit():
         return {}
@@ -587,7 +605,7 @@ def test_can_get_remaining_time_multiple(self, lambda_context_args):
     def test_does_populate_aws_request_id_with_valid_uuid(self,
                                                           lambda_context_args):
         context = LambdaContext(*lambda_context_args)
-        assert AWS_REQUEST_PATTERN.match(context.aws_request_id)
+        assert AWS_REQUEST_ID_PATTERN.match(context.aws_request_id)
 
     def test_does_set_version_to_latest(self, lambda_context_args):
         context = LambdaContext(*lambda_context_args)
@@ -633,13 +651,25 @@ def context_view():
         assert body['memory'] == 256
         assert body['version'] == '$LATEST'
         assert body['timeout'] <= 10
-        assert AWS_REQUEST_PATTERN.match(body['request_id'])
+        assert AWS_REQUEST_ID_PATTERN.match(body['request_id'])
+
+    def test_can_validate_route_with_variables(self, demo_app_auth):
+        gateway = LocalGateway(demo_app_auth, Config())
+        response = gateway.handle_request(
+            'GET', '/secret/foobar', {'Authorization': 'allow'}, '')
+        json_body = json.loads(response['body'])
+        assert json_body['secret'] == 'foobar'
 
     def test_does_reject_unauthed_request(self, demo_app_auth):
         gateway = LocalGateway(demo_app_auth, Config())
         with pytest.raises(NotAuthorizedError):
             gateway.handle_request('GET', '/index', {}, '')
 
+    def test_does_reject_when_route_not_found(self, demo_app_auth):
+        gateway = LocalGateway(demo_app_auth, Config())
+        with pytest.raises(NotAuthorizedError):
+            gateway.handle_request('GET', '/badindex', {}, '')
+
 
 class TestLocalBuiltinAuthorizers(object):
     def test_can_authorize_empty_path(self, lambda_context_args,
@@ -744,3 +774,129 @@ def test_can_understand_explicit_deny_policy(self, demo_app_auth,
         context = LambdaContext(*lambda_context_args)
         with pytest.raises(NotAuthorizedError):
             authorizer.authorize(event, context)
+
+
+class TestArnBuilder(object):
+    def test_can_create_basic_arn(self, arn_builder):
+        arn = ('arn:aws:execute-api:mars-west-1:123456789012:ymy8tbxw7b'
+               '/api/GET/resource')
+        built_arn = arn_builder.build_arn('GET', '/resource')
+        assert arn == built_arn
+
+    def test_can_create_root_arn(self, arn_builder):
+        arn = ('arn:aws:execute-api:mars-west-1:123456789012:ymy8tbxw7b'
+               '/api/GET//')
+        built_arn = arn_builder.build_arn('GET', '/')
+        assert arn == built_arn
+
+    def test_can_create_multi_part_arn(self, arn_builder):
+        arn = ('arn:aws:execute-api:mars-west-1:123456789012:ymy8tbxw7b'
+               '/api/GET/path/to/resource')
+        built_arn = arn_builder.build_arn('GET', '/path/to/resource')
+        assert arn == built_arn
+
+    def test_can_create_glob_method_arn(self, arn_builder):
+        arn = ('arn:aws:execute-api:mars-west-1:123456789012:ymy8tbxw7b'
+               '/api/*/resource')
+        built_arn = arn_builder.build_arn('*', '/resource')
+        assert arn == built_arn
+
+
+@pytest.mark.parametrize('arn,pattern', [
+    ('mars-west-2:123456789012:ymy8tbxw7b/api/GET/foo',
+     'mars-west-2:123456789012:ymy8tbxw7b/api/GET/foo'
+     ),
+    ('mars-west-1:123456789012:ymy8tbxw7b/api/GET/foobar',
+     'mars-west-1:123456789012:ymy8tbxw7b/api/GET/*'
+     ),
+    ('mars-west-1:123456789012:ymy8tbxw7b/api/PUT/foobar',
+     'mars-west-1:123456789012:ymy8tbxw7b/api/???/foobar'
+     ),
+    ('mars-west-1:123456789012:ymy8tbxw7b/api/GET/foobar',
+     'mars-west-1:123456789012:ymy8tbxw7b/api/???/*'
+     ),
+    ('mars-west-1:123456789012:ymy8tbxw7b/api/GET/foobar',
+     'mars-west-1:123456789012:*/api/GET/*'
+     ),
+    ('mars-west-2:123456789012:ymy8tbxw7b/api/GET/foobar',
+     '*'
+     ),
+    ('mars-west-2:123456789012:ymy8tbxw7b/api/GET/foo.bar',
+     'mars-west-2:123456789012:ymy8tbxw7b/*/GET/*')
+])
+def test_can_allow_route_arns(arn, pattern):
+    prefix = 'arn:aws:execute-api:'
+    full_arn = '%s%s' % (prefix, arn)
+    full_pattern = '%s%s' % (prefix, pattern)
+    matcher = local.ArnMatcher(full_arn)
+    does_match = matcher.does_any_resource_match([full_pattern])
+    assert does_match is True
+
+
+@pytest.mark.parametrize('arn,pattern', [
+    ('mars-west-1:123456789012:ymy8tbxw7b/api/GET/foobar',
+     'mars-west-1:123456789012:ymy8tbxw7b/api/PUT/*'
+     ),
+    ('mars-west-1:123456789012:ymy8tbxw7b/api/GET/foobar',
+     'mars-west-1:123456789012:ymy8tbxw7b/api/??/foobar'
+     ),
+    ('mars-west-1:123456789012:ymy8tbxw7b/api/GET/foobar',
+     'mars-west-2:123456789012:ymy8tbxw7b/api/???/*'
+     ),
+    ('mars-west-2:123456789012:ymy8tbxw7b/api/GET/foobar',
+     'mars-west-2:123456789012:ymy8tbxw7b/*/GET/foo...')
+])
+def test_can_deny_route_arns(arn, pattern):
+    prefix = 'arn:aws:execute-api:'
+    full_arn = '%s%s' % (prefix, arn)
+    full_pattern = '%s%s' % (prefix, pattern)
+    matcher = local.ArnMatcher(full_arn)
+    does_match = matcher.does_any_resource_match([full_pattern])
+    assert does_match is False
+
+
+@pytest.mark.parametrize('arn,patterns', [
+    ('mars-west-1:123456789012:ymy8tbxw7b/api/GET/foobar',
+     [
+         'mars-west-1:123456789012:ymy8tbxw7b/api/PUT/*',
+         'mars-west-1:123456789012:ymy8tbxw7b/api/GET/foobar'
+     ]),
+    ('mars-west-1:123456789012:ymy8tbxw7b/api/GET/foobar',
+     [
+         'mars-west-1:123456789012:ymy8tbxw7b/api/GET/foobar',
+         'mars-west-1:123456789012:ymy8tbxw7b/api/PUT/*'
+     ]),
+    ('mars-west-1:123456789012:ymy8tbxw7b/api/GET/foobar',
+     [
+         'mars-west-1:123456789012:ymy8tbxw7b/api/PUT/foobar',
+         '*'
+     ])
+])
+def test_can_allow_multiple_resource_arns(arn, patterns):
+    prefix = 'arn:aws:execute-api:'
+    full_arn = '%s%s' % (prefix, arn)
+    full_patterns = ['%s%s' % (prefix, pattern) for pattern in patterns]
+    matcher = local.ArnMatcher(full_arn)
+    does_match = matcher.does_any_resource_match(full_patterns)
+    assert does_match is True
+
+
+@pytest.mark.parametrize('arn,patterns', [
+    ('mars-west-1:123456789012:ymy8tbxw7b/api/GET/foobar',
+     [
+         'mars-west-1:123456789012:ymy8tbxw7b/api/POST/*',
+         'mars-west-1:123456789012:ymy8tbxw7b/api/PUT/foobar'
+     ]),
+    ('mars-west-1:123456789012:ymy8tbxw7b/api/GET/foobar',
+     [
+         'mars-west-2:123456789012:ymy8tbxw7b/api/GET/foobar',
+         'mars-west-2:123456789012:ymy8tbxw7b/api/*/*'
+     ])
+])
+def test_can_deny_multiple_resource_arns(arn, patterns):
+    prefix = 'arn:aws:execute-api:'
+    full_arn = '%s%s' % (prefix, arn)
+    full_patterns = ['%s%s' % (prefix, pattern) for pattern in patterns]
+    matcher = local.ArnMatcher(full_arn)
+    does_match = matcher.does_any_resource_match(full_patterns)
+    assert does_match is False