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.
- LICENSE +201 -0
- NOTICE +16 -0
- core/__init__.py +0 -0
- core/api/__init__.py +201 -0
- core/api/_common.py +37 -0
- core/api/audit.py +244 -0
- core/api/auth.py +50 -0
- core/api/config.py +96 -0
- core/api/errors.py +65 -0
- core/api/global_permissions.py +107 -0
- core/api/lock.py +99 -0
- core/api/namespace.py +86 -0
- core/api/propagation.py +29 -0
- core/api/resolve.py +229 -0
- core/api/resolver.py +96 -0
- core/api/secret.py +53 -0
- core/api/system.py +42 -0
- core/api/template.py +53 -0
- core/api/tree.py +274 -0
- core/apps.py +18 -0
- core/audit_operations.py +0 -0
- core/constants/__init__.py +5 -0
- core/constants/audit_operations.py +35 -0
- core/constants/permission_actions.py +59 -0
- core/constants/permissions.py +41 -0
- core/constants/resolve.py +3 -0
- core/constants/transformers.py +22 -0
- core/constants/webhook_events.py +31 -0
- core/data/builtin_schemas/_git_sync.schema.yaml +24 -0
- core/data/builtin_schemas/_permissions.schema.yaml +211 -0
- core/data/builtin_schemas/_webhooks.schema.yaml +145 -0
- core/decorators/__init__.py +14 -0
- core/decorators/audit.py +105 -0
- core/decorators/binding.py +49 -0
- core/decorators/permissions.py +129 -0
- core/decorators/webhook.py +87 -0
- core/exceptions.py +165 -0
- core/management/__init__.py +0 -0
- core/management/commands/__init__.py +0 -0
- core/management/commands/dump_openapi.py +40 -0
- core/managers/artifacts.py +346 -0
- core/managers/audit/__init__.py +1172 -0
- core/managers/audit/timeline.py +365 -0
- core/managers/auth.py +276 -0
- core/managers/cast.py +506 -0
- core/managers/config_validation.py +216 -0
- core/managers/crypto.py +78 -0
- core/managers/global_permissions.py +183 -0
- core/managers/health.py +56 -0
- core/managers/lock.py +229 -0
- core/managers/namespace.py +132 -0
- core/managers/permissions.py +168 -0
- core/managers/propagation.py +244 -0
- core/managers/resolution.py +938 -0
- core/managers/resolve_cache.py +266 -0
- core/managers/resolve_parameters.py +503 -0
- core/managers/resolver.py +56 -0
- core/managers/resolver_tokens.py +114 -0
- core/managers/resolving.py +673 -0
- core/managers/secret.py +147 -0
- core/managers/system.py +44 -0
- core/managers/tree/__init__.py +85 -0
- core/managers/tree/_common.py +85 -0
- core/managers/tree/base.py +203 -0
- core/managers/tree/config_ops.py +167 -0
- core/managers/tree/constants.py +11 -0
- core/managers/tree/create.py +75 -0
- core/managers/tree/diff.py +270 -0
- core/managers/tree/mutate.py +433 -0
- core/managers/tree/paths.py +49 -0
- core/managers/tree/propagation.py +200 -0
- core/managers/tree/read.py +263 -0
- core/managers/tree/resolver_ops.py +21 -0
- core/managers/tree/tags.py +167 -0
- core/managers/tree/update.py +137 -0
- core/managers/tree/versions.py +87 -0
- core/managers/tree_capabilities.py +189 -0
- core/managers/webhook/__init__.py +3 -0
- core/managers/webhook/cache.py +68 -0
- core/managers/webhook/main.py +357 -0
- core/middleware.py +32 -0
- core/migrations/0001_initial.py +157 -0
- core/migrations/0002_folder.py +33 -0
- core/migrations/0003_alter_namespace_name.py +17 -0
- core/migrations/0004_alter_namespace_name_and_more.py +25 -0
- core/migrations/0005_configversion_templateversion.py +85 -0
- core/migrations/0006_alter_templateversion_options_and_more.py +47 -0
- core/migrations/0007_configversion_deleted_at_templateversion_deleted_at.py +22 -0
- core/migrations/0008_namespace_tags_dek_secret_global_permissions.py +139 -0
- core/migrations/0009_tree_lock.py +58 -0
- core/migrations/0010_resolver_token_encryption.py +75 -0
- core/migrations/0011_audit_event.py +134 -0
- core/migrations/0012_audit_event_namespace_name.py +24 -0
- core/migrations/0013_audit_event_object_id.py +28 -0
- core/migrations/0014_sync_permissions_webhooks_builtin_schemas.py +26 -0
- core/migrations/0015_audit_event_operation_subresource.py +27 -0
- core/migrations/0016_sync_permissions_schema_audit_actions.py +26 -0
- core/migrations/0017_sync_permissions_schema_valid_actions.py +26 -0
- core/migrations/__init__.py +0 -0
- core/models/__init__.py +9 -0
- core/models/audit.py +97 -0
- core/models/config.py +132 -0
- core/models/folder.py +12 -0
- core/models/global_permissions.py +27 -0
- core/models/lock.py +35 -0
- core/models/namespace.py +45 -0
- core/models/resolver.py +25 -0
- core/models/secret.py +58 -0
- core/models/tree.py +29 -0
- core/notice.py +18 -0
- core/operation_ids.py +153 -0
- core/schemas/__init__.py +2 -0
- core/schemas/audit.py +75 -0
- core/schemas/cast_json_schema.py +57 -0
- core/schemas/cast_options.py +508 -0
- core/schemas/generic.py +233 -0
- core/schemas/metadata_json_schema.py +21 -0
- core/schemas/propagation.py +88 -0
- core/schemas/requests.py +773 -0
- core/schemas/resolver_json_schema.py +65 -0
- core/schemas/responses.py +690 -0
- core/schemas/webhooks.py +40 -0
- core/shortcuts.py +415 -0
- core/tests/__init__.py +0 -0
- core/tests/auth_helpers.py +23 -0
- core/tests/namespace_helpers.py +29 -0
- core/tests/test_api_cli.py +43 -0
- core/tests/test_api_errors.py +151 -0
- core/tests/test_artifacts.py +123 -0
- core/tests/test_audit.py +973 -0
- core/tests/test_audit_event_id.py +90 -0
- core/tests/test_audit_operations.py +222 -0
- core/tests/test_builtin_config_schemas.py +174 -0
- core/tests/test_can_i.py +510 -0
- core/tests/test_cast_format_options.py +57 -0
- core/tests/test_cast_options_schema.py +54 -0
- core/tests/test_cast_yaml.py +26 -0
- core/tests/test_config_schema_api.py +173 -0
- core/tests/test_config_validation.py +378 -0
- core/tests/test_document_parser.py +122 -0
- core/tests/test_dump_openapi.py +46 -0
- core/tests/test_extend_key.py +267 -0
- core/tests/test_folder_get_item.py +23 -0
- core/tests/test_folder_resolve_missing_tags.py +73 -0
- core/tests/test_get_item_version.py +31 -0
- core/tests/test_global_permission_create.py +171 -0
- core/tests/test_list_pagination.py +262 -0
- core/tests/test_locks.py +209 -0
- core/tests/test_multiline_transformer.py +132 -0
- core/tests/test_namespace_create.py +11 -0
- core/tests/test_ocmo_metadata.py +127 -0
- core/tests/test_oidc_jwt_audiences.py +64 -0
- core/tests/test_omit_transformer.py +78 -0
- core/tests/test_operation_ids.py +47 -0
- core/tests/test_permissions.py +1148 -0
- core/tests/test_propagation.py +441 -0
- core/tests/test_public_base_url.py +20 -0
- core/tests/test_resolve_draft.py +176 -0
- core/tests/test_resolve_parameters_version.py +181 -0
- core/tests/test_resolve_permissions.py +492 -0
- core/tests/test_resolve_resolver_response.py +107 -0
- core/tests/test_resolver_auth.py +33 -0
- core/tests/test_resolver_folder_filters.py +95 -0
- core/tests/test_resolver_get_item.py +23 -0
- core/tests/test_resolver_navigate_search_scope.py +98 -0
- core/tests/test_resolver_schema_api.py +91 -0
- core/tests/test_security_hardening.py +324 -0
- core/tests/test_selectors.py +65 -0
- core/tests/test_special_namespace_paths.py +409 -0
- core/tests/test_stable_promotion.py +124 -0
- core/tests/test_system.py +140 -0
- core/tests/test_tree_manager_for_item.py +192 -0
- core/tests/test_two_layer_cache.py +334 -0
- core/tests/test_url_trailing_slashes.py +53 -0
- core/tests/test_validation_error_responses.py +43 -0
- core/tests/test_webhooks.py +985 -0
- core/utils/__init__.py +23 -0
- core/utils/deep_merge.py +91 -0
- core/utils/namespace_special_configs.py +105 -0
- core/utils/permissions_compiler.py +627 -0
- core/utils/permissions_schema_document.py +52 -0
- core/validation_errors.py +105 -0
- core/version.py +27 -0
- core/views.py +1 -0
- ocmo_api-0.8.19.dist-info/METADATA +252 -0
- ocmo_api-0.8.19.dist-info/RECORD +203 -0
- ocmo_api-0.8.19.dist-info/WHEEL +4 -0
- ocmo_api-0.8.19.dist-info/entry_points.txt +2 -0
- ocmo_api-0.8.19.dist-info/licenses/LICENSE +201 -0
- ocmo_api-0.8.19.dist-info/licenses/NOTICE +16 -0
- ocmoapi/__init__.py +1 -0
- ocmoapi/__main__.py +6 -0
- ocmoapi/asgi.py +16 -0
- ocmoapi/auth.py +163 -0
- ocmoapi/cli.py +85 -0
- ocmoapi/oauth2_provider.py +23 -0
- ocmoapi/parser.py +120 -0
- ocmoapi/settings.py +275 -0
- ocmoapi/templates/ninja/oauth2-redirect.html +81 -0
- ocmoapi/templates/ninja/swagger.html +55 -0
- ocmoapi/testing_auth.py +64 -0
- ocmoapi/urls.py +40 -0
- 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)
|