Coverage for tests/test_http.py: 16%

407 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-03-09 03:06 -0800

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. 

11 

12import hashlib 

13import importlib 

14import io 

15import os.path 

16import random 

17import shutil 

18import socket 

19import stat 

20import string 

21import tempfile 

22import time 

23import unittest 

24from threading import Thread 

25from typing import Callable, Tuple, cast 

26 

27try: 

28 from cheroot import wsgi 

29 from wsgidav.wsgidav_app import WsgiDAVApp 

30except ImportError: 

31 WsgiDAVApp = None 

32 

33import lsst.resources 

34import requests 

35import responses 

36from lsst.resources import ResourcePath 

37from lsst.resources._resourceHandles._httpResourceHandle import HttpReadResourceHandle 

38from lsst.resources.http import BearerTokenAuth, SessionStore, _is_protected, _is_webdav_endpoint 

39from lsst.resources.tests import GenericReadWriteTestCase, GenericTestCase 

40from lsst.resources.utils import makeTestTempDir, removeTestTempDir 

41 

42TESTDIR = os.path.abspath(os.path.dirname(__file__)) 

43 

44 

45class GenericHttpTestCase(GenericTestCase, unittest.TestCase): 

46 scheme = "http" 

47 netloc = "server.example" 

48 

49 

50class HttpReadWriteWebdavTestCase(GenericReadWriteTestCase, unittest.TestCase): 

51 """Test with a real webDAV server, as opposed to mocking responses.""" 

52 

53 scheme = "http" 

54 

55 @classmethod 

56 def setUpClass(cls): 

57 cls.webdav_tmpdir = tempfile.mkdtemp(prefix="webdav-server-test-") 

58 cls.local_files_to_remove = [] 

59 cls.server_thread = None 

60 

61 # Should we test against a running server? 

62 # 

63 # This is convenient for testing against real servers in the 

64 # developer environment by initializing the environment variable 

65 # LSST_RESOURCES_HTTP_TEST_SERVER_URL with the URL of the server, e.g. 

66 # https://dav.example.org:1234/path/to/top/dir 

67 if (test_endpoint := os.getenv("LSST_RESOURCES_HTTP_TEST_SERVER_URL")) is not None: 

68 # Run this test case against the specified server. 

69 uri = ResourcePath(test_endpoint) 

70 cls.scheme = uri.scheme 

71 cls.netloc = uri.netloc 

72 cls.base_path = uri.path 

73 elif WsgiDAVApp is not None: 

74 # WsgiDAVApp is available, launch a local server in its own 

75 # thread to expose a local temporary directory and run this 

76 # test case against it. 

77 cls.port_number = cls._get_port_number() 

78 cls.stop_webdav_server = False 

79 cls.server_thread = Thread( 

80 target=cls._serve_webdav, 

81 args=(cls, cls.webdav_tmpdir, cls.port_number, lambda: cls.stop_webdav_server), 

82 daemon=True, 

83 ) 

84 cls.server_thread.start() 

85 

86 # Wait for it to start 

87 time.sleep(1) 

88 

89 # Initialize the server endpoint 

90 cls.netloc = f"127.0.0.1:{cls.port_number}" 

91 else: 

92 cls.skipTest( 

93 cls, 

94 "neither WsgiDAVApp is available nor a webDAV test endpoint is configured to test against", 

95 ) 

96 

97 @classmethod 

98 def tearDownClass(cls): 

99 # Stop the WsgiDAVApp server, if any 

100 if WsgiDAVApp is not None: 

101 # Shut down of the webdav server and wait for the thread to exit 

102 cls.stop_webdav_server = True 

103 if cls.server_thread is not None: 

104 cls.server_thread.join() 

105 

106 # Remove local temporary files 

107 for file in cls.local_files_to_remove: 

108 if os.path.exists(file): 

109 os.remove(file) 

110 

111 # Remove temp dir 

