• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

zopefoundation / Zope / 3956162881

pending completion
3956162881

push

github

Michael Howitz
Update to deprecation warning free releases.

4401 of 7036 branches covered (62.55%)

Branch coverage included in aggregate %.

27161 of 31488 relevant lines covered (86.26%)

0.86 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.66
/src/ZPublisher/tests/test_WSGIPublisher.py
1
##############################################################################
2
#
3
# Copyright (c) 2009 Zope Foundation and Contributors.
4
#
5
# This software is subject to the provisions of the Zope Public License,
6
# Version 2.1 (ZPL).  A copy of the ZPL should accompany this distribution.
7
# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
8
# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
9
# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
10
# FOR A PARTICULAR PURPOSE
11
#
12
##############################################################################
13
import io
1✔
14
import unittest
1✔
15
from urllib.parse import quote
1✔
16

17
import Testing.testbrowser
1✔
18
import transaction
1✔
19
from Testing.ZopeTestCase import FunctionalTestCase
1✔
20
from Testing.ZopeTestCase import ZopeTestCase
1✔
21
from Testing.ZopeTestCase import user_name
1✔
22
from zExceptions import NotFound
1✔
23
from ZODB.POSException import ConflictError
1✔
24
from zope.interface.common.interfaces import IException
1✔
25
from zope.publisher.interfaces import INotFound
1✔
26
from zope.security.interfaces import IForbidden
1✔
27
from zope.security.interfaces import IUnauthorized
1✔
28
from ZPublisher.WSGIPublisher import get_module_info
1✔
29

30

31
class WSGIResponseTests(unittest.TestCase):
1✔
32

33
    _old_NOW = None
1✔
34

35
    def tearDown(self):
1✔
36
        if self._old_NOW is not None:
1!
37
            self._setNOW(self._old_NOW)
×
38

39
    def _getTargetClass(self):
1✔
40
        from ZPublisher.HTTPResponse import WSGIResponse
1✔
41
        return WSGIResponse
1✔
42

43
    def _makeOne(self, *args, **kw):
1✔
44
        return self._getTargetClass()(*args, **kw)
1✔
45

46
    def _setNOW(self, value):
1✔
47
        from ZPublisher import HTTPResponse
1✔
48
        HTTPResponse._NOW, self._old_NOW = value, HTTPResponse._NOW
1✔
49

50
    def test_finalize_sets_204_on_empty_not_streaming(self):
1✔
51
        response = self._makeOne()
1✔
52
        response.finalize()
1✔
53
        self.assertEqual(response.status, 204)
1✔
54

55
    def test_finalize_sets_204_on_empty_not_streaming_ignores_non_200(self):
1✔
56
        response = self._makeOne()
1✔
57
        response.setStatus(302)
1✔
58
        response.finalize()
1✔
59
        self.assertEqual(response.status, 302)
1✔
60

61
    def test_finalize_sets_content_length_if_missing(self):
1✔
62
        response = self._makeOne()
1✔
63
        response.setBody('TESTING')
1✔
64
        response.finalize()
1✔
65
        self.assertEqual(response.getHeader('Content-Length'), '7')
1✔
66

67
    def test_finalize_skips_content_length_if_missing_w_streaming(self):
1✔
68
        response = self._makeOne()
1✔
69
        response._streaming = True
1✔
70
        response.body = 'TESTING'
1✔
71
        response.finalize()
1✔
72
        self.assertFalse(response.getHeader('Content-Length'))
1✔
73

74
    def test_listHeaders_skips_Server_header_wo_server_version_set(self):
1✔
75
        response = self._makeOne()
1✔
76
        response.setBody('TESTING')
1✔
77
        headers = response.listHeaders()
1✔
78
        sv = [x for x in headers if x[0] == 'Server']
1✔
79
        self.assertFalse(sv)
1✔
80

81
    def test_listHeaders_includes_Server_header_w_server_version_set(self):
1✔
82
        response = self._makeOne()
1✔
83
        response._server_version = 'TESTME'
1✔
84
        response.setBody('TESTING')
1✔
85
        headers = response.listHeaders()
1✔
86
        sv = [x for x in headers if x[0] == 'Server']
1✔
87
        self.assertTrue(('Server', 'TESTME') in sv)
1✔
88

89
    def test_listHeaders_includes_Date_header(self):
1✔
90
        import time
1✔
91
        WHEN = time.localtime()
1✔
92
        self._setNOW(time.mktime(WHEN))
1✔
93
        response = self._makeOne()
1✔
94
        response.setBody('TESTING')
1✔
95
        headers = response.listHeaders()
1✔
96
        whenstr = time.strftime('%a, %d %b %Y %H:%M:%S GMT',
1✔
97
                                time.gmtime(time.mktime(WHEN)))
98
        self.assertIn(('Date', whenstr), headers)
1✔
99

100
    def test_setBody_IUnboundStreamIterator(self):
1✔
101
        from zope.interface import implementer
1✔
102
        from ZPublisher.Iterators import IUnboundStreamIterator
1✔
103

104
        @implementer(IUnboundStreamIterator)
1✔
105
        class TestStreamIterator:
1✔
106
            data = "hello"
1✔
107
            done = 0
1✔
108

109
            def __next__(self):
1✔
110
                if not self.done:
×
111
                    self.done = 1
×
112
                    return self.data
×
113
                raise StopIteration
×
114

115
            next = __next__
1✔
116

117
        response = self._makeOne()
1✔
118
        response.setStatus(200)
1✔
119
        body = TestStreamIterator()
1✔
120
        response.setBody(body)
1✔
121
        response.finalize()
1✔
122
        self.assertTrue(body is response.body)
1✔
123
        self.assertEqual(response._streaming, 1)
1✔
124

125
    def test_setBody_IStreamIterator(self):
1✔
126
        from zope.interface import implementer
1✔
127
        from ZPublisher.Iterators import IStreamIterator
1✔
128

129
        @implementer(IStreamIterator)
1✔
130
        class TestStreamIterator:
1✔
131
            data = "hello"
1✔
132
            done = 0
1✔
133

134
            def __next__(self):
1✔
135
                if not self.done:
×
136
                    self.done = 1
×
137
                    return self.data
×
138
                raise StopIteration
×
139

140
            next = __next__
1✔
141

142
            def __len__(self):
1✔
143
                return len(self.data)
