Skip to content

Commit 7df58e0

Browse files
committed
Fix for issue #510.
Address attempting to concatenate strings to a an empty byte string in the file_format output func. The added tests here make use of changes to migwsgi that make it possible to exercise the entire output path. Bring those changes over because having to write them again has no benefit.
1 parent e2ba5b9 commit 7df58e0

4 files changed

Lines changed: 233 additions & 49 deletions

File tree

mig/shared/output.py

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
exit(1)
3939

4040
from past.builtins import basestring
41+
import inspect
4142
import os
4243
import sys
4344
import time
@@ -64,6 +65,20 @@
6465
'yaml', 'xmlrpc', 'resource', 'json', 'file']
6566

6667

68+
def kwargs_for_functionality(main, configuration=None, environ=None):
69+
"""
70+
Determine which additional arguments are supported by the
71+
selected functionality method and arrange to to pass them.
72+
"""
73+
74+
parameters = inspect.signature(main).parameters
75+
76+
kwargs = dict()
77+
if 'environ' in parameters:
78+
kwargs['environ'] = environ
79+
return kwargs
80+
81+
6782
def reject_main(client_id, user_arguments_dict):
6883
"""A simple main-function to use if functionality backend is disabled"""
6984
output_objs = [bailout_title(None, 'Access Error'),
@@ -2697,9 +2712,6 @@ def file_format(configuration, ret_val, ret_msg, out_obj):
26972712

26982713
# TODO: use wsgi file_wrapper helper here if out_obj has wsgi entry?
26992714

2700-
# NOTE: we expect binary data here and must use it consistently
2701-
file_content = b''
2702-
27032715
# NOTE: carefully handle errors and ONLY render them when proper care has
27042716
# been taken to deliver them as actual output, to avoid that they end
27052717
# up hidden inside downloaded files.
@@ -2719,6 +2731,12 @@ def file_format(configuration, ret_val, ret_msg, out_obj):
27192731
render_text, render_errors = True, True
27202732
# _logger.debug("render output in file_format: %s (%s %s)" %
27212733
# (out_obj, render_text, render_errors))
2734+
2735+
if render_text:
2736+
file_content = ''
2737+
else:
2738+
file_content = b''
2739+
27222740
for entry in out_obj:
27232741
if entry['object_type'] == 'file_output':
27242742
for line in entry['lines']:

mig/wsgi-bin/migwsgi.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@
4444
from mig.shared.defaults import download_block_size, default_fs_coding
4545
from mig.shared.conf import get_configuration_object
4646
from mig.shared.objecttypes import get_object_type_info
47-
from mig.shared.output import validate, format_output, dummy_main, reject_main
47+
from mig.shared.output import validate, format_output, \
48+
kwargs_for_functionality, dummy_main, reject_main
4849
from mig.shared.safeinput import valid_backend_name, html_escape, InputException
4950
from mig.shared.scriptinput import fieldstorage_to_dict, FixedFieldStorage
5051

@@ -124,8 +125,11 @@ def stub(configuration, client_id, import_path, backend, user_arguments_dict,
124125
return (output_objects, returnvalues.INVALID_ARGUMENT)
125126

126127
try:
128+
main_kwargs = kwargs_for_functionality(main,
129+
environ=environ)
127130
(output_objects, (ret_code, ret_msg)) = main(client_id,
128-
user_arguments_dict)
131+
user_arguments_dict,
132+
**main_kwargs)
129133
except Exception as err:
130134
import traceback
131135
_logger.error("%s script crashed:\n%s" % (_addr,

tests/support/wsgisupp.py

Lines changed: 125 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,32 @@
2929

3030
from collections import namedtuple
3131
import codecs
32+
import importlib
3233
from io import BytesIO
34+
import os
35+
import sys
3336
from urllib.parse import urlencode, urlparse
3437

35-
from werkzeug.datastructures import MultiDict
38+
from tests.support.suppconst import MIG_BASE
3639

40+
TEXTUAL_CONTENT_TYPES = set(('text/plain', 'text/html', 'application/json'))
41+
OBJECTS_TYPE = 'objects'
3742

38-
# named type representing the tuple that is passed to WSGI handlers
39-
_PreparedWsgi = namedtuple('_PreparedWsgi', ['environ', 'start_response'])
43+
44+
def _import_forcibly(module_name, relative_module_dir=None):
45+
"""Custom import function to allow an import of a file for testing
46+
that resides within a non-module directory."""
47+
48+
module_path = os.path.join(MIG_BASE, 'mig')
49+
if relative_module_dir is not None:
50+
module_path = os.path.join(module_path, relative_module_dir)
51+
sys.path.append(module_path)
52+
mod = importlib.import_module(module_name)
53+
sys.path.pop(-1) # do not leave the forced module path
54+
return mod
55+
56+
57+
migwsgi = _import_forcibly('migwsgi', relative_module_dir='wsgi-bin')
4058

4159

4260
class FakeWsgiStartResponse:
@@ -51,7 +69,29 @@ def __call__(self, status, headers, exc=None):
5169
self.calls.append((status, headers, exc))
5270

5371

54-
def create_wsgi_environ(configuration, wsgi_url, method='GET', query=None, headers=None, form=None):
72+
def _urlencode_form(form_content):
73+
"""
74+
Convert a data structure describing form contents to byte string
75+
that can be directly sent as the body of an HTTP request.
76+
"""
77+
78+
field_key_and_value_pairs = []
79+
if isinstance(form_content, dict):
80+
for key, value in form_content.items():
81+
if isinstance(value, list):
82+
for item in value:
83+
field_key_and_value_pairs.append((key, item))
84+
continue
85+
field_key_and_value_pairs.append((key, value))
86+
elif isinstance(form_content, list):
87+
field_key_and_value_pairs = form_content
88+
else:
89+
raise AssertionError("invalid form content")
90+
return urlencode(field_key_and_value_pairs, doseq=True).encode('ascii')
91+
92+
93+
def create_wsgi_environ(configuration, wsgi_url, method=None,
94+
query=None, headers=None, form=None, mig_user_dn=None):
5595
"""Populate the necessary variables that will constitute a valid WSGI
5696
environment given a URL to which we will make a requests under test and
5797
various other options that set up the nature of that request."""
@@ -67,7 +107,7 @@ def create_wsgi_environ(configuration, wsgi_url, method='GET', query=None, heade
67107
method = 'POST'
68108
request_query = ''
69109

70-
body = urlencode(MultiDict(form)).encode('ascii')
110+
body = _urlencode_form(form)
71111

72112
headers = headers or {}
73113
if not 'Content-Type' in headers:
@@ -76,6 +116,7 @@ def create_wsgi_environ(configuration, wsgi_url, method='GET', query=None, heade
76116
headers['Content-Length'] = str(len(body))
77117
wsgi_input = BytesIO(body)
78118
else:
119+
assert method is not None, "method required with no payload specified"
79120
request_query = parsed_url.query
80121
wsgi_input = ()
81122

@@ -95,10 +136,21 @@ def close(self, *ars, **kwargs):
95136
environ['HTTP_HOST'] = parsed_url.netloc
96137
environ['PATH_INFO'] = parsed_url.path
97138
environ['QUERY_STRING'] = request_query
139+
environ['REMOTE_ADDR'] = '127.0.0.1'
98140
environ['REQUEST_METHOD'] = method
99141
environ['SCRIPT_URI'] = ''.join(
100142
('http://', environ['HTTP_HOST'], environ['PATH_INFO']))
101143

144+
if mig_user_dn:
145+
environ['REMOTE_USER'] = mig_user_dn
146+
147+
path_parts = parsed_url.path.split('/')
148+
maybe_script_name = path_parts[-1]
149+
_, script_ext = os.path.splitext(path_parts[-1])
150+
if script_ext != '':
151+
# the script has an extension, so treat it as a functionality file
152+
environ['SCRIPT_NAME'] = maybe_script_name
153+
102154
if headers:
103155
for k, v in headers.items():
104156
header_key = k.replace('-', '_').upper()
@@ -112,38 +164,72 @@ def close(self, *ars, **kwargs):
112164
return environ
113165

114166

115-
def create_wsgi_start_response():
116-
return FakeWsgiStartResponse()
167+
class _PreparedWsgi:
168+
"""
169+
Object representing a simulated WSGI request to be exercised by a test case.
170+
"""
117171

172+
def __init__(self, configuration, url, **kwargs):
173+
self.configuration = configuration
174+
self.environ = create_wsgi_environ(configuration, url, **kwargs)
175+
self.start_response = FakeWsgiStartResponse()
118176

119-
def prepare_wsgi(configuration, url, **kwargs):
120-
return _PreparedWsgi(
121-
create_wsgi_environ(configuration, url, **kwargs),
122-
create_wsgi_start_response()
123-
)
177+
def __iter__(self):
178+
return iter((self.environ, self.start_response))
124179

180+
def _bind_invocation(self):
181+
self.application_args = (
182+
self.environ,
183+
self.start_response,
184+
)
125185

126-
def _trigger_and_unpack_result(wsgi_result):
127-
chunks = list(wsgi_result)
128-
assert len(chunks) > 0, "invocation returned no output"
129-
complete_value = b''.join(chunks)
130-
decoded_value = codecs.decode(complete_value, 'utf8')
131-
return decoded_value
186+
self.application_kwargs = dict(
187+
configuration=self.configuration,
188+
_set_os_environ=False,
189+
)
190+
191+
return migwsgi.application(
192+
*self.application_args,
193+
**self.application_kwargs
194+
)
195+
196+
@staticmethod
197+
def trigger_wsgi(wsgi_result):
198+
chunks = list(wsgi_result)
199+
assert len(chunks) > 0, "invocation returned no output"
200+
return b''.join(chunks)
201+
202+
203+
def prepare_wsgi(configuration, url, **kwargs):
204+
if 'method' not in kwargs:
205+
kwargs['method'] = 'GET'
206+
return _PreparedWsgi(configuration, url, **kwargs)
132207

133208

134209
class WsgiAssertMixin:
135210
"""Custom assertions for verifying server code executed under test."""
136211

137-
def assertWsgiResponse(self, wsgi_result, fake_wsgi, expected_status_code):
138-
assert isinstance(fake_wsgi, _PreparedWsgi)
212+
def prepareWsgiAssert(self, configuration, url, **kwargs):
213+
return _PreparedWsgi(configuration, url, **kwargs)
139214

140-
content = _trigger_and_unpack_result(wsgi_result)
215+
def assertWsgiResponse(self, wsgi_result, prepared_wsgi,
216+
expected_status_code=None,
217+
expected_content_type=None,
218+
content_format=None):
219+
assert isinstance(prepared_wsgi, _PreparedWsgi)
220+
221+
if wsgi_result:
222+
# legacy codepath
223+
pass
224+
else:
225+
wsgi_result = prepared_wsgi._bind_invocation()
226+
content = _PreparedWsgi.trigger_wsgi(wsgi_result)
141227

142228
def called_once(fake):
143229
assert hasattr(fake, 'calls')
144230
return len(fake.calls) == 1
145231

146-
fake_start_response = fake_wsgi.start_response
232+
fake_start_response = prepared_wsgi.start_response
147233

148234
try:
149235
self.assertTrue(called_once(fake_start_response))
@@ -155,11 +241,24 @@ def called_once(fake):
155241

156242
wsgi_call = fake_start_response.calls[0]
157243

158-
# check for expected HTTP status code
159-
wsgi_status = wsgi_call[0]
160-
actual_status_code = int(wsgi_status[0:3])
161-
self.assertEqual(actual_status_code, expected_status_code)
244+
actual_content_type = wsgi_call
245+
is_textual = None
246+
247+
if expected_status_code:
248+
# check for expected HTTP status code
249+
wsgi_status = wsgi_call[0]
250+
actual_status_code = int(wsgi_status[0:3])
251+
self.assertEqual(actual_status_code, expected_status_code)
162252

163253
headers = dict(wsgi_call[1])
164254

255+
actual_content_type = headers.get('Content-Type', 'none/none')
256+
if expected_content_type:
257+
self.assertEqual(actual_content_type, expected_content_type, "mismatched Content-Type")
258+
259+
content_is_textual = actual_content_type in TEXTUAL_CONTENT_TYPES
260+
if content_is_textual:
261+
textual_content = codecs.decode(content, 'utf8')
262+
return textual_content, headers
263+
165264
return content, headers

0 commit comments

Comments
 (0)