112 if cls.webdav_tmpdir: 

113 shutil.rmtree(cls.webdav_tmpdir, ignore_errors=True) 

114 

115 def tearDown(self): 

116 if self.tmpdir: 

117 self.tmpdir.remove() 

118 

119 super().tearDown() 

120 

121 def test_dav_file_handle(self): 

122 # Upload a new file with known contents. 

123 contents = "These are some \n bytes to read" 

124 remote_file = self.tmpdir.join(self._get_file_name()) 

125 self.assertIsNone(remote_file.write(data=contents, overwrite=True)) 

126 

127 # Test that the correct handle is returned. 

128 with remote_file.open("rb") as handle: 

129 self.assertIsInstance(handle, HttpReadResourceHandle) 

130 

131 # Test reading byte ranges works 

132 with remote_file.open("rb") as handle: 

133 sub_contents = contents[:10] 

134 handle = cast(HttpReadResourceHandle, handle) 

135 result = handle.read(len(sub_contents)).decode() 

136 self.assertEqual(result, sub_contents) 

137 # Verify there is no internal buffer. 

138 self.assertIsNone(handle._completeBuffer) 

139 # Verify the position. 

140 self.assertEqual(handle.tell(), len(sub_contents)) 

141 

142 # Jump back to the beginning and test if reading the whole file 

143 # prompts the internal buffer to be read. 

144 handle.seek(0) 

145 self.assertEqual(handle.tell(), 0) 

146 result = handle.read().decode() 

147 self.assertIsNotNone(handle._completeBuffer) 

148 self.assertEqual(result, contents) 

149 

150 # Verify reading as a string handle works as expected. 

151 with remote_file.open("r") as handle: 

152 self.assertIsInstance(handle, io.TextIOWrapper) 

153 

154 handle = cast(io.TextIOWrapper, handle) 

155 self.assertIsInstance(handle.buffer, HttpReadResourceHandle) 

156 

157 # Check if string methods work. 

158 result = handle.read() 

159 self.assertEqual(result, contents) 

160 

161 # Verify that write modes invoke the default base method 

162 with remote_file.open("w") as handle: 

163 self.assertIsInstance(handle, io.StringIO) 

164 

165 def test_dav_is_dav_enpoint(self): 

166 # Ensure the server is a webDAV endpoint 

167 self.assertTrue(self.tmpdir.is_webdav_endpoint) 

168 

169 def test_dav_mkdir(self): 

170 # Check creation and deletion of an empty directory 

171 subdir = self.tmpdir.join(self._get_dir_name(), forceDirectory=True) 

172 self.assertIsNone(subdir.mkdir()) 

173 self.assertTrue(subdir.exists()) 

174 

175 # Creating an existing remote directory must succeed 

176 self.assertIsNone(subdir.mkdir()) 

177 

178 # Deletion of an existing directory must succeed 

179 self.assertIsNone(subdir.remove()) 

180 

181 # Deletion of an non-existing directory must raise 

182 subir_not_exists = self.tmpdir.join(self._get_dir_name(), forceDirectory=True) 

183 with self.assertRaises(FileNotFoundError): 

184 self.assertIsNone(subir_not_exists.remove()) 

185 

186 # Creation of a directory at a path where a file exists must raise 

187 file = self.tmpdir.join(self._get_file_name(), forceDirectory=False) 

188 file.write(data=None, overwrite=True) 

189 self.assertTrue(file.exists()) 

190 

191 existing_file = self.tmpdir.join(file.basename(), forceDirectory=True) 

192 with self.assertRaises(NotADirectoryError): 

193 self.assertIsNone(existing_file.mkdir()) 

194 

195 def test_dav_upload_download(self): 

196 # Test upload a randomly-generated file via write() with and without 

197 # overwrite 

198 local_file, file_size = self._generate_file() 

199 with open(local_file, "rb") as f: 

200 data = f.read() 