1✔
144

145
        response = self._makeOne()
1✔
146
        response.setStatus(200)
1✔
147
        body = TestStreamIterator()
1✔
148
        response.setBody(body)
1✔
149
        response.finalize()
1✔
150
        self.assertTrue(body is response.body)
1✔
151
        self.assertEqual(response._streaming, 0)
1✔
152
        self.assertEqual(response.getHeader('Content-Length'),
1✔
153
                         '%d' % len(TestStreamIterator.data))
154

155
    def test_setBody_w_locking(self):
1✔
156
        response = self._makeOne()
1✔
157
        response.setBody(b'BEFORE', lock=True)
1✔
158
        result = response.setBody(b'AFTER')
1✔
159
        self.assertFalse(result)
1✔
160
        self.assertEqual(response.body, b'BEFORE')
1✔
161

162
    def test___str___raises(self):
1✔
163
        response = self._makeOne()
1✔
164
        response.setBody('TESTING')
1✔
165
        self.assertRaises(NotImplementedError, lambda: str(response))
1✔
166

167
    def test_exception_calls_unauthorized(self):
1✔
168
        from zExceptions import Unauthorized
1✔
169
        response = self._makeOne()
1✔
170
        _unauthorized = DummyCallable()
1✔
171
        response._unauthorized = _unauthorized
1✔
172
        with self.assertRaises(Unauthorized):
1✔
173
            response.exception(info=(Unauthorized, Unauthorized('fail'), None))
1✔
174
        self.assertEqual(_unauthorized._called_with, ((), {}))
1✔
175

176
    def test_debugError(self):
1✔
177
        response = self._makeOne()
1✔
178
        try:
1✔
179
            response.debugError('testing')
1✔
180
        except NotFound as raised:
1✔
181
            self.assertEqual(response.status, 404)
1✔
182
            self.assertIn(
1✔
183
                "Zope has encountered a problem publishing your object. <p>'testing'</p>",  # noqa: E501
184
                raised.detail,
185
            )
186
        else:
187
            self.fail("Didn't raise NotFound")
188
        try:
1✔
189
            response.debugError(("testing",))
1✔
190
        except NotFound as raised:
1✔
191
            self.assertEqual(response.status, 404)
1✔
192
            self.assertIn(
1✔
193
                "Zope has encountered a problem publishing your object. <p>(\'testing\',)</p>",  # noqa: E501
194
                raised.detail,
195
            )
196
        else:
197
            self.fail("Didn't raise NotFound")
198
        try:
1✔
199
            response.debugError(("foo", "bar"))
1✔
200
        except NotFound as raised:
1✔
201
            self.assertEqual(response.status, 404)
1✔
202
            self.assertIn(
1✔
203
                "Zope has encountered a problem publishing your object. <p>(\'foo\', \'bar\')</p>",  # noqa: E501
204
                raised.detail,
205
            )
206
        else:
207
            self.fail("Didn't raise NotFound")
208

209

210
class TestPublish(unittest.TestCase):
1✔
211

212
    def _callFUT(self, request, module_info=None):
1✔
213
        from ZPublisher.WSGIPublisher import publish
1✔
214
        if module_info is None:
1!
215
            module_info = get_module_info()
×
216

217
        return publish(request, module_info)
1✔
218

219
    def test_wo_REMOTE_USER(self):
1✔
220
        request = DummyRequest(PATH_INFO='/')
1✔
221
        response = request.response = DummyResponse()
1✔
222
        _object = DummyCallable()
1✔
223
        _object._result = 'RESULT'
1✔
224
        request._traverse_to = _object
1✔
225
        _realm = 'TESTING'
1✔
226
        _debug_mode = True
1✔
227
        returned = self._callFUT(request, (_object, _realm, _debug_mode))
1✔
228
        self.assertTrue(returned is response)
1✔
229
        self.assertTrue(request._processedInputs)
1✔
230
        self.assertTrue(response.debug_mode)
1✔
231
        self.assertEqual(response.realm, 'TESTING')
1✔
232
        self.assertEqual(request['PARENTS'], [_object])
1✔
233
        self.assertEqual(request._traversed[:2], ('/', None))
1✔
234
        self.assertEqual(_object._called_with, ((), {}))
1✔
235
        self.assertEqual(response._body, 'RESULT')
1✔
236

237
    def test_w_REMOTE_USER(self):
1✔
238
        request = DummyRequest(PATH_INFO='/', REMOTE_USER='phred')
1✔
239
        response = request.response = DummyResponse()
1✔
240
        _object = DummyCallable()
1✔
241
        _object._result = 'RESULT'
1✔
242
        request._traverse_to = _object
1✔
243
        _realm = 'TESTING'
1✔
244
        _debug_mode = True
1✔
245
        self._callFUT(request, (_object, _realm, _debug_mode))
1✔
246
        self.assertEqual(response.realm, None)
1✔
247

248

249
class TestPublishModule(ZopeTestCase):
1✔
250

251
    def _callFUT(self, environ, start_response,
1✔
252
                 _publish=None, _response_factory=None, _request_factory=None):
253
        from ZPublisher.WSGIPublisher import publish_module
1✔
254
        if _publish is not None:
1✔
255
            if _response_factory is not None:
1✔
256
                if _request_factory is not None:
1!
257
                    return publish_module(environ, start_response,
×
258
                                          _publish=_publish,
259
                                          _response_factory=_response_factory,
260
                                          _request_factory=_request_factory)
261
                return publish_module(environ, start_response,
1✔
262
                                      _publish=_publish,
263
                                      _response_factory=_response_factory)
264
            else:
265
                if _request_factory is not None:
1✔
266
                    return publish_module(environ, start_response,
1✔
267
                                          _publish=_publish,
268
                                          _request_factory=_request_factory)
269
                return publish_module(environ, start_response,
1✔
270
                                      _publish=_publish)
271
        return publish_module(environ, start_response)
1✔
272

273
    def _registerView(self, factory, name, provides=None):
1✔
274
        from OFS.interfaces import IApplication
1✔
275
        from zope.component import provideAdapter
1✔
276
        from zope.interface import Interface
1✔
277
        from zope.publisher.browser import IDefaultBrowserLayer
1✔
278
        if provides is None:
1✔
279
            provides = Interface
1✔
280
        requires = (IApplication, IDefaultBrowserLayer)
