Add HTTP response test suite

This commit is contained in:
Andrew Godwin 2017-11-26 00:06:23 -08:00
parent e0e60e4117
commit 1ca1c67032
6 changed files with 156 additions and 171 deletions

View File

@ -152,7 +152,7 @@ class Server(object):
"""
Coroutine that jumps the reply message from asyncio to Twisted
"""
reactor.callLater(0, protocol.handle_reply, message)
protocol.handle_reply(message)
### Utility

View File

@ -1,5 +1,5 @@
import msgpack
import os
import pickle
import tempfile
@ -19,12 +19,15 @@ class TestApplication:
async def __call__(self, send, receive):
# Load setup info
setup = self.load_setup()
# Receive input and send output
try:
for _ in range(setup["receive_messages"]):
self.messages.append(await receive())
for message in setup["response_messages"]:
await send(message)
finally:
except Exception as e:
self.save_exception(e)
else:
self.save_result()
@classmethod
@ -33,13 +36,13 @@ class TestApplication:
Stores setup information.
"""
with open(cls.setup_storage, "wb") as fh:
fh.write(msgpack.packb(
pickle.dump(
{
"response_messages": response_messages,
"receive_messages": receive_messages,
},
use_bin_type=True,
))
fh,
)
@classmethod
def load_setup(cls):
@ -47,7 +50,7 @@ class TestApplication:
Returns setup details.
"""
with open(cls.setup_storage, "rb") as fh:
return msgpack.unpackb(fh.read(), encoding="utf-8")
return pickle.load(fh)
def save_result(self):
"""
@ -55,13 +58,26 @@ class TestApplication:
We could use pickle here, but that seems wrong, still, somehow.
"""
with open(self.result_storage, "wb") as fh:
fh.write(msgpack.packb(
pickle.dump(
{
"scope": self.scope,
"messages": self.messages,
},
use_bin_type=True,
))
fh,
)
def save_exception(self, exception):
"""
Saves details of what happened to the result storage.
We could use pickle here, but that seems wrong, still, somehow.
"""
with open(self.result_storage, "wb") as fh:
pickle.dump(
{
"exception": exception,
},
fh,
)
@classmethod
def load_result(cls):
@ -69,7 +85,7 @@ class TestApplication:
Returns result details.
"""
with open(cls.result_storage, "rb") as fh:
return msgpack.unpackb(fh.read(), encoding="utf-8")
return pickle.load(fh)
@classmethod
def clear_storage(cls):

View File

@ -1,128 +0,0 @@
# coding: utf8
"""
Tests for the HTTP response section of the ASGI spec
"""
from __future__ import unicode_literals
from unittest import TestCase
from asgiref.inmemory import ChannelLayer
from hypothesis import given, settings
from twisted.test import proto_helpers
from daphne.http_protocol import HTTPFactory
from . import factories, http_strategies, testcases
class TestHTTPResponseSpec(testcases.ASGIHTTPTestCase):
def test_minimal_response(self):
"""
Smallest viable example. Mostly verifies that our response building works.
"""
message = {"status": 200}
response = factories.response_for_message(message)
self.assert_valid_http_response_message(message, response)
self.assertIn(b"200 OK", response)
# Assert that the response is the last of the chunks.
# N.b. at the time of writing, Daphne did not support multiple response chunks,
# but still sends with Transfer-Encoding: chunked if no Content-Length header
# is specified (and maybe even if specified).
self.assertTrue(response.endswith(b"0\r\n\r\n"))
def test_status_code_required(self):
"""
Asserts that passing in the 'status' key is required.
Previous versions of Daphne did not enforce this, so this test is here
to make sure it stays required.
"""
with self.assertRaises(ValueError):
factories.response_for_message({})
def test_status_code_is_transmitted(self):
"""
Tests that a custom status code is present in the response.
We can't really use hypothesis to test all sorts of status codes, because a lot
of them have meaning that is respected by Twisted. E.g. setting 204 (No Content)
as a status code results in Twisted discarding the body.
"""
message = {"status": 201} # 'Created'
response = factories.response_for_message(message)
self.assert_valid_http_response_message(message, response)
self.assertIn(b"201 Created", response)
@given(body=http_strategies.http_body())
def test_body_is_transmitted(self, body):
message = {"status": 200, "content": body.encode("ascii")}
response = factories.response_for_message(message)
self.assert_valid_http_response_message(message, response)
@given(headers=http_strategies.headers())
def test_headers(self, headers):
# The ASGI spec requires us to lowercase our header names
message = {"status": 200, "headers": [(name.lower(), value) for name, value in headers]}
response = factories.response_for_message(message)
# The assert_ method does the heavy lifting of checking that headers are
# as expected.
self.assert_valid_http_response_message(message, response)
@given(
headers=http_strategies.headers(),
body=http_strategies.http_body(),
)
@settings(perform_health_check=False)
def test_kitchen_sink(self, headers, body):
"""
This tests tries to let Hypothesis find combinations of variables that result
in breaking our assumptions. But responses are less exciting than responses,
so there's not a lot going on here.
"""
message = {
"status": 202, # 'Accepted'
"headers": [(name.lower(), value) for name, value in headers],
"content": body.encode("ascii")
}
response = factories.response_for_message(message)
self.assert_valid_http_response_message(message, response)
class TestHTTPResponse(TestCase):
"""
Tests that the HTTP protocol class correctly generates and parses messages.
"""
def setUp(self):
self.channel_layer = ChannelLayer()
self.factory = HTTPFactory(self.channel_layer, send_channel="test!")
self.proto = self.factory.buildProtocol(("127.0.0.1", 0))
self.tr = proto_helpers.StringTransport()
self.proto.makeConnection(self.tr)
def test_http_disconnect_sets_path_key(self):
"""
Tests http disconnect has the path key set, see https://channels.readthedocs.io/en/latest/asgi.html#disconnect
"""
# Send a simple request to the protocol
self.proto.dataReceived(
b"GET /te%20st-%C3%A0/?foo=bar HTTP/1.1\r\n" +
b"Host: anywhere.com\r\n" +
b"\r\n"
)
# Get the request message
_, message = self.channel_layer.receive(["http.request"])
# Send back an example response
self.factory.dispatch_reply(
message["reply_channel"],
{
"status": 200,
"status_text": b"OK",
"content": b"DISCO",
}
)
# Get the disconnection notification
_, disconnect_message = self.channel_layer.receive(["http.disconnect"])
self.assertEqual(disconnect_message["path"], "/te st-à/")

View File

@ -82,7 +82,19 @@ class DaphneTestCase(unittest.TestCase):
conn.endheaders(message_body=body)
else:
conn.endheaders()
try:
response = conn.getresponse()
except socket.timeout:
# See if they left an exception for us to load
try:
exception_result = TestApplication.load_result()
except OSError:
raise RuntimeError("Daphne timed out handling request, no result file")
else:
if "exception" in exception_result:
raise exception_result["exception"]
else:
raise RuntimeError("Daphne timed out handling request, no exception found: %r" % exception_result)
finally:
# Shut down daphne
process.terminate()
@ -107,6 +119,20 @@ class DaphneTestCase(unittest.TestCase):
)
return inner_result["scope"], inner_result["messages"]
def run_daphne_response(self, response_messages):
"""
Convenience method for just testing response handling.
Returns (scope, messages)
"""
_, response = self.run_daphne(
method="GET",
path="/",
params={},
body=b"",
responses=response_messages,
)
return response
def tearDown(self):
"""
Ensures any storage files are cleared.
@ -155,36 +181,6 @@ class DaphneTestCase(unittest.TestCase):
self.assertIsInstance(port, int)
# class ASGIHTTPTestCase(ASGITestCaseBase):
# """
# Test case with helpers for verifying HTTP channel messages
# """
# def assert_valid_http_response_message(self, message, response):
# self.assertTrue(message)
# self.assertTrue(response.startswith(b"HTTP"))
# status_code_bytes = str(message["status"]).encode("ascii")
# self.assertIn(status_code_bytes, response)
# if "content" in message:
# self.assertIn(message["content"], response)
# # Check that headers are in the given order.
# # N.b. HTTP spec only enforces that the order of header values is kept, but
# # the ASGI spec requires that order of all headers is kept. This code
# # checks conformance with the stricter ASGI spec.
# if "headers" in message:
# for name, value in message["headers"]:
# expected_header = factories.header_line(name, value)
# # Daphne or Twisted turn our lower cased header names ('foo-bar') into title
# # case ('Foo-Bar'). So technically we want to to match that the header name is
# # present while ignoring casing, and want to ensure the value is present without
# # altered casing. The approach below does this well enough.
# self.assertIn(expected_header.lower(), response.lower())
# self.assertIn(value.encode("ascii"), response)
# class ASGIWebSocketTestCase(ASGITestCaseBase):
# """