201 

202 remote_file = self.tmpdir.join(self._get_file_name()) 

203 self.assertIsNone(remote_file.write(data, overwrite=True)) 

204 self.assertTrue(remote_file.exists()) 

205 self.assertEqual(remote_file.size(), file_size) 

206 

207 # Write without overwrite must raise since target file exists 

208 with self.assertRaises(FileExistsError): 

209 remote_file.write(data, overwrite=False) 

210 

211 # Download the file we just uploaded. Compute and compare a digest of 

212 # the uploaded and downloaded data and ensure they match 

213 downloaded_data = remote_file.read() 

214 self.assertEqual(len(downloaded_data), file_size) 

215 upload_digest = self._compute_digest(data) 

216 download_digest = self._compute_digest(downloaded_data) 

217 self.assertEqual(upload_digest, download_digest) 

218 os.remove(local_file) 

219 

220 def test_dav_as_local(self): 

221 contents = str.encode("12345") 

222 remote_file = self.tmpdir.join(self._get_file_name()) 

223 self.assertIsNone(remote_file.write(data=contents, overwrite=True)) 

224 

225 local_path, is_temp = remote_file._as_local() 

226 self.assertTrue(is_temp) 

227 self.assertTrue(os.path.exists(local_path)) 

228 self.assertEqual(ResourcePath(local_path).read(), contents) 

229 os.remove(local_path) 

230 

231 def test_dav_size(self): 

232 # Size of a non-existent file must raise. 

233 remote_file = self.tmpdir.join(self._get_file_name()) 

234 with self.assertRaises(FileNotFoundError): 

235 remote_file.size() 

236 

237 # Retrieving the size of a remote directory using a file-like path must 

238 # raise 

239 remote_dir = self.tmpdir.join(self._get_dir_name(), forceDirectory=True) 

240 self.assertIsNone(remote_dir.mkdir()) 

241 self.assertTrue(remote_dir.exists()) 

242 

243 dir_as_file = ResourcePath(remote_dir.geturl().rstrip("/"), forceDirectory=False) 

244 with self.assertRaises(IsADirectoryError): 

245 dir_as_file.size() 

246 

247 def test_dav_upload_creates_dir(self): 

248 # Uploading a file to a non existing directory must ensure its 

249 # parent directories are automatically created and upload succeeds 

250 non_existing_dir = self.tmpdir.join(self._get_dir_name(), forceDirectory=True) 

251 non_existing_dir = non_existing_dir.join(self._get_dir_name(), forceDirectory=True) 

252 non_existing_dir = non_existing_dir.join(self._get_dir_name(), forceDirectory=True) 

253 remote_file = non_existing_dir.join(self._get_file_name()) 

254 

255 local_file, file_size = self._generate_file() 

256 with open(local_file, "rb") as f: 

257 data = f.read() 

258 self.assertIsNone(remote_file.write(data, overwrite=True)) 

259 

260 self.assertTrue(remote_file.exists()) 

261 self.assertEqual(remote_file.size(), file_size) 

262 self.assertTrue(remote_file.parent().exists()) 

263 

264 downloaded_data = remote_file.read() 

265 upload_digest = self._compute_digest(data) 

266 download_digest = self._compute_digest(downloaded_data) 

267 self.assertEqual(upload_digest, download_digest) 

268 os.remove(local_file) 

269 

270 def test_dav_transfer_from(self): 

271 # Transfer from local file via "copy", with and without overwrite 

272 remote_file = self.tmpdir.join(self._get_file_name()) 

273 local_file, _ = self._generate_file() 

274 source_file = ResourcePath(local_file) 

275 self.assertIsNone(remote_file.transfer_from(source_file, transfer="copy", overwrite=True)) 

276 self.assertTrue(remote_file.exists()) 

277 self.assertEqual(remote_file.size(), source_file.size()) 

278 with self.assertRaises(FileExistsError): 

