testRanges.py 12.9 KB
Newer Older
1
##############################################################################
matt@zope.com's avatar
matt@zope.com committed
2
#
3
# Copyright (c) 2002 Zope Corporation and Contributors. All Rights Reserved.
4
#
matt@zope.com's avatar
matt@zope.com committed
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.
matt@zope.com's avatar
matt@zope.com committed
7 8 9 10
# THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED
# WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
# WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS
# FOR A PARTICULAR PURPOSE
11
#
12
##############################################################################
13
import os, sys, unittest
14

15
import string, random, cStringIO, time, re
16
import ZODB
17
import transaction
18
from OFS.Application import Application
19 20
from OFS.Folder import manage_addFolder
from OFS.Image import manage_addFile
21 22 23 24 25 26
from Testing.makerequest import makerequest
from webdav.common import rfc1123_date

from mimetools import Message
from multifile import MultiFile

27 28 29 30
def makeConnection():
    import ZODB
    from ZODB.DemoStorage import DemoStorage

31
    s = DemoStorage(quota=(1<<20))
32 33
    return ZODB.DB( s ).open()

34 35 36
def createBigFile():
    # Create a file that is several 1<<16 blocks of data big, to force the
    # use of chained Pdata objects.
37 38
    # Make sure we create a file that isn't of x * 1<<16 length! Coll #671
    size = (1<<16) * 5 + 12345
39 40
    file = cStringIO.StringIO()

41
    def addLetter(x, add=file.write, l=string.letters,
42
            c=random.choice):
43 44 45 46 47 48 49 50 51 52 53 54
        add(c(l))
    filter(addLetter, range(size))

    return file

TESTFOLDER_NAME = 'RangesTestSuite_testFolder'
BIGFILE = createBigFile()

class TestRequestRange(unittest.TestCase):
    # Test case setup and teardown
    def setUp(self):
        self.responseOut = cStringIO.StringIO()
55
        self.connection = makeConnection()
56
        try:
57 58 59 60
            r = self.connection.root()
            a = Application()
            r['Application'] = a
            self.root = a
61 62 63
            self.app = makerequest(self.root, stdout=self.responseOut)
            try: self.app._delObject(TESTFOLDER_NAME)
            except AttributeError: pass
64 65
            manage_addFolder(self.app, TESTFOLDER_NAME)
            folder = getattr( self.app, TESTFOLDER_NAME )
66 67

            data = string.letters
68 69
            manage_addFile( folder, 'file'
                          , file=data, content_type='text/plain')
70

71
            self.file = folder.file
72 73 74 75 76
            self.data = data

            # Hack, we need a _p_mtime for the file, so we make sure that it
            # has one. We use a subtransaction, which means we can rollback
            # later and pretend we didn't touch the ZODB.
77
            transaction.commit()
78 79 80
        except:
            self.connection.close()
            raise
81 82 83 84

    def tearDown(self):
        try: self.app._delObject(TESTFOLDER_NAME)
        except AttributeError: pass
85
        transaction.abort()
86
        self.app._p_jar.sync()
87
        self.connection.close()
88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104
        self.app = None
        del self.app

    # Utility methods
    def uploadBigFile(self):
        self.file.manage_upload(BIGFILE)
        self.data = BIGFILE.getvalue()

    def doGET(self, request, response):
        rv = self.file.index_html(request, response)

        # Large files are written to resposeOut directly, small ones are
        # returned from the index_html method.
        body = self.responseOut.getvalue()

        # Chop off any printed headers (only when response.write was used)
        if body:
105
            body = string.split(body, '\r\n\r\n', 1)[1]
106 107 108 109 110

        return body + rv

    def createLastModifiedDate(self, offset=0):
        return rfc1123_date(self.file._p_mtime + offset)
111

112 113 114 115 116 117 118 119
    def expectUnsatisfiable(self, range):
        req = self.app.REQUEST
        rsp = req.RESPONSE

        # Add the Range header
        req.environ['HTTP_RANGE'] = 'bytes=%s' % range

        body = self.doGET(req, rsp)
120

121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142
        self.failUnless(rsp.getStatus() == 416,
            'Expected a 416 status, got %s' % rsp.getStatus())

        expect_content_range = 'bytes */%d' % len(self.data)
        content_range = rsp.getHeader('content-range')
        self.failIf(content_range is None, 'No Content-Range header was set!')
        self.failUnless(content_range == expect_content_range,
            'Received incorrect Content-Range header. Expected %s, got %s' % (
                `expect_content_range`, `content_range`))

        self.failUnless(body == '', 'index_html returned %s' % `body`)

    def expectOK(self, rangeHeader, if_range=None):
        req = self.app.REQUEST
        rsp = req.RESPONSE

        # Add headers
        req.environ['HTTP_RANGE'] = rangeHeader
        if if_range is not None:
            req.environ['HTTP_IF_RANGE'] = if_range

        body = self.doGET(req, rsp)
