Coverage for tests / test_derivepassphrase_cli / test_000_basic.py: 100.000%

67 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"""Tests for the `derivepassphrase` command-line interface: common subsystems. 

6 

7(Currently, these tests are performed on the `derivepassphrase vault` 

8subcommand, because that is the main command using all these 

9subsystems.) 

10 

11""" 

12 

13from __future__ import annotations 

14 

15import contextlib 

16import json 

17import textwrap 

18from typing import TYPE_CHECKING, ClassVar 

19 

20import pytest 

21 

22from derivepassphrase import _types, cli 

23from derivepassphrase._internals import cli_helpers 

24from tests import data, machinery 

25from tests.machinery import pytest as pytest_machinery 

26 

27if TYPE_CHECKING: 

28 from collections.abc import Generator 

29 

30DUMMY_SERVICE = data.DUMMY_SERVICE 

31DUMMY_PASSPHRASE = data.DUMMY_PASSPHRASE 

32DUMMY_CONFIG_SETTINGS = data.DUMMY_CONFIG_SETTINGS 

33 

34 

35class Parametrize(pytest_machinery.Parametrize): 

36 """Common test parametrizations.""" 

37 

38 UNICODE_NORMALIZATION_ERROR_INPUTS = pytest.mark.parametrize( 

39 ["main_config", "command_line", "input", "error_message"], 

40 [ 

41 pytest.param( 

42 textwrap.dedent(r""" 

43 [vault] 

44 default-unicode-normalization-form = 'XXX' 

45 """), 

46 ["--import", "-"], 

47 json.dumps({ 

48 "services": { 

49 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), 

50 "with_normalization": {"phrase": "D\u00fcsseldorf"}, 

51 }, 

52 }), 

53 ( 

54 "Invalid value 'XXX' for config key " 

55 "vault.default-unicode-normalization-form" 

56 ), 

57 id="global", 

58 ), 

59 pytest.param( 

60 textwrap.dedent(r""" 

61 [vault.unicode-normalization-form] 

62 with_normalization = 'XXX' 

63 """), 

64 ["--import", "-"], 

65 json.dumps({ 

66 "services": { 

67 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), 

68 "with_normalization": {"phrase": "D\u00fcsseldorf"}, 

69 }, 

70 }), 

71 ( 

72 "Invalid value 'XXX' for config key " 

73 "vault.with_normalization.unicode-normalization-form" 

74 ), 

75 id="service", 

76 ), 

77 pytest.param( 

78 textwrap.dedent(r""" 

79 [vault] 

80 default-unicode-normalization-form = 'XXX' 

81 """), 

82 ["--config", "--phrase"], 

83 DUMMY_PASSPHRASE, 

84 ( 

85 "Invalid value 'XXX' for config key " 

86 "vault.default-unicode-normalization-form" 

87 ), 

88 id="configure global passphrase", 

89 ), 

90 pytest.param( 

91 textwrap.dedent(r""" 

92 [vault] 

93 default-unicode-normalization-form = 'XXX' 

94 """), 

95 ["--config", "--phrase", "--", DUMMY_SERVICE], 

96 DUMMY_PASSPHRASE, 

97 ( 

98 "Invalid value 'XXX' for config key " 

99 "vault.default-unicode-normalization-form" 

100 ), 

101 id="configure service passphrase", 

102 ), 

103 pytest.param( 

104 textwrap.dedent(r""" 

105 [vault] 

106 default-unicode-normalization-form = 'XXX' 

107 """), 

108 ["--phrase", "--", DUMMY_SERVICE], 

109 DUMMY_PASSPHRASE, 

110 ( 

111 "Invalid value 'XXX' for config key " 

112 "vault.default-unicode-normalization-form" 

113 ), 

114 id="interactive passphrase", 

115 ), 

116 ], 

117 ) 