279 remote_file.transfer_from(ResourcePath(local_file), transfer="copy", overwrite=False) 

280 

281 # Transfer from remote file via "copy", with and without overwrite 

282 source_file = remote_file 

283 target_file = self.tmpdir.join(self._get_file_name()) 

284 self.assertIsNone(target_file.transfer_from(source_file, transfer="copy", overwrite=True)) 

285 self.assertTrue(target_file.exists()) 

286 self.assertEqual(target_file.size(), source_file.size()) 

287 

288 # Transfer without overwrite must raise since target resource exists 

289 with self.assertRaises(FileExistsError): 

290 target_file.transfer_from(source_file, transfer="copy", overwrite=False) 

291 

292 # Test transfer from local file via "move", with and without overwrite 

293 source_file = ResourcePath(local_file) 

294 source_size = source_file.size() 

295 target_file = self.tmpdir.join(self._get_file_name()) 

296 self.assertIsNone(target_file.transfer_from(source_file, transfer="move", overwrite=True)) 

297 self.assertTrue(target_file.exists()) 

298 self.assertEqual(target_file.size(), source_size) 

299 self.assertFalse(source_file.exists()) 

300 

301 # Test transfer without overwrite must raise since target resource 

302 # exists 

303 local_file, file_size = self._generate_file() 

304 with self.assertRaises(FileExistsError): 

305 source_file = ResourcePath(local_file) 

306 target_file.transfer_from(source_file, transfer="move", overwrite=False) 

307 

308 # Test transfer from remote file via "move" with and without overwrite 

309 # must succeed 

310 source_file = target_file 

311 source_size = source_file.size() 

312 target_file = self.tmpdir.join(self._get_file_name()) 

313 self.assertIsNone(target_file.transfer_from(source_file, transfer="move", overwrite=True)) 

314 self.assertTrue(target_file.exists()) 

315 self.assertEqual(target_file.size(), source_size) 

316 self.assertFalse(source_file.exists()) 

317 

318 # Transfer without overwrite must raise since target resource exists 

319 with self.assertRaises(FileExistsError): 

320 source_file = ResourcePath(local_file) 

321 target_file.transfer_from(source_file, transfer="move", overwrite=False) 

322 

323 def test_dav_handle(self): 

324 # Resource handle must succeed 

325 target_file = self.tmpdir.join(self._get_file_name()) 

326 data = "abcdefghi" 

327 self.assertIsNone(target_file.write(data, overwrite=True)) 

328 with target_file.open("rb") as handle: 

329 handle.seek(1) 

330 self.assertEqual(handle.read(4).decode("utf-8"), data[1:5]) 

331 

332 def test_dav_delete(self): 

333 # Deletion of an existing remote file must succeed 

334 local_file, file_size = self._generate_file() 

335 with open(local_file, "rb") as f: 

336 data = f.read() 

337 

338 remote_file = self.tmpdir.join(self._get_file_name()) 

339 self.assertIsNone(remote_file.write(data, overwrite=True)) 

340 self.assertTrue(remote_file.exists()) 

341 self.assertEqual(remote_file.size(), file_size) 

342 self.assertIsNone(remote_file.remove()) 

343 os.remove(local_file) 

344 

345 # Deletion of a non-existing remote file must raise 

346 non_existing_file = self.tmpdir.join(self._get_file_name()) 

347 with self.assertRaises(FileNotFoundError): 

348 self.assertIsNone(non_existing_file.remove()) 

349 

350 # Deletion of a non-empty remote directory must succeed 

351 subdir = self.tmpdir.join(self._get_dir_name(), forceDirectory=True) 

352 self.assertIsNone(subdir.mkdir()) 

353 self.assertTrue(subdir.exists()) 

354 local_file, _ = self._generate_file() 

355 source_file = ResourcePath(local_file) 

356 target_file = self.tmpdir.join(self._get_file_name(), forceDirectory=True) 

