diff --git a/azure_functions_worker/bindings/datumdef.py b/azure_functions_worker/bindings/datumdef.py index 2257d38b0..8449219d7 100644 --- a/azure_functions_worker/bindings/datumdef.py +++ b/azure_functions_worker/bindings/datumdef.py @@ -1,10 +1,19 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. - +import logging from typing import Any, Optional import json from .. import protos from ..logging import logger +from typing import List +try: + from http.cookies import SimpleCookie +except ImportError: + from Cookie import SimpleCookie +from dateutil import parser +from dateutil.parser import ParserError +from .nullable_converters import to_nullable_bool, to_nullable_string, \ + to_nullable_double, to_nullable_timestamp class Datum: @@ -99,8 +108,8 @@ def from_rpc_shared_memory( shmem: protos.RpcSharedMemory, shmem_mgr) -> Optional['Datum']: """ - Reads the specified shared memory region and converts the read data into - a datum object of the corresponding type. + Reads the specified shared memory region and converts the read data + into a datum object of the corresponding type. """ if shmem is None: logger.warning('Cannot read from shared memory. ' @@ -183,6 +192,7 @@ def datum_as_proto(datum: Datum) -> protos.TypedData: k: v.value for k, v in datum.value['headers'].items() }, + cookies=parse_to_rpc_http_cookie_list(datum.value['cookies']), enable_content_negotiation=False, body=datum_as_proto(datum.value['body']), )) @@ -190,3 +200,75 @@ def datum_as_proto(datum: Datum) -> protos.TypedData: raise NotImplementedError( 'unexpected Datum type: {!r}'.format(datum.type) ) + + +def parse_to_rpc_http_cookie_list(cookies: Optional[List[SimpleCookie]]): + if cookies is None: + return cookies + + rpc_http_cookies = [] + + for cookie in cookies: + for name, cookie_entity in cookie.items(): + rpc_http_cookies.append( + protos.RpcHttpCookie(name=name, + value=cookie_entity.value, + domain=to_nullable_string( + cookie_entity['domain'], + 'cookie.domain'), + path=to_nullable_string( + cookie_entity['path'], 'cookie.path'), + expires=to_nullable_timestamp( + parse_cookie_attr_expires( + cookie_entity), 'cookie.expires'), + secure=to_nullable_bool( + bool(cookie_entity['secure']), + 'cookie.secure'), + http_only=to_nullable_bool( + bool(cookie_entity['httponly']), + 'cookie.httpOnly'), + same_site=parse_cookie_attr_same_site( + cookie_entity), + max_age=to_nullable_double( + cookie_entity['max-age'], + 'cookie.maxAge'))) + + return rpc_http_cookies + + +def parse_cookie_attr_expires(cookie_entity): + expires = cookie_entity['expires'] + + if expires is not None and len(expires) != 0: + try: + return parser.parse(expires) + except ParserError: + logging.error( + f"Can not parse value {expires} of expires in the cookie " + f"due to invalid format.") + raise + except OverflowError: + logging.error( + f"Can not parse value {expires} of expires in the cookie " + f"because the parsed date exceeds the largest valid C " + f"integer on your system.") + raise + + return None + + +def parse_cookie_attr_same_site(cookie_entity): + same_site = getattr(protos.RpcHttpCookie.SameSite, "None") + try: + raw_same_site_str = cookie_entity['samesite'].lower() + + if raw_same_site_str == 'lax': + same_site = protos.RpcHttpCookie.SameSite.Lax + elif raw_same_site_str == 'strict': + same_site = protos.RpcHttpCookie.SameSite.Strict + elif raw_same_site_str == 'none': + same_site = protos.RpcHttpCookie.SameSite.ExplicitNone + except Exception: + return same_site + + return same_site diff --git a/azure_functions_worker/bindings/nullable_converters.py b/azure_functions_worker/bindings/nullable_converters.py new file mode 100644 index 000000000..e1c75aecc --- /dev/null +++ b/azure_functions_worker/bindings/nullable_converters.py @@ -0,0 +1,111 @@ +from datetime import datetime +from typing import Optional, Union + +from google.protobuf.timestamp_pb2 import Timestamp + +from azure_functions_worker import protos + + +def to_nullable_string(nullable: Optional[str], property_name: str) -> \ + Optional[protos.NullableString]: + """Converts string input to an 'NullableString' to be sent through the + RPC layer. Input that is not a string but is also not null or undefined + logs a function app level warning. + + :param nullable Input to be converted to an NullableString if it is a + valid string + :param property_name The name of the property that the caller will + assign the output to. Used for debugging. + """ + if isinstance(nullable, str): + return protos.NullableString(value=nullable) + + if nullable is not None: + raise TypeError( + f"A 'str' type was expected instead of a '{type(nullable)}' " + f"type. Cannot parse value {nullable} of '{property_name}'.") + + return None + + +def to_nullable_bool(nullable: Optional[bool], property_name: str) -> \ + Optional[protos.NullableBool]: + """Converts boolean input to an 'NullableBool' to be sent through the + RPC layer. Input that is not a boolean but is also not null or undefined + logs a function app level warning. + + :param nullable Input to be converted to an NullableBool if it is a + valid boolean + :param property_name The name of the property that the caller will + assign the output to. Used for debugging. + """ + if isinstance(nullable, bool): + return protos.NullableBool(value=nullable) + + if nullable is not None: + raise TypeError( + f"A 'bool' type was expected instead of a '{type(nullable)}' " + f"type. Cannot parse value {nullable} of '{property_name}'.") + + return None + + +def to_nullable_double(nullable: Optional[Union[str, int, float]], + property_name: str) -> \ + Optional[protos.NullableDouble]: + """Converts int or float or str that parses to a number to an + 'NullableDouble' to be sent through the RPC layer. Input that is not a + valid number but is also not null or undefined logs a function app level + warning. + :param nullable Input to be converted to an NullableDouble if it is a + valid number + :param property_name The name of the property that the caller will + assign the output to. Used for debugging. + """ + if isinstance(nullable, int) or isinstance(nullable, float): + return protos.NullableDouble(value=nullable) + elif isinstance(nullable, str): + if len(nullable) == 0: + return None + + try: + return protos.NullableDouble(value=float(nullable)) + except Exception: + raise TypeError( + f"Cannot parse value {nullable} of '{property_name}' to " + f"float.") + + if nullable is not None: + raise TypeError( + f"A 'int' or 'float'" + f" type was expected instead of a '{type(nullable)}' " + f"type. Cannot parse value {nullable} of '{property_name}'.") + + return None + + +def to_nullable_timestamp(date_time: Optional[Union[datetime, int]], + property_name: str) -> protos.NullableTimestamp: + """Converts Date or number input to an 'NullableTimestamp' to be sent + through the RPC layer. Input that is not a Date or number but is also + not null or undefined logs a function app level warning. + + :param date_time Input to be converted to an NullableTimestamp if it is + valid input + :param property_name The name of the property that the caller will + assign the output to. Used for debugging. + """ + if date_time is not None: + try: + time_in_seconds = date_time if isinstance(date_time, + int) else \ + date_time.timestamp() + + return protos.NullableTimestamp( + value=Timestamp(seconds=int(time_in_seconds))) + except Exception: + raise TypeError( + f"A 'datetime' or 'int'" + f" type was expected instead of a '{type(date_time)}' " + f"type. Cannot parse value {date_time} of '{property_name}'.") + return None diff --git a/azure_functions_worker/dispatcher.py b/azure_functions_worker/dispatcher.py index d00b1dd05..f8dd1e8b6 100644 --- a/azure_functions_worker/dispatcher.py +++ b/azure_functions_worker/dispatcher.py @@ -206,12 +206,6 @@ def on_logging(self, record: logging.LogRecord, formatted_msg: str) -> None: if invocation_id is not None: log['invocation_id'] = invocation_id - # XXX: When an exception field is set in RpcLog, WebHost doesn't - # wait for the call result and simply aborts the execution. - # - # if record.exc_info and record.exc_info[1] is not None: - # log['exception'] = self._serialize_exception(record.exc_info[1]) - self._grpc_resp_queue.put_nowait( protos.StreamingMessage( request_id=self.request_id, diff --git a/azure_functions_worker/protos/__init__.py b/azure_functions_worker/protos/__init__.py index cd4d495c5..45c31d375 100644 --- a/azure_functions_worker/protos/__init__.py +++ b/azure_functions_worker/protos/__init__.py @@ -24,6 +24,7 @@ ParameterBinding, TypedData, RpcHttp, + RpcHttpCookie, RpcLog, RpcSharedMemory, RpcDataType, @@ -31,3 +32,10 @@ CloseSharedMemoryResourcesResponse, FunctionsMetadataRequest, FunctionMetadataResponse) + +from .shared.NullableTypes_pb2 import ( + NullableString, + NullableBool, + NullableDouble, + NullableTimestamp +) diff --git a/tests/endtoend/test_linux_consumption.py b/tests/endtoend/test_linux_consumption.py index f3aae7311..64f0d6205 100644 --- a/tests/endtoend/test_linux_consumption.py +++ b/tests/endtoend/test_linux_consumption.py @@ -2,21 +2,18 @@ # Licensed under the MIT License. import os import sys -from unittest import TestCase, skipIf +from unittest import TestCase, skip from requests import Request from azure_functions_worker.testutils_lc import ( LinuxConsumptionWebHostController ) -from azure_functions_worker.utils.common import is_python_version _DEFAULT_HOST_VERSION = "4" -@skipIf(is_python_version('3.10'), - "Skip the tests for Python 3.10 currently as the mesh images for " - "Python 3.10 aren't available currently.") +@skip class TestLinuxConsumption(TestCase): """Test worker behaviors on specific scenarios. diff --git a/tests/unittests/http_functions/http_functions_stein/function_app.py b/tests/unittests/http_functions/http_functions_stein/function_app.py index 7abf2be96..4dd703034 100644 --- a/tests/unittests/http_functions/http_functions_stein/function_app.py +++ b/tests/unittests/http_functions/http_functions_stein/function_app.py @@ -318,3 +318,89 @@ def user_event_loop(req: func.HttpRequest) -> func.HttpResponse: loop.run_until_complete(try_log()) loop.close() return 'OK-user-event-loop' + + +@app.route(route="multiple_set_cookie_resp_headers") +def multiple_set_cookie_resp_headers( + req: func.HttpRequest) -> func.HttpResponse: + logging.info('Python HTTP trigger function processed a request.') + resp = func.HttpResponse( + "This HTTP triggered function executed successfully.") + + resp.headers.add("Set-Cookie", + 'foo3=42; Domain=example.com; Expires=Thu, 12-Jan-2017 ' + '13:55:08 GMT; Path=/; Max-Age=10000000; Secure; ' + 'HttpOnly') + resp.headers.add("Set-Cookie", + 'foo3=43; Domain=example.com; Expires=Thu, 12-Jan-2018 ' + '13:55:08 GMT; Path=/; Max-Age=10000000; Secure; ' + 'HttpOnly') + resp.headers.add("HELLO", 'world') + + return resp + + +@app.route(route="response_cookie_header_nullable_bool_err") +def response_cookie_header_nullable_bool_err( + req: func.HttpRequest) -> func.HttpResponse: + logging.info('Python HTTP trigger function processed a request.') + resp = func.HttpResponse( + "This HTTP triggered function executed successfully.") + + resp.headers.add("Set-Cookie", + 'foo3=42; Domain=example.com; Expires=Thu, 12-Jan-2017 ' + '13:55:08 GMT; Path=/; Max-Age=10000000; SecureFalse; ' + 'HttpOnly') + + return resp + + +@app.route(route="response_cookie_header_nullable_double_err") +def response_cookie_header_nullable_double_err( + req: func.HttpRequest) -> func.HttpResponse: + logging.info('Python HTTP trigger function processed a request.') + resp = func.HttpResponse( + "This HTTP triggered function executed successfully.") + + resp.headers.add("Set-Cookie", + 'foo3=42; Domain=example.com; Expires=Thu, 12-Jan-2017 ' + '13:55:08 GMT; Path=/; Max-Age=Dummy; SecureFalse; ' + 'HttpOnly') + + return resp + + +@app.route(route="response_cookie_header_nullable_timestamp_err") +def response_cookie_header_nullable_timestamp_err( + req: func.HttpRequest) -> func.HttpResponse: + logging.info('Python HTTP trigger function processed a request.') + resp = func.HttpResponse( + "This HTTP triggered function executed successfully.") + + resp.headers.add("Set-Cookie", 'foo=bar; Domain=123; Expires=Dummy') + + return resp + + +@app.route(route="set_cookie_resp_header_default_values") +def set_cookie_resp_header_default_values( + req: func.HttpRequest) -> func.HttpResponse: + logging.info('Python HTTP trigger function processed a request.') + resp = func.HttpResponse( + "This HTTP triggered function executed successfully.") + + resp.headers.add("Set-Cookie", 'foo=bar') + + return resp + + +@app.route(route="set_cookie_resp_header_empty") +def set_cookie_resp_header_empty( + req: func.HttpRequest) -> func.HttpResponse: + logging.info('Python HTTP trigger function processed a request.') + resp = func.HttpResponse( + "This HTTP triggered function executed successfully.") + + resp.headers.add("Set-Cookie", '') + + return resp diff --git a/tests/unittests/http_functions/multiple_set_cookie_resp_headers/function.json b/tests/unittests/http_functions/multiple_set_cookie_resp_headers/function.json new file mode 100644 index 000000000..5d4d8285f --- /dev/null +++ b/tests/unittests/http_functions/multiple_set_cookie_resp_headers/function.json @@ -0,0 +1,15 @@ +{ + "scriptFile": "main.py", + "bindings": [ + { + "type": "httpTrigger", + "direction": "in", + "name": "req" + }, + { + "type": "http", + "direction": "out", + "name": "$return" + } + ] +} diff --git a/tests/unittests/http_functions/multiple_set_cookie_resp_headers/main.py b/tests/unittests/http_functions/multiple_set_cookie_resp_headers/main.py new file mode 100644 index 000000000..450496fb4 --- /dev/null +++ b/tests/unittests/http_functions/multiple_set_cookie_resp_headers/main.py @@ -0,0 +1,23 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import logging + +import azure.functions as func + + +def main(req: func.HttpRequest): + logging.info('Python HTTP trigger function processed a request.') + resp = func.HttpResponse( + "This HTTP triggered function executed successfully.") + + resp.headers.add("Set-Cookie", + 'foo3=42; Domain=example.com; Expires=Thu, 12-Jan-2017 ' + '13:55:08 GMT; Path=/; Max-Age=10000000; Secure; ' + 'HttpOnly') + resp.headers.add("Set-Cookie", + 'foo3=43; Domain=example.com; Expires=Thu, 12-Jan-2018 ' + '13:55:08 GMT; Path=/; Max-Age=10000000; Secure; ' + 'HttpOnly') + resp.headers.add("HELLO", 'world') + + return resp diff --git a/tests/unittests/http_functions/response_cookie_header_nullable_bool_err/function.json b/tests/unittests/http_functions/response_cookie_header_nullable_bool_err/function.json new file mode 100644 index 000000000..5d4d8285f --- /dev/null +++ b/tests/unittests/http_functions/response_cookie_header_nullable_bool_err/function.json @@ -0,0 +1,15 @@ +{ + "scriptFile": "main.py", + "bindings": [ + { + "type": "httpTrigger", + "direction": "in", + "name": "req" + }, + { + "type": "http", + "direction": "out", + "name": "$return" + } + ] +} diff --git a/tests/unittests/http_functions/response_cookie_header_nullable_bool_err/main.py b/tests/unittests/http_functions/response_cookie_header_nullable_bool_err/main.py new file mode 100644 index 000000000..630a33dff --- /dev/null +++ b/tests/unittests/http_functions/response_cookie_header_nullable_bool_err/main.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import logging + +import azure.functions as func + + +def main(req: func.HttpRequest): + logging.info('Python HTTP trigger function processed a request.') + resp = func.HttpResponse( + "This HTTP triggered function executed successfully.") + + resp.headers.add("Set-Cookie", + 'foo3=42; Domain=example.com; Expires=Thu, 12-Jan-2017 ' + '13:55:08 GMT; Path=/; Max-Age=10000000; SecureFalse; ' + 'HttpOnly') + + return resp diff --git a/tests/unittests/http_functions/response_cookie_header_nullable_double_err/function.json b/tests/unittests/http_functions/response_cookie_header_nullable_double_err/function.json new file mode 100644 index 000000000..5d4d8285f --- /dev/null +++ b/tests/unittests/http_functions/response_cookie_header_nullable_double_err/function.json @@ -0,0 +1,15 @@ +{ + "scriptFile": "main.py", + "bindings": [ + { + "type": "httpTrigger", + "direction": "in", + "name": "req" + }, + { + "type": "http", + "direction": "out", + "name": "$return" + } + ] +} diff --git a/tests/unittests/http_functions/response_cookie_header_nullable_double_err/main.py b/tests/unittests/http_functions/response_cookie_header_nullable_double_err/main.py new file mode 100644 index 000000000..81601b8b9 --- /dev/null +++ b/tests/unittests/http_functions/response_cookie_header_nullable_double_err/main.py @@ -0,0 +1,18 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import logging + +import azure.functions as func + + +def main(req: func.HttpRequest): + logging.info('Python HTTP trigger function processed a request.') + resp = func.HttpResponse( + "This HTTP triggered function executed successfully.") + + resp.headers.add("Set-Cookie", + 'foo3=42; Domain=example.com; Expires=Thu, 12-Jan-2017 ' + '13:55:08 GMT; Path=/; Max-Age=Dummy; SecureFalse; ' + 'HttpOnly') + + return resp diff --git a/tests/unittests/http_functions/response_cookie_header_nullable_timestamp_err/function.json b/tests/unittests/http_functions/response_cookie_header_nullable_timestamp_err/function.json new file mode 100644 index 000000000..5d4d8285f --- /dev/null +++ b/tests/unittests/http_functions/response_cookie_header_nullable_timestamp_err/function.json @@ -0,0 +1,15 @@ +{ + "scriptFile": "main.py", + "bindings": [ + { + "type": "httpTrigger", + "direction": "in", + "name": "req" + }, + { + "type": "http", + "direction": "out", + "name": "$return" + } + ] +} diff --git a/tests/unittests/http_functions/response_cookie_header_nullable_timestamp_err/main.py b/tests/unittests/http_functions/response_cookie_header_nullable_timestamp_err/main.py new file mode 100644 index 000000000..6a7c8cfef --- /dev/null +++ b/tests/unittests/http_functions/response_cookie_header_nullable_timestamp_err/main.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import logging + +import azure.functions as func + + +def main(req: func.HttpRequest): + logging.info('Python HTTP trigger function processed a request.') + resp = func.HttpResponse( + "This HTTP triggered function executed successfully.") + + resp.headers.add("Set-Cookie", 'foo=bar; Domain=123; Expires=Dummy') + + return resp diff --git a/tests/unittests/http_functions/set_cookie_resp_header_default_values/function.json b/tests/unittests/http_functions/set_cookie_resp_header_default_values/function.json new file mode 100644 index 000000000..5d4d8285f --- /dev/null +++ b/tests/unittests/http_functions/set_cookie_resp_header_default_values/function.json @@ -0,0 +1,15 @@ +{ + "scriptFile": "main.py", + "bindings": [ + { + "type": "httpTrigger", + "direction": "in", + "name": "req" + }, + { + "type": "http", + "direction": "out", + "name": "$return" + } + ] +} diff --git a/tests/unittests/http_functions/set_cookie_resp_header_default_values/main.py b/tests/unittests/http_functions/set_cookie_resp_header_default_values/main.py new file mode 100644 index 000000000..a29b693b9 --- /dev/null +++ b/tests/unittests/http_functions/set_cookie_resp_header_default_values/main.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import logging + +import azure.functions as func + + +def main(req: func.HttpRequest): + logging.info('Python HTTP trigger function processed a request.') + resp = func.HttpResponse( + "This HTTP triggered function executed successfully.") + + resp.headers.add("Set-Cookie", 'foo=bar') + + return resp diff --git a/tests/unittests/http_functions/set_cookie_resp_header_empty/function.json b/tests/unittests/http_functions/set_cookie_resp_header_empty/function.json new file mode 100644 index 000000000..5d4d8285f --- /dev/null +++ b/tests/unittests/http_functions/set_cookie_resp_header_empty/function.json @@ -0,0 +1,15 @@ +{ + "scriptFile": "main.py", + "bindings": [ + { + "type": "httpTrigger", + "direction": "in", + "name": "req" + }, + { + "type": "http", + "direction": "out", + "name": "$return" + } + ] +} diff --git a/tests/unittests/http_functions/set_cookie_resp_header_empty/main.py b/tests/unittests/http_functions/set_cookie_resp_header_empty/main.py new file mode 100644 index 000000000..3e33bc8e4 --- /dev/null +++ b/tests/unittests/http_functions/set_cookie_resp_header_empty/main.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import logging + +import azure.functions as func + + +def main(req: func.HttpRequest): + logging.info('Python HTTP trigger function processed a request.') + resp = func.HttpResponse( + "This HTTP triggered function executed successfully.") + + resp.headers.add("Set-Cookie", '') + + return resp diff --git a/tests/unittests/test_datumref.py b/tests/unittests/test_datumref.py new file mode 100644 index 000000000..62768592f --- /dev/null +++ b/tests/unittests/test_datumref.py @@ -0,0 +1,129 @@ +import sys +import unittest +from http.cookies import SimpleCookie +from unittest import skipIf + +from dateutil import parser +from dateutil.parser import ParserError + +from azure_functions_worker import protos +from azure_functions_worker.bindings.datumdef import \ + parse_cookie_attr_expires, \ + parse_cookie_attr_same_site, parse_to_rpc_http_cookie_list +from azure_functions_worker.bindings.nullable_converters import \ + to_nullable_bool, to_nullable_string, to_nullable_double, \ + to_nullable_timestamp +from azure_functions_worker.protos import RpcHttpCookie + + +class TestDatumRef(unittest.TestCase): + def test_parse_cookie_attr_expires_none(self): + self.assertEqual(parse_cookie_attr_expires({"expires": None}), None) + + def test_parse_cookie_attr_expires_zero_length(self): + self.assertEqual(parse_cookie_attr_expires({"expires": ""}), None) + + def test_parse_cookie_attr_expires_valid(self): + self.assertEqual(parse_cookie_attr_expires( + {"expires": "Thu, 12-Jan-2017 13:55:08 GMT"}), + parser.parse("Thu, 12-Jan-2017 13:55:08 GMT")) + + def test_parse_cookie_attr_expires_parse_error(self): + with self.assertRaises(ParserError): + parse_cookie_attr_expires( + {"expires": "Thu, 12-Jan-2017 13:550:08 GMT"}) + + def test_parse_cookie_attr_expires_overflow_error(self): + with self.assertRaises(OverflowError): + parse_cookie_attr_expires( + {"expires": "Thu, 12-Jan-9999999999999999 13:550:08 GMT"}) + + def test_parse_cookie_attr_same_site_default(self): + self.assertEqual(parse_cookie_attr_same_site( + {}), + getattr(protos.RpcHttpCookie.SameSite, "None")) + + def test_parse_cookie_attr_same_site_lax(self): + self.assertEqual(parse_cookie_attr_same_site( + {'samesite': 'lax'}), + getattr(protos.RpcHttpCookie.SameSite, "Lax")) + + def test_parse_cookie_attr_same_site_strict(self): + self.assertEqual(parse_cookie_attr_same_site( + {'samesite': 'strict'}), + getattr(protos.RpcHttpCookie.SameSite, "Strict")) + + def test_parse_cookie_attr_same_site_explicit_none(self): + self.assertEqual(parse_cookie_attr_same_site( + {'samesite': 'none'}), + getattr(protos.RpcHttpCookie.SameSite, "ExplicitNone")) + + def test_parse_to_rpc_http_cookie_list_none(self): + self.assertEqual(parse_to_rpc_http_cookie_list(None), None) + + @skipIf(sys.version_info < (3, 8, 0), + "Skip the tests for Python 3.7 and below") + def test_parse_to_rpc_http_cookie_list_valid(self): + headers = [ + 'foo=bar; Path=/some/path; Secure; HttpOnly; Domain=123; ' + 'SameSite=Lax; Max-Age=12345; Expires=Thu, 12-Jan-2017 13:55:08 ' + 'GMT;', + 'foo2=bar; Path=/some/path2; Secure; HttpOnly; Domain=123; ' + 'SameSite=Lax; Max-Age=12345; Expires=Thu, 12-Jan-2017 13:55:08 ' + 'GMT;'] + + cookies = SimpleCookie('\r\n'.join(headers)) + + cookie1 = RpcHttpCookie(name="foo", + value="bar", + domain=to_nullable_string("123", + "cookie.domain"), + path=to_nullable_string("/some/path", + "cookie.path"), + expires=to_nullable_timestamp( + parse_cookie_attr_expires( + { + "expires": "Thu, " + "12-Jan-2017 13:55:08" + " GMT"}), + 'cookie.expires'), + secure=to_nullable_bool( + bool("True"), + 'cookie.secure'), + http_only=to_nullable_bool( + bool("True"), + 'cookie.httpOnly'), + same_site=parse_cookie_attr_same_site( + {"samesite": "Lax"}), + max_age=to_nullable_double( + 12345, + 'cookie.maxAge')) + + cookie2 = RpcHttpCookie(name="foo2", + value="bar", + domain=to_nullable_string("123", + "cookie.domain"), + path=to_nullable_string("/some/path2", + "cookie.path"), + expires=to_nullable_timestamp( + parse_cookie_attr_expires( + { + "expires": "Thu, " + "12-Jan-2017 13:55:08" + " GMT"}), + 'cookie.expires'), + secure=to_nullable_bool( + bool("True"), + 'cookie.secure'), + http_only=to_nullable_bool( + bool("True"), + 'cookie.httpOnly'), + same_site=parse_cookie_attr_same_site( + {"samesite": "Lax"}), + max_age=to_nullable_double( + 12345, + 'cookie.maxAge')) + + rpc_cookies = parse_to_rpc_http_cookie_list([cookies]) + self.assertEqual(cookie1, rpc_cookies[0]) + self.assertEqual(cookie2, rpc_cookies[1]) diff --git a/tests/unittests/test_http_functions.py b/tests/unittests/test_http_functions.py index b514dfce4..14a6619fd 100644 --- a/tests/unittests/test_http_functions.py +++ b/tests/unittests/test_http_functions.py @@ -4,7 +4,9 @@ import hashlib import os import pathlib +import sys import typing +from unittest import skipIf import pytest @@ -349,6 +351,69 @@ def test_print_to_console_stdout(self): self.assertEqual(r.status_code, 200) self.assertEqual(r.text, 'OK-print-logging') + @skipIf(sys.version_info < (3, 8, 0), + "Skip the tests for Python 3.7 and below") + def test_multiple_cookie_header_in_response(self): + r = self.webhost.request('GET', 'multiple_set_cookie_resp_headers') + self.assertEqual(r.status_code, 200) + self.assertEqual(r.headers.get( + 'Set-Cookie'), + "foo3=42; expires=Thu, 12 Jan 2017 13:55:08 GMT; " + "max-age=10000000; domain=example.com; path=/; secure; httponly, " + "foo3=43; expires=Fri, 12 Jan 2018 13:55:08 GMT; " + "max-age=10000000; domain=example.com; path=/; secure; httponly") + + @skipIf(sys.version_info < (3, 8, 0), + "Skip the tests for Python 3.7 and below") + def test_set_cookie_header_in_response_empty_value(self): + r = self.webhost.request('GET', 'set_cookie_resp_header_empty') + self.assertEqual(r.status_code, 200) + self.assertEqual(r.headers.get('Set-Cookie'), None) + + @skipIf(sys.version_info < (3, 8, 0), + "Skip the tests for Python 3.7 and below") + def test_set_cookie_header_in_response_default_value(self): + r = self.webhost.request('GET', + 'set_cookie_resp_header_default_values') + self.assertEqual(r.status_code, 200) + self.assertEqual(r.headers.get('Set-Cookie'), + 'foo=bar; domain=; path=') + + @skipIf(sys.version_info < (3, 8, 0), + "Skip the tests for Python 3.7 and below") + def test_response_cookie_header_nullable_timestamp_err(self): + r = self.webhost.request( + 'GET', + 'response_cookie_header_nullable_timestamp_err') + self.assertEqual(r.status_code, 500) + + def check_log_response_cookie_header_nullable_timestamp_err(self, + host_out: + typing.List[ + str]): + self.assertIn( + "Can not parse value Dummy of expires in the cookie due to " + "invalid format.", + host_out) + + @skipIf(sys.version_info < (3, 8, 0), + "Skip the tests for Python 3.7 and below") + def test_response_cookie_header_nullable_bool_err(self): + r = self.webhost.request( + 'GET', + 'response_cookie_header_nullable_bool_err') + self.assertEqual(r.status_code, 200) + self.assertFalse("Set-Cookie" in r.headers) + + @skipIf(sys.version_info < (3, 8, 0), + "Skip the tests for Python 3.7 and below") + def test_response_cookie_header_nullable_double_err(self): + r = self.webhost.request( + 'GET', + 'response_cookie_header_nullable_double_err') + self.assertEqual(r.status_code, 200) + self.assertFalse("Set-Cookie" in r.headers) + @pytest.mark.flaky(reruns=3) def check_log_print_to_console_stdout(self, host_out: typing.List[str]): # System logs stdout should not exist in host_out diff --git a/tests/unittests/test_nullable_converters.py b/tests/unittests/test_nullable_converters.py new file mode 100644 index 000000000..6ac617459 --- /dev/null +++ b/tests/unittests/test_nullable_converters.py @@ -0,0 +1,107 @@ +import datetime +import unittest + +import pytest +from google.protobuf.timestamp_pb2 import Timestamp + +from azure_functions_worker import protos +from azure_functions_worker.bindings.nullable_converters import \ + to_nullable_string, to_nullable_bool, to_nullable_double, \ + to_nullable_timestamp + +try: + from http.cookies import SimpleCookie +except ImportError: + from Cookie import SimpleCookie + +headers = ['foo=bar; Path=/some/path; Secure', + 'foo2=42; Domain=123; Expires=Thu, 12-Jan-2017 13:55:08 GMT; ' + 'Path=/; Max-Age=dd;'] + +cookies = SimpleCookie('\r\n'.join(headers)) + + +class TestNullableConverters(unittest.TestCase): + def test_to_nullable_string_none(self): + self.assertEqual(to_nullable_string(None, "name"), None) + + def test_to_nullable_string_valid(self): + self.assertEqual(to_nullable_string("dummy", "name"), + protos.NullableString(value="dummy")) + + def test_to_nullable_string_wrong_type(self): + with pytest.raises(Exception) as e: + self.assertEqual(to_nullable_string(123, "name"), + protos.NullableString(value="dummy")) + self.assertEqual(type(e), TypeError) + + def test_to_nullable_bool_none(self): + self.assertEqual(to_nullable_bool(None, "name"), None) + + def test_to_nullable_bool_valid(self): + self.assertEqual(to_nullable_bool(True, "name"), + protos.NullableBool(value=True)) + + def test_to_nullable_bool_wrong_type(self): + with pytest.raises(Exception) as e: + to_nullable_bool("True", "name") + + self.assertEqual(e.type, TypeError) + self.assertEqual(e.value.args[0], + "A 'bool' type was expected instead of a '' type. " + "Cannot parse value True of 'name'.") + + def test_to_nullable_double_str(self): + self.assertEqual(to_nullable_double("12", "name"), + protos.NullableDouble(value=12)) + + def test_to_nullable_double_empty_str(self): + self.assertEqual(to_nullable_double("", "name"), None) + + def test_to_nullable_double_invalid_str(self): + with pytest.raises(TypeError) as e: + to_nullable_double("222d", "name") + + self.assertEqual(e.type, TypeError) + self.assertEqual(e.value.args[0], + "Cannot parse value 222d of 'name' to float.") + + def test_to_nullable_double_int(self): + self.assertEqual(to_nullable_double(12, "name"), + protos.NullableDouble(value=12)) + + def test_to_nullable_double_float(self): + self.assertEqual(to_nullable_double(12.0, "name"), + protos.NullableDouble(value=12)) + + def test_to_nullable_double_none(self): + self.assertEqual(to_nullable_double(None, "name"), None) + + def test_to_nullable_double_wrong_type(self): + with pytest.raises(Exception) as e: + to_nullable_double(object(), "name") + + self.assertIn( + "A 'int' or 'float' type was expected instead of a '' type", + e.value.args[0]) + self.assertEqual(e.type, TypeError) + + def test_to_nullable_timestamp_int(self): + self.assertEqual(to_nullable_timestamp(1000, "datetime"), + protos.NullableTimestamp( + value=Timestamp(seconds=int(1000)))) + + def test_to_nullable_timestamp_datetime(self): + now = datetime.datetime.now() + self.assertEqual(to_nullable_timestamp(now, "datetime"), + protos.NullableTimestamp( + value=Timestamp(seconds=int(now.timestamp())))) + + def test_to_nullable_timestamp_wrong_type(self): + with self.assertRaises(TypeError): + to_nullable_timestamp("now", "datetime") + + def test_to_nullable_timestamp_none(self): + self.assertEqual(to_nullable_timestamp(None, "timestamp"), None)