118 UNICODE_NORMALIZATION_WARNING_INPUTS = pytest.mark.parametrize( 

119 ["main_config", "command_line", "input", "warning_message"], 

120 [ 

121 pytest.param( 

122 "", 

123 ["--import", "-"], 

124 json.dumps({ 

125 "global": {"phrase": "Du\u0308sseldorf"}, 

126 "services": {}, 

127 }), 

128 "The $.global passphrase is not NFC-normalized", 

129 id="global-NFC", 

130 ), 

131 pytest.param( 

132 "", 

133 ["--import", "-"], 

134 json.dumps({ 

135 "services": { 

136 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), 

137 "weird entry name": {"phrase": "Du\u0308sseldorf"}, 

138 } 

139 }), 

140 ( 

141 'The $.services["weird entry name"] passphrase ' 

142 "is not NFC-normalized" 

143 ), 

144 id="service-weird-name-NFC", 

145 ), 

146 pytest.param( 

147 "", 

148 ["--config", "-p", "--", DUMMY_SERVICE], 

149 "Du\u0308sseldorf", 

150 ( 

151 f"The $.services.{DUMMY_SERVICE} passphrase " 

152 f"is not NFC-normalized" 

153 ), 

154 id="config-NFC", 

155 ), 

156 pytest.param( 

157 "", 

158 ["-p", "--", DUMMY_SERVICE], 

159 "Du\u0308sseldorf", 

160 "The interactive input passphrase is not NFC-normalized", 

161 id="direct-input-NFC", 

162 ), 

163 pytest.param( 

164 textwrap.dedent(r""" 

165 [vault] 

166 default-unicode-normalization-form = 'NFD' 

167 """), 

168 ["--import", "-"], 

169 json.dumps({ 

170 "global": { 

171 "phrase": "D\u00fcsseldorf", 

172 }, 

173 "services": {}, 

174 }), 

175 "The $.global passphrase is not NFD-normalized", 

176 id="global-NFD", 

177 ), 

178 pytest.param( 

179 textwrap.dedent(r""" 

180 [vault] 

181 default-unicode-normalization-form = 'NFD' 

182 """), 

183 ["--import", "-"], 

184 json.dumps({ 

185 "services": { 

186 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), 

187 "weird entry name": {"phrase": "D\u00fcsseldorf"}, 

188 }, 

189 }), 

190 ( 

191 'The $.services["weird entry name"] passphrase ' 

192 "is not NFD-normalized" 

193 ), 

194 id="service-weird-name-NFD", 

195 ), 

196 pytest.param( 

197 textwrap.dedent(r""" 

198 [vault.unicode-normalization-form] 

199 'weird entry name 2' = 'NFKD' 

200 """), 

201 ["--import", "-"], 

202 json.dumps({ 

203 "services": { 

204 DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), 

205 "weird entry name 1": {"phrase": "D\u00fcsseldorf"}, 

206 "weird entry name 2": {"phrase": "D\u00fcsseldorf"}, 

207 }, 

208 }), 

209 ( 

210 'The $.services["weird entry name 2"] passphrase ' 

211 "is not NFKD-normalized" 

212 ), 

213 id="service-weird-name-2-NFKD", 

214 ), 

215 ], 

216 ) 

217 

218 

219class TestPassphraseUnicodeNormalization: 

220 """Tests concerning the Unicode normalization of passphrases.""" 

221 

222 DEFAULT_VAULT_CONFIG: ClassVar[_types.VaultConfig] = { 

223 "services": {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()} 

224 } 

225 

226 def _test( 

227 self, 

228 command_line: list[str], 

229 /, 

230 *, 

231 main_config: str, 

232 message: str, 

233 caplog: pytest.LogCaptureFixture | None = None, 

234 input: str | None = None, 

235 ) -> None: 

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

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

238 # with-statements. 

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

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

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

242 stack.enter_context( 1de

243 pytest_machinery.isolated_vault_config( 

244 monkeypatch=monkeypatch, 

245 runner=runner, 

246 vault_config=self.DEFAULT_VAULT_CONFIG, 

247 main_config_str=main_config, 

248 ) 

249 ) 

250 result = runner.invoke( 1de

251 cli.derivepassphrase_vault, 

252 ["--debug", *command_line] 

253 if caplog is not None 

254 else command_line, 

255 catch_exceptions=False, 

256 input=input, 

257 ) 

258 if caplog is not None: 1de

259 assert result.clean_exit(), "expected clean exit" 1e

260 assert machinery.warning_emitted(message, caplog.record_tuples), ( 1e

261 "expected known warning message in stderr" 

262 ) 

263 else: 