357 self.assertIsNone(target_file.transfer_from(source_file, transfer="copy", overwrite=True)) 

358 self.assertIsNone(subdir.remove()) 

359 self.assertFalse(subdir.exists()) 

360 os.remove(local_file) 

361 

362 @classmethod 

363 def _get_port_number(cls) -> int: 

364 """Return a port number the webDAV server can use to listen to.""" 

365 s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 

366 s.bind(("127.0.0.1", 0)) 

367 s.listen() 

368 port = s.getsockname()[1] 

369 s.close() 

370 return port 

371 

372 def _serve_webdav(self, local_path: str, port: int, stop_webdav_server: Callable[[], bool]): 

373 """Start a local webDAV server, listening on http://localhost:port 

374 and exposing local_path. 

375 

376 This server only runs when this test class is instantiated, 

377 and then shuts down. The server must be started is a separate thread. 

378 

379 Parameters 

380 ---------- 

381 port : `int` 

382 The port number on which the server should listen 

383 local_path : `str` 

384 Path to an existing local directory for the server to expose. 

385 stop_webdav_server : `Callable[[], bool]` 

386 Boolean function which returns True when the server should be 

387 stopped. 

388 """ 

389 try: 

390 # Start the wsgi server in a separate thread 

391 config = { 

392 "host": "127.0.0.1", 

393 "port": port, 

394 "provider_mapping": {"/": local_path}, 

395 "http_authenticator": {"domain_controller": None}, 

396 "simple_dc": {"user_mapping": {"*": True}}, 

397 "verbose": 0, 

398 "lock_storage": False, 

399 "dir_browser": { 

400 "enable": False, 

401 "ms_sharepoint_support": False, 

402 "libre_office_support": False, 

403 "response_trailer": False, 

404 "davmount_links": False, 

405 }, 

406 } 

407 server = wsgi.Server(wsgi_app=WsgiDAVApp(config), bind_addr=(config["host"], config["port"])) 

408 t = Thread(target=server.start, daemon=True) 

409 t.start() 

410 

411 # Shut down the server when done: stop_webdav_server() returns 

412 # True when this test suite is being teared down 

413 while not stop_webdav_server(): 

414 time.sleep(1) 

415 except KeyboardInterrupt: 

416 # Caught Ctrl-C, shut down the server 

417 pass 

418 finally: 

419 server.stop() 

420 t.join() 

421 

422 @classmethod 

423 def _get_name(cls, prefix: str) -> str: 

424 alphabet = string.ascii_lowercase + string.digits 

425 return f"{prefix}-" + "".join(random.choices(alphabet, k=8)) 

426 

427 @classmethod 

428 def _get_dir_name(cls) -> str: 

429 """Return a randomly selected name for a file""" 

430 return cls._get_name(prefix="dir") 

431 

432 @classmethod 

433 def _get_file_name(cls) -> str: 

434 """Return a randomly selected name for a file""" 

435 return cls._get_name(prefix="file") 

436 

437 def _generate_file(self, remove_when_done=True) -> Tuple[str, int]: 

438 """Create a local file of random size with random contents. 

439 

440 Returns 

441 ------- 

442 path : `str` 

443 Path to local temporary file. The caller is responsible for 

444 removing the file when appropriate. 

445 size : `int` 

446 Size of the generated file, in bytes. 

447 """ 

448 megabyte = 1024 * 1024 

449 size = random.randint(2 * megabyte, 5 * megabyte) 

450 tmpfile, path = tempfile.mkstemp() 

451 self.assertEqual(os.write(tmpfile, os.urandom(size)), size) 

452 os.close(tmpfile) 

453 

454 if remove_when_done: 

455 self.local_files_to_remove.append(path) 

456 

457 return path, size 

458 

459 @classmethod 

460 def _compute_digest(cls, data: bytes) -> str: 

