Marco Ricci commited on 2024-10-15 11:22:02
Zeige 1 geänderte Dateien mit 285 Einfügungen und 9 Löschungen.
The state machine underlying this stateful hypothesis test constructs random vault settings objects and random service names, then merges these together into full configurations and imports those. Alternatively, it sets or purges single-service or global settings. The machine then checks after each step that the nominal, current configuration matches the actual configuration stored by `derivepassphrase`. The program runs with a temporary settings directory for the duration of the state machine run. Unlike how a fully general state machine for this task would probably run, *this* state machine does not filter its steps based on the list of services currently stored in the configuration, but rather on the full list of known service names. (Evaluating a hypothesis search strategy based on the current contents of an instance variable appears to be a very non-straight-foward ordeal, if not outright impossible.) As a consequence, the "purge" action may actually be a no-op, and the "set" action may actually be a "create" action. While this *could* be implemented with `hypothesis.assume`, presumably this would have such a low success probability that it triggers health check errors.
... | ... |
@@ -11,21 +11,19 @@ import json |
11 | 11 |
import os |
12 | 12 |
import shutil |
13 | 13 |
import socket |
14 |
-from typing import TYPE_CHECKING |
|
14 |
+from typing import TYPE_CHECKING, cast |
|
15 | 15 |
|
16 | 16 |
import click.testing |
17 | 17 |
import hypothesis |
18 | 18 |
import pytest |
19 |
-from hypothesis import strategies |
|
20 |
-from typing_extensions import NamedTuple |
|
19 |
+from hypothesis import stateful, strategies |
|
20 |
+from typing_extensions import Any, NamedTuple |
|
21 | 21 |
|
22 | 22 |
import tests |
23 | 23 |
from derivepassphrase import _types, cli, ssh_agent, vault |
24 | 24 |
|
25 | 25 |
if TYPE_CHECKING: |
26 |
- from collections.abc import Callable |
|
27 |
- |
|
28 |
- from typing_extensions import Any |
|
26 |
+ from collections.abc import Callable, Iterable |
|
29 | 27 |
|
30 | 28 |
DUMMY_SERVICE = tests.DUMMY_SERVICE |
31 | 29 |
DUMMY_PASSPHRASE = tests.DUMMY_PASSPHRASE |
... | ... |
@@ -486,9 +484,7 @@ class TestCLI: |
486 | 484 |
}, |
487 | 485 |
{ |
488 | 486 |
'global': {'key': DUMMY_KEY1_B64}, |
489 |
- 'services': { |
|
490 |
- DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy() |
|
491 |
- }, |
|
487 |
+ 'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}, |
|
492 | 488 |
}, |
493 | 489 |
], |
494 | 490 |
) |
... | ... |
@@ -2100,3 +2096,283 @@ class TestCLITransition: |
2100 | 2096 |
assert ( |
2101 | 2097 |
'Warning: Failed to migrate to ' in result.stderr |
2102 | 2098 |
), 'expected known warning message in stderr' |
2099 |
+ |
|
2100 |
+ |
|
2101 |
+class ConfigMergingStateMachine(stateful.RuleBasedStateMachine): |
|
2102 |
+ def __init__(self) -> None: |
|
2103 |
+ super().__init__() |
|
2104 |
+ self.runner = click.testing.CliRunner(mix_stderr=False) |
|
2105 |
+ self.exit_stack = contextlib.ExitStack().__enter__() |
|
2106 |
+ self.monkeypatch = self.exit_stack.enter_context( |
|
2107 |
+ pytest.MonkeyPatch().context() |
|
2108 |
+ ) |
|
2109 |
+ self.isolated_config = self.exit_stack.enter_context( |
|
2110 |
+ tests.isolated_vault_config( |
|
2111 |
+ monkeypatch=self.monkeypatch, |
|
2112 |
+ runner=self.runner, |
|
2113 |
+ config={'services': {}}, |
|
2114 |
+ ) |
|
2115 |
+ ) |
|
2116 |
+ self.current_config = cli._load_config() |
|
2117 |
+ |
|
2118 |
+ known_services = stateful.Bundle('known_services') |
|
2119 |
+ settings = stateful.Bundle('settings') |
|
2120 |
+ configurations = stateful.Bundle('configurations') |
|
2121 |
+ |
|
2122 |
+ @stateful.initialize(target=configurations) |
|
2123 |
+ def init_empty_configuration(self) -> _types.VaultConfig: |
|
2124 |
+ return copy.deepcopy(self.current_config) |
|
2125 |
+ |
|
2126 |
+ @stateful.initialize(target=configurations) |
|
2127 |
+ def init_standard_testing_configuration(self) -> _types.VaultConfig: |
|
2128 |
+ return cast( |
|
2129 |
+ _types.VaultConfig, |
|
2130 |
+ { |
|
2131 |
+ 'services': { |
|
2132 |
+ DUMMY_SERVICE: copy.deepcopy(DUMMY_CONFIG_SETTINGS) |
|
2133 |
+ } |
|
2134 |
+ }, |
|
2135 |
+ ) |
|
2136 |
+ |
|
2137 |
+ @stateful.initialize( |
|
2138 |
+ target=known_services, |
|
2139 |
+ service_names=strategies.lists( |
|
2140 |
+ strategies.text( |
|
2141 |
+ strategies.characters(min_codepoint=32, max_codepoint=126), |
|
2142 |
+ min_size=1, |
|
2143 |
+ max_size=50, |
|
2144 |
+ ), |
|
2145 |
+ min_size=10, |
|
2146 |
+ max_size=10, |
|
2147 |
+ unique=True, |
|
2148 |
+ ), |
|
2149 |
+ ) |
|
2150 |
+ def init_random_service_names( |
|
2151 |
+ self, service_names: list[str] |
|
2152 |
+ ) -> Iterable[str]: |
|
2153 |
+ return stateful.multiple(*service_names) |
|
2154 |
+ |
|
2155 |
+ # Don't include key or phrase settings here. While easy to |
|
2156 |
+ # implement when manipulating the stored config directly, the |
|
2157 |
+ # command-line interface for changing the passphrase and key values |
|
2158 |
+ # is not straight-forward, and key values require a running SSH |
|
2159 |
+ # agent and the key to be loaded. |
|
2160 |
+ @stateful.initialize( |
|
2161 |
+ target=settings, |
|
2162 |
+ settings_list=strategies.lists( |
|
2163 |
+ tests.vault_full_service_config(), |
|
2164 |
+ min_size=10, |
|
2165 |
+ max_size=10, |
|
2166 |
+ unique_by=lambda obj: json.dumps(obj, sort_keys=True), |
|
2167 |
+ ), |
|
2168 |
+ ) |
|
2169 |
+ def init_random_settings( |
|
2170 |
+ self, settings_list: list[_types.VaultConfigGlobalSettings] |
|
2171 |
+ ) -> Iterable[_types.VaultConfigGlobalSettings]: |
|
2172 |
+ return stateful.multiple(*settings_list) |
|
2173 |
+ |
|
2174 |
+ @stateful.invariant() |
|
2175 |
+ def check_consistency_of_configs(self) -> None: |
|
2176 |
+ _types.clean_up_falsy_vault_config_values(self.current_config) |
|
2177 |
+ assert self.current_config == cli._load_config() |
|
2178 |
+ |
|
2179 |
+ @stateful.rule( |
|
2180 |
+ target=settings, |
|
2181 |
+ settings_obj=tests.vault_full_service_config(), |
|
2182 |
+ ) |
|
2183 |
+ def prepare_settings( |
|
2184 |
+ self, settings_obj: dict[str, int] |
|
2185 |
+ ) -> _types.VaultConfigGlobalSettings: |
|
2186 |
+ return cast(_types.VaultConfigGlobalSettings, settings_obj.copy()) |
|
2187 |
+ |
|
2188 |
+ @stateful.rule( |
|
2189 |
+ target=known_services, |
|
2190 |
+ name=strategies.text( |
|
2191 |
+ strategies.characters(min_codepoint=32, max_codepoint=126), |
|
2192 |
+ min_size=1, |
|
2193 |
+ max_size=50, |
|
2194 |
+ ), |
|
2195 |
+ ) |
|
2196 |
+ def prepare_service_name(self, name: str) -> str: |
|
2197 |
+ return name |
|
2198 |
+ |
|
2199 |
+ @stateful.rule( |
|
2200 |
+ target=configurations, |
|
2201 |
+ settings_obj=stateful.consumes(settings), |
|
2202 |
+ ) |
|
2203 |
+ def prepare_global_config( |
|
2204 |
+ self, |
|
2205 |
+ settings_obj: dict[str, int], |
|
2206 |
+ ) -> _types.VaultConfig: |
|
2207 |
+ return { |
|
2208 |
+ 'global': cast(_types.VaultConfigGlobalSettings, settings_obj), |
|
2209 |
+ 'services': {}, |
|
2210 |
+ } |
|
2211 |
+ |
|
2212 |
+ @stateful.rule( |
|
2213 |
+ target=configurations, |
|
2214 |
+ service=known_services, |
|
2215 |
+ settings_obj=stateful.consumes(settings), |
|
2216 |
+ ) |
|
2217 |
+ def prepare_service_config( |
|
2218 |
+ self, |
|
2219 |
+ service: str, |
|
2220 |
+ settings_obj: dict[str, int], |
|
2221 |
+ ) -> _types.VaultConfig: |
|
2222 |
+ return { |
|
2223 |
+ 'services': { |
|
2224 |
+ service: cast( |
|
2225 |
+ _types.VaultConfigServicesSettings, settings_obj |
|
2226 |
+ ), |
|
2227 |
+ }, |
|
2228 |
+ } |
|
2229 |
+ |
|
2230 |
+ @staticmethod |
|
2231 |
+ def fold_configs( |
|
2232 |
+ c1: _types.VaultConfig, c2: _types.VaultConfig |
|
2233 |
+ ) -> _types.VaultConfig: |
|
2234 |
+ new_global_dict = c1.get('global', c2.get('global')) |
|
2235 |
+ if new_global_dict is not None: |
|
2236 |
+ return { |
|
2237 |
+ 'global': new_global_dict, |
|
2238 |
+ 'services': {**c2['services'], **c1['services']}, |
|
2239 |
+ } |
|
2240 |
+ return { |
|
2241 |
+ 'services': {**c2['services'], **c1['services']}, |
|
2242 |
+ } |
|
2243 |
+ |
|
2244 |
+ @stateful.rule( |
|
2245 |
+ target=configurations, |
|
2246 |
+ config_base=stateful.consumes(configurations), |
|
2247 |
+ config_folded=stateful.consumes(configurations), |
|
2248 |
+ ) |
|
2249 |
+ def fold_configuration_into( |
|
2250 |
+ self, |
|
2251 |
+ config_base: _types.VaultConfig, |
|
2252 |
+ config_folded: _types.VaultConfig, |
|
2253 |
+ ) -> _types.VaultConfig: |
|
2254 |
+ return self.fold_configs(config_folded, config_base) |
|
2255 |
+ |
|
2256 |
+ @stateful.rule( |
|
2257 |
+ settings_obj=stateful.consumes(settings), |
|
2258 |
+ ) |
|
2259 |
+ def set_globals( |
|
2260 |
+ self, |
|
2261 |
+ settings_obj: _types.VaultConfigGlobalSettings, |
|
2262 |
+ ) -> None: |
|
2263 |
+ self.current_config['global'] = settings_obj |
|
2264 |
+ assert _types.is_vault_config(self.current_config) |
|
2265 |
+ # NOTE: This relies on settings_obj containing only the keys |
|
2266 |
+ # "length", "repeat", "upper", "lower", "number", "space", |
|
2267 |
+ # "dash" and "symbol". |
|
2268 |
+ _result = self.runner.invoke( |
|
2269 |
+ cli.derivepassphrase_vault, |
|
2270 |
+ ['--config'] |
|
2271 |
+ + [f'--{key}={value}' for key, value in settings_obj.items()], |
|
2272 |
+ catch_exceptions=False, |
|
2273 |
+ ) |
|
2274 |
+ result = tests.ReadableResult.parse(_result) |
|
2275 |
+ assert result.clean_exit(empty_stderr=False) |
|
2276 |
+ |
|
2277 |
+ # No check for whether the service settings currently exist. This |
|
2278 |
+ # may therefore actually "create" the settings, not merely "modify" |
|
2279 |
+ # them. |
|
2280 |
+ # |
|
2281 |
+ # (There is no check because this appears to be hard or impossible |
|
2282 |
+ # to express as a hypothesis strategy: it would depend on the |
|
2283 |
+ # current state of the state machine instance. This could be |
|
2284 |
+ # circumvented with `hypothesis.assume`, but that may likely trigger |
|
2285 |
+ # health check errors.) |
|
2286 |
+ @stateful.rule( |
|
2287 |
+ service=known_services, |
|
2288 |
+ settings_obj=stateful.consumes(settings), |
|
2289 |
+ ) |
|
2290 |
+ def set_service( |
|
2291 |
+ self, |
|
2292 |
+ service: str, |
|
2293 |
+ settings_obj: _types.VaultConfigServicesSettings, |
|
2294 |
+ ) -> None: |
|
2295 |
+ self.current_config['services'][service] = settings_obj |
|
2296 |
+ assert _types.is_vault_config(self.current_config) |
|
2297 |
+ # NOTE: This relies on settings_obj containing only the keys |
|
2298 |
+ # "length", "repeat", "upper", "lower", "number", "space", |
|
2299 |
+ # "dash" and "symbol". |
|
2300 |
+ _result = self.runner.invoke( |
|
2301 |
+ cli.derivepassphrase_vault, |
|
2302 |
+ ['--config'] |
|
2303 |
+ + [f'--{key}={value}' for key, value in settings_obj.items()] |
|
2304 |
+ + ['--', service], |
|
2305 |
+ catch_exceptions=False, |
|
2306 |
+ ) |
|
2307 |
+ result = tests.ReadableResult.parse(_result) |
|
2308 |
+ assert result.clean_exit(empty_stderr=False) |
|
2309 |
+ |
|
2310 |
+ @stateful.precondition(lambda self: 'global' in self.current_config) |
|
2311 |
+ @stateful.rule() |
|
2312 |
+ def purge_global(self) -> None: |
|
2313 |
+ self.current_config.pop('global') |
|
2314 |
+ _result = self.runner.invoke( |
|
2315 |
+ cli.derivepassphrase_vault, |
|
2316 |
+ ['--delete-globals'], |
|
2317 |
+ input='y', |
|
2318 |
+ catch_exceptions=False, |
|
2319 |
+ ) |
|
2320 |
+ result = tests.ReadableResult.parse(_result) |
|
2321 |
+ assert result.clean_exit(empty_stderr=False) |
|
2322 |
+ |
|
2323 |
+ # No check for whether the service settings currently exist. This |
|
2324 |
+ # may therefore actually be almost a no-op, purging settings that |
|
2325 |
+ # aren't set in the first place. |
|
2326 |
+ # |
|
2327 |
+ # (There is no check because this appears to be hard or impossible |
|
2328 |
+ # to express as a hypothesis strategy: it would depend on the |
|
2329 |
+ # current state of the state machine instance. This could be |
|
2330 |
+ # circumvented with `hypothesis.assume`, but that may likely trigger |
|
2331 |
+ # health check errors.) |
|
2332 |
+ @stateful.precondition(lambda self: bool(self.current_config['services'])) |
|
2333 |
+ @stateful.rule(service=stateful.consumes(known_services)) |
|
2334 |
+ def purge_service(self, service: str) -> None: |
|
2335 |
+ ret = self.current_config['services'].pop(service, None) |
|
2336 |
+ if ret is not None: |
|
2337 |
+ _result = self.runner.invoke( |
|
2338 |
+ cli.derivepassphrase_vault, |
|
2339 |
+ ['--delete', service], |
|
2340 |
+ input='y', |
|
2341 |
+ catch_exceptions=False, |
|
2342 |
+ ) |
|
2343 |
+ result = tests.ReadableResult.parse(_result) |
|
2344 |
+ assert result.clean_exit(empty_stderr=False) |
|
2345 |
+ |
|
2346 |
+ @stateful.rule() |
|
2347 |
+ def purge_all(self) -> None: |
|
2348 |
+ self.current_config = {'services': {}} |
|
2349 |
+ _result = self.runner.invoke( |
|
2350 |
+ cli.derivepassphrase_vault, |
|
2351 |
+ ['--clear'], |
|
2352 |
+ input='y', |
|
2353 |
+ catch_exceptions=False, |
|
2354 |
+ ) |
|
2355 |
+ result = tests.ReadableResult.parse(_result) |
|
2356 |
+ assert result.clean_exit(empty_stderr=False) |
|
2357 |
+ |
|
2358 |
+ @stateful.rule( |
|
2359 |
+ config=stateful.consumes(configurations), |
|
2360 |
+ ) |
|
2361 |
+ def import_configuraton(self, config: _types.VaultConfig) -> None: |
|
2362 |
+ self.current_config = self.fold_configs(config, self.current_config) |
|
2363 |
+ assert _types.is_vault_config(self.current_config) |
|
2364 |
+ _result = self.runner.invoke( |
|
2365 |
+ cli.derivepassphrase_vault, |
|
2366 |
+ ['--import', '-'], |
|
2367 |
+ input=json.dumps(config), |
|
2368 |
+ catch_exceptions=False, |
|
2369 |
+ ) |
|
2370 |
+ assert tests.ReadableResult.parse(_result).clean_exit( |
|
2371 |
+ empty_stderr=False |
|
2372 |
+ ) |
|
2373 |
+ |
|
2374 |
+ def teardown(self) -> None: |
|
2375 |
+ self.exit_stack.close() |
|
2376 |
+ |
|
2377 |
+ |
|
2378 |
+TestConfigMerging = ConfigMergingStateMachine.TestCase |
|
2103 | 2379 |