-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathtest_enum_forward_compat.py
More file actions
136 lines (119 loc) · 5.58 KB
/
Copy pathtest_enum_forward_compat.py
File metadata and controls
136 lines (119 loc) · 5.58 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
"""Package-wide invariant: API-sourced enums must tolerate unknown values.
This generalizes two point fixes. Issue #78 (an unrecognized ``SocketCategory``)
and the unrecognized ``generic`` purl type were the same bug: the Socket
API added an enum value, the SDK coerced it strictly inside ``from_dict``, and
the resulting ``ValueError`` emptied an entire response instead of degrading one
field. Each was fixed on the one enum that happened to fire, leaving the others
holding the same landmine.
Rather than add a third bespoke regression test the next time it happens, this
discovers every ``Enum`` in the package -- including ones added after this file
was written -- and asserts the invariant directly. A new enum has to opt out
explicitly and say why.
"""
import enum
import importlib
import logging
import pkgutil
import unittest
import socketdev
# Enums that are only ever used to *build* requests, never to parse a response.
# Strictness is correct there: a bad value is the caller's typo and should raise
# rather than be silently coerced. Add an entry only with a comment justifying
# that the enum never sees API-supplied values.
REQUEST_ONLY_ENUMS = frozenset(
{
# Only ever travels outbound: FullScanParams.to_dict() is urlencoded
# onto the create-scan query string. It never parses an API response, so
# an unrecognized value is a caller typo that should surface at
# construction rather than reach the API as scan_type=unknown.
"ScanType",
}
)
# A value the API will never legitimately send.
SENTINEL = "__value_the_api_would_never_send__"
def _all_enums():
"""Every Enum subclass defined under the socketdev package."""
found = {}
for module_info in pkgutil.walk_packages(
socketdev.__path__, prefix="socketdev."
):
try:
module = importlib.import_module(module_info.name)
except Exception: # pragma: no cover - an unimportable module is its own bug
continue
for name in dir(module):
obj = getattr(module, name)
if (
isinstance(obj, type)
and issubclass(obj, enum.Enum)
and obj.__module__.startswith("socketdev")
and len(obj) > 0
):
found[f"{obj.__module__}.{obj.__name__}"] = obj
return found
class TestEnumForwardCompatibility(unittest.TestCase):
"""Every response-parsed enum degrades instead of raising."""
def test_enums_are_discovered(self):
# Guards against the discovery walk silently finding nothing, which
# would make every other test in this file vacuously pass.
self.assertGreaterEqual(
len(_all_enums()), 6, "enum discovery found suspiciously few enums"
)
def test_unknown_value_does_not_raise(self):
for qualname, enum_cls in sorted(_all_enums().items()):
if enum_cls.__name__ in REQUEST_ONLY_ENUMS:
continue
with self.subTest(enum=qualname):
try:
result = enum_cls(SENTINEL)
except ValueError:
self.fail(
f"{qualname} raised ValueError on an unrecognized value. "
f"Add a _missing_ that returns a documented fallback "
f"(see socketdev/core/enums.py), or add it to "
f"REQUEST_ONLY_ENUMS with a justification."
)
self.assertIsInstance(
result,
enum_cls,
f"{qualname}._missing_ must return a member of its own enum",
)
def test_unknown_value_warns(self):
# The fallback is a silent downgrade in accuracy, so it has to leave a
# trace that something drifted.
for qualname, enum_cls in sorted(_all_enums().items()):
if enum_cls.__name__ in REQUEST_ONLY_ENUMS:
continue
with self.subTest(enum=qualname):
with self.assertLogs("socketdev", level=logging.WARNING) as captured:
enum_cls(SENTINEL)
self.assertTrue(
any(enum_cls.__name__ in line for line in captured.output),
f"{qualname} fell back without naming itself in the warning; "
f"got: {captured.output}",
)
def test_request_only_enums_stay_strict(self):
# The opt-out is not a "skip this one" marker: these enums must actively
# keep raising. Coercing a caller's typo to a fallback would send the
# fallback to the API instead of failing at construction, which is how
# the forward-compat change first got ScanType wrong.
by_name = {cls.__name__: cls for cls in _all_enums().values()}
for name in sorted(REQUEST_ONLY_ENUMS):
with self.subTest(enum=name):
enum_cls = by_name.get(name)
self.assertIsNotNone(
enum_cls, f"{name} is exempted but no longer exists"
)
with self.assertRaises(
ValueError,
msg=f"{name} is request-only and must reject unknown values",
):
enum_cls(SENTINEL)
def test_known_values_still_round_trip(self):
# Forward-compat must not swallow legitimate values.
for qualname, enum_cls in sorted(_all_enums().items()):
for member in enum_cls:
with self.subTest(enum=qualname, member=member.name):
self.assertIs(enum_cls(member.value), member)
if __name__ == "__main__":
unittest.main()