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,409 @@
1
+ """Access control for built-in namespace configs and companion secrets."""
2
+
3
+ from unittest.mock import patch
4
+
5
+ from django.test import Client, TestCase, override_settings
6
+
7
+ from core.exceptions import NotFound, PermissionDenied
8
+ from core.managers.auth import AuthManager
9
+ from core.managers.permissions import PermissionsManager
10
+ from core.managers.resolver_tokens import ResolverTokenManager
11
+ from core.managers.tree import TreeManager
12
+ from core.managers.tree_capabilities import compute_tree_capabilities
13
+ from core.models import GlobalPermissionRule, Namespace
14
+ from core.utils.namespace_special_configs import init_namespace_special_configs
15
+ from core.utils.permissions_compiler import PermissionsCompiler
16
+
17
+ _SPECIAL_PATHS = (
18
+ "_permissions",
19
+ "_webhooks",
20
+ "_git_sync",
21
+ "_permissions.schema",
22
+ "_webhooks.schema",
23
+ "_git_sync.schema",
24
+ "_webhooks_secret",
25
+ "_git_sync_secret",
26
+ )
27
+ _SPECIAL_CONFIGS = ("_permissions", "_webhooks", "_git_sync")
28
+
29
+ _PERMISSIONS = """\
30
+ policies:
31
+ - effect: Allow
32
+ actors:
33
+ - kind: User
34
+ claims:
35
+ email: "*"
36
+ actions:
37
+ - "*:*"
38
+ resources:
39
+ - "**"
40
+ """
41
+
42
+ _EXTEND_YAML = """\
43
+ _ocmo:
44
+ extend:
45
+ mode: accumulate
46
+ configs:
47
+ - _permissions@latest
48
+ key: value
49
+ """
50
+
51
+ _SECRET_PARAM_YAML = """\
52
+ _ocmo:
53
+ parameters:
54
+ hmac_key:
55
+ type: secret
56
+ value: _webhooks_secret@latest
57
+ description: HMAC signing key for webhook payloads
58
+ key: "{!hmac_key}"
59
+ """
60
+
61
+ _WEBHOOKS_WITH_SECRET = """\
62
+ _ocmo:
63
+ parameters:
64
+ hmac_key:
65
+ type: secret
66
+ value: _webhooks_secret@latest
67
+ description: HMAC signing key for webhook payloads
68
+ webhooks: []
69
+ """
70
+
71
+
72
+ def _grant_namespace_read():
73
+ GlobalPermissionRule.objects.create(
74
+ position=1.0,
75
+ rule={
76
+ "namespace": "special-*",
77
+ "read": {"actors": [{"kind": "User", "claims": {"email": "*"}}]},
78
+ },
79
+ )
80
+
81
+
82
+ def _grant_namespace_write(email: str):
83
+ GlobalPermissionRule.objects.create(
84
+ position=1.0,
85
+ rule={
86
+ "namespace": "special-*",
87
+ "write": {"actors": [{"kind": "User", "claims": {"email": email}}]},
88
+ },
89
+ )
90
+
91
+
92
+ _TEST_MASTER_KEY = "ZDPuvW6Hx/1UxDK7K/CydLouVKtJl24nbHyb2EkvTzs="
93
+
94
+
95
+ @override_settings(OCMO_MASTER_KEY=_TEST_MASTER_KEY)
96
+ class SpecialNamespacePathsTestCase(TestCase):
97
+ def setUp(self):
98
+ PermissionsCompiler._policy_cache.clear()
99
+ PermissionsCompiler._global_cache.clear()
100
+ self.client = Client()
101
+ self.ns = Namespace.objects.create(name="special-ns-test", description="test")
102
+ init_namespace_special_configs(self.ns)
103
+ TreeManager(self.ns, "_permissions", auth=None).update_item(_PERMISSIONS)
104
+ TreeManager(self.ns, "app/cfg", auth=None).create_item("key: value\n", "config")
105
+ TreeManager(self.ns, "app/folder", auth=None).create_item("nested: true\n", "config")
106
+
107
+ def tearDown(self):
108
+ PermissionsCompiler._policy_cache.clear()
109
+ PermissionsCompiler._global_cache.clear()
110
+
111
+ def _reader_auth(self) -> AuthManager:
112
+ return AuthManager(
113
+ {
114
+ "_type": "user",
115
+ "email": "reader@example.com",
116
+ "name": "Reader",
117
+ }
118
+ )
119
+
120
+ def _writer_auth(self) -> AuthManager:
121
+ return AuthManager(
122
+ {
123
+ "_type": "user",
124
+ "email": "writer@example.com",
125
+ "name": "Writer",
126
+ }
127
+ )
128
+
129
+ def _patch_client_auth(self, auth: AuthManager):
130
+ return patch(
131
+ "core.managers.auth.AuthManager.from_request",
132
+ return_value=auth,
133
+ )
134
+
135
+ def _create_resolver(self, path: str = "app/svc") -> tuple[str, str]:
136
+ TreeManager(self.ns, path, auth=None).create_item("{}", "resolver")
137
+ resolver = TreeManager(self.ns, path, auth=None).get_or_raise(["resolver"])
138
+ return path, (mgr.plaintext if (mgr := ResolverTokenManager.from_resolver(resolver, 1)) else None)
139
+
140
+
141
+ class VisibilityTests(SpecialNamespacePathsTestCase):
142
+ def test_navigate_root_hides_special_paths_for_read_only_user(self):
143
+ _grant_namespace_read()
144
+ auth = self._reader_auth()
145
+ result = TreeManager(self.ns, "", auth=auth).navigate()
146
+ child_paths = {child["path"] for child in result["children"]}
147
+ for path in _SPECIAL_PATHS:
148
+ self.assertNotIn(path, child_paths)
149
+
150
+ def test_navigate_root_shows_special_paths_for_write_user(self):
151
+ _grant_namespace_write("writer@example.com")
152
+ auth = self._writer_auth()
153
+ result = TreeManager(self.ns, "", auth=auth).navigate()
154
+ child_paths = {child["path"] for child in result["children"]}
155
+ for path in _SPECIAL_PATHS:
156
+ self.assertIn(path, child_paths)
157
+
158
+ def test_search_hides_special_paths_for_read_only_user(self):
159
+ _grant_namespace_read()
160
+ auth = self._reader_auth()
161
+ results = TreeManager(self.ns, "", auth=auth).search(query="_permissions")
162
+ self.assertEqual(results, [])
163
+
164
+ def test_direct_navigate_special_path_returns_not_found_for_reader(self):
165
+ _grant_namespace_read()
166
+ auth = self._reader_auth()
167
+ with self.assertRaises(NotFound):
168
+ TreeManager(self.ns, "_permissions", auth=auth).navigate()
169
+
170
+ def test_api_navigate_root_hides_special_paths_for_reader(self):
171
+ _grant_namespace_read()
172
+ with self._patch_client_auth(self._reader_auth()):
173
+ resp = self.client.get(f"/api/v1/ns/{self.ns.name}/~navigate/")
174
+ self.assertEqual(resp.status_code, 200, resp.content)
175
+ child_paths = {child["path"] for child in resp.json()["children"]}
176
+ for path in _SPECIAL_PATHS:
177
+ self.assertNotIn(path, child_paths)
178
+
179
+
180
+ class ImmutabilityTests(SpecialNamespacePathsTestCase):
181
+ def test_delete_special_paths_denied_for_writer(self):
182
+ _grant_namespace_write("writer@example.com")
183
+ auth = self._writer_auth()
184
+ for path in _SPECIAL_PATHS:
185
+ with self.subTest(path=path):
186
+ with self.assertRaises(PermissionDenied):
187
+ TreeManager(self.ns, path, auth=auth).delete_item(preview=False)
188
+
189
+ def test_delete_version_denied_for_special_configs_and_secrets(self):
190
+ _grant_namespace_write("writer@example.com")
191
+ auth = self._writer_auth()
192
+ for path in _SPECIAL_PATHS:
193
+ with self.subTest(path=path):
194
+ with self.assertRaises(PermissionDenied):
195
+ TreeManager(self.ns, path, auth=auth).delete_item(preview=False, version=1)
196
+
197
+ def test_move_special_path_denied(self):
198
+ _grant_namespace_write("writer@example.com")
199
+ auth = self._writer_auth()
200
+ with self.assertRaises(PermissionDenied):
201
+ TreeManager(self.ns, "_permissions", auth=auth).move_item("other/_permissions")
202
+
203
+ def test_copy_special_path_denied(self):
204
+ _grant_namespace_write("writer@example.com")
205
+ auth = self._writer_auth()
206
+ with self.assertRaises(PermissionDenied):
207
+ TreeManager(self.ns, "_permissions", auth=auth).copy_item("copy/_permissions")
208
+
209
+ def test_api_delete_special_config_returns_403(self):
210
+ resp = self.client.delete(f"/api/v1/ns/{self.ns.name}/~delete/_permissions")
211
+ self.assertEqual(resp.status_code, 403, resp.content)
212
+
213
+
214
+ class ResolveTests(SpecialNamespacePathsTestCase):
215
+ def test_resolve_special_config_denied_for_reader(self):
216
+ _grant_namespace_read()
217
+ with self._patch_client_auth(self._reader_auth()):
218
+ resp = self.client.get(f"/api/v1/ns/{self.ns.name}/~resolve/_permissions")
219
+ self.assertEqual(resp.status_code, 403, resp.content)
220
+
221
+ def test_resolve_special_config_allowed_for_writer(self):
222
+ _grant_namespace_write("writer@example.com")
223
+ with self._patch_client_auth(self._writer_auth()):
224
+ resp = self.client.get(f"/api/v1/ns/{self.ns.name}/~resolve/_permissions")
225
+ self.assertEqual(resp.status_code, 200, resp.content)
226
+
227
+ def test_resolve_parameters_special_config_denied_for_reader(self):
228
+ _grant_namespace_read()
229
+ with self._patch_client_auth(self._reader_auth()):
230
+ resp = self.client.get(f"/api/v1/ns/{self.ns.name}/~resolve-parameters/_permissions")
231
+ self.assertEqual(resp.status_code, 403, resp.content)
232
+
233
+ def test_resolver_cannot_resolve_special_config(self):
234
+ path, _token = self._create_resolver()
235
+ auth = AuthManager(
236
+ {
237
+ "_type": "resolver",
238
+ "namespace": self.ns.id,
239
+ "name": path.split("/")[-1],
240
+ "access_scope": "/".join(path.split("/")[:-1]),
241
+ "token_number": 1,
242
+ }
243
+ )
244
+ self.assertFalse(TreeManager(self.ns, "_permissions", auth=auth).is_resolvable)
245
+
246
+ def test_folder_resolve_skips_special_configs(self):
247
+ _grant_namespace_write("writer@example.com")
248
+ with self._patch_client_auth(self._writer_auth()):
249
+ resp = self.client.get(f"/api/v1/ns/{self.ns.name}/~resolve/app")
250
+ self.assertEqual(resp.status_code, 200, resp.content)
251
+ names = {item["name"] for item in resp.json()["items"]}
252
+ for special in _SPECIAL_CONFIGS:
253
+ self.assertNotIn(special, names)
254
+ self.assertNotIn(special.split("/")[-1], names)
255
+
256
+ def test_include_cannot_override_special_folder_resolve_exclusion(self):
257
+ from core.managers.resolution import ResolutionManager
258
+ from core.models import Config
259
+
260
+ auth = self._writer_auth()
261
+ _grant_namespace_write("writer@example.com")
262
+ mgr = ResolutionManager(
263
+ self.ns,
264
+ "app",
265
+ auth=auth,
266
+ query_params={},
267
+ base_url="http://testserver/",
268
+ )
269
+ mgr.resolver_config = {"include": ["**", "_permissions"]}
270
+ configs = list(
271
+ Config.objects.filter(
272
+ namespace=self.ns,
273
+ path__in=["_permissions", "app/cfg", "app/folder"],
274
+ )
275
+ )
276
+ configs = [cfg for cfg in configs if TreeManager.for_item(self.ns, cfg, auth=auth).is_folder_resolvable]
277
+ filtered = mgr._filter_configs(configs)
278
+ filtered_paths = {cfg.path for cfg in filtered}
279
+ self.assertNotIn("_permissions", filtered_paths)
280
+ self.assertIn("app/cfg", filtered_paths)
281
+
282
+
283
+ class ExtendAndSecretParamTests(SpecialNamespacePathsTestCase):
284
+ def test_config_update_with_extend_to_special_config_denied(self):
285
+ _grant_namespace_write("writer@example.com")
286
+ auth = self._writer_auth()
287
+ with self.assertRaises(PermissionDenied):
288
+ TreeManager(self.ns, "app/cfg", auth=auth).update_item(_EXTEND_YAML)
289
+
290
+ def test_config_update_with_forbidden_secret_param_denied(self):
291
+ _grant_namespace_write("writer@example.com")
292
+ auth = self._writer_auth()
293
+ with self.assertRaises(PermissionDenied):
294
+ TreeManager(self.ns, "app/cfg", auth=auth).update_item(_SECRET_PARAM_YAML)
295
+
296
+ def test_webhooks_config_may_reference_companion_secret(self):
297
+ _grant_namespace_write("writer@example.com")
298
+ auth = self._writer_auth()
299
+ item = TreeManager(self.ns, "_webhooks", auth=auth).update_item(_WEBHOOKS_WITH_SECRET)
300
+ self.assertEqual(item.path, "_webhooks")
301
+
302
+ def test_resolve_config_with_extend_to_special_config_denied(self):
303
+ from core.managers.resolving import ResolvePipelineManager
304
+
305
+ TreeManager(self.ns, "app/bad", auth=None).create_item("key: value\n", "config")
306
+ from core.models import ConfigVersion
307
+
308
+ ConfigVersion.objects.filter(config__namespace=self.ns, config__path="app/bad", version=1).update(
309
+ data=_EXTEND_YAML
310
+ )
311
+
312
+ with self.assertRaises(PermissionDenied):
313
+ ResolvePipelineManager(
314
+ self.ns,
315
+ "app/bad",
316
+ "latest",
317
+ auth=self._writer_auth(),
318
+ ).resolve()
319
+
320
+
321
+ class TreeCapabilitiesUnitTests(SpecialNamespacePathsTestCase):
322
+ def test_ordinary_path_all_capabilities_true(self):
323
+ _grant_namespace_read()
324
+ caps = compute_tree_capabilities(
325
+ self.ns,
326
+ "app/cfg",
327
+ self._reader_auth(),
328
+ )
329
+ self.assertTrue(caps.is_visible)
330
+ self.assertTrue(caps.is_deletable)
331
+ self.assertTrue(caps.is_extend_target)
332
+
333
+ def test_auth_none_all_capabilities_true(self):
334
+ caps = compute_tree_capabilities(self.ns, "_permissions", None)
335
+ self.assertTrue(caps.is_resolvable)
336
+ self.assertTrue(caps.is_deletable)
337
+
338
+ def test_builtin_config_reader_cannot_resolve_or_extend(self):
339
+ _grant_namespace_read()
340
+ auth = self._reader_auth()
341
+ caps = compute_tree_capabilities(self.ns, "_permissions", auth)
342
+ self.assertFalse(caps.is_visible)
343
+ self.assertFalse(caps.is_resolvable)
344
+ self.assertFalse(caps.is_extend_target)
345
+ self.assertFalse(caps.is_deletable)
346
+
347
+ def test_builtin_config_writer_can_resolve_not_extend(self):
348
+ _grant_namespace_write("writer@example.com")
349
+ auth = self._writer_auth()
350
+ caps = compute_tree_capabilities(self.ns, "_webhooks", auth)
351
+ self.assertTrue(caps.is_visible)
352
+ self.assertTrue(caps.is_resolvable)
353
+ self.assertFalse(caps.is_extend_target)
354
+ self.assertFalse(caps.is_folder_resolvable)
355
+
356
+ def test_builtin_config_resolver_cannot_resolve_even_with_write(self):
357
+ _grant_namespace_write("writer@example.com")
358
+ path, _token = self._create_resolver()
359
+ auth = AuthManager(
360
+ {
361
+ "_type": "resolver",
362
+ "namespace": self.ns.id,
363
+ "name": path.split("/")[-1],
364
+ "access_scope": "/".join(path.split("/")[:-1]),
365
+ "token_number": 1,
366
+ }
367
+ )
368
+ caps = compute_tree_capabilities(self.ns, "_permissions", auth)
369
+ self.assertFalse(caps.is_resolvable)
370
+
371
+ def test_companion_secret_param_only_from_parent_config(self):
372
+ _grant_namespace_write("writer@example.com")
373
+ auth = self._writer_auth()
374
+ ok = compute_tree_capabilities(
375
+ self.ns,
376
+ "_webhooks_secret",
377
+ auth,
378
+ referencing_config_path="_webhooks",
379
+ )
380
+ self.assertTrue(ok.is_available_for_param)
381
+ bad = compute_tree_capabilities(
382
+ self.ns,
383
+ "_webhooks_secret",
384
+ auth,
385
+ referencing_config_path="app/cfg",
386
+ )
387
+ self.assertFalse(bad.is_available_for_param)
388
+
389
+
390
+ class PermissionsUnitTests(SpecialNamespacePathsTestCase):
391
+ def test_resolver_cannot_resolve_special_path_even_in_scope(self):
392
+ _grant_namespace_read()
393
+ auth = AuthManager(
394
+ {
395
+ "_type": "resolver",
396
+ "namespace": self.ns.id,
397
+ "name": "svc",
398
+ "access_scope": "",
399
+ "token_number": 1,
400
+ }
401
+ )
402
+ pm = PermissionsManager(auth, self.ns)
403
+ self.assertFalse(pm.check_tree("config:resolve", "_permissions"))
404
+
405
+ def test_writer_can_read_special_path(self):
406
+ _grant_namespace_write("writer@example.com")
407
+ auth = self._writer_auth()
408
+ pm = PermissionsManager(auth, self.ns)
409
+ self.assertTrue(pm.check_tree("config:read", "_permissions"))
@@ -0,0 +1,124 @@
1
+ from django.test import Client, TestCase
2
+
3
+ from core.exceptions import ReservedTagsCantBeSet
4
+ from core.managers.tree import TreeManager
5
+ from core.models import Config
6
+ from core.schemas.requests import TagPayload
7
+ from core.tests.namespace_helpers import create_test_namespace
8
+
9
+
10
+ class StablePromotionApiTests(TestCase):
11
+ def setUp(self):
12
+ self.client = Client()
13
+ self.ns = create_test_namespace("stabletest", description="test")
14
+ self._create_config("app/cfg", "key: one\n")
15
+ self._create_config("app/cfg2", "key: two\n")
16
+
17
+ def _create_config(self, path: str, body: bytes):
18
+ response = self.client.post(
19
+ f"/api/v1/ns/{self.ns.name}/~config/~create/{path}",
20
+ data=body,
21
+ content_type="application/yaml",
22
+ )
23
+ self.assertEqual(response.status_code, 201, response.content)
24
+
25
+ def _update_config(self, path: str, body: bytes):
26
+ response = self.client.put(
27
+ f"/api/v1/ns/{self.ns.name}/~config/~update/{path}",
28
+ data=body,
29
+ content_type="application/yaml",
30
+ )
31
+ self.assertEqual(response.status_code, 200, response.content)
32
+
33
+ def _resolve(self, path: str, **params):
34
+ query = "&".join(f"{k}={v}" for k, v in params.items())
35
+ url = f"/api/v1/ns/{self.ns.name}/~resolve/{path}"
36
+ if query:
37
+ url = f"{url}?{query}"
38
+ return self.client.get(url)
39
+
40
+ def _get_config(self, path: str):
41
+ return Config.objects.get(namespace=self.ns, path=path)
42
+
43
+ def test_stable_promoted_on_successful_resolve(self):
44
+ response = self._resolve("app/cfg", **{"mark-stable": "true"})
45
+ self.assertEqual(response.status_code, 200, response.content)
46
+
47
+ cfg = self._get_config("app/cfg")
48
+ self.assertEqual(cfg.tags.get("stable"), 1)
49
+
50
+ def test_stable_not_promoted_by_default(self):
51
+ response = self._resolve("app/cfg")
52
+ self.assertEqual(response.status_code, 200, response.content)
53
+
54
+ cfg = self._get_config("app/cfg")
55
+ self.assertNotIn("stable", cfg.tags)
56
+
57
+ def test_stable_promotion_is_idempotent(self):
58
+ first = self._resolve("app/cfg", **{"mark-stable": "true"})
59
+ self.assertEqual(first.status_code, 200, first.content)
60
+
61
+ second = self._resolve("app/cfg", **{"mark-stable": "true"})
62
+ self.assertEqual(second.status_code, 200, second.content)
63
+
64
+ cfg = self._get_config("app/cfg")
65
+ self.assertEqual(cfg.tags.get("stable"), 1)
66
+
67
+ def test_trace_only_skips_stable_promotion(self):
68
+ response = self._resolve("app/cfg", **{"mark-stable": "true", "trace_only": "true"})
69
+ self.assertEqual(response.status_code, 200, response.content)
70
+
71
+ cfg = self._get_config("app/cfg")
72
+ self.assertNotIn("stable", cfg.tags)
73
+
74
+ def test_folder_resolve_promotes_all_configs(self):
75
+ response = self._resolve("app", **{"mark-stable": "true"})
76
+ self.assertEqual(response.status_code, 200, response.content)
77
+
78
+ data = response.json()
79
+ self.assertEqual(data["length"], 2)
80
+
81
+ self.assertEqual(self._get_config("app/cfg").tags.get("stable"), 1)
82
+ self.assertEqual(self._get_config("app/cfg2").tags.get("stable"), 1)
83
+
84
+ def test_folder_partial_failure_leaves_tags_unchanged(self):
85
+ self._update_config("app/cfg2", b"key: v2\n")
86
+
87
+ response = self._resolve("app", **{"mark-stable": "true", "version": "2"})
88
+ self.assertEqual(response.status_code, 404, response.content)
89
+
90
+ self.assertNotIn("stable", self._get_config("app/cfg").tags)
91
+ self.assertNotIn("stable", self._get_config("app/cfg2").tags)
92
+
93
+ def test_stable_advances_to_new_version_after_update(self):
94
+ self._resolve("app/cfg", **{"mark-stable": "true"})
95
+ self._update_config("app/cfg", b"key: updated\n")
96
+ self._resolve("app/cfg", **{"mark-stable": "true"})
97
+
98
+ cfg = self._get_config("app/cfg")
99
+ self.assertEqual(cfg.tags.get("stable"), 2)
100
+
101
+
102
+ class StablePromotionManagerTests(TestCase):
103
+ def setUp(self):
104
+ self.ns = create_test_namespace("stablemgr", description="test")
105
+ TreeManager(self.ns, "cfg", auth=None).create_item("hello: world\n", "config")
106
+
107
+ def test_reserved_stable_tag_cannot_be_set_via_tag_api(self):
108
+ tm = TreeManager(self.ns, "cfg", auth=None)
109
+ with self.assertRaises(ReservedTagsCantBeSet):
110
+ tm.set_item_tag(TagPayload(tag="stable", version=1))
111
+
112
+ def test_promote_stable_tag_updates_config(self):
113
+ tm = TreeManager(self.ns, "cfg", auth=None)
114
+ changed = tm.promote_stable_tag(1)
115
+ self.assertTrue(changed)
116
+
117
+ cfg = Config.objects.get(namespace=self.ns, path="cfg")
118
+ self.assertEqual(cfg.tags["stable"], 1)
119
+
120
+ def test_promote_stable_tag_noop_when_unchanged(self):
121
+ tm = TreeManager(self.ns, "cfg", auth=None)
122
+ tm.promote_stable_tag(1)
123
+ changed = tm.promote_stable_tag(1)
124
+ self.assertFalse(changed)
@@ -0,0 +1,140 @@
1
+ """Tests for operational health and version endpoints."""
2
+
3
+ from unittest.mock import patch
4
+
5
+ from django.core.cache import caches
6
+ from django.test import Client, TestCase, override_settings
7
+
8
+ from core.managers.health import HealthManager
9
+
10
+
11
+ class VersionEndpointTests(TestCase):
12
+ def setUp(self):
13
+ self.client = Client()
14
+
15
+ def test_version_returns_product_and_version(self):
16
+ response = self.client.get("/api/version")
17
+ self.assertEqual(response.status_code, 200)
18
+ data = response.json()
19
+ self.assertEqual(data["product"], "ocmo")
20
+ self.assertRegex(data["version"], r"^\d+\.\d+\.\d+")
21
+ self.assertEqual(data["license"], "Apache-2.0")
22
+ self.assertEqual(data["license_name"], "Apache License, Version 2.0")
23
+ self.assertNotIn("notice", data)
24
+ self.assertEqual(data["config_metadata_key"], "_ocmo")
25
+ self.assertIn("builtin_namespace_paths", data)
26
+ self.assertEqual(
27
+ data["builtin_namespace_paths"]["config"],
28
+ ["_git_sync", "_permissions", "_webhooks"],
29
+ )
30
+ self.assertEqual(
31
+ data["builtin_namespace_paths"]["secret"],
32
+ ["_git_sync_secret", "_webhooks_secret"],
33
+ )
34
+ self.assertIn("_permissions.schema", data["builtin_namespace_paths"]["schema"])
35
+ self.assertIn("_permissions", data["builtin_namespace_paths"]["order"])
36
+ self.assertEqual(data["reserved_tags"]["config"], ["latest", "stable"])
37
+ self.assertEqual(data["reserved_tags"]["template"], ["latest"])
38
+ self.assertEqual(data["reserved_tags"]["secret"], ["latest"])
39
+ self.assertIn("auth", data)
40
+ self.assertIn("oidc", data["auth"])
41
+
42
+ @override_settings(
43
+ OIDC_ISSUER="http://localhost:8080/dex",
44
+ OIDC_CLIENT_ID="ocmo-api",
45
+ OIDC_AUTHORIZATION_URL="http://localhost:8080/dex/auth",
46
+ OIDC_TOKEN_URL="http://localhost:8080/dex/token",
47
+ OIDC_SCOPES="openid profile email groups",
48
+ )
49
+ def test_version_returns_oidc_auth(self):
50
+ response = self.client.get("/api/version")
51
+ self.assertEqual(response.status_code, 200)
52
+ oidc = response.json()["auth"]["oidc"]
53
+ self.assertEqual(oidc["issuer"], "http://localhost:8080/dex")
54
+ self.assertEqual(oidc["client_id"], "ocmo-api")
55
+ self.assertEqual(oidc["authorization_url"], "http://localhost:8080/dex/auth")
56
+ self.assertEqual(oidc["token_url"], "http://localhost:8080/dex/token")
57
+ self.assertEqual(oidc["scopes"], "openid profile email groups")
58
+
59
+ @override_settings(OCMO_CONFIG_METADATA_KEY="meta")
60
+ def test_version_returns_config_metadata_key_from_settings(self):
61
+ response = self.client.get("/api/version")
62
+ self.assertEqual(response.status_code, 200)
63
+ self.assertEqual(response.json()["config_metadata_key"], "meta")
64
+
65
+ def test_version_does_not_require_auth(self):
66
+ response = self.client.get("/api/version")
67
+ self.assertEqual(response.status_code, 200)
68
+
69
+ def test_version_includes_notice_when_requested(self):
70
+ response = self.client.get("/api/version", {"notice": "true"})
71
+ self.assertEqual(response.status_code, 200)
72
+ data = response.json()
73
+ self.assertIn("notice", data)
74
+ self.assertIn("STATEMENT ON RUSSIAN WAR CRIMES IN UKRAINE", data["notice"])
75
+ self.assertIn("OCMO", data["notice"])
76
+
77
+
78
+ class HealthEndpointTests(TestCase):
79
+ def setUp(self):
80
+ self.client = Client()
81
+
82
+ def test_health_ok_when_database_available(self):
83
+ response = self.client.get("/api/health")
84
+ self.assertEqual(response.status_code, 200)
85
+ data = response.json()
86
+ self.assertEqual(data["status"], "ok")
87
+ self.assertEqual(data["checks"]["database"]["status"], "ok")
88
+ self.assertNotIn("resolve_cache_redis", data["checks"])
89
+ self.assertNotIn("resolve_artifact_redis", data["checks"])
90
+
91
+ def test_health_does_not_require_auth(self):
92
+ response = self.client.get("/api/health")
93
+ self.assertEqual(response.status_code, 200)
94
+
95
+ def test_health_reports_database_failure(self):
96
+ with patch("core.managers.health.connection.ensure_connection", side_effect=RuntimeError("db down")):
97
+ response = self.client.get("/api/health")
98
+ self.assertEqual(response.status_code, 503)
99
+ data = response.json()
100
+ self.assertEqual(data["status"], "error")
101
+ self.assertEqual(data["checks"]["database"]["status"], "error")
102
+ self.assertIn("db down", data["checks"]["database"]["message"])
103
+
104
+ @override_settings(OCMO_RESOLVE_CACHE_BACKEND="redis")
105
+ def test_health_checks_resolve_cache_redis_when_configured(self):
106
+ caches["resolve"].clear()
107
+ response = self.client.get("/api/health")
108
+ self.assertEqual(response.status_code, 200)
109
+ self.assertEqual(response.json()["checks"]["resolve_cache_redis"]["status"], "ok")
110
+
111
+ @override_settings(OCMO_RESOLVE_CACHE_BACKEND="redis")
112
+ def test_health_reports_resolve_cache_redis_failure(self):
113
+ with patch.object(
114
+ HealthManager,
115
+ "_check_resolve_cache_redis",
116
+ return_value={"status": "error", "message": "redis down"},
117
+ ):
118
+ response = self.client.get("/api/health")
119
+ self.assertEqual(response.status_code, 503)
120
+ data = response.json()
121
+ self.assertEqual(data["checks"]["resolve_cache_redis"]["status"], "error")
122
+ self.assertIn("redis down", data["checks"]["resolve_cache_redis"]["message"])
123
+
124
+ @override_settings(OCMO_RESOLVE_ARTIFACT_BACKEND="redis")
125
+ def test_health_checks_resolve_artifact_redis_when_configured(self):
126
+ with patch("redis.from_url") as mock_from_url:
127
+ mock_from_url.return_value.ping.return_value = True
128
+ response = self.client.get("/api/health")
129
+ self.assertEqual(response.status_code, 200)
130
+ self.assertEqual(response.json()["checks"]["resolve_artifact_redis"]["status"], "ok")
131
+
132
+ @override_settings(OCMO_RESOLVE_ARTIFACT_BACKEND="redis")
133
+ def test_health_reports_resolve_artifact_redis_failure(self):
134
+ with patch("redis.from_url") as mock_from_url:
135
+ mock_from_url.return_value.ping.side_effect = RuntimeError("artifact redis down")
136
+ response = self.client.get("/api/health")
137
+ self.assertEqual(response.status_code, 503)
138
+ data = response.json()
139
+ self.assertEqual(data["checks"]["resolve_artifact_redis"]["status"], "error")
140
+ self.assertIn("artifact redis down", data["checks"]["resolve_artifact_redis"]["message"])