Coverage for tests / test_derivepassphrase_exporter.py: 100.000%

158 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-07-06 21:34 +0200

1# SPDX-FileCopyrightText: 2026 Marco Ricci <software@the13thletter.info> 

2# 

3# SPDX-License-Identifier: Zlib 

4 

5"""Test `derivepassphrase.exporter` and the `derivepassphrase export` CLI.""" 

6 

7from __future__ import annotations 

8 

9import contextlib 

10import operator 

11import os 

12import pathlib 

13import string 

14import types 

15from typing import TYPE_CHECKING, NamedTuple, TypeVar 

16 

17import hypothesis 

18import pytest 

19from hypothesis import strategies 

20 

21from derivepassphrase import cli, exporter 

22from tests import data, machinery 

23from tests.machinery import pytest as pytest_machinery 

24 

25if TYPE_CHECKING: 

26 from collections.abc import Callable, Generator 

27 

28 from typing_extensions import Any, Buffer 

29 

30 

31class Strategies: 

32 @strategies.composite 

33 @staticmethod 

34 def names(draw: strategies.DrawFn) -> str: 

35 """Return a strategy for identifier names.""" 

36 first_letter = draw( 1c

37 strategies.text(string.ascii_letters, min_size=1, max_size=1), 

38 label="first_letter", 

39 ) 

40 rest = draw( 1c

41 strategies.text( 

42 string.ascii_letters + string.digits + "_-", max_size=23 

43 ), 

44 label="rest", 

45 ) 

46 return first_letter + rest 1c

47 

48 _T = TypeVar("_T") 

49 

50 @strategies.composite 

51 @staticmethod 

52 def pairs_of_lists( 

53 draw: strategies.DrawFn, 

54 strat: strategies.SearchStrategy[_T], 

55 min_size: int = 1, 

56 max_size: int = 3, 

57 ) -> tuple[list[_T], list[_T]]: 

58 """Return a strategy for two short lists, with unique items.""" 

59 size1 = draw( 1c

60 strategies.integers(min_value=min_size, max_value=max_size), 

61 label="size1", 

62 ) 

63 size2 = draw( 1c

64 strategies.integers(min_value=min_size, max_value=max_size), 

65 label="size2", 

66 ) 

67 all_values = draw( 1c

68 strategies.lists( 

69 strat, 

70 min_size=size1 + size2, 

71 max_size=size1 + size2, 

72 unique=True, 

73 ), 

74 label="all_values", 

75 ) 

76 return all_values[:size1], all_values[size1:] 1c

77 

78 

79class Parametrize(types.SimpleNamespace): 

80 EXPECTED_VAULT_PATH = pytest.mark.parametrize( 

81 ["expected", "path"], 

82 [ 

83 (pathlib.Path("/tmp"), pathlib.Path("/tmp")), 

84 (pathlib.Path("~"), pathlib.Path()), 

85 (pathlib.Path("~/.vault"), None), 

86 ], 

87 ) 

88 EXPORT_VAULT_CONFIG_DATA_HANDLER_NAMELISTS = pytest.mark.parametrize( 

89 ["namelist", "err_pat"], 

90 [ 

91 pytest.param((), "[Nn]o names given", id="empty"), 

92 pytest.param( 

93 ("name1", "", "name2"), 

94 "[Uu]nder an empty name", 

95 id="empty-string", 

96 ), 

97 pytest.param( 

98 ("dummy", "name1", "name2"), 

99 "[Aa]lready registered", 

100 id="existing", 

101 ), 

102 ], 

103 ) 

104 

105 

106class TestCLIUtilities: 

107 """Test the command-line utility functions in the `exporter` subpackage.""" 

108 

109 class VaultKeyEnvironment(NamedTuple): 

110 """An environment configuration for vault key determination. 

111 

112 Attributes: 

113 expected: 

114 The correct vault key value. 

115 vault_key: 

116 The value for the `VAULT_KEY` environment variable. 

117 logname: 

118 The value for the `LOGNAME` environment variable. 

119 user: 

120 The value for the `USER` environment variable. 

121 username: 

122 The value for the `USERNAME` environment variable. 

123 

124 """ 

125 

126 expected: str | None 

127 """""" 

128 vault_key: str | None 

129 """""" 

130 logname: str | None 

131 """""" 

132 user: str | None 

133 """""" 

134 username: str | None 

135 """""" 

136 

137 @strategies.composite 

138 @staticmethod 

139 def strategy( 

140 draw: strategies.DrawFn, 

141 allow_missing: bool = False, 

142 ) -> TestCLIUtilities.VaultKeyEnvironment: 

143 """Return a vault key environment configuration.""" 

