Repository navigation
Expand file tree
/
Copy pathtest_config_endpoint.py
More file actions
331 lines (276 loc) · 14.4 KB
/
Copy pathtest_config_endpoint.py
File metadata and controls
331 lines (276 loc) · 14.4 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
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
#!/usr/bin/env python3
"""
TDD regression tests for the webhook config endpoint bugs (C1-C5, C8) filed
by the team lead. Each test is written to assert CORRECT behavior; they are
red against the bugs and green once temp_monitor.py is fixed.
New file, owned by this agent -- does not modify any existing test file.
"""
import json
import unittest
import unittest.mock
# Sets BEARER_TOKEN and mocks sense_hat; MUST precede importing temp_monitor.
from test_support import BaseAPITestCase, run_fresh_import
import temp_monitor # noqa: E402
from webhook_service import WebhookService, WebhookConfig, AlertThresholds # noqa: E402
class ConfigEndpointTestCase(BaseAPITestCase):
"""Common setup shared by all endpoint tests below."""
def setUp(self):
super().setUp()
self._orig_webhook_service = temp_monitor.webhook_service
def tearDown(self):
temp_monitor.webhook_service = self._orig_webhook_service
def put_config(self, payload):
return self.client.put(
'/api/webhook/config',
data=json.dumps(payload),
content_type='application/json',
headers=self.auth_header,
)
class TestC1PartialThresholdUpdatePreservesOthers(ConfigEndpointTestCase):
"""C1: a partial threshold update must merge with the stored thresholds,
not wipe every omitted key to None."""
def test_updating_one_threshold_preserves_the_others(self):
temp_monitor.webhook_service = WebhookService(
webhook_config=WebhookConfig(url='https://hooks.slack.com/services/X'),
alert_thresholds=AlertThresholds(
temp_min_c=15.0, temp_max_c=32.0, humidity_min=20.0, humidity_max=70.0
),
)
response = self.put_config({'thresholds': {'temp_max_c': 30.0}})
self.assertEqual(response.status_code, 200)
t = temp_monitor.webhook_service.alert_thresholds
self.assertEqual(t.temp_max_c, 30.0, "the field actually sent should update")
self.assertEqual(t.temp_min_c, 15.0, "omitted field must be preserved, not wiped to None")
self.assertEqual(t.humidity_min, 20.0, "omitted field must be preserved, not wiped to None")
self.assertEqual(t.humidity_max, 70.0, "omitted field must be preserved, not wiped to None")
def test_explicit_null_still_clears_a_threshold(self):
"""Thresholds intentionally support explicit null-to-clear (see
api_models.validate_thresholds allow_null=True) -- the merge fix must
not break that by treating null the same as "omitted"."""
temp_monitor.webhook_service = WebhookService(
webhook_config=WebhookConfig(url='https://hooks.slack.com/services/X'),
alert_thresholds=AlertThresholds(
temp_min_c=15.0, temp_max_c=32.0, humidity_min=20.0, humidity_max=70.0
),
)
response = self.put_config({'thresholds': {'temp_min_c': None}})
self.assertEqual(response.status_code, 200)
t = temp_monitor.webhook_service.alert_thresholds
self.assertIsNone(t.temp_min_c, "explicit null should clear the field")
self.assertEqual(t.temp_max_c, 32.0, "other fields untouched")
class TestC2WebhookNullDoesNotClobber(ConfigEndpointTestCase):
"""C2: explicit JSON null on webhook fields must be rejected (400), and
must not silently clobber the stored config."""
def test_null_url_rejected_and_config_unchanged(self):
temp_monitor.webhook_service = WebhookService(
webhook_config=WebhookConfig(url='https://hooks.slack.com/services/ORIGINAL')
)
response = self.put_config({'webhook': {'url': None}})
self.assertEqual(response.status_code, 400)
self.assertEqual(
temp_monitor.webhook_service.webhook_config.url,
'https://hooks.slack.com/services/ORIGINAL',
"stored config must be unchanged after a rejected update",
)
def test_null_retry_count_rejected_and_config_unchanged(self):
temp_monitor.webhook_service = WebhookService(
webhook_config=WebhookConfig(url='https://hooks.slack.com/services/ORIGINAL', retry_count=4)
)
response = self.put_config({'webhook': {'retry_count': None}})
self.assertEqual(response.status_code, 400)
self.assertEqual(temp_monitor.webhook_service.webhook_config.retry_count, 4)
class TestC3ThresholdCrossFieldValidationIsWired(ConfigEndpointTestCase):
"""C3: validate_thresholds must be called with current_thresholds so a
partial update that creates min >= max against the STORED config is
rejected, not just when both keys are in the same payload."""
def test_partial_update_creating_min_greater_than_stored_max_is_rejected(self):
temp_monitor.webhook_service = WebhookService(
webhook_config=WebhookConfig(url='https://hooks.slack.com/services/X'),
alert_thresholds=AlertThresholds(temp_min_c=10.0, temp_max_c=32.0),
)
# Only temp_min_c sent, but 40 > stored temp_max_c (32) -> must be rejected.
response = self.put_config({'thresholds': {'temp_min_c': 40.0}})
self.assertEqual(response.status_code, 400)
data = json.loads(response.data)
self.assertIn('temp_min_c must be less than temp_max_c', data.get('message', ''))
# Stored config must be unchanged.
self.assertEqual(temp_monitor.webhook_service.alert_thresholds.temp_min_c, 10.0)
class TestC4EnvDefaultsApply(unittest.TestCase):
"""C4: documented env defaults (15.0/32.0/20.0/70.0) must actually apply
when the corresponding env var is unset, and non-numeric values must fail
at import with a clear message instead of a bare traceback."""
def _import_fresh(self, env_overrides, expect_success=True):
return run_fresh_import(
probe=(
"{"
"'has_webhook_service': tm.webhook_service is not None,"
"'temp_min_c': tm.webhook_service.alert_thresholds.temp_min_c if tm.webhook_service else None,"
"'temp_max_c': tm.webhook_service.alert_thresholds.temp_max_c if tm.webhook_service else None,"
"'humidity_min': tm.webhook_service.alert_thresholds.humidity_min if tm.webhook_service else None,"
"'humidity_max': tm.webhook_service.alert_thresholds.humidity_max if tm.webhook_service else None,"
"}"
),
env_overrides=env_overrides,
scrub=('ALERT_TEMP_MIN_C', 'ALERT_TEMP_MAX_C', 'ALERT_HUMIDITY_MIN',
'ALERT_HUMIDITY_MAX', 'SLACK_WEBHOOK_URL', 'STATUS_UPDATE_INTERVAL'),
expect_success=expect_success,
)
def test_unset_threshold_envs_use_documented_defaults(self):
state = self._import_fresh({
'SLACK_WEBHOOK_URL': 'https://hooks.slack.com/services/T00/B00/XXX',
'ALERT_TEMP_MAX_C': '40.0', # operator sets only ONE var
})
self.assertTrue(state['has_webhook_service'])
self.assertEqual(state['temp_max_c'], 40.0, "explicitly set var should apply")
self.assertEqual(state['temp_min_c'], 15.0, "documented default should apply, not None")
self.assertEqual(state['humidity_min'], 20.0, "documented default should apply, not None")
self.assertEqual(state['humidity_max'], 70.0, "documented default should apply, not None")
def test_non_numeric_status_update_interval_fails_clearly_at_import(self):
result = self._import_fresh(
{'STATUS_UPDATE_INTERVAL': 'abc'}, expect_success=False
)
self.assertNotEqual(result.returncode, 0, "import should fail, not silently continue")
self.assertIn(
"Invalid value for environment variable STATUS_UPDATE_INTERVAL='abc'",
result.stderr,
"error should clearly name the offending variable and its bad value, "
"not just an opaque 'invalid literal for int()' ValueError",
)
class TestC5ErrorResponsesReachClientIntact(ConfigEndpointTestCase):
"""C5: a 500 from the PUT handler must not be marshalled into nulls --
the error text and error_id must survive in the response body."""
def test_internal_error_response_contains_error_and_error_id(self):
temp_monitor.webhook_service = WebhookService(
webhook_config=WebhookConfig(url='https://hooks.slack.com/services/X')
)
# Force an internal error inside the try block.
with unittest.mock.patch.object(
temp_monitor.webhook_service, 'set_webhook_config',
side_effect=RuntimeError("boom")
):
response = self.put_config({'webhook': {'enabled': False}})
self.assertEqual(response.status_code, 500)
data = json.loads(response.data)
self.assertIsNotNone(
data.get('error') or data.get('message'),
f"error text must survive marshalling, got: {data}",
)
self.assertTrue(
data.get('error_id'),
f"error_id must survive marshalling for log correlation, got: {data}",
)
class TestFix3NullBodyReturns400(ConfigEndpointTestCase):
"""Fix 3: a literal JSON `null` body is valid JSON, so it passes
flask-restx's payload parsing and reaches the handler as data=None.
`'webhook' in data` then raised TypeError: argument of type 'NoneType'
is not a container -- and that check runs BEFORE the handler's own try
block, so the TypeError escaped as an unhandled 500 instead of a clean
400."""
def test_null_body_returns_400_not_500(self):
response = self.client.put(
'/api/webhook/config',
data='null',
content_type='application/json',
headers=self.auth_header,
)
self.assertEqual(response.status_code, 400)
def test_non_object_body_returns_400(self):
"""Same defect class: a JSON array/string/number body also isn't a
dict and must not reach `'webhook' in data`."""
response = self.client.put(
'/api/webhook/config',
data='[1, 2, 3]',
content_type='application/json',
headers=self.auth_header,
)
self.assertEqual(response.status_code, 400)
class TestFix6EmptyThresholdEnvImportsCleanly(unittest.TestCase):
"""Fix 6 / Fix 1 interaction: a set-but-empty ALERT_TEMP_MIN_C must
import cleanly and yield the documented default (15.0) rather than
crashing at import (Fix 1), and must not trip the new __post_init__
range validation on AlertThresholds (Fix 6) since the default is a
valid value."""
def test_empty_alert_temp_min_c_imports_cleanly_and_yields_default(self):
state = run_fresh_import(
probe=(
"{'temp_min_c': tm.webhook_service.alert_thresholds.temp_min_c "
"if tm.webhook_service else None}"
),
env_overrides={
'SLACK_WEBHOOK_URL': 'https://hooks.slack.com/services/T00/B00/XXX',
'ALERT_TEMP_MIN_C': '',
},
scrub=('ALERT_TEMP_MIN_C', 'SLACK_WEBHOOK_URL'),
)
self.assertEqual(state['temp_min_c'], 15.0)
class TestFix6InvalidRangeEnvConfigFailsLoudlyAtImport(unittest.TestCase):
"""Fix 6: env-derived WebhookConfig/AlertThresholds skipped RANGE
validation entirely (only _parse_env_number's TYPE check applied), so
WEBHOOK_RETRY_COUNT=0 (webhook loop never runs) or an inverted
ALERT_TEMP_MIN_C > ALERT_TEMP_MAX_C used to boot cleanly with no
warning. With webhook_service.WebhookConfig/AlertThresholds
__post_init__ validation (ConfigValidationError) now wired to a
try/except at module init, these must instead fail the import loudly
with a RuntimeError naming the offending env var(s).
"""
def test_inverted_temp_thresholds_raise_at_import(self):
result = run_fresh_import(
probe="{}",
env_overrides={
'SLACK_WEBHOOK_URL': 'https://hooks.slack.com/services/T00/B00/XXX',
'ALERT_TEMP_MIN_C': '40',
'ALERT_TEMP_MAX_C': '10',
},
scrub=('ALERT_TEMP_MIN_C', 'ALERT_TEMP_MAX_C', 'SLACK_WEBHOOK_URL'),
expect_success=False,
)
self.assertNotEqual(result.returncode, 0, "import should fail, not silently continue")
self.assertIn('ALERT_TEMP_MIN_C', result.stderr, "message should name the offending env var")
self.assertIn(
'temp_min_c must be less than temp_max_c', result.stderr,
"underlying validation reason should reach the operator",
)
def test_zero_retry_count_raises_at_import(self):
result = run_fresh_import(
probe="{}",
env_overrides={
'SLACK_WEBHOOK_URL': 'https://hooks.slack.com/services/T00/B00/XXX',
'WEBHOOK_RETRY_COUNT': '0',
},
scrub=('WEBHOOK_RETRY_COUNT', 'SLACK_WEBHOOK_URL'),
expect_success=False,
)
self.assertNotEqual(result.returncode, 0, "import should fail, not silently continue")
self.assertIn('WEBHOOK_RETRY_COUNT', result.stderr, "message should name the offending env var")
class TestC8RecreatedServicePreservesCooldown(ConfigEndpointTestCase):
"""C8: creating a webhook service for the first time via the API must use
the configured ALERT_COOLDOWN_SECONDS, not silently fall back to the
hardcoded 900s default."""
def test_cooldown_env_var_applies_when_service_created_via_api(self):
state = run_fresh_import(
probe=(
"{"
"'status': resp.status_code,"
"'cooldown': tm.webhook_service.alert_cooldown if tm.webhook_service else None,"
"}"
),
body=(
"client = tm.app.test_client()\n"
"token = os.environ['BEARER_TOKEN']\n"
"resp = client.put(\n"
" '/api/webhook/config',\n"
" data=json.dumps({'webhook': {'url': 'https://hooks.slack.com/services/T00/B00/XXX'}}),\n"
" content_type='application/json',\n"
" headers={'Authorization': f'Bearer {token}'},\n"
")\n"
),
env_overrides={'ALERT_COOLDOWN_SECONDS': '120'},
scrub=('SLACK_WEBHOOK_URL',),
)
self.assertEqual(state['status'], 200)
self.assertEqual(
state['cooldown'], 120,
"newly-created webhook service should honor ALERT_COOLDOWN_SECONDS, not default to 900",
)
if __name__ == '__main__':
unittest.main(verbosity=2)