461 """Compute a SHA256 hash of data.""" 

462 m = hashlib.sha256() 

463 m.update(data) 

464 return m.hexdigest() 

465 

466 @classmethod 

467 def _is_server_running(cls, port: int) -> bool: 

468 """Return True if there is a server listening on local address 

469 127.0.0.1:<port>. 

470 """ 

471 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: 

472 try: 

473 s.connect(("127.0.0.1", port)) 

474 return True 

475 except ConnectionRefusedError: 

476 return False 

477 

478 

479class WebdavUtilsTestCase(unittest.TestCase): 

480 """Test for the Webdav related utilities.""" 

481 

482 def setUp(self): 

483 self.tmpdir = ResourcePath(makeTestTempDir(TESTDIR)) 

484 

485 def tearDown(self): 

486 if self.tmpdir: 

487 if self.tmpdir.isLocal: 

488 removeTestTempDir(self.tmpdir.ospath) 

489 

490 @responses.activate 

491 def test_is_webdav_endpoint(self): 

492 davEndpoint = "http://www.lsstwithwebdav.org" 

493 responses.add(responses.OPTIONS, davEndpoint, status=200, headers={"DAV": "1,2,3"}) 

494 self.assertTrue(_is_webdav_endpoint(davEndpoint)) 

495 

496 plainHttpEndpoint = "http://www.lsstwithoutwebdav.org" 

497 responses.add(responses.OPTIONS, plainHttpEndpoint, status=200) 

498 self.assertFalse(_is_webdav_endpoint(plainHttpEndpoint)) 

499 

500 def test_send_expect_header(self): 

501 # Ensure _SEND_EXPECT_HEADER_ON_PUT is correctly initialized from 

502 # the environment. 

503 os.environ.pop("LSST_HTTP_PUT_SEND_EXPECT_HEADER", None) 

504 importlib.reload(lsst.resources.http) 

505 self.assertFalse(lsst.resources.http._SEND_EXPECT_HEADER_ON_PUT) 

506 

507 with unittest.mock.patch.dict(os.environ, {"LSST_HTTP_PUT_SEND_EXPECT_HEADER": "true"}, clear=True): 

508 importlib.reload(lsst.resources.http) 

509 self.assertTrue(lsst.resources.http._SEND_EXPECT_HEADER_ON_PUT) 

510 

511 def test_timeout(self): 

512 connect_timeout = 100 

513 read_timeout = 200 

514 with unittest.mock.patch.dict( 

515 os.environ, 

516 {"LSST_HTTP_TIMEOUT_CONNECT": str(connect_timeout), "LSST_HTTP_TIMEOUT_READ": str(read_timeout)}, 

517 clear=True, 

518 ): 

519 # Force module reload to initialize TIMEOUT. 

520 importlib.reload(lsst.resources.http) 

521 self.assertEqual(lsst.resources.http.TIMEOUT, (connect_timeout, read_timeout)) 

522 

523 def test_is_protected(self): 

524 self.assertFalse(_is_protected("/this-file-does-not-exist")) 

525 

526 with tempfile.NamedTemporaryFile(mode="wt", dir=self.tmpdir.ospath, delete=False) as f: 

527 f.write("XXXX") 

528 file_path = f.name 

529 

530 os.chmod(file_path, stat.S_IRUSR) 

531 self.assertTrue(_is_protected(file_path)) 

532 

533 for mode in (stat.S_IRGRP, stat.S_IWGRP, stat.S_IXGRP, stat.S_IROTH, stat.S_IWOTH, stat.S_IXOTH): 

534 os.chmod(file_path, stat.S_IRUSR | mode) 

535 self.assertFalse(_is_protected(file_path)) 

536 

537 

538class BearerTokenAuthTestCase(unittest.TestCase): 

539 """Test for the BearerTokenAuth class.""" 

540 

541 def setUp(self): 

