tests.py 23 KB
Newer Older
1
#! -*- coding: utf-8 -*-
2
from __future__ import unicode_literals
3

4
import base64
5
import errno
6
import hashlib
7
import json
8
import os
9
import shutil
10
import tempfile as sys_tempfile
11
import unittest
12
from io import BytesIO
13

14
from django.core.files import temp as tempfile
15
from django.core.files.uploadedfile import SimpleUploadedFile
16
from django.http.multipartparser import MultiPartParser, parse_header
17
from django.test import SimpleTestCase, TestCase, client, override_settings
18
from django.utils.encoding import force_bytes
19
from django.utils.http import urlquote
20
from django.utils.six import PY2, StringIO
21

22
from . import uploadhandler
23
from .models import FileModel
24

25
UNICODE_FILENAME = 'test-0123456789_中文_Orléans.jpg'
26
MEDIA_ROOT = sys_tempfile.mkdtemp()
27
UPLOAD_TO = os.path.join(MEDIA_ROOT, 'test_upload')
28

Boryslav Larin's avatar
Boryslav Larin committed
29

30
@override_settings(MEDIA_ROOT=MEDIA_ROOT, ROOT_URLCONF='file_uploads.urls', MIDDLEWARE_CLASSES=[])
31
class FileUploadTests(TestCase):
32

33 34
    @classmethod
    def setUpClass(cls):
35
        super(FileUploadTests, cls).setUpClass()
36 37 38 39 40 41
        if not os.path.isdir(MEDIA_ROOT):
            os.makedirs(MEDIA_ROOT)

    @classmethod
    def tearDownClass(cls):
        shutil.rmtree(MEDIA_ROOT)
42
        super(FileUploadTests, cls).tearDownClass()
43

44
    def test_simple_upload(self):
45
        with open(__file__, 'rb') as fp:
46 47 48 49
            post_data = {
                'name': 'Ringo',
                'file_field': fp,
            }
50
            response = self.client.post('/upload/', post_data)
51 52 53
        self.assertEqual(response.status_code, 200)

    def test_large_upload(self):
54
        file = tempfile.NamedTemporaryFile
55
        with file(suffix=".file1") as file1, file(suffix=".file2") as file2:
56 57
            file1.write(b'a' * (2 ** 21))
            file1.seek(0)
58

59 60
            file2.write(b'a' * (10 * 2 ** 20))
            file2.seek(0)
61

62 63 64 65 66
            post_data = {
                'name': 'Ringo',
                'file_field1': file1,
                'file_field2': file2,
            }
67

68 69 70 71 72 73
            for key in list(post_data):
                try:
                    post_data[key + '_hash'] = hashlib.sha1(post_data[key].read()).hexdigest()
                    post_data[key].seek(0)
                except AttributeError:
                    post_data[key + '_hash'] = hashlib.sha1(force_bytes(post_data[key])).hexdigest()
74

75
            response = self.client.post('/verify/', post_data)
76

77
            self.assertEqual(response.status_code, 200)
78

79
    def _test_base64_upload(self, content, encode=base64.b64encode):
80
        payload = client.FakePayload("\r\n".join([
81 82 83 84
            '--' + client.BOUNDARY,
            'Content-Disposition: form-data; name="file"; filename="test.txt"',
            'Content-Type: application/octet-stream',
            'Content-Transfer-Encoding: base64',
Alex Gaynor's avatar
Alex Gaynor committed
85
            '']))
86
        payload.write(b"\r\n" + encode(force_bytes(content)) + b"\r\n")
87
        payload.write('--' + client.BOUNDARY + '--\r\n')
88 89
        r = {
            'CONTENT_LENGTH': len(payload),
Boryslav Larin's avatar
Boryslav Larin committed
90
            'CONTENT_TYPE': client.MULTIPART_CONTENT,
91
            'PATH_INFO': "/echo_content/",
92
            'REQUEST_METHOD': 'POST',
Boryslav Larin's avatar
Boryslav Larin committed
93
            'wsgi.input': payload,
94 95
        }
        response = self.client.request(**r)
96
        received = json.loads(response.content.decode('utf-8'))
97