View File

@ -3,7 +3,7 @@
import collections
from urllib import parse
from hypothesis import given, assume, settings, HealthCheck
from hypothesis import given, assume, settings
import http_strategies
from http_base import DaphneTestCase

101
tests/test_http_response.py Normal file
View File

@ -0,0 +1,101 @@
# coding: utf8
from hypothesis import given, assume, settings
import http_strategies
from http_base import DaphneTestCase
class TestHTTPResponseSpec(DaphneTestCase):
"""
Tests which try to pour the HTTP request section of the ASGI spec into code.
The heavy lifting is done by the assert_valid_http_request_message function,
the tests mostly serve to wire up hypothesis so that it exercise it's power to find
edge cases.
"""
def normalize_headers(self, headers):
"""
Lowercases and sorts headers, and strips transfer-encoding ones.
"""
return sorted([
(name.lower(), value)
for name, value in headers
if name.lower() != "transfer-encoding"
])
def test_minimal_response(self):
"""
Smallest viable example. Mostly verifies that our response building works.
"""
response = self.run_daphne_response([
{
"type": "http.response",
"status": 200,
"content": b"hello world",
},
])
self.assertEqual(response.status, 200)
self.assertEqual(response.read(), b"hello world")
def test_status_code_required(self):
"""
Asserts that passing in the 'status' key is required.
Previous versions of Daphne did not enforce this, so this test is here
to make sure it stays required.
"""
with self.assertRaises(ValueError):
self.run_daphne_response([
{
"type": "http.response",
"content": b"hello world",
},
])
def test_custom_status_code(self):
"""
Tries a non-default status code.
"""
response = self.run_daphne_response([
{
"type": "http.response",
"status": 201,
"content": b"i made a thing!",
},
])
self.assertEqual(response.status, 201)
self.assertEqual(response.read(), b"i made a thing!")
@given(body=http_strategies.http_body())
@settings(max_examples=5, deadline=2000)
def test_body(self, body):
"""
Tries body variants.
"""
response = self.run_daphne_response([
{
"type": "http.response",
"status": 200,
"content": body,
},
])
self.assertEqual(response.status, 200)
self.assertEqual(response.read(), body)
@given(headers=http_strategies.headers())
@settings(max_examples=5, deadline=2000)
def test_headers(self, headers):
# The ASGI spec requires us to lowercase our header names
response = self.run_daphne_response([
{
"type": "http.response",
"status": 200,
"headers": self.normalize_headers(headers),
},
])
# Check headers in a sensible way. Ignore transfer-encoding.
self.assertEqual(
self.normalize_headers(response.getheaders()),
self.normalize_headers(headers),
)