Marco Ricci commited on 2024-12-19 15:04:11
Zeige 2 geänderte Dateien mit 247 Einfügungen und 31 Löschungen.
Instead of storing Unicode normalization preferences in the vault
configuration file, store these settings in the user configuration file:
* For a service `SERVICE`, consult the configuration key
`vault.SERVICE.unicode-normalization-form`.
* If that is unset, or if there is no service, use
`vault.default-unicode-normalization-form`.
* If that too is unset, default to `NFC`.
| ... | ... |
@@ -1247,25 +1247,69 @@ def _prompt_for_passphrase() -> str: |
| 1247 | 1247 |
) |
| 1248 | 1248 |
|
| 1249 | 1249 |
|
| 1250 |
+def _toml_key(*parts: str) -> str: |
|
| 1251 |
+ """Return a formatted TOML key, given its parts.""" |
|
| 1252 |
+ def escape(string: str) -> str: |
|
| 1253 |
+ translated = string.translate({
|
|
| 1254 |
+ 0: r'\u0000', |
|
| 1255 |
+ 1: r'\u0001', |
|
| 1256 |
+ 2: r'\u0002', |
|
| 1257 |
+ 3: r'\u0003', |
|
| 1258 |
+ 4: r'\u0004', |
|
| 1259 |
+ 5: r'\u0005', |
|
| 1260 |
+ 6: r'\u0006', |
|
| 1261 |
+ 7: r'\u0007', |
|
| 1262 |
+ 8: r'\b', |
|
| 1263 |
+ 9: r'\t', |
|
| 1264 |
+ 10: r'\n', |
|
| 1265 |
+ 11: r'\u000B', |
|
| 1266 |
+ 12: r'\f', |
|
| 1267 |
+ 13: r'\r', |
|
| 1268 |
+ 14: r'\u000E', |
|
| 1269 |
+ 15: r'\u000F', |
|
| 1270 |
+ ord('"'): r'\"',
|
|
| 1271 |
+ ord('\\'): r'\\',
|
|
| 1272 |
+ 127: r'\u007F', |
|
| 1273 |
+ }) |
|
| 1274 |
+ return f'"{translated}"' if translated != string else string
|
|
| 1275 |
+ return '.'.join(map(escape, parts)) |
|
| 1276 |
+ |
|
| 1277 |
+ |
|
| 1250 | 1278 |
class _ORIGIN(enum.Enum): |
| 1251 |
- INTERACTIVE: str = 'interactive' |
|
| 1279 |
+ INTERACTIVE: str = 'interactive input' |
|
| 1252 | 1280 |
|
| 1253 | 1281 |
|
| 1254 | 1282 |
def _check_for_misleading_passphrase( |
| 1255 | 1283 |
key: tuple[str, ...] | _ORIGIN, |
| 1256 | 1284 |
value: dict[str, Any], |
| 1257 | 1285 |
*, |
| 1258 |
- form: Literal['NFC', 'NFD', 'NFKC', 'NFKD'] = 'NFC', |
|
| 1286 |
+ main_config: dict[str, Any], |
|
| 1259 | 1287 |
) -> None: |
| 1288 |
+ form_key = 'unicode-normalization-form' |
|
| 1289 |
+ default_form: str = main_config.get('vault', {}).get(
|
|
| 1290 |
+ f'default-{form_key}', 'NFC'
|
|
| 1291 |
+ ) |
|
| 1292 |
+ form_dict: dict[str, dict] = main_config.get('vault', {}).get(form_key, {})
|
|
| 1293 |
+ form: Any = ( |
|
| 1294 |
+ default_form |
|
| 1295 |
+ if isinstance(key, _ORIGIN) or key == ('global',)
|
|
| 1296 |
+ else form_dict.get(key[1], default_form) |
|
| 1297 |
+ ) |
|
| 1298 |
+ config_key = ( |
|
| 1299 |
+ _toml_key('vault', key[1], form_key)
|
|
| 1300 |
+ if isinstance(key, tuple) and len(key) > 1 and key[1] in form_dict |
|
| 1301 |
+ else f'vault.default-{form_key}'
|
|
| 1302 |
+ ) |
|
| 1303 |
+ if form not in {'NFC', 'NFD', 'NFKC', 'NFKD'}:
|
|
| 1304 |
+ msg = f'Invalid value {form!r} for config key {config_key}'
|
|
| 1305 |
+ raise AssertionError(msg) |
|
| 1260 | 1306 |
logger = logging.getLogger(PROG_NAME) |
| 1307 |
+ formatted_key = ( |
|
| 1308 |
+ key.value if isinstance(key, _ORIGIN) else _types.json_path(key) |
|
| 1309 |
+ ) |
|
| 1261 | 1310 |
if 'phrase' in value: |
| 1262 | 1311 |
phrase = value['phrase'] |
| 1263 | 1312 |
if not unicodedata.is_normalized(form, phrase): |
| 1264 |
- formatted_key = ( |
|
| 1265 |
- key.value |
|
| 1266 |
- if isinstance(key, _ORIGIN) |
|
| 1267 |
- else _types.json_path(key) |
|
| 1268 |
- ) |
|
| 1269 | 1313 |
logger.warning( |
| 1270 | 1314 |
( |
| 1271 | 1315 |
'the %s passphrase is not %s-normalized. ' |
| ... | ... |
@@ -1274,6 +1318,7 @@ def _check_for_misleading_passphrase( |
| 1274 | 1318 |
), |
| 1275 | 1319 |
formatted_key, |
| 1276 | 1320 |
form, |
| 1321 |
+ stacklevel=2, |
|
| 1277 | 1322 |
) |
| 1278 | 1323 |
|
| 1279 | 1324 |
|
| ... | ... |
@@ -1952,24 +1997,20 @@ def derivepassphrase_vault( # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915 |
| 1952 | 1997 |
), |
| 1953 | 1998 |
PROG_NAME, |
| 1954 | 1999 |
) |
| 1955 |
- form = cast( |
|
| 1956 |
- Literal['NFC', 'NFD', 'NFKC', 'NFKD'], |
|
| 1957 |
- maybe_config.get('global', {}).get(
|
|
| 1958 |
- 'unicode_normalization_form', 'NFC' |
|
| 1959 |
- ), |
|
| 1960 |
- ) |
|
| 1961 |
- assert form in {'NFC', 'NFD', 'NFKC', 'NFKD'}
|
|
| 2000 |
+ try: |
|
| 1962 | 2001 |
_check_for_misleading_passphrase( |
| 1963 | 2002 |
('global',),
|
| 1964 | 2003 |
cast(dict[str, Any], maybe_config.get('global', {})),
|
| 1965 |
- form=form, |
|
| 2004 |
+ main_config=user_config, |
|
| 1966 | 2005 |
) |
| 1967 | 2006 |
for key, value in maybe_config['services'].items(): |
| 1968 | 2007 |
_check_for_misleading_passphrase( |
| 1969 | 2008 |
('services', key),
|
| 1970 | 2009 |
cast(dict[str, Any], value), |
| 1971 |
- form=form, |
|
| 2010 |
+ main_config=user_config, |
|
| 1972 | 2011 |
) |
| 2012 |
+ except AssertionError as e: |
|
| 2013 |
+ err('The configuration file is invalid. ' + str(e))
|
|
| 1973 | 2014 |
if overwrite_config: |
| 1974 | 2015 |
put_config(maybe_config) |
| 1975 | 2016 |
else: |
| ... | ... |
@@ -2081,10 +2122,14 @@ def derivepassphrase_vault( # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915 |
| 2081 | 2122 |
elif use_phrase: |
| 2082 | 2123 |
view['phrase'] = phrase |
| 2083 | 2124 |
settings_type = 'service' if service else 'global' |
| 2125 |
+ try: |
|
| 2084 | 2126 |
_check_for_misleading_passphrase( |
| 2085 | 2127 |
('services', service) if service else ('global',),
|
| 2086 | 2128 |
{'phrase': phrase},
|
| 2129 |
+ main_config=user_config, |
|
| 2087 | 2130 |
) |
| 2131 |
+ except AssertionError as e: |
|
| 2132 |
+ err('The configuration file is invalid. ' + str(e))
|
|
| 2088 | 2133 |
if 'key' in settings: |
| 2089 | 2134 |
logger.warning( |
| 2090 | 2135 |
( |
| ... | ... |
@@ -2123,16 +2168,14 @@ def derivepassphrase_vault( # noqa: C901,PLR0912,PLR0913,PLR0914,PLR0915 |
| 2123 | 2168 |
} |
| 2124 | 2169 |
|
| 2125 | 2170 |
if use_phrase: |
| 2126 |
- form = cast( |
|
| 2127 |
- Literal['NFC', 'NFD', 'NFKC', 'NFKD'], |
|
| 2128 |
- configuration.get('global', {}).get(
|
|
| 2129 |
- 'unicode_normalization_form', 'NFC' |
|
| 2130 |
- ), |
|
| 2131 |
- ) |
|
| 2132 |
- assert form in {'NFC', 'NFD', 'NFKC', 'NFKD'}
|
|
| 2171 |
+ try: |
|
| 2133 | 2172 |
_check_for_misleading_passphrase( |
| 2134 |
- _ORIGIN.INTERACTIVE, {'phrase': phrase}, form=form
|
|
| 2173 |
+ _ORIGIN.INTERACTIVE, |
|
| 2174 |
+ {'phrase': phrase},
|
|
| 2175 |
+ main_config=user_config, |
|
| 2135 | 2176 |
) |
| 2177 |
+ except AssertionError as e: |
|
| 2178 |
+ err('The configuration file is invalid. ' + str(e))
|
|
| 2136 | 2179 |
|
| 2137 | 2180 |
# If either --key or --phrase are given, use that setting. |
| 2138 | 2181 |
# Otherwise, if both key and phrase are set in the config, |
| ... | ... |
@@ -12,6 +12,7 @@ import logging |
| 12 | 12 |
import os |
| 13 | 13 |
import shutil |
| 14 | 14 |
import socket |
| 15 |
+import textwrap |
|
| 15 | 16 |
import warnings |
| 16 | 17 |
from typing import TYPE_CHECKING |
| 17 | 18 |
|
| ... | ... |
@@ -1414,9 +1415,10 @@ contents go here |
| 1414 | 1415 |
), 'expected error exit and known error message' |
| 1415 | 1416 |
|
| 1416 | 1417 |
@pytest.mark.parametrize( |
| 1417 |
- ['command_line', 'input', 'warning_message'], |
|
| 1418 |
+ ['main_config', 'command_line', 'input', 'warning_message'], |
|
| 1418 | 1419 |
[ |
| 1419 | 1420 |
pytest.param( |
| 1421 |
+ '', |
|
| 1420 | 1422 |
['--import', '-'], |
| 1421 | 1423 |
json.dumps({
|
| 1422 | 1424 |
'global': {'phrase': 'Du\u0308sseldorf'},
|
| ... | ... |
@@ -1426,6 +1428,7 @@ contents go here |
| 1426 | 1428 |
id='global-NFC', |
| 1427 | 1429 |
), |
| 1428 | 1430 |
pytest.param( |
| 1431 |
+ '', |
|
| 1429 | 1432 |
['--import', '-'], |
| 1430 | 1433 |
json.dumps({
|
| 1431 | 1434 |
'services': {
|
| ... | ... |
@@ -1440,6 +1443,7 @@ contents go here |
| 1440 | 1443 |
id='service-weird-name-NFC', |
| 1441 | 1444 |
), |
| 1442 | 1445 |
pytest.param( |
| 1446 |
+ '', |
|
| 1443 | 1447 |
['--config', '-p', '--', DUMMY_SERVICE], |
| 1444 | 1448 |
'Du\u0308sseldorf', |
| 1445 | 1449 |
( |
| ... | ... |
@@ -1449,16 +1453,20 @@ contents go here |
| 1449 | 1453 |
id='config-NFC', |
| 1450 | 1454 |
), |
| 1451 | 1455 |
pytest.param( |
| 1456 |
+ '', |
|
| 1452 | 1457 |
['-p', '--', DUMMY_SERVICE], |
| 1453 | 1458 |
'Du\u0308sseldorf', |
| 1454 |
- 'the interactive passphrase is not NFC-normalized', |
|
| 1459 |
+ 'the interactive input passphrase is not NFC-normalized', |
|
| 1455 | 1460 |
id='direct-input-NFC', |
| 1456 | 1461 |
), |
| 1457 | 1462 |
pytest.param( |
| 1463 |
+ textwrap.dedent(r""" |
|
| 1464 |
+ [vault] |
|
| 1465 |
+ default-unicode-normalization-form = 'NFD' |
|
| 1466 |
+ """), |
|
| 1458 | 1467 |
['--import', '-'], |
| 1459 | 1468 |
json.dumps({
|
| 1460 | 1469 |
'global': {
|
| 1461 |
- 'unicode_normalization_form': 'NFD', |
|
| 1462 | 1470 |
'phrase': 'D\u00fcsseldorf', |
| 1463 | 1471 |
}, |
| 1464 | 1472 |
'services': {},
|
| ... | ... |
@@ -1467,11 +1475,12 @@ contents go here |
| 1467 | 1475 |
id='global-NFD', |
| 1468 | 1476 |
), |
| 1469 | 1477 |
pytest.param( |
| 1478 |
+ textwrap.dedent(r""" |
|
| 1479 |
+ [vault] |
|
| 1480 |
+ default-unicode-normalization-form = 'NFD' |
|
| 1481 |
+ """), |
|
| 1470 | 1482 |
['--import', '-'], |
| 1471 | 1483 |
json.dumps({
|
| 1472 |
- 'global': {
|
|
| 1473 |
- 'unicode_normalization_form': 'NFD', |
|
| 1474 |
- }, |
|
| 1475 | 1484 |
'services': {
|
| 1476 | 1485 |
DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), |
| 1477 | 1486 |
'weird entry name': {'phrase': 'D\u00fcsseldorf'},
|
| ... | ... |
@@ -1483,12 +1492,32 @@ contents go here |
| 1483 | 1492 |
), |
| 1484 | 1493 |
id='service-weird-name-NFD', |
| 1485 | 1494 |
), |
| 1495 |
+ pytest.param( |
|
| 1496 |
+ textwrap.dedent(r""" |
|
| 1497 |
+ [vault.unicode-normalization-form] |
|
| 1498 |
+ 'weird entry name 2' = 'NFKD' |
|
| 1499 |
+ """), |
|
| 1500 |
+ ['--import', '-'], |
|
| 1501 |
+ json.dumps({
|
|
| 1502 |
+ 'services': {
|
|
| 1503 |
+ DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), |
|
| 1504 |
+ 'weird entry name 1': {'phrase': 'D\u00fcsseldorf'},
|
|
| 1505 |
+ 'weird entry name 2': {'phrase': 'D\u00fcsseldorf'},
|
|
| 1506 |
+ }, |
|
| 1507 |
+ }), |
|
| 1508 |
+ ( |
|
| 1509 |
+ 'the $.services["weird entry name 2"] passphrase ' |
|
| 1510 |
+ 'is not NFKD-normalized' |
|
| 1511 |
+ ), |
|
| 1512 |
+ id='service-weird-name-2-NFKD', |
|
| 1513 |
+ ), |
|
| 1486 | 1514 |
], |
| 1487 | 1515 |
) |
| 1488 | 1516 |
def test_300_unicode_normalization_form_warning( |
| 1489 | 1517 |
self, |
| 1490 | 1518 |
monkeypatch: pytest.MonkeyPatch, |
| 1491 | 1519 |
caplog: pytest.LogCaptureFixture, |
| 1520 |
+ main_config: str, |
|
| 1492 | 1521 |
command_line: list[str], |
| 1493 | 1522 |
input: str | None, |
| 1494 | 1523 |
warning_message: str, |
| ... | ... |
@@ -1500,10 +1529,11 @@ contents go here |
| 1500 | 1529 |
vault_config={
|
| 1501 | 1530 |
'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}
|
| 1502 | 1531 |
}, |
| 1532 |
+ main_config_str=main_config, |
|
| 1503 | 1533 |
): |
| 1504 | 1534 |
_result = runner.invoke( |
| 1505 | 1535 |
cli.derivepassphrase_vault, |
| 1506 |
- command_line, |
|
| 1536 |
+ ['--debug', *command_line], |
|
| 1507 | 1537 |
catch_exceptions=False, |
| 1508 | 1538 |
input=input, |
| 1509 | 1539 |
) |
| ... | ... |
@@ -1513,6 +1543,149 @@ contents go here |
| 1513 | 1543 |
warning_message, caplog.record_tuples |
| 1514 | 1544 |
), 'expected known warning message in stderr' |
| 1515 | 1545 |
|
| 1546 |
+ @pytest.mark.parametrize( |
|
| 1547 |
+ ['main_config', 'command_line', 'input', 'error_message'], |
|
| 1548 |
+ [ |
|
| 1549 |
+ pytest.param( |
|
| 1550 |
+ textwrap.dedent(r""" |
|
| 1551 |
+ [vault] |
|
| 1552 |
+ default-unicode-normalization-form = 'XXX' |
|
| 1553 |
+ """), |
|
| 1554 |
+ ['--import', '-'], |
|
| 1555 |
+ json.dumps({
|
|
| 1556 |
+ 'services': {
|
|
| 1557 |
+ DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), |
|
| 1558 |
+ 'with_normalization': {'phrase': 'D\u00fcsseldorf'},
|
|
| 1559 |
+ }, |
|
| 1560 |
+ }), |
|
| 1561 |
+ ( |
|
| 1562 |
+ "Invalid value 'XXX' for config key " |
|
| 1563 |
+ "vault.default-unicode-normalization-form" |
|
| 1564 |
+ ), |
|
| 1565 |
+ id='global', |
|
| 1566 |
+ ), |
|
| 1567 |
+ pytest.param( |
|
| 1568 |
+ textwrap.dedent(r""" |
|
| 1569 |
+ [vault.unicode-normalization-form] |
|
| 1570 |
+ with_normalization = 'XXX' |
|
| 1571 |
+ """), |
|
| 1572 |
+ ['--import', '-'], |
|
| 1573 |
+ json.dumps({
|
|
| 1574 |
+ 'services': {
|
|
| 1575 |
+ DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy(), |
|
| 1576 |
+ 'with_normalization': {'phrase': 'D\u00fcsseldorf'},
|
|
| 1577 |
+ }, |
|
| 1578 |
+ }), |
|
| 1579 |
+ ( |
|
| 1580 |
+ "Invalid value 'XXX' for config key " |
|
| 1581 |
+ "vault.with_normalization.unicode-normalization-form" |
|
| 1582 |
+ ), |
|
| 1583 |
+ id='service', |
|
| 1584 |
+ ), |
|
| 1585 |
+ ], |
|
| 1586 |
+ ) |
|
| 1587 |
+ def test_301_unicode_normalization_form_error( |
|
| 1588 |
+ self, |
|
| 1589 |
+ monkeypatch: pytest.MonkeyPatch, |
|
| 1590 |
+ main_config: str, |
|
| 1591 |
+ command_line: list[str], |
|
| 1592 |
+ input: str | None, |
|
| 1593 |
+ error_message: str, |
|
| 1594 |
+ ) -> None: |
|
| 1595 |
+ runner = click.testing.CliRunner(mix_stderr=False) |
|
| 1596 |
+ with tests.isolated_vault_config( |
|
| 1597 |
+ monkeypatch=monkeypatch, |
|
| 1598 |
+ runner=runner, |
|
| 1599 |
+ vault_config={
|
|
| 1600 |
+ 'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}
|
|
| 1601 |
+ }, |
|
| 1602 |
+ main_config_str=main_config, |
|
| 1603 |
+ ): |
|
| 1604 |
+ _result = runner.invoke( |
|
| 1605 |
+ cli.derivepassphrase_vault, |
|
| 1606 |
+ command_line, |
|
| 1607 |
+ catch_exceptions=False, |
|
| 1608 |
+ input=input, |
|
| 1609 |
+ ) |
|
| 1610 |
+ result = tests.ReadableResult.parse(_result) |
|
| 1611 |
+ assert result.error_exit( |
|
| 1612 |
+ error='The configuration file is invalid.' |
|
| 1613 |
+ ), 'expected error exit and known error message' |
|
| 1614 |
+ assert result.error_exit( |
|
| 1615 |
+ error=error_message |
|
| 1616 |
+ ), 'expected error exit and known error message' |
|
| 1617 |
+ |
|
| 1618 |
+ @pytest.mark.parametrize( |
|
| 1619 |
+ 'command_line', |
|
| 1620 |
+ [ |
|
| 1621 |
+ pytest.param( |
|
| 1622 |
+ ['--config', '--phrase'], |
|
| 1623 |
+ id='configure global passphrase', |
|
| 1624 |
+ ), |
|
| 1625 |
+ pytest.param( |
|
| 1626 |
+ ['--phrase', '--', DUMMY_SERVICE], |
|
| 1627 |
+ id='interactive passphrase', |
|
| 1628 |
+ ), |
|
| 1629 |
+ ], |
|
| 1630 |
+ ) |
|
| 1631 |
+ def test_301a_unicode_normalization_form_error_from_stored_config( |
|
| 1632 |
+ self, |
|
| 1633 |
+ monkeypatch: pytest.MonkeyPatch, |
|
| 1634 |
+ command_line: list[str], |
|
| 1635 |
+ ) -> None: |
|
| 1636 |
+ runner = click.testing.CliRunner(mix_stderr=False) |
|
| 1637 |
+ with tests.isolated_vault_config( |
|
| 1638 |
+ monkeypatch=monkeypatch, |
|
| 1639 |
+ runner=runner, |
|
| 1640 |
+ vault_config={
|
|
| 1641 |
+ 'services': {DUMMY_SERVICE: DUMMY_CONFIG_SETTINGS.copy()}
|
|
| 1642 |
+ }, |
|
| 1643 |
+ main_config_str=textwrap.dedent("""
|
|
| 1644 |
+ [vault] |
|
| 1645 |
+ default-unicode-normalization-form = 'XXX' |
|
| 1646 |
+ """), |
|
| 1647 |
+ ): |
|
| 1648 |
+ _result = runner.invoke( |
|
| 1649 |
+ cli.derivepassphrase_vault, |
|
| 1650 |
+ command_line, |
|
| 1651 |
+ input=DUMMY_PASSPHRASE, |
|
| 1652 |
+ catch_exceptions=False, |
|
| 1653 |
+ ) |
|
| 1654 |
+ result = tests.ReadableResult.parse(_result) |
|
| 1655 |
+ assert result.error_exit( |
|
| 1656 |
+ error='The configuration file is invalid.' |
|
| 1657 |
+ ), 'expected error exit and known error message' |
|
| 1658 |
+ assert result.error_exit( |
|
| 1659 |
+ error=( |
|
| 1660 |
+ "Invalid value 'XXX' for config key " |
|
| 1661 |
+ "vault.default-unicode-normalization-form" |
|
| 1662 |
+ ), |
|
| 1663 |
+ ), 'expected error exit and known error message' |
|
| 1664 |
+ |
|
| 1665 |
+ def test_310_bad_user_config_file( |
|
| 1666 |
+ self, |
|
| 1667 |
+ monkeypatch: pytest.MonkeyPatch, |
|
| 1668 |
+ ) -> None: |
|
| 1669 |
+ runner = click.testing.CliRunner(mix_stderr=False) |
|
| 1670 |
+ with tests.isolated_vault_config( |
|
| 1671 |
+ monkeypatch=monkeypatch, |
|
| 1672 |
+ runner=runner, |
|
| 1673 |
+ vault_config={'services': {}},
|
|
| 1674 |
+ main_config_str=textwrap.dedent("""
|
|
| 1675 |
+ This file is not valid TOML. |
|
| 1676 |
+ """), |
|
| 1677 |
+ ): |
|
| 1678 |
+ _result = runner.invoke( |
|
| 1679 |
+ cli.derivepassphrase_vault, |
|
| 1680 |
+ ['--phrase', '--', DUMMY_SERVICE], |
|
| 1681 |
+ input=DUMMY_PASSPHRASE, |
|
| 1682 |
+ catch_exceptions=False, |
|
| 1683 |
+ ) |
|
| 1684 |
+ result = tests.ReadableResult.parse(_result) |
|
| 1685 |
+ assert result.error_exit( |
|
| 1686 |
+ error='Cannot load user config:' |
|
| 1687 |
+ ), 'expected error exit and known error message' |
|
| 1688 |
+ |
|
| 1516 | 1689 |
def test_400_missing_af_unix_support( |
| 1517 | 1690 |
self, |
| 1518 | 1691 |
monkeypatch: pytest.MonkeyPatch, |
| 1519 | 1692 |