1✔
281
        provideAdapter(factory, requires, provides, name)
1✔
282

283
    def _makeEnviron(self, **kw):
1✔
284
        from io import BytesIO
1✔
285
        environ = {
1✔
286
            'SCRIPT_NAME': '',
287
            'REQUEST_METHOD': 'GET',
288
            'QUERY_STRING': '',
289
            'SERVER_NAME': '127.0.0.1',
290
            'REMOTE_ADDR': '127.0.0.1',
291
            'wsgi.url_scheme': 'http',
292
            'SERVER_PORT': '8080',
293
            'HTTP_HOST': '127.0.0.1:8080',
294
            'SERVER_PROTOCOL': 'HTTP/1.1',
295
            'wsgi.input': BytesIO(b''),
296
            'CONTENT_LENGTH': '0',
297
            'HTTP_CONNECTION': 'keep-alive',
298
            'CONTENT_TYPE': ''
299
        }
300
        environ.update(kw)
1✔
301
        return environ
1✔
302

303
    def test_calls_setDefaultSkin(self):
1✔
304
        from zope.traversing.interfaces import ITraversable
1✔
305
        from zope.traversing.namespace import view
1✔
306

307
        class TestView:
1✔
308
            __name__ = 'testing'
1✔
309

310
            def __init__(self, context, request):
1✔
311
                pass
1✔
312

313
            def __call__(self):
1✔
314
                return 'foobar'
1✔
315

316
        # Define the views
317
        self._registerView(TestView, 'testing')
1✔
318

319
        # Bind the 'view' namespace (for @@ traversal)
320
        self._registerView(view, 'view', ITraversable)
1✔
321

322
        environ = self._makeEnviron(PATH_INFO='/@@testing')
1✔
323
        self.assertEqual(self._callFUT(environ, noopStartResponse),
1✔
324
                         (b'', b'foobar'))
325

326
    def test_publish_can_return_new_response(self):
1✔
327
        from ZPublisher.HTTPRequest import HTTPRequest
1✔
328
        _response = DummyResponse()
1✔
329
        _response.body = b'BODY'
1✔
330
        _after1 = DummyCallable()
1✔
331
        _after2 = DummyCallable()
1✔
332
        _response.after_list = (_after1, _after2)
1✔
333
        environ = self._makeEnviron()
1✔
334
        start_response = DummyCallable()
1✔
335
        _publish = DummyCallable()
1✔
336
        _publish._result = _response
1✔
337
        app_iter = self._callFUT(environ, start_response, _publish)
1✔
338
        self.assertEqual(app_iter, (b'', b'BODY'))
1✔
339
        (status, headers), kw = start_response._called_with
1✔
340
        self.assertEqual(status, '204 No Content')
1✔
341
        self.assertEqual(headers, [('Content-Length', '0')])
1✔
342
        self.assertEqual(kw, {})
1✔
343
        (request, module_info), kw = _publish._called_with
1✔
344
        self.assertIsInstance(request, HTTPRequest)
1✔
345
        self.assertEqual(kw, {})
1✔
346
        self.assertTrue(_response._finalized)
1✔
347
        self.assertEqual(_after1._called_with, ((), {}))
1✔
348
        self.assertEqual(_after2._called_with, ((), {}))
1✔
349

350
    def test_publish_returns_data_witten_to_response_before_body(self):
1✔
351
        # This also happens if publish creates a new response object.
352
        from ZPublisher.HTTPResponse import WSGIResponse
1✔
353
        environ = self._makeEnviron()
1✔
354
        start_response = DummyCallable()
1✔
355

356
        def _publish(request, mod_info):
1✔
357
            response = WSGIResponse()
1✔
358
            response.write(b'WRITTEN')
1✔
359
            response.body = b'BODY'
1✔
360
            return response
1✔
361

362
        app_iter = self._callFUT(environ, start_response, _publish)
1✔
363
        self.assertEqual(app_iter, (b'WRITTEN', b'BODY'))
1✔
364

365
    def test_raises_unauthorized(self):
1✔
366
        from zExceptions import Unauthorized
1✔
367
        environ = self._makeEnviron()
1✔
368
        start_response = DummyCallable()
1✔
369
        _publish = DummyCallable()
1✔
370
        _publish._raise = Unauthorized('TESTING')
1✔
371
        try:
1✔
372
            self._callFUT(environ, start_response, _publish)
1✔
373
        except Unauthorized as exc:
×
374
            self.assertEqual(exc.getStatus(), 401)
×
375
            self.assertEqual(
×
376
                exc.headers['WWW-Authenticate'], 'basic realm="Zope"')
377

378
    def test_raises_redirect(self):
1✔
379
        from zExceptions import Redirect
1✔
380
        environ = self._makeEnviron()
1✔
381
        start_response = DummyCallable()
1✔
382
        _publish = DummyCallable()
1✔
383
        _publish._raise = Redirect('/redirect_to')
1✔
384
        try:
1✔
385
            self._callFUT(environ, start_response, _publish)
1✔
386
        except Redirect as exc:
1✔
387
            self.assertEqual(exc.getStatus(), 302)
1✔
388
            self.assertEqual(exc.headers['Location'], '/redirect_to')
1✔
389

390
    def test_upgrades_ztk_not_found(self):
1✔
391
        from zExceptions import NotFound
1✔
392
        from zope.publisher.interfaces import NotFound as ZTK_NotFound
1✔
393
        environ = self._makeEnviron()
1✔
394
        start_response = DummyCallable()
1✔
395
        _publish = DummyCallable()
1✔
396
        _publish._raise = ZTK_NotFound(object(), 'name_not_found')
1✔
397
        try:
1✔
398
            self._callFUT(environ, start_response, _publish)
1✔
399
        except ZTK_NotFound:
1✔
400
            self.fail('ZTK exception raised, expected zExceptions.')
401
        except NotFound as exc:
1✔
402
            self.assertTrue('name_not_found' in str(exc))
1✔
403

404
    def test_response_body_is_file(self):
1✔
405
        from io import BytesIO
1✔
406

407
        class DummyFile(BytesIO):
1✔
408
            def __init__(self):
1✔
409
                pass
1✔
410

411
            def read(self, *args, **kw):
1✔
412
                raise NotImplementedError()
413

414
        _response = DummyResponse()
1✔
415
        _response._status = '200 OK'