144 text_strategy = strategies.text( 1b

145 strategies.characters(min_codepoint=32, max_codepoint=127), 

146 min_size=1, 

147 max_size=24, 

148 ) 

149 env_var_strategy = strategies.one_of( 1b

150 strategies.none(), 

151 text_strategy, 

152 ) 

153 num_fields = sum( 1b

154 1 

155 for f in TestCLIUtilities.VaultKeyEnvironment._fields 

156 if f != "expected" 

157 ) 

158 env_vars: list[str | None] = draw( 1b

159 strategies.builds( 

160 operator.add, 

161 strategies.lists( 

162 env_var_strategy, 

163 min_size=num_fields - 1, 

164 max_size=num_fields - 1, 

165 ), 

166 strategies.lists( 

167 text_strategy 

168 if not allow_missing 

169 else env_var_strategy, 

170 min_size=1, 

171 max_size=1, 

172 ), 

173 ) 

174 ) 

175 expected: str | None = None 1b

176 for value in reversed(env_vars): 1b

177 if value is not None: 1b

178 expected = value 1b

179 return TestCLIUtilities.VaultKeyEnvironment(expected, *env_vars) 1b

180 

181 @contextlib.contextmanager 

182 def _setup_environment(self) -> Generator[pytest.MonkeyPatch, None, None]: 

183 runner = machinery.CliRunner(mix_stderr=False) 1fb

184 # TODO(the-13th-letter): Rewrite using parenthesized 

185 # with-statements. 

186 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

187 with contextlib.ExitStack() as stack: 1fb

188 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1fb

189 stack.enter_context( 1fb

190 pytest_machinery.isolated_vault_exporter_config( 

191 monkeypatch=monkeypatch, runner=runner 

192 ) 

193 ) 

194 yield monkeypatch 1fb

195 

196 @hypothesis.given( 

197 vault_key_env=VaultKeyEnvironment.strategy().filter( 

198 lambda env: bool(env.expected) 

199 ), 

200 ) 

201 # BEGIN pre-hypothesis parametrization examples 

202 @hypothesis.example( 

203 vault_key_env=VaultKeyEnvironment( 

204 "4username", None, None, None, "4username" 

205 ) 

206 ) 

207 @hypothesis.example( 

208 vault_key_env=VaultKeyEnvironment("3user", None, None, "3user", None) 

209 ) 

210 @hypothesis.example( 

211 vault_key_env=VaultKeyEnvironment( 

212 "3user", None, None, "3user", "4username" 

213 ) 

214 ) 

215 @hypothesis.example( 

216 vault_key_env=VaultKeyEnvironment( 

217 "2logname", None, "2logname", None, None 

218 ) 

219 ) 

220 @hypothesis.example( 

221 vault_key_env=VaultKeyEnvironment( 

222 "2logname", None, "2logname", None, "4username" 

223 ) 

224 ) 

225 @hypothesis.example( 

226 vault_key_env=VaultKeyEnvironment( 

227 "2logname", None, "2logname", "3user", None 

228 ) 

229 ) 

230 @hypothesis.example( 

231 vault_key_env=VaultKeyEnvironment( 

232 "2logname", None, "2logname", "3user", "4username" 

233 ) 

234 ) 

235 @hypothesis.example( 

236 vault_key_env=VaultKeyEnvironment( 

237 "1vault_key", "1vault_key", None, None, None 

238 ) 

239 ) 

240 @hypothesis.example( 

241 vault_key_env=VaultKeyEnvironment( 

242 "1vault_key", "1vault_key", None, None, "4username" 

243 ) 

244 ) 

245 @hypothesis.example( 

246 vault_key_env=VaultKeyEnvironment( 

247 "1vault_key", "1vault_key", None, "3user", None 

248 ) 

249 ) 

250 @hypothesis.example( 

251 vault_key_env=VaultKeyEnvironment( 

252 "1vault_key", "1vault_key", None, "3user", "4username" 

253 ) 

254 ) 

255 @hypothesis.example( 

256 vault_key_env=VaultKeyEnvironment( 

257 "1vault_key", "1vault_key", "2logname", None, None 

258 ) 

259 ) 

260 @hypothesis.example( 

261 vault_key_env=VaultKeyEnvironment( 

262 "1vault_key", "1vault_key", "2logname", None, "4username" 

263 ) 

264 ) 

265 @hypothesis.example( 

266 vault_key_env=VaultKeyEnvironment( 

267 "1vault_key", "1vault_key", "2logname", "3user", None 

268 ) 

269 ) 

270 @hypothesis.example( 

271 vault_key_env=VaultKeyEnvironment( 

272 "1vault_key", "1vault_key", "2logname", "3user", "4username" 

273 ) 

274 ) 

