Coverage for tests\test_derivepassphrase_exporter.py: 100.000%

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

2#

3# SPDX-License-Identifier: Zlib

4

5 from __future__ import annotations (empty)

6

7 import contextlib (empty)

8 import operator (empty)

9 import os (empty)

10 import pathlib (empty)

11 import string (empty)

12 import types (empty)

13 from typing import TYPE_CHECKING, Any, NamedTuple (empty)

14

15 import hypothesis (empty)

16 import pytest (empty)

17 from hypothesis import strategies (empty)

18

19 import tests (empty)

20 from derivepassphrase import cli, exporter (empty)

21

22 if TYPE_CHECKING: (empty)

23 from typing_extensions import Buffer

24

25

26 class Parametrize(types.SimpleNamespace): (empty)

27 EXPECTED_VAULT_PATH = pytest.mark.parametrize( (empty)

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( (empty)

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

53 class Test001ExporterUtils: (empty)

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

55

56 class VaultKeyEnvironment(NamedTuple): (empty)

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 (empty)

74 """""" (empty)

75 vault_key: str | None (empty)

76 """""" (empty)

77 logname: str | None (empty)

78 """""" (empty)

79 user: str | None (empty)

80 """""" (empty)

81 username: str | None (empty)

82 """""" (empty)

83

84 @strategies.composite (empty)

85 @staticmethod (empty)

86 def strategy( (empty)

87 draw: strategies.DrawFn,

88 allow_missing: bool = False,

89 ) -> Test001ExporterUtils.VaultKeyEnvironment:

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

91 text_strategy = strategies.text( 1 ctx 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( 1 ctx 1b

97 strategies.none(),

98 text_strategy,

99 )

100 num_fields = sum( 1 ctx 1b

101 1

102 for f in Test001ExporterUtils.VaultKeyEnvironment._fields

103 if f != 'expected'

104 )

105 env_vars: list[str | None] = draw( 1 ctx 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 1 ctx 1b

123 for value in reversed(env_vars): 1 ctx 1b

124 if value is not None: 1 ctx 1b

125 expected = value 1 ctx 1b

126 return Test001ExporterUtils.VaultKeyEnvironment( 1 ctx 1b

127 expected, *env_vars

128 )

129

130 @hypothesis.example( (empty)

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

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

133 @hypothesis.example( (empty)

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

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

136 @hypothesis.example( (empty)

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

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

139 @hypothesis.example( (empty)

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

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

142 @hypothesis.example( (empty)

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

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

145 @hypothesis.example( (empty)

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

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

148 @hypothesis.example( (empty)

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

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

151 @hypothesis.example( (empty)

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

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

154 @hypothesis.example( (empty)

155 VaultKeyEnvironment(

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

157 )

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

159 @hypothesis.example( (empty)

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

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

162 @hypothesis.example( (empty)

163 VaultKeyEnvironment(

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

165 )

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

167 @hypothesis.example( (empty)

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

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

170 @hypothesis.example( (empty)

171 VaultKeyEnvironment(

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

173 )

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

175 @hypothesis.example( (empty)

176 VaultKeyEnvironment(

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

178 )

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

180 @hypothesis.example( (empty)

181 VaultKeyEnvironment(

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

183 )

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

185 @hypothesis.given( (empty)

186 vault_key_env=VaultKeyEnvironment.strategy().filter(

187 lambda env: bool(env.expected)

188 ),

189 )

190 def test_200_get_vault_key( (empty)

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 1 ctx 1b

201 assert expected is not None 1 ctx 1b

202 priority_list = [ 1 ctx 1b

203 ('VAULT_KEY', vault_key),

204 ('LOGNAME', logname),

205 ('USER', user),

206 ('USERNAME', username),

207 ]

208 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 1b

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

214 stack.enter_context( 1 ctx 1b

215 tests.isolated_vault_exporter_config(

216 monkeypatch=monkeypatch, runner=runner

217 )

218 )

219 for key, value in priority_list: 1 ctx 1b

220 if value is not None: 1 ctx 1b

221 monkeypatch.setenv(key, value) 1 ctx 1b

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

223

224 @Parametrize.EXPECTED_VAULT_PATH (empty)

225 def test_210_get_vault_path( (empty)

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) 1 ctx 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: 1 ctx 1e

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

241 stack.enter_context( 1 ctx 1e

242 tests.isolated_vault_exporter_config(

243 monkeypatch=monkeypatch, runner=runner

244 )

245 )

246 if path: 1 ctx 1e

247 monkeypatch.setenv( 1 ctx 1e

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

249 )

250 assert ( 1 ctx 1e

251 exporter.get_vault_path().resolve()

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

253 )

254

255 @hypothesis.given( (empty)

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( (empty)

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 1 ctx 1c

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

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

286 *,

287 format: str, 1 ctx 1c

288 ) -> Any: 1 ctx 1c

289 del path, key

290 raise ValueError(format)

291

292 names1, names2 = name_data 1 ctx 1c

293

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

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

296 monkeypatch.setattr( 1 ctx 1c

297 exporter, '_export_vault_config_data_registry', registry

298 )

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

300 assert dec(handler) == handler 1 ctx 1c

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

302

303 def test_300_get_vault_key_without_envs(self) -> None: (empty)

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

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

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

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

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

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

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

311 exporter.get_vault_key() 1 ctx 1f

312

313 def test_310_get_vault_path_without_home(self) -> None: (empty)

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

315

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

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

318

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

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

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

322 with pytest.raises( 1 ctx 1g

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

324 ):

325 exporter.get_vault_path() 1 ctx 1g

326

327 @Parametrize.EXPORT_VAULT_CONFIG_DATA_HANDLER_NAMELISTS (empty)

328 def test_320_register_export_vault_config_data_handler_errors( (empty)

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 1 ctx 1d

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

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

343 *,

344 format: str, 1 ctx 1d

345 ) -> Any: 1 ctx 1d

346 del path, key

347 raise ValueError(format)

348

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

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

351 monkeypatch.setattr( 1 ctx 1d

352 exporter, '_export_vault_config_data_registry', registry

353 )

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

355 exporter.register_export_vault_config_data_handler(*namelist)( 1 ctx 1d

356 handler

357 )

358

359 def test_321_export_vault_config_data_bad_handler(self) -> None: (empty)

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

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

362 monkeypatch.setattr( 1 ctx 1j

363 exporter, '_export_vault_config_data_registry', {}

364 )

365 monkeypatch.setattr( 1 ctx 1j

366 exporter, 'find_vault_config_data_handlers', lambda: None

367 )

368 with pytest.raises( 1 ctx 1j

369 ValueError,

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

371 ):

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

373

374

375 class Test002CLI: (empty)

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

377

378 def test_300_invalid_format(self) -> None: (empty)

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

380 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 1h

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

386 stack.enter_context( 1 ctx 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( 1 ctx 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'): 1 ctx 1h

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

401 'expected error exit and known error message'

402 )

403

404 @tests.skip_if_cryptography_support (empty)

405 @tests.Parametrize.VAULT_CONFIG_FORMATS_DATA (empty)

406 def test_999_no_cryptography_error_message( (empty)

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 1 ctx 1i

415 runner = tests.CliRunner(mix_stderr=False) 1 ctx 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: 1 ctx 1i

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

421 stack.enter_context( 1 ctx 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( 1 ctx 1i

430 cli.derivepassphrase_export_vault,

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

432 catch_exceptions=False,

433 )

434 assert result.error_exit( 1 ctx 1i

435 error=tests.CANNOT_LOAD_CRYPTOGRAPHY,

436 record_tuples=caplog.record_tuples,

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