143

144 145 146 147 148 149 150 151 152 153 154 155 156
        self.failUnless(rsp.getStatus() == 200,
            'Expected a 200 status, got %s' % rsp.getStatus())

    def expectSingleRange(self, range, start, end, if_range=None):
        req = self.app.REQUEST
        rsp = req.RESPONSE

        # Add headers
        req.environ['HTTP_RANGE'] = 'bytes=%s' % range
        if if_range is not None:
            req.environ['HTTP_IF_RANGE'] = if_range

        body = self.doGET(req, rsp)
157

158 159 160 161 162 163 164 165 166 167
        self.failUnless(rsp.getStatus() == 206,
            'Expected a 206 status, got %s' % rsp.getStatus())

        expect_content_range = 'bytes %d-%d/%d' % (
            start, end - 1, len(self.data))
        content_range = rsp.getHeader('content-range')
        self.failIf(content_range is None, 'No Content-Range header was set!')
        self.failUnless(content_range == expect_content_range,
            'Received incorrect Content-Range header. Expected %s, got %s' % (
                `expect_content_range`, `content_range`))
168 169 170
        self.failIf(rsp.getHeader('content-length') != str(len(body)),
            'Incorrect Content-Length is set! Expected %s, got %s.' % (
                str(len(body)), rsp.getHeader('content-length')))
171

172
        self.failUnless(body == self.data[start:end],
173 174 175
            'Incorrect range returned, expected %s, got %s' % (
                `self.data[start:end]`, `body`))

176
    def expectMultipleRanges(self, range, sets, draft=0,
177 178 179 180 181 182 183
            rangeParse=re.compile('bytes\s*(\d+)-(\d+)/(\d+)')):
        req = self.app.REQUEST
        rsp = req.RESPONSE

        # Add headers
        req.environ['HTTP_RANGE'] = 'bytes=%s' % range

184 185 186
        if draft:
            req.environ['HTTP_REQUEST_RANGE'] = 'bytes=%s' % range

187
        body = self.doGET(req, rsp)
188

189 190
        self.failUnless(rsp.getStatus() == 206,
            'Expected a 206 status, got %s' % rsp.getStatus())
191
        self.failIf(rsp.getHeader('content-range'),
192 193 194
            'The Content-Range header should not be set!')

        ct = string.split(rsp.getHeader('content-type'), ';')[0]
195 196 197 198
        draftprefix = draft and 'x-' or ''
        self.failIf(ct != 'multipart/%sbyteranges' % draftprefix,
            "Incorrect Content-Type set. Expected 'multipart/%sbyteranges', "
            "got %s" % (draftprefix, ct))
199
        if rsp.getHeader('content-length'):
200 201 202
            self.failIf(rsp.getHeader('content-length') != str(len(body)),
                'Incorrect Content-Length is set! Expected %s, got %s.' % (
                    str(len(body)), rsp.getHeader('content-length')))
203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231

        # Decode the multipart message
        bodyfile = cStringIO.StringIO('Content-Type: %s\n\n%s' % (
            rsp.getHeader('content-type'), body))
        bodymessage = Message(bodyfile)
        partfiles = MultiFile(bodyfile)
        partfiles.push(bodymessage.getparam('boundary'))

        partmessages = []
        add = partmessages.append
        while partfiles.next():
            add(Message(cStringIO.StringIO(partfiles.read())))

        # Check the different parts
        returnedRanges = []
        add = returnedRanges.append
        for part in partmessages:
            range = part['content-range']
            start, end, size = rangeParse.search(range).groups()
            start, end, size = int(start), int(end), int(size)
            end = end + 1

            self.failIf(size != len(self.data),
                'Part Content-Range header reported incorrect length. '
                'Expected %d, got %d.' % (len(self.data), size))

            part.rewindbody()
            body = part.fp.read()
            # Whotcha! Bug in MultiFile; the CRLF that is part of the boundary
232 233
            # is returned as part of the body. Note that this bug is resolved
            # in Python 2.2.
234 235
            if body[-2:] == '\r\n':
                body = body[:-2]
236

