ocmo-api 0.8.19__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. LICENSE +201 -0
  2. NOTICE +16 -0
  3. core/__init__.py +0 -0
  4. core/api/__init__.py +201 -0
  5. core/api/_common.py +37 -0
  6. core/api/audit.py +244 -0
  7. core/api/auth.py +50 -0
  8. core/api/config.py +96 -0
  9. core/api/errors.py +65 -0
  10. core/api/global_permissions.py +107 -0
  11. core/api/lock.py +99 -0
  12. core/api/namespace.py +86 -0
  13. core/api/propagation.py +29 -0
  14. core/api/resolve.py +229 -0
  15. core/api/resolver.py +96 -0
  16. core/api/secret.py +53 -0
  17. core/api/system.py +42 -0
  18. core/api/template.py +53 -0
  19. core/api/tree.py +274 -0
  20. core/apps.py +18 -0
  21. core/audit_operations.py +0 -0
  22. core/constants/__init__.py +5 -0
  23. core/constants/audit_operations.py +35 -0
  24. core/constants/permission_actions.py +59 -0
  25. core/constants/permissions.py +41 -0
  26. core/constants/resolve.py +3 -0
  27. core/constants/transformers.py +22 -0
  28. core/constants/webhook_events.py +31 -0
  29. core/data/builtin_schemas/_git_sync.schema.yaml +24 -0
  30. core/data/builtin_schemas/_permissions.schema.yaml +211 -0
  31. core/data/builtin_schemas/_webhooks.schema.yaml +145 -0
  32. core/decorators/__init__.py +14 -0
  33. core/decorators/audit.py +105 -0
  34. core/decorators/binding.py +49 -0
  35. core/decorators/permissions.py +129 -0
  36. core/decorators/webhook.py +87 -0
  37. core/exceptions.py +165 -0
  38. core/management/__init__.py +0 -0
  39. core/management/commands/__init__.py +0 -0
  40. core/management/commands/dump_openapi.py +40 -0
  41. core/managers/artifacts.py +346 -0
  42. core/managers/audit/__init__.py +1172 -0
  43. core/managers/audit/timeline.py +365 -0
  44. core/managers/auth.py +276 -0
  45. core/managers/cast.py +506 -0
  46. core/managers/config_validation.py +216 -0
  47. core/managers/crypto.py +78 -0
  48. core/managers/global_permissions.py +183 -0
  49. core/managers/health.py +56 -0
  50. core/managers/lock.py +229 -0
  51. core/managers/namespace.py +132 -0
  52. core/managers/permissions.py +168 -0
  53. core/managers/propagation.py +244 -0
  54. core/managers/resolution.py +938 -0
  55. core/managers/resolve_cache.py +266 -0
  56. core/managers/resolve_parameters.py +503 -0
  57. core/managers/resolver.py +56 -0
  58. core/managers/resolver_tokens.py +114 -0
  59. core/managers/resolving.py +673 -0
  60. core/managers/secret.py +147 -0
  61. core/managers/system.py +44 -0
  62. core/managers/tree/__init__.py +85 -0
  63. core/managers/tree/_common.py +85 -0
  64. core/managers/tree/base.py +203 -0
  65. core/managers/tree/config_ops.py +167 -0
  66. core/managers/tree/constants.py +11 -0
  67. core/managers/tree/create.py +75 -0
  68. core/managers/tree/diff.py +270 -0
  69. core/managers/tree/mutate.py +433 -0
  70. core/managers/tree/paths.py +49 -0
  71. core/managers/tree/propagation.py +200 -0
  72. core/managers/tree/read.py +263 -0
  73. core/managers/tree/resolver_ops.py +21 -0
  74. core/managers/tree/tags.py +167 -0
  75. core/managers/tree/update.py +137 -0
  76. core/managers/tree/versions.py +87 -0
  77. core/managers/tree_capabilities.py +189 -0
  78. core/managers/webhook/__init__.py +3 -0
  79. core/managers/webhook/cache.py +68 -0
  80. core/managers/webhook/main.py +357 -0
  81. core/middleware.py +32 -0
  82. core/migrations/0001_initial.py +157 -0
  83. core/migrations/0002_folder.py +33 -0
  84. core/migrations/0003_alter_namespace_name.py +17 -0
  85. core/migrations/0004_alter_namespace_name_and_more.py +25 -0
  86. core/migrations/0005_configversion_templateversion.py +85 -0
  87. core/migrations/0006_alter_templateversion_options_and_more.py +47 -0
  88. core/migrations/0007_configversion_deleted_at_templateversion_deleted_at.py +22 -0
  89. core/migrations/0008_namespace_tags_dek_secret_global_permissions.py +139 -0
  90. core/migrations/0009_tree_lock.py +58 -0
  91. core/migrations/0010_resolver_token_encryption.py +75 -0
  92. core/migrations/0011_audit_event.py +134 -0
  93. core/migrations/0012_audit_event_namespace_name.py +24 -0
  94. core/migrations/0013_audit_event_object_id.py +28 -0
  95. core/migrations/0014_sync_permissions_webhooks_builtin_schemas.py +26 -0
  96. core/migrations/0015_audit_event_operation_subresource.py +27 -0
  97. core/migrations/0016_sync_permissions_schema_audit_actions.py +26 -0
  98. core/migrations/0017_sync_permissions_schema_valid_actions.py +26 -0
  99. core/migrations/__init__.py +0 -0
  100. core/models/__init__.py +9 -0
  101. core/models/audit.py +97 -0
  102. core/models/config.py +132 -0
  103. core/models/folder.py +12 -0
  104. core/models/global_permissions.py +27 -0
  105. core/models/lock.py +35 -0
  106. core/models/namespace.py +45 -0
  107. core/models/resolver.py +25 -0
  108. core/models/secret.py +58 -0
  109. core/models/tree.py +29 -0
  110. core/notice.py +18 -0
  111. core/operation_ids.py +153 -0
  112. core/schemas/__init__.py +2 -0
  113. core/schemas/audit.py +75 -0
  114. core/schemas/cast_json_schema.py +57 -0
  115. core/schemas/cast_options.py +508 -0
  116. core/schemas/generic.py +233 -0
  117. core/schemas/metadata_json_schema.py +21 -0
  118. core/schemas/propagation.py +88 -0
  119. core/schemas/requests.py +773 -0
  120. core/schemas/resolver_json_schema.py +65 -0
  121. core/schemas/responses.py +690 -0
  122. core/schemas/webhooks.py +40 -0
  123. core/shortcuts.py +415 -0
  124. core/tests/__init__.py +0 -0
  125. core/tests/auth_helpers.py +23 -0
  126. core/tests/namespace_helpers.py +29 -0
  127. core/tests/test_api_cli.py +43 -0
  128. core/tests/test_api_errors.py +151 -0
  129. core/tests/test_artifacts.py +123 -0
  130. core/tests/test_audit.py +973 -0
  131. core/tests/test_audit_event_id.py +90 -0
  132. core/tests/test_audit_operations.py +222 -0
  133. core/tests/test_builtin_config_schemas.py +174 -0
  134. core/tests/test_can_i.py +510 -0
  135. core/tests/test_cast_format_options.py +57 -0
  136. core/tests/test_cast_options_schema.py +54 -0
  137. core/tests/test_cast_yaml.py +26 -0
  138. core/tests/test_config_schema_api.py +173 -0
  139. core/tests/test_config_validation.py +378 -0
  140. core/tests/test_document_parser.py +122 -0
  141. core/tests/test_dump_openapi.py +46 -0
  142. core/tests/test_extend_key.py +267 -0
  143. core/tests/test_folder_get_item.py +23 -0
  144. core/tests/test_folder_resolve_missing_tags.py +73 -0
  145. core/tests/test_get_item_version.py +31 -0
  146. core/tests/test_global_permission_create.py +171 -0
  147. core/tests/test_list_pagination.py +262 -0
  148. core/tests/test_locks.py +209 -0
  149. core/tests/test_multiline_transformer.py +132 -0
  150. core/tests/test_namespace_create.py +11 -0
  151. core/tests/test_ocmo_metadata.py +127 -0
  152. core/tests/test_oidc_jwt_audiences.py +64 -0
  153. core/tests/test_omit_transformer.py +78 -0
  154. core/tests/test_operation_ids.py +47 -0
  155. core/tests/test_permissions.py +1148 -0
  156. core/tests/test_propagation.py +441 -0
  157. core/tests/test_public_base_url.py +20 -0
  158. core/tests/test_resolve_draft.py +176 -0
  159. core/tests/test_resolve_parameters_version.py +181 -0
  160. core/tests/test_resolve_permissions.py +492 -0
  161. core/tests/test_resolve_resolver_response.py +107 -0
  162. core/tests/test_resolver_auth.py +33 -0
  163. core/tests/test_resolver_folder_filters.py +95 -0
  164. core/tests/test_resolver_get_item.py +23 -0
  165. core/tests/test_resolver_navigate_search_scope.py +98 -0
  166. core/tests/test_resolver_schema_api.py +91 -0
  167. core/tests/test_security_hardening.py +324 -0
  168. core/tests/test_selectors.py +65 -0
  169. core/tests/test_special_namespace_paths.py +409 -0
  170. core/tests/test_stable_promotion.py +124 -0
  171. core/tests/test_system.py +140 -0
  172. core/tests/test_tree_manager_for_item.py +192 -0
  173. core/tests/test_two_layer_cache.py +334 -0
  174. core/tests/test_url_trailing_slashes.py +53 -0
  175. core/tests/test_validation_error_responses.py +43 -0
  176. core/tests/test_webhooks.py +985 -0
  177. core/utils/__init__.py +23 -0
  178. core/utils/deep_merge.py +91 -0
  179. core/utils/namespace_special_configs.py +105 -0
  180. core/utils/permissions_compiler.py +627 -0
  181. core/utils/permissions_schema_document.py +52 -0
  182. core/validation_errors.py +105 -0
  183. core/version.py +27 -0
  184. core/views.py +1 -0
  185. ocmo_api-0.8.19.dist-info/METADATA +252 -0
  186. ocmo_api-0.8.19.dist-info/RECORD +203 -0
  187. ocmo_api-0.8.19.dist-info/WHEEL +4 -0
  188. ocmo_api-0.8.19.dist-info/entry_points.txt +2 -0
  189. ocmo_api-0.8.19.dist-info/licenses/LICENSE +201 -0
  190. ocmo_api-0.8.19.dist-info/licenses/NOTICE +16 -0
  191. ocmoapi/__init__.py +1 -0
  192. ocmoapi/__main__.py +6 -0
  193. ocmoapi/asgi.py +16 -0
  194. ocmoapi/auth.py +163 -0
  195. ocmoapi/cli.py +85 -0
  196. ocmoapi/oauth2_provider.py +23 -0
  197. ocmoapi/parser.py +120 -0
  198. ocmoapi/settings.py +275 -0
  199. ocmoapi/templates/ninja/oauth2-redirect.html +81 -0
  200. ocmoapi/templates/ninja/swagger.html +55 -0
  201. ocmoapi/testing_auth.py +64 -0
  202. ocmoapi/urls.py +40 -0
  203. ocmoapi/wsgi.py +16 -0