542 self.tmpdir = ResourcePath(makeTestTempDir(TESTDIR)) 

543 self.token = "ABCDE1234" 

544 

545 def tearDown(self): 

546 if self.tmpdir and self.tmpdir.isLocal: 

547 removeTestTempDir(self.tmpdir.ospath) 

548 

549 def test_empty_token(self): 

550 """Ensure that when no token is provided the request is not 

551 modified. 

552 """ 

553 auth = BearerTokenAuth(None) 

554 auth._refresh() 

555 self.assertIsNone(auth._token) 

556 self.assertIsNone(auth._path) 

557 req = requests.Request("GET", "https://example.org") 

558 self.assertEqual(auth(req), req) 

559 

560 def test_token_value(self): 

561 """Ensure that when a token value is provided, the 'Authorization' 

562 header is added to the requests. 

563 """ 

564 auth = BearerTokenAuth(self.token) 

565 req = auth(requests.Request("GET", "https://example.org").prepare()) 

566 self.assertEqual(req.headers.get("Authorization"), f"Bearer {self.token}") 

567 

568 def test_token_file(self): 

569 """Ensure when the provided token is a file path, its contents is 

570 correctly used in the the 'Authorization' header of the requests. 

571 """ 

572 with tempfile.NamedTemporaryFile(mode="wt", dir=self.tmpdir.ospath, delete=False) as f: 

573 f.write(self.token) 

574 token_file_path = f.name 

575 

576 # Ensure the request's "Authorization" header is set with the right 

577 # token value 

578 os.chmod(token_file_path, stat.S_IRUSR) 

579 auth = BearerTokenAuth(token_file_path) 

580 req = auth(requests.Request("GET", "https://example.org").prepare()) 

581 self.assertEqual(req.headers.get("Authorization"), f"Bearer {self.token}") 

582 

583 # Ensure an exception is raised if either group or other can read the 

584 # token file 

585 for mode in (stat.S_IRGRP, stat.S_IWGRP, stat.S_IXGRP, stat.S_IROTH, stat.S_IWOTH, stat.S_IXOTH): 

586 os.chmod(token_file_path, stat.S_IRUSR | mode) 

587 with self.assertRaises(PermissionError): 

588 BearerTokenAuth(token_file_path) 

589 

590 

591class SessionStoreTestCase(unittest.TestCase): 

592 """Test for the SessionStore class.""" 

593 

594 def setUp(self): 

595 self.tmpdir = ResourcePath(makeTestTempDir(TESTDIR)) 

596 self.rpath = ResourcePath("https://example.org") 

597 

598 def tearDown(self): 

599 if self.tmpdir and self.tmpdir.isLocal: 

600 removeTestTempDir(self.tmpdir.ospath) 

601 

602 def test_ca_cert_bundle(self): 

603 """Ensure a certificate authorities bundle is used to authentify 

604 the remote server. 

605 """ 

606 with tempfile.NamedTemporaryFile(mode="wt", dir=self.tmpdir.ospath, delete=False) as f: 

607 f.write("CERT BUNDLE") 

608 cert_bundle = f.name 

609 

610 with unittest.mock.patch.dict(os.environ, {"LSST_HTTP_CACERT_BUNDLE": cert_bundle}, clear=True): 

611 session = SessionStore().get(self.rpath) 

612 self.assertEqual(session.verify, cert_bundle) 

613 

614 def test_user_cert(self): 

615 """Ensure if user certificate and private key are provided, they are 

616 used for authenticating the client. 

617 """ 

618 

619 # Create mock certificate and private key files. 

620 with tempfile.NamedTemporaryFile(mode="wt", dir=self.tmpdir.ospath, delete=False) as f: 

621 f.write("CERT") 

622 client_cert = f.name 

623 

624 with tempfile.NamedTemporaryFile(mode="wt", dir=self.tmpdir.ospath, delete=False) as f: 

625 f.write("KEY") 