1✔
416
        _response._headers = [('Content-Length', '4')]
1✔
417
        body = _response.body = DummyFile()
1✔
418
        environ = self._makeEnviron()
1✔
419
        start_response = DummyCallable()
1✔
420
        _publish = DummyCallable()
1✔
421
        _publish._result = _response
1✔
422
        app_iter = self._callFUT(environ, start_response, _publish)
1✔
423
        self.assertTrue(app_iter is body)
1✔
424

425
    def test_response_is_stream(self):
1✔
426
        from zope.interface import implementer
1✔
427
        from ZPublisher.Iterators import IStreamIterator
1✔
428

429
        @implementer(IStreamIterator)
1✔
430
        class TestStreamIterator:
1✔
431
            data = "hello"
1✔
432
            done = 0
1✔
433

434
            def __next__(self):
1✔
435
                if not self.done:
×
436
                    self.done = 1
×
437
                    return self.data
×
438
                raise StopIteration
×
439

440
            next = __next__
1✔
441

442
        _response = DummyResponse()
1✔
443
        _response._status = '200 OK'
1✔
444
        _response._headers = [('Content-Length', '4')]
1✔
445
        body = _response.body = TestStreamIterator()
1✔
446
        environ = self._makeEnviron()
1✔
447
        start_response = DummyCallable()
1✔
448
        _publish = DummyCallable()
1✔
449
        _publish._result = _response
1✔
450
        app_iter = self._callFUT(environ, start_response, _publish)
1✔
451
        self.assertTrue(app_iter is body)
1✔
452

453
    def test_response_is_unboundstream(self):
1✔
454
        from zope.interface import implementer
1✔
455
        from ZPublisher.Iterators import IUnboundStreamIterator
1✔
456

457
        @implementer(IUnboundStreamIterator)
1✔
458
        class TestUnboundStreamIterator:
1✔
459
            data = "hello"
1✔
460
            done = 0
1✔
461

462
            def __next__(self):
1✔
463
                if not self.done:
×
464
                    self.done = 1
×
465
                    return self.data
×
466
                raise StopIteration
×
467

468
            next = __next__
1✔
469

470
        _response = DummyResponse()
1✔
471
        _response._status = '200 OK'
1✔
472
        body = _response.body = TestUnboundStreamIterator()
1✔
473
        environ = self._makeEnviron()
1✔
474
        start_response = DummyCallable()
1✔
475
        _publish = DummyCallable()
1✔
476
        _publish._result = _response
1✔
477
        app_iter = self._callFUT(environ, start_response, _publish)
1✔
478
        self.assertTrue(app_iter is body)
1✔
479

480
    def test_stream_file_wrapper(self):
1✔
481
        from zope.interface import implementer
1✔
482
        from ZPublisher.HTTPResponse import WSGIResponse
1✔
483
        from ZPublisher.Iterators import IStreamIterator
1✔
484

485
        @implementer(IStreamIterator)
1✔
486
        class TestStreamIterator:
1✔
487
            data = "hello" * 20
1✔
488

489
            def __len__(self):
1✔
490
                return len(self.data)
1✔
491

492
            def read(self):
1✔
493
                return self.data
×
494

495
        class Wrapper:
1✔
496
            def __init__(self, file):
1✔
497
                self.file = file
1✔
498

499
        _response = WSGIResponse()
1✔
500
        _response.setHeader('Content-Type', 'text/plain')
1✔
501
        body = _response.body = TestStreamIterator()
1✔
502
        environ = self._makeEnviron(**{'wsgi.file_wrapper': Wrapper})
1✔
503
        start_response = DummyCallable()
1✔
504
        _publish = DummyCallable()
1✔
505
        _publish._result = _response
1✔
506
        app_iter = self._callFUT(environ, start_response, _publish)
1✔
507
        self.assertTrue(app_iter.file is body)
1✔
508
        self.assertTrue(isinstance(app_iter, Wrapper))
1✔
509
        self.assertEqual(
1✔
510
            int(_response.headers['content-length']), len(body))
511
        self.assertTrue(
1✔
512
            _response.headers['content-type'].startswith('text/plain'))
513
        self.assertEqual(_response.status, 200)
1✔
514

515
    def test_unboundstream_file_wrapper(self):
1✔
516
        from zope.interface import implementer
1✔
517
        from ZPublisher.HTTPResponse import WSGIResponse
1✔
518
        from ZPublisher.Iterators import IUnboundStreamIterator
1✔
519

520
        @implementer(IUnboundStreamIterator)
1✔
521
        class TestUnboundStreamIterator:
1✔
522
            data = "hello"
1✔
523

524
            def __len__(self):
1✔
525
                return len(self.data)
1✔
526

527
            def read(self):
1✔
528
                return self.data
×
529

530
        class Wrapper:
1✔
531
            def __init__(self, file):
1✔
532
                self.file = file
1✔
533

534
        _response = WSGIResponse()
1✔
535
        _response.setStatus(200)
1✔
536
        # UnboundStream needs Content-Length header
537
        _response.setHeader('Content-Length', '5')
1✔
538
        _response.setHeader('Content-Type', 'text/plain')
1✔
539
        body = _response.body = TestUnboundStreamIterator()
1✔
540
        environ = self._makeEnviron(**{'wsgi.file_wrapper': Wrapper})
1✔
541
        start_response = DummyCallable()
1✔
542
        _publish = DummyCallable()
1✔
543
        _publish._result = _response
1✔
544
        app_iter = self._callFUT(environ, start_response, _publish)
1✔
545
        self.assertTrue(app_iter.file is body)
1✔
546
        self.assertTrue(isinstance(app_iter, Wrapper))
1✔
547
        self.assertEqual(
1✔
548
            int(_response.headers['content-length']), len(body))
549
        self.assertTrue(
1✔
550
            _response.headers['content-type'].startswith('text/plain'))
551
        self.assertEqual(_response.status, 200)
1✔
552

553
    def test_stream_file_wrapper_without_read(self):
1✔
554
        from zope.interface import implementer
1✔
555
        from ZPublisher.HTTPResponse import WSGIResponse
1✔
556
        from ZPublisher.Iterators import IStreamIterator
1✔
557

558
        @implementer(IStreamIterator)
1✔
559
        class TestStreamIterator:
1✔
560
            data = "hello" * 20
1✔
561

562
            def __len__(self):
