async def test_incrementing_cookie_version_denies_access(): app = GitHubAuth( hello_world_app, client_id="x_client_id", client_secret="x_client_secret", require_auth=True, ) cookie = signed_auth_cookie_header(app) scope = { "type": "http", "http_version": "1.0", "method": "GET", "path": "/", "headers": [[b"cookie", cookie]], } instance = ApplicationCommunicator(app, scope) await instance.send_input({"type": "http.request"}) output = await instance.receive_output(1) assert 200 == output["status"] # Try it again with a different cookie version app = GitHubAuth( hello_world_app, client_id="x_client_id", client_secret="x_client_secret", require_auth=True, cookie_version=2, ) instance = ApplicationCommunicator(app, scope) await instance.send_input({"type": "http.request"}) output = await instance.receive_output(1) assert 302 == output["status"]
async def _connect(self, path: str = None): self.path = path or self.path queries = self.queries if isinstance(queries, Mapping): qsl = [f"{quote_plus(k)}={quote_plus(v)}" for k, v in queries.items()] else: qsl = [f"{quote_plus(k)}={quote_plus(v)}" for k, v in queries] qs = "&".join(qsl).encode("ascii") headers = [ (k.encode("latin-1"), v.encode("latin-1")) for k, v in self.headers.items() ] scope = { "type": "websocket", "asgi": {"spec_version": "2.1"}, "scheme": "ws", "http_version": "1.1", "path": self.path, "raw_path": quote_plus(self.path).encode("ascii"), "query_string": qs, "root_path": "", "headers": headers, "client": self._client, "subprotocols": [ x for x in self.headers.get("sec-websocket-protocol", "").split(", ") if x ], } self._connection = ApplicationCommunicator(self._app_ref["app"], scope) await self._connection.send_input({"type": "websocket.connect"}) message = await (self._connection.receive_output(self.timeout)) if message["type"] != "websocket.accept": raise RuntimeError("Connection refused.")
async def test_allow_orgs(require_auth_app): require_auth_app.allow_orgs = ["my-org"] scope = { "type": "http", "http_version": "1.0", "method": "GET", "path": "/-/auth-callback", "query_string": b"code=github-code-here", } instance = ApplicationCommunicator(require_auth_app, scope) await instance.send_input({"type": "http.request"}) output = await instance.receive_output(1) # Should return forbidden assert { "type": "http.response.start", "status": 403 } == { "type": output["type"], "status": output["status"], } # Try again with an org they are a member of require_auth_app.allow_orgs = ["demouser-org"] instance = ApplicationCommunicator(require_auth_app, scope) await instance.send_input({"type": "http.request"}) output = await instance.receive_output(1) assert 302 == output["status"]
async def test_cacheable_assets(require_auth_app): # Anything with a path matching cacheable_prefixes should not # have a cache-control: private header require_auth_app.cacheable_prefixes = ["/-/static/"] scope = { "type": "http", "http_version": "1.0", "method": "GET", "path": "/-/static/blah.js", "headers": [[b"cookie", signed_auth_cookie_header(require_auth_app)]], } instance = ApplicationCommunicator(require_auth_app, scope) await instance.send_input({"type": "http.request"}) output = await instance.receive_output(1) assert [ [b"content-type", b"text/html; charset=UTF-8"], [b"cache-control", b"max-age=123"], ] == output["headers"] # BUT... if we reset cacheable_prefixes to [] it should behave as default: require_auth_app.cacheable_prefixes = [] instance = ApplicationCommunicator(require_auth_app, scope) await instance.send_input({"type": "http.request"}) output = await instance.receive_output(1) assert [ [b"content-type", b"text/html; charset=UTF-8"], [b"cache-control", b"private"], ] == output["headers"]
async def test_scope_auth_allows_access(require_auth_app): scope = { "type": "http", "http_version": "1.0", "method": "GET", "path": "/", "headers": [], "auth": { "id": 1, "name": "authed" }, } instance = ApplicationCommunicator(require_auth_app, scope) await instance.send_input({"type": "http.request"}) output = await instance.receive_output(1) assert { "type": "http.response.start", "status": 200, "headers": [ [b"content-type", b"text/html; charset=UTF-8"], [b"cache-control", b"private"], ], } == output # Should have got back the auth information we passed in output = await instance.receive_output(1) body_data = json.loads(output["body"].decode("utf8")) assert "world" == body_data["hello"] auth = body_data["auth"] assert 1 == auth["id"] assert "authed" == auth["name"]
async def test_logout(require_auth_app): instance = ApplicationCommunicator( require_auth_app, {"type": "http", "http_version": "1.0", "method": "GET", "path": "/-/logout"}, ) await instance.send_input({"type": "http.request"}) output = await instance.receive_output(1) assert {"type": "http.response.start", "status": 302} == { "type": output["type"], "status": output["status"], } headers = tuple([tuple(pair) for pair in output["headers"]]) assert (b"location", b"/") in headers assert (b"set-cookie", b"asgi_auth_logout=stay-logged-out; Path=/") in headers assert (b"content-type", b"text/html; charset=UTF-8") in headers assert (b"cache-control", b"private") in headers # asgi_auth should have been set with max-age and expiry asgi_auth_cookie = [ p[1] for p in headers if p[0] == b"set-cookie" and p[1].startswith(b"asgi_auth=") ][0] assert b"Max-Age=0" in asgi_auth_cookie assert b"Path=/" in asgi_auth_cookie assert b"expires=" in asgi_auth_cookie
async def test_signed_cookie_allows_access(path, require_auth_app): scope = { "type": "http", "http_version": "1.0", "method": "GET", "path": path, "headers": [ [b"cookie", signed_auth_cookie_header(require_auth_app)], [b"cache-control", b"private"], ], } instance = ApplicationCommunicator(require_auth_app, scope) await instance.send_input({"type": "http.request"}) output = await instance.receive_output(1) assert { "type": "http.response.start", "status": 200, "headers": [ [b"content-type", b"text/html; charset=UTF-8"], [b"cache-control", b"private"], ], } == output # Should have got back the auth information we passed in output = await instance.receive_output(1) body_data = json.loads(output["body"].decode("utf8")) assert "world" == body_data["hello"] auth = body_data["auth"] assert "123" == auth["id"] assert "GitHub User" == auth["name"] assert "demouser" == auth["username"] assert "*****@*****.**" == auth["email"] assert isinstance(auth["ts"], int)
async def test_concurrent_async_uses_multiple_thread_pools(self): sync_waiter.active_threads.clear() # Send 2 requests concurrently application = get_asgi_application() scope = self.async_request_factory._base_scope(path="/wait/") communicators = [] for _ in range(2): communicators.append(ApplicationCommunicator(application, scope)) await communicators[-1].send_input({"type": "http.request"}) # Each request must complete with a status code of 200 # If requests aren't scheduled concurrently, the barrier in the # sync_wait view will time out, resulting in a 500 status code. for communicator in communicators: response_start = await communicator.receive_output() self.assertEqual(response_start["type"], "http.response.start") self.assertEqual(response_start["status"], 200) response_body = await communicator.receive_output() self.assertEqual(response_body["type"], "http.response.body") self.assertEqual(response_body["body"], b"Hello World!") # Give response.close() time to finish. await communicator.wait() # The requests should have scheduled on different threads. Note # active_threads is a set (a thread can only appear once), therefore # length is a sufficient check. self.assertEqual(len(sync_waiter.active_threads), 2) sync_waiter.active_threads.clear()
async def test_wsgi_stops_iterating_after_content_length_bytes(): """ Makes sure WsgiToAsgi does not iterate after than Content-Length bytes """ def wsgi_application(environ, start_response): start_response("200 OK", [("Content-Length", "4")]) yield b"0123" pytest.fail("WsgiToAsgi should not iterate after Content-Length bytes") yield b"4567" application = WsgiToAsgi(wsgi_application) instance = ApplicationCommunicator( application, { "type": "http", "http_version": "1.0", "method": "GET", "path": "/", "query_string": b"", "headers": [], }, ) await instance.send_input({"type": "http.request"}) assert (await instance.receive_output(1)) == { "type": "http.response.start", "status": 200, "headers": [(b"content-length", b"4")], } assert (await instance.receive_output(1)) == { "type": "http.response.body", "body": b"0123", "more_body": True, } assert (await instance.receive_output(1)) == {"type": "http.response.body"}
async def test_background_jobs(tracked_requests): with app_with_scout() as app: communicator = ApplicationCommunicator( app, asgi_http_scope(path="/background-jobs/")) await communicator.send_input({"type": "http.request"}) response_start = await communicator.receive_output() response_body = await communicator.receive_output() await communicator.wait() assert response_start["type"] == "http.response.start" assert response_start["status"] == 200 assert response_body["body"] == b"Triggering background jobs" assert len(tracked_requests) == 3 sync_tracked_request = tracked_requests[1] assert len(sync_tracked_request.complete_spans) == 1 sync_span = sync_tracked_request.complete_spans[0] assert sync_span.operation == ( "Job/tests.integration.test_starlette_py36plus." + "app_with_scout.<locals>.background_jobs.<locals>.sync_noop") async_tracked_request = tracked_requests[2] assert len(async_tracked_request.complete_spans) == 1 async_span = async_tracked_request.complete_spans[0] assert async_span.operation == ( "Job/tests.integration.test_starlette_py36plus." + "app_with_scout.<locals>.background_jobs.<locals>.async_noop")
async def test_headers(self): application = get_asgi_application() communicator = ApplicationCommunicator( application, self.async_request_factory._base_scope( path="/meta/", headers=[ [b"content-type", b"text/plain; charset=utf-8"], [b"content-length", b"77"], [b"referer", b"Scotland"], [b"referer", b"Wales"], ], ), ) await communicator.send_input({"type": "http.request"}) response_start = await communicator.receive_output() self.assertEqual(response_start["type"], "http.response.start") self.assertEqual(response_start["status"], 200) self.assertEqual( set(response_start["headers"]), { (b"Content-Length", b"19"), (b"Content-Type", b"text/plain; charset=utf-8"), }, ) response_body = await communicator.receive_output() self.assertEqual(response_body["type"], "http.response.body") self.assertEqual(response_body["body"], b"From Scotland,Wales")
async def test_static_file_response(self): application = ASGIStaticFilesHandler(get_asgi_application()) # Construct HTTP request. scope = self.async_request_factory._base_scope(path="/static/file.txt") communicator = ApplicationCommunicator(application, scope) await communicator.send_input({"type": "http.request"}) # Get the file content. file_path = TEST_STATIC_ROOT / "file.txt" with open(file_path, "rb") as test_file: test_file_contents = test_file.read() # Read the response. stat = file_path.stat() response_start = await communicator.receive_output() self.assertEqual(response_start["type"], "http.response.start") self.assertEqual(response_start["status"], 200) self.assertEqual( set(response_start["headers"]), { (b"Content-Length", str( len(test_file_contents)).encode("ascii")), (b"Content-Type", b"text/plain"), (b"Content-Disposition", b'inline; filename="file.txt"'), (b"Last-Modified", http_date(stat.st_mtime).encode("ascii")), }, ) response_body = await communicator.receive_output() self.assertEqual(response_body["type"], "http.response.body") self.assertEqual(response_body["body"], test_file_contents) # Allow response.close() to finish. await communicator.wait()
async def test_handler_body_multiple(): """ Tests request handling with a multi-part body """ scope = { "type": "http", "http_version": "1.1", "method": "GET", "path": "/test/" } handler = ApplicationCommunicator(MockHandler(), scope) await handler.send_input({ "type": "http.request", "body": b"chunk one", "more_body": True }) await handler.send_input({ "type": "http.request", "body": b" \x01 ", "more_body": True }) await handler.send_input({"type": "http.request", "body": b"chunk two"}) await handler.receive_output(1) # response start await handler.receive_output(1) # response body scope, body_stream = MockHandler.request_class.call_args[0] body_stream.seek(0) assert body_stream.read() == b"chunk one \x01 chunk two"
async def test_request_lifecycle_signals_dispatched_with_thread_sensitive( self): class SignalHandler: """Track threads handler is dispatched on.""" threads = [] def __call__(self, **kwargs): self.threads.append(threading.current_thread()) signal_handler = SignalHandler() request_started.connect(signal_handler) request_finished.connect(signal_handler) # Perform a basic request. application = get_asgi_application() scope = self.async_request_factory._base_scope(path='/') communicator = ApplicationCommunicator(application, scope) await communicator.send_input({'type': 'http.request'}) response_start = await communicator.receive_output() self.assertEqual(response_start['type'], 'http.response.start') self.assertEqual(response_start['status'], 200) response_body = await communicator.receive_output() self.assertEqual(response_body['type'], 'http.response.body') self.assertEqual(response_body['body'], b'Hello World!') # Give response.close() time to finish. await communicator.wait() # At this point, AsyncToSync does not have a current executor. Thus # SyncToAsync falls-back to .single_thread_executor. target_thread = next(iter(SyncToAsync.single_thread_executor._threads)) request_started_thread, request_finished_thread = signal_handler.threads self.assertEqual(request_started_thread, target_thread) self.assertEqual(request_finished_thread, target_thread) request_started.disconnect(signal_handler) request_finished.disconnect(signal_handler)
async def test_auth_callback_calls_github_apis_and_sets_cookie( redirect_path, require_auth_app): cookie = SimpleCookie() cookie["asgi_auth_redirect"] = redirect_path cookie["asgi_auth_redirect"]["path"] = "/" instance = ApplicationCommunicator( require_auth_app, { "type": "http", "http_version": "1.0", "method": "GET", "path": "/-/auth-callback", "query_string": b"code=github-code-here", "headers": [[b"cookie", cookie.output(header="").lstrip().encode("utf8")]], }, ) await instance.send_input({"type": "http.request"}) output = await instance.receive_output(1) assert_redirects_and_sets_cookie(require_auth_app, output, redirect_path)
async def test_require_auth_false(require_auth_app): scope = {"type": "http", "http_version": "1.0", "method": "GET", "path": "/"} # Should redirect if require_auth=True: instance = ApplicationCommunicator(require_auth_app, scope) await instance.send_input({"type": "http.request"}) output = await instance.receive_output(1) assert 302 == output["status"] # Should 200 if require_auth=False require_auth_app.require_auth = False instance = ApplicationCommunicator(require_auth_app, scope) await instance.send_input({"type": "http.request"}) output2 = await instance.receive_output(1) assert 200 == output2["status"] # And scope["auth"] should have been None body = await instance.receive_output(1) assert {"hello": "world", "auth": None} == json.loads(body["body"].decode("utf8"))
async def test_disconnect(self): application = get_asgi_application() scope = self.async_request_factory._base_scope(path="/") communicator = ApplicationCommunicator(application, scope) await communicator.send_input({"type": "http.disconnect"}) with self.assertRaises(asyncio.TimeoutError): await communicator.receive_output()
async def test_request_lifecycle_signals_dispatched_with_thread_sensitive( self): class SignalHandler: """Track threads handler is dispatched on.""" threads = [] def __call__(self, **kwargs): self.threads.append(threading.current_thread()) signal_handler = SignalHandler() request_started.connect(signal_handler) request_finished.connect(signal_handler) # Perform a basic request. application = get_asgi_application() scope = self.async_request_factory._base_scope(path="/") communicator = ApplicationCommunicator(application, scope) await communicator.send_input({"type": "http.request"}) response_start = await communicator.receive_output() self.assertEqual(response_start["type"], "http.response.start") self.assertEqual(response_start["status"], 200) response_body = await communicator.receive_output() self.assertEqual(response_body["type"], "http.response.body") self.assertEqual(response_body["body"], b"Hello World!") # Give response.close() time to finish. await communicator.wait() # AsyncToSync should have executed the signals in the same thread. request_started_thread, request_finished_thread = signal_handler.threads self.assertEqual(request_started_thread, request_finished_thread) request_started.disconnect(signal_handler) request_finished.disconnect(signal_handler)
async def test_asgi_debug(): captured = [] app = asgi_debug(hello_world_app, log_to=captured.append) instance = ApplicationCommunicator(app, { "type": "http", "http_version": "1.0", "method": "GET", "path": "/" }) await instance.send_input({"type": "http.request"}) assert (await instance.receive_output(1)) == { "type": "http.response.start", "status": 200, "headers": [[b"content-type", b"application/json"]], } assert (await instance.receive_output(1)) == { "type": "http.response.body", "body": b'{"hello": "world"}', } assert [ "{'http_version': '1.0', 'method': 'GET', 'path': '/', 'type': 'http'}", "\n", "{'headers': [[b'content-type', b'application/json']],\n 'status': 200,\n 'type': 'http.response.start'}", "\n", "{'body': b'{\"hello\": \"world\"}', 'type': 'http.response.body'}", "\n", ] == captured
async def test_redirects_to_github_with_asgi_auth_redirect_cookie( path, require_auth_app ): instance = ApplicationCommunicator( require_auth_app, {"type": "http", "http_version": "1.0", "method": "GET", "path": path}, ) await instance.send_input({"type": "http.request"}) output = await instance.receive_output(1) assert "http.response.start" == output["type"] assert 302 == output["status"] headers = tuple([tuple(pair) for pair in output["headers"]]) assert ( b"location", b"https://github.com/login/oauth/authorize?scope=user:email&client_id=x_client_id", ) in headers assert (b"cache-control", b"private") in headers simple_cookie = SimpleCookie() for key, value in headers: if key == b"set-cookie": simple_cookie.load(value.decode("utf8")) assert path == simple_cookie["asgi_auth_redirect"].value assert (await instance.receive_output(1)) == { "type": "http.response.body", "body": b"", }
async def test_headers(self): application = get_asgi_application() communicator = ApplicationCommunicator( application, self._get_scope( path='/meta/', headers=[ [b'content-type', b'text/plain; charset=utf-8'], [b'content-length', b'77'], [b'referer', b'Scotland'], [b'referer', b'Wales'], ], ), ) await communicator.send_input({'type': 'http.request'}) response_start = await communicator.receive_output() self.assertEqual(response_start['type'], 'http.response.start') self.assertEqual(response_start['status'], 200) self.assertEqual( set(response_start['headers']), { (b'Content-Length', b'19'), (b'Content-Type', b'text/plain; charset=utf-8'), }, ) response_body = await communicator.receive_output() self.assertEqual(response_body['type'], 'http.response.body') self.assertEqual(response_body['body'], b'From Scotland,Wales')
async def test_file_response(self): """ Makes sure that FileResponse works over ASGI. """ application = get_asgi_application() # Construct HTTP request. communicator = ApplicationCommunicator(application, self._get_scope(path='/file/')) await communicator.send_input({'type': 'http.request'}) # Get the file content. with open(test_filename, 'rb') as test_file: test_file_contents = test_file.read() # Read the response. response_start = await communicator.receive_output() self.assertEqual(response_start['type'], 'http.response.start') self.assertEqual(response_start['status'], 200) self.assertEqual( set(response_start['headers']), { (b'Content-Length', str( len(test_file_contents)).encode('ascii')), (b'Content-Type', b'text/plain' if sys.platform == 'win32' else b'text/x-python'), (b'Content-Disposition', b'inline; filename="urls.py"'), }, ) response_body = await communicator.receive_output() self.assertEqual(response_body['type'], 'http.response.body') self.assertEqual(response_body['body'], test_file_contents)
async def test_static_file_response(self): application = ASGIStaticFilesHandler(get_asgi_application()) # Construct HTTP request. scope = self.async_request_factory._base_scope(path='/static/file.txt') communicator = ApplicationCommunicator(application, scope) await communicator.send_input({'type': 'http.request'}) # Get the file content. file_path = TEST_STATIC_ROOT / 'file.txt' with open(file_path, 'rb') as test_file: test_file_contents = test_file.read() # Read the response. stat = file_path.stat() response_start = await communicator.receive_output() self.assertEqual(response_start['type'], 'http.response.start') self.assertEqual(response_start['status'], 200) self.assertEqual( set(response_start['headers']), { (b'Content-Length', str( len(test_file_contents)).encode('ascii')), (b'Content-Type', b'text/plain'), (b'Content-Disposition', b'inline; filename="file.txt"'), (b'Last-Modified', http_date(stat.st_mtime).encode('ascii')), }, ) response_body = await communicator.receive_output() self.assertEqual(response_body['type'], 'http.response.body') self.assertEqual(response_body['body'], test_file_contents) # Allow response.close() to finish. await communicator.wait()
async def test_distributed_tracing(scope, tracer): app = TraceMiddleware(basic_app, tracer=tracer) headers = [ (http_propagation.HTTP_HEADER_PARENT_ID.encode(), "1234".encode()), (http_propagation.HTTP_HEADER_TRACE_ID.encode(), "5678".encode()), ] scope["headers"] = headers instance = ApplicationCommunicator(app, scope) await instance.send_input({"type": "http.request", "body": b""}) response_start = await instance.receive_output(1) assert response_start == { "type": "http.response.start", "status": 200, "headers": [[b"Content-Type", b"text/plain"]], } response_body = await instance.receive_output(1) assert response_body == { "type": "http.response.body", "body": b"*", } spans = tracer.writer.pop_traces() assert len(spans) == 1 assert len(spans[0]) == 1 request_span = spans[0][0] assert request_span.name == "asgi.request" assert request_span.span_type == "web" assert request_span.parent_id == 1234 assert request_span.trace_id == 5678 assert request_span.error == 0 assert request_span.get_tag("http.status_code") == "200" _check_span_tags(scope, request_span)
async def test_disconnect(self): application = get_asgi_application() communicator = ApplicationCommunicator(application, self._get_scope(path='/')) await communicator.send_input({'type': 'http.disconnect'}) with self.assertRaises(asyncio.TimeoutError): await communicator.receive_output()
async def test_query_string(scope, tracer): with override_http_config("asgi", dict(trace_query_string=True)): app = TraceMiddleware(basic_app, tracer=tracer) scope["query_string"] = "foo=bar" instance = ApplicationCommunicator(app, scope) await instance.send_input({"type": "http.request", "body": b""}) response_start = await instance.receive_output(1) assert response_start == { "type": "http.response.start", "status": 200, "headers": [[b"Content-Type", b"text/plain"]], } response_body = await instance.receive_output(1) assert response_body == { "type": "http.response.body", "body": b"*", } spans = tracer.writer.pop_traces() assert len(spans) == 1 assert len(spans[0]) == 1 request_span = spans[0][0] assert request_span.name == "asgi.request" assert request_span.span_type == "web" assert request_span.error == 0 assert request_span.get_tag("http.status_code") == "200" _check_span_tags(scope, request_span)
async def test_double_callable_asgi(scope, tracer): app = TraceMiddleware(double_callable_app, tracer=tracer) instance = ApplicationCommunicator(app, scope) await instance.send_input({"type": "http.request", "body": b""}) response_start = await instance.receive_output(1) assert response_start == { "type": "http.response.start", "status": 200, "headers": [[b"Content-Type", b"text/plain"]], } response_body = await instance.receive_output(1) assert response_body == { "type": "http.response.body", "body": b"*", } spans = tracer.writer.pop_traces() assert len(spans) == 1 assert len(spans[0]) == 1 request_span = spans[0][0] assert request_span.name == "asgi.request" assert request_span.span_type == "web" assert request_span.error == 0 assert request_span.get_tag("http.status_code") == "200" _check_span_tags(scope, request_span)
async def test_wsgi_empty_body(): """ Makes sure WsgiToAsgi handles an empty body response correctly """ def wsgi_application(environ, start_response): start_response("200 OK", []) return [] application = WsgiToAsgi(wsgi_application) instance = ApplicationCommunicator( application, { "type": "http", "http_version": "1.0", "method": "GET", "path": "/", "query_string": b"", "headers": [], }, ) await instance.send_input({"type": "http.request"}) # response.start should always be send assert (await instance.receive_output(1)) == { "type": "http.response.start", "status": 200, "headers": [], } assert (await instance.receive_output(1)) == {"type": "http.response.body"}
async def test_handler_body_multiple(): """ Tests request handling with a multi-part body """ scope = { "type": "http", "http_version": "1.1", "method": "GET", "path": "/test/", } handler = ApplicationCommunicator(MockHandler, scope) await handler.send_input({ "type": "http.request", "body": b"chunk one", "more_body": True, }) await handler.send_input({ "type": "http.request", "body": b" \x01 ", "more_body": True, }) await handler.send_input({ "type": "http.request", "body": b"chunk two", }) await handler.receive_output(1) # response start await handler.receive_output(1) # response body MockHandler.request_class.assert_called_with(scope, b"chunk one \x01 chunk two")
async def test_double_to_single_communicator(): """ Test that the new application works """ new_app = double_to_single_callable(double_application_function) instance = ApplicationCommunicator(new_app, {"value": "woohoo"}) await instance.send_input({"value": 42}) assert await instance.receive_output() == {"scope": "woohoo", "message": 42}