626 client_key = f.name 

627 

628 # Check both LSST_HTTP_AUTH_CLIENT_CERT and LSST_HTTP_AUTH_CLIENT_KEY 

629 # must be initialized. 

630 with unittest.mock.patch.dict(os.environ, {"LSST_HTTP_AUTH_CLIENT_CERT": client_cert}, clear=True): 

631 with self.assertRaises(ValueError): 

632 SessionStore().get(self.rpath) 

633 

634 with unittest.mock.patch.dict(os.environ, {"LSST_HTTP_AUTH_CLIENT_KEY": client_key}, clear=True): 

635 with self.assertRaises(ValueError): 

636 SessionStore().get(self.rpath) 

637 

638 # Check private key file must be accessible only by its owner. 

639 with unittest.mock.patch.dict( 

640 os.environ, 

641 {"LSST_HTTP_AUTH_CLIENT_CERT": client_cert, "LSST_HTTP_AUTH_CLIENT_KEY": client_key}, 

642 clear=True, 

643 ): 

644 # Ensure the session client certificate is initialized when 

645 # only the owner can read the private key file. 

646 os.chmod(client_key, stat.S_IRUSR) 

647 session = SessionStore().get(self.rpath) 

648 self.assertEqual(session.cert[0], client_cert) 

649 self.assertEqual(session.cert[1], client_key) 

650 

651 # Ensure an exception is raised if either group or other can access 

652 # the private key file. 

653 for mode in (stat.S_IRGRP, stat.S_IWGRP, stat.S_IXGRP, stat.S_IROTH, stat.S_IWOTH, stat.S_IXOTH): 

654 os.chmod(client_key, stat.S_IRUSR | mode) 

655 with self.assertRaises(PermissionError): 

656 SessionStore().get(self.rpath) 

657 

658 def test_token_env(self): 

659 """Ensure when the token is provided via an environment variable 

660 the sessions are equipped with a BearerTokenAuth. 

661 """ 

662 token = "ABCDE" 

663 with unittest.mock.patch.dict(os.environ, {"LSST_HTTP_AUTH_BEARER_TOKEN": token}, clear=True): 

664 session = SessionStore().get(self.rpath) 

665 self.assertEqual(type(session.auth), lsst.resources.http.BearerTokenAuth) 

666 self.assertEqual(session.auth._token, token) 

667 self.assertIsNone(session.auth._path) 

668 

669 def test_sessions(self): 

670 """Ensure the session caching mechanism works.""" 

671 

672 # Ensure the store provides a session for a given URL 

673 root_url = "https://example.org" 

674 store = SessionStore() 

675 session = store.get(ResourcePath(root_url)) 

676 self.assertIsNotNone(session) 

677 

678 # Ensure the sessions retrieved from a single store with the same 

679 # root URIs are equal 

680 for u in (f"{root_url}", f"{root_url}/path/to/file"): 

681 self.assertEqual(session, store.get(ResourcePath(u))) 

682 

683 # Ensure sessions retrieved for different root URIs are different 

684 another_url = "https://another.example.org" 

685 self.assertNotEqual(session, store.get(ResourcePath(another_url))) 

686 

687 # Ensure the sessions retrieved from a single store for URLs with 

688 # different port numbers are different 

689 root_url_with_port = f"{another_url}:12345" 

690 session = store.get(ResourcePath(root_url_with_port)) 

691 self.assertNotEqual(session, store.get(ResourcePath(another_url))) 

692 

693 # Ensure the sessions retrieved from a single store with the same 

694 # root URIs (including port numbers) are equal 

695 for u in (f"{root_url_with_port}", f"{root_url_with_port}/path/to/file"): 

696 self.assertEqual(session, store.get(ResourcePath(u))) 

697 

698 

699if __name__ == "__main__": 699 ↛ 700line 699 didn't jump to line 700, because the condition on line 699 was never true

700 unittest.main()