98 99 100 101 102 103 104
        self.assertEqual(received['file'], content)

    def test_base64_upload(self):
        self._test_base64_upload("This data will be transmitted base64-encoded.")

    def test_big_base64_upload(self):
        self._test_base64_upload("Big data" * 68000)  # > 512Kb
105

106 107
    def test_big_base64_newlines_upload(self):
        self._test_base64_upload(
108 109
            # encodestring is a deprecated alias on Python 3
            "Big data" * 68000, encode=base64.encodestring if PY2 else base64.encodebytes)
110

111
    def test_unicode_file_name(self):
112 113
        tdir = sys_tempfile.mkdtemp()
        self.addCleanup(shutil.rmtree, tdir, True)
114 115

        # This file contains chinese symbols and an accented char in the name.
116
        with open(os.path.join(tdir, UNICODE_FILENAME), 'w+b') as file1:
117
            file1.write(b'b' * (2 ** 10))
118
            file1.seek(0)
119

120 121
            post_data = {
                'file_unicode': file1,
122
            }
123

124
            response = self.client.post('/unicode_name/', post_data)
125

126
        self.assertEqual(response.status_code, 200)
127

128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158
    def test_unicode_file_name_rfc2231(self):
        """
        Test receiving file upload when filename is encoded with RFC2231
        (#22971).
        """
        payload = client.FakePayload()
        payload.write('\r\n'.join([
            '--' + client.BOUNDARY,
            'Content-Disposition: form-data; name="file_unicode"; filename*=UTF-8\'\'%s' % urlquote(UNICODE_FILENAME),
            'Content-Type: application/octet-stream',
            '',
            'You got pwnd.\r\n',
            '\r\n--' + client.BOUNDARY + '--\r\n'
        ]))

        r = {
            'CONTENT_LENGTH': len(payload),
            'CONTENT_TYPE': client.MULTIPART_CONTENT,
            'PATH_INFO': "/unicode_name/",
            'REQUEST_METHOD': 'POST',
            'wsgi.input': payload,
        }
        response = self.client.request(**r)
        self.assertEqual(response.status_code, 200)

    def test_unicode_name_rfc2231(self):
        """
        Test receiving file upload when filename is encoded with RFC2231
        (#22971).
        """
        payload = client.FakePayload()
159 160 161 162 163 164 165 166 167 168 169 170
        payload.write(
            '\r\n'.join([
                '--' + client.BOUNDARY,
                'Content-Disposition: form-data; name*=UTF-8\'\'file_unicode; filename*=UTF-8\'\'%s' % urlquote(
                    UNICODE_FILENAME
                ),
                'Content-Type: application/octet-stream',
                '',
                'You got pwnd.\r\n',
                '\r\n--' + client.BOUNDARY + '--\r\n'
            ])
        )
171 172 173 174 175 176 177 178 179 180 181

        r = {
            'CONTENT_LENGTH': len(payload),
            'CONTENT_TYPE': client.MULTIPART_CONTENT,
            'PATH_INFO': "/unicode_name/",
            'REQUEST_METHOD': 'POST',
            'wsgi.input': payload,
        }
        response = self.client.request(**r)
        self.assertEqual(response.status_code, 200)

182 183 184
    def test_dangerous_file_names(self):
        """Uploaded file names should be sanitized before ever reaching the view."""
        # This test simulates possible directory traversal attacks by a
185
        # malicious uploader We have to do some monkeybusiness here to construct
186 187 188 189 190
        # a malicious payload with an invalid file name (containing os.sep or
        # os.pardir). This similar to what an attacker would need to do when
        # trying such an attack.
        scary_file_names = [
            "/tmp/hax0rd.txt",          # Absolute path, *nix-style.
191
            "C:\\Windows\\hax0rd.txt",  # Absolute path, win-style.
192 193 194 195 196 197 198 199 200 201
            "C:/Windows/hax0rd.txt",    # Absolute path, broken-style.
            "\\tmp\\hax0rd.txt",        # Absolute path, broken in a different way.
            "/tmp\\hax0rd.txt",         # Absolute path, broken by mixing.
            "subdir/hax0rd.txt",        # Descendant path, *nix-style.
            "subdir\\hax0rd.txt",       # Descendant path, win-style.
            "sub/dir\\hax0rd.txt",      # Descendant path, mixed.
            "../../hax0rd.txt",         # Relative path, *nix-style.
            "..\\..\\hax0rd.txt",       # Relative path, win-style.
            "../..\\hax0rd.txt"         # Relative path, mixed.
        ]