1✔
563
                return len(self.data)
1✔
564

565
        class Wrapper:
1✔
566
            def __init__(self, file):
1✔
567
                self.file = file
×
568

569
        _response = WSGIResponse()
1✔
570
        _response.setHeader('Content-Type', 'text/plain')
1✔
571
        body = _response.body = TestStreamIterator()
1✔
572
        environ = self._makeEnviron(**{'wsgi.file_wrapper': Wrapper})
1✔
573
        start_response = DummyCallable()
1✔
574
        _publish = DummyCallable()
1✔
575
        _publish._result = _response
1✔
576
        app_iter = self._callFUT(environ, start_response, _publish)
1✔
577
        # The stream iterator has no ``read`` and will not be used
578
        # for ``wsgi.file_wrapper``. It is returned as-is.
579
        self.assertTrue(app_iter is body)
1✔
580
        self.assertTrue(isinstance(app_iter, TestStreamIterator))
1✔
581
        self.assertEqual(
1✔
582
            int(_response.headers['content-length']), len(body))
583
        self.assertTrue(
1✔
584
            _response.headers['content-type'].startswith('text/plain'))
585
        self.assertEqual(_response.status, 200)
1✔
586

587
    def test_request_closed(self):
1✔
588
        environ = self._makeEnviron()
1✔
589
        start_response = DummyCallable()
1✔
590
        _request = DummyRequest()
1✔
591
        _request._closed = False
1✔
592

593
        def _close():
1✔
594
            _request._closed = True
1✔
595
        _request.close = _close
1✔
596

597
        def _request_factory(stdin, environ, response):
1✔
598
            return _request
1✔
599
        _publish = DummyCallable()
1✔
600
        _publish._result = DummyResponse()
1✔
601
        self._callFUT(environ, start_response, _publish,
1✔
602
                      _request_factory=_request_factory)
603
        self.assertTrue(_request._closed)
1✔
604

605
    def test_handle_ConflictError(self):
1✔
606
        environ = self._makeEnviron()
1✔
607
        start_response = DummyCallable()
1✔
608

609
        def _publish(request, module_info):
1✔
610
            if request.retry_count < 1:
1✔
611
                raise ConflictError
1✔
612
            response = DummyResponse()
1✔
613
            response.setBody(request.other.get('method'))
1✔
614
            return response
1✔
615

616
        try:
1✔
617
            from ZPublisher.HTTPRequest import HTTPRequest
1✔
618
            original_retry_max_count = HTTPRequest.retry_max_count
1✔
619
            HTTPRequest.retry_max_count = 1
1✔
620
            # After the retry the request has a filled `other` dict, thus the
621
            # new request is not closed before processing it:
622
            self.assertEqual(
1✔
623
                self._callFUT(environ, start_response, _publish), (b'', 'GET'))
624
        finally:
625
            HTTPRequest.retry_max_count = original_retry_max_count
1✔
626

627
    def testCustomExceptionViewConflictErrorHandling(self):
1✔
628
        # Make sure requests are retried as often as configured
629
        # even if an exception view has been registered that
630
        # matches ConflictError
631
        from zope.interface import directlyProvides
1✔
632
        from zope.publisher.browser import IDefaultBrowserLayer
1✔
633
        registerExceptionView(Exception)
1✔
634
        environ = self._makeEnviron()
1✔
635
        start_response = DummyCallable()
1✔
636
        _publish = DummyCallable()
1✔
637
        _publish._raise = ConflictError('oops')
1✔
638
        _request = DummyRequest()
1✔
639
        directlyProvides(_request, IDefaultBrowserLayer)
1✔
640
        _request.response = DummyResponse()
1✔
641
        _request.retry_count = 0
1✔
642
        _request.retry_max_count = 2
1✔
643
        _request.environ = {}
1✔
644

645
        def _close():
1✔
646
            pass
1✔
647
        _request.close = _close
1✔
648

649
        def _retry():
1✔
650
            _request.retry_count += 1
1✔
651
            return _request
1✔
652
        _request.retry = _retry
1✔
653

654
        def _supports_retry():
1✔
655
            return _request.retry_count < _request.retry_max_count
1✔
656
        _request.supports_retry = _supports_retry
1✔
657

658
        def _request_factory(stdin, environ, response):
1✔
659
            return _request
1✔
660

661
        # At first, retry_count is zero. Request has never been retried.
662
        self.assertEqual(_request.retry_count, 0)
1✔
663
        app_iter = self._callFUT(environ, start_response, _publish,
1✔
664
                                 _request_factory=_request_factory)
665

666
        # In the end the error view is rendered, but the request should
667
        # have been retried up to retry_max_count times
668
        self.assertTrue(app_iter[1].startswith(
1✔
669
            'Exception View: ConflictError'))
670
        self.assertEqual(_request.retry_count, _request.retry_max_count)
1✔
671

672
        # The Content-Type response header should be set to text/html
673
        self.assertIn('text/html', _request.response.getHeader('Content-Type'))
1✔
674

675
        unregisterExceptionView(Exception)
1✔
676

677
    def testCustomExceptionViewUnauthorized(self):
1✔
678
        from AccessControl import Unauthorized
1✔
679
        registerExceptionView(IUnauthorized)
1✔
680
        environ = self._makeEnviron()
1✔
681
        start_response = DummyCallable()
1✔
682
        _publish = DummyCallable()
1✔
683
        _publish._raise = Unauthorized('argg')
1✔
684
        app_iter = self._callFUT(environ, start_response, _publish)
1✔
685
        body = b''.join(app_iter)
1✔
686
        self.assertEqual(start_response._called_with[0][0], '401 Unauthorized')
1✔
687
        self.assertTrue(b'Exception View: Unauthorized' in body)
1✔
688
        unregisterExceptionView(IUnauthorized)
1✔
689

690
    def testCustomExceptionViewForbidden(self):
1✔
691
        from zExceptions import Forbidden
1✔
692
        registerExceptionView(IForbidden)
1✔
693
        environ = self._makeEnviron()
1✔
694
        start_response = DummyCallable()
1✔
695
        _publish = DummyCallable()
1✔
696
        _publish._raise = Forbidden('argh')
1✔
697
        app_iter = self._callFUT(environ, start_response, _publish)
1✔
698
        body = b''.join(app_iter)