@@ -0,0 +1,985 @@
1
+ """Webhook delivery tests.
2
+
3
+ Covers:
4
+ - WebhookManager.load_config: tag pointer, empty list, cache
5
+ - WebhookManager.dispatch: event/path filtering, HMAC, preset shapes,
6
+ Jinja2 template, custom headers
7
+ - Integration hooks: config create/update/delete/tag, secret CRUD/tag,
8
+ namespace.updated, config.resolved, secret.resolved, lock.created/deleted,
9
+ propagation.triggered
10
+ - Built-in paths deliverable when subscribed
11
+ - Delivery failure isolation (API still returns 2xx)
12
+ - on_commit scheduling
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import hashlib
18
+ import hmac
19
+ import json
20
+ from unittest.mock import MagicMock, patch
21
+
22
+ from django.test import Client, TestCase, override_settings
23
+
24
+ from core.managers.namespace import NamespaceManager
25
+ from core.managers.resolving import ResolvePipelineManager
26
+ from core.managers.secret import SecretManager
27
+ from core.managers.tree import TreeManager
28
+ from core.managers.webhook import (
29
+ WebhookEvent,
30
+ WebhookManager,
31
+ )
32
+ from core.models import Namespace
33
+
34
+ _TEST_MASTER_KEY = "ZDPuvW6Hx/1UxDK7K/CydLouVKtJl24nbHyb2EkvTzs="
35
+
36
+ _OPEN_PERMISSIONS = """\
37
+ policies:
38
+ - effect: Allow
39
+ actors:
40
+ - kind: User
41
+ claims:
42
+ email: "*"
43
+ actions:
44
+ - "*:*"
45
+ resources:
46
+ - "**"
47
+ """
48
+
49
+ # Minimal _webhooks YAML with HMAC signing key hard-coded as a plain string
50
+ # (not via secret reference) to avoid needing encrypted secrets in most tests.
51
+ _WEBHOOKS_CFG = """\
52
+ webhooks:
53
+ - id: test-hook
54
+ enabled: true
55
+ url: https://hooks.example.com/ocmo
56
+ events:
57
+ - config.created
58
+ - config.updated
59
+ - config.deleted
60
+ - config.tagged
61
+ - config.resolved
62
+ - secret.resolved
63
+ - secret.created
64
+ - secret.updated
65
+ - secret.deleted
66
+ - secret.tagged
67
+ - resolver.created
68
+ - resolver.updated
69
+ - namespace.updated
70
+ - lock.created
71
+ - lock.updated
72
+ - lock.deleted
73
+ - propagation.triggered
74
+ signature_header: X-Hook-Sig
75
+ signature_key: "test-hmac-key-1234"
76
+ payload:
77
+ preset: ocmo
78
+ """
79
+
80
+ _WEBHOOKS_SLACK = """\
81
+ webhooks:
82
+ - id: slack-hook
83
+ enabled: true
84
+ url: https://hooks.slack.com/services/T/B/X
85
+ events:
86
+ - config.updated
87
+ signature_key: "test-hmac-key-1234"
88
+ payload:
89
+ preset: slack
90
+ """
91
+
92
+ _WEBHOOKS_TEMPLATE = """\
93
+ webhooks:
94
+ - id: tmpl-hook
95
+ enabled: true
96
+ url: https://example.com/hook
97
+ events:
98
+ - config.updated
99
+ signature_key: "test-hmac-key-1234"
100
+ payload:
101
+ template: '{"evt":"{{ event }}","ns":"{{ namespace }}"}'
102
+ headers:
103
+ X-Custom: my-token
104
+ """
105
+
106
+ _WEBHOOKS_PATH_FILTER = """\
107
+ webhooks:
108
+ - id: filtered-hook
109
+ enabled: true
110
+ url: https://hooks.example.com/filtered
111
+ events:
112
+ - config.updated
113
+ filter:
114
+ paths:
115
+ - project/prod/**
116
+ signature_key: "test-hmac-key-1234"
117
+ payload:
118
+ preset: ocmo
119
+ """
120
+
121
+ _WEBHOOKS_PARAM = """\
122
+ _ocmo:
123
+ parameters:
124
+ hmac_key:
125
+ type: secret
126
+ value: _webhooks_secret@latest
127
+ description: HMAC signing key for webhook payloads
128
+ webhooks:
129
+ - id: param-hook
130
+ enabled: true
131
+ url: https://hooks.example.com/param
132
+ events:
133
+ - config.updated
134
+ signature_header: X-Hook-Sig
135
+ signature_key: "{!hmac_key}"
136
+ payload:
137
+ preset: ocmo
138
+ """
139
+
140
+ _WEBHOOKS_DUAL_KEY = """\
141
+ _ocmo:
142
+ parameters:
143
+ key_a:
144
+ type: secret
145
+ value: _webhooks_secret@latest
146
+ description: First webhook signing key
147
+ key_b:
148
+ type: secret
149
+ value: creds/partner@latest
150
+ description: Second webhook signing key
151
+ webhooks:
152
+ - id: hook-a
153
+ enabled: true
154
+ url: https://hooks.example.com/a
155
+ events:
156
+ - config.updated
157
+ signature_key: "{!key_a}"
158
+ payload:
159
+ preset: ocmo
160
+ - id: hook-b
161
+ enabled: true
162
+ url: https://hooks.example.com/b
163
+ events:
164
+ - config.updated
165
+ signature_key: "{!key_b}"
166
+ payload:
167
+ preset: ocmo
168
+ """
169
+
170
+ _PROPAGATION_RULES = """\
171
+ _ocmo:
172
+ propagation:
173
+ enabled: true
174
+ trigger: manual
175
+ targets:
176
+ - proj/target@latest
177
+ mode: data
178
+ key: source
179
+ """
180
+
181
+
182
+ def _set_webhooks_secret(ns: Namespace, value: str) -> None:
183
+ SecretManager(ns, "_webhooks_secret", auth=None).update(value)
184
+
185
+
186
+ @override_settings(OCMO_MASTER_KEY=_TEST_MASTER_KEY)
187
+ class WebhooksConfigLoaderTests(TestCase):
188
+ """Unit tests for WebhookManager.load_config."""
189
+
190
+ def setUp(self):
191
+ WebhookManager._config_cache.clear()
192
+ self.ns = _create_full_test_namespace("wh-loader-test")
193
+
194
+ def tearDown(self):
195
+ WebhookManager._config_cache.clear()
196
+
197
+ def test_bootstrap_has_disabled_example_entry(self):
198
+ cfg = WebhookManager(self.ns).load_config()
199
+ self.assertEqual(len(cfg.entries), 1)
200
+ self.assertEqual(cfg.entries[0].id, "example")
201
+ self.assertFalse(cfg.entries[0].enabled)
202
+ self.assertNotEqual(cfg.entries[0].signature_key, "{!hmac_signing_key}")
203
+
204
+ def test_populated_webhooks_parsed(self):
205
+ TreeManager(self.ns, "_webhooks", auth=None).update_item(_WEBHOOKS_CFG)
206
+ self.ns.refresh_from_db()
207
+ cfg = WebhookManager(self.ns).load_config()
208
+ self.assertEqual(len(cfg.entries), 1)
209
+ self.assertEqual(cfg.entries[0].id, "test-hook")
210
+
211
+ def test_load_config_does_not_fire_secret_resolved(self):
212
+ _set_webhooks_secret(self.ns, "signing-key")
213
+ TreeManager(self.ns, "_webhooks", auth=None).update_item(_WEBHOOKS_PARAM)
214
+ self.ns.refresh_from_db()
215
+ WebhookManager.invalidate(self.ns.id)
216
+ with patch("requests.post") as mock_post:
217
+ WebhookManager(self.ns, auth=None).load_config()
218
+ mock_post.assert_not_called()
219
+
220
+ def test_load_config_uses_resolve_for_param_signature_key(self):
221
+ _set_webhooks_secret(self.ns, "resolved-secret-key")
222
+ TreeManager(self.ns, "_webhooks", auth=None).update_item(_WEBHOOKS_PARAM)
223
+ self.ns.refresh_from_db()
224
+ WebhookManager.invalidate(self.ns.id)
225
+ cfg = WebhookManager(self.ns).load_config()
226
+ self.assertEqual(cfg.entries[0].signature_key, "resolved-secret-key")
227
+
228
+ def test_cache_hit_avoids_second_resolve(self):
229
+ TreeManager(self.ns, "_webhooks", auth=None).update_item(_WEBHOOKS_CFG)
230
+ self.ns.refresh_from_db()
231
+ WebhookManager.invalidate(self.ns.id)
232
+ resolve_calls: list[int] = []
233
+ original = ResolvePipelineManager.resolve_data_only
234
+
235
+ def counting_resolve(self, chain):
236
+ resolve_calls.append(1)
237
+ return original(self, chain)
238
+
239
+ with patch.object(ResolvePipelineManager, "resolve_data_only", counting_resolve):
240
+ mgr = WebhookManager(self.ns)
241
+ mgr.load_config()
242
+ mgr.load_config()
243
+ self.assertEqual(len(resolve_calls), 1)
244
+
245
+ def test_cache_invalidated_on_webhooks_update(self):
246
+ TreeManager(self.ns, "_webhooks", auth=None).update_item(_WEBHOOKS_CFG)
247
+ self.ns.refresh_from_db()
248
+ WebhookManager.invalidate(self.ns.id)
249
+ resolve_calls: list[int] = []
250
+ original = ResolvePipelineManager.resolve_data_only
251
+
252
+ def counting_resolve(self, chain):
253
+ resolve_calls.append(1)
254
+ return original(self, chain)
255
+
256
+ with patch.object(ResolvePipelineManager, "resolve_data_only", counting_resolve):
257
+ WebhookManager(self.ns).load_config()
258
+ WebhookManager.invalidate(self.ns.id)
259
+ WebhookManager(self.ns).load_config()
260
+ self.assertEqual(len(resolve_calls), 2)
261
+
262
+ def test_missing_webhooks_config_returns_empty(self):
263
+ # Namespace without _webhooks config should not crash
264
+ ns2 = Namespace.objects.create(name="wh-no-config", description="test")
265
+ cfg = WebhookManager(ns2).load_config()
266
+ self.assertEqual(cfg.entries, [])
267
+
268
+
269
+ @override_settings(OCMO_MASTER_KEY=_TEST_MASTER_KEY)
270
+ class PayloadBuilderTests(TestCase):
271
+ """Unit tests for payload preset and template builders."""
272
+
273
+ def setUp(self):
274
+ self.mgr = WebhookManager(Namespace(name="ns1"))
275
+
276
+ def _event(self, **kwargs) -> WebhookEvent:
277
+ defaults = {
278
+ "event": "config.updated",
279
+ "namespace": "ns1",
280
+ "path": "project/prod/app",
281
+ "version": 3,
282
+ "tag": None,
283
+ "actor": {"type": "user", "email": "alice@example.com"},
284
+ }
285
+ defaults.update(kwargs)
286
+ return WebhookEvent(**defaults)
287
+
288
+ def test_ocmo_preset_shape(self):
289
+ evt = self._event()
290
+ body, headers = self.mgr.build_ocmo(evt)
291
+ data = json.loads(body)
292
+ self.assertEqual(data["event"], "config.updated")
293
+ self.assertEqual(data["namespace"], "ns1")
294
+ self.assertEqual(data["path"], "project/prod/app")
295
+ self.assertEqual(data["version"], 3)
296
+ self.assertIn("actor", data)
297
+ self.assertIn("timestamp", data)
298
+ self.assertNotIn("details", data) # omitted when None
299
+
300
+ def test_ocmo_preset_includes_details_when_set(self):
301
+ evt = self._event(details={"reason": "freeze", "locked_by": "alice@example.com"})
302
+ body, _ = self.mgr.build_ocmo(evt)
303
+ data = json.loads(body)
304
+ self.assertIn("details", data)
305
+ self.assertEqual(data["details"]["reason"], "freeze")
306
+
307
+ def test_generic_json_preset_flat(self):
308
+ evt = self._event()
309
+ body, _ = self.mgr.build_generic_json(evt)
310
+ data = json.loads(body)
311
+ self.assertIn("actor_type", data)
312
+ self.assertNotIn("actor", data) # actor is split into flat fields
313
+
314
+ def test_slack_preset_has_attachments(self):
315
+ evt = self._event()
316
+ body, _ = self.mgr.build_slack(evt)
317
+ data = json.loads(body)
318
+ self.assertIn("attachments", data)
319
+ self.assertTrue(len(data["attachments"]) > 0)
320
+
321
+ def test_jinja2_template_rendered(self):
322
+ evt = self._event()
323
+ body, headers = self.mgr.build_payload(evt, template='{"e":"{{ event }}"}')
324
+ self.assertIn(b'"config.updated"', body)
325
+
326
+ def test_jinja2_template_custom_headers_merged(self):
327
+ evt = self._event()
328
+ _, headers = self.mgr.build_payload(
329
+ evt,
330
+ template='{"e":"{{ event }}"}',
331
+ extra_headers={"X-Custom": "my-token"},
332
+ )
333
+ self.assertEqual(headers.get("X-Custom"), "my-token")
334
+
335
+ def test_both_preset_and_template_falls_back_to_ocmo(self):
336
+ evt = self._event()
337
+ body, headers = self.mgr.build_payload(evt, preset="ocmo", template='{"x":"y"}')
338
+ data = json.loads(body)
339
+ self.assertIn("event", data) # ocmo shape
340
+
341
+ def test_unknown_preset_falls_back_to_ocmo(self):
342
+ evt = self._event()
343
+ body, _ = self.mgr.build_payload(evt, preset="nonexistent")
344
+ data = json.loads(body)
345
+ self.assertIn("event", data)
346
+
347
+
348
+ @override_settings(OCMO_MASTER_KEY=_TEST_MASTER_KEY)
349
+ class WebhookDispatchUnitTests(TestCase):
350
+ """Unit tests for WebhookManager.dispatch filtering, HMAC, and delivery."""
351
+
352
+ def setUp(self):
353
+ WebhookManager._config_cache.clear()
354
+ self.ns = _create_full_test_namespace("wh-dispatch-test")
355
+
356
+ def tearDown(self):
357
+ WebhookManager._config_cache.clear()
358
+
359
+ def _set_webhooks(self, yaml_doc: str):
360
+ TreeManager(self.ns, "_webhooks", auth=None).update_item(yaml_doc)
361
+ self.ns.refresh_from_db()
362
+ WebhookManager._config_cache.clear()
363
+
364
+ def _mgr(self) -> WebhookManager:
365
+ return WebhookManager(self.ns)
366
+
367
+ def _event(self, event="config.updated", path="app/cfg", version=1) -> WebhookEvent:
368
+ return self._mgr().build_event(event, path=path, version=version)
369
+
370
+ def test_empty_webhooks_no_http(self):
371
+ with patch("requests.post") as mock_post:
372
+ self._mgr().dispatch(self._event())
373
+ mock_post.assert_not_called()
374
+
375
+ def test_event_mismatch_no_delivery(self):
376
+ self._set_webhooks(_WEBHOOKS_CFG)
377
+ evt = self._mgr().build_event("config.created_UNKNOWN", path="app/cfg")
378
+ with patch("requests.post") as mock_post:
379
+ with self.captureOnCommitCallbacks(execute=True):
380
+ self._mgr().dispatch(evt)
381
+ mock_post.assert_not_called()
382
+
383
+ def test_event_match_delivers(self):
384
+ self._set_webhooks(_WEBHOOKS_CFG)
385
+ with patch("requests.post") as mock_post:
386
+ mock_post.return_value = MagicMock(ok=True, status_code=200)
387
+ with self.captureOnCommitCallbacks(execute=True):
388
+ self._mgr().dispatch(self._event("config.updated"))
389
+ mock_post.assert_called_once()
390
+ url = mock_post.call_args[0][0]
391
+ self.assertEqual(url, "https://hooks.example.com/ocmo")
392
+
393
+ def test_hmac_signature_header_correct(self):
394
+ self._set_webhooks(_WEBHOOKS_CFG)
395
+ with patch("requests.post") as mock_post:
396
+ mock_post.return_value = MagicMock(ok=True, status_code=200)
397
+ with self.captureOnCommitCallbacks(execute=True):
398
+ self._mgr().dispatch(self._event("config.updated"))
399
+ _, kwargs = mock_post.call_args
400
+ headers = (
401
+ kwargs.get("headers")
402
+ or mock_post.call_args[1].get("headers")
403
+ or mock_post.call_args.kwargs.get("headers")
404
+ )
405
+ body = kwargs.get("data") or mock_post.call_args.kwargs.get("data")
406
+ key = b"test-hmac-key-1234"
407
+ expected_sig = hmac.new(key, body, hashlib.sha256).hexdigest()
408
+ self.assertEqual(headers["X-Hook-Sig"], expected_sig)
409
+
410
+ def test_custom_signature_header_name(self):
411
+ self._set_webhooks(_WEBHOOKS_CFG)
412
+ with patch("requests.post") as mock_post:
413
+ mock_post.return_value = MagicMock(ok=True, status_code=200)
414
+ with self.captureOnCommitCallbacks(execute=True):
415
+ self._mgr().dispatch(self._event("config.updated"))
416
+ headers = mock_post.call_args.kwargs.get("headers") or mock_post.call_args[1]["headers"]
417
+ self.assertIn("X-Hook-Sig", headers)
418
+
419
+ def test_path_filter_match(self):
420
+ self._set_webhooks(_WEBHOOKS_PATH_FILTER)
421
+ with patch("requests.post") as mock_post:
422
+ mock_post.return_value = MagicMock(ok=True, status_code=200)
423
+ with self.captureOnCommitCallbacks(execute=True):
424
+ self._mgr().dispatch(
425
+ self._mgr().build_event("config.updated", path="project/prod/api"),
426
+ )
427
+ mock_post.assert_called_once()
428
+
429
+ def test_path_filter_no_match(self):
430
+ self._set_webhooks(_WEBHOOKS_PATH_FILTER)
431
+ with patch("requests.post") as mock_post:
432
+ with self.captureOnCommitCallbacks(execute=True):
433
+ self._mgr().dispatch(
434
+ self._mgr().build_event("config.updated", path="project/dev/api"),
435
+ )
436
+ mock_post.assert_not_called()
437
+
438
+ def test_disabled_entry_skipped(self):
439
+ disabled_cfg = _WEBHOOKS_CFG.replace("enabled: true", "enabled: false")
440
+ self._set_webhooks(disabled_cfg)
441
+ with patch("requests.post") as mock_post:
442
+ with self.captureOnCommitCallbacks(execute=True):
443
+ self._mgr().dispatch(self._event("config.updated"))
444
+ mock_post.assert_not_called()
445
+
446
+ def test_slack_preset_delivered(self):
447
+ self._set_webhooks(_WEBHOOKS_SLACK)
448
+ with patch("requests.post") as mock_post:
449
+ mock_post.return_value = MagicMock(ok=True, status_code=200)
450
+ with self.captureOnCommitCallbacks(execute=True):
451
+ self._mgr().dispatch(self._event("config.updated"))
452
+ mock_post.assert_called_once()
453
+ body = mock_post.call_args.kwargs.get("data") or mock_post.call_args[1]["data"]
454
+ data = json.loads(body)
455
+ self.assertIn("attachments", data)
456
+
457
+ def test_template_delivered_with_custom_header(self):
458
+ self._set_webhooks(_WEBHOOKS_TEMPLATE)
459
+ with patch("requests.post") as mock_post:
460
+ mock_post.return_value = MagicMock(ok=True, status_code=200)
461
+ with self.captureOnCommitCallbacks(execute=True):
462
+ self._mgr().dispatch(self._event("config.updated"))
463
+ mock_post.assert_called_once()
464
+ headers = mock_post.call_args.kwargs.get("headers") or mock_post.call_args[1]["headers"]
465
+ self.assertEqual(headers.get("X-Custom"), "my-token")
466
+ body = mock_post.call_args.kwargs.get("data") or mock_post.call_args[1]["data"]
467
+ data = json.loads(body)
468
+ self.assertEqual(data["evt"], "config.updated")
469
+
470
+ def test_per_entry_different_signing_keys(self):
471
+ _set_webhooks_secret(self.ns, "key-alpha")
472
+ SecretManager(self.ns, "creds/partner", auth=None).create("key-beta")
473
+ self._set_webhooks(_WEBHOOKS_DUAL_KEY)
474
+ with patch("requests.post") as mock_post:
475
+ mock_post.return_value = MagicMock(ok=True, status_code=200)
476
+ with self.captureOnCommitCallbacks(execute=True):
477
+ self._mgr().dispatch(self._event("config.updated"))
478
+ self.assertEqual(mock_post.call_count, 2)
479
+ calls = mock_post.call_args_list
480
+ body_a = calls[0].kwargs.get("data") or calls[0][1]["data"]
481
+ body_b = calls[1].kwargs.get("data") or calls[1][1]["data"]
482
+ sig_a = (calls[0].kwargs.get("headers") or calls[0][1]["headers"])["X-OCMO-Signature"]
483
+ sig_b = (calls[1].kwargs.get("headers") or calls[1][1]["headers"])["X-OCMO-Signature"]
484
+ self.assertEqual(
485
+ sig_a,
486
+ hmac.new(b"key-alpha", body_a, hashlib.sha256).hexdigest(),
487
+ )
488
+ self.assertEqual(
489
+ sig_b,
490
+ hmac.new(b"key-beta", body_b, hashlib.sha256).hexdigest(),
491
+ )
492
+
493
+ def test_secret_update_invalidates_cached_signing_key(self):
494
+ _set_webhooks_secret(self.ns, "old-key")
495
+ self._set_webhooks(_WEBHOOKS_PARAM)
496
+ self._mgr().load_config()
497
+ _set_webhooks_secret(self.ns, "new-key")
498
+ with patch("requests.post") as mock_post:
499
+ mock_post.return_value = MagicMock(ok=True, status_code=200)
500
+ with self.captureOnCommitCallbacks(execute=True):
501
+ self._mgr().dispatch(self._event("config.updated"))
502
+ body = mock_post.call_args.kwargs.get("data") or mock_post.call_args[1]["data"]
503
+ sig = (mock_post.call_args.kwargs.get("headers") or mock_post.call_args[1]["headers"])["X-Hook-Sig"]
504
+ self.assertEqual(sig, hmac.new(b"new-key", body, hashlib.sha256).hexdigest())
505
+
506
+ def test_unrelated_secret_update_keeps_webhook_cache(self):
507
+ _set_webhooks_secret(self.ns, "cached-key")
508
+ self._set_webhooks(_WEBHOOKS_PARAM)
509
+ resolve_calls: list[int] = []
510
+ original = ResolvePipelineManager.resolve_data_only
511
+
512
+ def counting_resolve(self, chain):
513
+ resolve_calls.append(1)
514
+ return original(self, chain)
515
+
516
+ with patch.object(ResolvePipelineManager, "resolve_data_only", counting_resolve):
517
+ self._mgr().load_config()
518
+ SecretManager(self.ns, "creds/unrelated", auth=None).create("other-secret")
519
+ self._mgr().load_config()
520
+ self.assertEqual(len(resolve_calls), 1)
521
+
522
+ def test_delivery_failure_does_not_propagate(self):
523
+ self._set_webhooks(_WEBHOOKS_CFG)
524
+ with patch("requests.post", side_effect=Exception("timeout")):
525
+ with self.captureOnCommitCallbacks(execute=True):
526
+ self._mgr().dispatch(self._event("config.updated"))
527
+
528
+
529
+ @override_settings(OCMO_MASTER_KEY=_TEST_MASTER_KEY)
530
+ class WebhookSchemaValidationTests(TestCase):
531
+ def setUp(self):
532
+ WebhookManager._config_cache.clear()
533
+ self.ns = _create_full_test_namespace("wh-schema-test")
534
+
535
+ def test_signature_key_required_on_save(self):
536
+ from django.core.exceptions import ValidationError
537
+
538
+ bad_cfg = """\
539
+ webhooks:
540
+ - id: bad-hook
541
+ enabled: true
542
+ url: https://example.com/hook
543
+ events:
544
+ - config.updated
545
+ payload:
546
+ preset: ocmo
547
+ """
548
+ with self.assertRaises(ValidationError):
549
+ TreeManager(self.ns, "_webhooks", auth=None).update_item(bad_cfg)
550
+
551
+
552
+ def _create_full_test_namespace(name: str) -> Namespace:
553
+ """Create a namespace with ALL built-in configs + open permissions."""
554
+ from core.managers.namespace import NamespaceManager as _NM
555
+ from core.utils.namespace_special_configs import init_namespace_special_configs
556
+
557
+ ns = Namespace.objects.create(name=name, description="test")
558
+ mgr = _NM(name, auth=None)
559
+ mgr.ns = ns
560
+ init_namespace_special_configs(ns)
561
+ # Set permissive policies so API client calls work without auth
562
+ TreeManager(ns, "_permissions", auth=None).update_item(_OPEN_PERMISSIONS)
563
+ return ns
564
+
565
+
566
+ @override_settings(OCMO_MASTER_KEY=_TEST_MASTER_KEY)
567
+ class WebhookIntegrationTests(TestCase):
568
+ """End-to-end integration tests verifying hook wiring in manager methods."""
569
+
570
+ def setUp(self):
571
+ WebhookManager._config_cache.clear()
572
+ self.client = Client()
573
+ self.ns = _create_full_test_namespace("wh-int-test")
574
+ # Configure webhooks
575
+ TreeManager(self.ns, "_webhooks", auth=None).update_item(_WEBHOOKS_CFG)
576
+ self.ns.refresh_from_db()
577
+ WebhookManager._config_cache.clear()
578
+
579
+ def tearDown(self):
580
+ WebhookManager._config_cache.clear()
581
+
582
+ # --- config CRUD via API ---
583
+
584
+ def test_config_create_fires_event(self):
585
+ with patch("requests.post") as mock_post:
586
+ mock_post.return_value = MagicMock(ok=True, status_code=200)
587
+ with self.captureOnCommitCallbacks(execute=True):
588
+ resp = self.client.post(
589
+ f"/api/v1/ns/{self.ns.name}/~config/~create/app/cfg",
590
+ data=b"key: val\n",
591
+ content_type="application/yaml",
592
+ )
593
+ self.assertEqual(resp.status_code, 201, resp.content)
594
+ # At least one call should be for config.created
595
+ events = [json.loads(c.kwargs.get("data") or c[1]["data"])["event"] for c in mock_post.call_args_list]
596
+ self.assertIn("config.created", events)
597
+
598
+ def test_config_update_fires_event(self):
599
+ self.client.post(
600
+ f"/api/v1/ns/{self.ns.name}/~config/~create/app/upd",
601
+ data=b"key: val\n",
602
+ content_type="application/yaml",
603
+ )
604
+ WebhookManager._config_cache.clear()
605
+ with patch("requests.post") as mock_post:
606
+ mock_post.return_value = MagicMock(ok=True, status_code=200)
607
+ with self.captureOnCommitCallbacks(execute=True):
608
+ resp = self.client.put(
609
+ f"/api/v1/ns/{self.ns.name}/~config/~update/app/upd",
610
+ data=b"key: changed\n",
611
+ content_type="application/yaml",
612
+ )
613
+ self.assertEqual(resp.status_code, 200, resp.content)
614
+ events = [json.loads(c.kwargs.get("data") or c[1]["data"])["event"] for c in mock_post.call_args_list]
615
+ self.assertIn("config.updated", events)
616
+
617
+ def test_config_delete_fires_event(self):
618
+ self.client.post(
619
+ f"/api/v1/ns/{self.ns.name}/~config/~create/app/del",
620
+ data=b"key: val\n",
621
+ content_type="application/yaml",
622
+ )
623
+ WebhookManager._config_cache.clear()
624
+ with patch("requests.post") as mock_post:
625
+ mock_post.return_value = MagicMock(ok=True, status_code=200)
626
+ with self.captureOnCommitCallbacks(execute=True):
627
+ resp = self.client.delete(
628
+ f"/api/v1/ns/{self.ns.name}/~delete/app/del?preview=false",
629
+ )
630
+ self.assertEqual(resp.status_code, 200, resp.content)
631
+ events = [json.loads(c.kwargs.get("data") or c[1]["data"])["event"] for c in mock_post.call_args_list]
632
+ self.assertIn("config.deleted", events)
633
+
634
+ def test_config_tag_fires_event(self):
635
+ self.client.post(
636
+ f"/api/v1/ns/{self.ns.name}/~config/~create/app/tagged",
637
+ data=b"key: val\n",
638
+ content_type="application/yaml",
639
+ )
640
+ WebhookManager._config_cache.clear()
641
+ with patch("requests.post") as mock_post:
642
+ mock_post.return_value = MagicMock(ok=True, status_code=200)
643
+ with self.captureOnCommitCallbacks(execute=True):
644
+ resp = self.client.post(
645
+ f"/api/v1/ns/{self.ns.name}/~tag/app/tagged",
646
+ data=json.dumps({"tag": "mytag", "version": 1}),
647
+ content_type="application/json",
648
+ )
649
+ self.assertEqual(resp.status_code, 200, resp.content)
650
+ events = [json.loads(c.kwargs.get("data") or c[1]["data"])["event"] for c in mock_post.call_args_list]
651
+ self.assertIn("config.tagged", events)
652
+
653
+ # --- namespace.updated ---
654
+
655
+ def test_namespace_updated_fires_event(self):
656
+ from core.schemas.requests import NamespacePatchSchema
657
+
658
+ with patch("requests.post") as mock_post:
659
+ mock_post.return_value = MagicMock(ok=True, status_code=200)
660
+ with self.captureOnCommitCallbacks(execute=True):
661
+ NamespaceManager(self.ns.name, auth=None).update(NamespacePatchSchema(description="updated directly"))
662
+ events = [json.loads(c.kwargs.get("data") or c[1]["data"])["event"] for c in mock_post.call_args_list]
663
+ self.assertIn("namespace.updated", events)
664
+ namespace_events = [
665
+ json.loads(c.kwargs.get("data") or c[1]["data"])
666
+ for c in mock_post.call_args_list
667
+ if json.loads(c.kwargs.get("data") or c[1]["data"])["event"] == "namespace.updated"
668
+ ]
669
+ self.assertIsNone(namespace_events[0]["path"])
670
+
671
+ # --- lock events ---
672
+
673
+ def test_lock_created_fires_event(self):
674
+ self.client.post(
675
+ f"/api/v1/ns/{self.ns.name}/~config/~create/lockable/cfg",
676
+ data=b"key: val\n",
677
+ content_type="application/yaml",
678
+ )
679
+ WebhookManager._config_cache.clear()
680
+ with patch("requests.post") as mock_post:
681
+ mock_post.return_value = MagicMock(ok=True, status_code=200)
682
+ with self.captureOnCommitCallbacks(execute=True):
683
+ resp = self.client.post(
684
+ f"/api/v1/ns/{self.ns.name}/~lock/lockable/cfg",
685
+ data=json.dumps({"reason": "freeze"}),
686
+ content_type="application/json",
687
+ )
688
+ self.assertEqual(resp.status_code, 201, resp.content)
689
+ events = [json.loads(c.kwargs.get("data") or c[1]["data"])["event"] for c in mock_post.call_args_list]
690
+ self.assertIn("lock.created", events)
691
+ lock_evt = next(
692
+ json.loads(c.kwargs.get("data") or c[1]["data"])
693
+ for c in mock_post.call_args_list
694
+ if json.loads(c.kwargs.get("data") or c[1]["data"])["event"] == "lock.created"
695
+ )
696
+ self.assertEqual(lock_evt["details"]["reason"], "freeze")
697
+
698
+ def test_lock_deleted_fires_event(self):
699
+ self.client.post(
700
+ f"/api/v1/ns/{self.ns.name}/~config/~create/lockable/del",
701
+ data=b"key: val\n",
702
+ content_type="application/yaml",
703
+ )
704
+ self.client.post(
705
+ f"/api/v1/ns/{self.ns.name}/~lock/lockable/del",
706
+ data=json.dumps({"reason": "freeze"}),
707
+ content_type="application/json",
708
+ )
709
+ WebhookManager._config_cache.clear()
710
+ with patch("requests.post") as mock_post:
711
+ mock_post.return_value = MagicMock(ok=True, status_code=200)
712
+ with self.captureOnCommitCallbacks(execute=True):
713
+ resp = self.client.delete(f"/api/v1/ns/{self.ns.name}/~lock/lockable/del")
714
+ self.assertEqual(resp.status_code, 204, resp.content)
715
+ events = [json.loads(c.kwargs.get("data") or c[1]["data"])["event"] for c in mock_post.call_args_list]
716
+ self.assertIn("lock.deleted", events)
717
+
718
+ # --- secret events ---
719
+
720
+ def test_secret_created_fires_event(self):
721
+ with patch("requests.post") as mock_post:
722
+ mock_post.return_value = MagicMock(ok=True, status_code=200)
723
+ with self.captureOnCommitCallbacks(execute=True):
724
+ resp = self.client.post(
725
+ f"/api/v1/ns/{self.ns.name}/~secret/~create/creds/db",
726
+ data=b"password: secret123\n",
727
+ content_type="application/yaml",
728
+ )
729
+ self.assertEqual(resp.status_code, 201, resp.content)
730
+ events = [json.loads(c.kwargs.get("data") or c[1]["data"])["event"] for c in mock_post.call_args_list]
731
+ self.assertIn("secret.created", events)
732
+
733
+ def test_secret_payload_has_no_plaintext(self):
734
+ with patch("requests.post") as mock_post:
735
+ mock_post.return_value = MagicMock(ok=True, status_code=200)
736
+ with self.captureOnCommitCallbacks(execute=True):
737
+ self.client.post(
738
+ f"/api/v1/ns/{self.ns.name}/~secret/~create/creds/safe",
739
+ data=b"password: super-secret\n",
740
+ content_type="application/yaml",
741
+ )
742
+ for c in mock_post.call_args_list:
743
+ body_str = (c.kwargs.get("data") or c[1]["data"]).decode()
744
+ self.assertNotIn("super-secret", body_str)
745
+
746
+ def test_secret_updated_fires_event(self):
747
+ SecretManager(self.ns, "creds/upd", auth=None).create("password: first\n")
748
+ WebhookManager._config_cache.clear()
749
+ with patch("requests.post") as mock_post:
750
+ mock_post.return_value = MagicMock(ok=True, status_code=200)
751
+ with self.captureOnCommitCallbacks(execute=True):
752
+ resp = self.client.put(
753
+ f"/api/v1/ns/{self.ns.name}/~secret/~update/creds/upd",
754
+ data=b"password: second\n",
755
+ content_type="application/yaml",
756
+ )
757
+ self.assertEqual(resp.status_code, 200, resp.content)
758
+ events = [json.loads(c.kwargs.get("data") or c[1]["data"])["event"] for c in mock_post.call_args_list]
759
+ self.assertIn("secret.updated", events)
760
+
761
+ def test_secret_deleted_fires_event(self):
762
+ SecretManager(self.ns, "creds/del", auth=None).create("password: pass\n")
763
+ WebhookManager._config_cache.clear()
764
+ with patch("requests.post") as mock_post:
765
+ mock_post.return_value = MagicMock(ok=True, status_code=200)
766
+ with self.captureOnCommitCallbacks(execute=True):
767
+ resp = self.client.delete(
768
+ f"/api/v1/ns/{self.ns.name}/~delete/creds/del?preview=false",
769
+ )
770
+ self.assertEqual(resp.status_code, 200, resp.content)
771
+ events = [json.loads(c.kwargs.get("data") or c[1]["data"])["event"] for c in mock_post.call_args_list]
772
+ self.assertIn("secret.deleted", events)
773
+
774
+ def test_secret_tagged_fires_event(self):
775
+ SecretManager(self.ns, "creds/tag", auth=None).create("password: pass\n")
776
+ WebhookManager._config_cache.clear()
777
+ with patch("requests.post") as mock_post:
778
+ mock_post.return_value = MagicMock(ok=True, status_code=200)
779
+ with self.captureOnCommitCallbacks(execute=True):
780
+ resp = self.client.post(
781
+ f"/api/v1/ns/{self.ns.name}/~tag/creds/tag",
782
+ data=json.dumps({"tag": "mytag", "version": 1}),
783
+ content_type="application/json",
784
+ )
785
+ self.assertEqual(resp.status_code, 200, resp.content)
786
+ events = [json.loads(c.kwargs.get("data") or c[1]["data"])["event"] for c in mock_post.call_args_list]
787
+ self.assertIn("secret.tagged", events)
788
+
789
+ # --- built-in path deliverable when subscribed ---
790
+
791
+ def test_builtin_path_deliverable_when_subscribed(self):
792
+ """Updating _permissions fires config.updated when subscribed."""
793
+ cfg_with_perm_filter = """\
794
+ webhooks:
795
+ - id: perm-audit
796
+ enabled: true
797
+ url: https://hooks.example.com/perms
798
+ events:
799
+ - config.updated
800
+ filter:
801
+ paths:
802
+ - _permissions
803
+ signature_key: "test-hmac-key-1234"
804
+ payload:
805
+ preset: ocmo
806
+ """
807
+ TreeManager(self.ns, "_webhooks", auth=None).update_item(cfg_with_perm_filter)
808
+ self.ns.refresh_from_db()
809
+ WebhookManager._config_cache.clear()
810
+ # Use different content from what's already stored to guarantee content_changed=True
811
+ updated_perm = _OPEN_PERMISSIONS + "# updated\n"
812
+ with patch("requests.post") as mock_post:
813
+ mock_post.return_value = MagicMock(ok=True, status_code=200)
814
+ with self.captureOnCommitCallbacks(execute=True):
815
+ TreeManager(self.ns, "_permissions", auth=None).update_item(updated_perm)
816
+ mock_post.assert_called_once()
817
+ body = json.loads(mock_post.call_args.kwargs.get("data") or mock_post.call_args[1]["data"])
818
+ self.assertEqual(body["path"], "_permissions")
819
+ self.assertEqual(body["event"], "config.updated")
820
+
821
+ def test_builtin_path_not_delivered_without_subscription(self):
822
+ """Updating _permissions should NOT fire if no webhook subscribes to it."""
823
+ WebhookManager._config_cache.clear()
824
+ with patch("requests.post") as mock_post:
825
+ with self.captureOnCommitCallbacks(execute=True):
826
+ TreeManager(self.ns, "_permissions", auth=None).update_item(_OPEN_PERMISSIONS)
827
+ # _WEBHOOKS_CFG has no path filter, so _permissions IS in scope — but we
828
+ # test that a path-filtered hook that excludes _permissions stays quiet.
829
+ # The test above (path_filter_no_match) already covers filter exclusion;
830
+ # here we just verify the built-in path triggers normally.
831
+ # Reset to a filtered hook that won't match _permissions.
832
+ TreeManager(self.ns, "_webhooks", auth=None).update_item(_WEBHOOKS_PATH_FILTER)
833
+ self.ns.refresh_from_db()
834
+ WebhookManager._config_cache.clear()
835
+ with patch("requests.post") as mock_post:
836
+ with self.captureOnCommitCallbacks(execute=True):
837
+ TreeManager(self.ns, "_permissions", auth=None).update_item(_OPEN_PERMISSIONS)
838
+ mock_post.assert_not_called()
839
+
840
+ # --- propagation ---
841
+
842
+ def test_propagation_triggered_fires_on_manual(self):
843
+ # Set up source and target configs
844
+ self.client.post(
845
+ f"/api/v1/ns/{self.ns.name}/~config/~create/proj/source",
846
+ data=_PROPAGATION_RULES.encode(),
847
+ content_type="application/yaml",
848
+ )
849
+ self.client.post(
850
+ f"/api/v1/ns/{self.ns.name}/~config/~create/proj/target",
851
+ data=b"mode: data\nkey: target\n",
852
+ content_type="application/yaml",
853
+ )
854
+ WebhookManager._config_cache.clear()
855
+ with patch("requests.post") as mock_post:
856
+ mock_post.return_value = MagicMock(ok=True, status_code=200)
857
+ with self.captureOnCommitCallbacks(execute=True):
858
+ resp = self.client.post(
859
+ f"/api/v1/ns/{self.ns.name}/~propagate/proj/source",
860
+ )
861
+ self.assertEqual(resp.status_code, 200, resp.content)
862
+ events = [json.loads(c.kwargs.get("data") or c[1]["data"])["event"] for c in mock_post.call_args_list]
863
+ self.assertIn("propagation.triggered", events)
864
+ prop_evt = next(
865
+ json.loads(c.kwargs.get("data") or c[1]["data"])
866
+ for c in mock_post.call_args_list
867
+ if json.loads(c.kwargs.get("data") or c[1]["data"])["event"] == "propagation.triggered"
868
+ )
869
+ self.assertEqual(prop_evt["path"], "proj/source")
870
+ self.assertIn("targets", prop_evt["details"])
871
+
872
+ # --- delivery failure isolation ---
873
+
874
+ def test_delivery_failure_does_not_fail_api(self):
875
+ with patch("requests.post", side_effect=Exception("network error")):
876
+ with self.captureOnCommitCallbacks(execute=True):
877
+ resp = self.client.post(
878
+ f"/api/v1/ns/{self.ns.name}/~config/~create/app/isolated",
879
+ data=b"key: val\n",
880
+ content_type="application/yaml",
881
+ )
882
+ self.assertEqual(resp.status_code, 201)
883
+
884
+ # --- config.resolved / secret.resolved ---
885
+
886
+ def test_config_resolved_fires_on_resolve(self):
887
+ self.client.post(
888
+ f"/api/v1/ns/{self.ns.name}/~config/~create/app/resolvable",
889
+ data=b"key: val\n",
890
+ content_type="application/yaml",
891
+ )
892
+ WebhookManager._config_cache.clear()
893
+ with patch("requests.post") as mock_post:
894
+ mock_post.return_value = MagicMock(ok=True, status_code=200)
895
+ with self.captureOnCommitCallbacks(execute=True):
896
+ resp = self.client.get(
897
+ f"/api/v1/ns/{self.ns.name}/~resolve/app/resolvable",
898
+ )
899
+ self.assertEqual(resp.status_code, 200, resp.content)
900
+ events = [json.loads(c.kwargs.get("data") or c[1]["data"])["event"] for c in mock_post.call_args_list]
901
+ self.assertIn("config.resolved", events)
902
+
903
+ def test_secret_resolved_fires_on_config_resolve(self):
904
+ SecretManager(self.ns, "creds/db", auth=None).create("password: db-secret\n")
905
+ self.client.post(
906
+ f"/api/v1/ns/{self.ns.name}/~config/~create/app/with-secret",
907
+ data=(
908
+ b"_ocmo:\n"
909
+ b" parameters:\n"
910
+ b" db_pass:\n"
911
+ b" type: secret\n"
912
+ b" value: creds/db@latest\n"
913
+ b" description: Database password\n"
914
+ b'key: "{!db_pass}"\n'
915
+ ),
916
+ content_type="application/yaml",
917
+ )
918
+ WebhookManager._config_cache.clear()
919
+ with patch("requests.post") as mock_post:
920
+ mock_post.return_value = MagicMock(ok=True, status_code=200)
921
+ with self.captureOnCommitCallbacks(execute=True):
922
+ resp = self.client.get(
923
+ f"/api/v1/ns/{self.ns.name}/~resolve/app/with-secret",
924
+ )
925
+ self.assertEqual(resp.status_code, 200, resp.content)
926
+ payloads = [json.loads(c.kwargs.get("data") or c[1]["data"]) for c in mock_post.call_args_list]
927
+ events = [p["event"] for p in payloads]
928
+ self.assertIn("secret.resolved", events)
929
+ secret_evt = next(p for p in payloads if p["event"] == "secret.resolved")
930
+ self.assertEqual(secret_evt["path"], "creds/db")
931
+ self.assertEqual(secret_evt["details"]["config_path"], "app/with-secret")
932
+ self.assertEqual(secret_evt["details"]["ref"], "latest")
933
+
934
+ def test_secret_resolved_payload_has_no_plaintext(self):
935
+ SecretManager(self.ns, "creds/safe", auth=None).create("token: ultra-secret\n")
936
+ self.client.post(
937
+ f"/api/v1/ns/{self.ns.name}/~config/~create/app/safe-secret",
938
+ data=(
939
+ b"_ocmo:\n"
940
+ b" parameters:\n"
941
+ b" tok:\n"
942
+ b" type: secret\n"
943
+ b" value: creds/safe@latest\n"
944
+ b" description: API token\n"
945
+ b'key: "{!tok}"\n'
946
+ ),
947
+ content_type="application/yaml",
948
+ )
949
+ WebhookManager._config_cache.clear()
950
+ with patch("requests.post") as mock_post:
951
+ mock_post.return_value = MagicMock(ok=True, status_code=200)
952
+ with self.captureOnCommitCallbacks(execute=True):
953
+ self.client.get(
954
+ f"/api/v1/ns/{self.ns.name}/~resolve/app/safe-secret",
955
+ )
956
+ for c in mock_post.call_args_list:
957
+ body_str = (c.kwargs.get("data") or c[1]["data"]).decode()
958
+ self.assertNotIn("ultra-secret", body_str)
959
+
960
+ def test_secret_resolved_skipped_with_no_creds(self):
961
+ SecretManager(self.ns, "creds/nc", auth=None).create("token: hidden\n")
962
+ self.client.post(
963
+ f"/api/v1/ns/{self.ns.name}/~config/~create/app/nc-secret",
964
+ data=(
965
+ b"_ocmo:\n"
966
+ b" parameters:\n"
967
+ b" tok:\n"
968
+ b" type: secret\n"
969
+ b" value: creds/nc@latest\n"
970
+ b" description: API token\n"
971
+ b'key: "{!tok}"\n'
972
+ ),
973
+ content_type="application/yaml",
974
+ )
975
+ WebhookManager._config_cache.clear()
976
+ with patch("requests.post") as mock_post:
977
+ mock_post.return_value = MagicMock(ok=True, status_code=200)
978
+ with self.captureOnCommitCallbacks(execute=True):
979
+ resp = self.client.get(
980
+ f"/api/v1/ns/{self.ns.name}/~resolve/app/nc-secret?no-creds=true",
981
+ )
982
+ self.assertEqual(resp.status_code, 200, resp.content)
983
+ events = [json.loads(c.kwargs.get("data") or c[1]["data"])["event"] for c in mock_post.call_args_list]
984
+ self.assertIn("config.resolved", events)
985
+ self.assertNotIn("secret.resolved", events)