Marco Ricci commited on 2025-08-09 14:44:43
Zeige 8 geänderte Dateien mit 1681 Einfügungen und 1589 Löschungen.
Split off the slow, `hypothesis`-based integration tests (the "heavy duty" tests) from the `test_derivepassphrase_cli`, `test_derivepassphrase_ssh_agent` and `test_derivepassphrase_types` modules into separate submodules named `heavy_duty`. Additionally, mark the contents of these `heavy_duty` submodules with the `heavy_duty` `pytest` mark. We do this both because the integration tests are slow and because they are relatively large per test: you typically write a whole new class plus support code per test, plus you reexport one of the class's attributes as a top-level, auto-discoverable test. Though specifically marked, the tests are still run by default.
... | ... |
@@ -110,6 +110,17 @@ def _hypothesis_settings_setup() -> None: |
110 | 110 |
_hypothesis_settings_setup() |
111 | 111 |
|
112 | 112 |
|
113 |
+def pytest_configure(config: pytest.Config) -> None: |
|
114 |
+ """Configure `pytest`: add the `heavy_duty` marker.""" |
|
115 |
+ config.addinivalue_line( |
|
116 |
+ 'markers', |
|
117 |
+ ( |
|
118 |
+ 'heavy_duty: ' |
|
119 |
+ 'mark test as a slow, heavy-duty test (e.g., an integration test)' |
|
120 |
+ ), |
|
121 |
+ ) |
|
122 |
+ |
|
123 |
+ |
|
113 | 124 |
# https://docs.pytest.org/en/stable/explanation/fixtures.html#a-note-about-fixture-cleanup |
114 | 125 |
# https://github.com/pytest-dev/pytest/issues/5243#issuecomment-491522595 |
115 | 126 |
@pytest.fixture(scope="session", autouse=False) |
... | ... |
@@ -140,6 +140,17 @@ def xfail_on_the_annoying_os( |
140 | 140 |
return mark if f is None else mark(f) |
141 | 141 |
|
142 | 142 |
|
143 |
+heavy_duty = pytest.mark.heavy_duty |
|
144 |
+""" |
|
145 |
+A cached `pytest` mark indicating that this test function/class/module |
|
146 |
+is a slow, heavy duty test. Users who are impatient (or otherwise |
|
147 |
+cannot afford to wait for these tests to complete) may wish to exclude |
|
148 |
+these tests; this mark helps in achieving that. |
|
149 |
+ |
|
150 |
+All current heavy duty tests are integration tests. |
|
151 |
+""" |
|
152 |
+ |
|
153 |
+ |
|
143 | 154 |
# Parameter sets |
144 | 155 |
# ============== |
145 | 156 |
|
... | ... |
@@ -16,7 +16,6 @@ import logging |
16 | 16 |
import operator |
17 | 17 |
import os |
18 | 18 |
import pathlib |
19 |
-import queue |
|
20 | 19 |
import re |
21 | 20 |
import shlex |
22 | 21 |
import shutil |
... | ... |
@@ -25,14 +24,14 @@ import tempfile |
25 | 24 |
import textwrap |
26 | 25 |
import types |
27 | 26 |
import warnings |
28 |
-from typing import TYPE_CHECKING, cast |
|
27 |
+from typing import TYPE_CHECKING |
|
29 | 28 |
|
30 | 29 |
import click.testing |
31 | 30 |
import exceptiongroup |
32 | 31 |
import hypothesis |
33 | 32 |
import pytest |
34 |
-from hypothesis import stateful, strategies |
|
35 |
-from typing_extensions import Any, NamedTuple, TypeAlias |
|
33 |
+from hypothesis import strategies |
|
34 |
+from typing_extensions import Any, NamedTuple |
|
36 | 35 |
|
37 | 36 |
import tests.data |
38 | 37 |
import tests.data.callables |
... | ... |
@@ -48,7 +47,6 @@ from derivepassphrase._internals import ( |
48 | 47 |
from derivepassphrase.ssh_agent import socketprovider |
49 | 48 |
|
50 | 49 |
if TYPE_CHECKING: |
51 |
- import multiprocessing |
|
52 | 50 |
from collections.abc import Callable, Iterable, Iterator, Sequence |
53 | 51 |
from collections.abc import Set as AbstractSet |
54 | 52 |
from typing import NoReturn |
... | ... |
@@ -5992,1290 +5990,6 @@ class TestCLITransition: |
5992 | 5990 |
) == [DUMMY_SERVICE] |
5993 | 5991 |
|
5994 | 5992 |
|
5995 |
-KNOWN_SERVICES = (DUMMY_SERVICE, "email", "bank", "work") |
|
5996 |
-"""Known service names. Used for the [`ConfigManagementStateMachine`][].""" |
|
5997 |
-VALID_PROPERTIES = ( |
|
5998 |
- "length", |
|
5999 |
- "repeat", |
|
6000 |
- "upper", |
|
6001 |
- "lower", |
|
6002 |
- "number", |
|
6003 |
- "space", |
|
6004 |
- "dash", |
|
6005 |
- "symbol", |
|
6006 |
-) |
|
6007 |
-"""Known vault properties. Used for the [`ConfigManagementStateMachine`][].""" |
|
6008 |
- |
|
6009 |
- |
|
6010 |
-def build_reduced_vault_config_settings( |
|
6011 |
- config: _types.VaultConfigServicesSettings, |
|
6012 |
- keys_to_prune: frozenset[str], |
|
6013 |
-) -> _types.VaultConfigServicesSettings: |
|
6014 |
- """Return a service settings object with certain keys pruned. |
|
6015 |
- |
|
6016 |
- Args: |
|
6017 |
- config: |
|
6018 |
- The original service settings object. |
|
6019 |
- keys_to_prune: |
|
6020 |
- The keys to prune from the settings object. |
|
6021 |
- |
|
6022 |
- """ |
|
6023 |
- config2 = copy.deepcopy(config) |
|
6024 |
- for key in keys_to_prune: |
|
6025 |
- config2.pop(key, None) # type: ignore[misc] |
|
6026 |
- return config2 |
|
6027 |
- |
|
6028 |
- |
|
6029 |
-SERVICES_STRATEGY = strategies.builds( |
|
6030 |
- build_reduced_vault_config_settings, |
|
6031 |
- tests.machinery.hypothesis.vault_full_service_config(), |
|
6032 |
- strategies.sets( |
|
6033 |
- strategies.sampled_from(VALID_PROPERTIES), |
|
6034 |
- max_size=7, |
|
6035 |
- ), |
|
6036 |
-) |
|
6037 |
-"""A hypothesis strategy to build incomplete service configurations.""" |
|
6038 |
- |
|
6039 |
- |
|
6040 |
-def services_strategy() -> strategies.SearchStrategy[ |
|
6041 |
- _types.VaultConfigServicesSettings |
|
6042 |
-]: |
|
6043 |
- """Return a strategy to build incomplete service configurations.""" |
|
6044 |
- return SERVICES_STRATEGY |
|
6045 |
- |
|
6046 |
- |
|
6047 |
-def assemble_config( |
|
6048 |
- global_data: _types.VaultConfigGlobalSettings, |
|
6049 |
- service_data: list[tuple[str, _types.VaultConfigServicesSettings]], |
|
6050 |
-) -> _types.VaultConfig: |
|
6051 |
- """Return a vault config using the global and service data.""" |
|
6052 |
- services_dict = dict(service_data) |
|
6053 |
- return ( |
|
6054 |
- {"global": global_data, "services": services_dict} |
|
6055 |
- if global_data |
|
6056 |
- else {"services": services_dict} |
|
6057 |
- ) |
|
6058 |
- |
|
6059 |
- |
|
6060 |
-@strategies.composite |
|
6061 |
-def draw_service_name_and_data( |
|
6062 |
- draw: hypothesis.strategies.DrawFn, |
|
6063 |
- num_entries: int, |
|
6064 |
-) -> tuple[tuple[str, _types.VaultConfigServicesSettings], ...]: |
|
6065 |
- """Draw a service name and settings, as a hypothesis strategy. |
|
6066 |
- |
|
6067 |
- Will draw service names from [`KNOWN_SERVICES`][] and service |
|
6068 |
- settings via [`services_strategy`][]. |
|
6069 |
- |
|
6070 |
- Args: |
|
6071 |
- draw: |
|
6072 |
- The `draw` function, as provided for by hypothesis. |
|
6073 |
- num_entries: |
|
6074 |
- The number of services to draw. |
|
6075 |
- |
|
6076 |
- Returns: |
|
6077 |
- A sequence of pairs of service names and service settings. |
|
6078 |
- |
|
6079 |
- """ |
|
6080 |
- possible_services = list(KNOWN_SERVICES) |
|
6081 |
- selected_services: list[str] = [] |
|
6082 |
- for _ in range(num_entries): |
|
6083 |
- selected_services.append( |
|
6084 |
- draw(strategies.sampled_from(possible_services)) |
|
6085 |
- ) |
|
6086 |
- possible_services.remove(selected_services[-1]) |
|
6087 |
- return tuple( |
|
6088 |
- (service, draw(services_strategy())) for service in selected_services |
|
6089 |
- ) |
|
6090 |
- |
|
6091 |
- |
|
6092 |
-VAULT_FULL_CONFIG = strategies.builds( |
|
6093 |
- assemble_config, |
|
6094 |
- services_strategy(), |
|
6095 |
- strategies.integers( |
|
6096 |
- min_value=2, |
|
6097 |
- max_value=4, |
|
6098 |
- ).flatmap(draw_service_name_and_data), |
|
6099 |
-) |
|
6100 |
-"""A hypothesis strategy to build full vault configurations.""" |
|
6101 |
- |
|
6102 |
- |
|
6103 |
-def vault_full_config() -> strategies.SearchStrategy[_types.VaultConfig]: |
|
6104 |
- """Return a strategy to build full vault configurations.""" |
|
6105 |
- return VAULT_FULL_CONFIG |
|
6106 |
- |
|
6107 |
- |
|
6108 |
-class ConfigManagementStateMachine(stateful.RuleBasedStateMachine): |
|
6109 |
- """A state machine recording changes in the vault configuration. |
|
6110 |
- |
|
6111 |
- Record possible configuration states in bundles, then in each rule, |
|
6112 |
- take a configuration and manipulate it somehow. |
|
6113 |
- |
|
6114 |
- Attributes: |
|
6115 |
- setting: |
|
6116 |
- A bundle for single-service settings. |
|
6117 |
- configuration: |
|
6118 |
- A bundle for full vault configurations. |
|
6119 |
- |
|
6120 |
- """ |
|
6121 |
- |
|
6122 |
- def __init__(self) -> None: |
|
6123 |
- """Initialize self, set up context managers and enter them.""" |
|
6124 |
- super().__init__() |
|
6125 |
- self.runner = tests.machinery.CliRunner(mix_stderr=False) |
|
6126 |
- self.exit_stack = contextlib.ExitStack().__enter__() |
|
6127 |
- self.monkeypatch = self.exit_stack.enter_context( |
|
6128 |
- pytest.MonkeyPatch().context() |
|
6129 |
- ) |
|
6130 |
- self.isolated_config = self.exit_stack.enter_context( |
|
6131 |
- tests.machinery.pytest.isolated_vault_config( |
|
6132 |
- monkeypatch=self.monkeypatch, |
|
6133 |
- runner=self.runner, |
|
6134 |
- vault_config={"services": {}}, |
|
6135 |
- ) |
|
6136 |
- ) |
|
6137 |
- |
|
6138 |
- setting: stateful.Bundle[_types.VaultConfigServicesSettings] = ( |
|
6139 |
- stateful.Bundle("setting") |
|
6140 |
- ) |
|
6141 |
- """""" |
|
6142 |
- configuration: stateful.Bundle[_types.VaultConfig] = stateful.Bundle( |
|
6143 |
- "configuration" |
|
6144 |
- ) |
|
6145 |
- """""" |
|
6146 |
- |
|
6147 |
- @stateful.initialize( |
|
6148 |
- target=configuration, |
|
6149 |
- configs=strategies.lists( |
|
6150 |
- vault_full_config(), |
|
6151 |
- min_size=8, |
|
6152 |
- max_size=8, |
|
6153 |
- ), |
|
6154 |
- ) |
|
6155 |
- def declare_initial_configs( |
|
6156 |
- self, |
|
6157 |
- configs: Iterable[_types.VaultConfig], |
|
6158 |
- ) -> stateful.MultipleResults[_types.VaultConfig]: |
|
6159 |
- """Initialize the configuration bundle with eight configurations.""" |
|
6160 |
- return stateful.multiple(*configs) |
|
6161 |
- |
|
6162 |
- @stateful.initialize( |
|
6163 |
- target=setting, |
|
6164 |
- configs=strategies.lists( |
|
6165 |
- vault_full_config(), |
|
6166 |
- min_size=4, |
|
6167 |
- max_size=4, |
|
6168 |
- ), |
|
6169 |
- ) |
|
6170 |
- def extract_initial_settings( |
|
6171 |
- self, |
|
6172 |
- configs: list[_types.VaultConfig], |
|
6173 |
- ) -> stateful.MultipleResults[_types.VaultConfigServicesSettings]: |
|
6174 |
- """Initialize the settings bundle with four service settings.""" |
|
6175 |
- settings: list[_types.VaultConfigServicesSettings] = [] |
|
6176 |
- for c in configs: |
|
6177 |
- settings.extend(c["services"].values()) |
|
6178 |
- return stateful.multiple(*map(copy.deepcopy, settings)) |
|
6179 |
- |
|
6180 |
- @staticmethod |
|
6181 |
- def fold_configs( |
|
6182 |
- c1: _types.VaultConfig, c2: _types.VaultConfig |
|
6183 |
- ) -> _types.VaultConfig: |
|
6184 |
- """Fold `c1` into `c2`, overriding the latter.""" |
|
6185 |
- new_global_dict = c1.get("global", c2.get("global")) |
|
6186 |
- if new_global_dict is not None: |
|
6187 |
- return { |
|
6188 |
- "global": new_global_dict, |
|
6189 |
- "services": {**c2["services"], **c1["services"]}, |
|
6190 |
- } |
|
6191 |
- return { |
|
6192 |
- "services": {**c2["services"], **c1["services"]}, |
|
6193 |
- } |
|
6194 |
- |
|
6195 |
- @stateful.rule( |
|
6196 |
- target=configuration, |
|
6197 |
- config=configuration, |
|
6198 |
- setting=setting.filter(bool), |
|
6199 |
- maybe_unset=strategies.sets( |
|
6200 |
- strategies.sampled_from(VALID_PROPERTIES), |
|
6201 |
- max_size=3, |
|
6202 |
- ), |
|
6203 |
- overwrite=strategies.booleans(), |
|
6204 |
- ) |
|
6205 |
- def set_globals( |
|
6206 |
- self, |
|
6207 |
- config: _types.VaultConfig, |
|
6208 |
- setting: _types.VaultConfigGlobalSettings, |
|
6209 |
- maybe_unset: set[str], |
|
6210 |
- overwrite: bool, |
|
6211 |
- ) -> _types.VaultConfig: |
|
6212 |
- """Set the global settings of a configuration. |
|
6213 |
- |
|
6214 |
- Args: |
|
6215 |
- config: |
|
6216 |
- The configuration to edit. |
|
6217 |
- setting: |
|
6218 |
- The new global settings. |
|
6219 |
- maybe_unset: |
|
6220 |
- Settings keys to additionally unset, if not already |
|
6221 |
- present in the new settings. Corresponds to the |
|
6222 |
- `--unset` command-line argument. |
|
6223 |
- overwrite: |
|
6224 |
- Overwrite the settings object if true, or merge if |
|
6225 |
- false. Corresponds to the `--overwrite-existing` and |
|
6226 |
- `--merge-existing` command-line arguments. |
|
6227 |
- |
|
6228 |
- Returns: |
|
6229 |
- The amended configuration. |
|
6230 |
- |
|
6231 |
- """ |
|
6232 |
- cli_helpers.save_config(config) |
|
6233 |
- config_global = config.get("global", {}) |
|
6234 |
- maybe_unset = set(maybe_unset) - setting.keys() |
|
6235 |
- if overwrite: |
|
6236 |
- config["global"] = config_global = {} |
|
6237 |
- elif maybe_unset: |
|
6238 |
- for key in maybe_unset: |
|
6239 |
- config_global.pop(key, None) # type: ignore[misc] |
|
6240 |
- config.setdefault("global", {}).update(setting) |
|
6241 |
- assert _types.is_vault_config(config) |
|
6242 |
- # NOTE: This relies on settings_obj containing only the keys |
|
6243 |
- # "length", "repeat", "upper", "lower", "number", "space", |
|
6244 |
- # "dash" and "symbol". |
|
6245 |
- result = self.runner.invoke( |
|
6246 |
- cli.derivepassphrase_vault, |
|
6247 |
- [ |
|
6248 |
- "--config", |
|
6249 |
- "--overwrite-existing" if overwrite else "--merge-existing", |
|
6250 |
- ] |
|
6251 |
- + [f"--unset={key}" for key in maybe_unset] |
|
6252 |
- + [ |
|
6253 |
- f"--{key}={value}" |
|
6254 |
- for key, value in setting.items() |
|
6255 |
- if key in VALID_PROPERTIES |
|
6256 |
- ], |
|
6257 |
- catch_exceptions=False, |
|
6258 |
- ) |
|
6259 |
- assert result.clean_exit(empty_stderr=False) |
|
6260 |
- assert cli_helpers.load_config() == config |
|
6261 |
- return config |
|
6262 |
- |
|
6263 |
- @stateful.rule( |
|
6264 |
- target=configuration, |
|
6265 |
- config=configuration, |
|
6266 |
- service=strategies.sampled_from(KNOWN_SERVICES), |
|
6267 |
- setting=setting.filter(bool), |
|
6268 |
- maybe_unset=strategies.sets( |
|
6269 |
- strategies.sampled_from(VALID_PROPERTIES), |
|
6270 |
- max_size=3, |
|
6271 |
- ), |
|
6272 |
- overwrite=strategies.booleans(), |
|
6273 |
- ) |
|
6274 |
- def set_service( |
|
6275 |
- self, |
|
6276 |
- config: _types.VaultConfig, |
|
6277 |
- service: str, |
|
6278 |
- setting: _types.VaultConfigServicesSettings, |
|
6279 |
- maybe_unset: set[str], |
|
6280 |
- overwrite: bool, |
|
6281 |
- ) -> _types.VaultConfig: |
|
6282 |
- """Set the named service settings for a configuration. |
|
6283 |
- |
|
6284 |
- Args: |
|
6285 |
- config: |
|
6286 |
- The configuration to edit. |
|
6287 |
- service: |
|
6288 |
- The name of the service to set. |
|
6289 |
- setting: |
|
6290 |
- The new service settings. |
|
6291 |
- maybe_unset: |
|
6292 |
- Settings keys to additionally unset, if not already |
|
6293 |
- present in the new settings. Corresponds to the |
|
6294 |
- `--unset` command-line argument. |
|
6295 |
- overwrite: |
|
6296 |
- Overwrite the settings object if true, or merge if |
|
6297 |
- false. Corresponds to the `--overwrite-existing` and |
|
6298 |
- `--merge-existing` command-line arguments. |
|
6299 |
- |
|
6300 |
- Returns: |
|
6301 |
- The amended configuration. |
|
6302 |
- |
|
6303 |
- """ |
|
6304 |
- cli_helpers.save_config(config) |
|
6305 |
- config_service = config["services"].get(service, {}) |
|
6306 |
- maybe_unset = set(maybe_unset) - setting.keys() |
|
6307 |
- if overwrite: |
|
6308 |
- config["services"][service] = config_service = {} |
|
6309 |
- elif maybe_unset: |
|
6310 |
- for key in maybe_unset: |
|
6311 |
- config_service.pop(key, None) # type: ignore[misc] |
|
6312 |
- config["services"].setdefault(service, {}).update(setting) |
|
6313 |
- assert _types.is_vault_config(config) |
|
6314 |
- # NOTE: This relies on settings_obj containing only the keys |
|
6315 |
- # "length", "repeat", "upper", "lower", "number", "space", |
|
6316 |
- # "dash" and "symbol". |
|
6317 |
- result = self.runner.invoke( |
|
6318 |
- cli.derivepassphrase_vault, |
|
6319 |
- [ |
|
6320 |
- "--config", |
|
6321 |
- "--overwrite-existing" if overwrite else "--merge-existing", |
|
6322 |
- ] |
|
6323 |
- + [f"--unset={key}" for key in maybe_unset] |
|
6324 |
- + [ |
|
6325 |
- f"--{key}={value}" |
|
6326 |
- for key, value in setting.items() |
|
6327 |
- if key in VALID_PROPERTIES |
|
6328 |
- ] |
|
6329 |
- + ["--", service], |
|
6330 |
- catch_exceptions=False, |
|
6331 |
- ) |
|
6332 |
- assert result.clean_exit(empty_stderr=False) |
|
6333 |
- assert cli_helpers.load_config() == config |
|
6334 |
- return config |
|
6335 |
- |
|
6336 |
- @stateful.rule( |
|
6337 |
- target=configuration, |
|
6338 |
- config=configuration, |
|
6339 |
- ) |
|
6340 |
- def purge_global( |
|
6341 |
- self, |
|
6342 |
- config: _types.VaultConfig, |
|
6343 |
- ) -> _types.VaultConfig: |
|
6344 |
- """Purge the globals of a configuration. |
|
6345 |
- |
|
6346 |
- Args: |
|
6347 |
- config: |
|
6348 |
- The configuration to edit. |
|
6349 |
- |
|
6350 |
- Returns: |
|
6351 |
- The pruned configuration. |
|
6352 |
- |
|
6353 |
- """ |
|
6354 |
- cli_helpers.save_config(config) |
|
6355 |
- config.pop("global", None) |
|
6356 |
- result = self.runner.invoke( |
|
6357 |
- cli.derivepassphrase_vault, |
|
6358 |
- ["--delete-globals"], |
|
6359 |
- input="y", |
|
6360 |
- catch_exceptions=False, |
|
6361 |
- ) |
|
6362 |
- assert result.clean_exit(empty_stderr=False) |
|
6363 |
- assert cli_helpers.load_config() == config |
|
6364 |
- return config |
|
6365 |
- |
|
6366 |
- @stateful.rule( |
|
6367 |
- target=configuration, |
|
6368 |
- config_and_service=configuration.filter( |
|
6369 |
- lambda c: bool(c["services"]) |
|
6370 |
- ).flatmap( |
|
6371 |
- lambda c: strategies.tuples( |
|
6372 |
- strategies.just(c), |
|
6373 |
- strategies.sampled_from(tuple(c["services"].keys())), |
|
6374 |
- ) |
|
6375 |
- ), |
|
6376 |
- ) |
|
6377 |
- def purge_service( |
|
6378 |
- self, |
|
6379 |
- config_and_service: tuple[_types.VaultConfig, str], |
|
6380 |
- ) -> _types.VaultConfig: |
|
6381 |
- """Purge the settings of a named service in a configuration. |
|
6382 |
- |
|
6383 |
- Args: |
|
6384 |
- config_and_service: |
|
6385 |
- A 2-tuple containing the configuration to edit, and the |
|
6386 |
- service name to purge. |
|
6387 |
- |
|
6388 |
- Returns: |
|
6389 |
- The pruned configuration. |
|
6390 |
- |
|
6391 |
- """ |
|
6392 |
- config, service = config_and_service |
|
6393 |
- cli_helpers.save_config(config) |
|
6394 |
- config["services"].pop(service, None) |
|
6395 |
- result = self.runner.invoke( |
|
6396 |
- cli.derivepassphrase_vault, |
|
6397 |
- ["--delete", "--", service], |
|
6398 |
- input="y", |
|
6399 |
- catch_exceptions=False, |
|
6400 |
- ) |
|
6401 |
- assert result.clean_exit(empty_stderr=False) |
|
6402 |
- assert cli_helpers.load_config() == config |
|
6403 |
- return config |
|
6404 |
- |
|
6405 |
- @stateful.rule( |
|
6406 |
- target=configuration, |
|
6407 |
- config=configuration, |
|
6408 |
- ) |
|
6409 |
- def purge_all( |
|
6410 |
- self, |
|
6411 |
- config: _types.VaultConfig, |
|
6412 |
- ) -> _types.VaultConfig: |
|
6413 |
- """Purge the entire configuration. |
|
6414 |
- |
|
6415 |
- Args: |
|
6416 |
- config: |
|
6417 |
- The configuration to edit. |
|
6418 |
- |
|
6419 |
- Returns: |
|
6420 |
- The empty configuration. |
|
6421 |
- |
|
6422 |
- """ |
|
6423 |
- cli_helpers.save_config(config) |
|
6424 |
- config = {"services": {}} |
|
6425 |
- result = self.runner.invoke( |
|
6426 |
- cli.derivepassphrase_vault, |
|
6427 |
- ["--clear"], |
|
6428 |
- input="y", |
|
6429 |
- catch_exceptions=False, |
|
6430 |
- ) |
|
6431 |
- assert result.clean_exit(empty_stderr=False) |
|
6432 |
- assert cli_helpers.load_config() == config |
|
6433 |
- return config |
|
6434 |
- |
|
6435 |
- @stateful.rule( |
|
6436 |
- target=configuration, |
|
6437 |
- base_config=configuration, |
|
6438 |
- config_to_import=configuration, |
|
6439 |
- overwrite=strategies.booleans(), |
|
6440 |
- ) |
|
6441 |
- def import_configuration( |
|
6442 |
- self, |
|
6443 |
- base_config: _types.VaultConfig, |
|
6444 |
- config_to_import: _types.VaultConfig, |
|
6445 |
- overwrite: bool, |
|
6446 |
- ) -> _types.VaultConfig: |
|
6447 |
- """Import the given configuration into a base configuration. |
|
6448 |
- |
|
6449 |
- Args: |
|
6450 |
- base_config: |
|
6451 |
- The configuration to import into. |
|
6452 |
- config_to_import: |
|
6453 |
- The configuration to import. |
|
6454 |
- overwrite: |
|
6455 |
- Overwrite the base configuration if true, or merge if |
|
6456 |
- false. Corresponds to the `--overwrite-existing` and |
|
6457 |
- `--merge-existing` command-line arguments. |
|
6458 |
- |
|
6459 |
- Returns: |
|
6460 |
- The imported or merged configuration. |
|
6461 |
- |
|
6462 |
- """ |
|
6463 |
- cli_helpers.save_config(base_config) |
|
6464 |
- config = ( |
|
6465 |
- self.fold_configs(config_to_import, base_config) |
|
6466 |
- if not overwrite |
|
6467 |
- else config_to_import |
|
6468 |
- ) |
|
6469 |
- assert _types.is_vault_config(config) |
|
6470 |
- result = self.runner.invoke( |
|
6471 |
- cli.derivepassphrase_vault, |
|
6472 |
- ["--import", "-"] |
|
6473 |
- + (["--overwrite-existing"] if overwrite else []), |
|
6474 |
- input=json.dumps(config_to_import), |
|
6475 |
- catch_exceptions=False, |
|
6476 |
- ) |
|
6477 |
- assert result.clean_exit(empty_stderr=False) |
|
6478 |
- assert cli_helpers.load_config() == config |
|
6479 |
- return config |
|
6480 |
- |
|
6481 |
- def teardown(self) -> None: |
|
6482 |
- """Upon teardown, exit all contexts entered in `__init__`.""" |
|
6483 |
- self.exit_stack.close() |
|
6484 |
- |
|
6485 |
- |
|
6486 |
-TestConfigManagement = ConfigManagementStateMachine.TestCase |
|
6487 |
-"""The [`unittest.TestCase`][] class that will actually be run.""" |
|
6488 |
- |
|
6489 |
- |
|
6490 |
-class FakeConfigurationMutexAction(NamedTuple): |
|
6491 |
- """An action/a step in the [`FakeConfigurationMutexStateMachine`][]. |
|
6492 |
- |
|
6493 |
- Attributes: |
|
6494 |
- command_line: |
|
6495 |
- The command-line for `derivepassphrase vault` to execute. |
|
6496 |
- input: |
|
6497 |
- The input to this command. |
|
6498 |
- |
|
6499 |
- """ |
|
6500 |
- |
|
6501 |
- command_line: list[str] |
|
6502 |
- """""" |
|
6503 |
- input: str | bytes | None = None |
|
6504 |
- """""" |
|
6505 |
- |
|
6506 |
- |
|
6507 |
-def run_actions_handler( |
|
6508 |
- id_num: int, |
|
6509 |
- action: FakeConfigurationMutexAction, |
|
6510 |
- *, |
|
6511 |
- input_queue: queue.Queue, |
|
6512 |
- output_queue: queue.Queue, |
|
6513 |
- timeout: int, |
|
6514 |
-) -> None: |
|
6515 |
- """Prepare the faked mutex, then run `action`. |
|
6516 |
- |
|
6517 |
- This is a top-level handler function -- to be used in a new |
|
6518 |
- [`multiprocessing.Process`][] -- to run a single action from the |
|
6519 |
- [`FakeConfigurationMutexStateMachine`][]. Output from this function |
|
6520 |
- must be sent down the output queue instead of relying on the call |
|
6521 |
- stack. Additionally, because this runs in a separate process, we |
|
6522 |
- need to restart coverage tracking if it is currently running. |
|
6523 |
- |
|
6524 |
- Args: |
|
6525 |
- id_num: |
|
6526 |
- The internal ID of this subprocess. |
|
6527 |
- action: |
|
6528 |
- The action to execute. |
|
6529 |
- input_queue: |
|
6530 |
- The queue for data passed from the manager/parent process to |
|
6531 |
- this subprocess. |
|
6532 |
- output_queue: |
|
6533 |
- The queue for data passed from this subprocess to the |
|
6534 |
- manager/parent process. |
|
6535 |
- timeout: |
|
6536 |
- The maximum amount of time to wait for a data transfer along |
|
6537 |
- the input or the output queue. If exceeded, we exit |
|
6538 |
- immediately. |
|
6539 |
- |
|
6540 |
- """ |
|
6541 |
- with pytest.MonkeyPatch.context() as monkeypatch: |
|
6542 |
- monkeypatch.setattr( |
|
6543 |
- cli_helpers, |
|
6544 |
- "configuration_mutex", |
|
6545 |
- lambda: FakeConfigurationMutexStateMachine.ConfigurationMutexStub( |
|
6546 |
- my_id=id_num, |
|
6547 |
- input_queue=input_queue, |
|
6548 |
- output_queue=output_queue, |
|
6549 |
- timeout=timeout, |
|
6550 |
- ), |
|
6551 |
- ) |
|
6552 |
- runner = tests.machinery.CliRunner(mix_stderr=False) |
|
6553 |
- try: |
|
6554 |
- result = runner.invoke( |
|
6555 |
- cli.derivepassphrase_vault, |
|
6556 |
- args=action.command_line, |
|
6557 |
- input=action.input, |
|
6558 |
- catch_exceptions=True, |
|
6559 |
- ) |
|
6560 |
- output_queue.put( |
|
6561 |
- FakeConfigurationMutexStateMachine.IPCMessage( |
|
6562 |
- id_num, |
|
6563 |
- "result", |
|
6564 |
- ( |
|
6565 |
- result.clean_exit(empty_stderr=False), |
|
6566 |
- copy.copy(result.stdout), |
|
6567 |
- copy.copy(result.stderr), |
|
6568 |
- ), |
|
6569 |
- ), |
|
6570 |
- block=True, |
|
6571 |
- timeout=timeout, |
|
6572 |
- ) |
|
6573 |
- except Exception as exc: # pragma: no cover # noqa: BLE001 |
|
6574 |
- output_queue.put( |
|
6575 |
- FakeConfigurationMutexStateMachine.IPCMessage( |
|
6576 |
- id_num, "exception", exc |
|
6577 |
- ), |
|
6578 |
- block=False, |
|
6579 |
- ) |
|
6580 |
- |
|
6581 |
- |
|
6582 |
-@hypothesis.settings( |
|
6583 |
- stateful_step_count=tests.machinery.hypothesis.get_concurrency_step_count(), |
|
6584 |
- deadline=None, |
|
6585 |
-) |
|
6586 |
-class FakeConfigurationMutexStateMachine(stateful.RuleBasedStateMachine): |
|
6587 |
- """A state machine simulating the (faked) configuration mutex. |
|
6588 |
- |
|
6589 |
- Generate an ordered set of concurrent writers to the |
|
6590 |
- derivepassphrase configuration, then test that the writers' accesses |
|
6591 |
- are serialized correctly, i.e., test that the writers correctly use |
|
6592 |
- the mutex to avoid concurrent accesses, under the assumption that |
|
6593 |
- the mutex itself is correctly implemented. |
|
6594 |
- |
|
6595 |
- We use a custom mutex implementation to both ensure that all writers |
|
6596 |
- attempt to lock the configuration at the same time and that the lock |
|
6597 |
- is granted in our desired order. This test is therefore independent |
|
6598 |
- of the actual (operating system-specific) mutex implementation in |
|
6599 |
- `derivepassphrase`. |
|
6600 |
- |
|
6601 |
- Attributes: |
|
6602 |
- setting: |
|
6603 |
- A bundle for single-service settings. |
|
6604 |
- configuration: |
|
6605 |
- A bundle for full vault configurations. |
|
6606 |
- |
|
6607 |
- """ |
|
6608 |
- |
|
6609 |
- class IPCMessage(NamedTuple): |
|
6610 |
- """A message for inter-process communication. |
|
6611 |
- |
|
6612 |
- Used by the configuration mutex stub class to affect/signal the |
|
6613 |
- control flow amongst the linked mutex clients. |
|
6614 |
- |
|
6615 |
- Attributes: |
|
6616 |
- child_id: |
|
6617 |
- The ID of the sending or receiving child process. |
|
6618 |
- message: |
|
6619 |
- One of "ready", "go", "config", "result" or "exception". |
|
6620 |
- payload: |
|
6621 |
- The (optional) message payload. |
|
6622 |
- |
|
6623 |
- """ |
|
6624 |
- |
|
6625 |
- child_id: int |
|
6626 |
- """""" |
|
6627 |
- message: Literal["ready", "go", "config", "result", "exception"] |
|
6628 |
- """""" |
|
6629 |
- payload: object | None |
|
6630 |
- """""" |
|
6631 |
- |
|
6632 |
- class ConfigurationMutexStub(cli_helpers.ConfigurationMutex): |
|
6633 |
- """Configuration mutex subclass that enforces a locking order. |
|
6634 |
- |
|
6635 |
- Each configuration mutex stub object ("mutex client") has an |
|
6636 |
- associated ID, and one read-only and one write-only pipe |
|
6637 |
- (actually: [`multiprocessing.Queue`][] objects) to the "manager" |
|
6638 |
- instance coordinating these stub objects. First, the mutex |
|
6639 |
- client signals readiness, then the manager signals when the |
|
6640 |
- mutex shall be considered "acquired", then finally the mutex |
|
6641 |
- client sends the result back (simultaneously releasing the mutex |
|
6642 |
- again). The manager may optionally send an abort signal if the |
|
6643 |
- operations take too long. |
|
6644 |
- |
|
6645 |
- This subclass also copies the effective vault configuration |
|
6646 |
- to `intermediate_configs` upon releasing the lock. |
|
6647 |
- |
|
6648 |
- """ |
|
6649 |
- |
|
6650 |
- def __init__( |
|
6651 |
- self, |
|
6652 |
- *, |
|
6653 |
- my_id: int, |
|
6654 |
- timeout: int, |
|
6655 |
- input_queue: queue.Queue[ |
|
6656 |
- FakeConfigurationMutexStateMachine.IPCMessage |
|
6657 |
- ], |
|
6658 |
- output_queue: queue.Queue[ |
|
6659 |
- FakeConfigurationMutexStateMachine.IPCMessage |
|
6660 |
- ], |
|
6661 |
- ) -> None: |
|
6662 |
- """Initialize this mutex client. |
|
6663 |
- |
|
6664 |
- Args: |
|
6665 |
- my_id: |
|
6666 |
- The ID of this client. |
|
6667 |
- timeout: |
|
6668 |
- The timeout for each get and put operation on the |
|
6669 |
- queues. |
|
6670 |
- input_queue: |
|
6671 |
- The message queue for IPC messages from the manager |
|
6672 |
- instance to this mutex client. |
|
6673 |
- output_queue: |
|
6674 |
- The message queue for IPC messages from this mutex |
|
6675 |
- client to the manager instance. |
|
6676 |
- |
|
6677 |
- """ |
|
6678 |
- super().__init__() |
|
6679 |
- |
|
6680 |
- def lock() -> None: |
|
6681 |
- """Simulate locking of the mutex. |
|
6682 |
- |
|
6683 |
- Issue a "ready" message, wait for a "go", then return. |
|
6684 |
- If an exception occurs, issue an "exception" message, |
|
6685 |
- then raise the exception. |
|
6686 |
- |
|
6687 |
- """ |
|
6688 |
- IPCMessage: TypeAlias = ( |
|
6689 |
- FakeConfigurationMutexStateMachine.IPCMessage |
|
6690 |
- ) |
|
6691 |
- try: |
|
6692 |
- output_queue.put( |
|
6693 |
- IPCMessage(my_id, "ready", None), |
|
6694 |
- block=True, |
|
6695 |
- timeout=timeout, |
|
6696 |
- ) |
|
6697 |
- ok = input_queue.get(block=True, timeout=timeout) |
|
6698 |
- if ok != IPCMessage(my_id, "go", None): # pragma: no cover |
|
6699 |
- output_queue.put( |
|
6700 |
- IPCMessage(my_id, "exception", ok), block=False |
|
6701 |
- ) |
|
6702 |
- raise ( |
|
6703 |
- ok[2] |
|
6704 |
- if isinstance(ok[2], BaseException) |
|
6705 |
- else RuntimeError(ok[2]) |
|
6706 |
- ) |
|
6707 |
- except (queue.Empty, queue.Full) as exc: # pragma: no cover |
|
6708 |
- output_queue.put( |
|
6709 |
- IPCMessage(my_id, "exception", exc), block=False |
|
6710 |
- ) |
|
6711 |
- return |
|
6712 |
- |
|
6713 |
- def unlock() -> None: |
|
6714 |
- """Simulate unlocking of the mutex. |
|
6715 |
- |
|
6716 |
- Issue a "config" message, then return. If an exception |
|
6717 |
- occurs, issue an "exception" message, then raise the |
|
6718 |
- exception. |
|
6719 |
- |
|
6720 |
- """ |
|
6721 |
- IPCMessage: TypeAlias = ( |
|
6722 |
- FakeConfigurationMutexStateMachine.IPCMessage |
|
6723 |
- ) |
|
6724 |
- try: |
|
6725 |
- output_queue.put( |
|
6726 |
- IPCMessage( |
|
6727 |
- my_id, |
|
6728 |
- "config", |
|
6729 |
- copy.copy(cli_helpers.load_config()), |
|
6730 |
- ), |
|
6731 |
- block=True, |
|
6732 |
- timeout=timeout, |
|
6733 |
- ) |
|
6734 |
- except (queue.Empty, queue.Full) as exc: # pragma: no cover |
|
6735 |
- output_queue.put( |
|
6736 |
- IPCMessage(my_id, "exception", exc), block=False |
|
6737 |
- ) |
|
6738 |
- raise |
|
6739 |
- |
|
6740 |
- self.lock = lock |
|
6741 |
- self.unlock = unlock |
|
6742 |
- |
|
6743 |
- setting: stateful.Bundle[_types.VaultConfigServicesSettings] = ( |
|
6744 |
- stateful.Bundle("setting") |
|
6745 |
- ) |
|
6746 |
- """""" |
|
6747 |
- configuration: stateful.Bundle[_types.VaultConfig] = stateful.Bundle( |
|
6748 |
- "configuration" |
|
6749 |
- ) |
|
6750 |
- """""" |
|
6751 |
- |
|
6752 |
- def __init__(self, *args: Any, **kwargs: Any) -> None: |
|
6753 |
- """Initialize the state machine.""" |
|
6754 |
- super().__init__(*args, **kwargs) |
|
6755 |
- self.actions: list[FakeConfigurationMutexAction] = [] |
|
6756 |
- # Determine the step count by poking around in the hypothesis |
|
6757 |
- # internals. As this isn't guaranteed to be stable, turn off |
|
6758 |
- # coverage. |
|
6759 |
- try: # pragma: no cover |
|
6760 |
- settings: hypothesis.settings | None |
|
6761 |
- settings = FakeConfigurationMutexStateMachine.TestCase.settings |
|
6762 |
- except AttributeError: # pragma: no cover |
|
6763 |
- settings = None |
|
6764 |
- self.step_count = ( |
|
6765 |
- tests.machinery.hypothesis.get_concurrency_step_count(settings) |
|
6766 |
- ) |
|
6767 |
- |
|
6768 |
- @stateful.initialize( |
|
6769 |
- target=configuration, |
|
6770 |
- configs=strategies.lists( |
|
6771 |
- vault_full_config(), |
|
6772 |
- min_size=8, |
|
6773 |
- max_size=8, |
|
6774 |
- ), |
|
6775 |
- ) |
|
6776 |
- def declare_initial_configs( |
|
6777 |
- self, |
|
6778 |
- configs: list[_types.VaultConfig], |
|
6779 |
- ) -> stateful.MultipleResults[_types.VaultConfig]: |
|
6780 |
- """Initialize the configuration bundle with eight configurations.""" |
|
6781 |
- return stateful.multiple(*configs) |
|
6782 |
- |
|
6783 |
- @stateful.initialize( |
|
6784 |
- target=setting, |
|
6785 |
- configs=strategies.lists( |
|
6786 |
- vault_full_config(), |
|
6787 |
- min_size=4, |
|
6788 |
- max_size=4, |
|
6789 |
- ), |
|
6790 |
- ) |
|
6791 |
- def extract_initial_settings( |
|
6792 |
- self, |
|
6793 |
- configs: list[_types.VaultConfig], |
|
6794 |
- ) -> stateful.MultipleResults[_types.VaultConfigServicesSettings]: |
|
6795 |
- """Initialize the settings bundle with four service settings.""" |
|
6796 |
- settings: list[_types.VaultConfigServicesSettings] = [] |
|
6797 |
- for c in configs: |
|
6798 |
- settings.extend(c["services"].values()) |
|
6799 |
- return stateful.multiple(*map(copy.deepcopy, settings)) |
|
6800 |
- |
|
6801 |
- @stateful.initialize( |
|
6802 |
- config=vault_full_config(), |
|
6803 |
- ) |
|
6804 |
- def declare_initial_action( |
|
6805 |
- self, |
|
6806 |
- config: _types.VaultConfig, |
|
6807 |
- ) -> None: |
|
6808 |
- """Initialize the actions bundle from the configuration bundle. |
|
6809 |
- |
|
6810 |
- This is roughly comparable to the |
|
6811 |
- [`add_import_configuration_action`][] general rule, but adding |
|
6812 |
- it as a separate initialize rule avoids having to guard every |
|
6813 |
- other action-amending rule against empty action sequences, which |
|
6814 |
- would discard huge portions of the rule selection search space |
|
6815 |
- and thus trigger loads of hypothesis health check warnings. |
|
6816 |
- |
|
6817 |
- """ |
|
6818 |
- command_line = ["--import", "-", "--overwrite-existing"] |
|
6819 |
- input = json.dumps(config) # noqa: A001 |
|
6820 |
- hypothesis.note(f"# {command_line = }, {input = }") |
|
6821 |
- action = FakeConfigurationMutexAction( |
|
6822 |
- command_line=command_line, input=input |
|
6823 |
- ) |
|
6824 |
- self.actions.append(action) |
|
6825 |
- |
|
6826 |
- @stateful.rule( |
|
6827 |
- setting=setting.filter(bool), |
|
6828 |
- maybe_unset=strategies.sets( |
|
6829 |
- strategies.sampled_from(VALID_PROPERTIES), |
|
6830 |
- max_size=3, |
|
6831 |
- ), |
|
6832 |
- overwrite=strategies.booleans(), |
|
6833 |
- ) |
|
6834 |
- def add_set_globals_action( |
|
6835 |
- self, |
|
6836 |
- setting: _types.VaultConfigGlobalSettings, |
|
6837 |
- maybe_unset: set[str], |
|
6838 |
- overwrite: bool, |
|
6839 |
- ) -> None: |
|
6840 |
- """Set the global settings of a configuration. |
|
6841 |
- |
|
6842 |
- Args: |
|
6843 |
- setting: |
|
6844 |
- The new global settings. |
|
6845 |
- maybe_unset: |
|
6846 |
- Settings keys to additionally unset, if not already |
|
6847 |
- present in the new settings. Corresponds to the |
|
6848 |
- `--unset` command-line argument. |
|
6849 |
- overwrite: |
|
6850 |
- Overwrite the settings object if true, or merge if |
|
6851 |
- false. Corresponds to the `--overwrite-existing` and |
|
6852 |
- `--merge-existing` command-line arguments. |
|
6853 |
- |
|
6854 |
- """ |
|
6855 |
- maybe_unset = set(maybe_unset) - setting.keys() |
|
6856 |
- command_line = ( |
|
6857 |
- [ |
|
6858 |
- "--config", |
|
6859 |
- "--overwrite-existing" if overwrite else "--merge-existing", |
|
6860 |
- ] |
|
6861 |
- + [f"--unset={key}" for key in maybe_unset] |
|
6862 |
- + [ |
|
6863 |
- f"--{key}={value}" |
|
6864 |
- for key, value in setting.items() |
|
6865 |
- if key in VALID_PROPERTIES |
|
6866 |
- ] |
|
6867 |
- ) |
|
6868 |
- input = None # noqa: A001 |
|
6869 |
- hypothesis.note(f"# {command_line = }, {input = }") |
|
6870 |
- action = FakeConfigurationMutexAction( |
|
6871 |
- command_line=command_line, input=input |
|
6872 |
- ) |
|
6873 |
- self.actions.append(action) |
|
6874 |
- |
|
6875 |
- @stateful.rule( |
|
6876 |
- service=strategies.sampled_from(KNOWN_SERVICES), |
|
6877 |
- setting=setting.filter(bool), |
|
6878 |
- maybe_unset=strategies.sets( |
|
6879 |
- strategies.sampled_from(VALID_PROPERTIES), |
|
6880 |
- max_size=3, |
|
6881 |
- ), |
|
6882 |
- overwrite=strategies.booleans(), |
|
6883 |
- ) |
|
6884 |
- def add_set_service_action( |
|
6885 |
- self, |
|
6886 |
- service: str, |
|
6887 |
- setting: _types.VaultConfigServicesSettings, |
|
6888 |
- maybe_unset: set[str], |
|
6889 |
- overwrite: bool, |
|
6890 |
- ) -> None: |
|
6891 |
- """Set the named service settings for a configuration. |
|
6892 |
- |
|
6893 |
- Args: |
|
6894 |
- service: |
|
6895 |
- The name of the service to set. |
|
6896 |
- setting: |
|
6897 |
- The new service settings. |
|
6898 |
- maybe_unset: |
|
6899 |
- Settings keys to additionally unset, if not already |
|
6900 |
- present in the new settings. Corresponds to the |
|
6901 |
- `--unset` command-line argument. |
|
6902 |
- overwrite: |
|
6903 |
- Overwrite the settings object if true, or merge if |
|
6904 |
- false. Corresponds to the `--overwrite-existing` and |
|
6905 |
- `--merge-existing` command-line arguments. |
|
6906 |
- |
|
6907 |
- """ |
|
6908 |
- maybe_unset = set(maybe_unset) - setting.keys() |
|
6909 |
- command_line = ( |
|
6910 |
- [ |
|
6911 |
- "--config", |
|
6912 |
- "--overwrite-existing" if overwrite else "--merge-existing", |
|
6913 |
- ] |
|
6914 |
- + [f"--unset={key}" for key in maybe_unset] |
|
6915 |
- + [ |
|
6916 |
- f"--{key}={value}" |
|
6917 |
- for key, value in setting.items() |
|
6918 |
- if key in VALID_PROPERTIES |
|
6919 |
- ] |
|
6920 |
- + ["--", service] |
|
6921 |
- ) |
|
6922 |
- input = None # noqa: A001 |
|
6923 |
- hypothesis.note(f"# {command_line = }, {input = }") |
|
6924 |
- action = FakeConfigurationMutexAction( |
|
6925 |
- command_line=command_line, input=input |
|
6926 |
- ) |
|
6927 |
- self.actions.append(action) |
|
6928 |
- |
|
6929 |
- @stateful.rule() |
|
6930 |
- def add_purge_global_action( |
|
6931 |
- self, |
|
6932 |
- ) -> None: |
|
6933 |
- """Purge the globals of a configuration.""" |
|
6934 |
- command_line = ["--delete-globals"] |
|
6935 |
- input = None # 'y' # noqa: A001 |
|
6936 |
- hypothesis.note(f"# {command_line = }, {input = }") |
|
6937 |
- action = FakeConfigurationMutexAction( |
|
6938 |
- command_line=command_line, input=input |
|
6939 |
- ) |
|
6940 |
- self.actions.append(action) |
|
6941 |
- |
|
6942 |
- @stateful.rule( |
|
6943 |
- service=strategies.sampled_from(KNOWN_SERVICES), |
|
6944 |
- ) |
|
6945 |
- def add_purge_service_action( |
|
6946 |
- self, |
|
6947 |
- service: str, |
|
6948 |
- ) -> None: |
|
6949 |
- """Purge the settings of a named service in a configuration. |
|
6950 |
- |
|
6951 |
- Args: |
|
6952 |
- service: |
|
6953 |
- The service name to purge. |
|
6954 |
- |
|
6955 |
- """ |
|
6956 |
- command_line = ["--delete", "--", service] |
|
6957 |
- input = None # 'y' # noqa: A001 |
|
6958 |
- hypothesis.note(f"# {command_line = }, {input = }") |
|
6959 |
- action = FakeConfigurationMutexAction( |
|
6960 |
- command_line=command_line, input=input |
|
6961 |
- ) |
|
6962 |
- self.actions.append(action) |
|
6963 |
- |
|
6964 |
- @stateful.rule() |
|
6965 |
- def add_purge_all_action( |
|
6966 |
- self, |
|
6967 |
- ) -> None: |
|
6968 |
- """Purge the entire configuration.""" |
|
6969 |
- command_line = ["--clear"] |
|
6970 |
- input = None # 'y' # noqa: A001 |
|
6971 |
- hypothesis.note(f"# {command_line = }, {input = }") |
|
6972 |
- action = FakeConfigurationMutexAction( |
|
6973 |
- command_line=command_line, input=input |
|
6974 |
- ) |
|
6975 |
- self.actions.append(action) |
|
6976 |
- |
|
6977 |
- @stateful.rule( |
|
6978 |
- config_to_import=configuration, |
|
6979 |
- overwrite=strategies.booleans(), |
|
6980 |
- ) |
|
6981 |
- def add_import_configuration_action( |
|
6982 |
- self, |
|
6983 |
- config_to_import: _types.VaultConfig, |
|
6984 |
- overwrite: bool, |
|
6985 |
- ) -> None: |
|
6986 |
- """Import the given configuration. |
|
6987 |
- |
|
6988 |
- Args: |
|
6989 |
- config_to_import: |
|
6990 |
- The configuration to import. |
|
6991 |
- overwrite: |
|
6992 |
- Overwrite the base configuration if true, or merge if |
|
6993 |
- false. Corresponds to the `--overwrite-existing` and |
|
6994 |
- `--merge-existing` command-line arguments. |
|
6995 |
- |
|
6996 |
- """ |
|
6997 |
- command_line = ["--import", "-"] + ( |
|
6998 |
- ["--overwrite-existing"] if overwrite else [] |
|
6999 |
- ) |
|
7000 |
- input = json.dumps(config_to_import) # noqa: A001 |
|
7001 |
- hypothesis.note(f"# {command_line = }, {input = }") |
|
7002 |
- action = FakeConfigurationMutexAction( |
|
7003 |
- command_line=command_line, input=input |
|
7004 |
- ) |
|
7005 |
- self.actions.append(action) |
|
7006 |
- |
|
7007 |
- @stateful.precondition(lambda self: len(self.actions) > 0) |
|
7008 |
- @stateful.invariant() |
|
7009 |
- def run_actions( # noqa: C901 |
|
7010 |
- self, |
|
7011 |
- ) -> None: |
|
7012 |
- """Run the actions, serially and concurrently. |
|
7013 |
- |
|
7014 |
- Run the actions once serially, then once more concurrently with |
|
7015 |
- the faked configuration mutex, and assert that both runs yield |
|
7016 |
- identical intermediate and final results. |
|
7017 |
- |
|
7018 |
- We must run the concurrent version in processes, not threads or |
|
7019 |
- Python async functions, because the `click` testing machinery |
|
7020 |
- manipulates global properties (e.g. the standard I/O streams, |
|
7021 |
- the current directory, and the environment), and we require this |
|
7022 |
- manipulation to happen in a time-overlapped manner. |
|
7023 |
- |
|
7024 |
- However, running multiple processes increases the risk of the |
|
7025 |
- operating system imposing process count or memory limits on us. |
|
7026 |
- We therefore skip the test as a whole if we fail to start a new |
|
7027 |
- process due to lack of necessary resources (memory, processes, |
|
7028 |
- or open file descriptors). |
|
7029 |
- |
|
7030 |
- """ |
|
7031 |
- if not TYPE_CHECKING: # pragma: no branch |
|
7032 |
- multiprocessing = pytest.importorskip("multiprocessing") |
|
7033 |
- IPCMessage: TypeAlias = FakeConfigurationMutexStateMachine.IPCMessage |
|
7034 |
- intermediate_configs: dict[int, _types.VaultConfig] = {} |
|
7035 |
- intermediate_results: dict[ |
|
7036 |
- int, tuple[bool, str | None, str | None] |
|
7037 |
- ] = {} |
|
7038 |
- true_configs: dict[int, _types.VaultConfig] = {} |
|
7039 |
- true_results: dict[int, tuple[bool, str | None, str | None]] = {} |
|
7040 |
- timeout = 30 # Hopefully slow enough to accomodate The Annoying OS. |
|
7041 |
- actions = self.actions |
|
7042 |
- mp = multiprocessing.get_context() |
|
7043 |
- # Coverage tracking writes coverage data to the current working |
|
7044 |
- # directory, but because the subprocesses are spawned within the |
|
7045 |
- # `tests.machinery.pytest.isolated_vault_config` context manager, their starting |
|
7046 |
- # working directory is the isolated one, not the original one. |
|
7047 |
- orig_cwd = pathlib.Path.cwd() |
|
7048 |
- |
|
7049 |
- fatal_process_creation_errnos = { |
|
7050 |
- # Specified by POSIX for fork(3). |
|
7051 |
- errno.ENOMEM, |
|
7052 |
- # Specified by POSIX for fork(3). |
|
7053 |
- errno.EAGAIN, |
|
7054 |
- # Specified by Linux/glibc for fork(3) |
|
7055 |
- getattr(errno, "ENOSYS", errno.ENOMEM), |
|
7056 |
- # Specified by POSIX for posix_spawn(3). |
|
7057 |
- errno.EINVAL, |
|
7058 |
- } |
|
7059 |
- |
|
7060 |
- hypothesis.note(f"# {actions = }") |
|
7061 |
- |
|
7062 |
- stack = contextlib.ExitStack() |
|
7063 |
- with stack: |
|
7064 |
- runner = tests.machinery.CliRunner(mix_stderr=False) |
|
7065 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
7066 |
- stack.enter_context( |
|
7067 |
- tests.machinery.pytest.isolated_vault_config( |
|
7068 |
- monkeypatch=monkeypatch, |
|
7069 |
- runner=runner, |
|
7070 |
- vault_config={"services": {}}, |
|
7071 |
- ) |
|
7072 |
- ) |
|
7073 |
- for i, action in enumerate(actions): |
|
7074 |
- result = runner.invoke( |
|
7075 |
- cli.derivepassphrase_vault, |
|
7076 |
- args=action.command_line, |
|
7077 |
- input=action.input, |
|
7078 |
- catch_exceptions=True, |
|
7079 |
- ) |
|
7080 |
- true_configs[i] = copy.copy(cli_helpers.load_config()) |
|
7081 |
- true_results[i] = ( |
|
7082 |
- result.clean_exit(empty_stderr=False), |
|
7083 |
- result.stdout, |
|
7084 |
- result.stderr, |
|
7085 |
- ) |
|
7086 |
- |
|
7087 |
- with stack: # noqa: PLR1702 |
|
7088 |
- runner = tests.machinery.CliRunner(mix_stderr=False) |
|
7089 |
- monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
7090 |
- stack.enter_context( |
|
7091 |
- tests.machinery.pytest.isolated_vault_config( |
|
7092 |
- monkeypatch=monkeypatch, |
|
7093 |
- runner=runner, |
|
7094 |
- vault_config={"services": {}}, |
|
7095 |
- ) |
|
7096 |
- ) |
|
7097 |
- |
|
7098 |
- child_output_queue: multiprocessing.Queue[IPCMessage] = mp.Queue() |
|
7099 |
- child_input_queues: list[ |
|
7100 |
- multiprocessing.Queue[IPCMessage] | None |
|
7101 |
- ] = [] |
|
7102 |
- processes: list[multiprocessing.process.BaseProcess] = [] |
|
7103 |
- processes_pending: set[multiprocessing.process.BaseProcess] = set() |
|
7104 |
- ready_wait: set[int] = set() |
|
7105 |
- |
|
7106 |
- try: |
|
7107 |
- for i, action in enumerate(actions): |
|
7108 |
- q: multiprocessing.Queue[IPCMessage] | None = mp.Queue() |
|
7109 |
- try: |
|
7110 |
- p: multiprocessing.process.BaseProcess = mp.Process( |
|
7111 |
- name=f"fake-mutex-action-{i:02d}", |
|
7112 |
- target=run_actions_handler, |
|
7113 |
- kwargs={ |
|
7114 |
- "id_num": i, |
|
7115 |
- "timeout": timeout, |
|
7116 |
- "action": action, |
|
7117 |
- "input_queue": q, |
|
7118 |
- "output_queue": child_output_queue, |
|
7119 |
- }, |
|
7120 |
- daemon=False, |
|
7121 |
- ) |
|
7122 |
- p.start() |
|
7123 |
- except OSError as exc: # pragma: no cover |
|
7124 |
- if exc.errno in fatal_process_creation_errnos: |
|
7125 |
- pytest.skip( |
|
7126 |
- "cannot test mutex functionality due to " |
|
7127 |
- "lack of system resources for " |
|
7128 |
- "creating enough subprocesses" |
|
7129 |
- ) |
|
7130 |
- raise |
|
7131 |
- else: |
|
7132 |
- processes.append(p) |
|
7133 |
- processes_pending.add(p) |
|
7134 |
- child_input_queues.append(q) |
|
7135 |
- ready_wait.add(i) |
|
7136 |
- |
|
7137 |
- while processes_pending: |
|
7138 |
- try: |
|
7139 |
- self.mainloop( |
|
7140 |
- timeout=timeout, |
|
7141 |
- child_output_queue=child_output_queue, |
|
7142 |
- child_input_queues=child_input_queues, |
|
7143 |
- ready_wait=ready_wait, |
|
7144 |
- intermediate_configs=intermediate_configs, |
|
7145 |
- intermediate_results=intermediate_results, |
|
7146 |
- processes=processes, |
|
7147 |
- processes_pending=processes_pending, |
|
7148 |
- block=True, |
|
7149 |
- ) |
|
7150 |
- except Exception as exc: # pragma: no cover |
|
7151 |
- for i, q in enumerate(child_input_queues): |
|
7152 |
- if q: |
|
7153 |
- q.put(IPCMessage(i, "exception", exc)) |
|
7154 |
- for p in processes_pending: |
|
7155 |
- p.join(timeout=timeout) |
|
7156 |
- raise |
|
7157 |
- finally: |
|
7158 |
- try: |
|
7159 |
- while True: |
|
7160 |
- try: |
|
7161 |
- self.mainloop( |
|
7162 |
- timeout=timeout, |
|
7163 |
- child_output_queue=child_output_queue, |
|
7164 |
- child_input_queues=child_input_queues, |
|
7165 |
- ready_wait=ready_wait, |
|
7166 |
- intermediate_configs=intermediate_configs, |
|
7167 |
- intermediate_results=intermediate_results, |
|
7168 |
- processes=processes, |
|
7169 |
- processes_pending=processes_pending, |
|
7170 |
- block=False, |
|
7171 |
- ) |
|
7172 |
- except queue.Empty: |
|
7173 |
- break |
|
7174 |
- finally: |
|
7175 |
- # The subprocesses have this |
|
7176 |
- # `tests.machinery.pytest.isolated_vault_config` directory as their |
|
7177 |
- # startup and working directory, so systems like |
|
7178 |
- # coverage tracking write their data files to this |
|
7179 |
- # directory. We need to manually move them back to |
|
7180 |
- # the starting working directory if they are to |
|
7181 |
- # survive this test. |
|
7182 |
- for coverage_file in pathlib.Path.cwd().glob( |
|
7183 |
- ".coverage.*" |
|
7184 |
- ): |
|
7185 |
- shutil.move(coverage_file, orig_cwd) |
|
7186 |
- hypothesis.note( |
|
7187 |
- f"# {true_results = }, {intermediate_results = }, " |
|
7188 |
- f"identical = {true_results == intermediate_results}" |
|
7189 |
- ) |
|
7190 |
- hypothesis.note( |
|
7191 |
- f"# {true_configs = }, {intermediate_configs = }, " |
|
7192 |
- f"identical = {true_configs == intermediate_configs}" |
|
7193 |
- ) |
|
7194 |
- assert intermediate_results == true_results |
|
7195 |
- assert intermediate_configs == true_configs |
|
7196 |
- |
|
7197 |
- @staticmethod |
|
7198 |
- def mainloop( |
|
7199 |
- *, |
|
7200 |
- timeout: int, |
|
7201 |
- child_output_queue: multiprocessing.Queue[ |
|
7202 |
- FakeConfigurationMutexStateMachine.IPCMessage |
|
7203 |
- ], |
|
7204 |
- child_input_queues: list[ |
|
7205 |
- multiprocessing.Queue[ |
|
7206 |
- FakeConfigurationMutexStateMachine.IPCMessage |
|
7207 |
- ] |
|
7208 |
- | None |
|
7209 |
- ], |
|
7210 |
- ready_wait: set[int], |
|
7211 |
- intermediate_configs: dict[int, _types.VaultConfig], |
|
7212 |
- intermediate_results: dict[int, tuple[bool, str | None, str | None]], |
|
7213 |
- processes: list[multiprocessing.process.BaseProcess], |
|
7214 |
- processes_pending: set[multiprocessing.process.BaseProcess], |
|
7215 |
- block: bool = True, |
|
7216 |
- ) -> None: |
|
7217 |
- IPCMessage: TypeAlias = FakeConfigurationMutexStateMachine.IPCMessage |
|
7218 |
- msg = child_output_queue.get(block=block, timeout=timeout) |
|
7219 |
- # TODO(the-13th-letter): Rewrite using structural pattern |
|
7220 |
- # matching. |
|
7221 |
- # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
7222 |
- if ( # pragma: no cover |
|
7223 |
- isinstance(msg, IPCMessage) |
|
7224 |
- and msg[1] == "exception" |
|
7225 |
- and isinstance(msg[2], Exception) |
|
7226 |
- ): |
|
7227 |
- e = msg[2] |
|
7228 |
- raise e |
|
7229 |
- if isinstance(msg, IPCMessage) and msg[1] == "ready": |
|
7230 |
- n = msg[0] |
|
7231 |
- ready_wait.remove(n) |
|
7232 |
- if not ready_wait: |
|
7233 |
- assert child_input_queues |
|
7234 |
- assert child_input_queues[0] |
|
7235 |
- child_input_queues[0].put( |
|
7236 |
- IPCMessage(0, "go", None), |
|
7237 |
- block=True, |
|
7238 |
- timeout=timeout, |
|
7239 |
- ) |
|
7240 |
- elif isinstance(msg, IPCMessage) and msg[1] == "config": |
|
7241 |
- n = msg[0] |
|
7242 |
- config = msg[2] |
|
7243 |
- intermediate_configs[n] = cast("_types.VaultConfig", config) |
|
7244 |
- elif isinstance(msg, IPCMessage) and msg[1] == "result": |
|
7245 |
- n = msg[0] |
|
7246 |
- result_ = msg[2] |
|
7247 |
- result_tuple: tuple[bool, str | None, str | None] = cast( |
|
7248 |
- "tuple[bool, str | None, str | None]", result_ |
|
7249 |
- ) |
|
7250 |
- intermediate_results[n] = result_tuple |
|
7251 |
- child_input_queues[n] = None |
|
7252 |
- p = processes[n] |
|
7253 |
- p.join(timeout=timeout) |
|
7254 |
- assert not p.is_alive() |
|
7255 |
- processes_pending.remove(p) |
|
7256 |
- assert result_tuple[0], ( |
|
7257 |
- f"action #{n} exited with an error: {result_tuple!r}" |
|
7258 |
- ) |
|
7259 |
- if n + 1 < len(processes): |
|
7260 |
- next_child_input_queue = child_input_queues[n + 1] |
|
7261 |
- assert next_child_input_queue |
|
7262 |
- next_child_input_queue.put( |
|
7263 |
- IPCMessage(n + 1, "go", None), |
|
7264 |
- block=True, |
|
7265 |
- timeout=timeout, |
|
7266 |
- ) |
|
7267 |
- else: |
|
7268 |
- raise AssertionError() |
|
7269 |
- |
|
7270 |
- |
|
7271 |
-TestFakedConfigurationMutex = ( |
|
7272 |
- tests.machinery.pytest.skip_if_no_multiprocessing_support( |
|
7273 |
- FakeConfigurationMutexStateMachine.TestCase |
|
7274 |
- ) |
|
7275 |
-) |
|
7276 |
-"""The [`unittest.TestCase`][] class that will actually be run.""" |
|
7277 |
- |
|
7278 |
- |
|
7279 | 5993 |
def completion_item( |
7280 | 5994 |
item: str | click.shell_completion.CompletionItem, |
7281 | 5995 |
) -> click.shell_completion.CompletionItem: |
... | ... |
@@ -0,0 +1,1319 @@ |
1 |
+# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info> |
|
2 |
+# |
|
3 |
+# SPDX-License-Identifier: Zlib |
|
4 |
+ |
|
5 |
+from __future__ import annotations |
|
6 |
+ |
|
7 |
+import contextlib |
|
8 |
+import copy |
|
9 |
+import errno |
|
10 |
+import json |
|
11 |
+import pathlib |
|
12 |
+import queue |
|
13 |
+import shutil |
|
14 |
+from typing import TYPE_CHECKING, cast |
|
15 |
+ |
|
16 |
+import hypothesis |
|
17 |
+import pytest |
|
18 |
+from hypothesis import stateful, strategies |
|
19 |
+from typing_extensions import Any, NamedTuple, TypeAlias |
|
20 |
+ |
|
21 |
+import tests.data |
|
22 |
+import tests.data.callables |
|
23 |
+import tests.machinery |
|
24 |
+import tests.machinery.hypothesis |
|
25 |
+import tests.machinery.pytest |
|
26 |
+from derivepassphrase import _types, cli |
|
27 |
+from derivepassphrase._internals import cli_helpers |
|
28 |
+ |
|
29 |
+if TYPE_CHECKING: |
|
30 |
+ import multiprocessing |
|
31 |
+ from collections.abc import Iterable |
|
32 |
+ |
|
33 |
+ from typing_extensions import Literal |
|
34 |
+ |
|
35 |
+# All tests in this module are heavy-duty tests. |
|
36 |
+pytestmark = [tests.machinery.pytest.heavy_duty] |
|
37 |
+ |
|
38 |
+KNOWN_SERVICES = (tests.data.DUMMY_SERVICE, "email", "bank", "work") |
|
39 |
+"""Known service names. Used for the [`ConfigManagementStateMachine`][].""" |
|
40 |
+VALID_PROPERTIES = ( |
|
41 |
+ "length", |
|
42 |
+ "repeat", |
|
43 |
+ "upper", |
|
44 |
+ "lower", |
|
45 |
+ "number", |
|
46 |
+ "space", |
|
47 |
+ "dash", |
|
48 |
+ "symbol", |
|
49 |
+) |
|
50 |
+"""Known vault properties. Used for the [`ConfigManagementStateMachine`][].""" |
|
51 |
+ |
|
52 |
+ |
|
53 |
+def build_reduced_vault_config_settings( |
|
54 |
+ config: _types.VaultConfigServicesSettings, |
|
55 |
+ keys_to_prune: frozenset[str], |
|
56 |
+) -> _types.VaultConfigServicesSettings: |
|
57 |
+ """Return a service settings object with certain keys pruned. |
|
58 |
+ |
|
59 |
+ Args: |
|
60 |
+ config: |
|
61 |
+ The original service settings object. |
|
62 |
+ keys_to_prune: |
|
63 |
+ The keys to prune from the settings object. |
|
64 |
+ |
|
65 |
+ """ |
|
66 |
+ config2 = copy.deepcopy(config) |
|
67 |
+ for key in keys_to_prune: |
|
68 |
+ config2.pop(key, None) # type: ignore[misc] |
|
69 |
+ return config2 |
|
70 |
+ |
|
71 |
+ |
|
72 |
+SERVICES_STRATEGY = strategies.builds( |
|
73 |
+ build_reduced_vault_config_settings, |
|
74 |
+ tests.machinery.hypothesis.vault_full_service_config(), |
|
75 |
+ strategies.sets( |
|
76 |
+ strategies.sampled_from(VALID_PROPERTIES), |
|
77 |
+ max_size=7, |
|
78 |
+ ), |
|
79 |
+) |
|
80 |
+"""A hypothesis strategy to build incomplete service configurations.""" |
|
81 |
+ |
|
82 |
+ |
|
83 |
+def services_strategy() -> strategies.SearchStrategy[ |
|
84 |
+ _types.VaultConfigServicesSettings |
|
85 |
+]: |
|
86 |
+ """Return a strategy to build incomplete service configurations.""" |
|
87 |
+ return SERVICES_STRATEGY |
|
88 |
+ |
|
89 |
+ |
|
90 |
+def assemble_config( |
|
91 |
+ global_data: _types.VaultConfigGlobalSettings, |
|
92 |
+ service_data: list[tuple[str, _types.VaultConfigServicesSettings]], |
|
93 |
+) -> _types.VaultConfig: |
|
94 |
+ """Return a vault config using the global and service data.""" |
|
95 |
+ services_dict = dict(service_data) |
|
96 |
+ return ( |
|
97 |
+ {"global": global_data, "services": services_dict} |
|
98 |
+ if global_data |
|
99 |
+ else {"services": services_dict} |
|
100 |
+ ) |
|
101 |
+ |
|
102 |
+ |
|
103 |
+@strategies.composite |
|
104 |
+def draw_service_name_and_data( |
|
105 |
+ draw: hypothesis.strategies.DrawFn, |
|
106 |
+ num_entries: int, |
|
107 |
+) -> tuple[tuple[str, _types.VaultConfigServicesSettings], ...]: |
|
108 |
+ """Draw a service name and settings, as a hypothesis strategy. |
|
109 |
+ |
|
110 |
+ Will draw service names from [`KNOWN_SERVICES`][] and service |
|
111 |
+ settings via [`services_strategy`][]. |
|
112 |
+ |
|
113 |
+ Args: |
|
114 |
+ draw: |
|
115 |
+ The `draw` function, as provided for by hypothesis. |
|
116 |
+ num_entries: |
|
117 |
+ The number of services to draw. |
|
118 |
+ |
|
119 |
+ Returns: |
|
120 |
+ A sequence of pairs of service names and service settings. |
|
121 |
+ |
|
122 |
+ """ |
|
123 |
+ possible_services = list(KNOWN_SERVICES) |
|
124 |
+ selected_services: list[str] = [] |
|
125 |
+ for _ in range(num_entries): |
|
126 |
+ selected_services.append( |
|
127 |
+ draw(strategies.sampled_from(possible_services)) |
|
128 |
+ ) |
|
129 |
+ possible_services.remove(selected_services[-1]) |
|
130 |
+ return tuple( |
|
131 |
+ (service, draw(services_strategy())) for service in selected_services |
|
132 |
+ ) |
|
133 |
+ |
|
134 |
+ |
|
135 |
+VAULT_FULL_CONFIG = strategies.builds( |
|
136 |
+ assemble_config, |
|
137 |
+ services_strategy(), |
|
138 |
+ strategies.integers( |
|
139 |
+ min_value=2, |
|
140 |
+ max_value=4, |
|
141 |
+ ).flatmap(draw_service_name_and_data), |
|
142 |
+) |
|
143 |
+"""A hypothesis strategy to build full vault configurations.""" |
|
144 |
+ |
|
145 |
+ |
|
146 |
+def vault_full_config() -> strategies.SearchStrategy[_types.VaultConfig]: |
|
147 |
+ """Return a strategy to build full vault configurations.""" |
|
148 |
+ return VAULT_FULL_CONFIG |
|
149 |
+ |
|
150 |
+ |
|
151 |
+class ConfigManagementStateMachine(stateful.RuleBasedStateMachine): |
|
152 |
+ """A state machine recording changes in the vault configuration. |
|
153 |
+ |
|
154 |
+ Record possible configuration states in bundles, then in each rule, |
|
155 |
+ take a configuration and manipulate it somehow. |
|
156 |
+ |
|
157 |
+ Attributes: |
|
158 |
+ setting: |
|
159 |
+ A bundle for single-service settings. |
|
160 |
+ configuration: |
|
161 |
+ A bundle for full vault configurations. |
|
162 |
+ |
|
163 |
+ """ |
|
164 |
+ |
|
165 |
+ def __init__(self) -> None: |
|
166 |
+ """Initialize self, set up context managers and enter them.""" |
|
167 |
+ super().__init__() |
|
168 |
+ self.runner = tests.machinery.CliRunner(mix_stderr=False) |
|
169 |
+ self.exit_stack = contextlib.ExitStack().__enter__() |
|
170 |
+ self.monkeypatch = self.exit_stack.enter_context( |
|
171 |
+ pytest.MonkeyPatch().context() |
|
172 |
+ ) |
|
173 |
+ self.isolated_config = self.exit_stack.enter_context( |
|
174 |
+ tests.machinery.pytest.isolated_vault_config( |
|
175 |
+ monkeypatch=self.monkeypatch, |
|
176 |
+ runner=self.runner, |
|
177 |
+ vault_config={"services": {}}, |
|
178 |
+ ) |
|
179 |
+ ) |
|
180 |
+ |
|
181 |
+ setting: stateful.Bundle[_types.VaultConfigServicesSettings] = ( |
|
182 |
+ stateful.Bundle("setting") |
|
183 |
+ ) |
|
184 |
+ """""" |
|
185 |
+ configuration: stateful.Bundle[_types.VaultConfig] = stateful.Bundle( |
|
186 |
+ "configuration" |
|
187 |
+ ) |
|
188 |
+ """""" |
|
189 |
+ |
|
190 |
+ @stateful.initialize( |
|
191 |
+ target=configuration, |
|
192 |
+ configs=strategies.lists( |
|
193 |
+ vault_full_config(), |
|
194 |
+ min_size=8, |
|
195 |
+ max_size=8, |
|
196 |
+ ), |
|
197 |
+ ) |
|
198 |
+ def declare_initial_configs( |
|
199 |
+ self, |
|
200 |
+ configs: Iterable[_types.VaultConfig], |
|
201 |
+ ) -> stateful.MultipleResults[_types.VaultConfig]: |
|
202 |
+ """Initialize the configuration bundle with eight configurations.""" |
|
203 |
+ return stateful.multiple(*configs) |
|
204 |
+ |
|
205 |
+ @stateful.initialize( |
|
206 |
+ target=setting, |
|
207 |
+ configs=strategies.lists( |
|
208 |
+ vault_full_config(), |
|
209 |
+ min_size=4, |
|
210 |
+ max_size=4, |
|
211 |
+ ), |
|
212 |
+ ) |
|
213 |
+ def extract_initial_settings( |
|
214 |
+ self, |
|
215 |
+ configs: list[_types.VaultConfig], |
|
216 |
+ ) -> stateful.MultipleResults[_types.VaultConfigServicesSettings]: |
|
217 |
+ """Initialize the settings bundle with four service settings.""" |
|
218 |
+ settings: list[_types.VaultConfigServicesSettings] = [] |
|
219 |
+ for c in configs: |
|
220 |
+ settings.extend(c["services"].values()) |
|
221 |
+ return stateful.multiple(*map(copy.deepcopy, settings)) |
|
222 |
+ |
|
223 |
+ @staticmethod |
|
224 |
+ def fold_configs( |
|
225 |
+ c1: _types.VaultConfig, c2: _types.VaultConfig |
|
226 |
+ ) -> _types.VaultConfig: |
|
227 |
+ """Fold `c1` into `c2`, overriding the latter.""" |
|
228 |
+ new_global_dict = c1.get("global", c2.get("global")) |
|
229 |
+ if new_global_dict is not None: |
|
230 |
+ return { |
|
231 |
+ "global": new_global_dict, |
|
232 |
+ "services": {**c2["services"], **c1["services"]}, |
|
233 |
+ } |
|
234 |
+ return { |
|
235 |
+ "services": {**c2["services"], **c1["services"]}, |
|
236 |
+ } |
|
237 |
+ |
|
238 |
+ @stateful.rule( |
|
239 |
+ target=configuration, |
|
240 |
+ config=configuration, |
|
241 |
+ setting=setting.filter(bool), |
|
242 |
+ maybe_unset=strategies.sets( |
|
243 |
+ strategies.sampled_from(VALID_PROPERTIES), |
|
244 |
+ max_size=3, |
|
245 |
+ ), |
|
246 |
+ overwrite=strategies.booleans(), |
|
247 |
+ ) |
|
248 |
+ def set_globals( |
|
249 |
+ self, |
|
250 |
+ config: _types.VaultConfig, |
|
251 |
+ setting: _types.VaultConfigGlobalSettings, |
|
252 |
+ maybe_unset: set[str], |
|
253 |
+ overwrite: bool, |
|
254 |
+ ) -> _types.VaultConfig: |
|
255 |
+ """Set the global settings of a configuration. |
|
256 |
+ |
|
257 |
+ Args: |
|
258 |
+ config: |
|
259 |
+ The configuration to edit. |
|
260 |
+ setting: |
|
261 |
+ The new global settings. |
|
262 |
+ maybe_unset: |
|
263 |
+ Settings keys to additionally unset, if not already |
|
264 |
+ present in the new settings. Corresponds to the |
|
265 |
+ `--unset` command-line argument. |
|
266 |
+ overwrite: |
|
267 |
+ Overwrite the settings object if true, or merge if |
|
268 |
+ false. Corresponds to the `--overwrite-existing` and |
|
269 |
+ `--merge-existing` command-line arguments. |
|
270 |
+ |
|
271 |
+ Returns: |
|
272 |
+ The amended configuration. |
|
273 |
+ |
|
274 |
+ """ |
|
275 |
+ cli_helpers.save_config(config) |
|
276 |
+ config_global = config.get("global", {}) |
|
277 |
+ maybe_unset = set(maybe_unset) - setting.keys() |
|
278 |
+ if overwrite: |
|
279 |
+ config["global"] = config_global = {} |
|
280 |
+ elif maybe_unset: |
|
281 |
+ for key in maybe_unset: |
|
282 |
+ config_global.pop(key, None) # type: ignore[misc] |
|
283 |
+ config.setdefault("global", {}).update(setting) |
|
284 |
+ assert _types.is_vault_config(config) |
|
285 |
+ # NOTE: This relies on settings_obj containing only the keys |
|
286 |
+ # "length", "repeat", "upper", "lower", "number", "space", |
|
287 |
+ # "dash" and "symbol". |
|
288 |
+ result = self.runner.invoke( |
|
289 |
+ cli.derivepassphrase_vault, |
|
290 |
+ [ |
|
291 |
+ "--config", |
|
292 |
+ "--overwrite-existing" if overwrite else "--merge-existing", |
|
293 |
+ ] |
|
294 |
+ + [f"--unset={key}" for key in maybe_unset] |
|
295 |
+ + [ |
|
296 |
+ f"--{key}={value}" |
|
297 |
+ for key, value in setting.items() |
|
298 |
+ if key in VALID_PROPERTIES |
|
299 |
+ ], |
|
300 |
+ catch_exceptions=False, |
|
301 |
+ ) |
|
302 |
+ assert result.clean_exit(empty_stderr=False) |
|
303 |
+ assert cli_helpers.load_config() == config |
|
304 |
+ return config |
|
305 |
+ |
|
306 |
+ @stateful.rule( |
|
307 |
+ target=configuration, |
|
308 |
+ config=configuration, |
|
309 |
+ service=strategies.sampled_from(KNOWN_SERVICES), |
|
310 |
+ setting=setting.filter(bool), |
|
311 |
+ maybe_unset=strategies.sets( |
|
312 |
+ strategies.sampled_from(VALID_PROPERTIES), |
|
313 |
+ max_size=3, |
|
314 |
+ ), |
|
315 |
+ overwrite=strategies.booleans(), |
|
316 |
+ ) |
|
317 |
+ def set_service( |
|
318 |
+ self, |
|
319 |
+ config: _types.VaultConfig, |
|
320 |
+ service: str, |
|
321 |
+ setting: _types.VaultConfigServicesSettings, |
|
322 |
+ maybe_unset: set[str], |
|
323 |
+ overwrite: bool, |
|
324 |
+ ) -> _types.VaultConfig: |
|
325 |
+ """Set the named service settings for a configuration. |
|
326 |
+ |
|
327 |
+ Args: |
|
328 |
+ config: |
|
329 |
+ The configuration to edit. |
|
330 |
+ service: |
|
331 |
+ The name of the service to set. |
|
332 |
+ setting: |
|
333 |
+ The new service settings. |
|
334 |
+ maybe_unset: |
|
335 |
+ Settings keys to additionally unset, if not already |
|
336 |
+ present in the new settings. Corresponds to the |
|
337 |
+ `--unset` command-line argument. |
|
338 |
+ overwrite: |
|
339 |
+ Overwrite the settings object if true, or merge if |
|
340 |
+ false. Corresponds to the `--overwrite-existing` and |
|
341 |
+ `--merge-existing` command-line arguments. |
|
342 |
+ |
|
343 |
+ Returns: |
|
344 |
+ The amended configuration. |
|
345 |
+ |
|
346 |
+ """ |
|
347 |
+ cli_helpers.save_config(config) |
|
348 |
+ config_service = config["services"].get(service, {}) |
|
349 |
+ maybe_unset = set(maybe_unset) - setting.keys() |
|
350 |
+ if overwrite: |
|
351 |
+ config["services"][service] = config_service = {} |
|
352 |
+ elif maybe_unset: |
|
353 |
+ for key in maybe_unset: |
|
354 |
+ config_service.pop(key, None) # type: ignore[misc] |
|
355 |
+ config["services"].setdefault(service, {}).update(setting) |
|
356 |
+ assert _types.is_vault_config(config) |
|
357 |
+ # NOTE: This relies on settings_obj containing only the keys |
|
358 |
+ # "length", "repeat", "upper", "lower", "number", "space", |
|
359 |
+ # "dash" and "symbol". |
|
360 |
+ result = self.runner.invoke( |
|
361 |
+ cli.derivepassphrase_vault, |
|
362 |
+ [ |
|
363 |
+ "--config", |
|
364 |
+ "--overwrite-existing" if overwrite else "--merge-existing", |
|
365 |
+ ] |
|
366 |
+ + [f"--unset={key}" for key in maybe_unset] |
|
367 |
+ + [ |
|
368 |
+ f"--{key}={value}" |
|
369 |
+ for key, value in setting.items() |
|
370 |
+ if key in VALID_PROPERTIES |
|
371 |
+ ] |
|
372 |
+ + ["--", service], |
|
373 |
+ catch_exceptions=False, |
|
374 |
+ ) |
|
375 |
+ assert result.clean_exit(empty_stderr=False) |
|
376 |
+ assert cli_helpers.load_config() == config |
|
377 |
+ return config |
|
378 |
+ |
|
379 |
+ @stateful.rule( |
|
380 |
+ target=configuration, |
|
381 |
+ config=configuration, |
|
382 |
+ ) |
|
383 |
+ def purge_global( |
|
384 |
+ self, |
|
385 |
+ config: _types.VaultConfig, |
|
386 |
+ ) -> _types.VaultConfig: |
|
387 |
+ """Purge the globals of a configuration. |
|
388 |
+ |
|
389 |
+ Args: |
|
390 |
+ config: |
|
391 |
+ The configuration to edit. |
|
392 |
+ |
|
393 |
+ Returns: |
|
394 |
+ The pruned configuration. |
|
395 |
+ |
|
396 |
+ """ |
|
397 |
+ cli_helpers.save_config(config) |
|
398 |
+ config.pop("global", None) |
|
399 |
+ result = self.runner.invoke( |
|
400 |
+ cli.derivepassphrase_vault, |
|
401 |
+ ["--delete-globals"], |
|
402 |
+ input="y", |
|
403 |
+ catch_exceptions=False, |
|
404 |
+ ) |
|
405 |
+ assert result.clean_exit(empty_stderr=False) |
|
406 |
+ assert cli_helpers.load_config() == config |
|
407 |
+ return config |
|
408 |
+ |
|
409 |
+ @stateful.rule( |
|
410 |
+ target=configuration, |
|
411 |
+ config_and_service=configuration.filter( |
|
412 |
+ lambda c: bool(c["services"]) |
|
413 |
+ ).flatmap( |
|
414 |
+ lambda c: strategies.tuples( |
|
415 |
+ strategies.just(c), |
|
416 |
+ strategies.sampled_from(tuple(c["services"].keys())), |
|
417 |
+ ) |
|
418 |
+ ), |
|
419 |
+ ) |
|
420 |
+ def purge_service( |
|
421 |
+ self, |
|
422 |
+ config_and_service: tuple[_types.VaultConfig, str], |
|
423 |
+ ) -> _types.VaultConfig: |
|
424 |
+ """Purge the settings of a named service in a configuration. |
|
425 |
+ |
|
426 |
+ Args: |
|
427 |
+ config_and_service: |
|
428 |
+ A 2-tuple containing the configuration to edit, and the |
|
429 |
+ service name to purge. |
|
430 |
+ |
|
431 |
+ Returns: |
|
432 |
+ The pruned configuration. |
|
433 |
+ |
|
434 |
+ """ |
|
435 |
+ config, service = config_and_service |
|
436 |
+ cli_helpers.save_config(config) |
|
437 |
+ config["services"].pop(service, None) |
|
438 |
+ result = self.runner.invoke( |
|
439 |
+ cli.derivepassphrase_vault, |
|
440 |
+ ["--delete", "--", service], |
|
441 |
+ input="y", |
|
442 |
+ catch_exceptions=False, |
|
443 |
+ ) |
|
444 |
+ assert result.clean_exit(empty_stderr=False) |
|
445 |
+ assert cli_helpers.load_config() == config |
|
446 |
+ return config |
|
447 |
+ |
|
448 |
+ @stateful.rule( |
|
449 |
+ target=configuration, |
|
450 |
+ config=configuration, |
|
451 |
+ ) |
|
452 |
+ def purge_all( |
|
453 |
+ self, |
|
454 |
+ config: _types.VaultConfig, |
|
455 |
+ ) -> _types.VaultConfig: |
|
456 |
+ """Purge the entire configuration. |
|
457 |
+ |
|
458 |
+ Args: |
|
459 |
+ config: |
|
460 |
+ The configuration to edit. |
|
461 |
+ |
|
462 |
+ Returns: |
|
463 |
+ The empty configuration. |
|
464 |
+ |
|
465 |
+ """ |
|
466 |
+ cli_helpers.save_config(config) |
|
467 |
+ config = {"services": {}} |
|
468 |
+ result = self.runner.invoke( |
|
469 |
+ cli.derivepassphrase_vault, |
|
470 |
+ ["--clear"], |
|
471 |
+ input="y", |
|
472 |
+ catch_exceptions=False, |
|
473 |
+ ) |
|
474 |
+ assert result.clean_exit(empty_stderr=False) |
|
475 |
+ assert cli_helpers.load_config() == config |
|
476 |
+ return config |
|
477 |
+ |
|
478 |
+ @stateful.rule( |
|
479 |
+ target=configuration, |
|
480 |
+ base_config=configuration, |
|
481 |
+ config_to_import=configuration, |
|
482 |
+ overwrite=strategies.booleans(), |
|
483 |
+ ) |
|
484 |
+ def import_configuration( |
|
485 |
+ self, |
|
486 |
+ base_config: _types.VaultConfig, |
|
487 |
+ config_to_import: _types.VaultConfig, |
|
488 |
+ overwrite: bool, |
|
489 |
+ ) -> _types.VaultConfig: |
|
490 |
+ """Import the given configuration into a base configuration. |
|
491 |
+ |
|
492 |
+ Args: |
|
493 |
+ base_config: |
|
494 |
+ The configuration to import into. |
|
495 |
+ config_to_import: |
|
496 |
+ The configuration to import. |
|
497 |
+ overwrite: |
|
498 |
+ Overwrite the base configuration if true, or merge if |
|
499 |
+ false. Corresponds to the `--overwrite-existing` and |
|
500 |
+ `--merge-existing` command-line arguments. |
|
501 |
+ |
|
502 |
+ Returns: |
|
503 |
+ The imported or merged configuration. |
|
504 |
+ |
|
505 |
+ """ |
|
506 |
+ cli_helpers.save_config(base_config) |
|
507 |
+ config = ( |
|
508 |
+ self.fold_configs(config_to_import, base_config) |
|
509 |
+ if not overwrite |
|
510 |
+ else config_to_import |
|
511 |
+ ) |
|
512 |
+ assert _types.is_vault_config(config) |
|
513 |
+ result = self.runner.invoke( |
|
514 |
+ cli.derivepassphrase_vault, |
|
515 |
+ ["--import", "-"] |
|
516 |
+ + (["--overwrite-existing"] if overwrite else []), |
|
517 |
+ input=json.dumps(config_to_import), |
|
518 |
+ catch_exceptions=False, |
|
519 |
+ ) |
|
520 |
+ assert result.clean_exit(empty_stderr=False) |
|
521 |
+ assert cli_helpers.load_config() == config |
|
522 |
+ return config |
|
523 |
+ |
|
524 |
+ def teardown(self) -> None: |
|
525 |
+ """Upon teardown, exit all contexts entered in `__init__`.""" |
|
526 |
+ self.exit_stack.close() |
|
527 |
+ |
|
528 |
+ |
|
529 |
+TestConfigManagement = ConfigManagementStateMachine.TestCase |
|
530 |
+"""The [`unittest.TestCase`][] class that will actually be run.""" |
|
531 |
+ |
|
532 |
+ |
|
533 |
+class FakeConfigurationMutexAction(NamedTuple): |
|
534 |
+ """An action/a step in the [`FakeConfigurationMutexStateMachine`][]. |
|
535 |
+ |
|
536 |
+ Attributes: |
|
537 |
+ command_line: |
|
538 |
+ The command-line for `derivepassphrase vault` to execute. |
|
539 |
+ input: |
|
540 |
+ The input to this command. |
|
541 |
+ |
|
542 |
+ """ |
|
543 |
+ |
|
544 |
+ command_line: list[str] |
|
545 |
+ """""" |
|
546 |
+ input: str | bytes | None = None |
|
547 |
+ """""" |
|
548 |
+ |
|
549 |
+ |
|
550 |
+def run_actions_handler( |
|
551 |
+ id_num: int, |
|
552 |
+ action: FakeConfigurationMutexAction, |
|
553 |
+ *, |
|
554 |
+ input_queue: queue.Queue, |
|
555 |
+ output_queue: queue.Queue, |
|
556 |
+ timeout: int, |
|
557 |
+) -> None: |
|
558 |
+ """Prepare the faked mutex, then run `action`. |
|
559 |
+ |
|
560 |
+ This is a top-level handler function -- to be used in a new |
|
561 |
+ [`multiprocessing.Process`][] -- to run a single action from the |
|
562 |
+ [`FakeConfigurationMutexStateMachine`][]. Output from this function |
|
563 |
+ must be sent down the output queue instead of relying on the call |
|
564 |
+ stack. Additionally, because this runs in a separate process, we |
|
565 |
+ need to restart coverage tracking if it is currently running. |
|
566 |
+ |
|
567 |
+ Args: |
|
568 |
+ id_num: |
|
569 |
+ The internal ID of this subprocess. |
|
570 |
+ action: |
|
571 |
+ The action to execute. |
|
572 |
+ input_queue: |
|
573 |
+ The queue for data passed from the manager/parent process to |
|
574 |
+ this subprocess. |
|
575 |
+ output_queue: |
|
576 |
+ The queue for data passed from this subprocess to the |
|
577 |
+ manager/parent process. |
|
578 |
+ timeout: |
|
579 |
+ The maximum amount of time to wait for a data transfer along |
|
580 |
+ the input or the output queue. If exceeded, we exit |
|
581 |
+ immediately. |
|
582 |
+ |
|
583 |
+ """ |
|
584 |
+ with pytest.MonkeyPatch.context() as monkeypatch: |
|
585 |
+ monkeypatch.setattr( |
|
586 |
+ cli_helpers, |
|
587 |
+ "configuration_mutex", |
|
588 |
+ lambda: FakeConfigurationMutexStateMachine.ConfigurationMutexStub( |
|
589 |
+ my_id=id_num, |
|
590 |
+ input_queue=input_queue, |
|
591 |
+ output_queue=output_queue, |
|
592 |
+ timeout=timeout, |
|
593 |
+ ), |
|
594 |
+ ) |
|
595 |
+ runner = tests.machinery.CliRunner(mix_stderr=False) |
|
596 |
+ try: |
|
597 |
+ result = runner.invoke( |
|
598 |
+ cli.derivepassphrase_vault, |
|
599 |
+ args=action.command_line, |
|
600 |
+ input=action.input, |
|
601 |
+ catch_exceptions=True, |
|
602 |
+ ) |
|
603 |
+ output_queue.put( |
|
604 |
+ FakeConfigurationMutexStateMachine.IPCMessage( |
|
605 |
+ id_num, |
|
606 |
+ "result", |
|
607 |
+ ( |
|
608 |
+ result.clean_exit(empty_stderr=False), |
|
609 |
+ copy.copy(result.stdout), |
|
610 |
+ copy.copy(result.stderr), |
|
611 |
+ ), |
|
612 |
+ ), |
|
613 |
+ block=True, |
|
614 |
+ timeout=timeout, |
|
615 |
+ ) |
|
616 |
+ except Exception as exc: # pragma: no cover # noqa: BLE001 |
|
617 |
+ output_queue.put( |
|
618 |
+ FakeConfigurationMutexStateMachine.IPCMessage( |
|
619 |
+ id_num, "exception", exc |
|
620 |
+ ), |
|
621 |
+ block=False, |
|
622 |
+ ) |
|
623 |
+ |
|
624 |
+ |
|
625 |
+@hypothesis.settings( |
|
626 |
+ stateful_step_count=tests.machinery.hypothesis.get_concurrency_step_count(), |
|
627 |
+ deadline=None, |
|
628 |
+) |
|
629 |
+class FakeConfigurationMutexStateMachine(stateful.RuleBasedStateMachine): |
|
630 |
+ """A state machine simulating the (faked) configuration mutex. |
|
631 |
+ |
|
632 |
+ Generate an ordered set of concurrent writers to the |
|
633 |
+ derivepassphrase configuration, then test that the writers' accesses |
|
634 |
+ are serialized correctly, i.e., test that the writers correctly use |
|
635 |
+ the mutex to avoid concurrent accesses, under the assumption that |
|
636 |
+ the mutex itself is correctly implemented. |
|
637 |
+ |
|
638 |
+ We use a custom mutex implementation to both ensure that all writers |
|
639 |
+ attempt to lock the configuration at the same time and that the lock |
|
640 |
+ is granted in our desired order. This test is therefore independent |
|
641 |
+ of the actual (operating system-specific) mutex implementation in |
|
642 |
+ `derivepassphrase`. |
|
643 |
+ |
|
644 |
+ Attributes: |
|
645 |
+ setting: |
|
646 |
+ A bundle for single-service settings. |
|
647 |
+ configuration: |
|
648 |
+ A bundle for full vault configurations. |
|
649 |
+ |
|
650 |
+ """ |
|
651 |
+ |
|
652 |
+ class IPCMessage(NamedTuple): |
|
653 |
+ """A message for inter-process communication. |
|
654 |
+ |
|
655 |
+ Used by the configuration mutex stub class to affect/signal the |
|
656 |
+ control flow amongst the linked mutex clients. |
|
657 |
+ |
|
658 |
+ Attributes: |
|
659 |
+ child_id: |
|
660 |
+ The ID of the sending or receiving child process. |
|
661 |
+ message: |
|
662 |
+ One of "ready", "go", "config", "result" or "exception". |
|
663 |
+ payload: |
|
664 |
+ The (optional) message payload. |
|
665 |
+ |
|
666 |
+ """ |
|
667 |
+ |
|
668 |
+ child_id: int |
|
669 |
+ """""" |
|
670 |
+ message: Literal["ready", "go", "config", "result", "exception"] |
|
671 |
+ """""" |
|
672 |
+ payload: object | None |
|
673 |
+ """""" |
|
674 |
+ |
|
675 |
+ class ConfigurationMutexStub(cli_helpers.ConfigurationMutex): |
|
676 |
+ """Configuration mutex subclass that enforces a locking order. |
|
677 |
+ |
|
678 |
+ Each configuration mutex stub object ("mutex client") has an |
|
679 |
+ associated ID, and one read-only and one write-only pipe |
|
680 |
+ (actually: [`multiprocessing.Queue`][] objects) to the "manager" |
|
681 |
+ instance coordinating these stub objects. First, the mutex |
|
682 |
+ client signals readiness, then the manager signals when the |
|
683 |
+ mutex shall be considered "acquired", then finally the mutex |
|
684 |
+ client sends the result back (simultaneously releasing the mutex |
|
685 |
+ again). The manager may optionally send an abort signal if the |
|
686 |
+ operations take too long. |
|
687 |
+ |
|
688 |
+ This subclass also copies the effective vault configuration |
|
689 |
+ to `intermediate_configs` upon releasing the lock. |
|
690 |
+ |
|
691 |
+ """ |
|
692 |
+ |
|
693 |
+ def __init__( |
|
694 |
+ self, |
|
695 |
+ *, |
|
696 |
+ my_id: int, |
|
697 |
+ timeout: int, |
|
698 |
+ input_queue: queue.Queue[ |
|
699 |
+ FakeConfigurationMutexStateMachine.IPCMessage |
|
700 |
+ ], |
|
701 |
+ output_queue: queue.Queue[ |
|
702 |
+ FakeConfigurationMutexStateMachine.IPCMessage |
|
703 |
+ ], |
|
704 |
+ ) -> None: |
|
705 |
+ """Initialize this mutex client. |
|
706 |
+ |
|
707 |
+ Args: |
|
708 |
+ my_id: |
|
709 |
+ The ID of this client. |
|
710 |
+ timeout: |
|
711 |
+ The timeout for each get and put operation on the |
|
712 |
+ queues. |
|
713 |
+ input_queue: |
|
714 |
+ The message queue for IPC messages from the manager |
|
715 |
+ instance to this mutex client. |
|
716 |
+ output_queue: |
|
717 |
+ The message queue for IPC messages from this mutex |
|
718 |
+ client to the manager instance. |
|
719 |
+ |
|
720 |
+ """ |
|
721 |
+ super().__init__() |
|
722 |
+ |
|
723 |
+ def lock() -> None: |
|
724 |
+ """Simulate locking of the mutex. |
|
725 |
+ |
|
726 |
+ Issue a "ready" message, wait for a "go", then return. |
|
727 |
+ If an exception occurs, issue an "exception" message, |
|
728 |
+ then raise the exception. |
|
729 |
+ |
|
730 |
+ """ |
|
731 |
+ IPCMessage: TypeAlias = ( |
|
732 |
+ FakeConfigurationMutexStateMachine.IPCMessage |
|
733 |
+ ) |
|
734 |
+ try: |
|
735 |
+ output_queue.put( |
|
736 |
+ IPCMessage(my_id, "ready", None), |
|
737 |
+ block=True, |
|
738 |
+ timeout=timeout, |
|
739 |
+ ) |
|
740 |
+ ok = input_queue.get(block=True, timeout=timeout) |
|
741 |
+ if ok != IPCMessage(my_id, "go", None): # pragma: no cover |
|
742 |
+ output_queue.put( |
|
743 |
+ IPCMessage(my_id, "exception", ok), block=False |
|
744 |
+ ) |
|
745 |
+ raise ( |
|
746 |
+ ok[2] |
|
747 |
+ if isinstance(ok[2], BaseException) |
|
748 |
+ else RuntimeError(ok[2]) |
|
749 |
+ ) |
|
750 |
+ except (queue.Empty, queue.Full) as exc: # pragma: no cover |
|
751 |
+ output_queue.put( |
|
752 |
+ IPCMessage(my_id, "exception", exc), block=False |
|
753 |
+ ) |
|
754 |
+ return |
|
755 |
+ |
|
756 |
+ def unlock() -> None: |
|
757 |
+ """Simulate unlocking of the mutex. |
|
758 |
+ |
|
759 |
+ Issue a "config" message, then return. If an exception |
|
760 |
+ occurs, issue an "exception" message, then raise the |
|
761 |
+ exception. |
|
762 |
+ |
|
763 |
+ """ |
|
764 |
+ IPCMessage: TypeAlias = ( |
|
765 |
+ FakeConfigurationMutexStateMachine.IPCMessage |
|
766 |
+ ) |
|
767 |
+ try: |
|
768 |
+ output_queue.put( |
|
769 |
+ IPCMessage( |
|
770 |
+ my_id, |
|
771 |
+ "config", |
|
772 |
+ copy.copy(cli_helpers.load_config()), |
|
773 |
+ ), |
|
774 |
+ block=True, |
|
775 |
+ timeout=timeout, |
|
776 |
+ ) |
|
777 |
+ except (queue.Empty, queue.Full) as exc: # pragma: no cover |
|
778 |
+ output_queue.put( |
|
779 |
+ IPCMessage(my_id, "exception", exc), block=False |
|
780 |
+ ) |
|
781 |
+ raise |
|
782 |
+ |
|
783 |
+ self.lock = lock |
|
784 |
+ self.unlock = unlock |
|
785 |
+ |
|
786 |
+ setting: stateful.Bundle[_types.VaultConfigServicesSettings] = ( |
|
787 |
+ stateful.Bundle("setting") |
|
788 |
+ ) |
|
789 |
+ """""" |
|
790 |
+ configuration: stateful.Bundle[_types.VaultConfig] = stateful.Bundle( |
|
791 |
+ "configuration" |
|
792 |
+ ) |
|
793 |
+ """""" |
|
794 |
+ |
|
795 |
+ def __init__(self, *args: Any, **kwargs: Any) -> None: |
|
796 |
+ """Initialize the state machine.""" |
|
797 |
+ super().__init__(*args, **kwargs) |
|
798 |
+ self.actions: list[FakeConfigurationMutexAction] = [] |
|
799 |
+ # Determine the step count by poking around in the hypothesis |
|
800 |
+ # internals. As this isn't guaranteed to be stable, turn off |
|
801 |
+ # coverage. |
|
802 |
+ try: # pragma: no cover |
|
803 |
+ settings: hypothesis.settings | None |
|
804 |
+ settings = FakeConfigurationMutexStateMachine.TestCase.settings |
|
805 |
+ except AttributeError: # pragma: no cover |
|
806 |
+ settings = None |
|
807 |
+ self.step_count = ( |
|
808 |
+ tests.machinery.hypothesis.get_concurrency_step_count(settings) |
|
809 |
+ ) |
|
810 |
+ |
|
811 |
+ @stateful.initialize( |
|
812 |
+ target=configuration, |
|
813 |
+ configs=strategies.lists( |
|
814 |
+ vault_full_config(), |
|
815 |
+ min_size=8, |
|
816 |
+ max_size=8, |
|
817 |
+ ), |
|
818 |
+ ) |
|
819 |
+ def declare_initial_configs( |
|
820 |
+ self, |
|
821 |
+ configs: list[_types.VaultConfig], |
|
822 |
+ ) -> stateful.MultipleResults[_types.VaultConfig]: |
|
823 |
+ """Initialize the configuration bundle with eight configurations.""" |
|
824 |
+ return stateful.multiple(*configs) |
|
825 |
+ |
|
826 |
+ @stateful.initialize( |
|
827 |
+ target=setting, |
|
828 |
+ configs=strategies.lists( |
|
829 |
+ vault_full_config(), |
|
830 |
+ min_size=4, |
|
831 |
+ max_size=4, |
|
832 |
+ ), |
|
833 |
+ ) |
|
834 |
+ def extract_initial_settings( |
|
835 |
+ self, |
|
836 |
+ configs: list[_types.VaultConfig], |
|
837 |
+ ) -> stateful.MultipleResults[_types.VaultConfigServicesSettings]: |
|
838 |
+ """Initialize the settings bundle with four service settings.""" |
|
839 |
+ settings: list[_types.VaultConfigServicesSettings] = [] |
|
840 |
+ for c in configs: |
|
841 |
+ settings.extend(c["services"].values()) |
|
842 |
+ return stateful.multiple(*map(copy.deepcopy, settings)) |
|
843 |
+ |
|
844 |
+ @stateful.initialize( |
|
845 |
+ config=vault_full_config(), |
|
846 |
+ ) |
|
847 |
+ def declare_initial_action( |
|
848 |
+ self, |
|
849 |
+ config: _types.VaultConfig, |
|
850 |
+ ) -> None: |
|
851 |
+ """Initialize the actions bundle from the configuration bundle. |
|
852 |
+ |
|
853 |
+ This is roughly comparable to the |
|
854 |
+ [`add_import_configuration_action`][] general rule, but adding |
|
855 |
+ it as a separate initialize rule avoids having to guard every |
|
856 |
+ other action-amending rule against empty action sequences, which |
|
857 |
+ would discard huge portions of the rule selection search space |
|
858 |
+ and thus trigger loads of hypothesis health check warnings. |
|
859 |
+ |
|
860 |
+ """ |
|
861 |
+ command_line = ["--import", "-", "--overwrite-existing"] |
|
862 |
+ input = json.dumps(config) # noqa: A001 |
|
863 |
+ hypothesis.note(f"# {command_line = }, {input = }") |
|
864 |
+ action = FakeConfigurationMutexAction( |
|
865 |
+ command_line=command_line, input=input |
|
866 |
+ ) |
|
867 |
+ self.actions.append(action) |
|
868 |
+ |
|
869 |
+ @stateful.rule( |
|
870 |
+ setting=setting.filter(bool), |
|
871 |
+ maybe_unset=strategies.sets( |
|
872 |
+ strategies.sampled_from(VALID_PROPERTIES), |
|
873 |
+ max_size=3, |
|
874 |
+ ), |
|
875 |
+ overwrite=strategies.booleans(), |
|
876 |
+ ) |
|
877 |
+ def add_set_globals_action( |
|
878 |
+ self, |
|
879 |
+ setting: _types.VaultConfigGlobalSettings, |
|
880 |
+ maybe_unset: set[str], |
|
881 |
+ overwrite: bool, |
|
882 |
+ ) -> None: |
|
883 |
+ """Set the global settings of a configuration. |
|
884 |
+ |
|
885 |
+ Args: |
|
886 |
+ setting: |
|
887 |
+ The new global settings. |
|
888 |
+ maybe_unset: |
|
889 |
+ Settings keys to additionally unset, if not already |
|
890 |
+ present in the new settings. Corresponds to the |
|
891 |
+ `--unset` command-line argument. |
|
892 |
+ overwrite: |
|
893 |
+ Overwrite the settings object if true, or merge if |
|
894 |
+ false. Corresponds to the `--overwrite-existing` and |
|
895 |
+ `--merge-existing` command-line arguments. |
|
896 |
+ |
|
897 |
+ """ |
|
898 |
+ maybe_unset = set(maybe_unset) - setting.keys() |
|
899 |
+ command_line = ( |
|
900 |
+ [ |
|
901 |
+ "--config", |
|
902 |
+ "--overwrite-existing" if overwrite else "--merge-existing", |
|
903 |
+ ] |
|
904 |
+ + [f"--unset={key}" for key in maybe_unset] |
|
905 |
+ + [ |
|
906 |
+ f"--{key}={value}" |
|
907 |
+ for key, value in setting.items() |
|
908 |
+ if key in VALID_PROPERTIES |
|
909 |
+ ] |
|
910 |
+ ) |
|
911 |
+ input = None # noqa: A001 |
|
912 |
+ hypothesis.note(f"# {command_line = }, {input = }") |
|
913 |
+ action = FakeConfigurationMutexAction( |
|
914 |
+ command_line=command_line, input=input |
|
915 |
+ ) |
|
916 |
+ self.actions.append(action) |
|
917 |
+ |
|
918 |
+ @stateful.rule( |
|
919 |
+ service=strategies.sampled_from(KNOWN_SERVICES), |
|
920 |
+ setting=setting.filter(bool), |
|
921 |
+ maybe_unset=strategies.sets( |
|
922 |
+ strategies.sampled_from(VALID_PROPERTIES), |
|
923 |
+ max_size=3, |
|
924 |
+ ), |
|
925 |
+ overwrite=strategies.booleans(), |
|
926 |
+ ) |
|
927 |
+ def add_set_service_action( |
|
928 |
+ self, |
|
929 |
+ service: str, |
|
930 |
+ setting: _types.VaultConfigServicesSettings, |
|
931 |
+ maybe_unset: set[str], |
|
932 |
+ overwrite: bool, |
|
933 |
+ ) -> None: |
|
934 |
+ """Set the named service settings for a configuration. |
|
935 |
+ |
|
936 |
+ Args: |
|
937 |
+ service: |
|
938 |
+ The name of the service to set. |
|
939 |
+ setting: |
|
940 |
+ The new service settings. |
|
941 |
+ maybe_unset: |
|
942 |
+ Settings keys to additionally unset, if not already |
|
943 |
+ present in the new settings. Corresponds to the |
|
944 |
+ `--unset` command-line argument. |
|
945 |
+ overwrite: |
|
946 |
+ Overwrite the settings object if true, or merge if |
|
947 |
+ false. Corresponds to the `--overwrite-existing` and |
|
948 |
+ `--merge-existing` command-line arguments. |
|
949 |
+ |
|
950 |
+ """ |
|
951 |
+ maybe_unset = set(maybe_unset) - setting.keys() |
|
952 |
+ command_line = ( |
|
953 |
+ [ |
|
954 |
+ "--config", |
|
955 |
+ "--overwrite-existing" if overwrite else "--merge-existing", |
|
956 |
+ ] |
|
957 |
+ + [f"--unset={key}" for key in maybe_unset] |
|
958 |
+ + [ |
|
959 |
+ f"--{key}={value}" |
|
960 |
+ for key, value in setting.items() |
|
961 |
+ if key in VALID_PROPERTIES |
|
962 |
+ ] |
|
963 |
+ + ["--", service] |
|
964 |
+ ) |
|
965 |
+ input = None # noqa: A001 |
|
966 |
+ hypothesis.note(f"# {command_line = }, {input = }") |
|
967 |
+ action = FakeConfigurationMutexAction( |
|
968 |
+ command_line=command_line, input=input |
|
969 |
+ ) |
|
970 |
+ self.actions.append(action) |
|
971 |
+ |
|
972 |
+ @stateful.rule() |
|
973 |
+ def add_purge_global_action( |
|
974 |
+ self, |
|
975 |
+ ) -> None: |
|
976 |
+ """Purge the globals of a configuration.""" |
|
977 |
+ command_line = ["--delete-globals"] |
|
978 |
+ input = None # 'y' # noqa: A001 |
|
979 |
+ hypothesis.note(f"# {command_line = }, {input = }") |
|
980 |
+ action = FakeConfigurationMutexAction( |
|
981 |
+ command_line=command_line, input=input |
|
982 |
+ ) |
|
983 |
+ self.actions.append(action) |
|
984 |
+ |
|
985 |
+ @stateful.rule( |
|
986 |
+ service=strategies.sampled_from(KNOWN_SERVICES), |
|
987 |
+ ) |
|
988 |
+ def add_purge_service_action( |
|
989 |
+ self, |
|
990 |
+ service: str, |
|
991 |
+ ) -> None: |
|
992 |
+ """Purge the settings of a named service in a configuration. |
|
993 |
+ |
|
994 |
+ Args: |
|
995 |
+ service: |
|
996 |
+ The service name to purge. |
|
997 |
+ |
|
998 |
+ """ |
|
999 |
+ command_line = ["--delete", "--", service] |
|
1000 |
+ input = None # 'y' # noqa: A001 |
|
1001 |
+ hypothesis.note(f"# {command_line = }, {input = }") |
|
1002 |
+ action = FakeConfigurationMutexAction( |
|
1003 |
+ command_line=command_line, input=input |
|
1004 |
+ ) |
|
1005 |
+ self.actions.append(action) |
|
1006 |
+ |
|
1007 |
+ @stateful.rule() |
|
1008 |
+ def add_purge_all_action( |
|
1009 |
+ self, |
|
1010 |
+ ) -> None: |
|
1011 |
+ """Purge the entire configuration.""" |
|
1012 |
+ command_line = ["--clear"] |
|
1013 |
+ input = None # 'y' # noqa: A001 |
|
1014 |
+ hypothesis.note(f"# {command_line = }, {input = }") |
|
1015 |
+ action = FakeConfigurationMutexAction( |
|
1016 |
+ command_line=command_line, input=input |
|
1017 |
+ ) |
|
1018 |
+ self.actions.append(action) |
|
1019 |
+ |
|
1020 |
+ @stateful.rule( |
|
1021 |
+ config_to_import=configuration, |
|
1022 |
+ overwrite=strategies.booleans(), |
|
1023 |
+ ) |
|
1024 |
+ def add_import_configuration_action( |
|
1025 |
+ self, |
|
1026 |
+ config_to_import: _types.VaultConfig, |
|
1027 |
+ overwrite: bool, |
|
1028 |
+ ) -> None: |
|
1029 |
+ """Import the given configuration. |
|
1030 |
+ |
|
1031 |
+ Args: |
|
1032 |
+ config_to_import: |
|
1033 |
+ The configuration to import. |
|
1034 |
+ overwrite: |
|
1035 |
+ Overwrite the base configuration if true, or merge if |
|
1036 |
+ false. Corresponds to the `--overwrite-existing` and |
|
1037 |
+ `--merge-existing` command-line arguments. |
|
1038 |
+ |
|
1039 |
+ """ |
|
1040 |
+ command_line = ["--import", "-"] + ( |
|
1041 |
+ ["--overwrite-existing"] if overwrite else [] |
|
1042 |
+ ) |
|
1043 |
+ input = json.dumps(config_to_import) # noqa: A001 |
|
1044 |
+ hypothesis.note(f"# {command_line = }, {input = }") |
|
1045 |
+ action = FakeConfigurationMutexAction( |
|
1046 |
+ command_line=command_line, input=input |
|
1047 |
+ ) |
|
1048 |
+ self.actions.append(action) |
|
1049 |
+ |
|
1050 |
+ @stateful.precondition(lambda self: len(self.actions) > 0) |
|
1051 |
+ @stateful.invariant() |
|
1052 |
+ def run_actions( # noqa: C901 |
|
1053 |
+ self, |
|
1054 |
+ ) -> None: |
|
1055 |
+ """Run the actions, serially and concurrently. |
|
1056 |
+ |
|
1057 |
+ Run the actions once serially, then once more concurrently with |
|
1058 |
+ the faked configuration mutex, and assert that both runs yield |
|
1059 |
+ identical intermediate and final results. |
|
1060 |
+ |
|
1061 |
+ We must run the concurrent version in processes, not threads or |
|
1062 |
+ Python async functions, because the `click` testing machinery |
|
1063 |
+ manipulates global properties (e.g. the standard I/O streams, |
|
1064 |
+ the current directory, and the environment), and we require this |
|
1065 |
+ manipulation to happen in a time-overlapped manner. |
|
1066 |
+ |
|
1067 |
+ However, running multiple processes increases the risk of the |
|
1068 |
+ operating system imposing process count or memory limits on us. |
|
1069 |
+ We therefore skip the test as a whole if we fail to start a new |
|
1070 |
+ process due to lack of necessary resources (memory, processes, |
|
1071 |
+ or open file descriptors). |
|
1072 |
+ |
|
1073 |
+ """ |
|
1074 |
+ if not TYPE_CHECKING: # pragma: no branch |
|
1075 |
+ multiprocessing = pytest.importorskip("multiprocessing") |
|
1076 |
+ IPCMessage: TypeAlias = FakeConfigurationMutexStateMachine.IPCMessage |
|
1077 |
+ intermediate_configs: dict[int, _types.VaultConfig] = {} |
|
1078 |
+ intermediate_results: dict[ |
|
1079 |
+ int, tuple[bool, str | None, str | None] |
|
1080 |
+ ] = {} |
|
1081 |
+ true_configs: dict[int, _types.VaultConfig] = {} |
|
1082 |
+ true_results: dict[int, tuple[bool, str | None, str | None]] = {} |
|
1083 |
+ timeout = 30 # Hopefully slow enough to accomodate The Annoying OS. |
|
1084 |
+ actions = self.actions |
|
1085 |
+ mp = multiprocessing.get_context() |
|
1086 |
+ # Coverage tracking writes coverage data to the current working |
|
1087 |
+ # directory, but because the subprocesses are spawned within the |
|
1088 |
+ # `tests.machinery.pytest.isolated_vault_config` context manager, their starting |
|
1089 |
+ # working directory is the isolated one, not the original one. |
|
1090 |
+ orig_cwd = pathlib.Path.cwd() |
|
1091 |
+ |
|
1092 |
+ fatal_process_creation_errnos = { |
|
1093 |
+ # Specified by POSIX for fork(3). |
|
1094 |
+ errno.ENOMEM, |
|
1095 |
+ # Specified by POSIX for fork(3). |
|
1096 |
+ errno.EAGAIN, |
|
1097 |
+ # Specified by Linux/glibc for fork(3) |
|
1098 |
+ getattr(errno, "ENOSYS", errno.ENOMEM), |
|
1099 |
+ # Specified by POSIX for posix_spawn(3). |
|
1100 |
+ errno.EINVAL, |
|
1101 |
+ } |
|
1102 |
+ |
|
1103 |
+ hypothesis.note(f"# {actions = }") |
|
1104 |
+ |
|
1105 |
+ stack = contextlib.ExitStack() |
|
1106 |
+ with stack: |
|
1107 |
+ runner = tests.machinery.CliRunner(mix_stderr=False) |
|
1108 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1109 |
+ stack.enter_context( |
|
1110 |
+ tests.machinery.pytest.isolated_vault_config( |
|
1111 |
+ monkeypatch=monkeypatch, |
|
1112 |
+ runner=runner, |
|
1113 |
+ vault_config={"services": {}}, |
|
1114 |
+ ) |
|
1115 |
+ ) |
|
1116 |
+ for i, action in enumerate(actions): |
|
1117 |
+ result = runner.invoke( |
|
1118 |
+ cli.derivepassphrase_vault, |
|
1119 |
+ args=action.command_line, |
|
1120 |
+ input=action.input, |
|
1121 |
+ catch_exceptions=True, |
|
1122 |
+ ) |
|
1123 |
+ true_configs[i] = copy.copy(cli_helpers.load_config()) |
|
1124 |
+ true_results[i] = ( |
|
1125 |
+ result.clean_exit(empty_stderr=False), |
|
1126 |
+ result.stdout, |
|
1127 |
+ result.stderr, |
|
1128 |
+ ) |
|
1129 |
+ |
|
1130 |
+ with stack: # noqa: PLR1702 |
|
1131 |
+ runner = tests.machinery.CliRunner(mix_stderr=False) |
|
1132 |
+ monkeypatch = stack.enter_context(pytest.MonkeyPatch.context()) |
|
1133 |
+ stack.enter_context( |
|
1134 |
+ tests.machinery.pytest.isolated_vault_config( |
|
1135 |
+ monkeypatch=monkeypatch, |
|
1136 |
+ runner=runner, |
|
1137 |
+ vault_config={"services": {}}, |
|
1138 |
+ ) |
|
1139 |
+ ) |
|
1140 |
+ |
|
1141 |
+ child_output_queue: multiprocessing.Queue[IPCMessage] = mp.Queue() |
|
1142 |
+ child_input_queues: list[ |
|
1143 |
+ multiprocessing.Queue[IPCMessage] | None |
|
1144 |
+ ] = [] |
|
1145 |
+ processes: list[multiprocessing.process.BaseProcess] = [] |
|
1146 |
+ processes_pending: set[multiprocessing.process.BaseProcess] = set() |
|
1147 |
+ ready_wait: set[int] = set() |
|
1148 |
+ |
|
1149 |
+ try: |
|
1150 |
+ for i, action in enumerate(actions): |
|
1151 |
+ q: multiprocessing.Queue[IPCMessage] | None = mp.Queue() |
|
1152 |
+ try: |
|
1153 |
+ p: multiprocessing.process.BaseProcess = mp.Process( |
|
1154 |
+ name=f"fake-mutex-action-{i:02d}", |
|
1155 |
+ target=run_actions_handler, |
|
1156 |
+ kwargs={ |
|
1157 |
+ "id_num": i, |
|
1158 |
+ "timeout": timeout, |
|
1159 |
+ "action": action, |
|
1160 |
+ "input_queue": q, |
|
1161 |
+ "output_queue": child_output_queue, |
|
1162 |
+ }, |
|
1163 |
+ daemon=False, |
|
1164 |
+ ) |
|
1165 |
+ p.start() |
|
1166 |
+ except OSError as exc: # pragma: no cover |
|
1167 |
+ if exc.errno in fatal_process_creation_errnos: |
|
1168 |
+ pytest.skip( |
|
1169 |
+ "cannot test mutex functionality due to " |
|
1170 |
+ "lack of system resources for " |
|
1171 |
+ "creating enough subprocesses" |
|
1172 |
+ ) |
|
1173 |
+ raise |
|
1174 |
+ else: |
|
1175 |
+ processes.append(p) |
|
1176 |
+ processes_pending.add(p) |
|
1177 |
+ child_input_queues.append(q) |
|
1178 |
+ ready_wait.add(i) |
|
1179 |
+ |
|
1180 |
+ while processes_pending: |
|
1181 |
+ try: |
|
1182 |
+ self.mainloop( |
|
1183 |
+ timeout=timeout, |
|
1184 |
+ child_output_queue=child_output_queue, |
|
1185 |
+ child_input_queues=child_input_queues, |
|
1186 |
+ ready_wait=ready_wait, |
|
1187 |
+ intermediate_configs=intermediate_configs, |
|
1188 |
+ intermediate_results=intermediate_results, |
|
1189 |
+ processes=processes, |
|
1190 |
+ processes_pending=processes_pending, |
|
1191 |
+ block=True, |
|
1192 |
+ ) |
|
1193 |
+ except Exception as exc: # pragma: no cover |
|
1194 |
+ for i, q in enumerate(child_input_queues): |
|
1195 |
+ if q: |
|
1196 |
+ q.put(IPCMessage(i, "exception", exc)) |
|
1197 |
+ for p in processes_pending: |
|
1198 |
+ p.join(timeout=timeout) |
|
1199 |
+ raise |
|
1200 |
+ finally: |
|
1201 |
+ try: |
|
1202 |
+ while True: |
|
1203 |
+ try: |
|
1204 |
+ self.mainloop( |
|
1205 |
+ timeout=timeout, |
|
1206 |
+ child_output_queue=child_output_queue, |
|
1207 |
+ child_input_queues=child_input_queues, |
|
1208 |
+ ready_wait=ready_wait, |
|
1209 |
+ intermediate_configs=intermediate_configs, |
|
1210 |
+ intermediate_results=intermediate_results, |
|
1211 |
+ processes=processes, |
|
1212 |
+ processes_pending=processes_pending, |
|
1213 |
+ block=False, |
|
1214 |
+ ) |
|
1215 |
+ except queue.Empty: |
|
1216 |
+ break |
|
1217 |
+ finally: |
|
1218 |
+ # The subprocesses have this |
|
1219 |
+ # `tests.machinery.pytest.isolated_vault_config` directory as their |
|
1220 |
+ # startup and working directory, so systems like |
|
1221 |
+ # coverage tracking write their data files to this |
|
1222 |
+ # directory. We need to manually move them back to |
|
1223 |
+ # the starting working directory if they are to |
|
1224 |
+ # survive this test. |
|
1225 |
+ for coverage_file in pathlib.Path.cwd().glob( |
|
1226 |
+ ".coverage.*" |
|
1227 |
+ ): |
|
1228 |
+ shutil.move(coverage_file, orig_cwd) |
|
1229 |
+ hypothesis.note( |
|
1230 |
+ f"# {true_results = }, {intermediate_results = }, " |
|
1231 |
+ f"identical = {true_results == intermediate_results}" |
|
1232 |
+ ) |
|
1233 |
+ hypothesis.note( |
|
1234 |
+ f"# {true_configs = }, {intermediate_configs = }, " |
|
1235 |
+ f"identical = {true_configs == intermediate_configs}" |
|
1236 |
+ ) |
|
1237 |
+ assert intermediate_results == true_results |
|
1238 |
+ assert intermediate_configs == true_configs |
|
1239 |
+ |
|
1240 |
+ @staticmethod |
|
1241 |
+ def mainloop( |
|
1242 |
+ *, |
|
1243 |
+ timeout: int, |
|
1244 |
+ child_output_queue: multiprocessing.Queue[ |
|
1245 |
+ FakeConfigurationMutexStateMachine.IPCMessage |
|
1246 |
+ ], |
|
1247 |
+ child_input_queues: list[ |
|
1248 |
+ multiprocessing.Queue[ |
|
1249 |
+ FakeConfigurationMutexStateMachine.IPCMessage |
|
1250 |
+ ] |
|
1251 |
+ | None |
|
1252 |
+ ], |
|
1253 |
+ ready_wait: set[int], |
|
1254 |
+ intermediate_configs: dict[int, _types.VaultConfig], |
|
1255 |
+ intermediate_results: dict[int, tuple[bool, str | None, str | None]], |
|
1256 |
+ processes: list[multiprocessing.process.BaseProcess], |
|
1257 |
+ processes_pending: set[multiprocessing.process.BaseProcess], |
|
1258 |
+ block: bool = True, |
|
1259 |
+ ) -> None: |
|
1260 |
+ IPCMessage: TypeAlias = FakeConfigurationMutexStateMachine.IPCMessage |
|
1261 |
+ msg = child_output_queue.get(block=block, timeout=timeout) |
|
1262 |
+ # TODO(the-13th-letter): Rewrite using structural pattern |
|
1263 |
+ # matching. |
|
1264 |
+ # https://the13thletter.info/derivepassphrase/latest/pycompatibility/#after-eol-py3.9 |
|
1265 |
+ if ( # pragma: no cover |
|
1266 |
+ isinstance(msg, IPCMessage) |
|
1267 |
+ and msg[1] == "exception" |
|
1268 |
+ and isinstance(msg[2], Exception) |
|
1269 |
+ ): |
|
1270 |
+ e = msg[2] |
|
1271 |
+ raise e |
|
1272 |
+ if isinstance(msg, IPCMessage) and msg[1] == "ready": |
|
1273 |
+ n = msg[0] |
|
1274 |
+ ready_wait.remove(n) |
|
1275 |
+ if not ready_wait: |
|
1276 |
+ assert child_input_queues |
|
1277 |
+ assert child_input_queues[0] |
|
1278 |
+ child_input_queues[0].put( |
|
1279 |
+ IPCMessage(0, "go", None), |
|
1280 |
+ block=True, |
|
1281 |
+ timeout=timeout, |
|
1282 |
+ ) |
|
1283 |
+ elif isinstance(msg, IPCMessage) and msg[1] == "config": |
|
1284 |
+ n = msg[0] |
|
1285 |
+ config = msg[2] |
|
1286 |
+ intermediate_configs[n] = cast("_types.VaultConfig", config) |
|
1287 |
+ elif isinstance(msg, IPCMessage) and msg[1] == "result": |
|
1288 |
+ n = msg[0] |
|
1289 |
+ result_ = msg[2] |
|
1290 |
+ result_tuple: tuple[bool, str | None, str | None] = cast( |
|
1291 |
+ "tuple[bool, str | None, str | None]", result_ |
|
1292 |
+ ) |
|
1293 |
+ intermediate_results[n] = result_tuple |
|
1294 |
+ child_input_queues[n] = None |
|
1295 |
+ p = processes[n] |
|
1296 |
+ p.join(timeout=timeout) |
|
1297 |
+ assert not p.is_alive() |
|
1298 |
+ processes_pending.remove(p) |
|
1299 |
+ assert result_tuple[0], ( |
|
1300 |
+ f"action #{n} exited with an error: {result_tuple!r}" |
|
1301 |
+ ) |
|
1302 |
+ if n + 1 < len(processes): |
|
1303 |
+ next_child_input_queue = child_input_queues[n + 1] |
|
1304 |
+ assert next_child_input_queue |
|
1305 |
+ next_child_input_queue.put( |
|
1306 |
+ IPCMessage(n + 1, "go", None), |
|
1307 |
+ block=True, |
|
1308 |
+ timeout=timeout, |
|
1309 |
+ ) |
|
1310 |
+ else: |
|
1311 |
+ raise AssertionError() |
|
1312 |
+ |
|
1313 |
+ |
|
1314 |
+TestFakedConfigurationMutex = ( |
|
1315 |
+ tests.machinery.pytest.skip_if_no_multiprocessing_support( |
|
1316 |
+ FakeConfigurationMutexStateMachine.TestCase |
|
1317 |
+ ) |
|
1318 |
+) |
|
1319 |
+"""The [`unittest.TestCase`][] class that will actually be run.""" |
... | ... |
@@ -23,7 +23,7 @@ import click |
23 | 23 |
import click.testing |
24 | 24 |
import hypothesis |
25 | 25 |
import pytest |
26 |
-from hypothesis import stateful, strategies |
|
26 |
+from hypothesis import strategies |
|
27 | 27 |
|
28 | 28 |
import tests.data |
29 | 29 |
import tests.data.callables |
... | ... |
@@ -1694,231 +1694,3 @@ class TestAgentInteraction: |
1694 | 1694 |
match=r"Malformed response|does not match request", |
1695 | 1695 |
): |
1696 | 1696 |
client.query_extensions() |
1697 |
- |
|
1698 |
- |
|
1699 |
-@strategies.composite |
|
1700 |
-def draw_alias_chain( |
|
1701 |
- draw: strategies.DrawFn, |
|
1702 |
- *, |
|
1703 |
- known_keys_strategy: strategies.SearchStrategy[str], |
|
1704 |
- new_keys_strategy: strategies.SearchStrategy[str], |
|
1705 |
- chain_size: strategies.SearchStrategy[int] = strategies.integers( # noqa: B008 |
|
1706 |
- min_value=1, |
|
1707 |
- max_value=5, |
|
1708 |
- ), |
|
1709 |
- existing: bool = False, |
|
1710 |
-) -> tuple[str, ...]: |
|
1711 |
- """Draw names for alias chains in the SSH agent socket provider registry. |
|
1712 |
- |
|
1713 |
- Depending on arguments, draw a set of names from the new keys bundle |
|
1714 |
- that do not yet exist in the registry, to insert as a new alias |
|
1715 |
- chain. Alternatively, draw a non-alias name from the known keys |
|
1716 |
- bundle, then draw other names that either don't exist yet in the |
|
1717 |
- registry, or that alias the first name directly or indirectly. The |
|
1718 |
- chain length, and whether to target existing registry entries or |
|
1719 |
- not, may be set statically, or may be drawn from a respective |
|
1720 |
- strategy. |
|
1721 |
- |
|
1722 |
- Args: |
|
1723 |
- draw: |
|
1724 |
- The `hypothesis` draw function. |
|
1725 |
- chain_size: |
|
1726 |
- A strategy for determining the correct alias chain length. |
|
1727 |
- Must not yield any integers less than 1. |
|
1728 |
- existing: |
|
1729 |
- If true, target an existing registry entry in the alias |
|
1730 |
- chain, and permit rewriting existing aliases of that same |
|
1731 |
- entry to the new alias. Otherwise, draw only new names. |
|
1732 |
- known_keys_strategy: |
|
1733 |
- A strategy for generating provider registry keys already |
|
1734 |
- contained in the registry. Typically, this is |
|
1735 |
- a [Bundle][hypothesis.stateful.Bundle]. |
|
1736 |
- new_keys_strategy: |
|
1737 |
- A strategy for generating provider registry keys not yet |
|
1738 |
- contained in the registry with high probability. Typically, |
|
1739 |
- this is a [consuming][hypothesis.stateful.consumes] |
|
1740 |
- [Bundle][hypothesis.stateful.Bundle]. |
|
1741 |
- |
|
1742 |
- Returns: |
|
1743 |
- A tuple of names forming an alias chain, each entry pointing to |
|
1744 |
- or intending to point to the previous entry in the tuple. |
|
1745 |
- |
|
1746 |
- """ |
|
1747 |
- registry = socketprovider.SocketProvider.registry |
|
1748 |
- |
|
1749 |
- def not_an_alias(key: str) -> bool: |
|
1750 |
- return key in registry and not isinstance(registry[key], str) |
|
1751 |
- |
|
1752 |
- def is_indirect_alias_of( |
|
1753 |
- key: str, target: str |
|
1754 |
- ) -> bool: # pragma: no cover |
|
1755 |
- if key == target: |
|
1756 |
- return False # not an alias |
|
1757 |
- seen = set() # loop detection |
|
1758 |
- while key not in seen: |
|
1759 |
- seen.add(key) |
|
1760 |
- if key not in registry: |
|
1761 |
- return False |
|
1762 |
- if not isinstance(registry[key], str): |
|
1763 |
- return False |
|
1764 |
- if key == target: |
|
1765 |
- return True |
|
1766 |
- tmp = registry[key] |
|
1767 |
- assert isinstance(tmp, str) |
|
1768 |
- key = tmp |
|
1769 |
- return False # loop |
|
1770 |
- |
|
1771 |
- err_msg_chain_size = "Chain sizes must always be 1 or larger." |
|
1772 |
- |
|
1773 |
- size = draw(chain_size) |
|
1774 |
- if size < 1: # pragma: no cover |
|
1775 |
- raise ValueError(err_msg_chain_size) |
|
1776 |
- names: list[str] = [] |
|
1777 |
- base: str | None = None |
|
1778 |
- if existing: |
|
1779 |
- names.append(draw(known_keys_strategy.filter(not_an_alias))) |
|
1780 |
- base = names[0] |
|
1781 |
- size -= 1 |
|
1782 |
- new_key_strategy = new_keys_strategy.filter( |
|
1783 |
- lambda key: key not in registry |
|
1784 |
- ) |
|
1785 |
- old_key_strategy = known_keys_strategy.filter( |
|
1786 |
- lambda key: is_indirect_alias_of(key, target=base) |
|
1787 |
- ) |
|
1788 |
- list_strategy_source = strategies.one_of( |
|
1789 |
- new_key_strategy, old_key_strategy |
|
1790 |
- ) |
|
1791 |
- else: |
|
1792 |
- list_strategy_source = new_keys_strategy.filter( |
|
1793 |
- lambda key: key not in registry |
|
1794 |
- ) |
|
1795 |
- list_strategy = strategies.lists( |
|
1796 |
- list_strategy_source.filter(lambda candidate: candidate != base), |
|
1797 |
- min_size=size, |
|
1798 |
- max_size=size, |
|
1799 |
- unique=True, |
|
1800 |
- ) |
|
1801 |
- names.extend(draw(list_strategy)) |
|
1802 |
- return tuple(names) |
|
1803 |
- |
|
1804 |
- |
|
1805 |
-class SSHAgentSocketProviderRegistryStateMachine( |
|
1806 |
- stateful.RuleBasedStateMachine |
|
1807 |
-): |
|
1808 |
- """A state machine for the SSH agent socket provider registry. |
|
1809 |
- |
|
1810 |
- Record possible changes to the socket provider registry, keeping track |
|
1811 |
- of true entries, aliases, and reservations. |
|
1812 |
- |
|
1813 |
- """ |
|
1814 |
- |
|
1815 |
- def __init__(self) -> None: |
|
1816 |
- """Initialize self, set up context managers and enter them.""" |
|
1817 |
- super().__init__() |
|
1818 |
- self.exit_stack = contextlib.ExitStack().__enter__() |
|
1819 |
- self.monkeypatch = self.exit_stack.enter_context( |
|
1820 |
- pytest.MonkeyPatch.context() |
|
1821 |
- ) |
|
1822 |
- self.orig_registry = socketprovider.SocketProvider.registry |
|
1823 |
- self.registry: dict[ |
|
1824 |
- str, _types.SSHAgentSocketProvider | str | None |
|
1825 |
- ] = { |
|
1826 |
- "posix": self.orig_registry["posix"], |
|
1827 |
- "the_annoying_os": self.orig_registry["the_annoying_os"], |
|
1828 |
- "native": self.orig_registry["native"], |
|
1829 |
- "unix_domain": "posix", |
|
1830 |
- "the_annoying_os_named_pipe": "the_annoying_os", |
|
1831 |
- } |
|
1832 |
- self.monkeypatch.setattr( |
|
1833 |
- socketprovider.SocketProvider, "registry", self.registry |
|
1834 |
- ) |
|
1835 |
- self.model: dict[str, _types.SSHAgentSocketProvider | None] = {} |
|
1836 |
- |
|
1837 |
- known_keys: stateful.Bundle[str] = stateful.Bundle("known_keys") |
|
1838 |
- """""" |
|
1839 |
- new_keys: stateful.Bundle[str] = stateful.Bundle("new_keys") |
|
1840 |
- """""" |
|
1841 |
- |
|
1842 |
- def sample_provider(self) -> _types.SSHAgentSocket: |
|
1843 |
- raise AssertionError |
|
1844 |
- |
|
1845 |
- @stateful.initialize( |
|
1846 |
- target=known_keys, |
|
1847 |
- ) |
|
1848 |
- def get_registry_keys(self) -> stateful.MultipleResults[str]: |
|
1849 |
- """Read the standard keys from the registry.""" |
|
1850 |
- self.model.update({ |
|
1851 |
- k: socketprovider.SocketProvider.lookup(k) for k in self.registry |
|
1852 |
- }) |
|
1853 |
- return stateful.multiple(*self.registry.keys()) |
|
1854 |
- |
|
1855 |
- @stateful.rule( |
|
1856 |
- target=new_keys, |
|
1857 |
- k=strategies.text("abcdefghijklmnopqrstuvwxyz0123456789_").filter( |
|
1858 |
- lambda s: s not in socketprovider.SocketProvider.registry |
|
1859 |
- ), |
|
1860 |
- ) |
|
1861 |
- def new_key(self, k: str) -> str: |
|
1862 |
- return k |
|
1863 |
- |
|
1864 |
- @stateful.invariant() |
|
1865 |
- def check_consistency(self) -> None: |
|
1866 |
- lookup = socketprovider.SocketProvider.lookup |
|
1867 |
- assert self.registry.keys() == self.model.keys() |
|
1868 |
- for k in self.model: |
|
1869 |
- resolved = lookup(k) |
|
1870 |
- modelled = self.model[k] |
|
1871 |
- step1 = self.registry[k] |
|
1872 |
- manually = lookup(step1) if isinstance(step1, str) else step1 |
|
1873 |
- assert resolved == modelled |
|
1874 |
- assert resolved == manually |
|
1875 |
- |
|
1876 |
- @stateful.rule( |
|
1877 |
- target=known_keys, |
|
1878 |
- chain=draw_alias_chain( |
|
1879 |
- known_keys_strategy=known_keys, |
|
1880 |
- new_keys_strategy=stateful.consumes(new_keys), |
|
1881 |
- existing=True, |
|
1882 |
- ), |
|
1883 |
- ) |
|
1884 |
- def alias_existing( |
|
1885 |
- self, chain: tuple[str, ...] |
|
1886 |
- ) -> stateful.MultipleResults[str]: |
|
1887 |
- try: |
|
1888 |
- provider = socketprovider.SocketProvider.resolve(chain[0]) |
|
1889 |
- except NotImplementedError: # pragma: no cover [failsafe] |
|
1890 |
- provider = self.sample_provider |
|
1891 |
- assert ( |
|
1892 |
- socketprovider.SocketProvider.register(*chain)(provider) |
|
1893 |
- == provider |
|
1894 |
- ) |
|
1895 |
- for k in chain: |
|
1896 |
- self.model[k] = provider |
|
1897 |
- return stateful.multiple(*chain[1:]) |
|
1898 |
- |
|
1899 |
- @stateful.rule( |
|
1900 |
- target=known_keys, |
|
1901 |
- chain=draw_alias_chain( |
|
1902 |
- known_keys_strategy=known_keys, |
|
1903 |
- new_keys_strategy=stateful.consumes(new_keys), |
|
1904 |
- existing=False, |
|
1905 |
- ), |
|
1906 |
- ) |
|
1907 |
- def alias_new(self, chain: list[str]) -> stateful.MultipleResults[str]: |
|
1908 |
- provider = self.sample_provider |
|
1909 |
- assert ( |
|
1910 |
- socketprovider.SocketProvider.register(*chain)(provider) |
|
1911 |
- == provider |
|
1912 |
- ) |
|
1913 |
- for k in chain: |
|
1914 |
- self.model[k] = provider |
|
1915 |
- return stateful.multiple(*chain) |
|
1916 |
- |
|
1917 |
- def teardown(self) -> None: |
|
1918 |
- """Upon teardown, exit all contexts entered in `__init__`.""" |
|
1919 |
- self.exit_stack.close() |
|
1920 |
- |
|
1921 |
- |
|
1922 |
-TestSSHAgentSocketProviderRegistry = ( |
|
1923 |
- SSHAgentSocketProviderRegistryStateMachine.TestCase |
|
1924 |
-) |
... | ... |
@@ -0,0 +1,250 @@ |
1 |
+# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info> |
|
2 |
+# |
|
3 |
+# SPDX-License-Identifier: Zlib |
|
4 |
+ |
|
5 |
+"""Test OpenSSH key loading and signing.""" |
|
6 |
+ |
|
7 |
+from __future__ import annotations |
|
8 |
+ |
|
9 |
+import contextlib |
|
10 |
+from typing import TYPE_CHECKING |
|
11 |
+ |
|
12 |
+import pytest |
|
13 |
+from hypothesis import stateful, strategies |
|
14 |
+ |
|
15 |
+import tests.machinery.pytest |
|
16 |
+from derivepassphrase.ssh_agent import socketprovider |
|
17 |
+ |
|
18 |
+if TYPE_CHECKING: |
|
19 |
+ from derivepassphrase import _types |
|
20 |
+ |
|
21 |
+# All tests in this module are heavy-duty tests. |
|
22 |
+pytestmark = [tests.machinery.pytest.heavy_duty] |
|
23 |
+ |
|
24 |
+ |
|
25 |
+@strategies.composite |
|
26 |
+def draw_alias_chain( |
|
27 |
+ draw: strategies.DrawFn, |
|
28 |
+ *, |
|
29 |
+ known_keys_strategy: strategies.SearchStrategy[str], |
|
30 |
+ new_keys_strategy: strategies.SearchStrategy[str], |
|
31 |
+ chain_size: strategies.SearchStrategy[int] = strategies.integers( # noqa: B008 |
|
32 |
+ min_value=1, |
|
33 |
+ max_value=5, |
|
34 |
+ ), |
|
35 |
+ existing: bool = False, |
|
36 |
+) -> tuple[str, ...]: |
|
37 |
+ """Draw names for alias chains in the SSH agent socket provider registry. |
|
38 |
+ |
|
39 |
+ Depending on arguments, draw a set of names from the new keys bundle |
|
40 |
+ that do not yet exist in the registry, to insert as a new alias |
|
41 |
+ chain. Alternatively, draw a non-alias name from the known keys |
|
42 |
+ bundle, then draw other names that either don't exist yet in the |
|
43 |
+ registry, or that alias the first name directly or indirectly. The |
|
44 |
+ chain length, and whether to target existing registry entries or |
|
45 |
+ not, may be set statically, or may be drawn from a respective |
|
46 |
+ strategy. |
|
47 |
+ |
|
48 |
+ Args: |
|
49 |
+ draw: |
|
50 |
+ The `hypothesis` draw function. |
|
51 |
+ chain_size: |
|
52 |
+ A strategy for determining the correct alias chain length. |
|
53 |
+ Must not yield any integers less than 1. |
|
54 |
+ existing: |
|
55 |
+ If true, target an existing registry entry in the alias |
|
56 |
+ chain, and permit rewriting existing aliases of that same |
|
57 |
+ entry to the new alias. Otherwise, draw only new names. |
|
58 |
+ known_keys_strategy: |
|
59 |
+ A strategy for generating provider registry keys already |
|
60 |
+ contained in the registry. Typically, this is |
|
61 |
+ a [Bundle][hypothesis.stateful.Bundle]. |
|
62 |
+ new_keys_strategy: |
|
63 |
+ A strategy for generating provider registry keys not yet |
|
64 |
+ contained in the registry with high probability. Typically, |
|
65 |
+ this is a [consuming][hypothesis.stateful.consumes] |
|
66 |
+ [Bundle][hypothesis.stateful.Bundle]. |
|
67 |
+ |
|
68 |
+ Returns: |
|
69 |
+ A tuple of names forming an alias chain, each entry pointing to |
|
70 |
+ or intending to point to the previous entry in the tuple. |
|
71 |
+ |
|
72 |
+ """ |
|
73 |
+ registry = socketprovider.SocketProvider.registry |
|
74 |
+ |
|
75 |
+ def not_an_alias(key: str) -> bool: |
|
76 |
+ return key in registry and not isinstance(registry[key], str) |
|
77 |
+ |
|
78 |
+ def is_indirect_alias_of( |
|
79 |
+ key: str, target: str |
|
80 |
+ ) -> bool: # pragma: no cover |
|
81 |
+ if key == target: |
|
82 |
+ return False # not an alias |
|
83 |
+ seen = set() # loop detection |
|
84 |
+ while key not in seen: |
|
85 |
+ seen.add(key) |
|
86 |
+ if key not in registry: |
|
87 |
+ return False |
|
88 |
+ if not isinstance(registry[key], str): |
|
89 |
+ return False |
|
90 |
+ if key == target: |
|
91 |
+ return True |
|
92 |
+ tmp = registry[key] |
|
93 |
+ assert isinstance(tmp, str) |
|
94 |
+ key = tmp |
|
95 |
+ return False # loop |
|
96 |
+ |
|
97 |
+ err_msg_chain_size = "Chain sizes must always be 1 or larger." |
|
98 |
+ |
|
99 |
+ size = draw(chain_size) |
|
100 |
+ if size < 1: # pragma: no cover |
|
101 |
+ raise ValueError(err_msg_chain_size) |
|
102 |
+ names: list[str] = [] |
|
103 |
+ base: str | None = None |
|
104 |
+ if existing: |
|
105 |
+ names.append(draw(known_keys_strategy.filter(not_an_alias))) |
|
106 |
+ base = names[0] |
|
107 |
+ size -= 1 |
|
108 |
+ new_key_strategy = new_keys_strategy.filter( |
|
109 |
+ lambda key: key not in registry |
|
110 |
+ ) |
|
111 |
+ old_key_strategy = known_keys_strategy.filter( |
|
112 |
+ lambda key: is_indirect_alias_of(key, target=base) |
|
113 |
+ ) |
|
114 |
+ list_strategy_source = strategies.one_of( |
|
115 |
+ new_key_strategy, old_key_strategy |
|
116 |
+ ) |
|
117 |
+ else: |
|
118 |
+ list_strategy_source = new_keys_strategy.filter( |
|
119 |
+ lambda key: key not in registry |
|
120 |
+ ) |
|
121 |
+ list_strategy = strategies.lists( |
|
122 |
+ list_strategy_source.filter(lambda candidate: candidate != base), |
|
123 |
+ min_size=size, |
|
124 |
+ max_size=size, |
|
125 |
+ unique=True, |
|
126 |
+ ) |
|
127 |
+ names.extend(draw(list_strategy)) |
|
128 |
+ return tuple(names) |
|
129 |
+ |
|
130 |
+ |
|
131 |
+class SSHAgentSocketProviderRegistryStateMachine( |
|
132 |
+ stateful.RuleBasedStateMachine |
|
133 |
+): |
|
134 |
+ """A state machine for the SSH agent socket provider registry. |
|
135 |
+ |
|
136 |
+ Record possible changes to the socket provider registry, keeping track |
|
137 |
+ of true entries, aliases, and reservations. |
|
138 |
+ |
|
139 |
+ """ |
|
140 |
+ |
|
141 |
+ def __init__(self) -> None: |
|
142 |
+ """Initialize self, set up context managers and enter them.""" |
|
143 |
+ super().__init__() |
|
144 |
+ self.exit_stack = contextlib.ExitStack().__enter__() |
|
145 |
+ self.monkeypatch = self.exit_stack.enter_context( |
|
146 |
+ pytest.MonkeyPatch.context() |
|
147 |
+ ) |
|
148 |
+ self.orig_registry = socketprovider.SocketProvider.registry |
|
149 |
+ self.registry: dict[ |
|
150 |
+ str, _types.SSHAgentSocketProvider | str | None |
|
151 |
+ ] = { |
|
152 |
+ "posix": self.orig_registry["posix"], |
|
153 |
+ "the_annoying_os": self.orig_registry["the_annoying_os"], |
|
154 |
+ "native": self.orig_registry["native"], |
|
155 |
+ "unix_domain": "posix", |
|
156 |
+ "the_annoying_os_named_pipe": "the_annoying_os", |
|
157 |
+ } |
|
158 |
+ self.monkeypatch.setattr( |
|
159 |
+ socketprovider.SocketProvider, "registry", self.registry |
|
160 |
+ ) |
|
161 |
+ self.model: dict[str, _types.SSHAgentSocketProvider | None] = {} |
|
162 |
+ |
|
163 |
+ known_keys: stateful.Bundle[str] = stateful.Bundle("known_keys") |
|
164 |
+ """""" |
|
165 |
+ new_keys: stateful.Bundle[str] = stateful.Bundle("new_keys") |
|
166 |
+ """""" |
|
167 |
+ |
|
168 |
+ def sample_provider(self) -> _types.SSHAgentSocket: |
|
169 |
+ raise AssertionError |
|
170 |
+ |
|
171 |
+ @stateful.initialize( |
|
172 |
+ target=known_keys, |
|
173 |
+ ) |
|
174 |
+ def get_registry_keys(self) -> stateful.MultipleResults[str]: |
|
175 |
+ """Read the standard keys from the registry.""" |
|
176 |
+ self.model.update({ |
|
177 |
+ k: socketprovider.SocketProvider.lookup(k) for k in self.registry |
|
178 |
+ }) |
|
179 |
+ return stateful.multiple(*self.registry.keys()) |
|
180 |
+ |
|
181 |
+ @stateful.rule( |
|
182 |
+ target=new_keys, |
|
183 |
+ k=strategies.text("abcdefghijklmnopqrstuvwxyz0123456789_").filter( |
|
184 |
+ lambda s: s not in socketprovider.SocketProvider.registry |
|
185 |
+ ), |
|
186 |
+ ) |
|
187 |
+ def new_key(self, k: str) -> str: |
|
188 |
+ return k |
|
189 |
+ |
|
190 |
+ @stateful.invariant() |
|
191 |
+ def check_consistency(self) -> None: |
|
192 |
+ lookup = socketprovider.SocketProvider.lookup |
|
193 |
+ assert self.registry.keys() == self.model.keys() |
|
194 |
+ for k in self.model: |
|
195 |
+ resolved = lookup(k) |
|
196 |
+ modelled = self.model[k] |
|
197 |
+ step1 = self.registry[k] |
|
198 |
+ manually = lookup(step1) if isinstance(step1, str) else step1 |
|
199 |
+ assert resolved == modelled |
|
200 |
+ assert resolved == manually |
|
201 |
+ |
|
202 |
+ @stateful.rule( |
|
203 |
+ target=known_keys, |
|
204 |
+ chain=draw_alias_chain( |
|
205 |
+ known_keys_strategy=known_keys, |
|
206 |
+ new_keys_strategy=stateful.consumes(new_keys), |
|
207 |
+ existing=True, |
|
208 |
+ ), |
|
209 |
+ ) |
|
210 |
+ def alias_existing( |
|
211 |
+ self, chain: tuple[str, ...] |
|
212 |
+ ) -> stateful.MultipleResults[str]: |
|
213 |
+ try: |
|
214 |
+ provider = socketprovider.SocketProvider.resolve(chain[0]) |
|
215 |
+ except NotImplementedError: # pragma: no cover [failsafe] |
|
216 |
+ provider = self.sample_provider |
|
217 |
+ assert ( |
|
218 |
+ socketprovider.SocketProvider.register(*chain)(provider) |
|
219 |
+ == provider |
|
220 |
+ ) |
|
221 |
+ for k in chain: |
|
222 |
+ self.model[k] = provider |
|
223 |
+ return stateful.multiple(*chain[1:]) |
|
224 |
+ |
|
225 |
+ @stateful.rule( |
|
226 |
+ target=known_keys, |
|
227 |
+ chain=draw_alias_chain( |
|
228 |
+ known_keys_strategy=known_keys, |
|
229 |
+ new_keys_strategy=stateful.consumes(new_keys), |
|
230 |
+ existing=False, |
|
231 |
+ ), |
|
232 |
+ ) |
|
233 |
+ def alias_new(self, chain: list[str]) -> stateful.MultipleResults[str]: |
|
234 |
+ provider = self.sample_provider |
|
235 |
+ assert ( |
|
236 |
+ socketprovider.SocketProvider.register(*chain)(provider) |
|
237 |
+ == provider |
|
238 |
+ ) |
|
239 |
+ for k in chain: |
|
240 |
+ self.model[k] = provider |
|
241 |
+ return stateful.multiple(*chain) |
|
242 |
+ |
|
243 |
+ def teardown(self) -> None: |
|
244 |
+ """Upon teardown, exit all contexts entered in `__init__`.""" |
|
245 |
+ self.exit_stack.close() |
|
246 |
+ |
|
247 |
+ |
|
248 |
+TestSSHAgentSocketProviderRegistry = ( |
|
249 |
+ SSHAgentSocketProviderRegistryStateMachine.TestCase |
|
250 |
+) |
... | ... |
@@ -5,13 +5,11 @@ |
5 | 5 |
from __future__ import annotations |
6 | 6 |
|
7 | 7 |
import copy |
8 |
-import math |
|
9 | 8 |
import types |
10 | 9 |
|
11 | 10 |
import hypothesis |
12 | 11 |
import pytest |
13 | 12 |
from hypothesis import strategies |
14 |
-from typing_extensions import Any |
|
15 | 13 |
|
16 | 14 |
import tests.data |
17 | 15 |
import tests.data.callables |
... | ... |
@@ -19,56 +17,6 @@ import tests.machinery.hypothesis |
19 | 17 |
from derivepassphrase import _types |
20 | 18 |
|
21 | 19 |
|
22 |
-@strategies.composite |
|
23 |
-def js_atoms_strategy( |
|
24 |
- draw: strategies.DrawFn, |
|
25 |
-) -> int | float | str | bytes | bool | None: |
|
26 |
- """Yield a JS atom.""" |
|
27 |
- return draw( |
|
28 |
- strategies.one_of( |
|
29 |
- strategies.integers(), |
|
30 |
- strategies.floats(allow_nan=False, allow_infinity=False), |
|
31 |
- strategies.text(max_size=100), |
|
32 |
- strategies.binary(max_size=100), |
|
33 |
- strategies.booleans(), |
|
34 |
- strategies.none(), |
|
35 |
- ), |
|
36 |
- ) |
|
37 |
- |
|
38 |
- |
|
39 |
-@strategies.composite |
|
40 |
-def js_nested_strategy(draw: strategies.DrawFn) -> Any: |
|
41 |
- """Yield an arbitrary and perhaps nested JS value.""" |
|
42 |
- return draw( |
|
43 |
- strategies.one_of( |
|
44 |
- js_atoms_strategy(), |
|
45 |
- strategies.builds(tuple), |
|
46 |
- strategies.builds(list), |
|
47 |
- strategies.builds(dict), |
|
48 |
- strategies.builds(set), |
|
49 |
- strategies.builds(frozenset), |
|
50 |
- strategies.recursive( |
|
51 |
- js_atoms_strategy(), |
|
52 |
- lambda s: strategies.one_of( |
|
53 |
- strategies.frozensets(s, max_size=100), |
|
54 |
- strategies.builds( |
|
55 |
- tuple, strategies.frozensets(s, max_size=100) |
|
56 |
- ), |
|
57 |
- ), |
|
58 |
- max_leaves=8, |
|
59 |
- ), |
|
60 |
- strategies.recursive( |
|
61 |
- js_atoms_strategy(), |
|
62 |
- lambda s: strategies.one_of( |
|
63 |
- strategies.lists(s, max_size=100), |
|
64 |
- strategies.dictionaries(strategies.text(max_size=100), s), |
|
65 |
- ), |
|
66 |
- max_leaves=25, |
|
67 |
- ), |
|
68 |
- ), |
|
69 |
- ) |
|
70 |
- |
|
71 |
- |
|
72 | 20 |
class Parametrize(types.SimpleNamespace): |
73 | 21 |
VALID_VAULT_TEST_CONFIGS = pytest.mark.parametrize( |
74 | 22 |
"test_config", |
... | ... |
@@ -86,25 +34,6 @@ class Parametrize(types.SimpleNamespace): |
86 | 34 |
) |
87 | 35 |
|
88 | 36 |
|
89 |
-@hypothesis.given(value=js_nested_strategy()) |
|
90 |
-@hypothesis.example(float("nan")) |
|
91 |
-def test_100_js_truthiness(value: Any) -> None: |
|
92 |
- """Determine the truthiness of a value according to JavaScript. |
|
93 |
- |
|
94 |
- Use hypothesis to generate test values. |
|
95 |
- |
|
96 |
- """ |
|
97 |
- expected = ( |
|
98 |
- value is not None # noqa: PLR1714 |
|
99 |
- and value != False # noqa: E712 |
|
100 |
- and value != 0 |
|
101 |
- and value != 0.0 |
|
102 |
- and value != "" |
|
103 |
- and not (isinstance(value, float) and math.isnan(value)) |
|
104 |
- ) |
|
105 |
- assert _types.js_truthiness(value) == expected |
|
106 |
- |
|
107 |
- |
|
108 | 37 |
@Parametrize.VALID_VAULT_TEST_CONFIGS |
109 | 38 |
def test_200_is_vault_config(test_config: tests.data.VaultTestConfig) -> None: |
110 | 39 |
"""Is this vault configuration recognized as valid/invalid? |
... | ... |
@@ -0,0 +1,86 @@ |
1 |
+# SPDX-FileCopyrightText: 2025 Marco Ricci <software@the13thletter.info> |
|
2 |
+# |
|
3 |
+# SPDX-License-Identifier: Zlib |
|
4 |
+ |
|
5 |
+from __future__ import annotations |
|
6 |
+ |
|
7 |
+import math |
|
8 |
+ |
|
9 |
+import hypothesis |
|
10 |
+from hypothesis import strategies |
|
11 |
+from typing_extensions import Any |
|
12 |
+ |
|
13 |
+import tests.machinery.pytest |
|
14 |
+from derivepassphrase import _types |
|
15 |
+ |
|
16 |
+# All tests in this module are heavy-duty tests. |
|
17 |
+pytestmark = [tests.machinery.pytest.heavy_duty] |
|
18 |
+ |
|
19 |
+ |
|
20 |
+@strategies.composite |
|
21 |
+def js_atoms_strategy( |
|
22 |
+ draw: strategies.DrawFn, |
|
23 |
+) -> int | float | str | bytes | bool | None: |
|
24 |
+ """Yield a JS atom.""" |
|
25 |
+ return draw( |
|
26 |
+ strategies.one_of( |
|
27 |
+ strategies.integers(), |
|
28 |
+ strategies.floats(allow_nan=False, allow_infinity=False), |
|
29 |
+ strategies.text(max_size=100), |
|
30 |
+ strategies.binary(max_size=100), |
|
31 |
+ strategies.booleans(), |
|
32 |
+ strategies.none(), |
|
33 |
+ ), |
|
34 |
+ ) |
|
35 |
+ |
|
36 |
+ |
|
37 |
+@strategies.composite |
|
38 |
+def js_nested_strategy(draw: strategies.DrawFn) -> Any: |
|
39 |
+ """Yield an arbitrary and perhaps nested JS value.""" |
|
40 |
+ return draw( |
|
41 |
+ strategies.one_of( |
|
42 |
+ js_atoms_strategy(), |
|
43 |
+ strategies.builds(tuple), |
|
44 |
+ strategies.builds(list), |
|
45 |
+ strategies.builds(dict), |
|
46 |
+ strategies.builds(set), |
|
47 |
+ strategies.builds(frozenset), |
|
48 |
+ strategies.recursive( |
|
49 |
+ js_atoms_strategy(), |
|
50 |
+ lambda s: strategies.one_of( |
|
51 |
+ strategies.frozensets(s, max_size=100), |
|
52 |
+ strategies.builds( |
|
53 |
+ tuple, strategies.frozensets(s, max_size=100) |
|
54 |
+ ), |
|
55 |
+ ), |
|
56 |
+ max_leaves=8, |
|
57 |
+ ), |
|
58 |
+ strategies.recursive( |
|
59 |
+ js_atoms_strategy(), |
|
60 |
+ lambda s: strategies.one_of( |
|
61 |
+ strategies.lists(s, max_size=100), |
|
62 |
+ strategies.dictionaries(strategies.text(max_size=100), s), |
|
63 |
+ ), |
|
64 |
+ max_leaves=25, |
|
65 |
+ ), |
|
66 |
+ ), |
|
67 |
+ ) |
|
68 |
+ |
|
69 |
+ |
|
70 |
+@hypothesis.given(value=js_nested_strategy()) |
|
71 |
+@hypothesis.example(float("nan")) |
|
72 |
+def test_100_js_truthiness(value: Any) -> None: |
|
73 |
+ """Determine the truthiness of a value according to JavaScript. |
|
74 |
+ |
|
75 |
+ Use hypothesis to generate test values. |
|
76 |
+ |
|
77 |
+ """ |
|
78 |
+ expected = ( |
|
79 |
+ value is not None # noqa: PLR1714 |
|
80 |
+ and value != False # noqa: E712 |
|
81 |
+ and value != 0 |
|
82 |
+ and value != 0.0 |
|
83 |
+ and value != "" |
|
84 |
+ and not (isinstance(value, float) and math.isnan(value)) |
|
85 |
+ ) |
|
86 |
+ assert _types.js_truthiness(value) == expected |
|
0 | 87 |