Marco Ricci commited on 2025-01-24 23:03:43
Zeige 10 geänderte Dateien mit 1246 Einfügungen und 14 Löschungen.
These docstrings aim to provide intent and context to the test function.
| ... | ... |
@@ -40,25 +40,85 @@ if TYPE_CHECKING: |
| 40 | 40 |
|
| 41 | 41 |
|
| 42 | 42 |
class SSHTestKey(NamedTuple): |
| 43 |
+ """An SSH test key. |
|
| 44 |
+ |
|
| 45 |
+ Attributes: |
|
| 46 |
+ public_key: |
|
| 47 |
+ The SSH public key string, as used e.g. by OpenSSH's |
|
| 48 |
+ `authorized_keys` file. Includes a comment. |
|
| 49 |
+ public_key_data: |
|
| 50 |
+ The SSH protocol wire format of the public key. |
|
| 51 |
+ private_key: |
|
| 52 |
+ A base64 encoded representation of the private key, in |
|
| 53 |
+ OpenSSH's v1 private key format. |
|
| 54 |
+ private_key_blob: |
|
| 55 |
+ The SSH protocol wire format of the private key. |
|
| 56 |
+ expected_signature: |
|
| 57 |
+ For deterministic signature types, this is the expected |
|
| 58 |
+ signature of the vault UUID. For other types this is |
|
| 59 |
+ `None`. |
|
| 60 |
+ derived_passphrase: |
|
| 61 |
+ For deterministic signature types, this is the "equivalent |
|
| 62 |
+ master passphrase" derived from this key (a transformation |
|
| 63 |
+ of [`expected_signature`][]). For other types this is |
|
| 64 |
+ `None`. |
|
| 65 |
+ |
|
| 66 |
+ """ |
|
| 67 |
+ |
|
| 43 | 68 |
public_key: bytes | str |
| 69 |
+ """""" |
|
| 44 | 70 |
public_key_data: bytes |
| 71 |
+ """""" |
|
| 45 | 72 |
private_key: bytes |
| 73 |
+ """""" |
|
| 46 | 74 |
private_key_blob: bytes | None = None |
| 75 |
+ """""" |
|
| 47 | 76 |
expected_signature: bytes | None = None |
| 77 |
+ """""" |
|
| 48 | 78 |
derived_passphrase: bytes | str | None = None |
| 79 |
+ """""" |
|
| 49 | 80 |
|
| 50 | 81 |
def is_suitable(self) -> bool: |
| 82 |
+ """Return if this key is suitable for use with vault.""" |
|
| 51 | 83 |
return vault.Vault.is_suitable_ssh_key(self.public_key_data) |
| 52 | 84 |
|
| 53 | 85 |
|
| 54 | 86 |
class ValidationSettings(NamedTuple): |
| 87 |
+ """Validation settings for [`VaultTestConfig`][]s. |
|
| 88 |
+ |
|
| 89 |
+ Attributes: |
|
| 90 |
+ allow_unknown_settings: |
|
| 91 |
+ See [`_types.validate_vault_config`][]. |
|
| 92 |
+ |
|
| 93 |
+ """ |
|
| 94 |
+ |
|
| 55 | 95 |
allow_unknown_settings: bool |
| 96 |
+ """""" |
|
| 56 | 97 |
|
| 57 | 98 |
|
| 58 | 99 |
class VaultTestConfig(NamedTuple): |
| 100 |
+ """A (not necessarily valid) sample vault config, plus metadata. |
|
| 101 |
+ |
|
| 102 |
+ Attributes: |
|
| 103 |
+ config: |
|
| 104 |
+ The actual configuration object. Usually a [`dict`][]. |
|
| 105 |
+ comment: |
|
| 106 |
+ An explanatory comment for what is wrong with this config, |
|
| 107 |
+ or empty if the config is valid. This is intended as |
|
| 108 |
+ a debugging message to be shown to the user (e.g. when an |
|
| 109 |
+ assertion fails), not as an error message to |
|
| 110 |
+ programmatically match against. |
|
| 111 |
+ validation_settings: |
|
| 112 |
+ See [`_types.validate_vault_config`][]. |
|
| 113 |
+ |
|
| 114 |
+ """ |
|
| 115 |
+ |
|
| 59 | 116 |
config: Any |
| 117 |
+ """""" |
|
| 60 | 118 |
comment: str |
| 119 |
+ """""" |
|
| 61 | 120 |
validation_settings: ValidationSettings | None |
| 121 |
+ """""" |
|
| 62 | 122 |
|
| 63 | 123 |
|
| 64 | 124 |
TEST_CONFIGS: list[VaultTestConfig] = [ |
| ... | ... |
@@ -310,6 +370,7 @@ TEST_CONFIGS: list[VaultTestConfig] = [ |
| 310 | 370 |
ValidationSettings(True), |
| 311 | 371 |
), |
| 312 | 372 |
] |
| 373 |
+"""The master list of test configurations for vault.""" |
|
| 313 | 374 |
|
| 314 | 375 |
|
| 315 | 376 |
def is_valid_test_config(conf: VaultTestConfig, /) -> bool: |
| ... | ... |
@@ -333,6 +394,17 @@ def _test_config_ids(val: VaultTestConfig) -> Any: # pragma: no cover |
| 333 | 394 |
|
| 334 | 395 |
@strategies.composite |
| 335 | 396 |
def vault_full_service_config(draw: strategies.DrawFn) -> dict[str, int]: |
| 397 |
+ """Hypothesis strategy for full vault service configurations. |
|
| 398 |
+ |
|
| 399 |
+ Returns a sample configuration with restrictions on length, repeat |
|
| 400 |
+ count, and all character classes, while ensuring the settings are |
|
| 401 |
+ not obviously unsatisfiable. |
|
| 402 |
+ |
|
| 403 |
+ Args: |
|
| 404 |
+ draw: |
|
| 405 |
+ The `draw` function, as provided for by hypothesis. |
|
| 406 |
+ |
|
| 407 |
+ """ |
|
| 336 | 408 |
repeat = draw(strategies.integers(min_value=0, max_value=10)) |
| 337 | 409 |
lower = draw(strategies.integers(min_value=0, max_value=10)) |
| 338 | 410 |
upper = draw(strategies.integers(min_value=0, max_value=10)) |
| ... | ... |
@@ -400,7 +472,7 @@ def smudged_vault_test_config( |
| 400 | 472 |
|
| 401 | 473 |
Args: |
| 402 | 474 |
draw: |
| 403 |
- The hypothesis draw function. |
|
| 475 |
+ The `draw` function, as provided for by hypothesis. |
|
| 404 | 476 |
config: |
| 405 | 477 |
A strategy which generates [`VaultTestConfig`][] objects. |
| 406 | 478 |
|
| ... | ... |
@@ -421,7 +493,7 @@ def smudged_vault_test_config( |
| 421 | 493 |
services.append(obj['global']) |
| 422 | 494 |
assert all(isinstance(x, dict) for x in services), ( |
| 423 | 495 |
'is_smudgable_vault_test_config guard failed to ' |
| 424 |
- 'ensure each setings dict is a dict' |
|
| 496 |
+ 'ensure each settings dict is a dict' |
|
| 425 | 497 |
) |
| 426 | 498 |
for service in services: |
| 427 | 499 |
for key in ('phrase',):
|
| ... | ... |
@@ -453,20 +525,75 @@ def smudged_vault_test_config( |
| 453 | 525 |
|
| 454 | 526 |
|
| 455 | 527 |
class KnownSSHAgent(str, enum.Enum): |
| 528 |
+ """Known SSH agents. |
|
| 529 |
+ |
|
| 530 |
+ Attributes: |
|
| 531 |
+ UNKNOWN: |
|
| 532 |
+ Not a known agent, or not known statically. |
|
| 533 |
+ Pageant: |
|
| 534 |
+ The agent from Simon Tatham's PuTTY suite. |
|
| 535 |
+ OpenSSHAgent: |
|
| 536 |
+ The agent from OpenBSD's OpenSSH suite. |
|
| 537 |
+ |
|
| 538 |
+ """ |
|
| 539 |
+ |
|
| 456 | 540 |
UNKNOWN: str = '(unknown)' |
| 541 |
+ """""" |
|
| 457 | 542 |
Pageant: str = 'Pageant' |
| 543 |
+ """""" |
|
| 458 | 544 |
OpenSSHAgent: str = 'OpenSSHAgent' |
| 545 |
+ """""" |
|
| 459 | 546 |
|
| 460 | 547 |
|
| 461 | 548 |
class SpawnedSSHAgentInfo(NamedTuple): |
| 549 |
+ """Info about a spawned SSH agent, as provided by some fixtures. |
|
| 550 |
+ |
|
| 551 |
+ Differs from [`RunningSSHAgentInfo`][] in that this info object |
|
| 552 |
+ already provides a functional client connected to the agent, but not |
|
| 553 |
+ the address. |
|
| 554 |
+ |
|
| 555 |
+ Attributes: |
|
| 556 |
+ agent_type: |
|
| 557 |
+ The agent's type. |
|
| 558 |
+ client: |
|
| 559 |
+ An SSH agent client connected to this agent. |
|
| 560 |
+ isolated: |
|
| 561 |
+ Whether this agent was spawned specifically for this test |
|
| 562 |
+ suite, with attempts to isolate it from the user. If false, |
|
| 563 |
+ then the user may be interacting with the agent externally, |
|
| 564 |
+ meaning e.g. keys other than the test keys may be visible in |
|
| 565 |
+ this agent. |
|
| 566 |
+ |
|
| 567 |
+ """ |
|
| 568 |
+ |
|
| 462 | 569 |
agent_type: KnownSSHAgent |
| 570 |
+ """""" |
|
| 463 | 571 |
client: ssh_agent.SSHAgentClient |
| 572 |
+ """""" |
|
| 464 | 573 |
isolated: bool |
| 574 |
+ """""" |
|
| 465 | 575 |
|
| 466 | 576 |
|
| 467 | 577 |
class RunningSSHAgentInfo(NamedTuple): |
| 578 |
+ """Info about a running SSH agent, as provided by some fixtures. |
|
| 579 |
+ |
|
| 580 |
+ Differs from [`SpawnedSSHAgentInfo`][] in that this info object |
|
| 581 |
+ provides only an address of the agent, not a functional client |
|
| 582 |
+ already connected to it. The running SSH agent may or may not be |
|
| 583 |
+ isolated. |
|
| 584 |
+ |
|
| 585 |
+ Attributes: |
|
| 586 |
+ socket: |
|
| 587 |
+ A socket address to connect to the agent. |
|
| 588 |
+ agent_type: |
|
| 589 |
+ The agent's type. |
|
| 590 |
+ |
|
| 591 |
+ """ |
|
| 592 |
+ |
|
| 468 | 593 |
socket: str |
| 594 |
+ """""" |
|
| 469 | 595 |
agent_type: KnownSSHAgent |
| 596 |
+ """""" |
|
| 470 | 597 |
|
| 471 | 598 |
|
| 472 | 599 |
ALL_KEYS: Mapping[str, SSHTestKey] = {
|
| ... | ... |
@@ -998,21 +1125,34 @@ Rlc3Qga2V5IHdpdGhvdXQgcGFzc3BocmFzZQ== |
| 998 | 1125 |
derived_passphrase=None, |
| 999 | 1126 |
), |
| 1000 | 1127 |
} |
| 1128 |
+"""The master list of SSH test keys.""" |
|
| 1001 | 1129 |
SUPPORTED_KEYS: Mapping[str, SSHTestKey] = {
|
| 1002 | 1130 |
k: v for k, v in ALL_KEYS.items() if v.is_suitable() |
| 1003 | 1131 |
} |
| 1132 |
+"""The subset of SSH test keys suitable for use with vault.""" |
|
| 1004 | 1133 |
UNSUITABLE_KEYS: Mapping[str, SSHTestKey] = {
|
| 1005 | 1134 |
k: v for k, v in ALL_KEYS.items() if not v.is_suitable() |
| 1006 | 1135 |
} |
| 1136 |
+"""The subset of SSH test keys not suitable for use with vault.""" |
|
| 1007 | 1137 |
|
| 1008 | 1138 |
DUMMY_SERVICE = 'service1' |
| 1139 |
+"""A standard/sample service name.""" |
|
| 1009 | 1140 |
DUMMY_PASSPHRASE = 'my secret passphrase' |
| 1141 |
+"""A standard/sample passphrase.""" |
|
| 1010 | 1142 |
DUMMY_KEY1 = SUPPORTED_KEYS['ed25519'].public_key_data |
| 1143 |
+"""A sample universally supported SSH test key (in wire format).""" |
|
| 1011 | 1144 |
DUMMY_KEY1_B64 = base64.standard_b64encode(DUMMY_KEY1).decode('ASCII')
|
| 1145 |
+""" |
|
| 1146 |
+A sample universally supported SSH test key (in `authorized_keys` format). |
|
| 1147 |
+""" |
|
| 1012 | 1148 |
DUMMY_KEY2 = SUPPORTED_KEYS['rsa'].public_key_data |
| 1149 |
+"""A second supported SSH test key (in wire format).""" |
|
| 1013 | 1150 |
DUMMY_KEY2_B64 = base64.standard_b64encode(DUMMY_KEY2).decode('ASCII')
|
| 1151 |
+"""A second supported SSH test key (in `authorized_keys` format).""" |
|
| 1014 | 1152 |
DUMMY_KEY3 = SUPPORTED_KEYS['ed448'].public_key_data |
| 1153 |
+"""A third supported SSH test key (in wire format).""" |
|
| 1015 | 1154 |
DUMMY_KEY3_B64 = base64.standard_b64encode(DUMMY_KEY3).decode('ASCII')
|
| 1155 |
+"""A third supported SSH test key (in `authorized_keys` format).""" |
|
| 1016 | 1156 |
DUMMY_CONFIG_SETTINGS = {
|
| 1017 | 1157 |
'length': 10, |
| 1018 | 1158 |
'upper': 1, |
| ... | ... |
@@ -1023,8 +1163,15 @@ DUMMY_CONFIG_SETTINGS = {
|
| 1023 | 1163 |
'dash': 1, |
| 1024 | 1164 |
'symbol': 1, |
| 1025 | 1165 |
} |
| 1166 |
+"""Sample vault settings.""" |
|
| 1026 | 1167 |
DUMMY_RESULT_PASSPHRASE = b'.2V_QJkd o' |
| 1168 |
+""" |
|
| 1169 |
+The passphrase derived from [`DUMMY_SERVICE`][] using [`DUMMY_PASSPHRASE`][]. |
|
| 1170 |
+""" |
|
| 1027 | 1171 |
DUMMY_RESULT_KEY1 = b'E<b<{ -7iG'
|
| 1172 |
+""" |
|
| 1173 |
+The passphrase derived from [`DUMMY_SERVICE`][] using [`DUMMY_KEY1`][]. |
|
| 1174 |
+""" |
|
| 1028 | 1175 |
DUMMY_PHRASE_FROM_KEY1_RAW = ( |
| 1029 | 1176 |
b'\x00\x00\x00\x0bssh-ed25519' |
| 1030 | 1177 |
b'\x00\x00\x00@\xf0\x98\x19\x80l\x1a\x97\xd5&\x03n' |
| ... | ... |
@@ -1032,10 +1179,23 @@ DUMMY_PHRASE_FROM_KEY1_RAW = ( |
| 1032 | 1179 |
b'\x1d\xaf\xfd\r\x08\x1f\xec\xf8s\x9b\x8c_U9\x16|ST,' |
| 1033 | 1180 |
b'\x1eR\xbb0\xed\x7f\x89\xe2/iQU\xd8\x9e\xa6\x02' |
| 1034 | 1181 |
) |
| 1182 |
+""" |
|
| 1183 |
+The "equivalent master passphrase" derived from [`DUMMY_KEY1`][] (raw format). |
|
| 1184 |
+""" |
|
| 1035 | 1185 |
DUMMY_PHRASE_FROM_KEY1 = b'8JgZgGwal9UmA27M42WPhmYHExkTCSEzM/nkNlMdr/0NCB/s+HObjF9VORZ8U1QsHlK7MO1/ieIvaVFV2J6mAg==' |
| 1186 |
+""" |
|
| 1187 |
+The "equivalent master passphrase" derived from [`DUMMY_KEY1`][] (in base64). |
|
| 1188 |
+""" |
|
| 1036 | 1189 |
|
| 1037 | 1190 |
VAULT_MASTER_KEY = 'vault key' |
| 1191 |
+""" |
|
| 1192 |
+The storage passphrase used to encrypt all sample vault native configurations. |
|
| 1193 |
+""" |
|
| 1038 | 1194 |
VAULT_V02_CONFIG = 'P7xeh5y4jmjpJ2pFq4KUcTVoaE9ZOEkwWmpVTURSSWQxbGt6emN4aFE4eFM3anVPbDRNTGpOLzY3eDF5aE1YTm5LNWh5Q1BwWTMwM3M5S083MWRWRFlmOXNqSFJNcStGMWFOS3c2emhiOUNNenZYTmNNMnZxaUErdlRoOGF2ZHdGT1ZLNTNLOVJQcU9jWmJrR3g5N09VcVBRZ0ZnSFNUQy9HdFVWWnFteVhRVkY3MHNBdnF2ZWFEbFBseWRGelE1c3BFTnVUckRQdWJSL29wNjFxd2Y2ZVpob3VyVzRod3FKTElTenJ1WTZacTJFOFBtK3BnVzh0QWVxcWtyWFdXOXYyenNQeFNZbWt1MDU2Vm1kVGtISWIxWTBpcWRFbyswUVJudVVhZkVlNVpGWDA4WUQ2Q2JTWW81SnlhQ2Zxa3cxNmZoQjJES0Uyd29rNXpSck5iWVBrVmEwOXFya1NpMi9saU5LL3F0M3N3MjZKekNCem9ER2svWkZ0SUJLdmlHRno0VlQzQ3pqZTBWcTM3YmRiNmJjTkhqUHZoQ0NxMW1ldW1XOFVVK3pQMEtUMkRMVGNvNHFlOG40ck5KcGhsYXg1b1VzZ1NYU1B2T3RXdEkwYzg4NWE3YWUzOWI1MDI0MThhMWZjODQ3MDA2OTJmNDQ0MDkxNGFiNmRlMGQ2YjZiNjI5NGMwN2IwMmI4MGZi' |
| 1195 |
+""" |
|
| 1196 |
+A sample vault native configuration, in v0.2 format, encoded in base64 |
|
| 1197 |
+and encrypted with [`VAULT_MASTER_KEY`][]. |
|
| 1198 |
+""" |
|
| 1039 | 1199 |
VAULT_V02_CONFIG_DATA = {
|
| 1040 | 1200 |
'global': {
|
| 1041 | 1201 |
'phrase': DUMMY_PASSPHRASE.rstrip('\n'),
|
| ... | ... |
@@ -1047,7 +1207,15 @@ VAULT_V02_CONFIG_DATA = {
|
| 1047 | 1207 |
DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), |
| 1048 | 1208 |
}, |
| 1049 | 1209 |
} |
| 1210 |
+""" |
|
| 1211 |
+The plaintext contents (a vault native configuration) stored in |
|
| 1212 |
+[`VAULT_V02_CONFIG`][]. |
|
| 1213 |
+""" |
|
| 1050 | 1214 |
VAULT_V03_CONFIG = 'sBPBrr8BFHPxSJkV/A53zk9zwDQHFxLe6UIusCVvzFQre103pcj5xxmE11lMTA0U2QTYjkhRXKkH5WegSmYpAnzReuRsYZlWWp6N4kkubf+twZ9C3EeggPm7as2Af4TICHVbX4uXpIHeQJf9y1OtqrO+SRBrgPBzgItoxsIxebxVKgyvh1CZQOSkn7BIzt9xKhDng3ubS4hQ91fB0QCumlldTbUl8tj4Xs5JbvsSlUMxRlVzZ0OgAOrSsoWELXmsp6zXFa9K6wIuZa4wQuMLQFHiA64JO1CR3I+rviWCeMlbTOuJNx6vMB5zotKJqA2hIUpN467TQ9vI4g/QTo40m5LT2EQKbIdTvBQAzcV4lOcpr5Lqt4LHED5mKvm/4YfpuuT3I3XCdWfdG5SB7ciiB4Go+xQdddy3zZMiwm1fEwIB8XjFf2cxoJdccLQ2yxf+9diedBP04EsMHrvxKDhQ7/vHl7xF2MMFTDKl3WFd23vvcjpR1JgNAKYprG/e1p/7' |
| 1215 |
+""" |
|
| 1216 |
+A sample vault native configuration, in v0.3 format, encoded in base64 |
|
| 1217 |
+and encrypted with [`VAULT_MASTER_KEY`][]. |
|
| 1218 |
+""" |
|
| 1051 | 1219 |
VAULT_V03_CONFIG_DATA = {
|
| 1052 | 1220 |
'global': {
|
| 1053 | 1221 |
'phrase': DUMMY_PASSPHRASE.rstrip('\n'),
|
| ... | ... |
@@ -1059,6 +1227,10 @@ VAULT_V03_CONFIG_DATA = {
|
| 1059 | 1227 |
DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), |
| 1060 | 1228 |
}, |
| 1061 | 1229 |
} |
| 1230 |
+""" |
|
| 1231 |
+The plaintext contents (a vault native configuration) stored in |
|
| 1232 |
+[`VAULT_V03_CONFIG`][]. |
|
| 1233 |
+""" |
|
| 1062 | 1234 |
VAULT_STOREROOM_CONFIG_ZIPPED = b""" |
| 1063 | 1235 |
UEsDBBQAAAAIAJ1WGVnTVFGT0gAAAOYAAAAFAAAALmtleXMFwclSgzAAANC7n9GrBzBldcYDE5Al |
| 1064 | 1236 |
EKbFAvGWklBAtqYsBcd/973fw8LFox76w/vb34tzhD5OATeEAk6tJ6Fbp3WrvkJO7l0KIjtxCLfY |
| ... | ... |
@@ -1111,6 +1283,11 @@ AAAACACdVhlZ3Wlf4QUDAADUAwAAAgAAAAAAAAABAAAApIH1AAAAMDBQSwECHgMUAAAACACdVhlZ |
| 1111 | 1283 |
AgAAAgAAAAAAAAABAAAApIEEBgAAMDlQSwECHgMUAAAACACdVhlZyjtiYvgBAABrAgAAAgAAAAAA |
| 1112 | 1284 |
AAABAAAApIHsBwAAMWFQSwUGAAAAAAUABQDzAAAABAoAAAAA |
| 1113 | 1285 |
""" |
| 1286 |
+""" |
|
| 1287 |
+A sample vault native configuration, in storeroom format, encrypted with |
|
| 1288 |
+[`VAULT_MASTER_KEY`][]. The configuration is compressed (zip archive) |
|
| 1289 |
+and then encoded in base64. |
|
| 1290 |
+""" |
|
| 1114 | 1291 |
VAULT_STOREROOM_CONFIG_DATA = {
|
| 1115 | 1292 |
'global': {
|
| 1116 | 1293 |
'phrase': DUMMY_PASSPHRASE.rstrip('\n'),
|
| ... | ... |
@@ -1122,6 +1299,10 @@ VAULT_STOREROOM_CONFIG_DATA = {
|
| 1122 | 1299 |
DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), |
| 1123 | 1300 |
}, |
| 1124 | 1301 |
} |
| 1302 |
+""" |
|
| 1303 |
+The parsed vault configuration stored in |
|
| 1304 |
+[`VAULT_STOREROOM_CONFIG_ZIPPED`][]. |
|
| 1305 |
+""" |
|
| 1125 | 1306 |
|
| 1126 | 1307 |
_VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED_JAVASCRIPT_SOURCE = """ |
| 1127 | 1308 |
// Executed in the top-level directory of the vault project code, in Node.js. |
| ... | ... |
@@ -1131,6 +1312,10 @@ let store = new Store(storeroom.createFileAdapter('./broken-dir'), 'vault key')
|
| 1131 | 1312 |
await store._storeroom.put('/services/array/', ['entry1','entry2'])
|
| 1132 | 1313 |
// The resulting "broken-dir" was then zipped manually. |
| 1133 | 1314 |
""" |
| 1315 |
+""" |
|
| 1316 |
+The JavaScript source for the script that generated the storeroom |
|
| 1317 |
+archive in [`VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED`][]. |
|
| 1318 |
+""" |
|
| 1134 | 1319 |
VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED = b""" |
| 1135 | 1320 |
UEsDBBQAAgAIAHijH1kjc0ql0gAAAOYAAAAFAAAALmtleXMFwclygjAAANB7P8Mrh7LIYmd6oGxC |
| 1136 | 1321 |
HKwTJJgbNpBKCpGAhNTpv/e952ZpxHTjw+bN+HuJJABEikvHecD0pLgpgYKWjue0CZGk19mKF+4f |
| ... | ... |
@@ -1167,6 +1352,17 @@ ox9ZI3NKpdIAAADmAAAABQAAAAAAAAABAAAApIEAAAAALmtleXNQSwECHgMUAAIACAB4ox9Zfgvu |
| 1167 | 1352 |
AgAAAAAAAAABAAAApIHtAgAAMWFQSwECHgMUAAIACAB4ox9ZGgj3mrkBAAAXAgAAAgAAAAAAAAAB |
| 1168 | 1353 |
AAAApIHIBAAAMWVQSwUGAAAAAAQABADDAAAAoQYAAAAA |
| 1169 | 1354 |
""" |
| 1355 |
+""" |
|
| 1356 |
+A sample corrupted storeroom archive, encrypted with |
|
| 1357 |
+[`VAULT_MASTER_KEY`][]. The configuration is compressed (zip archive) |
|
| 1358 |
+and then encoded in base64. |
|
| 1359 |
+ |
|
| 1360 |
+The archive contains a directory `/services/array/` that claims to have |
|
| 1361 |
+two child items 'entry1' and 'entry2', but no such child items are |
|
| 1362 |
+present in the archive. See |
|
| 1363 |
+[`_VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED_JAVASCRIPT_SOURCE`][] for |
|
| 1364 |
+the exact script that created this archive. |
|
| 1365 |
+""" |
|
| 1170 | 1366 |
|
| 1171 | 1367 |
_VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED2_JAVASCRIPT_SOURCE = """ |
| 1172 | 1368 |
// Executed in the top-level directory of the vault project code, in Node.js. |
| ... | ... |
@@ -1176,6 +1372,10 @@ let store = new Store(storeroom.createFileAdapter('./broken-dir'), 'vault key')
|
| 1176 | 1372 |
await store._storeroom.put('/services/array/', 'not a directory index')
|
| 1177 | 1373 |
// The resulting "broken-dir" was then zipped manually. |
| 1178 | 1374 |
""" |
| 1375 |
+""" |
|
| 1376 |
+The JavaScript source for the script that generated the storeroom |
|
| 1377 |
+archive in [`VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED2`][]. |
|
| 1378 |
+""" |
|
| 1179 | 1379 |
VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED2 = b""" |
| 1180 | 1380 |
UEsDBAoAAAAAAM6NSVmrcHdV5gAAAOYAAAAFAAAALmtleXN7InZlcnNpb24iOjF9CkV3ZS9LZkJp |
| 1181 | 1381 |
L0V0OUcrZmxYM3gxaFU4ZjE4YlE3S253bHoxN0IxSDE3cUhVOGdWK2RpWWY5MTdFZ0YrSStidEpZ |
| ... | ... |
@@ -1217,6 +1417,16 @@ LmtleXNQSwECHgMKAAAAAADOjUlZJg3/BhcCAAAXAgAAAgAAAAAAAAAAAAAApIEJAQAAMGJQSwEC |
| 1217 | 1417 |
HgMKAAAAAADOjUlZTNfdphcCAAAXAgAAAgAAAAAAAAAAAAAApIFAAwAAMGZQSwECHgMKAAAAAADO |
| 1218 | 1418 |
jUlZn9rNID8CAAA/AgAAAgAAAAAAAAAAAAAApIF3BQAAMWRQSwUGAAAAAAQABADDAAAA1gcAAAAA |
| 1219 | 1419 |
""" |
| 1420 |
+""" |
|
| 1421 |
+A sample corrupted storeroom archive, encrypted with |
|
| 1422 |
+[`VAULT_MASTER_KEY`][]. The configuration is compressed (zip archive) |
|
| 1423 |
+and then encoded in base64. |
|
| 1424 |
+ |
|
| 1425 |
+The archive contains a directory `/services/array/` whose list of child |
|
| 1426 |
+items does not adhere to the serialization format. See |
|
| 1427 |
+[`_VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED2_JAVASCRIPT_SOURCE`][] for |
|
| 1428 |
+the exact script that created this archive. |
|
| 1429 |
+""" |
|
| 1220 | 1430 |
|
| 1221 | 1431 |
_VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED3_JAVASCRIPT_SOURCE = """ |
| 1222 | 1432 |
// Executed in the top-level directory of the vault project code, in Node.js. |
| ... | ... |
@@ -1226,6 +1436,10 @@ let store = new Store(storeroom.createFileAdapter('./broken-dir'), 'vault key')
|
| 1226 | 1436 |
await store._storeroom.put('/services/array/', [null, 1, true, [], {}])
|
| 1227 | 1437 |
// The resulting "broken-dir" was then zipped manually. |
| 1228 | 1438 |
""" |
| 1439 |
+""" |
|
| 1440 |
+The JavaScript source for the script that generated the storeroom |
|
| 1441 |
+archive in [`VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED3`][]. |
|
| 1442 |
+""" |
|
| 1229 | 1443 |
VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED3 = b""" |
| 1230 | 1444 |
UEsDBAoAAAAAAEOPSVnVlcff5gAAAOYAAAAFAAAALmtleXN7InZlcnNpb24iOjF9CkV4dVBHUDBi |
| 1231 | 1445 |
YkxrUVdvWnV5ZUJQRy8xdmM2MCt6MThOa3BsS09ydFAvUTVnQmxkYVpIOG10dTE5VWZFNGdGRGRj |
| ... | ... |
@@ -1267,6 +1481,16 @@ LmtleXNQSwECHgMKAAAAAABDj0lZ77OVHxcCAAAXAgAAAgAAAAAAAAAAAAAApIEJAQAAMGNQSwEC |
| 1267 | 1481 |
HgMKAAAAAABDj0lZGk9LVj8CAAA/AgAAAgAAAAAAAAAAAAAApIFAAwAAMTRQSwECHgMKAAAAAABD |
| 1268 | 1482 |
j0lZUkzxBhcCAAAXAgAAAgAAAAAAAAAAAAAApIGfBQAAMTZQSwUGAAAAAAQABADDAAAA1gcAAAAA |
| 1269 | 1483 |
""" |
| 1484 |
+""" |
|
| 1485 |
+A sample corrupted storeroom archive, encrypted with |
|
| 1486 |
+[`VAULT_MASTER_KEY`][]. The configuration is compressed (zip archive) |
|
| 1487 |
+and then encoded in base64. |
|
| 1488 |
+ |
|
| 1489 |
+The archive contains a directory `/services/array/` whose list of child |
|
| 1490 |
+items are not all valid item names. See |
|
| 1491 |
+[`_VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED3_JAVASCRIPT_SOURCE`][] for |
|
| 1492 |
+the exact script that created this archive. |
|
| 1493 |
+""" |
|
| 1270 | 1494 |
|
| 1271 | 1495 |
_VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED4_JAVASCRIPT_SOURCE = """ |
| 1272 | 1496 |
// Executed in the top-level directory of the vault project code, in Node.js. |
| ... | ... |
@@ -1277,6 +1501,10 @@ await store._storeroom.put('/dir/subdir/', [])
|
| 1277 | 1501 |
await store._storeroom.put('/dir/', [])
|
| 1278 | 1502 |
// The resulting "broken-dir" was then zipped manually. |
| 1279 | 1503 |
""" |
| 1504 |
+""" |
|
| 1505 |
+The JavaScript source for the script that generated the storeroom |
|
| 1506 |
+archive in [`VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED4`][]. |
|
| 1507 |
+""" |
|
| 1280 | 1508 |
VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED4 = b""" |
| 1281 | 1509 |
UEsDBAoAAAAAAE+5SVloORS+5gAAAOYAAAAFAAAALmtleXN7InZlcnNpb24iOjF9CkV6dWRoNkRQ |
| 1282 | 1510 |
YTlNSWFabHZ5TytVYTFuamhjV2hIaTFBU0lKYW5zcXBxVlA0blN2V0twUzdZOUc2bjFSbi8vUnVM |
| ... | ... |
@@ -1318,24 +1546,73 @@ Weut7lIXAgAAFwIAAAIAAAAAAAAAAAAAAKSBCQEAADAzUEsBAh4DCgAAAAAAT7lJWUV5MuArAgAA |
| 1318 | 1546 |
KwIAAAIAAAAAAAAAAAAAAKSBQAMAADEwUEsBAh4DCgAAAAAAT7lJWQ98rH0XAgAAFwIAAAIAAAAA |
| 1319 | 1547 |
AAAAAAAAAKSBiwUAADFkUEsFBgAAAAAEAAQAwwAAAMIHAAAAAA== |
| 1320 | 1548 |
""" |
| 1549 |
+""" |
|
| 1550 |
+A sample corrupted storeroom archive, encrypted with |
|
| 1551 |
+[`VAULT_MASTER_KEY`][]. The configuration is compressed (zip archive) |
|
| 1552 |
+and then encoded in base64. |
|
| 1553 |
+ |
|
| 1554 |
+The archive contains two directories `/dir/` and `/dir/subdir/`, where |
|
| 1555 |
+`/dir/subdir/` is a correctly serialized directory, but `/dir/` does not |
|
| 1556 |
+contain `/dir/subdir/` in its list of child items. See |
|
| 1557 |
+[`_VAULT_STOREROOM_BROKEN_DIR_CONFIG_ZIPPED4_JAVASCRIPT_SOURCE`][] for |
|
| 1558 |
+the exact script that created this archive. |
|
| 1559 |
+""" |
|
| 1321 | 1560 |
|
| 1322 | 1561 |
CANNOT_LOAD_CRYPTOGRAPHY = ( |
| 1323 | 1562 |
"Cannot load the required Python module 'cryptography'." |
| 1324 | 1563 |
) |
| 1564 |
+""" |
|
| 1565 |
+The expected `derivepassphrase` error message when the `cryptography` |
|
| 1566 |
+module cannot be loaded, which is needed e.g. by the `export vault` |
|
| 1567 |
+subcommands. |
|
| 1568 |
+""" |
|
| 1325 | 1569 |
|
| 1326 | 1570 |
skip_if_cryptography_support = pytest.mark.skipif( |
| 1327 | 1571 |
importlib.util.find_spec('cryptography') is not None,
|
| 1328 | 1572 |
reason='cryptography support available; cannot test "no support" scenario', |
| 1329 | 1573 |
) |
| 1574 |
+""" |
|
| 1575 |
+A cached pytest mark to skip this test if cryptography support is |
|
| 1576 |
+available. Usually this means that the test targets |
|
| 1577 |
+`derivepassphrase`'s fallback functionality, which is not available |
|
| 1578 |
+whenever the primary functionality is. |
|
| 1579 |
+""" |
|
| 1330 | 1580 |
skip_if_no_cryptography_support = pytest.mark.skipif( |
| 1331 | 1581 |
importlib.util.find_spec('cryptography') is None,
|
| 1332 | 1582 |
reason='no "cryptography" support', |
| 1333 | 1583 |
) |
| 1584 |
+""" |
|
| 1585 |
+A cached pytest mark to skip this test if cryptography support is not |
|
| 1586 |
+available. Usually this means that the test targets the |
|
| 1587 |
+`derivepassphrase export vault` subcommand, whose functionality depends |
|
| 1588 |
+on cryptography support being available. |
|
| 1589 |
+""" |
|
| 1334 | 1590 |
|
| 1335 | 1591 |
|
| 1336 | 1592 |
def hypothesis_settings_coverage_compatible( |
| 1337 | 1593 |
f: Any = None, |
| 1338 | 1594 |
) -> Any: |
| 1595 |
+ """Return (or decorate `f` with) coverage-friendly hypothesis settings. |
|
| 1596 |
+ |
|
| 1597 |
+ Specifically, we increase the deadline 40-fold if we detect we are |
|
| 1598 |
+ running under coverage testing, because the slow Python trace |
|
| 1599 |
+ function (necessary on PyPy) drastically increases runtime for |
|
| 1600 |
+ hypothesis tests. |
|
| 1601 |
+ |
|
| 1602 |
+ In any case, we *also* reduce the state machine step count to 32 |
|
| 1603 |
+ steps per run, because the current state machines defined in the |
|
| 1604 |
+ tests rather benefit from broad testing rather than deep testing. |
|
| 1605 |
+ |
|
| 1606 |
+ Args: |
|
| 1607 |
+ f: |
|
| 1608 |
+ An optional object to decorate with these settings. |
|
| 1609 |
+ |
|
| 1610 |
+ Returns: |
|
| 1611 |
+ The modified hypothesis settings, as a settings object. If |
|
| 1612 |
+ decorating a function/class, return that function/class |
|
| 1613 |
+ directly, after decorating. |
|
| 1614 |
+ |
|
| 1615 |
+ """ |
|
| 1339 | 1616 |
settings = ( |
| 1340 | 1617 |
hypothesis.settings( |
| 1341 | 1618 |
# Running under coverage with the Python tracer increases |
| ... | ... |
@@ -1362,6 +1639,22 @@ def hypothesis_settings_coverage_compatible( |
| 1362 | 1639 |
def hypothesis_settings_coverage_compatible_with_caplog( |
| 1363 | 1640 |
f: Any = None, |
| 1364 | 1641 |
) -> Any: |
| 1642 |
+ """Return (or decorate `f` with) coverage-friendly hypothesis settings. |
|
| 1643 |
+ |
|
| 1644 |
+ This variant of [`hypothesis_settings_coverage_compatible`][] does |
|
| 1645 |
+ all the same, and additionally disables the check for function |
|
| 1646 |
+ scoped pytest fixtures such as `caplog`. |
|
| 1647 |
+ |
|
| 1648 |
+ Args: |
|
| 1649 |
+ f: |
|
| 1650 |
+ An optional object to decorate with these settings. |
|
| 1651 |
+ |
|
| 1652 |
+ Returns: |
|
| 1653 |
+ The modified hypothesis settings, as a settings object. If |
|
| 1654 |
+ decorating a function/class, return that function/class |
|
| 1655 |
+ directly, after decorating. |
|
| 1656 |
+ |
|
| 1657 |
+ """ |
|
| 1365 | 1658 |
parent_settings = hypothesis_settings_coverage_compatible() |
| 1366 | 1659 |
settings = hypothesis.settings( |
| 1367 | 1660 |
parent=parent_settings, |
| ... | ... |
@@ -1374,6 +1667,12 @@ def hypothesis_settings_coverage_compatible_with_caplog( |
| 1374 | 1667 |
|
| 1375 | 1668 |
|
| 1376 | 1669 |
def list_keys(self: Any = None) -> list[_types.SSHKeyCommentPair]: |
| 1670 |
+ """Return a list of all SSH test keys, as key/comment pairs. |
|
| 1671 |
+ |
|
| 1672 |
+ Intended as a monkeypatching replacement for |
|
| 1673 |
+ [`ssh_agent.SSHAgentClient.list_keys`][]. |
|
| 1674 |
+ |
|
| 1675 |
+ """ |
|
| 1377 | 1676 |
del self # Unused. |
| 1378 | 1677 |
Pair = _types.SSHKeyCommentPair # noqa: N806 |
| 1379 | 1678 |
return [ |
| ... | ... |
@@ -1385,6 +1684,15 @@ def list_keys(self: Any = None) -> list[_types.SSHKeyCommentPair]: |
| 1385 | 1684 |
def sign( |
| 1386 | 1685 |
self: Any, key: bytes | bytearray, message: bytes | bytearray |
| 1387 | 1686 |
) -> bytes: |
| 1687 |
+ """Return the signature of `message` under `key`. |
|
| 1688 |
+ |
|
| 1689 |
+ Can only handle keys in [`SUPPORTED_KEYS`][], and only the vault |
|
| 1690 |
+ UUID as the message. |
|
| 1691 |
+ |
|
| 1692 |
+ Intended as a monkeypatching replacement for |
|
| 1693 |
+ [`ssh_agent.SSHAgentClient.sign`][]. |
|
| 1694 |
+ |
|
| 1695 |
+ """ |
|
| 1388 | 1696 |
del self # Unused. |
| 1389 | 1697 |
assert message == vault.Vault._UUID |
| 1390 | 1698 |
for value in SUPPORTED_KEYS.values(): |
| ... | ... |
@@ -1395,6 +1703,14 @@ def sign( |
| 1395 | 1703 |
|
| 1396 | 1704 |
|
| 1397 | 1705 |
def list_keys_singleton(self: Any = None) -> list[_types.SSHKeyCommentPair]: |
| 1706 |
+ """Return a singleton list of the first supported SSH test key. |
|
| 1707 |
+ |
|
| 1708 |
+ The key is returned as a key/comment pair. |
|
| 1709 |
+ |
|
| 1710 |
+ Intended as a monkeypatching replacement for |
|
| 1711 |
+ [`ssh_agent.SSHAgentClient.list_keys`][]. |
|
| 1712 |
+ |
|
| 1713 |
+ """ |
|
| 1398 | 1714 |
del self # Unused. |
| 1399 | 1715 |
Pair = _types.SSHKeyCommentPair # noqa: N806 |
| 1400 | 1716 |
list1 = [ |
| ... | ... |
@@ -1405,6 +1721,14 @@ def list_keys_singleton(self: Any = None) -> list[_types.SSHKeyCommentPair]: |
| 1405 | 1721 |
|
| 1406 | 1722 |
|
| 1407 | 1723 |
def suitable_ssh_keys(conn: Any) -> Iterator[_types.SSHKeyCommentPair]: |
| 1724 |
+ """Return a two-item list of SSH test keys (key/comment pairs). |
|
| 1725 |
+ |
|
| 1726 |
+ Intended as a monkeypatching replacement for |
|
| 1727 |
+ `cli._get_suitable_ssh_keys` to better script and test the |
|
| 1728 |
+ interactive key selection. When used this way, `derivepassphrase` |
|
| 1729 |
+ believes that only those two keys are loaded and suitable. |
|
| 1730 |
+ |
|
| 1731 |
+ """ |
|
| 1408 | 1732 |
del conn # Unused. |
| 1409 | 1733 |
Pair = _types.SSHKeyCommentPair # noqa: N806 |
| 1410 | 1734 |
yield from [ |
| ... | ... |
@@ -1419,6 +1743,15 @@ def phrase_from_key( |
| 1419 | 1743 |
*, |
| 1420 | 1744 |
conn: ssh_agent.SSHAgentClient | socket.socket | None = None, |
| 1421 | 1745 |
) -> bytes: |
| 1746 |
+ """Return the "equivalent master passphrase" for key. |
|
| 1747 |
+ |
|
| 1748 |
+ Only works for key [`DUMMY_KEY1`][]. |
|
| 1749 |
+ |
|
| 1750 |
+ Intended as a monkeypatching replacement for |
|
| 1751 |
+ [`vault.Vault.phrase_from_key`][], bypassing communication with an |
|
| 1752 |
+ actual SSH agent. |
|
| 1753 |
+ |
|
| 1754 |
+ """ |
|
| 1422 | 1755 |
del conn |
| 1423 | 1756 |
if key == DUMMY_KEY1: # pragma: no branch |
| 1424 | 1757 |
return DUMMY_PHRASE_FROM_KEY1 |
| ... | ... |
@@ -1431,6 +1764,28 @@ def isolated_config( |
| 1431 | 1764 |
runner: click.testing.CliRunner, |
| 1432 | 1765 |
main_config_str: str | None = None, |
| 1433 | 1766 |
) -> Iterator[None]: |
| 1767 |
+ """Provide an isolated configuration setup, as a context. |
|
| 1768 |
+ |
|
| 1769 |
+ This context manager sets up (and changes into) a temporary |
|
| 1770 |
+ directory, which holds the user configuration specified in |
|
| 1771 |
+ `main_config_str`, if any. The manager also ensures that the |
|
| 1772 |
+ environment variables `HOME` and `USERPROFILE` are set, and that |
|
| 1773 |
+ `DERIVEPASSPHRASE_PATH` is unset. Upon exiting the context, the |
|
| 1774 |
+ changes are undone and the temporary directory is removed. |
|
| 1775 |
+ |
|
| 1776 |
+ Args: |
|
| 1777 |
+ monkeypatch: |
|
| 1778 |
+ A monkeypatch fixture object. |
|
| 1779 |
+ runner: |
|
| 1780 |
+ A `click` CLI runner harness. |
|
| 1781 |
+ main_config_str: |
|
| 1782 |
+ Optional TOML file contents, to be used as the user |
|
| 1783 |
+ configuration. |
|
| 1784 |
+ |
|
| 1785 |
+ Returns: |
|
| 1786 |
+ A context manager, without a return value. |
|
| 1787 |
+ |
|
| 1788 |
+ """ |
|
| 1434 | 1789 |
prog_name = cli.PROG_NAME |
| 1435 | 1790 |
env_name = prog_name.replace(' ', '_').upper() + '_PATH'
|
| 1436 | 1791 |
# TODO(the-13th-letter): Rewrite using parenthesized with-statements. |
| ... | ... |
@@ -1461,6 +1816,28 @@ def isolated_vault_config( |
| 1461 | 1816 |
vault_config: Any, |
| 1462 | 1817 |
main_config_str: str | None = None, |
| 1463 | 1818 |
) -> Iterator[None]: |
| 1819 |
+ """Provide an isolated vault configuration setup, as a context. |
|
| 1820 |
+ |
|
| 1821 |
+ Uses [`isolated_config`][] internally. Beyond those actions, this |
|
| 1822 |
+ manager also loads the specified vault configuration into the |
|
| 1823 |
+ context. |
|
| 1824 |
+ |
|
| 1825 |
+ Args: |
|
| 1826 |
+ monkeypatch: |
|
| 1827 |
+ A monkeypatch fixture object. |
|
| 1828 |
+ runner: |
|
| 1829 |
+ A `click` CLI runner harness. |
|
| 1830 |
+ vault_config: |
|
| 1831 |
+ A valid vault configuration, to be integrated into the |
|
| 1832 |
+ context. |
|
| 1833 |
+ main_config_str: |
|
| 1834 |
+ Optional TOML file contents, to be used as the user |
|
| 1835 |
+ configuration. |
|
| 1836 |
+ |
|
| 1837 |
+ Returns: |
|
| 1838 |
+ A context manager, without a return value. |
|
| 1839 |
+ |
|
| 1840 |
+ """ |
|
| 1464 | 1841 |
with isolated_config( |
| 1465 | 1842 |
monkeypatch=monkeypatch, runner=runner, main_config_str=main_config_str |
| 1466 | 1843 |
): |
| ... | ... |
@@ -1477,6 +1854,35 @@ def isolated_vault_exporter_config( |
| 1477 | 1854 |
vault_config: str | bytes | None = None, |
| 1478 | 1855 |
vault_key: str | None = None, |
| 1479 | 1856 |
) -> Iterator[None]: |
| 1857 |
+ """Provide an isolated vault configuration setup, as a context. |
|
| 1858 |
+ |
|
| 1859 |
+ Works similarly to [`isolated_config`][], except that no user |
|
| 1860 |
+ configuration is accepted or integrated into the context. This |
|
| 1861 |
+ manager also accepts a serialized vault-native configuration and |
|
| 1862 |
+ a vault encryption key to integrate into the context. |
|
| 1863 |
+ |
|
| 1864 |
+ Args: |
|
| 1865 |
+ monkeypatch: |
|
| 1866 |
+ A monkeypatch fixture object. |
|
| 1867 |
+ runner: |
|
| 1868 |
+ A `click` CLI runner harness. |
|
| 1869 |
+ vault_config: |
|
| 1870 |
+ An optional serialized vault-native configuration, to be |
|
| 1871 |
+ integrated into the context. If a text string, then the |
|
| 1872 |
+ contents are written to the file `.vault`. If a byte |
|
| 1873 |
+ string, then it is treated as base64-encoded zip file |
|
| 1874 |
+ contents, which---once inside the `.vault` directory---will |
|
| 1875 |
+ be extracted into the current directory. |
|
| 1876 |
+ vault_key: |
|
| 1877 |
+ An optional encryption key presumably for the stored |
|
| 1878 |
+ vault-native configuration. If given, then the environment |
|
| 1879 |
+ variable `VAULT_KEY` will be populated with this key while |
|
| 1880 |
+ the context is active. |
|
| 1881 |
+ |
|
| 1882 |
+ Returns: |
|
| 1883 |
+ A context manager, without a return value. |
|
| 1884 |
+ |
|
| 1885 |
+ """ |
|
| 1480 | 1886 |
# TODO(the-13th-letter): Remove the fallback implementation. |
| 1481 | 1887 |
# https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.10 |
| 1482 | 1888 |
if TYPE_CHECKING: |
| ... | ... |
@@ -1535,6 +1941,13 @@ def isolated_vault_exporter_config( |
| 1535 | 1941 |
|
| 1536 | 1942 |
|
| 1537 | 1943 |
def auto_prompt(*args: Any, **kwargs: Any) -> str: |
| 1944 |
+ """Return [`DUMMY_PASSPHRASE`][]. |
|
| 1945 |
+ |
|
| 1946 |
+ Intended as a monkeypatching replacement for |
|
| 1947 |
+ `cli.prompt_for_passphrase` to better script and test the |
|
| 1948 |
+ interactive passphrase queries. |
|
| 1949 |
+ |
|
| 1950 |
+ """ |
|
| 1538 | 1951 |
del args, kwargs # Unused. |
| 1539 | 1952 |
return DUMMY_PASSPHRASE |
| 1540 | 1953 |
|
| ... | ... |
@@ -1601,6 +2014,7 @@ class ReadableResult(NamedTuple): |
| 1601 | 2014 |
|
| 1602 | 2015 |
@classmethod |
| 1603 | 2016 |
def parse(cls, r: click.testing.Result, /) -> Self: |
| 2017 |
+ """Return a readable result object, given a result.""" |
|
| 1604 | 2018 |
try: |
| 1605 | 2019 |
stderr = r.stderr |
| 1606 | 2020 |
except ValueError: |
| ... | ... |
@@ -1672,6 +2086,29 @@ class ReadableResult(NamedTuple): |
| 1672 | 2086 |
|
| 1673 | 2087 |
|
| 1674 | 2088 |
def parse_sh_export_line(line: str, *, env_name: str) -> str: |
| 2089 |
+ """Parse the output of typical SSH agents' SSH_AUTH_SOCK lines. |
|
| 2090 |
+ |
|
| 2091 |
+ Intentionally parses only a small subset of sh(1) syntax which works |
|
| 2092 |
+ with current OpenSSH and PuTTY output. We require exactly one |
|
| 2093 |
+ variable setting, and one export instruction, both on the same line, |
|
| 2094 |
+ and perhaps combined into one statement. Terminating semicolons |
|
| 2095 |
+ after each command are ignored. |
|
| 2096 |
+ |
|
| 2097 |
+ Args: |
|
| 2098 |
+ line: |
|
| 2099 |
+ A line of sh(1) script to parse. |
|
| 2100 |
+ env_name: |
|
| 2101 |
+ The name of the environment variable to expect. |
|
| 2102 |
+ |
|
| 2103 |
+ Returns: |
|
| 2104 |
+ The parsed environment variable value. |
|
| 2105 |
+ |
|
| 2106 |
+ Raises: |
|
| 2107 |
+ ValueError: |
|
| 2108 |
+ Cannot parse the sh script. Perhaps it is too complex, |
|
| 2109 |
+ perhaps it is malformed. |
|
| 2110 |
+ |
|
| 2111 |
+ """ |
|
| 1675 | 2112 |
line = line.rstrip('\r\n')
|
| 1676 | 2113 |
shlex_parser = shlex.shlex( |
| 1677 | 2114 |
instream=line, posix=True, punctuation_chars=True |
| ... | ... |
@@ -70,7 +70,28 @@ class SpawnFunc(Protocol): |
| 70 | 70 |
self, |
| 71 | 71 |
executable: str | None, |
| 72 | 72 |
env: dict[str, str], |
| 73 |
- ) -> subprocess.Popen[str] | None: ... |
|
| 73 |
+ ) -> subprocess.Popen[str] | None: |
|
| 74 |
+ """Spawn the SSH agent. |
|
| 75 |
+ |
|
| 76 |
+ Args: |
|
| 77 |
+ executable: |
|
| 78 |
+ The respective SSH agent executable. |
|
| 79 |
+ env: |
|
| 80 |
+ The new environment for the respective agent. Should |
|
| 81 |
+ typically not include an SSH_AUTH_SOCK variable. |
|
| 82 |
+ |
|
| 83 |
+ Returns: |
|
| 84 |
+ The spawned SSH agent subprocess. If the executable is |
|
| 85 |
+ `None`, then return `None` directly. |
|
| 86 |
+ |
|
| 87 |
+ It is the caller's responsibility to clean up the spawned |
|
| 88 |
+ subprocess. |
|
| 89 |
+ |
|
| 90 |
+ Raises: |
|
| 91 |
+ OSError: |
|
| 92 |
+ The [`subprocess.Popen`][] call failed. See there. |
|
| 93 |
+ |
|
| 94 |
+ """ |
|
| 74 | 95 |
|
| 75 | 96 |
|
| 76 | 97 |
def _spawn_pageant( # pragma: no cover |
| ... | ... |
@@ -190,12 +211,27 @@ _spawn_handlers = [ |
| 190 | 211 |
('ssh-agent', _spawn_openssh_agent, tests.KnownSSHAgent.OpenSSHAgent),
|
| 191 | 212 |
('(system)', _spawn_noop, tests.KnownSSHAgent.UNKNOWN),
|
| 192 | 213 |
] |
| 214 |
+""" |
|
| 215 |
+The standard registry of agent spawning functions. |
|
| 216 |
+""" |
|
| 193 | 217 |
|
| 194 | 218 |
Popen = TypeVar('Popen', bound=subprocess.Popen)
|
| 195 | 219 |
|
| 196 | 220 |
|
| 197 | 221 |
@contextlib.contextmanager |
| 198 | 222 |
def _terminate_on_exit(proc: Popen) -> Iterator[Popen]: |
| 223 |
+ """Terminate and wait for the subprocess upon exiting the context. |
|
| 224 |
+ |
|
| 225 |
+ Args: |
|
| 226 |
+ proc: |
|
| 227 |
+ The subprocess to manage. |
|
| 228 |
+ |
|
| 229 |
+ Returns: |
|
| 230 |
+ A context manager. Upon entering the manager, return the |
|
| 231 |
+ managed subprocess. Upon exiting the manager, terminate the |
|
| 232 |
+ process and wait for it. |
|
| 233 |
+ |
|
| 234 |
+ """ |
|
| 199 | 235 |
try: |
| 200 | 236 |
yield proc |
| 201 | 237 |
finally: |
| ... | ... |
@@ -204,7 +240,7 @@ def _terminate_on_exit(proc: Popen) -> Iterator[Popen]: |
| 204 | 240 |
|
| 205 | 241 |
|
| 206 | 242 |
class CannotSpawnError(RuntimeError): |
| 207 |
- pass |
|
| 243 |
+ """Cannot spawn the SSH agent.""" |
|
| 208 | 244 |
|
| 209 | 245 |
|
| 210 | 246 |
def _spawn_named_agent( |
| ... | ... |
@@ -212,6 +248,41 @@ def _spawn_named_agent( |
| 212 | 248 |
spawn_func: SpawnFunc, |
| 213 | 249 |
agent_type: tests.KnownSSHAgent, |
| 214 | 250 |
) -> Iterator[tests.SpawnedSSHAgentInfo]: # pragma: no cover |
| 251 |
+ """Spawn the named SSH agent and check that it is operational. |
|
| 252 |
+ |
|
| 253 |
+ Using the correct agent-specific spawn function from the |
|
| 254 |
+ [`spawn_handlers`][] registry, spawn the named SSH agent (according |
|
| 255 |
+ to its declared type), then set up the communication channel and |
|
| 256 |
+ yield an SSH agent client connected to this agent. After resuming, |
|
| 257 |
+ tear down the communication channel and terminate the SSH agent. |
|
| 258 |
+ |
|
| 259 |
+ The SSH agent's instructions for setting up the communication |
|
| 260 |
+ channel are parsed with [`tests.parse_sh_export_line`][]. See the |
|
| 261 |
+ caveats there. |
|
| 262 |
+ |
|
| 263 |
+ Args: |
|
| 264 |
+ exec_name: |
|
| 265 |
+ The executable to spawn. |
|
| 266 |
+ spawn_func: |
|
| 267 |
+ The agent-specific spawn function. |
|
| 268 |
+ agent_type: |
|
| 269 |
+ The agent type. |
|
| 270 |
+ |
|
| 271 |
+ Yields: |
|
| 272 |
+ A 3-tuple containing the agent type, an SSH agent client |
|
| 273 |
+ connected to this agent, and a boolean indicating whether this |
|
| 274 |
+ agent was actually spawned in an isolated manner. |
|
| 275 |
+ |
|
| 276 |
+ Only one tuple will ever be yielded. After resuming, the |
|
| 277 |
+ connected client will be torn down, as will the agent if it was |
|
| 278 |
+ isolated. |
|
| 279 |
+ |
|
| 280 |
+ Raises: |
|
| 281 |
+ CannotSpawnError: |
|
| 282 |
+ We failed to spawn the agent or otherwise set up the |
|
| 283 |
+ environment/communication channel/etc. |
|
| 284 |
+ |
|
| 285 |
+ """ |
|
| 215 | 286 |
# pytest's fixture system does not seem to guarantee that |
| 216 | 287 |
# environment variables are set up correctly if nested and |
| 217 | 288 |
# parametrized fixtures are used: it is possible that "outer" |
| ... | ... |
@@ -242,6 +242,15 @@ def vault_config_exporter_shell_interpreter( # noqa: C901 |
| 242 | 242 |
command: click.BaseCommand | None = None, |
| 243 | 243 |
runner: click.testing.CliRunner | None = None, |
| 244 | 244 |
) -> Iterator[click.testing.Result]: |
| 245 |
+ """A rudimentary sh(1) interpreter for `--export-as=sh` output. |
|
| 246 |
+ |
|
| 247 |
+ Assumes a script as emitted by `derivepassphrase vault |
|
| 248 |
+ --export-as=sh --export -` and interprets the calls to |
|
| 249 |
+ `derivepassphrase vault` within. (One call per line, skips all |
|
| 250 |
+ other lines.) Also has rudimentary support for (quoted) |
|
| 251 |
+ here-documents using `HERE` as the marker. |
|
| 252 |
+ |
|
| 253 |
+ """ |
|
| 245 | 254 |
if isinstance(script, str): # pragma: no cover |
| 246 | 255 |
script = script.splitlines(False) |
| 247 | 256 |
if prog_name_list is None: # pragma: no cover |
| ... | ... |
@@ -285,6 +294,8 @@ def vault_config_exporter_shell_interpreter( # noqa: C901 |
| 285 | 294 |
|
| 286 | 295 |
|
| 287 | 296 |
class TestAllCLI: |
| 297 |
+ """Tests uniformly for all command-line interfaces.""" |
|
| 298 |
+ |
|
| 288 | 299 |
@pytest.mark.parametrize( |
| 289 | 300 |
['command', 'non_eager_arguments'], |
| 290 | 301 |
[ |
| ... | ... |
@@ -342,6 +353,7 @@ class TestAllCLI: |
| 342 | 353 |
arguments: list[str], |
| 343 | 354 |
non_eager_arguments: list[str], |
| 344 | 355 |
) -> None: |
| 356 |
+ """Eager options terminate option and argument processing.""" |
|
| 345 | 357 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 346 | 358 |
with tests.isolated_config( |
| 347 | 359 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -389,6 +401,7 @@ class TestAllCLI: |
| 389 | 401 |
command_line: list[str], |
| 390 | 402 |
input: str | None, |
| 391 | 403 |
) -> None: |
| 404 |
+ """Respect the `NO_COLOR` and `FORCE_COLOR` environment variables.""" |
|
| 392 | 405 |
# Force color on if force_color. Otherwise force color off if |
| 393 | 406 |
# no_color. Otherwise set color if and only if we have a TTY. |
| 394 | 407 |
color = force_color or not no_color if isatty else force_color |
| ... | ... |
@@ -420,10 +433,13 @@ class TestAllCLI: |
| 420 | 433 |
|
| 421 | 434 |
|
| 422 | 435 |
class TestCLI: |
| 436 |
+ """Tests for the `derivepassphrase vault` command-line interface.""" |
|
| 437 |
+ |
|
| 423 | 438 |
def test_200_help_output( |
| 424 | 439 |
self, |
| 425 | 440 |
monkeypatch: pytest.MonkeyPatch, |
| 426 | 441 |
) -> None: |
| 442 |
+ """The `--help` option emits help text.""" |
|
| 427 | 443 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 428 | 444 |
with tests.isolated_config( |
| 429 | 445 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -446,6 +462,7 @@ class TestCLI: |
| 446 | 462 |
self, |
| 447 | 463 |
monkeypatch: pytest.MonkeyPatch, |
| 448 | 464 |
) -> None: |
| 465 |
+ """The `--version` option emits version information.""" |
|
| 449 | 466 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 450 | 467 |
with tests.isolated_config( |
| 451 | 468 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -470,6 +487,7 @@ class TestCLI: |
| 470 | 487 |
def test_201_disable_character_set( |
| 471 | 488 |
self, monkeypatch: pytest.MonkeyPatch, charset_name: str |
| 472 | 489 |
) -> None: |
| 490 |
+ """Named character classes can be disabled on the command-line.""" |
|
| 473 | 491 |
monkeypatch.setattr(cli, '_prompt_for_passphrase', tests.auto_prompt) |
| 474 | 492 |
option = f'--{charset_name}'
|
| 475 | 493 |
charset = vault.Vault._CHARSETS[charset_name].decode('ascii')
|
| ... | ... |
@@ -494,6 +512,7 @@ class TestCLI: |
| 494 | 512 |
def test_202_disable_repetition( |
| 495 | 513 |
self, monkeypatch: pytest.MonkeyPatch |
| 496 | 514 |
) -> None: |
| 515 |
+ """Character repetition can be disabled on the command-line.""" |
|
| 497 | 516 |
monkeypatch.setattr(cli, '_prompt_for_passphrase', tests.auto_prompt) |
| 498 | 517 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 499 | 518 |
with tests.isolated_config( |
| ... | ... |
@@ -546,6 +565,7 @@ class TestCLI: |
| 546 | 565 |
monkeypatch: pytest.MonkeyPatch, |
| 547 | 566 |
config: _types.VaultConfig, |
| 548 | 567 |
) -> None: |
| 568 |
+ """A stored configured SSH key will be used.""" |
|
| 549 | 569 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 550 | 570 |
with tests.isolated_vault_config( |
| 551 | 571 |
monkeypatch=monkeypatch, runner=runner, vault_config=config |
| ... | ... |
@@ -573,6 +593,7 @@ class TestCLI: |
| 573 | 593 |
def test_204b_key_from_command_line( |
| 574 | 594 |
self, monkeypatch: pytest.MonkeyPatch |
| 575 | 595 |
) -> None: |
| 596 |
+ """An SSH key requested on the command-line will be used.""" |
|
| 576 | 597 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 577 | 598 |
with tests.isolated_vault_config( |
| 578 | 599 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -633,6 +654,7 @@ class TestCLI: |
| 633 | 654 |
config: dict[str, Any], |
| 634 | 655 |
key_index: int, |
| 635 | 656 |
) -> None: |
| 657 |
+ """A command-line SSH key will override the configured key.""" |
|
| 636 | 658 |
with monkeypatch.context(): |
| 637 | 659 |
monkeypatch.setenv('SSH_AUTH_SOCK', running_ssh_agent.socket)
|
| 638 | 660 |
monkeypatch.setattr( |
| ... | ... |
@@ -661,6 +683,7 @@ class TestCLI: |
| 661 | 683 |
monkeypatch: pytest.MonkeyPatch, |
| 662 | 684 |
running_ssh_agent: tests.RunningSSHAgentInfo, |
| 663 | 685 |
) -> None: |
| 686 |
+ """A command-line passphrase will override the configured key.""" |
|
| 664 | 687 |
with monkeypatch.context(): |
| 665 | 688 |
monkeypatch.setenv('SSH_AUTH_SOCK', running_ssh_agent.socket)
|
| 666 | 689 |
monkeypatch.setattr( |
| ... | ... |
@@ -738,6 +761,7 @@ class TestCLI: |
| 738 | 761 |
config: _types.VaultConfig, |
| 739 | 762 |
command_line: list[str], |
| 740 | 763 |
) -> None: |
| 764 |
+ """Configuring a passphrase atop an SSH key works, but warns.""" |
|
| 741 | 765 |
with monkeypatch.context(): |
| 742 | 766 |
monkeypatch.setenv('SSH_AUTH_SOCK', running_ssh_agent.socket)
|
| 743 | 767 |
monkeypatch.setattr( |
| ... | ... |
@@ -790,6 +814,7 @@ class TestCLI: |
| 790 | 814 |
def test_210_invalid_argument_range( |
| 791 | 815 |
self, monkeypatch: pytest.MonkeyPatch, option: str |
| 792 | 816 |
) -> None: |
| 817 |
+ """Requesting invalidly many characters from a class fails.""" |
|
| 793 | 818 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 794 | 819 |
with tests.isolated_config( |
| 795 | 820 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -829,6 +854,7 @@ class TestCLI: |
| 829 | 854 |
input: str | None, |
| 830 | 855 |
check_success: bool, |
| 831 | 856 |
) -> None: |
| 857 |
+ """We require or forbid a service argument, depending on options.""" |
|
| 832 | 858 |
monkeypatch.setattr(cli, '_prompt_for_passphrase', tests.auto_prompt) |
| 833 | 859 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 834 | 860 |
with tests.isolated_vault_config( |
| ... | ... |
@@ -879,6 +905,12 @@ class TestCLI: |
| 879 | 905 |
monkeypatch: pytest.MonkeyPatch, |
| 880 | 906 |
caplog: pytest.LogCaptureFixture, |
| 881 | 907 |
) -> None: |
| 908 |
+ """Using an empty service name (where permissible) warns. |
|
| 909 |
+ |
|
| 910 |
+ Only the `--config` option can optionally take a service name. |
|
| 911 |
+ |
|
| 912 |
+ """ |
|
| 913 |
+ |
|
| 882 | 914 |
def is_expected_warning(record: tuple[str, int, str]) -> bool: |
| 883 | 915 |
return is_harmless_config_import_warning( |
| 884 | 916 |
record |
| ... | ... |
@@ -940,6 +972,7 @@ class TestCLI: |
| 940 | 972 |
options: list[str], |
| 941 | 973 |
service: bool | None, |
| 942 | 974 |
) -> None: |
| 975 |
+ """Incompatible options are detected.""" |
|
| 943 | 976 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 944 | 977 |
with tests.isolated_config( |
| 945 | 978 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -970,6 +1003,7 @@ class TestCLI: |
| 970 | 1003 |
caplog: pytest.LogCaptureFixture, |
| 971 | 1004 |
config: Any, |
| 972 | 1005 |
) -> None: |
| 1006 |
+ """Importing a configuration works.""" |
|
| 973 | 1007 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 974 | 1008 |
with tests.isolated_vault_config( |
| 975 | 1009 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -1006,6 +1040,11 @@ class TestCLI: |
| 1006 | 1040 |
caplog: pytest.LogCaptureFixture, |
| 1007 | 1041 |
conf: tests.VaultTestConfig, |
| 1008 | 1042 |
) -> None: |
| 1043 |
+ """Importing a smudged configuration works. |
|
| 1044 |
+ |
|
| 1045 |
+ Tested via hypothesis. |
|
| 1046 |
+ |
|
| 1047 |
+ """ |
|
| 1009 | 1048 |
config = conf.config |
| 1010 | 1049 |
config2 = copy.deepcopy(config) |
| 1011 | 1050 |
_types.clean_up_falsy_vault_config_values(config2) |
| ... | ... |
@@ -1036,6 +1075,7 @@ class TestCLI: |
| 1036 | 1075 |
self, |
| 1037 | 1076 |
monkeypatch: pytest.MonkeyPatch, |
| 1038 | 1077 |
) -> None: |
| 1078 |
+ """Importing an invalid config fails.""" |
|
| 1039 | 1079 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 1040 | 1080 |
with tests.isolated_config(monkeypatch=monkeypatch, runner=runner): |
| 1041 | 1081 |
result_ = runner.invoke( |
| ... | ... |
@@ -1053,6 +1093,7 @@ class TestCLI: |
| 1053 | 1093 |
self, |
| 1054 | 1094 |
monkeypatch: pytest.MonkeyPatch, |
| 1055 | 1095 |
) -> None: |
| 1096 |
+ """Importing an invalid config fails.""" |
|
| 1056 | 1097 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 1057 | 1098 |
with tests.isolated_config(monkeypatch=monkeypatch, runner=runner): |
| 1058 | 1099 |
result_ = runner.invoke( |
| ... | ... |
@@ -1070,6 +1111,7 @@ class TestCLI: |
| 1070 | 1111 |
self, |
| 1071 | 1112 |
monkeypatch: pytest.MonkeyPatch, |
| 1072 | 1113 |
) -> None: |
| 1114 |
+ """Importing an invalid config fails.""" |
|
| 1073 | 1115 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 1074 | 1116 |
# `isolated_vault_config` validates the configuration. So, to |
| 1075 | 1117 |
# pass an actual broken configuration, we must open the |
| ... | ... |
@@ -1102,6 +1144,7 @@ class TestCLI: |
| 1102 | 1144 |
monkeypatch: pytest.MonkeyPatch, |
| 1103 | 1145 |
export_options: list[str], |
| 1104 | 1146 |
) -> None: |
| 1147 |
+ """Exporting the default, empty config works.""" |
|
| 1105 | 1148 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 1106 | 1149 |
with tests.isolated_config(monkeypatch=monkeypatch, runner=runner): |
| 1107 | 1150 |
cli._config_filename(subsystem='vault').unlink(missing_ok=True) |
| ... | ... |
@@ -1129,6 +1172,7 @@ class TestCLI: |
| 1129 | 1172 |
monkeypatch: pytest.MonkeyPatch, |
| 1130 | 1173 |
export_options: list[str], |
| 1131 | 1174 |
) -> None: |
| 1175 |
+ """Exporting an invalid config fails.""" |
|
| 1132 | 1176 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 1133 | 1177 |
with tests.isolated_vault_config( |
| 1134 | 1178 |
monkeypatch=monkeypatch, runner=runner, vault_config={}
|
| ... | ... |
@@ -1156,6 +1200,7 @@ class TestCLI: |
| 1156 | 1200 |
monkeypatch: pytest.MonkeyPatch, |
| 1157 | 1201 |
export_options: list[str], |
| 1158 | 1202 |
) -> None: |
| 1203 |
+ """Exporting an invalid config fails.""" |
|
| 1159 | 1204 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 1160 | 1205 |
with tests.isolated_config(monkeypatch=monkeypatch, runner=runner): |
| 1161 | 1206 |
config_file = cli._config_filename(subsystem='vault') |
| ... | ... |
@@ -1184,6 +1229,7 @@ class TestCLI: |
| 1184 | 1229 |
monkeypatch: pytest.MonkeyPatch, |
| 1185 | 1230 |
export_options: list[str], |
| 1186 | 1231 |
) -> None: |
| 1232 |
+ """Exporting an invalid config fails.""" |
|
| 1187 | 1233 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 1188 | 1234 |
with tests.isolated_config(monkeypatch=monkeypatch, runner=runner): |
| 1189 | 1235 |
dname = cli._config_filename(subsystem=None) |
| ... | ... |
@@ -1210,6 +1256,7 @@ class TestCLI: |
| 1210 | 1256 |
monkeypatch: pytest.MonkeyPatch, |
| 1211 | 1257 |
export_options: list[str], |
| 1212 | 1258 |
) -> None: |
| 1259 |
+ """Exporting an invalid config fails.""" |
|
| 1213 | 1260 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 1214 | 1261 |
with tests.isolated_config(monkeypatch=monkeypatch, runner=runner): |
| 1215 | 1262 |
config_dir = cli._config_filename(subsystem=None) |
| ... | ... |
@@ -1232,6 +1279,7 @@ class TestCLI: |
| 1232 | 1279 |
def test_220_edit_notes_successfully( |
| 1233 | 1280 |
self, monkeypatch: pytest.MonkeyPatch |
| 1234 | 1281 |
) -> None: |
| 1282 |
+ """Editing notes works.""" |
|
| 1235 | 1283 |
edit_result = """ |
| 1236 | 1284 |
|
| 1237 | 1285 |
# - - - - - >8 - - - - - >8 - - - - - >8 - - - - - >8 - - - - - |
| ... | ... |
@@ -1263,6 +1311,7 @@ contents go here |
| 1263 | 1311 |
def test_221_edit_notes_noop( |
| 1264 | 1312 |
self, monkeypatch: pytest.MonkeyPatch |
| 1265 | 1313 |
) -> None: |
| 1314 |
+ """Abandoning edited notes works.""" |
|
| 1266 | 1315 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 1267 | 1316 |
with tests.isolated_vault_config( |
| 1268 | 1317 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -1283,9 +1332,16 @@ contents go here |
| 1283 | 1332 |
config = json.load(infile) |
| 1284 | 1333 |
assert config == {'global': {'phrase': 'abc'}, 'services': {}}
|
| 1285 | 1334 |
|
| 1335 |
+ # TODO(the-13th-letter): Keep this behavior or not, with or without |
|
| 1336 |
+ # warning? |
|
| 1286 | 1337 |
def test_222_edit_notes_marker_removed( |
| 1287 | 1338 |
self, monkeypatch: pytest.MonkeyPatch |
| 1288 | 1339 |
) -> None: |
| 1340 |
+ """Removing the notes marker still saves the notes. |
|
| 1341 |
+ |
|
| 1342 |
+ TODO: Keep this behavior or not, with or without warning? |
|
| 1343 |
+ |
|
| 1344 |
+ """ |
|
| 1289 | 1345 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 1290 | 1346 |
with tests.isolated_vault_config( |
| 1291 | 1347 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -1312,6 +1368,7 @@ contents go here |
| 1312 | 1368 |
def test_223_edit_notes_abort( |
| 1313 | 1369 |
self, monkeypatch: pytest.MonkeyPatch |
| 1314 | 1370 |
) -> None: |
| 1371 |
+ """Aborting editing notes works.""" |
|
| 1315 | 1372 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 1316 | 1373 |
with tests.isolated_vault_config( |
| 1317 | 1374 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -1388,6 +1445,7 @@ contents go here |
| 1388 | 1445 |
input: str, |
| 1389 | 1446 |
result_config: Any, |
| 1390 | 1447 |
) -> None: |
| 1448 |
+ """Storing valid settings via `--config` works.""" |
|
| 1391 | 1449 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 1392 | 1450 |
with tests.isolated_vault_config( |
| 1393 | 1451 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -1449,6 +1507,7 @@ contents go here |
| 1449 | 1507 |
input: str, |
| 1450 | 1508 |
err_text: str, |
| 1451 | 1509 |
) -> None: |
| 1510 |
+ """Storing invalid settings via `--config` fails.""" |
|
| 1452 | 1511 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 1453 | 1512 |
with tests.isolated_vault_config( |
| 1454 | 1513 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -1473,6 +1532,7 @@ contents go here |
| 1473 | 1532 |
self, |
| 1474 | 1533 |
monkeypatch: pytest.MonkeyPatch, |
| 1475 | 1534 |
) -> None: |
| 1535 |
+ """Not selecting an SSH key during `--config --key` fails.""" |
|
| 1476 | 1536 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 1477 | 1537 |
with tests.isolated_vault_config( |
| 1478 | 1538 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -1500,6 +1560,7 @@ contents go here |
| 1500 | 1560 |
monkeypatch: pytest.MonkeyPatch, |
| 1501 | 1561 |
skip_if_no_af_unix_support: None, |
| 1502 | 1562 |
) -> None: |
| 1563 |
+ """Not running an SSH agent during `--config --key` fails.""" |
|
| 1503 | 1564 |
del skip_if_no_af_unix_support |
| 1504 | 1565 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 1505 | 1566 |
with tests.isolated_vault_config( |
| ... | ... |
@@ -1522,6 +1583,7 @@ contents go here |
| 1522 | 1583 |
self, |
| 1523 | 1584 |
monkeypatch: pytest.MonkeyPatch, |
| 1524 | 1585 |
) -> None: |
| 1586 |
+ """Not running a reachable SSH agent during `--config --key` fails.""" |
|
| 1525 | 1587 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 1526 | 1588 |
with tests.isolated_vault_config( |
| 1527 | 1589 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -1546,6 +1608,7 @@ contents go here |
| 1546 | 1608 |
monkeypatch: pytest.MonkeyPatch, |
| 1547 | 1609 |
try_race_free_implementation: bool, |
| 1548 | 1610 |
) -> None: |
| 1611 |
+ """Using a read-only configuration file with `--config` fails.""" |
|
| 1549 | 1612 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 1550 | 1613 |
with tests.isolated_vault_config( |
| 1551 | 1614 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -1570,6 +1633,7 @@ contents go here |
| 1570 | 1633 |
self, |
| 1571 | 1634 |
monkeypatch: pytest.MonkeyPatch, |
| 1572 | 1635 |
) -> None: |
| 1636 |
+ """OS-erroring with `--config` fails.""" |
|
| 1573 | 1637 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 1574 | 1638 |
with tests.isolated_vault_config( |
| 1575 | 1639 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -1597,6 +1661,7 @@ contents go here |
| 1597 | 1661 |
self, |
| 1598 | 1662 |
monkeypatch: pytest.MonkeyPatch, |
| 1599 | 1663 |
) -> None: |
| 1664 |
+ """Issuing conflicting settings to `--config` fails.""" |
|
| 1600 | 1665 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 1601 | 1666 |
with tests.isolated_vault_config( |
| 1602 | 1667 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -1624,6 +1689,7 @@ contents go here |
| 1624 | 1689 |
monkeypatch: pytest.MonkeyPatch, |
| 1625 | 1690 |
running_ssh_agent: tests.RunningSSHAgentInfo, |
| 1626 | 1691 |
) -> None: |
| 1692 |
+ """Not holding any SSH keys during `--config --key` fails.""" |
|
| 1627 | 1693 |
del running_ssh_agent |
| 1628 | 1694 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 1629 | 1695 |
with tests.isolated_vault_config( |
| ... | ... |
@@ -1654,6 +1720,7 @@ contents go here |
| 1654 | 1720 |
monkeypatch: pytest.MonkeyPatch, |
| 1655 | 1721 |
running_ssh_agent: tests.RunningSSHAgentInfo, |
| 1656 | 1722 |
) -> None: |
| 1723 |
+ """The SSH agent erroring during `--config --key` fails.""" |
|
| 1657 | 1724 |
del running_ssh_agent |
| 1658 | 1725 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 1659 | 1726 |
with tests.isolated_vault_config( |
| ... | ... |
@@ -1681,6 +1748,7 @@ contents go here |
| 1681 | 1748 |
monkeypatch: pytest.MonkeyPatch, |
| 1682 | 1749 |
running_ssh_agent: tests.RunningSSHAgentInfo, |
| 1683 | 1750 |
) -> None: |
| 1751 |
+ """The SSH agent refusing during `--config --key` fails.""" |
|
| 1684 | 1752 |
del running_ssh_agent |
| 1685 | 1753 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 1686 | 1754 |
with tests.isolated_vault_config( |
| ... | ... |
@@ -1706,6 +1774,7 @@ contents go here |
| 1706 | 1774 |
) |
| 1707 | 1775 |
|
| 1708 | 1776 |
def test_226_no_arguments(self, monkeypatch: pytest.MonkeyPatch) -> None: |
| 1777 |
+ """Calling `derivepassphrase vault` without any arguments fails.""" |
|
| 1709 | 1778 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 1710 | 1779 |
with tests.isolated_config( |
| 1711 | 1780 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -1722,6 +1791,7 @@ contents go here |
| 1722 | 1791 |
def test_226a_no_passphrase_or_key( |
| 1723 | 1792 |
self, monkeypatch: pytest.MonkeyPatch |
| 1724 | 1793 |
) -> None: |
| 1794 |
+ """Deriving a passphrase without a passphrase or key fails.""" |
|
| 1725 | 1795 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 1726 | 1796 |
with tests.isolated_config( |
| 1727 | 1797 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -1740,7 +1810,13 @@ contents go here |
| 1740 | 1810 |
def test_230_config_directory_nonexistant( |
| 1741 | 1811 |
self, monkeypatch: pytest.MonkeyPatch |
| 1742 | 1812 |
) -> None: |
| 1743 |
- """https://github.com/the-13th-letter/derivepassphrase/issues/6""" |
|
| 1813 |
+ """Running without an existing config directory works. |
|
| 1814 |
+ |
|
| 1815 |
+ This is a regression test; see [issue\u00a0#6][] for context. |
|
| 1816 |
+ |
|
| 1817 |
+ [issue #6]: https://github.com/the-13th-letter/derivepassphrase/issues/6 |
|
| 1818 |
+ |
|
| 1819 |
+ """ |
|
| 1744 | 1820 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 1745 | 1821 |
with tests.isolated_config( |
| 1746 | 1822 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -1771,7 +1847,16 @@ contents go here |
| 1771 | 1847 |
def test_230a_config_directory_not_a_file( |
| 1772 | 1848 |
self, monkeypatch: pytest.MonkeyPatch |
| 1773 | 1849 |
) -> None: |
| 1774 |
- """https://github.com/the-13th-letter/derivepassphrase/issues/6""" |
|
| 1850 |
+ """Erroring without an existing config directory errors normally. |
|
| 1851 |
+ |
|
| 1852 |
+ That is, the missing configuration directory does not cause any |
|
| 1853 |
+ errors by itself. |
|
| 1854 |
+ |
|
| 1855 |
+ This is a regression test; see [issue\u00a0#6][] for context. |
|
| 1856 |
+ |
|
| 1857 |
+ [issue #6]: https://github.com/the-13th-letter/derivepassphrase/issues/6 |
|
| 1858 |
+ |
|
| 1859 |
+ """ |
|
| 1775 | 1860 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 1776 | 1861 |
with tests.isolated_config( |
| 1777 | 1862 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -1802,6 +1887,7 @@ contents go here |
| 1802 | 1887 |
def test_230b_store_config_custom_error( |
| 1803 | 1888 |
self, monkeypatch: pytest.MonkeyPatch |
| 1804 | 1889 |
) -> None: |
| 1890 |
+ """Storing the configuration reacts even to weird errors.""" |
|
| 1805 | 1891 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 1806 | 1892 |
with tests.isolated_config( |
| 1807 | 1893 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -1933,6 +2019,7 @@ contents go here |
| 1933 | 2019 |
input: str | None, |
| 1934 | 2020 |
warning_message: str, |
| 1935 | 2021 |
) -> None: |
| 2022 |
+ """Using unnormalized Unicode passphrases warns.""" |
|
| 1936 | 2023 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 1937 | 2024 |
with tests.isolated_vault_config( |
| 1938 | 2025 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -2003,6 +2090,7 @@ contents go here |
| 2003 | 2090 |
input: str | None, |
| 2004 | 2091 |
error_message: str, |
| 2005 | 2092 |
) -> None: |
| 2093 |
+ """Using unknown Unicode normalization forms fails.""" |
|
| 2006 | 2094 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 2007 | 2095 |
with tests.isolated_vault_config( |
| 2008 | 2096 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -2044,6 +2132,7 @@ contents go here |
| 2044 | 2132 |
monkeypatch: pytest.MonkeyPatch, |
| 2045 | 2133 |
command_line: list[str], |
| 2046 | 2134 |
) -> None: |
| 2135 |
+ """Using unknown Unicode normalization forms in the config fails.""" |
|
| 2047 | 2136 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 2048 | 2137 |
with tests.isolated_vault_config( |
| 2049 | 2138 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -2077,6 +2166,7 @@ contents go here |
| 2077 | 2166 |
self, |
| 2078 | 2167 |
monkeypatch: pytest.MonkeyPatch, |
| 2079 | 2168 |
) -> None: |
| 2169 |
+ """Loading a user configuration file in an invalid format fails.""" |
|
| 2080 | 2170 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 2081 | 2171 |
with tests.isolated_vault_config( |
| 2082 | 2172 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -2101,6 +2191,7 @@ contents go here |
| 2101 | 2191 |
self, |
| 2102 | 2192 |
monkeypatch: pytest.MonkeyPatch, |
| 2103 | 2193 |
) -> None: |
| 2194 |
+ """Querying the SSH agent without `AF_UNIX` support fails.""" |
|
| 2104 | 2195 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 2105 | 2196 |
with tests.isolated_vault_config( |
| 2106 | 2197 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -2123,6 +2214,8 @@ contents go here |
| 2123 | 2214 |
|
| 2124 | 2215 |
|
| 2125 | 2216 |
class TestCLIUtils: |
| 2217 |
+ """Tests for command-line utility functions.""" |
|
| 2218 |
+ |
|
| 2126 | 2219 |
@pytest.mark.parametrize( |
| 2127 | 2220 |
'config', |
| 2128 | 2221 |
[ |
| ... | ... |
@@ -2145,6 +2238,7 @@ class TestCLIUtils: |
| 2145 | 2238 |
def test_100_load_config( |
| 2146 | 2239 |
self, monkeypatch: pytest.MonkeyPatch, config: Any |
| 2147 | 2240 |
) -> None: |
| 2241 |
+ """`cli._load_config` works for valid configurations.""" |
|
| 2148 | 2242 |
runner = click.testing.CliRunner() |
| 2149 | 2243 |
with tests.isolated_vault_config( |
| 2150 | 2244 |
monkeypatch=monkeypatch, runner=runner, vault_config=config |
| ... | ... |
@@ -2157,6 +2251,7 @@ class TestCLIUtils: |
| 2157 | 2251 |
def test_110_save_bad_config( |
| 2158 | 2252 |
self, monkeypatch: pytest.MonkeyPatch |
| 2159 | 2253 |
) -> None: |
| 2254 |
+ """`cli._save_config` fails for bad configurations.""" |
|
| 2160 | 2255 |
runner = click.testing.CliRunner() |
| 2161 | 2256 |
# TODO(the-13th-letter): Rewrite using parenthesized |
| 2162 | 2257 |
# with-statements. |
| ... | ... |
@@ -2173,6 +2268,7 @@ class TestCLIUtils: |
| 2173 | 2268 |
cli._save_config(None) # type: ignore[arg-type] |
| 2174 | 2269 |
|
| 2175 | 2270 |
def test_111_prompt_for_selection_multiple(self) -> None: |
| 2271 |
+ """`cli._prompt_for_selection` works in the "multiple" case.""" |
|
| 2176 | 2272 |
@click.command() |
| 2177 | 2273 |
@click.option('--heading', default='Our menu:')
|
| 2178 | 2274 |
@click.argument('items', nargs=-1)
|
| ... | ... |
@@ -2248,6 +2344,7 @@ Your selection? (1-10, leave empty to abort):\x20 |
| 2248 | 2344 |
), 'expected known output' |
| 2249 | 2345 |
|
| 2250 | 2346 |
def test_112_prompt_for_selection_single(self) -> None: |
| 2347 |
+ """`cli._prompt_for_selection` works in the "single" case.""" |
|
| 2251 | 2348 |
@click.command() |
| 2252 | 2349 |
@click.option('--item', default='baked beans')
|
| 2253 | 2350 |
@click.argument('prompt')
|
| ... | ... |
@@ -2295,6 +2392,7 @@ Boo. |
| 2295 | 2392 |
def test_113_prompt_for_passphrase( |
| 2296 | 2393 |
self, monkeypatch: pytest.MonkeyPatch |
| 2297 | 2394 |
) -> None: |
| 2395 |
+ """`cli._prompt_for_passphrase` works.""" |
|
| 2298 | 2396 |
monkeypatch.setattr( |
| 2299 | 2397 |
click, |
| 2300 | 2398 |
'prompt', |
| ... | ... |
@@ -2315,6 +2413,12 @@ Boo. |
| 2315 | 2413 |
caplog: pytest.LogCaptureFixture, |
| 2316 | 2414 |
capsys: pytest.CaptureFixture[str], |
| 2317 | 2415 |
) -> None: |
| 2416 |
+ """The standard logging context manager works. |
|
| 2417 |
+ |
|
| 2418 |
+ It registers its handlers, once, and emits formatted calls to |
|
| 2419 |
+ standard error prefixed with the program name. |
|
| 2420 |
+ |
|
| 2421 |
+ """ |
|
| 2318 | 2422 |
prog_name = cli.StandardCLILogging.prog_name |
| 2319 | 2423 |
package_name = cli.StandardCLILogging.package_name |
| 2320 | 2424 |
logger = logging.getLogger(package_name) |
| ... | ... |
@@ -2371,6 +2475,14 @@ Boo. |
| 2371 | 2475 |
caplog: pytest.LogCaptureFixture, |
| 2372 | 2476 |
capsys: pytest.CaptureFixture[str], |
| 2373 | 2477 |
) -> None: |
| 2478 |
+ """The standard warnings logging context manager works. |
|
| 2479 |
+ |
|
| 2480 |
+ It registers its handlers, once, and emits formatted calls to |
|
| 2481 |
+ standard error prefixed with the program name. It also adheres |
|
| 2482 |
+ to the global warnings filter concerning which messages it |
|
| 2483 |
+ actually emits to standard error. |
|
| 2484 |
+ |
|
| 2485 |
+ """ |
|
| 2374 | 2486 |
warnings_cm = cli.StandardCLILogging.ensure_standard_warnings_logging() |
| 2375 | 2487 |
THE_FUTURE = 'the future will be here sooner than you think' # noqa: N806 |
| 2376 | 2488 |
JUST_TESTING = 'just testing whether warnings work' # noqa: N806 |
| ... | ... |
@@ -2419,6 +2531,19 @@ Boo. |
| 2419 | 2531 |
self, |
| 2420 | 2532 |
config: Any, |
| 2421 | 2533 |
) -> None: |
| 2534 |
+ """Emits a config in sh(1) format, then reads it back to verify it. |
|
| 2535 |
+ |
|
| 2536 |
+ This function exports the configuration, sets up a new |
|
| 2537 |
+ enviroment, then calls |
|
| 2538 |
+ [`vault_config_exporter_shell_interpreter`][] on the export |
|
| 2539 |
+ script, verifying that each command ran successfully and that |
|
| 2540 |
+ the final configuration matches the initial one. |
|
| 2541 |
+ |
|
| 2542 |
+ Args: |
|
| 2543 |
+ config: |
|
| 2544 |
+ The configuration to emit and read back. |
|
| 2545 |
+ |
|
| 2546 |
+ """ |
|
| 2422 | 2547 |
prog_name_list = ('derivepassphrase', 'vault')
|
| 2423 | 2548 |
with io.StringIO() as outfile: |
| 2424 | 2549 |
cli._print_config_as_sh_script( |
| ... | ... |
@@ -2464,6 +2589,15 @@ Boo. |
| 2464 | 2589 |
global_config_settable: _types.VaultConfigServicesSettings, |
| 2465 | 2590 |
global_config_importable: _types.VaultConfigServicesSettings, |
| 2466 | 2591 |
) -> None: |
| 2592 |
+ """Exporting configurations as sh(1) script works. |
|
| 2593 |
+ |
|
| 2594 |
+ Here, we check global-only configurations which use both |
|
| 2595 |
+ settings settable via `--config` and settings requiring |
|
| 2596 |
+ `--import`. |
|
| 2597 |
+ |
|
| 2598 |
+ The actual verification is done by [`_export_as_sh_helper`][]. |
|
| 2599 |
+ |
|
| 2600 |
+ """ |
|
| 2467 | 2601 |
config: _types.VaultConfig = {
|
| 2468 | 2602 |
'global': global_config_settable | global_config_importable, |
| 2469 | 2603 |
'services': {},
|
| ... | ... |
@@ -2497,6 +2631,14 @@ Boo. |
| 2497 | 2631 |
self, |
| 2498 | 2632 |
global_config_importable: _types.VaultConfigServicesSettings, |
| 2499 | 2633 |
) -> None: |
| 2634 |
+ """Exporting configurations as sh(1) script works. |
|
| 2635 |
+ |
|
| 2636 |
+ Here, we check global-only configurations which only use |
|
| 2637 |
+ settings requiring `--import`. |
|
| 2638 |
+ |
|
| 2639 |
+ The actual verification is done by [`_export_as_sh_helper`][]. |
|
| 2640 |
+ |
|
| 2641 |
+ """ |
|
| 2500 | 2642 |
config: _types.VaultConfig = {
|
| 2501 | 2643 |
'global': global_config_importable, |
| 2502 | 2644 |
'services': {},
|
| ... | ... |
@@ -2551,6 +2693,15 @@ Boo. |
| 2551 | 2693 |
service_config_settable: _types.VaultConfigServicesSettings, |
| 2552 | 2694 |
service_config_importable: _types.VaultConfigServicesSettings, |
| 2553 | 2695 |
) -> None: |
| 2696 |
+ """Exporting configurations as sh(1) script works. |
|
| 2697 |
+ |
|
| 2698 |
+ Here, we check service-only configurations which use both |
|
| 2699 |
+ settings settable via `--config` and settings requiring |
|
| 2700 |
+ `--import`. |
|
| 2701 |
+ |
|
| 2702 |
+ The actual verification is done by [`_export_as_sh_helper`][]. |
|
| 2703 |
+ |
|
| 2704 |
+ """ |
|
| 2554 | 2705 |
config: _types.VaultConfig = {
|
| 2555 | 2706 |
'services': {
|
| 2556 | 2707 |
service_name: ( |
| ... | ... |
@@ -2604,6 +2755,14 @@ Boo. |
| 2604 | 2755 |
service_name: str, |
| 2605 | 2756 |
service_config_importable: _types.VaultConfigServicesSettings, |
| 2606 | 2757 |
) -> None: |
| 2758 |
+ """Exporting configurations as sh(1) script works. |
|
| 2759 |
+ |
|
| 2760 |
+ Here, we check service-only configurations which only use |
|
| 2761 |
+ settings requiring `--import`. |
|
| 2762 |
+ |
|
| 2763 |
+ The actual verification is done by [`_export_as_sh_helper`][]. |
|
| 2764 |
+ |
|
| 2765 |
+ """ |
|
| 2607 | 2766 |
config: _types.VaultConfig = {
|
| 2608 | 2767 |
'services': {
|
| 2609 | 2768 |
service_name: service_config_importable, |
| ... | ... |
@@ -2649,6 +2808,7 @@ Boo. |
| 2649 | 2808 |
config: _types.VaultConfig, |
| 2650 | 2809 |
result_config: _types.VaultConfig, |
| 2651 | 2810 |
) -> None: |
| 2811 |
+ """Repeatedly removing the same parts of a configuration works.""" |
|
| 2652 | 2812 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 2653 | 2813 |
for start_config in [config, result_config]: |
| 2654 | 2814 |
with tests.isolated_vault_config( |
| ... | ... |
@@ -2672,6 +2832,7 @@ Boo. |
| 2672 | 2832 |
assert config_readback == result_config |
| 2673 | 2833 |
|
| 2674 | 2834 |
def test_204_phrase_from_key_manually(self) -> None: |
| 2835 |
+ """The dummy service, key and config settings are consistent.""" |
|
| 2675 | 2836 |
assert ( |
| 2676 | 2837 |
vault.Vault( |
| 2677 | 2838 |
phrase=DUMMY_PHRASE_FROM_KEY1, **DUMMY_CONFIG_SETTINGS |
| ... | ... |
@@ -2691,6 +2852,7 @@ Boo. |
| 2691 | 2852 |
vfunc: Callable[[click.Context, click.Parameter, Any], int | None], |
| 2692 | 2853 |
input: int, |
| 2693 | 2854 |
) -> None: |
| 2855 |
+ """Command-line argument constraint validation works.""" |
|
| 2694 | 2856 |
ctx = cli.derivepassphrase_vault.make_context(cli.PROG_NAME, []) |
| 2695 | 2857 |
param = cli.derivepassphrase_vault.params[0] |
| 2696 | 2858 |
assert vfunc(ctx, param, input) == input |
| ... | ... |
@@ -2702,6 +2864,7 @@ Boo. |
| 2702 | 2864 |
running_ssh_agent: tests.RunningSSHAgentInfo, |
| 2703 | 2865 |
conn_hint: str, |
| 2704 | 2866 |
) -> None: |
| 2867 |
+ """`cli._get_suitable_ssh_keys` works.""" |
|
| 2705 | 2868 |
with monkeypatch.context(): |
| 2706 | 2869 |
monkeypatch.setenv('SSH_AUTH_SOCK', running_ssh_agent.socket)
|
| 2707 | 2870 |
monkeypatch.setattr( |
| ... | ... |
@@ -2737,6 +2900,8 @@ Boo. |
| 2737 | 2900 |
skip_if_no_af_unix_support: None, |
| 2738 | 2901 |
ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient, |
| 2739 | 2902 |
) -> None: |
| 2903 |
+ """All errors in `cli._key_to_phrase` are handled.""" |
|
| 2904 |
+ |
|
| 2740 | 2905 |
class ErrCallback(BaseException): |
| 2741 | 2906 |
def __init__(self, *args: Any, **kwargs: Any) -> None: |
| 2742 | 2907 |
super().__init__(*args[:1]) |
| ... | ... |
@@ -2818,7 +2983,10 @@ Boo. |
| 2818 | 2983 |
# TODO(the-13th-letter): Remove this class in v1.0. |
| 2819 | 2984 |
# https://the13thletter.info/derivepassphrase/latest/upgrade-notes/#upgrading-to-v1.0 |
| 2820 | 2985 |
class TestCLITransition: |
| 2986 |
+ """Transition tests for the command-line interface up to v1.0.""" |
|
| 2987 |
+ |
|
| 2821 | 2988 |
def test_100_help_output(self, monkeypatch: pytest.MonkeyPatch) -> None: |
| 2989 |
+ """The top-level help text mentions subcommands.""" |
|
| 2822 | 2990 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 2823 | 2991 |
with tests.isolated_config( |
| 2824 | 2992 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -2835,6 +3003,7 @@ class TestCLITransition: |
| 2835 | 3003 |
def test_101_help_output_export( |
| 2836 | 3004 |
self, monkeypatch: pytest.MonkeyPatch |
| 2837 | 3005 |
) -> None: |
| 3006 |
+ """The "export" subcommand help text mentions subcommands.""" |
|
| 2838 | 3007 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 2839 | 3008 |
with tests.isolated_config( |
| 2840 | 3009 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -2853,6 +3022,7 @@ class TestCLITransition: |
| 2853 | 3022 |
def test_102_help_output_export_vault( |
| 2854 | 3023 |
self, monkeypatch: pytest.MonkeyPatch |
| 2855 | 3024 |
) -> None: |
| 3025 |
+ """The "export vault" subcommand help text has known content.""" |
|
| 2856 | 3026 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 2857 | 3027 |
with tests.isolated_config( |
| 2858 | 3028 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -2871,6 +3041,7 @@ class TestCLITransition: |
| 2871 | 3041 |
def test_103_help_output_vault( |
| 2872 | 3042 |
self, monkeypatch: pytest.MonkeyPatch |
| 2873 | 3043 |
) -> None: |
| 3044 |
+ """The "vault" subcommand help text has known content.""" |
|
| 2874 | 3045 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 2875 | 3046 |
with tests.isolated_config( |
| 2876 | 3047 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -2911,6 +3082,7 @@ class TestCLITransition: |
| 2911 | 3082 |
def test_110_load_config_backup( |
| 2912 | 3083 |
self, monkeypatch: pytest.MonkeyPatch, config: Any |
| 2913 | 3084 |
) -> None: |
| 3085 |
+ """Loading the old settings file works.""" |
|
| 2914 | 3086 |
runner = click.testing.CliRunner() |
| 2915 | 3087 |
with tests.isolated_config(monkeypatch=monkeypatch, runner=runner): |
| 2916 | 3088 |
cli._config_filename(subsystem='old settings.json').write_text( |
| ... | ... |
@@ -2940,6 +3112,7 @@ class TestCLITransition: |
| 2940 | 3112 |
def test_111_migrate_config( |
| 2941 | 3113 |
self, monkeypatch: pytest.MonkeyPatch, config: Any |
| 2942 | 3114 |
) -> None: |
| 3115 |
+ """Migrating the old settings file works.""" |
|
| 2943 | 3116 |
runner = click.testing.CliRunner() |
| 2944 | 3117 |
with tests.isolated_config(monkeypatch=monkeypatch, runner=runner): |
| 2945 | 3118 |
cli._config_filename(subsystem='old settings.json').write_text( |
| ... | ... |
@@ -2969,6 +3142,7 @@ class TestCLITransition: |
| 2969 | 3142 |
def test_112_migrate_config_error( |
| 2970 | 3143 |
self, monkeypatch: pytest.MonkeyPatch, config: Any |
| 2971 | 3144 |
) -> None: |
| 3145 |
+ """Migrating the old settings file atop a directory fails.""" |
|
| 2972 | 3146 |
runner = click.testing.CliRunner() |
| 2973 | 3147 |
with tests.isolated_config(monkeypatch=monkeypatch, runner=runner): |
| 2974 | 3148 |
cli._config_filename(subsystem='old settings.json').write_text( |
| ... | ... |
@@ -3004,6 +3178,7 @@ class TestCLITransition: |
| 3004 | 3178 |
def test_113_migrate_config_error_bad_config_value( |
| 3005 | 3179 |
self, monkeypatch: pytest.MonkeyPatch, config: Any |
| 3006 | 3180 |
) -> None: |
| 3181 |
+ """Migrating an invalid old settings file fails.""" |
|
| 3007 | 3182 |
runner = click.testing.CliRunner() |
| 3008 | 3183 |
with tests.isolated_config(monkeypatch=monkeypatch, runner=runner): |
| 3009 | 3184 |
cli._config_filename(subsystem='old settings.json').write_text( |
| ... | ... |
@@ -3017,6 +3192,7 @@ class TestCLITransition: |
| 3017 | 3192 |
monkeypatch: pytest.MonkeyPatch, |
| 3018 | 3193 |
caplog: pytest.LogCaptureFixture, |
| 3019 | 3194 |
) -> None: |
| 3195 |
+ """Forwarding arguments from "export" to "export vault" works.""" |
|
| 3020 | 3196 |
pytest.importorskip('cryptography', minversion='38.0')
|
| 3021 | 3197 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 3022 | 3198 |
with tests.isolated_vault_exporter_config( |
| ... | ... |
@@ -3045,6 +3221,7 @@ class TestCLITransition: |
| 3045 | 3221 |
monkeypatch: pytest.MonkeyPatch, |
| 3046 | 3222 |
caplog: pytest.LogCaptureFixture, |
| 3047 | 3223 |
) -> None: |
| 3224 |
+ """Deferring from "export" to "export vault" works.""" |
|
| 3048 | 3225 |
pytest.importorskip('cryptography', minversion='38.0')
|
| 3049 | 3226 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 3050 | 3227 |
with tests.isolated_config( |
| ... | ... |
@@ -3075,6 +3252,7 @@ class TestCLITransition: |
| 3075 | 3252 |
caplog: pytest.LogCaptureFixture, |
| 3076 | 3253 |
charset_name: str, |
| 3077 | 3254 |
) -> None: |
| 3255 |
+ """Forwarding arguments from top-level to "vault" works.""" |
|
| 3078 | 3256 |
monkeypatch.setattr(cli, '_prompt_for_passphrase', tests.auto_prompt) |
| 3079 | 3257 |
option = f'--{charset_name}'
|
| 3080 | 3258 |
charset = vault.Vault._CHARSETS[charset_name].decode('ascii')
|
| ... | ... |
@@ -3107,6 +3285,7 @@ class TestCLITransition: |
| 3107 | 3285 |
monkeypatch: pytest.MonkeyPatch, |
| 3108 | 3286 |
caplog: pytest.LogCaptureFixture, |
| 3109 | 3287 |
) -> None: |
| 3288 |
+ """Deferring from top-level to "vault" works.""" |
|
| 3110 | 3289 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 3111 | 3290 |
with tests.isolated_config( |
| 3112 | 3291 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -3134,6 +3313,7 @@ class TestCLITransition: |
| 3134 | 3313 |
monkeypatch: pytest.MonkeyPatch, |
| 3135 | 3314 |
caplog: pytest.LogCaptureFixture, |
| 3136 | 3315 |
) -> None: |
| 3316 |
+ """Exporting from (and migrating) the old settings file works.""" |
|
| 3137 | 3317 |
caplog.set_level(logging.INFO) |
| 3138 | 3318 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 3139 | 3319 |
with tests.isolated_config( |
| ... | ... |
@@ -3167,6 +3347,7 @@ class TestCLITransition: |
| 3167 | 3347 |
monkeypatch: pytest.MonkeyPatch, |
| 3168 | 3348 |
caplog: pytest.LogCaptureFixture, |
| 3169 | 3349 |
) -> None: |
| 3350 |
+ """Exporting from (and not migrating) the old settings file fails.""" |
|
| 3170 | 3351 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 3171 | 3352 |
with tests.isolated_config( |
| 3172 | 3353 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -3208,6 +3389,7 @@ class TestCLITransition: |
| 3208 | 3389 |
self, |
| 3209 | 3390 |
monkeypatch: pytest.MonkeyPatch, |
| 3210 | 3391 |
) -> None: |
| 3392 |
+ """Completing service names from the old settings file works.""" |
|
| 3211 | 3393 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 3212 | 3394 |
config = {'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}}
|
| 3213 | 3395 |
with tests.isolated_vault_config( |
| ... | ... |
@@ -3227,6 +3409,7 @@ class TestCLITransition: |
| 3227 | 3409 |
|
| 3228 | 3410 |
|
| 3229 | 3411 |
_known_services = (DUMMY_SERVICE, 'email', 'bank', 'work') |
| 3412 |
+"""Known service names. Used for the [`ConfigManagementStateMachine`][].""" |
|
| 3230 | 3413 |
_valid_properties = ( |
| 3231 | 3414 |
'length', |
| 3232 | 3415 |
'repeat', |
| ... | ... |
@@ -3237,12 +3420,22 @@ _valid_properties = ( |
| 3237 | 3420 |
'dash', |
| 3238 | 3421 |
'symbol', |
| 3239 | 3422 |
) |
| 3423 |
+"""Known vault properties. Used for the [`ConfigManagementStateMachine`][].""" |
|
| 3240 | 3424 |
|
| 3241 | 3425 |
|
| 3242 | 3426 |
def _build_reduced_vault_config_settings( |
| 3243 | 3427 |
config: _types.VaultConfigServicesSettings, |
| 3244 | 3428 |
keys_to_purge: frozenset[str], |
| 3245 | 3429 |
) -> _types.VaultConfigServicesSettings: |
| 3430 |
+ """Return a service settings object with certain keys pruned. |
|
| 3431 |
+ |
|
| 3432 |
+ Args: |
|
| 3433 |
+ config: |
|
| 3434 |
+ The original service settings object. |
|
| 3435 |
+ keys_to_purge: |
|
| 3436 |
+ The keys to purge from the settings object. |
|
| 3437 |
+ |
|
| 3438 |
+ """ |
|
| 3246 | 3439 |
config2 = copy.deepcopy(config) |
| 3247 | 3440 |
for key in keys_to_purge: |
| 3248 | 3441 |
config2.pop(key, None) # type: ignore[misc] |
| ... | ... |
@@ -3257,12 +3450,14 @@ _services_strategy = strategies.builds( |
| 3257 | 3450 |
max_size=7, |
| 3258 | 3451 |
), |
| 3259 | 3452 |
) |
| 3453 |
+"""A hypothesis strategy to build incomplete service configurations.""" |
|
| 3260 | 3454 |
|
| 3261 | 3455 |
|
| 3262 | 3456 |
def _assemble_config( |
| 3263 | 3457 |
global_data: _types.VaultConfigGlobalSettings, |
| 3264 | 3458 |
service_data: list[tuple[str, _types.VaultConfigServicesSettings]], |
| 3265 | 3459 |
) -> _types.VaultConfig: |
| 3460 |
+ """Return a vault config using the global and service data.""" |
|
| 3266 | 3461 |
services_dict = dict(service_data) |
| 3267 | 3462 |
return ( |
| 3268 | 3463 |
{'global': global_data, 'services': services_dict}
|
| ... | ... |
@@ -3276,6 +3471,21 @@ def _draw_service_name_and_data( |
| 3276 | 3471 |
draw: hypothesis.strategies.DrawFn, |
| 3277 | 3472 |
num_entries: int, |
| 3278 | 3473 |
) -> tuple[tuple[str, _types.VaultConfigServicesSettings], ...]: |
| 3474 |
+ """Draw a service name and settings, as a hypothesis strategy. |
|
| 3475 |
+ |
|
| 3476 |
+ Will draw service names from [`_known_services`][] and service |
|
| 3477 |
+ settings via [`_services_strategy`][]. |
|
| 3478 |
+ |
|
| 3479 |
+ Args: |
|
| 3480 |
+ draw: |
|
| 3481 |
+ The `draw` function, as provided for by hypothesis. |
|
| 3482 |
+ num_entries: |
|
| 3483 |
+ The number of services to draw. |
|
| 3484 |
+ |
|
| 3485 |
+ Returns: |
|
| 3486 |
+ A sequence of pairs of service names and service settings. |
|
| 3487 |
+ |
|
| 3488 |
+ """ |
|
| 3279 | 3489 |
possible_services = list(_known_services) |
| 3280 | 3490 |
selected_services: list[str] = [] |
| 3281 | 3491 |
for _ in range(num_entries): |
| ... | ... |
@@ -3296,11 +3506,26 @@ _vault_full_config = strategies.builds( |
| 3296 | 3506 |
max_value=4, |
| 3297 | 3507 |
).flatmap(_draw_service_name_and_data), |
| 3298 | 3508 |
) |
| 3509 |
+"""A hypothesis strategy to build full vault configurations.""" |
|
| 3299 | 3510 |
|
| 3300 | 3511 |
|
| 3301 | 3512 |
@tests.hypothesis_settings_coverage_compatible |
| 3302 | 3513 |
class ConfigManagementStateMachine(stateful.RuleBasedStateMachine): |
| 3514 |
+ """A state machine recording changes in the vault configuration. |
|
| 3515 |
+ |
|
| 3516 |
+ Record possible configuration states in bundles, then in each rule, |
|
| 3517 |
+ take a configuration and manipulate it somehow. |
|
| 3518 |
+ |
|
| 3519 |
+ Attributes: |
|
| 3520 |
+ setting: |
|
| 3521 |
+ A bundle for single-service settings. |
|
| 3522 |
+ configuration: |
|
| 3523 |
+ A bundle for full vault configurations. |
|
| 3524 |
+ |
|
| 3525 |
+ """ |
|
| 3526 |
+ |
|
| 3303 | 3527 |
def __init__(self) -> None: |
| 3528 |
+ """Initialize self, set up context managers and enter them.""" |
|
| 3304 | 3529 |
super().__init__() |
| 3305 | 3530 |
self.runner = click.testing.CliRunner(mix_stderr=False) |
| 3306 | 3531 |
self.exit_stack = contextlib.ExitStack().__enter__() |
| ... | ... |
@@ -3315,8 +3540,14 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine): |
| 3315 | 3540 |
) |
| 3316 | 3541 |
) |
| 3317 | 3542 |
|
| 3318 |
- setting = stateful.Bundle('setting')
|
|
| 3319 |
- configuration = stateful.Bundle('configuration')
|
|
| 3543 |
+ setting: stateful.Bundle[_types.VaultConfigServicesSettings] = ( |
|
| 3544 |
+ stateful.Bundle('setting')
|
|
| 3545 |
+ ) |
|
| 3546 |
+ """""" |
|
| 3547 |
+ configuration: stateful.Bundle[_types.VaultConfig] = stateful.Bundle( |
|
| 3548 |
+ 'configuration' |
|
| 3549 |
+ ) |
|
| 3550 |
+ """""" |
|
| 3320 | 3551 |
|
| 3321 | 3552 |
@stateful.initialize( |
| 3322 | 3553 |
target=configuration, |
| ... | ... |
@@ -3329,7 +3560,8 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine): |
| 3329 | 3560 |
def declare_initial_configs( |
| 3330 | 3561 |
self, |
| 3331 | 3562 |
configs: Iterable[_types.VaultConfig], |
| 3332 |
- ) -> Iterable[_types.VaultConfig]: |
|
| 3563 |
+ ) -> stateful.MultipleResults[_types.VaultConfig]: |
|
| 3564 |
+ """Initialize the configuration bundle with eight configurations.""" |
|
| 3333 | 3565 |
return stateful.multiple(*configs) |
| 3334 | 3566 |
|
| 3335 | 3567 |
@stateful.initialize( |
| ... | ... |
@@ -3343,7 +3575,8 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine): |
| 3343 | 3575 |
def extract_initial_settings( |
| 3344 | 3576 |
self, |
| 3345 | 3577 |
configs: list[_types.VaultConfig], |
| 3346 |
- ) -> Iterable[_types.VaultConfigServicesSettings]: |
|
| 3578 |
+ ) -> stateful.MultipleResults[_types.VaultConfigServicesSettings]: |
|
| 3579 |
+ """Initialize the settings bundle with four service settings.""" |
|
| 3347 | 3580 |
settings: list[_types.VaultConfigServicesSettings] = [] |
| 3348 | 3581 |
for c in configs: |
| 3349 | 3582 |
settings.extend(c['services'].values()) |
| ... | ... |
@@ -3381,6 +3614,26 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine): |
| 3381 | 3614 |
maybe_unset: set[str], |
| 3382 | 3615 |
overwrite: bool, |
| 3383 | 3616 |
) -> _types.VaultConfig: |
| 3617 |
+ """Set the global settings of a configuration. |
|
| 3618 |
+ |
|
| 3619 |
+ Args: |
|
| 3620 |
+ config: |
|
| 3621 |
+ The configuration to edit. |
|
| 3622 |
+ setting: |
|
| 3623 |
+ The new global settings. |
|
| 3624 |
+ maybe_unset: |
|
| 3625 |
+ Settings keys to additionally unset, if not already |
|
| 3626 |
+ present in the new settings. Corresponds to the |
|
| 3627 |
+ `--unset` command-line argument. |
|
| 3628 |
+ overwrite: |
|
| 3629 |
+ Overwrite the settings object if true, or merge if |
|
| 3630 |
+ false. Corresponds to the `--overwrite-existing` and |
|
| 3631 |
+ `--merge-existing` command-line arguments. |
|
| 3632 |
+ |
|
| 3633 |
+ Returns: |
|
| 3634 |
+ The amended configuration. |
|
| 3635 |
+ |
|
| 3636 |
+ """ |
|
| 3384 | 3637 |
cli._save_config(config) |
| 3385 | 3638 |
config_global = config.get('global', {})
|
| 3386 | 3639 |
maybe_unset = set(maybe_unset) - setting.keys() |
| ... | ... |
@@ -3432,6 +3685,28 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine): |
| 3432 | 3685 |
maybe_unset: set[str], |
| 3433 | 3686 |
overwrite: bool, |
| 3434 | 3687 |
) -> _types.VaultConfig: |
| 3688 |
+ """Set the named service settings for a configuration. |
|
| 3689 |
+ |
|
| 3690 |
+ Args: |
|
| 3691 |
+ config: |
|
| 3692 |
+ The configuration to edit. |
|
| 3693 |
+ service: |
|
| 3694 |
+ The name of the service to set. |
|
| 3695 |
+ setting: |
|
| 3696 |
+ The new service settings. |
|
| 3697 |
+ maybe_unset: |
|
| 3698 |
+ Settings keys to additionally unset, if not already |
|
| 3699 |
+ present in the new settings. Corresponds to the |
|
| 3700 |
+ `--unset` command-line argument. |
|
| 3701 |
+ overwrite: |
|
| 3702 |
+ Overwrite the settings object if true, or merge if |
|
| 3703 |
+ false. Corresponds to the `--overwrite-existing` and |
|
| 3704 |
+ `--merge-existing` command-line arguments. |
|
| 3705 |
+ |
|
| 3706 |
+ Returns: |
|
| 3707 |
+ The amended configuration. |
|
| 3708 |
+ |
|
| 3709 |
+ """ |
|
| 3435 | 3710 |
cli._save_config(config) |
| 3436 | 3711 |
config_service = config['services'].get(service, {})
|
| 3437 | 3712 |
maybe_unset = set(maybe_unset) - setting.keys() |
| ... | ... |
@@ -3473,6 +3748,16 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine): |
| 3473 | 3748 |
self, |
| 3474 | 3749 |
config: _types.VaultConfig, |
| 3475 | 3750 |
) -> _types.VaultConfig: |
| 3751 |
+ """Purge the globals of a configuration. |
|
| 3752 |
+ |
|
| 3753 |
+ Args: |
|
| 3754 |
+ config: |
|
| 3755 |
+ The configuration to edit. |
|
| 3756 |
+ |
|
| 3757 |
+ Returns: |
|
| 3758 |
+ The pruned configuration. |
|
| 3759 |
+ |
|
| 3760 |
+ """ |
|
| 3476 | 3761 |
cli._save_config(config) |
| 3477 | 3762 |
config.pop('global', None)
|
| 3478 | 3763 |
result_ = self.runner.invoke( |
| ... | ... |
@@ -3501,6 +3786,17 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine): |
| 3501 | 3786 |
self, |
| 3502 | 3787 |
config_and_service: tuple[_types.VaultConfig, str], |
| 3503 | 3788 |
) -> _types.VaultConfig: |
| 3789 |
+ """Purge the settings of a named service in a configuration. |
|
| 3790 |
+ |
|
| 3791 |
+ Args: |
|
| 3792 |
+ config_and_service: |
|
| 3793 |
+ A 2-tuple containing the configuration to edit, and the |
|
| 3794 |
+ service name to purge. |
|
| 3795 |
+ |
|
| 3796 |
+ Returns: |
|
| 3797 |
+ The pruned configuration. |
|
| 3798 |
+ |
|
| 3799 |
+ """ |
|
| 3504 | 3800 |
config, service = config_and_service |
| 3505 | 3801 |
cli._save_config(config) |
| 3506 | 3802 |
config['services'].pop(service, None) |
| ... | ... |
@@ -3523,6 +3819,16 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine): |
| 3523 | 3819 |
self, |
| 3524 | 3820 |
config: _types.VaultConfig, |
| 3525 | 3821 |
) -> _types.VaultConfig: |
| 3822 |
+ """Purge the entire configuration. |
|
| 3823 |
+ |
|
| 3824 |
+ Args: |
|
| 3825 |
+ config: |
|
| 3826 |
+ The configuration to edit. |
|
| 3827 |
+ |
|
| 3828 |
+ Returns: |
|
| 3829 |
+ The empty configuration. |
|
| 3830 |
+ |
|
| 3831 |
+ """ |
|
| 3526 | 3832 |
cli._save_config(config) |
| 3527 | 3833 |
config = {'services': {}}
|
| 3528 | 3834 |
result_ = self.runner.invoke( |
| ... | ... |
@@ -3548,6 +3854,22 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine): |
| 3548 | 3854 |
config_to_import: _types.VaultConfig, |
| 3549 | 3855 |
overwrite: bool, |
| 3550 | 3856 |
) -> _types.VaultConfig: |
| 3857 |
+ """Import the given configuration into a base configuration. |
|
| 3858 |
+ |
|
| 3859 |
+ Args: |
|
| 3860 |
+ base_config: |
|
| 3861 |
+ The configuration to import into. |
|
| 3862 |
+ config_to_import: |
|
| 3863 |
+ The configuration to import. |
|
| 3864 |
+ overwrite: |
|
| 3865 |
+ Overwrite the base configuration if true, or merge if |
|
| 3866 |
+ false. Corresponds to the `--overwrite-existing` and |
|
| 3867 |
+ `--merge-existing` command-line arguments. |
|
| 3868 |
+ |
|
| 3869 |
+ Returns: |
|
| 3870 |
+ The imported or merged configuration. |
|
| 3871 |
+ |
|
| 3872 |
+ """ |
|
| 3551 | 3873 |
cli._save_config(base_config) |
| 3552 | 3874 |
config = ( |
| 3553 | 3875 |
self.fold_configs(config_to_import, base_config) |
| ... | ... |
@@ -3569,13 +3891,20 @@ class ConfigManagementStateMachine(stateful.RuleBasedStateMachine): |
| 3569 | 3891 |
return config |
| 3570 | 3892 |
|
| 3571 | 3893 |
def teardown(self) -> None: |
| 3894 |
+ """Upon teardown, exit all contexts entered in `__init__`.""" |
|
| 3572 | 3895 |
self.exit_stack.close() |
| 3573 | 3896 |
|
| 3574 | 3897 |
|
| 3575 | 3898 |
TestConfigManagement = ConfigManagementStateMachine.TestCase |
| 3899 |
+"""The [`unittest.TestCase`][] class that will actually be run.""" |
|
| 3576 | 3900 |
|
| 3577 | 3901 |
|
| 3578 | 3902 |
def bash_format(item: click.shell_completion.CompletionItem) -> str: |
| 3903 |
+ """A formatter for `bash`-style shell completion items. |
|
| 3904 |
+ |
|
| 3905 |
+ The format is `type,value`, and is dictated by [`click`][]. |
|
| 3906 |
+ |
|
| 3907 |
+ """ |
|
| 3579 | 3908 |
type, value = ( # noqa: A001 |
| 3580 | 3909 |
item.type, |
| 3581 | 3910 |
item.value, |
| ... | ... |
@@ -3584,6 +3913,11 @@ def bash_format(item: click.shell_completion.CompletionItem) -> str: |
| 3584 | 3913 |
|
| 3585 | 3914 |
|
| 3586 | 3915 |
def fish_format(item: click.shell_completion.CompletionItem) -> str: |
| 3916 |
+ r"""A formatter for `fish`-style shell completion items. |
|
| 3917 |
+ |
|
| 3918 |
+ The format is `type,value<tab>help`, and is dictated by [`click`][]. |
|
| 3919 |
+ |
|
| 3920 |
+ """ |
|
| 3587 | 3921 |
type, value, help = ( # noqa: A001 |
| 3588 | 3922 |
item.type, |
| 3589 | 3923 |
item.value, |
| ... | ... |
@@ -3593,6 +3927,19 @@ def fish_format(item: click.shell_completion.CompletionItem) -> str: |
| 3593 | 3927 |
|
| 3594 | 3928 |
|
| 3595 | 3929 |
def zsh_format(item: click.shell_completion.CompletionItem) -> str: |
| 3930 |
+ r"""A formatter for `zsh`-style shell completion items. |
|
| 3931 |
+ |
|
| 3932 |
+ The format is `type<newline>value<newline>help<newline>`, and is |
|
| 3933 |
+ dictated by [`click`][]. Upstream `click` currently (v8.2.0) does |
|
| 3934 |
+ not deal with colons in the value correctly when the help text is |
|
| 3935 |
+ non-degenerate. Our formatter here does, provided the upstream |
|
| 3936 |
+ `zsh` completion script is used; see the [`cli.ZshComplete`][] |
|
| 3937 |
+ class. A request is underway to merge this change into upstream |
|
| 3938 |
+ `click`; see [`pallets/click#2846`][PR2846]. |
|
| 3939 |
+ |
|
| 3940 |
+ [PR2846]: https://github.com/pallets/click/pull/2846 |
|
| 3941 |
+ |
|
| 3942 |
+ """ |
|
| 3596 | 3943 |
empty_help = '_' |
| 3597 | 3944 |
help_, value = ( |
| 3598 | 3945 |
(item.help, item.value.replace(':', r'\:'))
|
| ... | ... |
@@ -3605,6 +3952,7 @@ def zsh_format(item: click.shell_completion.CompletionItem) -> str: |
| 3605 | 3952 |
def completion_item( |
| 3606 | 3953 |
item: str | click.shell_completion.CompletionItem, |
| 3607 | 3954 |
) -> click.shell_completion.CompletionItem: |
| 3955 |
+ """Convert a string to a completion item, if necessary.""" |
|
| 3608 | 3956 |
return ( |
| 3609 | 3957 |
click.shell_completion.CompletionItem(item, type='plain') |
| 3610 | 3958 |
if isinstance(item, str) |
| ... | ... |
@@ -3615,21 +3963,41 @@ def completion_item( |
| 3615 | 3963 |
def assertable_item( |
| 3616 | 3964 |
item: str | click.shell_completion.CompletionItem, |
| 3617 | 3965 |
) -> tuple[str, Any, str | None]: |
| 3966 |
+ """Convert a completion item into a pretty-printable item. |
|
| 3967 |
+ |
|
| 3968 |
+ Intended to make completion items introspectable in pytest's |
|
| 3969 |
+ `assert` output. |
|
| 3970 |
+ |
|
| 3971 |
+ """ |
|
| 3618 | 3972 |
item = completion_item(item) |
| 3619 | 3973 |
return (item.type, item.value, item.help) |
| 3620 | 3974 |
|
| 3621 | 3975 |
|
| 3622 | 3976 |
class TestShellCompletion: |
| 3977 |
+ """Tests for the shell completion machinery.""" |
|
| 3978 |
+ |
|
| 3623 | 3979 |
class Completions: |
| 3980 |
+ """A deferred completion call.""" |
|
| 3981 |
+ |
|
| 3624 | 3982 |
def __init__( |
| 3625 | 3983 |
self, |
| 3626 | 3984 |
args: Sequence[str], |
| 3627 | 3985 |
incomplete: str, |
| 3628 | 3986 |
) -> None: |
| 3987 |
+ """Initialize the object. |
|
| 3988 |
+ |
|
| 3989 |
+ Args: |
|
| 3990 |
+ args: |
|
| 3991 |
+ The sequence of complete command-line arguments. |
|
| 3992 |
+ incomplete: |
|
| 3993 |
+ The final, incomplete, partial argument. |
|
| 3994 |
+ |
|
| 3995 |
+ """ |
|
| 3629 | 3996 |
self.args = tuple(args) |
| 3630 | 3997 |
self.incomplete = incomplete |
| 3631 | 3998 |
|
| 3632 | 3999 |
def __call__(self) -> Sequence[click.shell_completion.CompletionItem]: |
| 4000 |
+ """Return the completion items.""" |
|
| 3633 | 4001 |
args = list(self.args) |
| 3634 | 4002 |
completion = click.shell_completion.ShellComplete( |
| 3635 | 4003 |
cli=cli.derivepassphrase, |
| ... | ... |
@@ -3640,6 +4008,7 @@ class TestShellCompletion: |
| 3640 | 4008 |
return completion.get_completions(args, self.incomplete) |
| 3641 | 4009 |
|
| 3642 | 4010 |
def get_words(self) -> Sequence[str]: |
| 4011 |
+ """Return the completion items' values, as a sequence.""" |
|
| 3643 | 4012 |
return tuple(c.value for c in self()) |
| 3644 | 4013 |
|
| 3645 | 4014 |
@pytest.mark.parametrize( |
| ... | ... |
@@ -3661,6 +4030,7 @@ class TestShellCompletion: |
| 3661 | 4030 |
partial: str, |
| 3662 | 4031 |
is_completable: bool, |
| 3663 | 4032 |
) -> None: |
| 4033 |
+ """Our `_is_completable_item` predicate for service names works.""" |
|
| 3664 | 4034 |
assert cli._is_completable_item(partial) == is_completable |
| 3665 | 4035 |
|
| 3666 | 4036 |
@pytest.mark.parametrize( |
| ... | ... |
@@ -3769,6 +4139,7 @@ class TestShellCompletion: |
| 3769 | 4139 |
incomplete: str, |
| 3770 | 4140 |
completions: AbstractSet[str], |
| 3771 | 4141 |
) -> None: |
| 4142 |
+ """Our completion machinery works for all commands' options.""" |
|
| 3772 | 4143 |
comp = self.Completions(command_prefix, incomplete) |
| 3773 | 4144 |
assert frozenset(comp.get_words()) == completions |
| 3774 | 4145 |
|
| ... | ... |
@@ -3795,6 +4166,7 @@ class TestShellCompletion: |
| 3795 | 4166 |
incomplete: str, |
| 3796 | 4167 |
completions: AbstractSet[str], |
| 3797 | 4168 |
) -> None: |
| 4169 |
+ """Our completion machinery works for all commands' subcommands.""" |
|
| 3798 | 4170 |
comp = self.Completions(command_prefix, incomplete) |
| 3799 | 4171 |
assert frozenset(comp.get_words()) == completions |
| 3800 | 4172 |
|
| ... | ... |
@@ -3821,6 +4193,7 @@ class TestShellCompletion: |
| 3821 | 4193 |
command_prefix: Sequence[str], |
| 3822 | 4194 |
incomplete: str, |
| 3823 | 4195 |
) -> None: |
| 4196 |
+ """Our completion machinery works for all commands' paths.""" |
|
| 3824 | 4197 |
file = click.shell_completion.CompletionItem('', type='file')
|
| 3825 | 4198 |
completions = frozenset({(file.type, file.value, file.help)})
|
| 3826 | 4199 |
comp = self.Completions(command_prefix, incomplete) |
| ... | ... |
@@ -3870,6 +4243,7 @@ class TestShellCompletion: |
| 3870 | 4243 |
incomplete: str, |
| 3871 | 4244 |
completions: AbstractSet[str], |
| 3872 | 4245 |
) -> None: |
| 4246 |
+ """Our completion machinery works for vault service names.""" |
|
| 3873 | 4247 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 3874 | 4248 |
with tests.isolated_vault_config( |
| 3875 | 4249 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -3994,6 +4368,7 @@ class TestShellCompletion: |
| 3994 | 4368 |
incomplete: str, |
| 3995 | 4369 |
results: list[str | click.shell_completion.CompletionItem], |
| 3996 | 4370 |
) -> None: |
| 4371 |
+ """Custom completion functions work for all shells.""" |
|
| 3997 | 4372 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 3998 | 4373 |
with tests.isolated_vault_config( |
| 3999 | 4374 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -4196,6 +4571,7 @@ class TestShellCompletion: |
| 4196 | 4571 |
incomplete: str, |
| 4197 | 4572 |
completions: AbstractSet[str], |
| 4198 | 4573 |
) -> None: |
| 4574 |
+ """Completion skips incompletable items.""" |
|
| 4199 | 4575 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 4200 | 4576 |
vault_config = config if mode == 'config' else {'services': {}}
|
| 4201 | 4577 |
with tests.isolated_vault_config( |
| ... | ... |
@@ -4232,6 +4608,7 @@ class TestShellCompletion: |
| 4232 | 4608 |
self, |
| 4233 | 4609 |
monkeypatch: pytest.MonkeyPatch, |
| 4234 | 4610 |
) -> None: |
| 4611 |
+ """Service name completion quietly fails on missing configuration.""" |
|
| 4235 | 4612 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 4236 | 4613 |
with tests.isolated_vault_config( |
| 4237 | 4614 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -4251,6 +4628,7 @@ class TestShellCompletion: |
| 4251 | 4628 |
monkeypatch: pytest.MonkeyPatch, |
| 4252 | 4629 |
exc_type: type[Exception], |
| 4253 | 4630 |
) -> None: |
| 4631 |
+ """Service name completion quietly fails on configuration errors.""" |
|
| 4254 | 4632 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 4255 | 4633 |
with tests.isolated_vault_config( |
| 4256 | 4634 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -40,7 +40,16 @@ if TYPE_CHECKING: |
| 40 | 40 |
|
| 41 | 41 |
|
| 42 | 42 |
class TestCLI: |
| 43 |
+ """Test the command-line interface for `derivepassphrase export vault`.""" |
|
| 44 |
+ |
|
| 43 | 45 |
def test_200_path_parameter(self, monkeypatch: pytest.MonkeyPatch) -> None: |
| 46 |
+ """The path `VAULT_PATH` is supported. |
|
| 47 |
+ |
|
| 48 |
+ Using `VAULT_PATH` as the path looks up the actual path in the |
|
| 49 |
+ `VAULT_PATH` environment variable. See |
|
| 50 |
+ [`exporter.get_vault_path`][] for details. |
|
| 51 |
+ |
|
| 52 |
+ """ |
|
| 44 | 53 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 45 | 54 |
with tests.isolated_vault_exporter_config( |
| 46 | 55 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -58,6 +67,7 @@ class TestCLI: |
| 58 | 67 |
assert json.loads(result.output) == tests.VAULT_V03_CONFIG_DATA |
| 59 | 68 |
|
| 60 | 69 |
def test_201_key_parameter(self, monkeypatch: pytest.MonkeyPatch) -> None: |
| 70 |
+ """The `--key` option is supported.""" |
|
| 61 | 71 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 62 | 72 |
with tests.isolated_vault_exporter_config( |
| 63 | 73 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -102,6 +112,12 @@ class TestCLI: |
| 102 | 112 |
config: str | bytes, |
| 103 | 113 |
config_data: dict[str, Any], |
| 104 | 114 |
) -> None: |
| 115 |
+ """Passing a specific format works. |
|
| 116 |
+ |
|
| 117 |
+ Passing a specific format name causes `derivepassphrase export |
|
| 118 |
+ vault` to only attempt decoding in that named format. |
|
| 119 |
+ |
|
| 120 |
+ """ |
|
| 105 | 121 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 106 | 122 |
with tests.isolated_vault_exporter_config( |
| 107 | 123 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -124,6 +140,7 @@ class TestCLI: |
| 124 | 140 |
monkeypatch: pytest.MonkeyPatch, |
| 125 | 141 |
caplog: pytest.LogCaptureFixture, |
| 126 | 142 |
) -> None: |
| 143 |
+ """Fail when trying to decode non-existant files/directories.""" |
|
| 127 | 144 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 128 | 145 |
with tests.isolated_vault_exporter_config( |
| 129 | 146 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -150,6 +167,7 @@ class TestCLI: |
| 150 | 167 |
monkeypatch: pytest.MonkeyPatch, |
| 151 | 168 |
caplog: pytest.LogCaptureFixture, |
| 152 | 169 |
) -> None: |
| 170 |
+ """Fail to parse invalid vault configurations (files).""" |
|
| 153 | 171 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 154 | 172 |
with tests.isolated_vault_exporter_config( |
| 155 | 173 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -173,6 +191,7 @@ class TestCLI: |
| 173 | 191 |
monkeypatch: pytest.MonkeyPatch, |
| 174 | 192 |
caplog: pytest.LogCaptureFixture, |
| 175 | 193 |
) -> None: |
| 194 |
+ """Fail to parse invalid vault configurations (directories).""" |
|
| 176 | 195 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 177 | 196 |
with tests.isolated_vault_exporter_config( |
| 178 | 197 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -199,6 +218,7 @@ class TestCLI: |
| 199 | 218 |
monkeypatch: pytest.MonkeyPatch, |
| 200 | 219 |
caplog: pytest.LogCaptureFixture, |
| 201 | 220 |
) -> None: |
| 221 |
+ """Fail to parse vault configurations with invalid integrity checks.""" |
|
| 202 | 222 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 203 | 223 |
with tests.isolated_vault_exporter_config( |
| 204 | 224 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -222,6 +242,7 @@ class TestCLI: |
| 222 | 242 |
monkeypatch: pytest.MonkeyPatch, |
| 223 | 243 |
caplog: pytest.LogCaptureFixture, |
| 224 | 244 |
) -> None: |
| 245 |
+ """The decoded vault configuration data is valid.""" |
|
| 225 | 246 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 226 | 247 |
with tests.isolated_vault_exporter_config( |
| 227 | 248 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -251,6 +272,8 @@ class TestCLI: |
| 251 | 272 |
|
| 252 | 273 |
|
| 253 | 274 |
class TestStoreroom: |
| 275 |
+ """Test the "storeroom" handler and handler machinery.""" |
|
| 276 |
+ |
|
| 254 | 277 |
@pytest.mark.parametrize('path', ['.vault', None])
|
| 255 | 278 |
@pytest.mark.parametrize( |
| 256 | 279 |
'key', |
| ... | ... |
@@ -282,6 +305,12 @@ class TestStoreroom: |
| 282 | 305 |
key: str | Buffer | None, |
| 283 | 306 |
handler: exporter.ExportVaultConfigDataFunction, |
| 284 | 307 |
) -> None: |
| 308 |
+ """Support different argument types. |
|
| 309 |
+ |
|
| 310 |
+ The [`exporter.export_vault_config_data`][] dispatcher supports |
|
| 311 |
+ them as well. |
|
| 312 |
+ |
|
| 313 |
+ """ |
|
| 285 | 314 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 286 | 315 |
with tests.isolated_vault_exporter_config( |
| 287 | 316 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -295,6 +324,7 @@ class TestStoreroom: |
| 295 | 324 |
) |
| 296 | 325 |
|
| 297 | 326 |
def test_400_decrypt_bucket_item_unknown_version(self) -> None: |
| 327 |
+ """Fail on unknown versions of the master keys file.""" |
|
| 298 | 328 |
bucket_item = ( |
| 299 | 329 |
b'\xff' + bytes(storeroom.ENCRYPTED_KEYPAIR_SIZE) + bytes(3) |
| 300 | 330 |
) |
| ... | ... |
@@ -312,6 +342,12 @@ class TestStoreroom: |
| 312 | 342 |
monkeypatch: pytest.MonkeyPatch, |
| 313 | 343 |
config: str, |
| 314 | 344 |
) -> None: |
| 345 |
+ """Fail on bad or unsupported bucket file contents. |
|
| 346 |
+ |
|
| 347 |
+ These include unknown versions, invalid JSON, or JSON of the |
|
| 348 |
+ wrong shape. |
|
| 349 |
+ |
|
| 350 |
+ """ |
|
| 315 | 351 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 316 | 352 |
master_keys = _types.StoreroomMasterKeys( |
| 317 | 353 |
encryption_key=bytes(storeroom.KEY_SIZE), |
| ... | ... |
@@ -363,6 +399,11 @@ class TestStoreroom: |
| 363 | 399 |
err_msg: str, |
| 364 | 400 |
handler: exporter.ExportVaultConfigDataFunction, |
| 365 | 401 |
) -> None: |
| 402 |
+ """Fail on bad or unsupported master keys file contents. |
|
| 403 |
+ |
|
| 404 |
+ These include unknown versions, and data of the wrong shape. |
|
| 405 |
+ |
|
| 406 |
+ """ |
|
| 366 | 407 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 367 | 408 |
with tests.isolated_vault_exporter_config( |
| 368 | 409 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -415,6 +456,20 @@ class TestStoreroom: |
| 415 | 456 |
error_text: str, |
| 416 | 457 |
handler: exporter.ExportVaultConfigDataFunction, |
| 417 | 458 |
) -> None: |
| 459 |
+ """Fail on bad decoded directory structures. |
|
| 460 |
+ |
|
| 461 |
+ If the decoded configuration contains directories whose |
|
| 462 |
+ structures are inconsistent, it detects this and fails: |
|
| 463 |
+ |
|
| 464 |
+ - The key indicates a directory, but the contents don't. |
|
| 465 |
+ - The directory indicates children with invalid path names. |
|
| 466 |
+ - The directory indicates children that are missing from the |
|
| 467 |
+ configuration entirely. |
|
| 468 |
+ - The configuration contains nested subdirectories, but the |
|
| 469 |
+ higher-level directories don't indicate their |
|
| 470 |
+ subdirectories. |
|
| 471 |
+ |
|
| 472 |
+ """ |
|
| 418 | 473 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 419 | 474 |
# TODO(the-13th-letter): Rewrite using parenthesized |
| 420 | 475 |
# with-statements. |
| ... | ... |
@@ -432,6 +487,15 @@ class TestStoreroom: |
| 432 | 487 |
handler(format='storeroom') |
| 433 | 488 |
|
| 434 | 489 |
def test_404_decrypt_keys_wrong_data_length(self) -> None: |
| 490 |
+ """Fail on internal structural data of the wrong size. |
|
| 491 |
+ |
|
| 492 |
+ Specifically, fail on internal structural data such as master |
|
| 493 |
+ keys or session keys that is correctly encrypted according to |
|
| 494 |
+ its MAC, but is of the wrong shape. (Since the data usually are |
|
| 495 |
+ keys and thus are opaque, the only detectable shape violation is |
|
| 496 |
+ the wrong size of the data.) |
|
| 497 |
+ |
|
| 498 |
+ """ |
|
| 435 | 499 |
payload = ( |
| 436 | 500 |
b"Any text here, as long as it isn't exactly 64 or 96 bytes long." |
| 437 | 501 |
) |
| ... | ... |
@@ -480,6 +544,7 @@ class TestStoreroom: |
| 480 | 544 |
), |
| 481 | 545 |
) |
| 482 | 546 |
def test_405_decrypt_keys_invalid_signature(self, data: bytes) -> None: |
| 547 |
+ """Fail on bad MAC values.""" |
|
| 483 | 548 |
key = b'DEADBEEFdeadbeefDeAdBeEfdEaDbEeF' |
| 484 | 549 |
# Guessing a correct payload plus MAC would be a pre-image |
| 485 | 550 |
# attack on the underlying hash function (SHA-256), i.e. is |
| ... | ... |
@@ -500,6 +565,8 @@ class TestStoreroom: |
| 500 | 565 |
|
| 501 | 566 |
|
| 502 | 567 |
class TestVaultNativeConfig: |
| 568 |
+ """Test the vault-native handler and handler machinery.""" |
|
| 569 |
+ |
|
| 503 | 570 |
@pytest.mark.parametrize( |
| 504 | 571 |
['iterations', 'result'], |
| 505 | 572 |
[ |
| ... | ... |
@@ -508,6 +575,7 @@ class TestVaultNativeConfig: |
| 508 | 575 |
], |
| 509 | 576 |
) |
| 510 | 577 |
def test_200_pbkdf2_manually(self, iterations: int, result: bytes) -> None: |
| 578 |
+ """The PBKDF2 helper function works.""" |
|
| 511 | 579 |
assert ( |
| 512 | 580 |
vault_native.VaultNativeConfigParser._pbkdf2( |
| 513 | 581 |
tests.VAULT_MASTER_KEY.encode('utf-8'), 32, iterations
|
| ... | ... |
@@ -551,7 +619,7 @@ class TestVaultNativeConfig: |
| 551 | 619 |
pytest.param(exporter.export_vault_config_data, id='dispatcher'), |
| 552 | 620 |
], |
| 553 | 621 |
) |
| 554 |
- def test_201_export_vault_native_data_no_arguments( |
|
| 622 |
+ def test_201_export_vault_native_data_explicit_version( |
|
| 555 | 623 |
self, |
| 556 | 624 |
monkeypatch: pytest.MonkeyPatch, |
| 557 | 625 |
config: str, |
| ... | ... |
@@ -559,6 +627,16 @@ class TestVaultNativeConfig: |
| 559 | 627 |
result: _types.VaultConfig | type[Exception], |
| 560 | 628 |
handler: exporter.ExportVaultConfigDataFunction, |
| 561 | 629 |
) -> None: |
| 630 |
+ """Accept data only of the correct version. |
|
| 631 |
+ |
|
| 632 |
+ Note: Historic behavior |
|
| 633 |
+ `derivepassphrase` versions prior to 0.5 automatically tried |
|
| 634 |
+ to parse vault-native configurations as v0.3-type, then |
|
| 635 |
+ v0.2-type. Since `derivepassphrase` 0.5, the command-line |
|
| 636 |
+ interface still tries multi-version parsing, but the API |
|
| 637 |
+ no longer does. |
|
| 638 |
+ |
|
| 639 |
+ """ |
|
| 562 | 640 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 563 | 641 |
with tests.isolated_vault_exporter_config( |
| 564 | 642 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -604,6 +682,12 @@ class TestVaultNativeConfig: |
| 604 | 682 |
key: str | Buffer | None, |
| 605 | 683 |
handler: exporter.ExportVaultConfigDataFunction, |
| 606 | 684 |
) -> None: |
| 685 |
+ """The handler supports different argument types. |
|
| 686 |
+ |
|
| 687 |
+ The [`exporter.export_vault_config_data`][] dispatcher supports |
|
| 688 |
+ them as well. |
|
| 689 |
+ |
|
| 690 |
+ """ |
|
| 607 | 691 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 608 | 692 |
with tests.isolated_vault_exporter_config( |
| 609 | 693 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -640,6 +724,8 @@ class TestVaultNativeConfig: |
| 640 | 724 |
config: str, |
| 641 | 725 |
result: dict[str, Any], |
| 642 | 726 |
) -> None: |
| 727 |
+ """Cache the results of decrypting/decoding a configuration.""" |
|
| 728 |
+ |
|
| 643 | 729 |
def null_func(name: str) -> Callable[..., None]: |
| 644 | 730 |
def func(*_args: Any, **_kwargs: Any) -> None: # pragma: no cover |
| 645 | 731 |
msg = f'disallowed and stubbed out function {name} called'
|
| ... | ... |
@@ -675,5 +761,6 @@ class TestVaultNativeConfig: |
| 675 | 761 |
assert super_call(parser) == result |
| 676 | 762 |
|
| 677 | 763 |
def test_400_no_password(self) -> None: |
| 764 |
+ """Fail on empty master keys/master passphrases.""" |
|
| 678 | 765 |
with pytest.raises(ValueError, match='Password must not be empty'): |
| 679 | 766 |
vault_native.VaultNativeV03ConfigParser(b'', b'') |
| ... | ... |
@@ -19,6 +19,8 @@ if TYPE_CHECKING: |
| 19 | 19 |
|
| 20 | 20 |
|
| 21 | 21 |
class Test001ExporterUtils: |
| 22 |
+ """Test the utility functions in the `exporter` subpackage.""" |
|
| 23 |
+ |
|
| 22 | 24 |
@pytest.mark.parametrize( |
| 23 | 25 |
['expected', 'vault_key', 'logname', 'user', 'username'], |
| 24 | 26 |
[ |
| ... | ... |
@@ -48,6 +50,12 @@ class Test001ExporterUtils: |
| 48 | 50 |
user: str | None, |
| 49 | 51 |
username: str | None, |
| 50 | 52 |
) -> None: |
| 53 |
+ """Look up the vault key in `VAULT_KEY`/`LOGNAME`/`USER`/`USERNAME`. |
|
| 54 |
+ |
|
| 55 |
+ The correct environment variable value is used, according to |
|
| 56 |
+ their relative priorities. |
|
| 57 |
+ |
|
| 58 |
+ """ |
|
| 51 | 59 |
priority_list = [ |
| 52 | 60 |
('VAULT_KEY', vault_key),
|
| 53 | 61 |
('LOGNAME', logname),
|
| ... | ... |
@@ -77,6 +85,11 @@ class Test001ExporterUtils: |
| 77 | 85 |
expected: pathlib.Path, |
| 78 | 86 |
path: str | os.PathLike[str] | None, |
| 79 | 87 |
) -> None: |
| 88 |
+ """Determine the vault path from `VAULT_PATH`. |
|
| 89 |
+ |
|
| 90 |
+ Handle relative paths, absolute paths, and missing paths. |
|
| 91 |
+ |
|
| 92 |
+ """ |
|
| 80 | 93 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 81 | 94 |
with tests.isolated_vault_exporter_config( |
| 82 | 95 |
monkeypatch=monkeypatch, runner=runner |
| ... | ... |
@@ -93,6 +106,8 @@ class Test001ExporterUtils: |
| 93 | 106 |
def test_220_register_export_vault_config_data_handler( |
| 94 | 107 |
self, monkeypatch: pytest.MonkeyPatch |
| 95 | 108 |
) -> None: |
| 109 |
+ """Register vault config data export handlers.""" |
|
| 110 |
+ |
|
| 96 | 111 |
def handler( # pragma: no cover |
| 97 | 112 |
path: str | bytes | os.PathLike | None = None, |
| 98 | 113 |
key: str | Buffer | None = None, |
| ... | ... |
@@ -120,6 +135,7 @@ class Test001ExporterUtils: |
| 120 | 135 |
def test_300_get_vault_key_without_envs( |
| 121 | 136 |
self, monkeypatch: pytest.MonkeyPatch |
| 122 | 137 |
) -> None: |
| 138 |
+ """Fail to look up the vault key in the empty environment.""" |
|
| 123 | 139 |
monkeypatch.delenv('VAULT_KEY', raising=False)
|
| 124 | 140 |
monkeypatch.delenv('LOGNAME', raising=False)
|
| 125 | 141 |
monkeypatch.delenv('USER', raising=False)
|
| ... | ... |
@@ -130,6 +146,8 @@ class Test001ExporterUtils: |
| 130 | 146 |
def test_310_get_vault_path_without_home( |
| 131 | 147 |
self, monkeypatch: pytest.MonkeyPatch |
| 132 | 148 |
) -> None: |
| 149 |
+ """Fail to look up the vault path without `HOME`.""" |
|
| 150 |
+ |
|
| 133 | 151 |
def raiser(*_args: Any, **_kwargs: Any) -> Any: |
| 134 | 152 |
raise RuntimeError('Cannot determine home directory.') # noqa: EM101,TRY003
|
| 135 | 153 |
|
| ... | ... |
@@ -162,6 +180,13 @@ class Test001ExporterUtils: |
| 162 | 180 |
namelist: tuple[str, ...], |
| 163 | 181 |
err_pat: str, |
| 164 | 182 |
) -> None: |
| 183 |
+ """Fail to register a vault config data export handler. |
|
| 184 |
+ |
|
| 185 |
+ Fail because e.g. the associated name is missing, or already |
|
| 186 |
+ present in the handler registry. |
|
| 187 |
+ |
|
| 188 |
+ """ |
|
| 189 |
+ |
|
| 165 | 190 |
def handler( # pragma: no cover |
| 166 | 191 |
path: str | bytes | os.PathLike | None = None, |
| 167 | 192 |
key: str | Buffer | None = None, |
| ... | ... |
@@ -183,6 +208,7 @@ class Test001ExporterUtils: |
| 183 | 208 |
def test_321_export_vault_config_data_bad_handler( |
| 184 | 209 |
self, monkeypatch: pytest.MonkeyPatch |
| 185 | 210 |
) -> None: |
| 211 |
+ """Fail to export vault config data without known handlers.""" |
|
| 186 | 212 |
monkeypatch.setattr(exporter, '_export_vault_config_data_registry', {})
|
| 187 | 213 |
monkeypatch.setattr( |
| 188 | 214 |
exporter, 'find_vault_config_data_handlers', lambda: None |
| ... | ... |
@@ -195,10 +221,13 @@ class Test001ExporterUtils: |
| 195 | 221 |
|
| 196 | 222 |
|
| 197 | 223 |
class Test002CLI: |
| 224 |
+ """Test the command-line functionality of the `exporter` subpackage.""" |
|
| 225 |
+ |
|
| 198 | 226 |
def test_300_invalid_format( |
| 199 | 227 |
self, |
| 200 | 228 |
monkeypatch: pytest.MonkeyPatch, |
| 201 | 229 |
) -> None: |
| 230 |
+ """Reject invalid vault configuration format names.""" |
|
| 202 | 231 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 203 | 232 |
with tests.isolated_vault_exporter_config( |
| 204 | 233 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -249,6 +278,7 @@ class Test002CLI: |
| 249 | 278 |
config: str | bytes, |
| 250 | 279 |
key: str, |
| 251 | 280 |
) -> None: |
| 281 |
+ """Abort export call if no cryptography is available.""" |
|
| 252 | 282 |
runner = click.testing.CliRunner(mix_stderr=False) |
| 253 | 283 |
with tests.isolated_vault_exporter_config( |
| 254 | 284 |
monkeypatch=monkeypatch, |
| ... | ... |
@@ -19,6 +19,8 @@ def bitseq(string: str) -> list[int]: |
| 19 | 19 |
|
| 20 | 20 |
|
| 21 | 21 |
class TestStaticFunctionality: |
| 22 |
+ """Test the static functionality in the `sequin` module.""" |
|
| 23 |
+ |
|
| 22 | 24 |
@pytest.mark.parametrize( |
| 23 | 25 |
['sequence', 'base', 'expected'], |
| 24 | 26 |
[ |
| ... | ... |
@@ -32,6 +34,11 @@ class TestStaticFunctionality: |
| 32 | 34 |
def test_200_big_endian_number( |
| 33 | 35 |
self, sequence: list[int], base: int, expected: int |
| 34 | 36 |
) -> None: |
| 37 |
+ """Conversion to big endian numbers in any base works. |
|
| 38 |
+ |
|
| 39 |
+ See [`sequin.Sequin.generate`][] for where this is used. |
|
| 40 |
+ |
|
| 41 |
+ """ |
|
| 35 | 42 |
assert ( |
| 36 | 43 |
sequin.Sequin._big_endian_number(sequence, base=base) |
| 37 | 44 |
) == expected |
| ... | ... |
@@ -51,11 +58,18 @@ class TestStaticFunctionality: |
| 51 | 58 |
sequence: list[int], |
| 52 | 59 |
base: int, |
| 53 | 60 |
) -> None: |
| 61 |
+ """Nonsensical conversion of numbers in a given base raises. |
|
| 62 |
+ |
|
| 63 |
+ See [`sequin.Sequin.generate`][] for where this is used. |
|
| 64 |
+ |
|
| 65 |
+ """ |
|
| 54 | 66 |
with pytest.raises(exc_type, match=exc_pattern): |
| 55 | 67 |
sequin.Sequin._big_endian_number(sequence, base=base) |
| 56 | 68 |
|
| 57 | 69 |
|
| 58 | 70 |
class TestSequin: |
| 71 |
+ """Test the `Sequin` class.""" |
|
| 72 |
+ |
|
| 59 | 73 |
@pytest.mark.parametrize( |
| 60 | 74 |
['sequence', 'is_bitstring', 'expected'], |
| 61 | 75 |
[ |
| ... | ... |
@@ -75,10 +89,12 @@ class TestSequin: |
| 75 | 89 |
is_bitstring: bool, |
| 76 | 90 |
expected: list[int], |
| 77 | 91 |
) -> None: |
| 92 |
+ """The constructor handles both bit and integer sequences.""" |
|
| 78 | 93 |
seq = sequin.Sequin(sequence, is_bitstring=is_bitstring) |
| 79 | 94 |
assert seq.bases == {2: collections.deque(expected)}
|
| 80 | 95 |
|
| 81 | 96 |
def test_201_generating(self) -> None: |
| 97 |
+ """The sequin generates deterministic sequences.""" |
|
| 82 | 98 |
seq = sequin.Sequin( |
| 83 | 99 |
[1, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1], is_bitstring=True |
| 84 | 100 |
) |
| ... | ... |
@@ -97,6 +113,7 @@ class TestSequin: |
| 97 | 113 |
seq.generate(0) |
| 98 | 114 |
|
| 99 | 115 |
def test_210_internal_generating(self) -> None: |
| 116 |
+ """The sequin internals generate deterministic sequences.""" |
|
| 100 | 117 |
seq = sequin.Sequin( |
| 101 | 118 |
[1, 1, 0, 1, 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1], is_bitstring=True |
| 102 | 119 |
) |
| ... | ... |
@@ -115,6 +132,12 @@ class TestSequin: |
| 115 | 132 |
seq._generate_inner(16, base=1) |
| 116 | 133 |
|
| 117 | 134 |
def test_211_shifting(self) -> None: |
| 135 |
+ """The sequin manages the pool of remaining entropy for each base. |
|
| 136 |
+ |
|
| 137 |
+ Specifically, the sequin implements all-or-nothing fixed-length |
|
| 138 |
+ draws from the entropy pool. |
|
| 139 |
+ |
|
| 140 |
+ """ |
|
| 118 | 141 |
seq = sequin.Sequin([1, 0, 1, 0, 0, 1, 0, 0, 0, 1], is_bitstring=True) |
| 119 | 142 |
assert seq.bases == {
|
| 120 | 143 |
2: collections.deque([1, 0, 1, 0, 0, 1, 0, 0, 0, 1]) |
| ... | ... |
@@ -149,5 +172,6 @@ class TestSequin: |
| 149 | 172 |
exc_type: type[Exception], |
| 150 | 173 |
exc_pattern: str, |
| 151 | 174 |
) -> None: |
| 175 |
+ """The sequin raises on invalid bit and integer sequences.""" |
|
| 152 | 176 |
with pytest.raises(exc_type, match=exc_pattern): |
| 153 | 177 |
sequin.Sequin(sequence, is_bitstring=is_bitstring) |
| ... | ... |
@@ -28,6 +28,10 @@ if TYPE_CHECKING: |
| 28 | 28 |
|
| 29 | 29 |
|
| 30 | 30 |
class TestStaticFunctionality: |
| 31 |
+ """Test the static functionality of the `ssh_agent` module.""" |
|
| 32 |
+ |
|
| 33 |
+ # TODO(the-13th-letter): Re-evaluate if this check is worth keeping. |
|
| 34 |
+ # It cannot provide true tamper-resistence, but probably appears to. |
|
| 31 | 35 |
@pytest.mark.parametrize( |
| 32 | 36 |
['public_key', 'public_key_data'], |
| 33 | 37 |
[ |
| ... | ... |
@@ -39,6 +43,7 @@ class TestStaticFunctionality: |
| 39 | 43 |
def test_100_key_decoding( |
| 40 | 44 |
self, public_key: bytes, public_key_data: bytes |
| 41 | 45 |
) -> None: |
| 46 |
+ """The [`tests.ALL_KEYS`][] public key data looks sane.""" |
|
| 42 | 47 |
keydata = base64.b64decode(public_key.split(None, 2)[1]) |
| 43 | 48 |
assert keydata == public_key_data, ( |
| 44 | 49 |
"recorded public key data doesn't match" |
| ... | ... |
@@ -118,6 +123,7 @@ class TestStaticFunctionality: |
| 118 | 123 |
def test_190_sh_export_line_parsing( |
| 119 | 124 |
self, line: str, env_name: str, value: str | None |
| 120 | 125 |
) -> None: |
| 126 |
+ """[`tests.parse_sh_export_line`][] works.""" |
|
| 121 | 127 |
if value is not None: |
| 122 | 128 |
assert tests.parse_sh_export_line(line, env_name=env_name) == value |
| 123 | 129 |
else: |
| ... | ... |
@@ -129,6 +135,7 @@ class TestStaticFunctionality: |
| 129 | 135 |
monkeypatch: pytest.MonkeyPatch, |
| 130 | 136 |
skip_if_no_af_unix_support: None, |
| 131 | 137 |
) -> None: |
| 138 |
+ """Abort if the running agent cannot be located.""" |
|
| 132 | 139 |
del skip_if_no_af_unix_support |
| 133 | 140 |
monkeypatch.delenv('SSH_AUTH_SOCK', raising=False)
|
| 134 | 141 |
with pytest.raises( |
| ... | ... |
@@ -143,6 +150,7 @@ class TestStaticFunctionality: |
| 143 | 150 |
], |
| 144 | 151 |
) |
| 145 | 152 |
def test_210_uint32(self, input: int, expected: bytes | bytearray) -> None: |
| 153 |
+ """`uint32` encoding works.""" |
|
| 146 | 154 |
uint32 = ssh_agent.SSHAgentClient.uint32 |
| 147 | 155 |
assert uint32(input) == expected |
| 148 | 156 |
|
| ... | ... |
@@ -169,6 +177,7 @@ class TestStaticFunctionality: |
| 169 | 177 |
def test_211_string( |
| 170 | 178 |
self, input: bytes | bytearray, expected: bytes | bytearray |
| 171 | 179 |
) -> None: |
| 180 |
+ """SSH string encoding works.""" |
|
| 172 | 181 |
string = ssh_agent.SSHAgentClient.string |
| 173 | 182 |
assert bytes(string(input)) == expected |
| 174 | 183 |
|
| ... | ... |
@@ -190,6 +199,7 @@ class TestStaticFunctionality: |
| 190 | 199 |
def test_212_unstring( |
| 191 | 200 |
self, input: bytes | bytearray, expected: bytes | bytearray |
| 192 | 201 |
) -> None: |
| 202 |
+ """SSH string decoding works.""" |
|
| 193 | 203 |
unstring = ssh_agent.SSHAgentClient.unstring |
| 194 | 204 |
unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix |
| 195 | 205 |
assert bytes(unstring(input)) == expected |
| ... | ... |
@@ -218,6 +228,7 @@ class TestStaticFunctionality: |
| 218 | 228 |
def test_310_uint32_exceptions( |
| 219 | 229 |
self, value: int, exc_type: type[Exception], exc_pattern: str |
| 220 | 230 |
) -> None: |
| 231 |
+ """`uint32` encoding fails for out-of-bound values.""" |
|
| 221 | 232 |
uint32 = ssh_agent.SSHAgentClient.uint32 |
| 222 | 233 |
with pytest.raises(exc_type, match=exc_pattern): |
| 223 | 234 |
uint32(value) |
| ... | ... |
@@ -233,6 +244,7 @@ class TestStaticFunctionality: |
| 233 | 244 |
def test_311_string_exceptions( |
| 234 | 245 |
self, input: Any, exc_type: type[Exception], exc_pattern: str |
| 235 | 246 |
) -> None: |
| 247 |
+ """SSH string encoding fails for non-strings.""" |
|
| 236 | 248 |
string = ssh_agent.SSHAgentClient.string |
| 237 | 249 |
with pytest.raises(exc_type, match=exc_pattern): |
| 238 | 250 |
string(input) |
| ... | ... |
@@ -274,6 +286,7 @@ class TestStaticFunctionality: |
| 274 | 286 |
has_trailer: bool, |
| 275 | 287 |
parts: tuple[bytes | bytearray, bytes | bytearray] | None, |
| 276 | 288 |
) -> None: |
| 289 |
+ """SSH string decoding fails for invalid values.""" |
|
| 277 | 290 |
unstring = ssh_agent.SSHAgentClient.unstring |
| 278 | 291 |
unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix |
| 279 | 292 |
with pytest.raises(exc_type, match=exc_pattern): |
| ... | ... |
@@ -286,6 +299,11 @@ class TestStaticFunctionality: |
| 286 | 299 |
|
| 287 | 300 |
|
| 288 | 301 |
class TestAgentInteraction: |
| 302 |
+ """Test actually talking to the SSH agent.""" |
|
| 303 |
+ |
|
| 304 |
+ # TODO(the-13th-letter): Convert skip into xfail, and include the |
|
| 305 |
+ # key type in the skip/xfail message. This means the key type needs |
|
| 306 |
+ # to be passed to the test function as well. |
|
| 289 | 307 |
@pytest.mark.parametrize( |
| 290 | 308 |
'ssh_test_key', |
| 291 | 309 |
list(tests.SUPPORTED_KEYS.values()), |
| ... | ... |
@@ -296,6 +314,13 @@ class TestAgentInteraction: |
| 296 | 314 |
ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient, |
| 297 | 315 |
ssh_test_key: tests.SSHTestKey, |
| 298 | 316 |
) -> None: |
| 317 |
+ """Signing data with specific SSH keys works. |
|
| 318 |
+ |
|
| 319 |
+ Single tests may abort early (skip) if the indicated key is not |
|
| 320 |
+ loaded in the agent. Presumably this means the key type is |
|
| 321 |
+ unsupported. |
|
| 322 |
+ |
|
| 323 |
+ """ |
|
| 299 | 324 |
client = ssh_agent_client_with_test_keys_loaded |
| 300 | 325 |
key_comment_pairs = {bytes(k): bytes(c) for k, c in client.list_keys()}
|
| 301 | 326 |
public_key_data = ssh_test_key.public_key_data |
| ... | ... |
@@ -318,6 +343,9 @@ class TestAgentInteraction: |
| 318 | 343 |
== derived_passphrase |
| 319 | 344 |
), 'SSH signature mismatch' |
| 320 | 345 |
|
| 346 |
+ # TODO(the-13th-letter): Include the key type in the skip message. |
|
| 347 |
+ # This means the key type needs to be passed to the test function as |
|
| 348 |
+ # well. |
|
| 321 | 349 |
@pytest.mark.parametrize( |
| 322 | 350 |
'ssh_test_key', |
| 323 | 351 |
list(tests.UNSUITABLE_KEYS.values()), |
| ... | ... |
@@ -328,6 +356,15 @@ class TestAgentInteraction: |
| 328 | 356 |
ssh_agent_client_with_test_keys_loaded: ssh_agent.SSHAgentClient, |
| 329 | 357 |
ssh_test_key: tests.SSHTestKey, |
| 330 | 358 |
) -> None: |
| 359 |
+ """Using an unsuitable key with [`vault.Vault`][] fails. |
|
| 360 |
+ |
|
| 361 |
+ Single tests may abort early (skip) if the indicated key is not |
|
| 362 |
+ loaded in the agent. Presumably this means the key type is |
|
| 363 |
+ unsupported. Single tests may also abort early if the agent |
|
| 364 |
+ ensures that the generally unsuitable key is actually suitable |
|
| 365 |
+ under this agent. |
|
| 366 |
+ |
|
| 367 |
+ """ |
|
| 331 | 368 |
client = ssh_agent_client_with_test_keys_loaded |
| 332 | 369 |
key_comment_pairs = {bytes(k): bytes(c) for k, c in client.list_keys()}
|
| 333 | 370 |
public_key_data = ssh_test_key.public_key_data |
| ... | ... |
@@ -357,9 +394,16 @@ class TestAgentInteraction: |
| 357 | 394 |
key: bytes, |
| 358 | 395 |
single: bool, |
| 359 | 396 |
) -> None: |
| 397 |
+ """The key selector presents exactly the suitable keys. |
|
| 398 |
+ |
|
| 399 |
+ "Suitable" here means suitability for this SSH agent |
|
| 400 |
+ specifically. |
|
| 401 |
+ |
|
| 402 |
+ """ |
|
| 360 | 403 |
client = ssh_agent_client_with_test_keys_loaded |
| 361 | 404 |
|
| 362 | 405 |
def key_is_suitable(key: bytes) -> bool: |
| 406 |
+ """Stub out [`vault.Vault.key_is_suitable`][].""" |
|
| 363 | 407 |
always = {v.public_key_data for v in tests.SUPPORTED_KEYS.values()}
|
| 364 | 408 |
dsa = {
|
| 365 | 409 |
v.public_key_data |
| ... | ... |
@@ -370,6 +414,11 @@ class TestAgentInteraction: |
| 370 | 414 |
client.has_deterministic_dsa_signatures() and key in dsa |
| 371 | 415 |
) |
| 372 | 416 |
|
| 417 |
+ # TODO(the-13th-letter): Handle the unlikely(?) case that only |
|
| 418 |
+ # one test key is loaded, but `single` is False. Rename the |
|
| 419 |
+ # `index` variable to `input`, store the `input` in there, and |
|
| 420 |
+ # make the definition of `text` in the else block dependent on |
|
| 421 |
+ # `n` being singular or non-singular. |
|
| 373 | 422 |
if single: |
| 374 | 423 |
monkeypatch.setattr( |
| 375 | 424 |
ssh_agent.SSHAgentClient, |
| ... | ... |
@@ -399,9 +448,12 @@ class TestAgentInteraction: |
| 399 | 448 |
|
| 400 | 449 |
@click.command() |
| 401 | 450 |
def driver() -> None: |
| 451 |
+ """Call `cli._select_ssh_key` directly, as a command.""" |
|
| 402 | 452 |
key = cli._select_ssh_key() |
| 403 | 453 |
click.echo(base64.standard_b64encode(key).decode('ASCII'))
|
| 404 | 454 |
|
| 455 |
+ # TODO(the-13th-letter): (Continued from above.) Update input |
|
| 456 |
+ # data to use `index`/`input` directly and unconditionally. |
|
| 405 | 457 |
runner = click.testing.CliRunner(mix_stderr=True) |
| 406 | 458 |
result_ = runner.invoke( |
| 407 | 459 |
driver, |
| ... | ... |
@@ -418,6 +470,7 @@ class TestAgentInteraction: |
| 418 | 470 |
monkeypatch: pytest.MonkeyPatch, |
| 419 | 471 |
running_ssh_agent: tests.RunningSSHAgentInfo, |
| 420 | 472 |
) -> None: |
| 473 |
+ """Fail if the agent address is invalid.""" |
|
| 421 | 474 |
with monkeypatch.context() as monkeypatch2: |
| 422 | 475 |
monkeypatch2.setenv( |
| 423 | 476 |
'SSH_AUTH_SOCK', running_ssh_agent.socket + '~' |
| ... | ... |
@@ -430,6 +483,7 @@ class TestAgentInteraction: |
| 430 | 483 |
self, |
| 431 | 484 |
monkeypatch: pytest.MonkeyPatch, |
| 432 | 485 |
) -> None: |
| 486 |
+ """Fail without [`socket.AF_UNIX`][] support.""" |
|
| 433 | 487 |
with monkeypatch.context() as monkeypatch2: |
| 434 | 488 |
monkeypatch2.setenv('SSH_AUTH_SOCK', "the value doesn't matter")
|
| 435 | 489 |
monkeypatch2.delattr(socket, 'AF_UNIX', raising=False) |
| ... | ... |
@@ -453,6 +507,7 @@ class TestAgentInteraction: |
| 453 | 507 |
running_ssh_agent: tests.RunningSSHAgentInfo, |
| 454 | 508 |
response: bytes, |
| 455 | 509 |
) -> None: |
| 510 |
+ """Fail on truncated responses from the SSH agent.""" |
|
| 456 | 511 |
del running_ssh_agent |
| 457 | 512 |
client = ssh_agent.SSHAgentClient() |
| 458 | 513 |
response_stream = io.BytesIO(response) |
| ... | ... |
@@ -504,6 +559,16 @@ class TestAgentInteraction: |
| 504 | 559 |
exc_type: type[Exception], |
| 505 | 560 |
exc_pattern: str, |
| 506 | 561 |
) -> None: |
| 562 |
+ """Fail on problems during key listing. |
|
| 563 |
+ |
|
| 564 |
+ Known problems: |
|
| 565 |
+ |
|
| 566 |
+ - The agent refuses, or otherwise indicates the operation |
|
| 567 |
+ failed. |
|
| 568 |
+ - The agent response is truncated. |
|
| 569 |
+ - The agent response is overlong. |
|
| 570 |
+ |
|
| 571 |
+ """ |
|
| 507 | 572 |
del running_ssh_agent |
| 508 | 573 |
|
| 509 | 574 |
passed_response_code = response_code |
| ... | ... |
@@ -584,6 +649,15 @@ class TestAgentInteraction: |
| 584 | 649 |
exc_type: type[Exception], |
| 585 | 650 |
exc_pattern: str, |
| 586 | 651 |
) -> None: |
| 652 |
+ """Fail on problems during signing. |
|
| 653 |
+ |
|
| 654 |
+ Known problems: |
|
| 655 |
+ |
|
| 656 |
+ - The key is not loaded into the agent. |
|
| 657 |
+ - The agent refuses, or otherwise indicates the operation |
|
| 658 |
+ failed. |
|
| 659 |
+ |
|
| 660 |
+ """ |
|
| 587 | 661 |
del running_ssh_agent |
| 588 | 662 |
passed_response_code = response_code |
| 589 | 663 |
|
| ... | ... |
@@ -651,6 +725,15 @@ class TestAgentInteraction: |
| 651 | 725 |
exc_type: type[Exception], |
| 652 | 726 |
exc_pattern: str, |
| 653 | 727 |
) -> None: |
| 728 |
+ """Fail on problems during signing. |
|
| 729 |
+ |
|
| 730 |
+ Known problems: |
|
| 731 |
+ |
|
| 732 |
+ - The key is not loaded into the agent. |
|
| 733 |
+ - The agent refuses, or otherwise indicates the operation |
|
| 734 |
+ failed. |
|
| 735 |
+ |
|
| 736 |
+ """ |
|
| 654 | 737 |
del running_ssh_agent |
| 655 | 738 |
|
| 656 | 739 |
# TODO(the-13th-letter): Rewrite using parenthesized |
| ... | ... |
@@ -683,6 +766,7 @@ class TestAgentInteraction: |
| 683 | 766 |
running_ssh_agent: tests.RunningSSHAgentInfo, |
| 684 | 767 |
response_data: bytes, |
| 685 | 768 |
) -> None: |
| 769 |
+ """Fail on malformed responses while querying extensions.""" |
|
| 686 | 770 |
del running_ssh_agent |
| 687 | 771 |
|
| 688 | 772 |
def request( |
| ... | ... |
@@ -738,17 +822,21 @@ class TestAgentInteraction: |
| 738 | 822 |
|
| 739 | 823 |
|
| 740 | 824 |
class TestHypotheses: |
| 825 |
+ """Test properties via hypothesis.""" |
|
| 826 |
+ |
|
| 741 | 827 |
@hypothesis.given(strategies.integers(min_value=0, max_value=0xFFFFFFFF)) |
| 742 | 828 |
# standard example value |
| 743 | 829 |
@hypothesis.example(0xDEADBEEF) |
| 744 |
- def test_210_uint32(self, num: int) -> None: |
|
| 830 |
+ def test_210a_uint32_from_number(self, num: int) -> None: |
|
| 831 |
+ """`uint32` encoding works, starting from numbers.""" |
|
| 745 | 832 |
uint32 = ssh_agent.SSHAgentClient.uint32 |
| 746 | 833 |
assert int.from_bytes(uint32(num), 'big', signed=False) == num |
| 747 | 834 |
|
| 748 | 835 |
@hypothesis.given(strategies.binary(min_size=4, max_size=4)) |
| 749 | 836 |
# standard example value |
| 750 | 837 |
@hypothesis.example(b'\xde\xad\xbe\xef') |
| 751 |
- def test_210a_uint32(self, bytestring: bytes) -> None: |
|
| 838 |
+ def test_210b_uint32_from_bytestring(self, bytestring: bytes) -> None: |
|
| 839 |
+ """`uint32` encoding works, starting from length four byte strings.""" |
|
| 752 | 840 |
uint32 = ssh_agent.SSHAgentClient.uint32 |
| 753 | 841 |
assert ( |
| 754 | 842 |
uint32(int.from_bytes(bytestring, 'big', signed=False)) |
| ... | ... |
@@ -758,7 +846,8 @@ class TestHypotheses: |
| 758 | 846 |
@hypothesis.given(strategies.binary(max_size=0x0001FFFF)) |
| 759 | 847 |
# example: highest order bit is set |
| 760 | 848 |
@hypothesis.example(b'DEADBEEF' * 10000) |
| 761 |
- def test_211_string(self, bytestring: bytes) -> None: |
|
| 849 |
+ def test_211a_string_from_bytestring(self, bytestring: bytes) -> None: |
|
| 850 |
+ """SSH string encoding works, starting from a byte string.""" |
|
| 762 | 851 |
res = ssh_agent.SSHAgentClient.string(bytestring) |
| 763 | 852 |
assert res.startswith((b'\x00\x00', b'\x00\x01')) |
| 764 | 853 |
assert int.from_bytes(res[:4], 'big', signed=False) == len(bytestring) |
| ... | ... |
@@ -768,6 +857,7 @@ class TestHypotheses: |
| 768 | 857 |
# example: check for double-deserialization |
| 769 | 858 |
@hypothesis.example(b'\x00\x00\x00\x07ssh-rsa') |
| 770 | 859 |
def test_212_string_unstring(self, bytestring: bytes) -> None: |
| 860 |
+ """SSH string decoding of encoded SSH strings works.""" |
|
| 771 | 861 |
string = ssh_agent.SSHAgentClient.string |
| 772 | 862 |
unstring = ssh_agent.SSHAgentClient.unstring |
| 773 | 863 |
unstring_prefix = ssh_agent.SSHAgentClient.unstring_prefix |
| ... | ... |
@@ -58,6 +58,11 @@ from derivepassphrase import _types |
| 58 | 58 |
), |
| 59 | 59 |
) |
| 60 | 60 |
def test_100_js_truthiness(value: Any) -> None: |
| 61 |
+ """Determine the truthiness of a value according to JavaScript. |
|
| 62 |
+ |
|
| 63 |
+ Use hypothesis to generate test values. |
|
| 64 |
+ |
|
| 65 |
+ """ |
|
| 61 | 66 |
expected = ( |
| 62 | 67 |
value is not None # noqa: PLR1714 |
| 63 | 68 |
and value != False # noqa: E712 |
| ... | ... |
@@ -78,6 +83,15 @@ def test_100_js_truthiness(value: Any) -> None: |
| 78 | 83 |
ids=tests._test_config_ids, |
| 79 | 84 |
) |
| 80 | 85 |
def test_200_is_vault_config(test_config: tests.VaultTestConfig) -> None: |
| 86 |
+ """Is this vault configuration recognized as valid/invalid? |
|
| 87 |
+ |
|
| 88 |
+ Check all test configurations that do not need custom validation |
|
| 89 |
+ settings. |
|
| 90 |
+ |
|
| 91 |
+ This primarily tests the [`_types.is_vault_config`][] and |
|
| 92 |
+ [`_types.clean_up_falsy_vault_config_values`][] functions. |
|
| 93 |
+ |
|
| 94 |
+ """ |
|
| 81 | 95 |
obj, comment, _ = test_config |
| 82 | 96 |
obj = copy.deepcopy(obj) |
| 83 | 97 |
_types.clean_up_falsy_vault_config_values(obj) |
| ... | ... |
@@ -99,6 +113,15 @@ def test_200_is_vault_config(test_config: tests.VaultTestConfig) -> None: |
| 99 | 113 |
def test_200a_is_vault_config_smudged( |
| 100 | 114 |
test_config: tests.VaultTestConfig, |
| 101 | 115 |
) -> None: |
| 116 |
+ """Is this vault configuration recognized as valid/invalid? |
|
| 117 |
+ |
|
| 118 |
+ Generate test data via hypothesis by smudging all valid test |
|
| 119 |
+ configurations. |
|
| 120 |
+ |
|
| 121 |
+ This primarily tests the [`_types.is_vault_config`][] and |
|
| 122 |
+ [`_types.clean_up_falsy_vault_config_values`][] functions. |
|
| 123 |
+ |
|
| 124 |
+ """ |
|
| 102 | 125 |
obj_, comment, _ = test_config |
| 103 | 126 |
obj = copy.deepcopy(obj_) |
| 104 | 127 |
did_cleanup = _types.clean_up_falsy_vault_config_values(obj) |
| ... | ... |
@@ -116,6 +139,15 @@ def test_200a_is_vault_config_smudged( |
| 116 | 139 |
'test_config', tests.TEST_CONFIGS, ids=tests._test_config_ids |
| 117 | 140 |
) |
| 118 | 141 |
def test_400_validate_vault_config(test_config: tests.VaultTestConfig) -> None: |
| 142 |
+ """Validate this vault configuration. |
|
| 143 |
+ |
|
| 144 |
+ Check all test configurations, including those with non-standard |
|
| 145 |
+ validation settings. |
|
| 146 |
+ |
|
| 147 |
+ This primarily tests the [`_types.validate_vault_config`][] and |
|
| 148 |
+ [`_types.clean_up_falsy_vault_config_values`][] functions. |
|
| 149 |
+ |
|
| 150 |
+ """ |
|
| 119 | 151 |
obj, comment, validation_settings = test_config |
| 120 | 152 |
(allow_unknown_settings,) = validation_settings or (True,) |
| 121 | 153 |
obj = copy.deepcopy(obj) |
| ... | ... |
@@ -147,6 +179,15 @@ def test_400_validate_vault_config(test_config: tests.VaultTestConfig) -> None: |
| 147 | 179 |
def test_400a_validate_vault_config_smudged( |
| 148 | 180 |
test_config: tests.VaultTestConfig, |
| 149 | 181 |
) -> None: |
| 182 |
+ """Validate this vault configuration. |
|
| 183 |
+ |
|
| 184 |
+ Generate test data via hypothesis by smudging all smudgable test |
|
| 185 |
+ configurations. |
|
| 186 |
+ |
|
| 187 |
+ This primarily tests the [`_types.validate_vault_config`][] and |
|
| 188 |
+ [`_types.clean_up_falsy_vault_config_values`][] functions. |
|
| 189 |
+ |
|
| 190 |
+ """ |
|
| 150 | 191 |
obj_, comment, validation_settings = test_config |
| 151 | 192 |
(allow_unknown_settings,) = validation_settings or (True,) |
| 152 | 193 |
obj = copy.deepcopy(obj_) |
| ... | ... |
@@ -24,9 +24,20 @@ Vault: TypeAlias = derivepassphrase.vault.Vault |
| 24 | 24 |
|
| 25 | 25 |
|
| 26 | 26 |
class TestVault: |
| 27 |
+ """Test passphrase derivation with the "vault" scheme.""" |
|
| 28 |
+ |
|
| 27 | 29 |
phrase = b'She cells C shells bye the sea shoars' |
| 30 |
+ """The standard passphrase from <i>vault</i>(1)'s test suite.""" |
|
| 28 | 31 |
google_phrase = rb': 4TVH#5:aZl8LueOT\{'
|
| 32 |
+ """ |
|
| 33 |
+ The standard derived passphrase for the "google" service, from |
|
| 34 |
+ <i>vault</i>(1)'s test suite. |
|
| 35 |
+ """ |
|
| 29 | 36 |
twitter_phrase = rb"[ (HN_N:lI&<ro=)3'g9" |
| 37 |
+ """ |
|
| 38 |
+ The standard derived passphrase for the "twitter" service, from |
|
| 39 |
+ <i>vault</i>(1)'s test suite. |
|
| 40 |
+ """ |
|
| 30 | 41 |
|
| 31 | 42 |
@pytest.mark.parametrize( |
| 32 | 43 |
['service', 'expected'], |
| ... | ... |
@@ -38,30 +49,36 @@ class TestVault: |
| 38 | 49 |
def test_200_basic_configuration( |
| 39 | 50 |
self, service: bytes | str, expected: bytes |
| 40 | 51 |
) -> None: |
| 52 |
+ """Deriving a passphrase principally works.""" |
|
| 41 | 53 |
assert Vault(phrase=self.phrase).generate(service) == expected |
| 42 | 54 |
|
| 43 | 55 |
def test_201_phrase_dependence(self) -> None: |
| 56 |
+ """The derived passphrase is dependent on the master passphrase.""" |
|
| 44 | 57 |
assert ( |
| 45 | 58 |
Vault(phrase=(self.phrase + b'X')).generate('google')
|
| 46 | 59 |
== b'n+oIz6sL>K*lTEWYRO%7' |
| 47 | 60 |
) |
| 48 | 61 |
|
| 49 | 62 |
def test_202_reproducibility_and_bytes_service_name(self) -> None: |
| 63 |
+ """Deriving a passphrase works equally for byte strings.""" |
|
| 50 | 64 |
assert Vault(phrase=self.phrase).generate(b'google') == Vault( |
| 51 | 65 |
phrase=self.phrase |
| 52 | 66 |
).generate('google')
|
| 53 | 67 |
|
| 54 | 68 |
def test_203_reproducibility_and_bytearray_service_name(self) -> None: |
| 69 |
+ """Deriving a passphrase works equally for byte arrays.""" |
|
| 55 | 70 |
assert Vault(phrase=self.phrase).generate(b'google') == Vault( |
| 56 | 71 |
phrase=self.phrase |
| 57 | 72 |
).generate(bytearray(b'google')) |
| 58 | 73 |
|
| 59 | 74 |
def test_210_nonstandard_length(self) -> None: |
| 75 |
+ """Deriving a passphrase adheres to imposed length limits.""" |
|
| 60 | 76 |
assert ( |
| 61 | 77 |
Vault(phrase=self.phrase, length=4).generate('google') == b'xDFu'
|
| 62 | 78 |
) |
| 63 | 79 |
|
| 64 | 80 |
def test_211_repetition_limit(self) -> None: |
| 81 |
+ """Deriving a passphrase adheres to imposed repetition limits.""" |
|
| 65 | 82 |
assert ( |
| 66 | 83 |
Vault( |
| 67 | 84 |
phrase=b'', length=24, symbol=0, number=0, repeat=1 |
| ... | ... |
@@ -70,36 +87,44 @@ class TestVault: |
| 70 | 87 |
) |
| 71 | 88 |
|
| 72 | 89 |
def test_212_without_symbols(self) -> None: |
| 90 |
+ """Deriving a passphrase adheres to imposed limits on symbols.""" |
|
| 73 | 91 |
assert ( |
| 74 | 92 |
Vault(phrase=self.phrase, symbol=0).generate('google')
|
| 75 | 93 |
== b'XZ4wRe0bZCazbljCaMqR' |
| 76 | 94 |
) |
| 77 | 95 |
|
| 78 | 96 |
def test_213_no_numbers(self) -> None: |
| 97 |
+ """Deriving a passphrase adheres to imposed limits on numbers.""" |
|
| 79 | 98 |
assert ( |
| 80 | 99 |
Vault(phrase=self.phrase, number=0).generate('google')
|
| 81 | 100 |
== b'_*$TVH.%^aZl(LUeOT?>' |
| 82 | 101 |
) |
| 83 | 102 |
|
| 84 | 103 |
def test_214_no_lowercase_letters(self) -> None: |
| 104 |
+ """ |
|
| 105 |
+ Deriving a passphrase adheres to imposed limits on lowercase letters. |
|
| 106 |
+ """ |
|
| 85 | 107 |
assert ( |
| 86 | 108 |
Vault(phrase=self.phrase, lower=0).generate('google')
|
| 87 | 109 |
== b':{?)+7~@OA:L]!0E$)(+'
|
| 88 | 110 |
) |
| 89 | 111 |
|
| 90 | 112 |
def test_215_at_least_5_digits(self) -> None: |
| 113 |
+ """Deriving a passphrase adheres to imposed counts of numbers.""" |
|
| 91 | 114 |
assert ( |
| 92 | 115 |
Vault(phrase=self.phrase, length=8, number=5).generate('songkick')
|
| 93 | 116 |
== b'i0908.7[' |
| 94 | 117 |
) |
| 95 | 118 |
|
| 96 | 119 |
def test_216_lots_of_spaces(self) -> None: |
| 120 |
+ """Deriving a passphrase adheres to imposed counts of spaces.""" |
|
| 97 | 121 |
assert ( |
| 98 | 122 |
Vault(phrase=self.phrase, space=12).generate('songkick')
|
| 99 | 123 |
== b' c 6 Bq % 5fR ' |
| 100 | 124 |
) |
| 101 | 125 |
|
| 102 | 126 |
def test_217_all_character_classes(self) -> None: |
| 127 |
+ """Deriving a passphrase adheres to imposed counts of all types.""" |
|
| 103 | 128 |
assert ( |
| 104 | 129 |
Vault( |
| 105 | 130 |
phrase=self.phrase, |
| ... | ... |
@@ -114,6 +139,11 @@ class TestVault: |
| 114 | 139 |
) |
| 115 | 140 |
|
| 116 | 141 |
def test_218_only_numbers_and_very_high_repetition_limit(self) -> None: |
| 142 |
+ """Deriving a passphrase adheres to imposed repetition limits. |
|
| 143 |
+ |
|
| 144 |
+ This example is checked explicitly against forbidden substrings. |
|
| 145 |
+ |
|
| 146 |
+ """ |
|
| 117 | 147 |
generated = Vault( |
| 118 | 148 |
phrase=b'', |
| 119 | 149 |
length=40, |
| ... | ... |
@@ -140,12 +170,14 @@ class TestVault: |
| 140 | 170 |
assert substring not in generated |
| 141 | 171 |
|
| 142 | 172 |
def test_219_very_limited_character_set(self) -> None: |
| 173 |
+ """Deriving a passphrase works even with limited character sets.""" |
|
| 143 | 174 |
generated = Vault( |
| 144 | 175 |
phrase=b'', length=24, lower=0, upper=0, space=0, symbol=0 |
| 145 | 176 |
).generate('testing')
|
| 146 | 177 |
assert generated == b'763252593304946694588866' |
| 147 | 178 |
|
| 148 | 179 |
def test_220_character_set_subtraction(self) -> None: |
| 180 |
+ """Removing allowed characters internally works.""" |
|
| 149 | 181 |
assert Vault._subtract(b'be', b'abcdef') == bytearray(b'acdf') |
| 150 | 182 |
|
| 151 | 183 |
@pytest.mark.parametrize( |
| ... | ... |
@@ -170,6 +202,7 @@ class TestVault: |
| 170 | 202 |
def test_221_entropy( |
| 171 | 203 |
self, length: int, settings: dict[str, int], entropy: int |
| 172 | 204 |
) -> None: |
| 205 |
+ """Estimating the entropy and sufficient hash length works.""" |
|
| 173 | 206 |
v = Vault(length=length, **settings) # type: ignore[arg-type] |
| 174 | 207 |
assert math.isclose(v._entropy(), entropy) |
| 175 | 208 |
assert v._estimate_sufficient_hash_length() > 0 |
| ... | ... |
@@ -180,6 +213,9 @@ class TestVault: |
| 180 | 213 |
assert v._estimate_sufficient_hash_length(8.0) >= entropy |
| 181 | 214 |
|
| 182 | 215 |
def test_222_hash_length_estimation(self) -> None: |
| 216 |
+ """ |
|
| 217 |
+ Estimating the entropy and hash length for degenerate cases works. |
|
| 218 |
+ """ |
|
| 183 | 219 |
v = Vault( |
| 184 | 220 |
phrase=self.phrase, |
| 185 | 221 |
lower=0, |
| ... | ... |
@@ -205,6 +241,9 @@ class TestVault: |
| 205 | 241 |
service: str | bytes, |
| 206 | 242 |
expected: bytes, |
| 207 | 243 |
) -> None: |
| 244 |
+ """ |
|
| 245 |
+ Estimating the entropy and hash length for the degenerate case works. |
|
| 246 |
+ """ |
|
| 208 | 247 |
v = Vault(phrase=self.phrase) |
| 209 | 248 |
monkeypatch.setattr( |
| 210 | 249 |
v, |
| ... | ... |
@@ -226,6 +265,7 @@ class TestVault: |
| 226 | 265 |
], |
| 227 | 266 |
) |
| 228 | 267 |
def test_224_binary_strings(self, s: str | bytes | bytearray) -> None: |
| 268 |
+ """Byte string conversion is idempotent.""" |
|
| 229 | 269 |
binstr = Vault._get_binary_string |
| 230 | 270 |
if isinstance(s, str): |
| 231 | 271 |
assert binstr(s) == s.encode('UTF-8')
|
| ... | ... |
@@ -235,12 +275,14 @@ class TestVault: |
| 235 | 275 |
assert binstr(binstr(s)) == bytes(s) |
| 236 | 276 |
|
| 237 | 277 |
def test_310_too_many_symbols(self) -> None: |
| 278 |
+ """Deriving short passphrases with large length constraints fails.""" |
|
| 238 | 279 |
with pytest.raises( |
| 239 | 280 |
ValueError, match='requested passphrase length too short' |
| 240 | 281 |
): |
| 241 | 282 |
Vault(phrase=self.phrase, symbol=100) |
| 242 | 283 |
|
| 243 | 284 |
def test_311_no_viable_characters(self) -> None: |
| 285 |
+ """Deriving passphrases without allowed characters fails.""" |
|
| 244 | 286 |
with pytest.raises(ValueError, match='no allowed characters left'): |
| 245 | 287 |
Vault( |
| 246 | 288 |
phrase=self.phrase, |
| ... | ... |
@@ -253,12 +295,14 @@ class TestVault: |
| 253 | 295 |
) |
| 254 | 296 |
|
| 255 | 297 |
def test_320_character_set_subtraction_duplicate(self) -> None: |
| 298 |
+ """Character sets do not contain duplicate characters.""" |
|
| 256 | 299 |
with pytest.raises(ValueError, match='duplicate characters'): |
| 257 | 300 |
Vault._subtract(b'abcdef', b'aabbccddeeff') |
| 258 | 301 |
with pytest.raises(ValueError, match='duplicate characters'): |
| 259 | 302 |
Vault._subtract(b'aabbccddeeff', b'abcdef') |
| 260 | 303 |
|
| 261 | 304 |
def test_322_hash_length_estimation(self) -> None: |
| 305 |
+ """Hash length estimation rejects invalid safety factors.""" |
|
| 262 | 306 |
v = Vault(phrase=self.phrase) |
| 263 | 307 |
with pytest.raises(ValueError, match='invalid safety factor'): |
| 264 | 308 |
assert v._estimate_sufficient_hash_length(-1.0) |
| ... | ... |
@@ -269,6 +313,8 @@ class TestVault: |
| 269 | 313 |
|
| 270 | 314 |
|
| 271 | 315 |
class TestHypotheses: |
| 316 |
+ """Test properties via hypothesis.""" |
|
| 317 |
+ |
|
| 272 | 318 |
@tests.hypothesis_settings_coverage_compatible |
| 273 | 319 |
@hypothesis.given( |
| 274 | 320 |
phrase=strategies.one_of( |
| ... | ... |
@@ -328,6 +374,7 @@ class TestHypotheses: |
| 328 | 374 |
config: dict[str, int], |
| 329 | 375 |
service: str, |
| 330 | 376 |
) -> None: |
| 377 |
+ """Derived passphrases obey character and occurrence restraints.""" |
|
| 331 | 378 |
try: |
| 332 | 379 |
password = Vault(phrase=phrase, **config).generate(service) |
| 333 | 380 |
except ValueError as exc: |
| ... | ... |
@@ -386,6 +433,7 @@ class TestHypotheses: |
| 386 | 433 |
length: int, |
| 387 | 434 |
service: str, |
| 388 | 435 |
) -> None: |
| 436 |
+ """Derived passphrases have the requested length.""" |
|
| 389 | 437 |
password = Vault(phrase=phrase, length=length).generate(service) |
| 390 | 438 |
assert len(password) == length |
| 391 | 439 |
|
| ... | ... |
@@ -412,6 +460,7 @@ class TestHypotheses: |
| 412 | 460 |
repeat: int, |
| 413 | 461 |
service: str, |
| 414 | 462 |
) -> None: |
| 463 |
+ """Derived passphrases obey the given occurrence constraint.""" |
|
| 415 | 464 |
password = Vault(phrase=phrase, length=length, repeat=repeat).generate( |
| 416 | 465 |
service |
| 417 | 466 |
) |
| ... | ... |
@@ -42,6 +42,7 @@ all_translatable_strings = [ |
| 42 | 42 |
|
| 43 | 43 |
@pytest.fixture(scope='class') |
| 44 | 44 |
def use_debug_translations() -> Iterator[None]: |
| 45 |
+ """Force the use of debug translations (pytest class fixture).""" |
|
| 45 | 46 |
with pytest.MonkeyPatch.context() as monkeypatch: |
| 46 | 47 |
monkeypatch.setattr(msg, 'translation', msg.DebugTranslations()) |
| 47 | 48 |
yield |
| ... | ... |
@@ -49,6 +50,7 @@ def use_debug_translations() -> Iterator[None]: |
| 49 | 50 |
|
| 50 | 51 |
@contextlib.contextmanager |
| 51 | 52 |
def monkeypatched_null_translations() -> Iterator[None]: |
| 53 |
+ """Force the use of no-op translations in this context.""" |
|
| 52 | 54 |
with pytest.MonkeyPatch.context() as monkeypatch: |
| 53 | 55 |
monkeypatch.setattr(msg, 'translation', gettext.NullTranslations()) |
| 54 | 56 |
yield |
| ... | ... |
@@ -56,21 +58,29 @@ def monkeypatched_null_translations() -> Iterator[None]: |
| 56 | 58 |
|
| 57 | 59 |
@pytest.mark.usefixtures('use_debug_translations')
|
| 58 | 60 |
class TestL10nMachineryWithDebugTranslations: |
| 61 |
+ """Test the localization machinery together with debug translations.""" |
|
| 59 | 62 |
error_codes = tuple( |
| 60 | 63 |
sorted(errno.errorcode, key=errno.errorcode.__getitem__) |
| 61 | 64 |
) |
| 65 |
+ """A cache of the known error codes from the [`errno`][] module.""" |
|
| 62 | 66 |
known_fields_error_messages = tuple( |
| 63 | 67 |
e |
| 64 | 68 |
for e in sorted(msg.ErrMsgTemplate, key=str) |
| 65 | 69 |
if e.value.fields() == ['error', 'filename'] |
| 66 | 70 |
) |
| 71 |
+ """ |
|
| 72 |
+ A cache of known error messages that contain both `error` and |
|
| 73 |
+ `filename` replacement fields. |
|
| 74 |
+ """ |
|
| 67 | 75 |
no_fields_messages = tuple( |
| 68 | 76 |
e for e in all_translatable_strings_enum_values if not e.value.fields() |
| 69 | 77 |
) |
| 78 |
+ """A cache of known messages that don't contain replacement fields.""" |
|
| 70 | 79 |
|
| 71 | 80 |
@hypothesis.given(value=strategies.text(max_size=100)) |
| 72 | 81 |
@hypothesis.example('{')
|
| 73 | 82 |
def test_100_debug_translation_get_str(self, value: str) -> None: |
| 83 |
+ """Translating a raw string object does nothing.""" |
|
| 74 | 84 |
translated = msg.translation.gettext(value) |
| 75 | 85 |
assert translated == value |
| 76 | 86 |
|
| ... | ... |
@@ -79,6 +89,7 @@ class TestL10nMachineryWithDebugTranslations: |
| 79 | 89 |
self, |
| 80 | 90 |
value: msg.TranslatableString, |
| 81 | 91 |
) -> None: |
| 92 |
+ """Translating a TranslatableString translates and interpolates.""" |
|
| 82 | 93 |
ts_name = str(all_translatable_strings_dict[value]) |
| 83 | 94 |
context = value.l10n_context |
| 84 | 95 |
singular = value.singular |
| ... | ... |
@@ -94,6 +105,7 @@ class TestL10nMachineryWithDebugTranslations: |
| 94 | 105 |
self, |
| 95 | 106 |
value: msg.MsgTemplate, |
| 96 | 107 |
) -> None: |
| 108 |
+ """Translating a MsgTemplate operates on the enum value.""" |
|
| 97 | 109 |
ts_name = str(value) |
| 98 | 110 |
inner_value = cast('msg.TranslatableString', value.value)
|
| 99 | 111 |
context = inner_value.l10n_context |
| ... | ... |
@@ -106,6 +118,7 @@ class TestL10nMachineryWithDebugTranslations: |
| 106 | 118 |
@hypothesis.given(value=strategies.text(max_size=100)) |
| 107 | 119 |
@hypothesis.example('{')
|
| 108 | 120 |
def test_100c_debug_translation_get_ts_str(self, value: str) -> None: |
| 121 |
+ """Translating a constant TranslatableString does nothing.""" |
|
| 109 | 122 |
translated = msg.TranslatedString.constant(value) |
| 110 | 123 |
assert str(translated) == value |
| 111 | 124 |
|
| ... | ... |
@@ -121,6 +134,7 @@ class TestL10nMachineryWithDebugTranslations: |
| 121 | 134 |
self, |
| 122 | 135 |
values: list[msg.MsgTemplate], |
| 123 | 136 |
) -> None: |
| 137 |
+ """TranslatableStrings are hashable.""" |
|
| 124 | 138 |
assert len(values) == 2 |
| 125 | 139 |
ts0 = msg.TranslatedString(values[0]) |
| 126 | 140 |
ts1 = msg.TranslatedString(values[0]) |
| ... | ... |
@@ -148,6 +162,7 @@ class TestL10nMachineryWithDebugTranslations: |
| 148 | 162 |
value: msg.ErrMsgTemplate, |
| 149 | 163 |
errnos: list[int], |
| 150 | 164 |
) -> None: |
| 165 |
+ """TranslatableStrings are hashable even with interpolations.""" |
|
| 151 | 166 |
assert len(errnos) == 2 |
| 152 | 167 |
error1, error2 = [os.strerror(c) for c in errnos] |
| 153 | 168 |
ts1 = msg.TranslatedString( |
| ... | ... |
@@ -169,6 +184,7 @@ class TestL10nMachineryWithDebugTranslations: |
| 169 | 184 |
value: msg.ErrMsgTemplate, |
| 170 | 185 |
errno_: int, |
| 171 | 186 |
) -> None: |
| 187 |
+ """Interpolated TranslatableStrings with error/filename are hashable.""" |
|
| 172 | 188 |
error = os.strerror(errno_) |
| 173 | 189 |
# The debug translations specifically do *not* differ in output |
| 174 | 190 |
# when the filename is trimmed. So we need to request some |
| ... | ... |
@@ -190,6 +206,13 @@ class TestL10nMachineryWithDebugTranslations: |
| 190 | 206 |
self, |
| 191 | 207 |
s: str, |
| 192 | 208 |
) -> None: |
| 209 |
+ """TranslatableStrings require fixed replacement fields. |
|
| 210 |
+ |
|
| 211 |
+ They reject attempts at stringification if unknown fields are |
|
| 212 |
+ passed, or if fields are missing, or if the format string is |
|
| 213 |
+ invalid. |
|
| 214 |
+ |
|
| 215 |
+ """ |
|
| 193 | 216 |
with monkeypatched_null_translations(): |
| 194 | 217 |
ts1 = msg.TranslatedString(s) |
| 195 | 218 |
with pytest.raises((KeyError, ValueError)) as excinfo: |
| ... | ... |
@@ -223,6 +246,7 @@ class TestL10nMachineryWithDebugTranslations: |
| 223 | 246 |
self, |
| 224 | 247 |
s: str, |
| 225 | 248 |
) -> None: |
| 249 |
+ """Constant TranslatedStrings don't interpolate fields.""" |
|
| 226 | 250 |
with monkeypatched_null_translations(): |
| 227 | 251 |
ts = msg.TranslatedString.constant(s) |
| 228 | 252 |
try: |
| ... | ... |
@@ -244,6 +268,7 @@ class TestL10nMachineryWithDebugTranslations: |
| 244 | 268 |
self, |
| 245 | 269 |
s: str, |
| 246 | 270 |
) -> None: |
| 271 |
+ """Non-format TranslatedStrings don't interpolate fields.""" |
|
| 247 | 272 |
with monkeypatched_null_translations(): |
| 248 | 273 |
ts_inner = msg.TranslatableString( |
| 249 | 274 |
'', |
| 250 | 275 |