1✔
699
        self.assertEqual(start_response._called_with[0][0], '403 Forbidden')
1✔
700
        self.assertTrue(b'Exception View: Forbidden' in body)
1✔
701
        unregisterExceptionView(IForbidden)
1✔
702

703
    def testCustomExceptionViewNotFound(self):
1✔
704
        from zExceptions import NotFound
1✔
705
        registerExceptionView(INotFound)
1✔
706
        environ = self._makeEnviron()
1✔
707
        start_response = DummyCallable()
1✔
708
        _publish = DummyCallable()
1✔
709
        _publish._raise = NotFound('argh')
1✔
710
        app_iter = self._callFUT(environ, start_response, _publish)
1✔
711
        body = b''.join(app_iter)
1✔
712
        self.assertEqual(start_response._called_with[0][0], '404 Not Found')
1✔
713
        self.assertTrue(b'Exception View: NotFound' in body)
1✔
714
        unregisterExceptionView(INotFound)
1✔
715

716
    def testCustomExceptionViewZTKNotFound(self):
1✔
717
        from zope.publisher.interfaces import NotFound as ZTK_NotFound
1✔
718
        registerExceptionView(INotFound)
1✔
719
        environ = self._makeEnviron()
1✔
720
        start_response = DummyCallable()
1✔
721
        _publish = DummyCallable()
1✔
722
        _publish._raise = ZTK_NotFound(object(), 'argh')
1✔
723
        app_iter = self._callFUT(environ, start_response, _publish)
1✔
724
        body = b''.join(app_iter)
1✔
725
        self.assertEqual(start_response._called_with[0][0], '404 Not Found')
1✔
726
        self.assertTrue(b'Exception View: NotFound' in body)
1✔
727
        unregisterExceptionView(INotFound)
1✔
728

729
    def testCustomExceptionViewBadRequest(self):
1✔
730
        from zExceptions import BadRequest
1✔
731
        registerExceptionView(IException)
1✔
732
        environ = self._makeEnviron()
1✔
733
        start_response = DummyCallable()
1✔
734
        _publish = DummyCallable()
1✔
735
        _publish._raise = BadRequest('argh')
1✔
736
        app_iter = self._callFUT(environ, start_response, _publish)
1✔
737
        body = b''.join(app_iter)
1✔
738
        self.assertEqual(start_response._called_with[0][0], '400 Bad Request')
1✔
739
        self.assertTrue(b'Exception View: BadRequest' in body)
1✔
740
        unregisterExceptionView(IException)
1✔
741

742
    def testCustomExceptionViewInternalError(self):
1✔
743
        from zExceptions import InternalError
1✔
744
        registerExceptionView(IException)
1✔
745
        environ = self._makeEnviron()
1✔
746
        start_response = DummyCallable()
1✔
747
        _publish = DummyCallable()
1✔
748
        _publish._raise = InternalError('argh')
1✔
749
        app_iter = self._callFUT(environ, start_response, _publish)
1✔
750
        body = b''.join(app_iter)
1✔
751
        self.assertEqual(
1✔
752
            start_response._called_with[0][0], '500 Internal Server Error')
753
        self.assertTrue(b'Exception View: InternalError' in body)
1✔
754
        unregisterExceptionView(IException)
1✔
755

756
    def testRedirectExceptionView(self):
1✔
757
        from zExceptions import Redirect
1✔
758
        registerExceptionView(IException)
1✔
759
        environ = self._makeEnviron()
1✔
760
        start_response = DummyCallable()
1✔
761
        _publish = DummyCallable()
1✔
762
        _publish._raise = Redirect('http://localhost:9/')
1✔
763
        app_iter = self._callFUT(environ, start_response, _publish)
1✔
764
        body = b''.join(app_iter)
1✔
765
        status, headers = start_response._called_with[0]
1✔
766
        self.assertEqual(status, '302 Found')
1✔
767
        self.assertTrue(b'Exception View: Redirect' in body)
1✔
768
        headers = dict(headers)
1✔
769
        self.assertEqual(headers['Location'], 'http://localhost:9/')
1✔
770
        unregisterExceptionView(IException)
1✔
771

772
    def testHandleErrorsFalseBypassesExceptionResponse(self):
1✔
773
        from AccessControl import Unauthorized
1✔
774
        environ = self._makeEnviron(**{
1✔
775
            'x-wsgiorg.throw_errors': True,
776
        })
777
        start_response = DummyCallable()
1✔
778
        _publish = DummyCallable()
1✔
779
        _publish._raise = Unauthorized('argg')
1✔
780
        with self.assertRaises(Unauthorized):
1✔
781
            self._callFUT(environ, start_response, _publish)
1✔
782

783
    def testDebugExceptionsBypassesExceptionResponse(self):
1✔
784
        from zExceptions import BadRequest
1✔
785

786
        # Register an exception view for BadRequest
787
        registerExceptionView(IException)
1✔
788
        environ = self._makeEnviron()
1✔
789
        start_response = DummyCallable()
1✔
790
        _publish = DummyCallable()
1✔
791
        _publish._raise = BadRequest('debugbypass')
1✔
792

793
        # Responses will always have debug_exceptions set
794
        def response_factory(stdout, stderr):
1✔
795
            response = DummyResponse()
1✔
796
            response.debug_exceptions = True
1✔
797
            return response
1✔
798

799
        try:
1✔
800
            # With debug_exceptions, the exception view is not called.
801
            with self.assertRaises(BadRequest):
1✔
802
                self._callFUT(environ, start_response, _publish,
1✔
803
                              _response_factory=response_factory)
804
        finally:
805
            # Clean up view registration
806
            unregisterExceptionView(IException)
1✔
807

808
    def test_set_REMOTE_USER_environ(self):
1✔
809
        environ = self._makeEnviron()
1✔
810
        start_response = DummyCallable()
1✔
811
        _response = DummyResponse()
1✔
812
        _publish = DummyCallable()
1✔
813
        _publish._result = _response
1✔
814
        self.assertFalse('REMOTE_USER' in environ)
1✔
815
        self._callFUT(environ, start_response, _publish)
1✔
816
        self.assertEqual(environ['REMOTE_USER'], user_name)
1✔
817
        # After logout there is no REMOTE_USER in environ
818
        environ = self._makeEnviron()
1✔
819
        self.logout()
1✔
820
        self._callFUT(environ, start_response, _publish)