264 assert result.error_exit( 1d

265 error="The user configuration file is invalid." 

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

267 assert result.error_exit(error=message), ( 1d

268 "expected error exit and known error message" 

269 ) 

270 

271 @Parametrize.UNICODE_NORMALIZATION_WARNING_INPUTS 

272 def test_warning( 

273 self, 

274 caplog: pytest.LogCaptureFixture, 

275 main_config: str, 

276 command_line: list[str], 

277 input: str | None, 

278 warning_message: str, 

279 ) -> None: 

280 """Using unnormalized Unicode passphrases warns.""" 

281 self._test( 1e

282 command_line, 

283 main_config=main_config, 

284 message=warning_message, 

285 caplog=caplog, 

286 input=input, 

287 ) 

288 

289 @Parametrize.UNICODE_NORMALIZATION_ERROR_INPUTS 

290 def test_error( 

291 self, 

292 main_config: str, 

293 command_line: list[str], 

294 input: str | None, 

295 error_message: str, 

296 ) -> None: 

297 """Using unknown Unicode normalization forms fails.""" 

298 self._test( 1d

299 command_line, 

300 main_config=main_config, 

301 message=error_message, 

302 input=input, 

303 ) 

304 

305 

306class TestUserConfigurationFileOther: 

307 """Other tests concerning the user configuration file.""" 

308 

309 @contextlib.contextmanager 

310 def _test( 

311 self, 

312 *, 

313 main_config: str = "", 

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

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

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

317 # with-statements. 

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

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

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

321 stack.enter_context( 1fb

322 pytest_machinery.isolated_vault_config( 

323 monkeypatch=monkeypatch, 

324 runner=runner, 

325 vault_config={"services": {}}, 

326 main_config_str=main_config, 

327 ) 

328 ) 

329 # If the setup blows up, then bubble the exception. 

330 yield monkeypatch # noqa: RUF075 1fb

331 result = runner.invoke( 1fb

332 cli.derivepassphrase_vault, 

333 ["--phrase", "--", DUMMY_SERVICE], 

334 input=DUMMY_PASSPHRASE, 

335 catch_exceptions=False, 

336 ) 

337 assert result.error_exit(error="Cannot load user config:"), ( 1fb

338 "expected error exit and known error message" 

339 ) 

340 

341 def test_bad_user_config_file( 

342 self, 

343 ) -> None: 

344 """Loading a user configuration file in an invalid format fails.""" 

345 with self._test(main_config="This file is not valid TOML.\n"): 1f

346 pass 1f

347 

348 def test_user_config_is_a_directory( 

349 self, 

350 ) -> None: 

351 """Loading a user configuration non-file fails.""" 

352 with self._test(): 1b

353 user_config = cli_helpers.config_filename( 1b

354 subsystem="user configuration" 

355 ) 

356 user_config.unlink() 1b

357 user_config.mkdir(parents=True, exist_ok=True) 1b

358 

359 

360class TestSSHAgentAvailability: 

361 """Tests concerning the availability of the SSH agent.""" 

362 

363 @Parametrize.MISSING_AGENT_SUPPORT 

364 def test_missing_agent_support( 

365 self, 

366 caplog: pytest.LogCaptureFixture, 

367 provider: _types.BuiltinSSHAgentSocketProvider, 

368 sys_action: data.SystemSupportAction, 

369 exception: type[NotImplementedError], 

370 message: str, 

371 ) -> None: 

372 """Querying the SSH agent without support fails.""" 

373 del provider 1c

374 del exception 1c

375 runner = machinery.CliRunner(mix_stderr=False) 1c

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

377 # with-statements. 

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

379 with contextlib.ExitStack() as stack: 1c

380 monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) 1c

381 stack.enter_context( 1c

382 pytest_machinery.isolated_vault_config( 

383 monkeypatch=monkeypatch, 

384 runner=runner, 

385 vault_config={"services": {}}, 

386 ) 

387 ) 

388 monkeypatch.setenv( 1c

389 "SSH_AUTH_SOCK", "the value doesn't even matter" 

390 ) 

391 sys_action(monkeypatch) 1c

392 result = runner.invoke( 1c

393 cli.derivepassphrase_vault, 

394 ["--key", "--config"], 

395 catch_exceptions=False, 

396 ) 

397 assert result.error_exit( 1c

398 error="does not support communicating with it" 

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

400 assert machinery.warning_emitted(message, caplog.record_tuples), ( 1c

401 "expected known warning message in stderr" 

402 )