275 # END pre-hypothesis parametrization examples 

276 def test_get_vault_key( 

277 self, 

278 vault_key_env: VaultKeyEnvironment, 

279 ) -> None: 

280 """Look up the vault key in `VAULT_KEY`/`LOGNAME`/`USER`/`USERNAME`. 

281 

282 The correct environment variable value is used, according to 

283 their relative priorities. 

284 

285 """ 

286 expected, vault_key, logname, user, username = vault_key_env 1b

287 assert expected is not None 1b

288 priority_list = [ 1b

289 ("VAULT_KEY", vault_key), 

290 ("LOGNAME", logname), 

291 ("USER", user), 

292 ("USERNAME", username), 

293 ] 

294 with self._setup_environment() as monkeypatch: 1b

295 for key, value in priority_list: 1b

296 if value is not None: 1b

297 monkeypatch.setenv(key, value) 1b

298 assert os.fsdecode(exporter.get_vault_key()) == expected 1b

299 

300 def test_get_vault_key_without_envs(self) -> None: 

301 """Fail to look up the vault key in the empty environment.""" 

302 with pytest.MonkeyPatch.context() as monkeypatch: 1i

303 monkeypatch.delenv("VAULT_KEY", raising=False) 1i

304 monkeypatch.delenv("LOGNAME", raising=False) 1i

305 monkeypatch.delenv("USER", raising=False) 1i

306 monkeypatch.delenv("USERNAME", raising=False) 1i

307 with pytest.raises(KeyError, match="VAULT_KEY"): 1i

308 exporter.get_vault_key() 1i

309 

310 @Parametrize.EXPECTED_VAULT_PATH 

311 def test_get_vault_path( 

312 self, 

313 expected: pathlib.Path, 

314 path: str | os.PathLike[str] | None, 

315 ) -> None: 

316 """Determine the vault path from `VAULT_PATH`. 

317 

318 Handle relative paths, absolute paths, and missing paths. 

319 

320 """ 

321 with self._setup_environment() as monkeypatch: 1f

322 if path: 1f

323 monkeypatch.setenv("VAULT_PATH", os.fspath(path)) 1f

324 assert ( 1f

325 exporter.get_vault_path().resolve() 

326 == expected.expanduser().resolve() 

327 ) 

328 

329 # TODO(the-13th-letter): Revise, or remove. 

330 def test_get_vault_path_without_home(self) -> None: 

331 """Fail to look up the vault path without `HOME`.""" 

332 

333 def raiser(*_args: Any, **_kwargs: Any) -> Any: 1j

334 raise RuntimeError("Cannot determine home directory.") # noqa: EM101,TRY003 1j

335 

336 with pytest.MonkeyPatch.context() as monkeypatch: 1j

337 monkeypatch.setattr(pathlib.Path, "expanduser", raiser) 1j

338 monkeypatch.setattr(os.path, "expanduser", raiser) 1j

339 with pytest.raises( 1j

340 RuntimeError, match=r"[Cc]annot determine home directory" 

341 ): 

342 exporter.get_vault_path() 1j

343 

344 

345class TestExportVaultConfigDataHandlerRegistry: 

346 """Test the registry of `vault` config data exporters.""" 

347 

348 @contextlib.contextmanager 

349 def _setup_environment( 

350 self, 

351 *, 

352 registry: dict[str, Any] | None = None, 

353 find_handlers: Callable | None = None, 

354 ) -> Generator[pytest.MonkeyPatch, None, None]: 

355 with pytest.MonkeyPatch.context() as monkeypatch: 1ghc

356 if registry is not None: # pragma: no branch 1ghc

357 monkeypatch.setattr( 1ghc

358 exporter, "_export_vault_config_data_registry", registry 

359 ) 

360 if find_handlers is not None: # pragma: no branch 1ghc

361 monkeypatch.setattr( 1g

362 exporter, "find_vault_config_data_handlers", find_handlers 

363 ) 

364 yield monkeypatch 1ghc

365 

366 @staticmethod 

367 def dummy_handler( # pragma: no cover 

368 path: str | bytes | os.PathLike | None = None, 

369 key: str | Buffer | None = None, 

370 *, 

371 format: str, 

372 ) -> Any: 

373 del path, key 

374 raise ValueError(format) 

375 

376 @hypothesis.given(name_data=Strategies.pairs_of_lists(Strategies.names())) 

377 def test_register_export_vault_config_data_handler( 1ac

378 self, name_data: tuple[list[str], list[str]] 

379 ) -> None: 

380 """Register vault config data export handlers.""" 

381 names1, names2 = name_data 1c

382 registry = dict.fromkeys(names1, self.dummy_handler) 1c

383 with self._setup_environment(registry=registry): 1c

384 dec = exporter.register_export_vault_config_data_handler(*names2) 1c

385 assert dec(self.dummy_handler) == self.dummy_handler 1c

386 assert registry == dict.fromkeys( 1c

387 names1 + names2, self.dummy_handler 

388 ) 

389 

390 @Parametrize.EXPORT_VAULT_CONFIG_DATA_HANDLER_NAMELISTS 

391 def test_register_export_vault_config_data_handler_errors( 

392 self, 

393 namelist: tuple[str, ...], 

394 err_pat: str, 

395 ) -> None: 

396 """Fail to register a vault config data export handler. 

397 

398 Fail because e.g. the associated name is missing, or already 

399 present in the handler registry. 

400 

401 """ 

402 

403 with self._setup_environment(registry={"dummy": self.dummy_handler}): # noqa: SIM117 1h

404 # TODO(the-13th-letter): Rewrite using parenthesized 

405 # with-statements. 

406 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

407 with pytest.raises(ValueError, match=err_pat): 1h

408 exporter.register_export_vault_config_data_handler(*namelist)( 1h

409 self.dummy_handler 

410 ) 

411 

412 def test_export_vault_config_data_bad_handler(self) -> None: 

413 """Fail to export vault config data without known handlers.""" 

414 with self._setup_environment(registry={}, find_handlers=lambda: None): # noqa: SIM117 1g

415 # TODO(the-13th-letter): Rewrite using parenthesized 

416 # with-statements. 

417 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

418 with pytest.raises( 1g

419 ValueError, 

420 match=r"Invalid vault native configuration format", 

421 ): 

422 exporter.export_vault_config_data(format="v0.3") 1g

423 

424 

425class TestGenericVaultCLIErrors: 

426 """Test errors in the `derivepassphrase export vault` subpackage. 

427 

428 These errors are always possible, even if the `export` extra is not 

429 available. 

430 

431 """ 

432 

433 @contextlib.contextmanager 

434 def _setup_environment( 

435 self, *, vault_config: str | bytes = data.VAULT_V03_CONFIG 

436 ) -> Generator[tuple[pytest.MonkeyPatch, machinery.CliRunner], None, None]: 

437 runner = machinery.CliRunner(mix_stderr=False) 1de

438 # TODO(the-13th-letter): Rewrite using parenthesized 

439 # with-statements. 

440 # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 

441 with contextlib.ExitStack() as stack: 1de

442 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1de

443 stack.enter_context( 1de

444 pytest_machinery.isolated_vault_exporter_config( 

445 monkeypatch=monkeypatch, 

446 runner=runner, 

447 vault_config=vault_config, 

448 vault_key=data.VAULT_MASTER_KEY, 

449 ) 

450 ) 

451 yield monkeypatch, runner 1de

452 

453 def _call_cli( 

454 self, 

455 command_line: list[str], 

456 *, 

457 vault_config: str | bytes = data.VAULT_V03_CONFIG, 

458 ) -> machinery.ReadableResult: 

459 with self._setup_environment(vault_config=vault_config) as contexts: 1de

460 _, runner = contexts 1de

461 return runner.invoke( 1de

462 cli.derivepassphrase_export_vault, 

463 command_line, 

464 catch_exceptions=False, 

465 ) 

466 

467 # TODO(the-13th-letter): Rewrite as property-based test. 

468 def test_invalid_format(self) -> None: 

469 """Reject invalid vault configuration format names.""" 

470 result = self._call_cli(["-f", "INVALID", "VAULT_PATH"]) 1d

471 for snippet in ("Invalid value for", "-f", "--format", "INVALID"): 1d

472 assert result.error_exit(error=snippet), ( 1d

473 "expected error exit and known error message" 

474 ) 

475 

476 @pytest_machinery.skip_if_cryptography_support 

477 @pytest_machinery.Parametrize.VAULT_CONFIG_FORMATS_DATA 

478 def test_no_cryptography_error_message( 

479 self, 

480 caplog: pytest.LogCaptureFixture, 

481 config: str | bytes, 

482 format: str, 

483 config_data: str, 

484 ) -> None: 

485 """Abort export call if no cryptography is available.""" 

486 del config_data 1e

487 result = self._call_cli( 1e

488 ["-f", format, "VAULT_PATH"], vault_config=config 

489 ) 

490 assert result.error_exit( 1e

491 error=data.CANNOT_LOAD_CRYPTOGRAPHY, 

492 record_tuples=caplog.record_tuples, 

493 ), "expected error exit and known error message"