1✔
821
        self.assertFalse('REMOTE_USER' in environ)
1✔
822

823
    def test_webdav_source_port(self):
1✔
824
        from ZPublisher import WSGIPublisher
1✔
825
        old_webdav_source_port = WSGIPublisher._WEBDAV_SOURCE_PORT
1✔
826
        start_response = DummyCallable()
1✔
827
        _response = DummyResponse()
1✔
828
        _publish = DummyCallable()
1✔
829
        _publish._result = _response
1✔
830

831
        # WebDAV source port not configured
832
        environ = self._makeEnviron(PATH_INFO='/test')
1✔
833
        self.assertNotIn('WEBDAV_SOURCE_PORT', environ)
1✔
834
        self.assertEqual(WSGIPublisher._WEBDAV_SOURCE_PORT, 0)
1✔
835
        self._callFUT(environ, start_response, _publish)
1✔
836
        self.assertNotIn('WEBDAV_SOURCE_PORT', environ)
1✔
837
        self.assertEqual(environ['PATH_INFO'], '/test')
1✔
838

839
        # Configuring the port
840
        WSGIPublisher.set_webdav_source_port(9800)
1✔
841
        self.assertEqual(WSGIPublisher._WEBDAV_SOURCE_PORT, 9800)
1✔
842

843
        # Coming through the wrong port
844
        environ = self._makeEnviron(SERVER_PORT=8080, PATH_INFO='/test')
1✔
845
        self._callFUT(environ, start_response, _publish)
1✔
846
        self.assertNotIn('WEBDAV_SOURCE_PORT', environ)
1✔
847
        self.assertEqual(environ['PATH_INFO'], '/test')
1✔
848

849
        # Using the wrong request method, environ gets marked
850
        # but the path doesn't get changed
851
        environ = self._makeEnviron(SERVER_PORT=9800,
1✔
852
                                    PATH_INFO='/test',
853
                                    REQUEST_METHOD='POST')
854
        self._callFUT(environ, start_response, _publish)
1✔
855
        self.assertIn('WEBDAV_SOURCE_PORT', environ)
1✔
856
        self.assertEqual(environ['PATH_INFO'], '/test')
1✔
857

858
        # All stars aligned
859
        environ = self._makeEnviron(SERVER_PORT=9800,
1✔
860
                                    PATH_INFO='/test',
861
                                    REQUEST_METHOD='GET')
862
        self._callFUT(environ, start_response, _publish)
1✔
863
        self.assertTrue(environ['WEBDAV_SOURCE_PORT'])
1✔
864
        self.assertEqual(environ['PATH_INFO'], '/test/manage_DAVget')
1✔
865

866
        # Clean up
867
        WSGIPublisher.set_webdav_source_port(old_webdav_source_port)
1✔
868

869

870
class ExcViewCreatedTests(ZopeTestCase):
1✔
871

872
    def _callFUT(self, exc):
1✔
873
        from zope.interface import directlyProvides
1✔
874
        from zope.publisher.browser import IDefaultBrowserLayer
1✔
875
        from ZPublisher.WSGIPublisher import _exc_view_created_response
1✔
876
        req = self.app.REQUEST
1✔
877
        req['PARENTS'] = [self.app]
1✔
878
        directlyProvides(req, IDefaultBrowserLayer)
1✔
879
        return _exc_view_created_response(exc, req, req.RESPONSE)
1✔
880

881
    def _registerStandardErrorView(self):
1✔
882
        from OFS.browser import StandardErrorMessageView
1✔
883
        from zope.interface import Interface
1✔
884
        registerExceptionView(Interface, factory=StandardErrorMessageView,
1✔
885
                              name='standard_error_message')
886

887
    def _unregisterStandardErrorView(self):
1✔
888
        from OFS.browser import StandardErrorMessageView
1✔
889
        from zope.interface import Interface
1✔
890
        unregisterExceptionView(Interface, factory=StandardErrorMessageView,
1✔
891
                                name='standard_error_message')
892

893
    def testNoStandardErrorMessage(self):
1✔
894
        from zExceptions import NotFound
1✔
895
        self._registerStandardErrorView()
1✔
896

897
        try:
1✔
898
            self.assertFalse(self._callFUT(NotFound))
1✔
899
        finally:
900
            self._unregisterStandardErrorView()
1✔
901

902
    def testWithStandardErrorMessage(self):
1✔
903
        from OFS.DTMLMethod import addDTMLMethod
1✔
904
        from zExceptions import NotFound
1✔
905
        self._registerStandardErrorView()
1✔
906
        response = self.app.REQUEST.RESPONSE
1✔
907

908
        addDTMLMethod(self.app, 'standard_error_message', file='OOPS')
1✔
909

910
        # The response content-type header is not set before rendering
911
        # the standard error template
912
        self.assertFalse(response.getHeader('Content-Type'))
1✔
913

914
        try:
1✔
915
            self.assertTrue(self._callFUT(NotFound))
1✔
916
        finally:
917
            self._unregisterStandardErrorView()
1✔
918

919
        # After rendering the response content-type header is set
920
        self.assertIn('text/html', response.getHeader('Content-Type'))
1✔
921

922
    def testWithEmptyErrorMessage(self):
1✔
923
        from OFS.DTMLMethod import addDTMLMethod
1✔
924
        from zExceptions import NotFound
1✔
925
        self._registerStandardErrorView()
1✔
926
        response = self.app.REQUEST.RESPONSE
1✔
927
        addDTMLMethod(self.app, 'standard_error_message', file='')
1✔
928
        self.app.standard_error_message.raw = ''
1✔
929

930
        # The response content-type header is not set before rendering
931
        # the standard error template
932
        self.assertFalse(response.getHeader('Content-Type'))
1✔
933

934
        try:
1✔
935
            self.assertTrue(self._callFUT(NotFound))
1✔
936
        finally:
937
            self._unregisterStandardErrorView()
1✔
938

939
        # After rendering the response still no content-type header is set
940
        self.assertFalse(response.getHeader('Content-Type'))
1✔
941

942

943
class WSGIPublisherTests(FunctionalTestCase):
1✔
944

945
    def test_can_handle_non_ascii_URLs(self):
1✔
946
        from OFS.Image import manage_addFile
1✔
947
        manage_addFile(self.app, 'täst', 'çöńtêñt'.encode())