202

203
        payload = client.FakePayload()
204
        for i, name in enumerate(scary_file_names):
205
            payload.write('\r\n'.join([
206 207 208 209
                '--' + client.BOUNDARY,
                'Content-Disposition: form-data; name="file%s"; filename="%s"' % (i, name),
                'Content-Type: application/octet-stream',
                '',
210 211 212
                'You got pwnd.\r\n'
            ]))
        payload.write('\r\n--' + client.BOUNDARY + '--\r\n')
213

214 215
        r = {
            'CONTENT_LENGTH': len(payload),
Boryslav Larin's avatar
Boryslav Larin committed
216
            'CONTENT_TYPE': client.MULTIPART_CONTENT,
217
            'PATH_INFO': "/echo/",
218
            'REQUEST_METHOD': 'POST',
Boryslav Larin's avatar
Boryslav Larin committed
219
            'wsgi.input': payload,
220 221 222 223
        }
        response = self.client.request(**r)

        # The filenames should have been sanitized by the time it got to the view.
224
        received = json.loads(response.content.decode('utf-8'))
225
        for i, name in enumerate(scary_file_names):
226
            got = received["file%s" % i]
227
            self.assertEqual(got, "hax0rd.txt")
228

229 230
    def test_filename_overflow(self):
        """File names over 256 characters (dangerous on some platforms) get fixed up."""
231 232 233 234 235 236 237 238 239 240 241 242 243
        long_str = 'f' * 300
        cases = [
            # field name, filename, expected
            ('long_filename', '%s.txt' % long_str, '%s.txt' % long_str[:251]),
            ('long_extension', 'foo.%s' % long_str, '.%s' % long_str[:254]),
            ('no_extension', long_str, long_str[:255]),
            ('no_filename', '.%s' % long_str, '.%s' % long_str[:254]),
            ('long_everything', '%s.%s' % (long_str, long_str), '.%s' % long_str[:254]),
        ]
        payload = client.FakePayload()
        for name, filename, _ in cases:
            payload.write("\r\n".join([
                '--' + client.BOUNDARY,
244
                'Content-Disposition: form-data; name="{}"; filename="{}"',
245 246 247 248 249 250
                'Content-Type: application/octet-stream',
                '',
                'Oops.',
                ''
            ]).format(name, filename))
        payload.write('\r\n--' + client.BOUNDARY + '--\r\n')
251 252
        r = {
            'CONTENT_LENGTH': len(payload),
Boryslav Larin's avatar
Boryslav Larin committed
253
            'CONTENT_TYPE': client.MULTIPART_CONTENT,
254
            'PATH_INFO': "/echo/",
255
            'REQUEST_METHOD': 'POST',
Boryslav Larin's avatar
Boryslav Larin committed
256
            'wsgi.input': payload,
257
        }
258 259 260
        response = self.client.request(**r)

        result = json.loads(response.content.decode('utf-8'))
261 262
        for name, _, expected in cases:
            got = result[name]
263
            self.assertEqual(expected, got, 'Mismatch for {}'.format(name))
264
            self.assertLess(len(got), 256,
265
                            "Got a long file name (%s characters)." % len(got))
266

267 268
    def test_file_content(self):
        file = tempfile.NamedTemporaryFile
269
        with file(suffix=".ctype_extra") as no_content_type, file(suffix=".ctype_extra") as simple_file:
270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291
            no_content_type.write(b'no content')
            no_content_type.seek(0)

            simple_file.write(b'text content')
            simple_file.seek(0)
            simple_file.content_type = 'text/plain'

            string_io = StringIO('string content')
            bytes_io = BytesIO(b'binary content')

            response = self.client.post('/echo_content/', {
                'no_content_type': no_content_type,
                'simple_file': simple_file,
                'string': string_io,
                'binary': bytes_io,
            })
            received = json.loads(response.content.decode('utf-8'))
            self.assertEqual(received['no_content_type'], 'no content')
            self.assertEqual(received['simple_file'], 'text content')
            self.assertEqual(received['string'], 'string content')
            self.assertEqual(received['binary'], 'binary content')

