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 |