237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306
            self.failIf(len(body) != end - start,
                'Part (%d, %d) is of wrong length, expected %d, got %d.' % (
                    start, end, end - start, len(body)))
            self.failIf(body != self.data[start:end],
                'Part (%d, %d) has incorrect data. Expected %s, got %s.' % (
                    start, end, `self.data[start:end]`, `body`))

            add((start, end))

        # Copmare the ranges used with the expected range sets.
        self.failIf(returnedRanges != sets,
            'Got unexpected sets, expected %s, got %s' % (
                sets, returnedRanges))

    # Unsatisfiable requests
    def testNegativeZero(self):
        self.expectUnsatisfiable('-0')

    def testStartBeyondLength(self):
        self.expectUnsatisfiable('1000-')

    def testMultipleUnsatisfiable(self):
        self.expectUnsatisfiable('1000-1001,2000-,-0')

    # Malformed Range header
    def testGarbage(self):
        self.expectOK('kjhdkjhd = ew;jkj h eewh ew')

    def testIllegalSpec(self):
        self.expectOK('notbytes=0-1000')

    # Single ranges
    def testSimpleRange(self):
        self.expectSingleRange('3-7', 3, 8)

    def testOpenEndedRange(self):
        self.expectSingleRange('3-', 3, len(self.data))

    def testSuffixRange(self):
        l = len(self.data)
        self.expectSingleRange('-3', l - 3, l)

    def testWithNegativeZero(self):
        # A satisfiable and an unsatisfiable range
        self.expectSingleRange('-0,3-23', 3, 24)

    def testEndOverflow(self):
        l = len(self.data)
        start, end = l - 10, l + 10
        range = '%d-%d' % (start, end)
        self.expectSingleRange(range, start, len(self.data))

    def testBigFile(self):
        # Files of size 1<<16 are stored in linked Pdata objects. They are
        # treated seperately in the range code.
        self.uploadBigFile()
        join = 3 * (1<<16) # A join between two linked objects
        start = join - 1000
        end = join + 1000
        range = '%d-%d' % (start, end - 1)
        self.expectSingleRange(range, start, end)

    def testBigFileEndOverflow(self):
        self.uploadBigFile()
        l = len(self.data)
        start, end = l - 100, l + 100
        range = '%d-%d' % (start, end)
        self.expectSingleRange(range, start, len(self.data))

    # Multiple ranges
307
    def testAdjacentRanges(self):
308
        self.expectMultipleRanges('21-25,10-20', [(21, 26), (10, 21)])
309

310 311 312
    def testMultipleRanges(self):
        self.expectMultipleRanges('3-7,10-15', [(3, 8), (10, 16)])

313 314 315
    def testMultipleRangesDraft(self):
        self.expectMultipleRanges('3-7,10-15', [(3, 8), (10, 16)], draft=1)

316 317
    def testMultipleRangesBigFile(self):
        self.uploadBigFile()
318
        self.expectMultipleRanges('3-700,10-15,-10000',
319 320 321 322 323 324 325
            [(3, 701), (10, 16), (len(self.data) - 10000, len(self.data))])

    def testMultipleRangesBigFileOutOfOrder(self):
        self.uploadBigFile()
        self.expectMultipleRanges('10-15,-10000,70000-80000', 
            [(10, 16), (len(self.data) - 10000, len(self.data)),
             (70000, 80001)])
326 327 328 329 330

    def testMultipleRangesBigFileEndOverflow(self):
        self.uploadBigFile()
        l = len(self.data)
        start, end = l - 100, l + 100
331
        self.expectMultipleRanges('3-700,%s-%s' % (start, end),
332 333 334 335 336 337
            [(3, 701), (len(self.data) - 100, len(self.data))])

    # If-Range headers
    def testIllegalIfRange(self):
        # We assume that an illegal if-range is to be ignored, just like an
        # illegal if-modified since.
338
        self.expectSingleRange('10-25', 10, 26, if_range='garbage')
339 340

    def testEqualIfRangeDate(self):
341
        self.expectSingleRange('10-25', 10, 26,
342 343 344 345 346 347 348
            if_range=self.createLastModifiedDate())

    def testIsModifiedIfRangeDate(self):
        self.expectOK('21-25,10-20',
            if_range=self.createLastModifiedDate(offset=-100))

    def testIsNotModifiedIfRangeDate(self):
349
        self.expectSingleRange('10-25', 10, 26,
350 351 352
            if_range=self.createLastModifiedDate(offset=100))

    def testEqualIfRangeEtag(self):
353
        self.expectSingleRange('10-25', 10, 26,
354 355 356
            if_range=self.file.http__etag())

    def testNotEqualIfRangeEtag(self):
357
        self.expectOK('10-25',
358 359
            if_range=self.file.http__etag() + 'bar')

360 361 362 363 364 365 366 367 368 369 370

def test_suite():
    suite = unittest.TestSuite()
    suite.addTest( unittest.makeSuite( TestRequestRange ) )
    return suite

def main():
    unittest.TextTestRunner().run(test_suite())

if __name__ == '__main__':
    main()