292 293
    def test_content_type_extra(self):
        """Uploaded files may have content type parameters available."""
294
        file = tempfile.NamedTemporaryFile
295
        with file(suffix=".ctype_extra") as no_content_type, file(suffix=".ctype_extra") as simple_file:
296 297
            no_content_type.write(b'something')
            no_content_type.seek(0)
298

299 300 301
            simple_file.write(b'something')
            simple_file.seek(0)
            simple_file.content_type = 'text/plain; test-key=test_value'
302

303 304 305 306 307 308 309
            response = self.client.post('/echo_content_type_extra/', {
                'no_content_type': no_content_type,
                'simple_file': simple_file,
            })
            received = json.loads(response.content.decode('utf-8'))
            self.assertEqual(received['no_content_type'], {})
            self.assertEqual(received['simple_file'], {'test-key': 'test_value'})
310

311 312 313 314 315 316
    def test_truncated_multipart_handled_gracefully(self):
        """
        If passed an incomplete multipart message, MultiPartParser does not
        attempt to read beyond the end of the stream, and simply will handle
        the part that can be parsed gracefully.
        """
317
        payload_str = "\r\n".join([
318 319 320 321 322 323 324
            '--' + client.BOUNDARY,
            'Content-Disposition: form-data; name="file"; filename="foo.txt"',
            'Content-Type: application/octet-stream',
            '',
            'file contents'
            '--' + client.BOUNDARY + '--',
            '',
325 326
        ])
        payload = client.FakePayload(payload_str[:-10])
327 328 329
        r = {
            'CONTENT_LENGTH': len(payload),
            'CONTENT_TYPE': client.MULTIPART_CONTENT,
330
            'PATH_INFO': '/echo/',
331
            'REQUEST_METHOD': 'POST',
332
            'wsgi.input': payload,
333
        }
334
        got = json.loads(self.client.request(**r).content.decode('utf-8'))
335
        self.assertEqual(got, {})
336 337 338 339 340 341 342 343 344

    def test_empty_multipart_handled_gracefully(self):
        """
        If passed an empty multipart message, MultiPartParser will return
        an empty QueryDict.
        """
        r = {
            'CONTENT_LENGTH': 0,
            'CONTENT_TYPE': client.MULTIPART_CONTENT,
345
            'PATH_INFO': '/echo/',
346
            'REQUEST_METHOD': 'POST',
347
            'wsgi.input': client.FakePayload(b''),
348
        }
349
        got = json.loads(self.client.request(**r).content.decode('utf-8'))
350
        self.assertEqual(got, {})
351

352
    def test_custom_upload_handler(self):
353 354 355 356 357 358 359 360 361 362 363 364 365
        file = tempfile.NamedTemporaryFile
        with file() as smallfile, file() as bigfile:
            # A small file (under the 5M quota)
            smallfile.write(b'a' * (2 ** 21))
            smallfile.seek(0)

            # A big file (over the quota)
            bigfile.write(b'a' * (10 * 2 ** 20))
            bigfile.seek(0)

            # Small file posting should work.
            response = self.client.post('/quota/', {'f': smallfile})
            got = json.loads(response.content.decode('utf-8'))
366
            self.assertIn('f', got)
367 368 369 370

            # Large files don't go through.
            response = self.client.post("/quota/", {'f': bigfile})
            got = json.loads(response.content.decode('utf-8'))
371
            self.assertNotIn('f', got)
372

373
    def test_broken_custom_upload_handler(self):
374 375 376 377 378 379 380 381 382 383 384
        with tempfile.NamedTemporaryFile() as file:
            file.write(b'a' * (2 ** 21))
            file.seek(0)

            # AttributeError: You cannot alter upload handlers after the upload has been processed.
            self.assertRaises(
                AttributeError,
                self.client.post,
                '/quota/broken/',
                {'f': file}
            )
385 386

    def test_fileupload_getlist(self):
387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410
        file = tempfile.NamedTemporaryFile
        with file() as file1, file() as file2, file() as file2a:
            file1.write(b'a' * (2 ** 23))
            file1.seek(0)

            file2.write(b'a' * (2 * 2 ** 18))
            file2.seek(0)

            file2a.write(b'a' * (5 * 2 ** 20))
            file2a.seek(0)

            response = self.client.post('/getlist_count/', {
                'file1': file1,
                'field1': 'test',
                'field2': 'test3',
                'field3': 'test5',
                'field4': 'test6',
                'field5': 'test7',
                'file2': (file2, file2a)
            })
            got = json.loads(response.content.decode('utf-8'))

            self.assertEqual(got.get('file1'), 1)
            self.assertEqual(got.get('file2'), 2)
411

412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442
    def test_fileuploads_closed_at_request_end(self):
        file = tempfile.NamedTemporaryFile
        with file() as f1, file() as f2a, file() as f2b:
            response = self.client.post('/fd_closing/t/', {
                'file': f1,
                'file2': (f2a, f2b),
            })

        request = response.wsgi_request
        # Check that the files got actually parsed.
        self.assertTrue(hasattr(request, '_files'))

        file = request._files['file']
        self.assertTrue(file.closed)

        files = request._files.getlist('file2')
        self.assertTrue(files[0].closed)
        self.assertTrue(files[1].closed)

    def test_no_parsing_triggered_by_fd_closing(self):
        file = tempfile.NamedTemporaryFile
        with file() as f1, file() as f2a, file() as f2b:
            response = self.client.post('/fd_closing/f/', {
                'file': f1,
                'file2': (f2a, f2b),
            })

        request = response.wsgi_request
        # Check that the fd closing logic doesn't trigger parsing of the stream
        self.assertFalse(hasattr(request, '_files'))

443 444 445 446 447 448 449 450 451 452 453
    def test_file_error_blocking(self):
        """
        The server should not block when there are upload errors (bug #8622).
        This can happen if something -- i.e. an exception handler -- tries to
        access POST while handling an error in parsing POST. This shouldn't
        cause an infinite loop!
        """
        class POSTAccessingHandler(client.ClientHandler):
            """A handler that'll access POST during an exception."""
            def handle_uncaught_exception(self, request, resolver, exc_info):
                ret = super(POSTAccessingHandler, self).handle_uncaught_exception(request, resolver, exc_info)
454
                request.POST  # evaluate
455
                return ret
456

457 458 459 460 461
        # Maybe this is a little more complicated that it needs to be; but if
        # the django.test.client.FakePayload.read() implementation changes then
        # this test would fail.  So we need to know exactly what kind of error
        # it raises when there is an attempt to read more than the available bytes:
        try:
462
            client.FakePayload(b'a').read(2)
463 464
        except Exception as err:
            reference_error = err
465 466 467 468

        # install the custom handler that tries to access request.POST
        self.client.handler = POSTAccessingHandler()

469
        with open(__file__, 'rb') as fp:
470 471 472 473 474
            post_data = {
                'name': 'Ringo',
                'file_field': fp,
            }
            try:
475
                self.client.post('/upload_errors/', post_data)
476 477 478 479 480 481 482 483
            except reference_error.__class__ as err:
                self.assertFalse(
                    str(err) == str(reference_error),
                    "Caught a repeated exception that'll cause an infinite loop in file uploads."
                )
            except Exception as err:
                # CustomUploadError is the error that should have been raised
                self.assertEqual(err.__class__, uploadhandler.CustomUploadError)
484 485 486 487 488 489 490 491 492 493 494 495

    def test_filename_case_preservation(self):
        """
        The storage backend shouldn't mess with the case of the filenames
        uploaded.
        """
        # Synthesize the contents of a file upload with a mixed case filename
        # so we don't have to carry such a file in the Django tests source code
        # tree.
        vars = {'boundary': 'oUrBoUnDaRyStRiNg'}
        post_data = [
            '--%(boundary)s',
496
            'Content-Disposition: form-data; name="file_field"; filename="MiXeD_cAsE.txt"',
497 498 499 500 501 502 503
            'Content-Type: application/octet-stream',
            '',
            'file contents\n'
            '',
            '--%(boundary)s--\r\n',
        ]
        response = self.client.post(
504
            '/filename_case/',
505 506 507 508 509 510 511 512 513
            '\r\n'.join(post_data) % vars,
            'multipart/form-data; boundary=%(boundary)s' % vars
        )
        self.assertEqual(response.status_code, 200)
        id = int(response.content)
        obj = FileModel.objects.get(pk=id)
        # The name of the file uploaded and the file stored in the server-side
        # shouldn't differ.
        self.assertEqual(os.path.basename(obj.testfile.path), 'MiXeD_cAsE.txt')
514

Jason Myers's avatar
Jason Myers committed
515

516
@override_settings(MEDIA_ROOT=MEDIA_ROOT)
517
class DirectoryCreationTests(SimpleTestCase):
518
    """
519
    Tests for error handling during directory creation
520 521
    via _save_FIELD_file (ticket #6450)
    """
522 523
    @classmethod
    def setUpClass(cls):
524
        super(DirectoryCreationTests, cls).setUpClass()
525 526 527 528 529 530
        if not os.path.isdir(MEDIA_ROOT):
            os.makedirs(MEDIA_ROOT)

    @classmethod
    def tearDownClass(cls):
        shutil.rmtree(MEDIA_ROOT)
531
        super(DirectoryCreationTests, cls).tearDownClass()
532

533 534 535 536 537
    def setUp(self):
        self.obj = FileModel()

    def test_readonly_root(self):
        """Permission errors are not swallowed"""
538 539
        os.chmod(MEDIA_ROOT, 0o500)
        self.addCleanup(os.chmod, MEDIA_ROOT, 0o700)
540
        try:
541
            self.obj.testfile.save('foo.txt', SimpleUploadedFile('foo.txt', b'x'), save=False)
542
        except OSError as err:
543
            self.assertEqual(err.errno, errno.EACCES)
544
        except Exception:
545
            self.fail("OSError [Errno %s] not raised." % errno.EACCES)
546 547 548 549

    def test_not_a_directory(self):
        """The correct IOError is raised when the upload directory name exists but isn't a directory"""
        # Create a file with the upload directory name
550
        open(UPLOAD_TO, 'wb').close()
551
        self.addCleanup(os.remove, UPLOAD_TO)
552
        with self.assertRaises(IOError) as exc_info:
553
            with SimpleUploadedFile('foo.txt', b'x') as file:
554
                self.obj.testfile.save('foo.txt', file, save=False)
555 556 557
        # The test needs to be done on a specific string as IOError
        # is raised even without the patch (just not early enough)
        self.assertEqual(exc_info.exception.args[0],
Jason Myers's avatar
Jason Myers committed
558
            "%s exists and is not a directory." % UPLOAD_TO)
559

560 561 562 563 564 565

class MultiParserTests(unittest.TestCase):

    def test_empty_upload_handlers(self):
        # We're not actually parsing here; just checking if the parser properly
        # instantiates with empty upload handlers.
566
        MultiPartParser({
Boryslav Larin's avatar
Boryslav Larin committed
567 568
            'CONTENT_TYPE': 'multipart/form-data; boundary=_foo',
            'CONTENT_LENGTH': '1'
569
        }, StringIO('x'), [], 'utf-8')
570 571 572 573 574 575 576 577 578 579 580 581 582

    def test_rfc2231_parsing(self):
        test_data = (
            (b"Content-Type: application/x-stuff; title*=us-ascii'en-us'This%20is%20%2A%2A%2Afun%2A%2A%2A",
             "This is ***fun***"),
            (b"Content-Type: application/x-stuff; title*=UTF-8''foo-%c3%a4.html",
             "foo-ä.html"),
            (b"Content-Type: application/x-stuff; title*=iso-8859-1''foo-%E4.html",
             "foo-ä.html"),
        )
        for raw_line, expected_title in test_data:
            parsed = parse_header(raw_line)
            self.assertEqual(parsed[1]['title'], expected_title)
583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599

    def test_rfc2231_wrong_title(self):
        """
        Test wrongly formatted RFC 2231 headers (missing double single quotes).
        Parsing should not crash (#24209).
        """
        test_data = (
            (b"Content-Type: application/x-stuff; title*='This%20is%20%2A%2A%2Afun%2A%2A%2A",
             b"'This%20is%20%2A%2A%2Afun%2A%2A%2A"),
            (b"Content-Type: application/x-stuff; title*='foo.html",
             b"'foo.html"),
            (b"Content-Type: application/x-stuff; title*=bar.html",
             b"bar.html"),
        )
        for raw_line, expected_title in test_data:
            parsed = parse_header(raw_line)
            self.assertEqual(parsed[1]['title'], expected_title)