Coverage for tests/test_http.py: 16%
658 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-10-09 03:18 -0700
« prev ^ index » next coverage.py v7.10.6, created at 2025-10-09 03:18 -0700
1# This file is part of lsst-resources.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# Use of this source code is governed by a 3-clause BSD-style
10# license that can be found in the LICENSE file.
12import hashlib
13import io
14import os.path
15import pickle
16import random
17import shutil
18import socket
19import stat
20import string
21import tempfile
22import time
23import unittest
24import unittest.mock
25import warnings
26from collections.abc import Callable
27from threading import Thread
28from typing import cast
30try:
31 from cheroot import wsgi
32 from wsgidav.wsgidav_app import WsgiDAVApp
33except ImportError:
34 WsgiDAVApp = None
36import requests
37import responses
38import responses.matchers
40import lsst.resources
41from lsst.resources import ResourcePath
42from lsst.resources._resourceHandles._httpResourceHandle import (
43 HttpReadResourceHandle,
44 parse_content_range_header,
45)
46from lsst.resources.http import (
47 BearerTokenAuth,
48 HttpResourcePath,
49 HttpResourcePathConfig,
50 SessionStore,
51 _is_protected,
52)
53from lsst.resources.tests import GenericReadWriteTestCase, GenericTestCase
54from lsst.resources.utils import _get_num_workers, makeTestTempDir, removeTestTempDir
56TESTDIR = os.path.abspath(os.path.dirname(__file__))
59class GenericHttpTestCase(GenericTestCase, unittest.TestCase):
60 """Generic tests of http URIs."""
62 scheme = "http"
63 netloc = "server.example"
65 def test_root_uri(self):
66 self.assertEqual(ResourcePath("http://server.com").root_uri(), ResourcePath("http://server.com/"))
67 self.assertEqual(
68 ResourcePath("http://user:password@server.com:3000/").root_uri(),
69 ResourcePath("http://user:password@server.com:3000/"),
70 )
71 self.assertEqual(
72 ResourcePath("http://user:password@server.com:3000/some/path").root_uri(),
73 ResourcePath("http://user:password@server.com:3000/"),
74 )
75 self.assertEqual(
76 ResourcePath("http://user:password@server.com:3000/some/path#fragment").root_uri(),
77 ResourcePath("http://user:password@server.com:3000/"),
78 )
79 self.assertEqual(
80 ResourcePath("http://user:password@server.com:3000/some/path?param=value").root_uri(),
81 ResourcePath("http://user:password@server.com:3000/"),
82 )
83 self.assertEqual(
84 ResourcePath("http://user:password@server.com:3000/some/path;parameters").root_uri(),
85 ResourcePath("http://user:password@server.com:3000/"),
86 )
88 @responses.activate
89 def test_extra_headers(self):
90 url = "http://test.example/something.txt"
91 path = HttpResourcePath.create_http_resource_path(
92 url, extra_headers={"Authorization": "Bearer my-token"}
93 )
95 self.assertEqual(str(path), "http://test.example/something.txt")
96 self.assertEqual(path._extra_headers, {"Authorization": "Bearer my-token"})
98 # Make sure that headers are added to requests.
99 responses.add(
100 responses.GET,
101 url,
102 b"test",
103 match=[responses.matchers.header_matcher({"Authorization": "Bearer my-token"})],
104 )
105 self.assertEqual(path.read(), b"test")
107 # Make sure that headers are added to fsspec.
108 # This triggers logic for "webdav" vs "not-webdav" that does an OPTIONS
109 # request, so we need to check that too.
110 responses.add(
111 responses.OPTIONS,
112 "http://test.example/",
113 match=[responses.matchers.header_matcher({"Authorization": "Bearer my-token"})],
114 )
115 fs, _ = path.to_fsspec()
116 self.assertEqual(fs.client_kwargs.get("headers"), {"Authorization": "Bearer my-token"})
118 # Extra headers should be preserved through pickle, to ensure that
119 # `mtransfer` and similar methods work in multi-process mode.
120 dump = pickle.dumps(path)
121 restored = pickle.loads(dump)
122 self.assertEqual(restored._extra_headers, {"Authorization": "Bearer my-token"})
124 # Extra headers should be preserved when making a modified copy of the
125 # ResourcePath using replace() or the ResourcePath constructor.
126 replacement = path.replace(forceDirectory=True)
127 self.assertEqual(replacement._extra_headers, {"Authorization": "Bearer my-token"})
128 copy = ResourcePath(path, forceDirectory=True)
129 self.assertEqual(copy._extra_headers, {"Authorization": "Bearer my-token"})
132class HttpReadWriteWebdavTestCase(GenericReadWriteTestCase, unittest.TestCase):
133 """Test with a real webDAV server, as opposed to mocking responses."""
135 scheme = "http"
136 local_files_to_remove: list[str] = []
138 @classmethod
139 def setUpClass(cls):
140 cls.webdav_tmpdir = tempfile.mkdtemp(prefix="webdav-server-test-")
141 cls.server_thread = None
143 # Disable warnings about socket connections left open. We purposedly
144 # keep network connections to the remote server open and have no
145 # means through the API exposed by Requests of actually close the
146 # underlyng sockets to make tests pass without warning.
147 warnings.filterwarnings(action="ignore", message=r"unclosed.*socket", category=ResourceWarning)
149 # Should we test against a running server?
150 #
151 # This is convenient for testing against real servers in the
152 # developer environment by initializing the environment variable
153 # LSST_RESOURCES_HTTP_TEST_SERVER_URL with the URL of the server, e.g.
154 # https://dav.example.org:1234/path/to/top/dir
155 if (test_endpoint := os.getenv("LSST_RESOURCES_HTTP_TEST_SERVER_URL")) is not None:
156 # Run this test case against the specified server.
157 uri = ResourcePath(test_endpoint)
158 cls.scheme = uri.scheme
159 cls.netloc = uri.netloc
160 cls.base_path = uri.path
161 elif WsgiDAVApp is not None:
162 # WsgiDAVApp is available, launch a local server in its own
163 # thread to expose a local temporary directory and run this
164 # test case against it.
165 cls.port_number = cls._get_port_number()
166 cls.stop_webdav_server = False
167 cls.server_thread = Thread(
168 target=cls._serve_webdav,
169 args=(cls, cls.webdav_tmpdir, cls.port_number, lambda: cls.stop_webdav_server),
170 daemon=True,
171 )
172 cls.server_thread.start()
174 # Wait for it to start
175 time.sleep(1)
177 # Initialize the server endpoint
178 cls.netloc = f"127.0.0.1:{cls.port_number}"
179 else:
180 cls.skipTest(
181 cls,
182 "neither WsgiDAVApp is available nor a webDAV test endpoint is configured to test against",
183 )
185 @classmethod
186 def tearDownClass(cls):
187 # Stop the WsgiDAVApp server, if any
188 if WsgiDAVApp is not None:
189 # Shut down of the webdav server and wait for the thread to exit
190 cls.stop_webdav_server = True
191 if cls.server_thread is not None:
192 cls.server_thread.join()
194 # Remove local temporary files
195 for file in cls.local_files_to_remove:
196 if os.path.exists(file):
197 os.remove(file)
199 # Remove temp dir
200 if cls.webdav_tmpdir:
201 shutil.rmtree(cls.webdav_tmpdir, ignore_errors=True)
203 # Reset the warnings filter.
204 warnings.resetwarnings()
206 def tearDown(self):
207 if self.tmpdir:
208 self.tmpdir.remove()
210 # Clear sessions. Some sockets may be left open, because urllib3
211 # doest not close in-flight connections.
212 # See https://urllib3.readthedocs.io > API Reference >
213 # Pool Manager > clear()
214 # I cannot add the full URL here because it is longer than 79
215 # characters.
216 self.tmpdir._clear_sessions()
218 super().tearDown()
220 def test_dav_file_handle(self):
221 # Upload a new file with known contents.
222 contents = "These are some \n bytes to read"
223 remote_file = self.tmpdir.join(self._get_file_name())
224 self.assertIsNone(remote_file.write(data=contents, overwrite=True))
226 # Test that the correct handle is returned.
227 with remote_file.open("rb") as handle:
228 self.assertIsInstance(handle, HttpReadResourceHandle)
230 # Test reading byte ranges works
231 with remote_file.open("rb") as handle:
232 sub_contents = contents[:10]
233 handle = cast(HttpReadResourceHandle, handle)
234 result = handle.read(len(sub_contents)).decode()
235 self.assertEqual(result, sub_contents)
236 # Verify there is no internal buffer.
237 self.assertIsNone(handle._completeBuffer)
238 # Verify the position.
239 self.assertEqual(handle.tell(), len(sub_contents))
241 # Jump back to the beginning and test if reading the whole file
242 # prompts the internal buffer to be read.
243 handle.seek(0)
244 self.assertEqual(handle.tell(), 0)
245 result = handle.read().decode()
246 self.assertIsNotNone(handle._completeBuffer)
247 self.assertEqual(result, contents)
249 # Check that flush works on read-only handle.
250 handle.flush()
252 # Verify reading as a string handle works as expected.
253 with remote_file.open("r") as handle:
254 self.assertIsInstance(handle, io.TextIOWrapper)
256 handle = cast(io.TextIOWrapper, handle)
257 self.assertIsInstance(handle.buffer, HttpReadResourceHandle)
259 # Check if string methods work.
260 result = handle.read()
261 self.assertEqual(result, contents)
263 # Check that flush works on read-only handle.
264 handle.flush()
266 # Verify that write modes invoke the default base method
267 with remote_file.open("w") as handle:
268 self.assertIsInstance(handle, io.StringIO)
270 def test_dav_is_dav_enpoint(self):
271 # Ensure the server is a webDAV endpoint
272 self.assertTrue(self.tmpdir.is_webdav_endpoint)
274 def test_dav_mkdir(self):
275 # Check creation and deletion of an empty directory
276 subdir = self.tmpdir.join(self._get_dir_name(), forceDirectory=True)
277 self.assertIsNone(subdir.mkdir())
278 self.assertTrue(subdir.exists())
280 # Creating an existing remote directory must succeed
281 self.assertIsNone(subdir.mkdir())
283 # Deletion of an existing directory must succeed
284 self.assertIsNone(subdir.remove())
286 # Deletion of an non-existing directory must succeed
287 subir_not_exists = self.tmpdir.join(self._get_dir_name(), forceDirectory=True)
288 self.assertIsNone(subir_not_exists.remove())
290 # Creation of a directory at a path where a file exists must raise
291 file = self.tmpdir.join(self._get_file_name(), forceDirectory=False)
292 file.write(data=None, overwrite=True)
293 self.assertTrue(file.exists())
295 existing_file = self.tmpdir.join(file.basename(), forceDirectory=True)
296 with self.assertRaises(NotADirectoryError):
297 self.assertIsNone(existing_file.mkdir())
299 def test_dav_upload_download(self):
300 # Test upload a randomly-generated file via write() with and without
301 # overwrite
302 local_file, file_size = self._generate_file()
303 with open(local_file, "rb") as f:
304 data = f.read()
306 remote_file = self.tmpdir.join(self._get_file_name())
307 self.assertIsNone(remote_file.write(data, overwrite=True))
308 self.assertTrue(remote_file.exists())
309 self.assertEqual(remote_file.size(), file_size)
311 # Write without overwrite must raise since target file exists
312 with self.assertRaises(FileExistsError):
313 remote_file.write(data, overwrite=False)
315 # Download the file we just uploaded. Compute and compare a digest of
316 # the uploaded and downloaded data and ensure they match
317 downloaded_data = remote_file.read()
318 self.assertEqual(len(downloaded_data), file_size)
319 upload_digest = self._compute_digest(data)
320 download_digest = self._compute_digest(downloaded_data)
321 self.assertEqual(upload_digest, download_digest)
322 os.remove(local_file)
324 def test_dav_as_local(self):
325 contents = str.encode("12345")
326 remote_file = self.tmpdir.join(self._get_file_name())
327 self.assertIsNone(remote_file.write(data=contents, overwrite=True))
329 with remote_file._as_local() as local_uri:
330 self.assertTrue(local_uri.isTemporary)
331 self.assertTrue(os.path.exists(local_uri.ospath))
332 self.assertTrue(os.stat(local_uri.ospath).st_size, len(contents))
333 self.assertEqual(local_uri.read(), contents)
334 self.assertFalse(local_uri.exists())
336 def test_dav_size(self):
337 # Size of a non-existent file must raise.
338 remote_file = self.tmpdir.join(self._get_file_name())
339 with self.assertRaises(FileNotFoundError):
340 remote_file.size()
342 # Retrieving the size of a remote directory using a file-like path must
343 # raise
344 remote_dir = self.tmpdir.join(self._get_dir_name(), forceDirectory=True)
345 self.assertIsNone(remote_dir.mkdir())
346 self.assertTrue(remote_dir.exists())
348 dir_as_file = ResourcePath(remote_dir.geturl().rstrip("/"), forceDirectory=False)
349 with self.assertRaises(IsADirectoryError):
350 dir_as_file.size()
352 def test_dav_upload_creates_dir(self):
353 # Uploading a file to a non existing directory must ensure its
354 # parent directories are automatically created and upload succeeds
355 non_existing_dir = self.tmpdir.join(self._get_dir_name(), forceDirectory=True)
356 non_existing_dir = non_existing_dir.join(self._get_dir_name(), forceDirectory=True)
357 non_existing_dir = non_existing_dir.join(self._get_dir_name(), forceDirectory=True)
358 remote_file = non_existing_dir.join(self._get_file_name())
360 local_file, file_size = self._generate_file()
361 with open(local_file, "rb") as f:
362 data = f.read()
363 self.assertIsNone(remote_file.write(data, overwrite=True))
365 self.assertTrue(remote_file.exists())
366 self.assertEqual(remote_file.size(), file_size)
367 self.assertTrue(remote_file.parent().exists())
369 downloaded_data = remote_file.read()
370 upload_digest = self._compute_digest(data)
371 download_digest = self._compute_digest(downloaded_data)
372 self.assertEqual(upload_digest, download_digest)
373 os.remove(local_file)
375 def test_dav_transfer_from(self):
376 # Transfer from local file via "copy", with and without overwrite
377 remote_file = self.tmpdir.join(self._get_file_name())
378 local_file, _ = self._generate_file()
379 source_file = ResourcePath(local_file)
380 self.assertIsNone(remote_file.transfer_from(source_file, transfer="copy", overwrite=True))
381 self.assertTrue(remote_file.exists())
382 self.assertEqual(remote_file.size(), source_file.size())
383 with self.assertRaises(FileExistsError):
384 remote_file.transfer_from(ResourcePath(local_file), transfer="copy", overwrite=False)
386 # Transfer from remote file via "copy", with and without overwrite
387 source_file = remote_file
388 target_file = self.tmpdir.join(self._get_file_name())
389 self.assertIsNone(target_file.transfer_from(source_file, transfer="copy", overwrite=True))
390 self.assertTrue(target_file.exists())
391 self.assertEqual(target_file.size(), source_file.size())
393 # Transfer without overwrite must raise since target resource exists
394 with self.assertRaises(FileExistsError):
395 target_file.transfer_from(source_file, transfer="copy", overwrite=False)
397 # Test transfer from local file via "move", with and without overwrite
398 source_file = ResourcePath(local_file)
399 source_size = source_file.size()
400 target_file = self.tmpdir.join(self._get_file_name())
401 self.assertIsNone(target_file.transfer_from(source_file, transfer="move", overwrite=True))
402 self.assertTrue(target_file.exists())
403 self.assertEqual(target_file.size(), source_size)
404 self.assertFalse(source_file.exists())
406 # Test transfer without overwrite must raise since target resource
407 # exists
408 local_file, file_size = self._generate_file()
409 with self.assertRaises(FileExistsError):
410 source_file = ResourcePath(local_file)
411 target_file.transfer_from(source_file, transfer="move", overwrite=False)
413 # Test transfer from remote file via "move" with and without overwrite
414 # must succeed
415 source_file = target_file
416 source_size = source_file.size()
417 target_file = self.tmpdir.join(self._get_file_name())
418 self.assertIsNone(target_file.transfer_from(source_file, transfer="move", overwrite=True))
419 self.assertTrue(target_file.exists())
420 self.assertEqual(target_file.size(), source_size)
421 self.assertFalse(source_file.exists())
423 # Transfer without overwrite must raise since target resource exists
424 with self.assertRaises(FileExistsError):
425 source_file = ResourcePath(local_file)
426 target_file.transfer_from(source_file, transfer="move", overwrite=False)
428 def test_dav_handle(self):
429 # Resource handle must succeed
430 target_file = self.tmpdir.join(self._get_file_name())
431 data = "abcdefghi"
432 self.assertIsNone(target_file.write(data, overwrite=True))
433 with target_file.open("rb") as handle:
434 handle.seek(1)
435 self.assertEqual(handle.read(4).decode("utf-8"), data[1:5])
437 def test_dav_delete(self):
438 # Deletion of an existing remote file must succeed
439 local_file, file_size = self._generate_file()
440 with open(local_file, "rb") as f:
441 data = f.read()
443 remote_file = self.tmpdir.join(self._get_file_name())
444 self.assertIsNone(remote_file.write(data, overwrite=True))
445 self.assertTrue(remote_file.exists())
446 self.assertEqual(remote_file.size(), file_size)
447 self.assertIsNone(remote_file.remove())
448 os.remove(local_file)
450 # Deletion of a non-existing remote file must succeed
451 non_existing_file = self.tmpdir.join(self._get_file_name())
452 self.assertIsNone(non_existing_file.remove())
454 # Deletion of a non-empty remote directory must succeed
455 subdir = self.tmpdir.join(self._get_dir_name(), forceDirectory=True)
456 self.assertIsNone(subdir.mkdir())
457 self.assertTrue(subdir.exists())
458 local_file, _ = self._generate_file()
459 source_file = ResourcePath(local_file)
460 target_file = self.tmpdir.join(self._get_file_name(), forceDirectory=True)
461 self.assertIsNone(target_file.transfer_from(source_file, transfer="copy", overwrite=True))
462 self.assertIsNone(subdir.remove())
463 self.assertFalse(subdir.exists())
464 os.remove(local_file)
466 def test_dav_to_fsspec(self):
467 # Upload a randomly-generated file via write() with overwrite.
468 local_file, file_size = self._generate_file()
469 with open(local_file, "rb") as f:
470 data = f.read()
472 remote_file = self.tmpdir.join(self._get_file_name())
473 self.assertIsNone(remote_file.write(data, overwrite=True))
474 self.assertTrue(remote_file.exists())
475 self.assertEqual(remote_file.size(), file_size)
476 remote_file_url = remote_file.geturl()
478 # to_fsspec() may raise if that feature is not specifically
479 # enabled in the environment and remote server is one of the
480 # webDAV servers that support signing URLs.
481 with unittest.mock.patch.dict(os.environ, {}, clear=True):
482 try:
483 # Force reinitialization of the config from the environment
484 HttpResourcePath._reload_config()
485 fsys, url = ResourcePath(remote_file_url).to_fsspec()
486 self.assertEqual(data, fsys.cat(url))
487 except ImportError as e:
488 self.assertTrue("disable" in str(e))
490 # Ensure to_fsspec() works if that feature is enabled in the
491 # environment.
492 with unittest.mock.patch.dict(os.environ, {"LSST_HTTP_ENABLE_FSSPEC": "true"}, clear=True):
493 try:
494 # Force reinitialization of the config from the environment.
495 HttpResourcePath._reload_config()
496 rpath = ResourcePath(remote_file_url)
498 # Ensure that the contents of the remote file we just
499 # uploaded is identical to the contents of that file when
500 # retrieved via fsspec.open().
501 fsys, url = rpath.to_fsspec()
502 with fsys.open(url) as f:
503 self.assertEqual(data, f.read())
505 # Ensure the contents is identical to the result of
506 # fsspec.cat()
507 self.assertEqual(data, fsys.cat(url))
509 # Ensure that attempting to modify a remote via via fsspec
510 # fails, since the returned URL is signed for download only.
511 # fsspec.rm() raises NotImplementedError if it cannot remove
512 # the remote file.
513 if rpath.server_signs_urls:
514 with self.assertRaises(NotImplementedError):
515 fsys, url = rpath.to_fsspec()
516 fsys.rm(url)
517 except NotImplementedError as e:
518 # to_fsspec() must succeed if remote server knows how to
519 # sign URLs
520 if rpath.server_signs_urls:
521 raise e
523 # Force reinitialization of the config from the environment and
524 # clean up local file.
525 HttpResourcePath._reload_config()
526 os.remove(local_file)
528 @responses.activate
529 def test_is_webdav_endpoint(self):
530 davEndpoint = "http://www.lsstwithwebdav.org"
531 responses.add(responses.OPTIONS, davEndpoint, status=200, headers={"DAV": "1,2,3"})
532 self.assertTrue(ResourcePath(davEndpoint).is_webdav_endpoint)
534 plainHttpEndpoint = "http://www.lsstwithoutwebdav.org"
535 responses.add(responses.OPTIONS, plainHttpEndpoint, status=200)
536 self.assertFalse(ResourcePath(plainHttpEndpoint).is_webdav_endpoint)
538 notWebdavEndpoint = "http://www.notwebdav.org"
539 responses.add(responses.OPTIONS, notWebdavEndpoint, status=403)
540 self.assertFalse(ResourcePath(notWebdavEndpoint).is_webdav_endpoint)
542 @responses.activate
543 def test_plain_http_url_signing(self):
544 # As in test_is_webdav_endpoint above, configure a URL to appear as a
545 # non-webdav HTTP server.
546 plainHttpEndpoint = "http://nonwebdav.test"
547 responses.add(responses.OPTIONS, plainHttpEndpoint, status=200)
549 # Plain HTTP URLs are already readable without authentication, so
550 # generating a pre-signed URL is a no-op.
551 path = ResourcePath("http://nonwebdav.test/file#frag")
552 self.assertEqual(
553 path.generate_presigned_get_url(expiration_time_seconds=300), "http://nonwebdav.test/file#frag"
554 )
556 # Writing to an arbitrary plain HTTP URL is unlikely to work, so we
557 # don't generate put URLs.
558 with self.assertRaises(NotImplementedError):
559 path.generate_presigned_put_url(expiration_time_seconds=300)
561 @responses.activate
562 def test_server_identity(self):
563 server = "MyServer/v1.2.3"
564 endpointWithServer = "http://www.lsstwithserverheader.org"
565 responses.add(responses.OPTIONS, endpointWithServer, status=200, headers={"Server": server})
566 self.assertEqual(ResourcePath(endpointWithServer).server, "myserver")
568 endpointWithoutServer = "http://www.lsstwithoutserverheader.org"
569 responses.add(responses.OPTIONS, endpointWithoutServer, status=200)
570 self.assertIsNone(ResourcePath(endpointWithoutServer).server)
572 @classmethod
573 def _get_port_number(cls) -> int:
574 """Return a port number the webDAV server can use to listen to."""
575 s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
576 s.bind(("127.0.0.1", 0))
577 s.listen()
578 port = s.getsockname()[1]
579 s.close()
580 return port
582 def _serve_webdav(self, local_path: str, port: int, stop_webdav_server: Callable[[], bool]):
583 """Start a local webDAV server, listening on http://localhost:port
584 and exposing local_path.
586 This server only runs when this test class is instantiated,
587 and then shuts down. The server must be started is a separate thread.
589 Parameters
590 ----------
591 port : `int`
592 The port number on which the server should listen
593 local_path : `str`
594 Path to an existing local directory for the server to expose.
595 stop_webdav_server : `Callable[[], bool]`
596 Boolean function which returns True when the server should be
597 stopped.
598 """
599 try:
600 # Start the wsgi server in a separate thread
601 config = {
602 "host": "127.0.0.1",
603 "port": port,
604 "provider_mapping": {"/": local_path},
605 "http_authenticator": {"domain_controller": None},
606 "simple_dc": {"user_mapping": {"*": True}},
607 "verbose": 0,
608 "lock_storage": False,
609 "dir_browser": {
610 "enable": False,
611 "ms_sharepoint_support": False,
612 "libre_office_support": False,
613 "response_trailer": False,
614 "davmount_links": False,
615 },
616 }
617 server = wsgi.Server(wsgi_app=WsgiDAVApp(config), bind_addr=(config["host"], config["port"]))
618 t = Thread(target=server.start, daemon=True)
619 t.start()
621 # Shut down the server when done: stop_webdav_server() returns
622 # True when this test suite is being teared down
623 while not stop_webdav_server():
624 time.sleep(1)
625 except KeyboardInterrupt:
626 # Caught Ctrl-C, shut down the server
627 pass
628 finally:
629 server.stop()
630 t.join()
632 @classmethod
633 def _get_name(cls, prefix: str) -> str:
634 alphabet = string.ascii_lowercase + string.digits
635 return f"{prefix}-" + "".join(random.choices(alphabet, k=8))
637 @classmethod
638 def _get_dir_name(cls) -> str:
639 """Return a randomly selected name for a file"""
640 return cls._get_name(prefix="dir")
642 @classmethod
643 def _get_file_name(cls) -> str:
644 """Return a randomly selected name for a file"""
645 return cls._get_name(prefix="file")
647 def _generate_file(self, remove_when_done=True) -> tuple[str, int]:
648 """Create a local file of random size with random contents.
650 Returns
651 -------
652 path : `str`
653 Path to local temporary file. The caller is responsible for
654 removing the file when appropriate.
655 size : `int`
656 Size of the generated file, in bytes.
657 """
658 megabyte = 1024 * 1024
659 size = random.randint(2 * megabyte, 5 * megabyte)
660 tmpfile, path = tempfile.mkstemp()
661 self.assertEqual(os.write(tmpfile, os.urandom(size)), size)
662 os.close(tmpfile)
664 if remove_when_done:
665 HttpReadWriteWebdavTestCase.local_files_to_remove.append(path)
667 return path, size
669 @classmethod
670 def _compute_digest(cls, data: bytes) -> str:
671 """Compute a SHA256 hash of data."""
672 m = hashlib.sha256()
673 m.update(data)
674 return m.hexdigest()
676 @classmethod
677 def _is_server_running(cls, port: int) -> bool:
678 """Return True if there is a server listening on local address
679 127.0.0.1:<port>.
680 """
681 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
682 try:
683 s.connect(("127.0.0.1", port))
684 return True
685 except ConnectionRefusedError:
686 return False
689class HttpResourcePathConfigTestCase(unittest.TestCase):
690 """Test for the HttpResourcePathConfig class."""
692 def setUp(self):
693 self.tmpdir = ResourcePath(makeTestTempDir(TESTDIR))
695 def tearDown(self):
696 if self.tmpdir and self.tmpdir.isLocal:
697 removeTestTempDir(self.tmpdir.ospath)
699 def test_send_expect_header(self):
700 # Ensure environment variable LSST_HTTP_PUT_SEND_EXPECT_HEADER is
701 # inspected to initialize the HttpResourcePathConfig class.
702 with unittest.mock.patch.dict(os.environ, {}, clear=True):
703 config = HttpResourcePathConfig()
704 self.assertFalse(config.send_expect_on_put)
706 with unittest.mock.patch.dict(os.environ, {"LSST_HTTP_PUT_SEND_EXPECT_HEADER": "true"}, clear=True):
707 config = HttpResourcePathConfig()
708 self.assertTrue(config.send_expect_on_put)
710 def test_enable_fsspec(self):
711 # Ensure environment variable LSST_HTTP_ENABLE_FSSPEC is
712 # inspected to initialize the HttpResourcePathConfig class.
713 with unittest.mock.patch.dict(os.environ, {}, clear=True):
714 config = HttpResourcePathConfig()
715 self.assertFalse(config.fsspec_is_enabled)
717 with unittest.mock.patch.dict(os.environ, {"LSST_HTTP_ENABLE_FSSPEC": "any value"}, clear=True):
718 config = HttpResourcePathConfig()
719 self.assertTrue(config.fsspec_is_enabled)
721 def test_collect_memory_usage(self):
722 # Ensure environment variable LSST_HTTP_COLLECT_MEMORY_USAGE is
723 # inspected to initialize the HttpResourcePathConfig class.
724 with unittest.mock.patch.dict(os.environ, {}, clear=True):
725 config = HttpResourcePathConfig()
726 self.assertFalse(config.collect_memory_usage)
728 with unittest.mock.patch.dict(os.environ, {"LSST_HTTP_COLLECT_MEMORY_USAGE": "true"}, clear=True):
729 config = HttpResourcePathConfig()
730 self.assertTrue(config.collect_memory_usage)
732 def test_timeout(self):
733 # Ensure that when the connect and read timeouts are not specified
734 # the default values are stored in the config.
735 with unittest.mock.patch.dict(os.environ, {}, clear=True):
736 config = HttpResourcePathConfig()
737 self.assertAlmostEqual(config.timeout[0], config.DEFAULT_TIMEOUT_CONNECT)
738 self.assertAlmostEqual(config.timeout[1], config.DEFAULT_TIMEOUT_READ)
740 # Ensure that when both the connect and read timeouts are specified
741 # they are both stored in the config.
742 connect_timeout, read_timeout = 100.5, 200.8
743 with unittest.mock.patch.dict(
744 os.environ,
745 {"LSST_HTTP_TIMEOUT_CONNECT": str(connect_timeout), "LSST_HTTP_TIMEOUT_READ": str(read_timeout)},
746 clear=True,
747 ):
748 config = HttpResourcePathConfig()
749 self.assertAlmostEqual(config.timeout[0], connect_timeout)
750 self.assertAlmostEqual(config.timeout[1], read_timeout)
752 # Ensure that invalid float values (including NaN values) raise a
753 # ValueError.
754 for value in ("invalid", "NaN"):
755 with unittest.mock.patch.dict(
756 os.environ,
757 {"LSST_HTTP_TIMEOUT_CONNECT": value, "LSST_HTTP_TIMEOUT_READ": value},
758 clear=True,
759 ):
760 with self.assertRaises(ValueError):
761 config = HttpResourcePathConfig()
762 config.timeout()
764 def test_front_end_connections(self):
765 # Ensure that when the number of front end connections is not specified
766 # the default comes from the number of workers..
767 with unittest.mock.patch.dict(os.environ, {}, clear=True):
768 config = HttpResourcePathConfig()
769 self.assertEqual(config.front_end_connections, _get_num_workers())
771 # Ensure that when the number of front end connections is specified
772 # it is stored in the config.
773 connections = 42
774 with unittest.mock.patch.dict(
775 os.environ, {"LSST_HTTP_FRONTEND_PERSISTENT_CONNECTIONS": str(connections)}, clear=True
776 ):
777 config = HttpResourcePathConfig()
778 self.assertTrue(config.front_end_connections, connections)
780 def test_back_end_connections(self):
781 # Ensure that when the number of back end connections is not specified
782 # the default comes from the number of workers.
783 with unittest.mock.patch.dict(os.environ, {}, clear=True):
784 config = HttpResourcePathConfig()
785 self.assertEqual(config.back_end_connections, _get_num_workers())
787 # Ensure that when the number of back end connections is specified
788 # it is stored in the config.
789 connections = 42
790 with unittest.mock.patch.dict(
791 os.environ, {"LSST_HTTP_BACKEND_PERSISTENT_CONNECTIONS": str(connections)}, clear=True
792 ):
793 config = HttpResourcePathConfig()
794 self.assertTrue(config.back_end_connections, connections)
796 def test_digest_algorithm(self):
797 # Ensure that when no digest is specified in the environment, the
798 # configured digest algorithm is the empty string.
799 with unittest.mock.patch.dict(os.environ, {}, clear=True):
800 config = HttpResourcePathConfig()
801 self.assertEqual(config.digest_algorithm, "")
803 # Ensure that an invalid digest algorithm is ignored.
804 digest = "invalid"
805 with unittest.mock.patch.dict(os.environ, {"LSST_HTTP_DIGEST": digest}, clear=True):
806 config = HttpResourcePathConfig()
807 self.assertEqual(config.digest_algorithm, "")
809 # Ensure that an accepted digest algorithm is stored.
810 for digest in HttpResourcePathConfig().ACCEPTED_DIGESTS:
811 with unittest.mock.patch.dict(os.environ, {"LSST_HTTP_DIGEST": digest}, clear=True):
812 config = HttpResourcePathConfig()
813 self.assertTrue(config.digest_algorithm, digest)
815 def test_backoff_interval(self):
816 # Ensure that when no backoff interval is defined, the default values
817 # are used.
818 with unittest.mock.patch.dict(os.environ, {}, clear=True):
819 config = HttpResourcePathConfig()
820 self.assertAlmostEqual(config.backoff_min, config.DEFAULT_BACKOFF_MIN)
821 self.assertAlmostEqual(config.backoff_max, config.DEFAULT_BACKOFF_MAX)
823 # Ensure that an invalid value for backoff interval is ignored and
824 # the default value is used.
825 with unittest.mock.patch.dict(
826 os.environ, {"LSST_HTTP_BACKOFF_MIN": "XXX", "LSST_HTTP_BACKOFF_MAX": "YYY"}, clear=True
827 ):
828 config = HttpResourcePathConfig()
829 self.assertAlmostEqual(config.backoff_min, config.DEFAULT_BACKOFF_MIN)
830 self.assertAlmostEqual(config.backoff_max, config.DEFAULT_BACKOFF_MAX)
832 # Ensure that NaN values are ignored and the defaults values are used.
833 with unittest.mock.patch.dict(
834 os.environ, {"LSST_HTTP_BACKOFF_MIN": "NaN", "LSST_HTTP_BACKOFF_MAX": "NaN"}, clear=True
835 ):
836 config = HttpResourcePathConfig()
837 self.assertAlmostEqual(config.backoff_min, config.DEFAULT_BACKOFF_MIN)
838 self.assertAlmostEqual(config.backoff_max, config.DEFAULT_BACKOFF_MAX)
840 # Ensure that when specified, valid limits backoff interval are used.
841 backoff_min, backoff_max = 3.0, 8.0
842 with unittest.mock.patch.dict(
843 os.environ,
844 {"LSST_HTTP_BACKOFF_MIN": str(backoff_min), "LSST_HTTP_BACKOFF_MAX": str(backoff_max)},
845 clear=True,
846 ):
847 config = HttpResourcePathConfig()
848 self.assertAlmostEqual(config.backoff_min, backoff_min)
849 self.assertAlmostEqual(config.backoff_max, backoff_max)
851 def test_ca_bundle(self):
852 # Ensure that when no bundle is defined via environment variable
853 # LSST_HTTP_CACERT_BUNDLE either None is returned or the returned
854 # path does exist.
855 with unittest.mock.patch.dict(os.environ, {}, clear=True):
856 config = HttpResourcePathConfig()
857 if config.ca_bundle is not None:
858 self.assertTrue(os.path.exists(config.ca_bundle))
860 # Ensure that if LSST_HTTP_CACERT_BUNDLE is specified, the returned
861 # path is identical to the value of that variable (we don't check
862 # here that the path actually exists).
863 ca_bundle = "/path/to/bundle/dir"
864 with unittest.mock.patch.dict(os.environ, {"LSST_HTTP_CACERT_BUNDLE": ca_bundle}, clear=True):
865 config = HttpResourcePathConfig()
866 self.assertEqual(config.ca_bundle, ca_bundle)
868 def test_client_token(self):
869 # Ensure that when no token is defined via environment variable
870 # LSST_HTTP_AUTH_BEARER_TOKEN None is returned.
871 with unittest.mock.patch.dict(os.environ, {}, clear=True):
872 config = HttpResourcePathConfig()
873 self.assertIsNone(config.client_token)
875 # Ensure that if LSST_HTTP_AUTH_BEARER_TOKEN is specified, the returned
876 # path is identical to the value of that variable (we don't check
877 # here that the path actually exists).
878 token = "ABCDE12345"
879 with unittest.mock.patch.dict(os.environ, {"LSST_HTTP_AUTH_BEARER_TOKEN": token}, clear=True):
880 config = HttpResourcePathConfig()
881 self.assertEqual(config.client_token, token)
883 def test_client_cert_key(self):
884 """Ensure if user certificate and private key are provided via
885 environment variables, the configuration is correctly configured.
886 """
887 # Ensure that when no client certificate nor private key are provided
888 # via environment variables, both certificate and key are None.
889 with unittest.mock.patch.dict(os.environ, {}, clear=True):
890 config = HttpResourcePathConfig()
891 cert, key = config.client_cert_key
892 self.assertIsNone(cert)
893 self.assertIsNone(key)
895 # Create mock certificate and private key files.
896 with tempfile.NamedTemporaryFile(mode="wt", dir=self.tmpdir.ospath, delete=False) as f:
897 f.write("CERT")
898 client_cert = f.name
900 with tempfile.NamedTemporaryFile(mode="wt", dir=self.tmpdir.ospath, delete=False) as f:
901 f.write("KEY")
902 client_key = f.name
904 # Check that if only LSST_HTTP_AUTH_CLIENT_CERT is initialized
905 # an exception is raised.
906 with unittest.mock.patch.dict(os.environ, {"LSST_HTTP_AUTH_CLIENT_CERT": client_cert}, clear=True):
907 with self.assertRaises(ValueError):
908 HttpResourcePathConfig().client_cert_key
910 # Check that if only LSST_HTTP_AUTH_CLIENT_KEY is initialized
911 # an exception is raised.
912 with unittest.mock.patch.dict(os.environ, {"LSST_HTTP_AUTH_CLIENT_KEY": client_key}, clear=True):
913 with self.assertRaises(ValueError):
914 HttpResourcePathConfig().client_cert_key
916 # Check that the private key file must be accessible only by its owner.
917 with unittest.mock.patch.dict(
918 os.environ,
919 {"LSST_HTTP_AUTH_CLIENT_CERT": client_cert, "LSST_HTTP_AUTH_CLIENT_KEY": client_key},
920 clear=True,
921 ):
922 # Ensure the client certificate is initialized when only the owner
923 # can read the private key file.
924 os.chmod(client_key, stat.S_IRUSR)
925 config = HttpResourcePathConfig()
926 cert, key = config.client_cert_key
927 self.assertEqual(cert, client_cert)
928 self.assertEqual(key, client_key)
930 # Ensure an exception is raised if either group or other can access
931 # the private key file.
932 for mode in (stat.S_IRGRP, stat.S_IWGRP, stat.S_IXGRP, stat.S_IROTH, stat.S_IWOTH, stat.S_IXOTH):
933 os.chmod(client_key, stat.S_IRUSR | mode)
934 with self.assertRaises(PermissionError):
935 HttpResourcePathConfig().client_cert_key
937 # Check that if environment variable X509_USER_PROXY is initialized
938 # the configuration uses its value as the client's certificate and key.
939 with unittest.mock.patch.dict(os.environ, {"X509_USER_PROXY": client_cert}, clear=True):
940 config = HttpResourcePathConfig()
941 cert, key = config.client_cert_key
942 self.assertEqual(cert, client_cert)
943 self.assertEqual(key, client_cert)
946class WebdavUtilsTestCase(unittest.TestCase):
947 """Test for the Webdav related utilities."""
949 def setUp(self):
950 self.tmpdir = ResourcePath(makeTestTempDir(TESTDIR))
952 def tearDown(self):
953 if self.tmpdir and self.tmpdir.isLocal:
954 removeTestTempDir(self.tmpdir.ospath)
956 def test_is_protected(self):
957 self.assertFalse(_is_protected("/this-file-does-not-exist"))
959 with tempfile.NamedTemporaryFile(mode="wt", dir=self.tmpdir.ospath, delete=False) as f:
960 f.write("XXXX")
961 file_path = f.name
963 os.chmod(file_path, stat.S_IRUSR)
964 self.assertTrue(_is_protected(file_path))
966 for mode in (stat.S_IRGRP, stat.S_IWGRP, stat.S_IXGRP, stat.S_IROTH, stat.S_IWOTH, stat.S_IXOTH):
967 os.chmod(file_path, stat.S_IRUSR | mode)
968 self.assertFalse(_is_protected(file_path))
971class BearerTokenAuthTestCase(unittest.TestCase):
972 """Test for the BearerTokenAuth class."""
974 def setUp(self):
975 self.tmpdir = ResourcePath(makeTestTempDir(TESTDIR))
976 self.token = "ABCDE1234"
978 def tearDown(self):
979 if self.tmpdir and self.tmpdir.isLocal:
980 removeTestTempDir(self.tmpdir.ospath)
982 def test_empty_token(self):
983 """Ensure that when no token is provided the request is not
984 modified.
985 """
986 auth = BearerTokenAuth(None)
987 auth._refresh()
988 self.assertIsNone(auth._token)
989 self.assertIsNone(auth._path)
990 req = requests.Request("GET", "https://example.org")
991 self.assertEqual(auth(req), req)
993 def test_token_value(self):
994 """Ensure that when a token value is provided, the 'Authorization'
995 header is added to the requests.
996 """
997 auth = BearerTokenAuth(self.token)
998 req = auth(requests.Request("GET", "https://example.org").prepare())
999 self.assertEqual(req.headers.get("Authorization"), f"Bearer {self.token}")
1001 def test_token_insecure_http(self):
1002 """Ensure that no 'Authorization' header is attached to a request when
1003 using insecure HTTP.
1004 """
1005 auth = BearerTokenAuth(self.token)
1006 for url in ("http://example.org", "HTTP://example.org", "HttP://example.org"):
1007 req = auth(requests.Request("GET", url).prepare())
1008 self.assertIsNone(req.headers.get("Authorization"))
1010 def test_token_file(self):
1011 """Ensure when the provided token is a file path, its contents is
1012 correctly used in the the 'Authorization' header of the requests.
1013 """
1014 with tempfile.NamedTemporaryFile(mode="wt", dir=self.tmpdir.ospath, delete=False) as f:
1015 f.write(self.token)
1016 token_file_path = f.name
1018 # Ensure the request's "Authorization" header is set with the right
1019 # token value
1020 os.chmod(token_file_path, stat.S_IRUSR)
1021 auth = BearerTokenAuth(token_file_path)
1022 req = auth(requests.Request("GET", "https://example.org").prepare())
1023 self.assertEqual(req.headers.get("Authorization"), f"Bearer {self.token}")
1025 # Ensure an exception is raised if either group or other can read the
1026 # token file
1027 for mode in (stat.S_IRGRP, stat.S_IWGRP, stat.S_IXGRP, stat.S_IROTH, stat.S_IWOTH, stat.S_IXOTH):
1028 os.chmod(token_file_path, stat.S_IRUSR | mode)
1029 with self.assertRaises(PermissionError):
1030 BearerTokenAuth(token_file_path)
1033class SessionStoreTestCase(unittest.TestCase):
1034 """Test for the SessionStore class."""
1036 def setUp(self):
1037 self.tmpdir = ResourcePath(makeTestTempDir(TESTDIR))
1038 self.rpath = ResourcePath("https://example.org")
1040 def tearDown(self):
1041 if self.tmpdir and self.tmpdir.isLocal:
1042 removeTestTempDir(self.tmpdir.ospath)
1044 def test_ca_cert_bundle(self):
1045 """Ensure that, if specified, a certificate authorities bundle is used
1046 to authentify the remote server.
1047 """
1048 with tempfile.NamedTemporaryFile(mode="wt", dir=self.tmpdir.ospath, delete=False) as f:
1049 f.write("CERT BUNDLE")
1050 cert_bundle = f.name
1052 with unittest.mock.patch.dict(os.environ, {"LSST_HTTP_CACERT_BUNDLE": cert_bundle}, clear=True):
1053 config = HttpResourcePathConfig()
1054 session = SessionStore(config=config).get(self.rpath)
1055 self.assertEqual(session.verify, cert_bundle)
1057 def test_user_cert(self):
1058 """Ensure if user certificate and private key are provided, they are
1059 used for authenticating the client.
1060 """
1061 # Create mock certificate and private key files.
1062 with tempfile.NamedTemporaryFile(mode="wt", dir=self.tmpdir.ospath, delete=False) as f:
1063 f.write("CERT")
1064 client_cert = f.name
1066 with tempfile.NamedTemporaryFile(mode="wt", dir=self.tmpdir.ospath, delete=False) as f:
1067 f.write("KEY")
1068 client_key = f.name
1070 # Check both LSST_HTTP_AUTH_CLIENT_CERT and LSST_HTTP_AUTH_CLIENT_KEY
1071 # must be initialized.
1072 with unittest.mock.patch.dict(os.environ, {"LSST_HTTP_AUTH_CLIENT_CERT": client_cert}, clear=True):
1073 with self.assertRaises(ValueError):
1074 config = HttpResourcePathConfig()
1075 SessionStore(config=config).get(self.rpath)
1077 with unittest.mock.patch.dict(os.environ, {"LSST_HTTP_AUTH_CLIENT_KEY": client_key}, clear=True):
1078 with self.assertRaises(ValueError):
1079 config = HttpResourcePathConfig()
1080 SessionStore(config=config).get(self.rpath)
1082 # Check private key file must be accessible only by its owner.
1083 with unittest.mock.patch.dict(
1084 os.environ,
1085 {"LSST_HTTP_AUTH_CLIENT_CERT": client_cert, "LSST_HTTP_AUTH_CLIENT_KEY": client_key},
1086 clear=True,
1087 ):
1088 # Ensure the session client certificate is initialized when
1089 # only the owner can read the private key file.
1090 os.chmod(client_key, stat.S_IRUSR)
1091 config = HttpResourcePathConfig()
1092 session = SessionStore(config=config).get(self.rpath)
1093 self.assertEqual(session.cert[0], client_cert)
1094 self.assertEqual(session.cert[1], client_key)
1096 # Ensure an exception is raised if either group or other can access
1097 # the private key file.
1098 for mode in (stat.S_IRGRP, stat.S_IWGRP, stat.S_IXGRP, stat.S_IROTH, stat.S_IWOTH, stat.S_IXOTH):
1099 os.chmod(client_key, stat.S_IRUSR | mode)
1100 with self.assertRaises(PermissionError):
1101 config = HttpResourcePathConfig()
1102 SessionStore(config=config).get(self.rpath)
1104 def test_token_env(self):
1105 """Ensure when a token is provided via an environment variable
1106 the sessions are equipped with a BearerTokenAuth.
1107 """
1108 token = "ABCDE"
1109 with unittest.mock.patch.dict(os.environ, {"LSST_HTTP_AUTH_BEARER_TOKEN": token}, clear=True):
1110 config = HttpResourcePathConfig()
1111 session = SessionStore(config=config).get(self.rpath)
1112 self.assertEqual(type(session.auth), lsst.resources.http.BearerTokenAuth)
1113 self.assertEqual(session.auth._token, token)
1114 self.assertIsNone(session.auth._path)
1116 def test_sessions(self):
1117 """Ensure the session caching mechanism works."""
1118 # Ensure the store provides a session for a given URL
1119 root_url = "https://example.org"
1120 config = HttpResourcePathConfig()
1121 store = SessionStore(config=config)
1122 session = store.get(ResourcePath(root_url))
1123 self.assertIsNotNone(session)
1125 # Ensure the sessions retrieved from a single store with the same
1126 # root URIs are equal
1127 for u in (f"{root_url}", f"{root_url}/path/to/file"):
1128 self.assertEqual(session, store.get(ResourcePath(u)))
1130 # Ensure sessions retrieved for different root URIs are different
1131 another_url = "https://another.example.org"
1132 self.assertNotEqual(session, store.get(ResourcePath(another_url)))
1134 # Ensure the sessions retrieved from a single store for URLs with
1135 # different port numbers are different
1136 root_url_with_port = f"{another_url}:12345"
1137 session = store.get(ResourcePath(root_url_with_port))
1138 self.assertNotEqual(session, store.get(ResourcePath(another_url)))
1140 # Ensure the sessions retrieved from a single store with the same
1141 # root URIs (including port numbers) are equal
1142 for u in (f"{root_url_with_port}", f"{root_url_with_port}/path/to/file"):
1143 self.assertEqual(session, store.get(ResourcePath(u)))
1146class TestContentRange(unittest.TestCase):
1147 """Test parsing of Content-Range header."""
1149 def test_full_data(self):
1150 parsed = parse_content_range_header("bytes 123-2555/12345")
1151 self.assertEqual(parsed.range_start, 123)
1152 self.assertEqual(parsed.range_end, 2555)
1153 self.assertEqual(parsed.total, 12345)
1155 parsed = parse_content_range_header(" bytes 0-0/5 ")
1156 self.assertEqual(parsed.range_start, 0)
1157 self.assertEqual(parsed.range_end, 0)
1158 self.assertEqual(parsed.total, 5)
1160 def test_empty_total(self):
1161 parsed = parse_content_range_header("bytes 123-2555/*")
1162 self.assertEqual(parsed.range_start, 123)
1163 self.assertEqual(parsed.range_end, 2555)
1164 self.assertIsNone(parsed.total)
1166 parsed = parse_content_range_header(" bytes 0-0/* ")
1167 self.assertEqual(parsed.range_start, 0)
1168 self.assertEqual(parsed.range_end, 0)
1169 self.assertIsNone(parsed.total)
1171 def test_empty_range(self):
1172 parsed = parse_content_range_header("bytes */12345")
1173 self.assertIsNone(parsed.range_start)
1174 self.assertIsNone(parsed.range_end)
1175 self.assertEqual(parsed.total, 12345)
1177 parsed = parse_content_range_header(" bytes */5 ")
1178 self.assertIsNone(parsed.range_start)
1179 self.assertIsNone(parsed.range_end)
1180 self.assertEqual(parsed.total, 5)
1182 def test_invalid_input(self):
1183 with self.assertRaises(ValueError):
1184 parse_content_range_header("pages 0-10/12")
1187if __name__ == "__main__":
1188 unittest.main()