1✔
948

949
        browser = Testing.testbrowser.Browser()
1✔
950
        browser.login('manager', 'manager_pass')
1✔
951

952
        browser.open(f'http://localhost/{quote("täst")}')
1✔
953
        self.assertEqual(browser.contents.decode('utf-8'), 'çöńtêñt')
1✔
954

955

956
class TestLoadApp(unittest.TestCase):
1✔
957

958
    def _getTarget(self):
1✔
959
        from ZPublisher.WSGIPublisher import load_app
1✔
960
        return load_app
1✔
961

962
    def _makeModuleInfo(self):
1✔
963
        class Connection:
1✔
964
            def close(self):
1✔
965
                pass
1✔
966

967
        class App:
1✔
968
            _p_jar = Connection()
1✔
969

970
        return (App, 'Zope', False)
1✔
971

972
    def test_open_transaction_is_aborted(self):
1✔
973
        load_app = self._getTarget()
1✔
974

975
        transaction.begin()
1✔
976
        self.assertIsNotNone(transaction.manager.manager._txn)
1✔
977
        with load_app(self._makeModuleInfo()):
1✔
978
            pass
1✔
979
        self.assertIsNone(transaction.manager.manager._txn)
1✔
980

981
    def test_no_second_transaction_is_created_if_closed(self):
1✔
982
        load_app = self._getTarget()
1✔
983

984
        class TransactionCounter:
1✔
985

986
            after = 0
1✔
987
            before = 0
1✔
988

989
            def newTransaction(self, transaction):
1✔
990
                pass
1✔
991

992
            def beforeCompletion(self, transaction):
1✔
993
                self.before += 1
1✔
994

995
            def afterCompletion(self, transaction):
1✔
996
                self.after += 1
1✔
997

998
            def counts(self):
1✔
999
                return (self.after, self.before)
1✔
1000

1001
        counter = TransactionCounter()
1✔
1002
        self.addCleanup(lambda: transaction.manager.unregisterSynch(counter))
1✔
1003

1004
        transaction.manager.registerSynch(counter)
1✔
1005

1006
        transaction.begin()
1✔
1007
        self.assertIsNotNone(transaction.manager.manager._txn)
1✔
1008
        with load_app(self._makeModuleInfo()):
1✔
1009
            transaction.abort()
1✔
1010

1011
        self.assertIsNone(transaction.manager.manager._txn)
1✔
1012
        self.assertEqual(counter.counts(), (1, 1))
1✔
1013

1014

1015
class CustomExceptionView:
1✔
1016

1017
    def __init__(self, context, request):
1✔
1018
        self.context = context
1✔
1019
        self.__parent__ = None
1✔
1020
        self.request = request
1✔
1021

1022
    def __call__(self):
1✔
1023
        return (
1✔
1024
            f'Exception View: {self.context.__class__.__name__}\n'
1025
            f'Context: {self.__parent__.__class__.__name__}')
1026

1027

1028
def registerExceptionView(for_, factory=CustomExceptionView,
1✔
1029
                          name='index.html'):
1030
    from zope.component import getGlobalSiteManager
1✔
1031
    from zope.interface import Interface
1✔
1032
    from zope.publisher.interfaces.browser import IDefaultBrowserLayer
1✔
1033
    gsm = getGlobalSiteManager()
1✔
1034
    gsm.registerAdapter(
1✔
1035
        factory,
1036
        required=(for_, IDefaultBrowserLayer),
1037
        provided=Interface,
1038
        name=name,
1039
    )
1040

1041

1042
def unregisterExceptionView(for_, factory=CustomExceptionView,
1✔
1043
                            name='index.html'):
1044
    from zope.component import getGlobalSiteManager
1✔
1045
    from zope.interface import Interface
1✔
1046
    from zope.publisher.interfaces.browser import IDefaultBrowserLayer
1✔
1047
    gsm = getGlobalSiteManager()
1✔
1048
    gsm.unregisterAdapter(
1✔
1049
        factory,
1050
        required=(for_, IDefaultBrowserLayer),
1051
        provided=Interface,
1052
        name=name,
1053
    )
1054

1055

1056
class DummyRequest(dict):
1✔
1057
    _processedInputs = False
1✔
1058
    _traversed = None
1✔
1059
    _traverse_to = None
1✔
1060
    args = ()
1✔
1061

1062
    def processInputs(self):
1✔
1063
        self._processedInputs = True
1✔
1064

1065
    def traverse(self, path, response=None, validated_hook=None):
1✔
1066
        self._traversed = (path, response, validated_hook)
1✔
1067
        return self._traverse_to
1✔
1068

1069
    def delay_retry(self):
1✔
1070
        return None
1✔
1071

1072

1073
class DummyResponse:
1✔
1074
    debug_mode = False
1✔
1075
    after_list = ()
1✔
1076
    realm = None
1✔
1077
    _body = None
1✔
1078
    _finalized = False
1✔
1079
    _status = '204 No Content'
1✔
1080
    _headers = [('Content-Length', '0')]
1✔
1081

1082
    def __init__(self):
1✔
1083
        self.stdout = io.BytesIO()
1✔
1084

1085
    def finalize(self):
1✔
1086
        self._finalized = True
1✔
1087
        return self._status, self._headers
1✔
1088

1089
    def setBody(self, body):
1✔
1090
        self._body = body
1✔
1091

1092
    body = property(lambda self: self._body, setBody)
1✔
1093

1094
    def setStatus(self, status, reason=None, lock=None):
1✔
1095
        self._status = status
1✔
1096

1097
    status = property(lambda self: self._status, setStatus)
1!
1098

1099
    def getHeader(self, header):
1✔
1100
        return dict(self._headers).get(header, None)
1✔
1101

1102
    def setHeader(self, header, value):
1✔
1103
        headers = dict(self._headers)
1✔
1104
        headers[header] = value
1✔
1105
        self._headers = tuple(headers.items())
1✔
1106

1107

1108
class DummyCallable:
1✔
1109
    _called_with = _raise = _result = None
1✔
1110

1111
    def __call__(self, *args, **kw):
1✔
1112
        self._called_with = (args, kw)
1✔
1113
        if self._raise:
1✔
1114
            raise self._raise
1✔
1115
        return self._result
1✔
1116

1117

1118
def noopStartResponse(status, headers):
1✔
1119
    pass
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc