Coverage for tests\test_derivepassphrase_exporter.py: 100.000%

134 statements  

« prev     ^ index     » next       coverage.py v7.9.2, created at 2025-07-23 12:17 +0200

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

2# 

3# SPDX-License-Identifier: Zlib 

4 

5from __future__ import annotations 

6 

7import contextlib 

8import operator 

9import os 

10import pathlib 

11import string 

12import types 

13from typing import TYPE_CHECKING, Any, NamedTuple 

14 

15import hypothesis 

16import pytest 

17from hypothesis import strategies 

18 

19import tests 

20from derivepassphrase import cli, exporter 

21 

22if TYPE_CHECKING: 

23 from typing_extensions import Buffer 

24 

25 

26class Parametrize(types.SimpleNamespace): 

27 EXPECTED_VAULT_PATH = pytest.mark.parametrize( 

28 ['expected', 'path'], 

29 [ 

30 (pathlib.Path('/tmp'), pathlib.Path('/tmp')), 

31 (pathlib.Path('~'), pathlib.Path()), 

32 (pathlib.Path('~/.vault'), None), 

33 ], 

34 ) 

35 EXPORT_VAULT_CONFIG_DATA_HANDLER_NAMELISTS = pytest.mark.parametrize( 

36 ['namelist', 'err_pat'], 

37 [ 

38 pytest.param((), '[Nn]o names given', id='empty'), 

39 pytest.param( 

40 ('name1', '', 'name2'), 

41 '[Uu]nder an empty name', 

42 id='empty-string', 

43 ), 

44 pytest.param( 

45 ('dummy', 'name1', 'name2'), 

46 '[Aa]lready registered', 

47 id='existing', 

48 ), 

49 ], 

50 ) 

51 

52 

53class Test001ExporterUtils: 

54 """Test the utility functions in the `exporter` subpackage.""" 

55 

56 class VaultKeyEnvironment(NamedTuple): 

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

58 

59 Attributes: 

60 expected: 

61 The correct vault key value. 

62 vault_key: 

63 The value for the `VAULT_KEY` environment variable. 

64 logname: 

65 The value for the `LOGNAME` environment variable. 

66 user: 

67 The value for the `USER` environment variable. 

68 username: 

69 The value for the `USERNAME` environment variable. 

70 

71 """ 

72 

73 expected: str | None 

74 """""" 

75 vault_key: str | None 

76 """""" 

77 logname: str | None 

78 """""" 

79 user: str | None 

80 """""" 

81 username: str | None 

82 """""" 

83 

84 @strategies.composite 

85 @staticmethod 

86 def strategy( 

87 draw: strategies.DrawFn, 

88 allow_missing: bool = False, 

89 ) -> Test001ExporterUtils.VaultKeyEnvironment: 

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

91 text_strategy = strategies.text( 1b

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

93 min_size=1, 

94 max_size=24, 

95 ) 

96 env_var_strategy = strategies.one_of( 1b

97 strategies.none(), 

98 text_strategy, 

99 ) 

100 num_fields = sum( 1b

101 1 

102 for f in Test001ExporterUtils.VaultKeyEnvironment._fields 

103 if f != 'expected' 

104 ) 

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

106 strategies.builds( 

107 operator.add, 

108 strategies.lists( 

109 env_var_strategy, 

110 min_size=num_fields - 1, 

111 max_size=num_fields - 1, 

112 ), 

113 strategies.lists( 

114 text_strategy 

115 if not allow_missing 

116 else env_var_strategy, 

117 min_size=1, 

118 max_size=1, 

119 ), 

120 ) 

121 ) 

122 expected: str | None = None 1b

123 for value in reversed(env_vars): 1b

124 if value is not None: 1b

125 expected = value 1b

126 return Test001ExporterUtils.VaultKeyEnvironment( 1b

127 expected, *env_vars 

128 ) 

129 

130 @hypothesis.example( 

131 VaultKeyEnvironment('4username', None, None, None, '4username') 

132 ).via('manual, pre-hypothesis parametrization value') 

133 @hypothesis.example( 

134 VaultKeyEnvironment('3user', None, None, '3user', None) 

135 ).via('manual, pre-hypothesis parametrization value') 

136 @hypothesis.example( 

137 VaultKeyEnvironment('3user', None, None, '3user', '4username') 

138 ).via('manual, pre-hypothesis parametrization value') 

139 @hypothesis.example( 

140 VaultKeyEnvironment('2logname', None, '2logname', None, None) 

141 ).via('manual, pre-hypothesis parametrization value') 

142 @hypothesis.example( 

143 VaultKeyEnvironment('2logname', None, '2logname', None, '4username') 

144 ).via('manual, pre-hypothesis parametrization value') 

145 @hypothesis.example( 

146 VaultKeyEnvironment('2logname', None, '2logname', '3user', None) 

147 ).via('manual, pre-hypothesis parametrization value') 

148 @hypothesis.example( 

149 VaultKeyEnvironment('2logname', None, '2logname', '3user', '4username') 

150 ).via('manual, pre-hypothesis parametrization value') 

151 @hypothesis.example( 

152 VaultKeyEnvironment('1vault_key', '1vault_key', None, None, None) 

153 ).via('manual, pre-hypothesis parametrization value') 

154 @hypothesis.example( 

155 VaultKeyEnvironment( 

156 '1vault_key', '1vault_key', None, None, '4username' 

157 ) 

158 ).via('manual, pre-hypothesis parametrization value') 

159 @hypothesis.example( 

160 VaultKeyEnvironment('1vault_key', '1vault_key', None, '3user', None) 

161 ).via('manual, pre-hypothesis parametrization value') 

162 @hypothesis.example( 

163 VaultKeyEnvironment( 

164 '1vault_key', '1vault_key', None, '3user', '4username' 

165 ) 

166 ).via('manual, pre-hypothesis parametrization value') 

167 @hypothesis.example( 

168 VaultKeyEnvironment('1vault_key', '1vault_key', '2logname', None, None) 

169 ).via('manual, pre-hypothesis parametrization value') 

170 @hypothesis.example( 

171 VaultKeyEnvironment( 

172 '1vault_key', '1vault_key', '2logname', None, '4username' 

173 ) 

174 ).via('manual, pre-hypothesis parametrization value') 

175 @hypothesis.example( 

176 VaultKeyEnvironment( 

177 '1vault_key', '1vault_key', '2logname', '3user', None 

178 ) 

179 ).via('manual, pre-hypothesis parametrization value') 

180 @hypothesis.example( 

181 VaultKeyEnvironment( 

182 '1vault_key', '1vault_key', '2logname', '3user', '4username' 

183 ) 

184 ).via('manual, pre-hypothesis parametrization value') 

185 @hypothesis.given( 

186 vault_key_env=VaultKeyEnvironment.strategy().filter( 

187 lambda env: bool(env.expected) 

188 ), 

189 ) 

190 def test_200_get_vault_key( 

191 self, 

192 vault_key_env: VaultKeyEnvironment, 

193 ) -> None: 

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

195 

196 The correct environment variable value is used, according to 

197 their relative priorities. 

198 

199 """ 

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

201 assert expected is not None 1b

202 priority_list = [ 1b

203 ('VAULT_KEY', vault_key), 

204 ('LOGNAME', logname), 

205 ('USER', user), 

206 ('USERNAME', username), 

207 ] 

208 runner = tests.CliRunner(mix_stderr=False) 1b

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

210 # with-statements. 

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

212 with contextlib.ExitStack() as stack: 1b

213 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1b

214 stack.enter_context( 1b

215 tests.isolated_vault_exporter_config( 

216 monkeypatch=monkeypatch, runner=runner 

217 ) 

218 ) 

219 for key, value in priority_list: 1b

220 if value is not None: 1b

221 monkeypatch.setenv(key, value) 1b

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

223 

224 @Parametrize.EXPECTED_VAULT_PATH 

225 def test_210_get_vault_path( 

226 self, 

227 expected: pathlib.Path, 

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

229 ) -> None: 

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

231 

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

233 

234 """ 

235 runner = tests.CliRunner(mix_stderr=False) 1e

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

237 # with-statements. 

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

239 with contextlib.ExitStack() as stack: 1e

240 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1e

241 stack.enter_context( 1e

242 tests.isolated_vault_exporter_config( 

243 monkeypatch=monkeypatch, runner=runner 

244 ) 

245 ) 

246 if path: 1e

247 monkeypatch.setenv( 1e

248 'VAULT_PATH', os.fspath(path) if path is not None else None 

249 ) 

250 assert ( 1e

251 exporter.get_vault_path().resolve() 

252 == expected.expanduser().resolve() 

253 ) 

254 

255 @hypothesis.given( 

256 name_data=strategies.lists( 

257 strategies.integers(min_value=1, max_value=3), 

258 min_size=2, 

259 max_size=2, 

260 ).flatmap( 

261 lambda nm: strategies.lists( 

262 strategies.builds( 

263 operator.add, 

264 strategies.sampled_from(string.ascii_letters), 

265 strategies.text( 

266 string.ascii_letters + string.digits + '_-', 

267 max_size=23, 

268 ), 

269 ), 

270 min_size=sum(nm), 

271 max_size=sum(nm), 

272 unique=True, 

273 ).flatmap( 

274 lambda list_: strategies.just((list_[nm[0] :], list_[: nm[0]])) 

275 ) 

276 ), 

277 ) 

278 def test_220_register_export_vault_config_data_handler( 

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

280 ) -> None: 

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

282 

283 def handler( # pragma: no cover 1c

284 path: str | bytes | os.PathLike | None = None, 1c

285 key: str | Buffer | None = None, 1c

286 *, 

287 format: str, 1c

288 ) -> Any: 1c

289 del path, key 

290 raise ValueError(format) 

291 

292 names1, names2 = name_data 1c

293 

294 with pytest.MonkeyPatch.context() as monkeypatch: 1c

295 registry = dict.fromkeys(names1, handler) 1c

296 monkeypatch.setattr( 1c

297 exporter, '_export_vault_config_data_registry', registry 

298 ) 

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

300 assert dec(handler) == handler 1c

301 assert registry == dict.fromkeys(names1 + names2, handler) 1c

302 

303 def test_300_get_vault_key_without_envs(self) -> None: 

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

305 with pytest.MonkeyPatch.context() as monkeypatch: 1f

306 monkeypatch.delenv('VAULT_KEY', raising=False) 1f

307 monkeypatch.delenv('LOGNAME', raising=False) 1f

308 monkeypatch.delenv('USER', raising=False) 1f

309 monkeypatch.delenv('USERNAME', raising=False) 1f

310 with pytest.raises(KeyError, match='VAULT_KEY'): 1f

311 exporter.get_vault_key() 1f

312 

313 def test_310_get_vault_path_without_home(self) -> None: 

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

315 

316 def raiser(*_args: Any, **_kwargs: Any) -> Any: 1g

317 raise RuntimeError('Cannot determine home directory.') # noqa: EM101,TRY003 1g

318 

319 with pytest.MonkeyPatch.context() as monkeypatch: 1g

320 monkeypatch.setattr(pathlib.Path, 'expanduser', raiser) 1g

321 monkeypatch.setattr(os.path, 'expanduser', raiser) 1g

322 with pytest.raises( 1g

323 RuntimeError, match=r'[Cc]annot determine home directory' 

324 ): 

325 exporter.get_vault_path() 1g

326 

327 @Parametrize.EXPORT_VAULT_CONFIG_DATA_HANDLER_NAMELISTS 

328 def test_320_register_export_vault_config_data_handler_errors( 

329 self, 

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

331 err_pat: str, 

332 ) -> None: 

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

334 

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

336 present in the handler registry. 

337 

338 """ 

339 

340 def handler( # pragma: no cover 1d

341 path: str | bytes | os.PathLike | None = None, 1d

342 key: str | Buffer | None = None, 1d

343 *, 

344 format: str, 1d

345 ) -> Any: 1d

346 del path, key 

347 raise ValueError(format) 

348 

349 with pytest.MonkeyPatch.context() as monkeypatch: 1d

350 registry = {'dummy': handler} 1d

351 monkeypatch.setattr( 1d

352 exporter, '_export_vault_config_data_registry', registry 

353 ) 

354 with pytest.raises(ValueError, match=err_pat): 1d

355 exporter.register_export_vault_config_data_handler(*namelist)( 1d

356 handler 

357 ) 

358 

359 def test_321_export_vault_config_data_bad_handler(self) -> None: 

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

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

362 monkeypatch.setattr( 1j

363 exporter, '_export_vault_config_data_registry', {} 

364 ) 

365 monkeypatch.setattr( 1j

366 exporter, 'find_vault_config_data_handlers', lambda: None 

367 ) 

368 with pytest.raises( 1j

369 ValueError, 

370 match=r'Invalid vault native configuration format', 

371 ): 

372 exporter.export_vault_config_data(format='v0.3') 1j

373 

374 

375class Test002CLI: 

376 """Test the command-line functionality of the `exporter` subpackage.""" 

377 

378 def test_300_invalid_format(self) -> None: 

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

380 runner = tests.CliRunner(mix_stderr=False) 1h

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

382 # with-statements. 

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

384 with contextlib.ExitStack() as stack: 1h

385 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1h

386 stack.enter_context( 1h

387 tests.isolated_vault_exporter_config( 

388 monkeypatch=monkeypatch, 

389 runner=runner, 

390 vault_config=tests.VAULT_V03_CONFIG, 

391 vault_key=tests.VAULT_MASTER_KEY, 

392 ) 

393 ) 

394 result = runner.invoke( 1h

395 cli.derivepassphrase_export_vault, 

396 ['-f', 'INVALID', 'VAULT_PATH'], 

397 catch_exceptions=False, 

398 ) 

399 for snippet in ('Invalid value for', '-f', '--format', 'INVALID'): 1h

400 assert result.error_exit(error=snippet), ( 1h

401 'expected error exit and known error message' 

402 ) 

403 

404 @tests.skip_if_cryptography_support 

405 @tests.Parametrize.VAULT_CONFIG_FORMATS_DATA 

406 def test_999_no_cryptography_error_message( 

407 self, 

408 caplog: pytest.LogCaptureFixture, 

409 config: str | bytes, 

410 format: str, 

411 config_data: str, 

412 ) -> None: 

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

414 del config_data 1i

415 runner = tests.CliRunner(mix_stderr=False) 1i

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

417 # with-statements. 

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

419 with contextlib.ExitStack() as stack: 1i

420 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1i

421 stack.enter_context( 1i

422 tests.isolated_vault_exporter_config( 

423 monkeypatch=monkeypatch, 

424 runner=runner, 

425 vault_config=config, 

426 vault_key=tests.VAULT_MASTER_KEY, 

427 ) 

428 ) 

429 result = runner.invoke( 1i

430 cli.derivepassphrase_export_vault, 

431 ['-f', format, 'VAULT_PATH'], 

432 catch_exceptions=False, 

433 ) 

434 assert result.error_exit( 1i

435 error=tests.CANNOT_LOAD_CRYPTOGRAPHY, 

436 record_tuples=caplog.record_tuples, 

437 ), 'expected error exit and known error message'