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,192 @@
1
+ import json
2
+
3
+ from django.core.exceptions import ValidationError
4
+ from django.db import connection
5
+ from django.test import TestCase
6
+ from django.test.utils import CaptureQueriesContext
7
+
8
+ from core.managers.tree import TreeManager
9
+ from core.models import Config, TreeItem
10
+ from core.tests.namespace_helpers import create_test_namespace
11
+
12
+
13
+ class TreeManagerForItemTests(TestCase):
14
+ def setUp(self):
15
+ self.ns = create_test_namespace("for-item-test", description="test")
16
+ TreeManager(self.ns, "app/cfg1", auth=None).create_item("key: one\n", "config")
17
+ TreeManager(self.ns, "app/cfg2", auth=None).create_item("key: two\n", "config")
18
+ self.cfg1 = Config.objects.get(namespace=self.ns, path="app/cfg1")
19
+ self.folder = TreeItem.objects.get(namespace=self.ns, path="app", node_type="folder")
20
+
21
+ def test_for_item_matches_path_based_init(self):
22
+ by_path = TreeManager(self.ns, "app/cfg1", auth=None)
23
+ by_item = TreeManager.for_item(self.ns, self.cfg1, auth=None)
24
+
25
+ self.assertEqual(by_path.path, by_item.path)
26
+ self.assertEqual(by_path.item_type, by_item.item_type)
27
+ self.assertEqual(by_path.item.pk, by_item.item.pk)
28
+ self.assertEqual(
29
+ by_path.is_folder_resolvable,
30
+ by_item.is_folder_resolvable,
31
+ )
32
+
33
+ def test_for_item_accepts_folder_tree_item(self):
34
+ tm = TreeManager.for_item(self.ns, self.folder, auth=None)
35
+
36
+ self.assertEqual(tm.path, "app")
37
+ self.assertEqual(tm.item_type, "folder")
38
+ self.assertEqual(tm.item.node_type, "folder")
39
+
40
+ def test_for_item_rejects_namespace_mismatch(self):
41
+ other_ns = create_test_namespace("for-item-other", description="other")
42
+
43
+ with self.assertRaises(ValidationError):
44
+ TreeManager.for_item(other_ns, self.cfg1, auth=None)
45
+
46
+ def test_list_configs_under_folder_avoids_treeitem_refetch(self):
47
+ folder_tm = TreeManager.for_item(self.ns, self.folder, auth=None)
48
+
49
+ with CaptureQueriesContext(connection) as ctx:
50
+ configs = folder_tm.list_configs_under_folder(
51
+ ignore_configs_with_missing_tags=True,
52
+ version="latest",
53
+ )
54
+
55
+ self.assertEqual({cfg.path for cfg in configs}, {"app/cfg1", "app/cfg2"})
56
+ # One bulk config query plus per-config version tag resolution (no TreeManager.for_item N+1).
57
+ self.assertLessEqual(len(ctx.captured_queries), 3)
58
+
59
+ def test_delete_latest_tagged_version_updates_latest_pointer(self):
60
+ TreeManager(self.ns, "app/cfg1", auth=None).update_item("key: two\n")
61
+ cfg = Config.objects.get(namespace=self.ns, path="app/cfg1")
62
+ self.assertEqual(cfg.tags["latest"], 2)
63
+
64
+ TreeManager(self.ns, "app/cfg1", auth=None).delete_item(preview=False, version="2")
65
+
66
+ cfg.refresh_from_db()
67
+ self.assertEqual(cfg.tags["latest"], 1)
68
+ deleted_version = cfg.versions.get(version=2)
69
+ self.assertIsNotNone(deleted_version.deleted_at)
70
+
71
+ def test_delete_item_by_custom_tag(self):
72
+ TreeManager(self.ns, "app/cfg1", auth=None).update_item("key: two\n")
73
+ cfg = Config.objects.get(namespace=self.ns, path="app/cfg1")
74
+ cfg.tags["pinned"] = 2
75
+ cfg.save(update_fields=["tags"])
76
+
77
+ TreeManager(self.ns, "app/cfg1", auth=None).delete_item(preview=False, version="pinned")
78
+
79
+ deleted_version = cfg.versions.get(version=2)
80
+ self.assertIsNotNone(deleted_version.deleted_at)
81
+
82
+ def test_resolve_version_treats_deleted_version_as_not_found(self):
83
+ TreeManager(self.ns, "app/cfg1", auth=None).update_item("key: two\n")
84
+ TreeManager(self.ns, "app/cfg1", auth=None).delete_item(preview=False, version=2)
85
+
86
+ cfg = Config.objects.get(namespace=self.ns, path="app/cfg1")
87
+ cfg.tags["pinned"] = 2
88
+ cfg.save(update_fields=["tags"])
89
+
90
+ tm = TreeManager(self.ns, "app/cfg1", auth=None)
91
+
92
+ from core.exceptions import VersionNotFound
93
+
94
+ with self.assertRaises(VersionNotFound):
95
+ tm.resolve_version(cfg, "2")
96
+
97
+ with self.assertRaises(VersionNotFound):
98
+ tm.resolve_version(cfg, "pinned")
99
+
100
+ def test_move_single_config(self):
101
+ moved = TreeManager(self.ns, "app/cfg1", auth=None).move_item("moved/cfg1")
102
+
103
+ self.assertEqual(moved.path, "moved/cfg1")
104
+ self.assertEqual(moved.name, "cfg1")
105
+ self.assertTrue(Config.objects.filter(namespace=self.ns, path="moved/cfg1").exists())
106
+ self.assertFalse(Config.objects.filter(namespace=self.ns, path="app/cfg1").exists())
107
+ self.assertTrue(
108
+ TreeItem.objects.filter(namespace=self.ns, path="app", node_type="folder").exists(),
109
+ "sibling folder should keep parent folder alive",
110
+ )
111
+
112
+ def test_move_rename_in_same_folder_updates_name(self):
113
+ moved = TreeManager(self.ns, "app/cfg1", auth=None).move_item("app/cfg1-renamed")
114
+
115
+ self.assertEqual(moved.path, "app/cfg1-renamed")
116
+ self.assertEqual(moved.name, "cfg1-renamed")
117
+
118
+ def test_move_to_namespace_root_clears_parent(self):
119
+ moved = TreeManager(self.ns, "app/cfg1", auth=None).move_item("cfg1")
120
+
121
+ self.assertEqual(moved.path, "cfg1")
122
+ self.assertEqual(moved.name, "cfg1")
123
+ self.assertIsNone(moved.parent)
124
+
125
+ def test_move_folder_allows_destination_with_matching_suffix_segment(self):
126
+ TreeManager(self.ns, "tagtest/cfg", auth=None).create_item("key: x\n", "config")
127
+
128
+ moved = TreeManager(self.ns, "tagtest", auth=None).move_item("test/tagtest")
129
+
130
+ self.assertEqual(moved.path, "test/tagtest")
131
+ self.assertEqual(moved.name, "tagtest")
132
+ self.assertTrue(Config.objects.filter(namespace=self.ns, path="test/tagtest/cfg").exists())
133
+ self.assertFalse(TreeItem.objects.filter(namespace=self.ns, path="tagtest").exists())
134
+
135
+ def test_move_folder_rejects_move_into_descendant_path(self):
136
+ from core.exceptions import WrongMoveTargetException
137
+
138
+ with self.assertRaises(WrongMoveTargetException):
139
+ TreeManager(self.ns, "app", auth=None).move_item("app/sub")
140
+
141
+ def test_copy_config_to_full_destination_path(self):
142
+ result = TreeManager(self.ns, "app/cfg1", auth=None).copy_item("moved/cfg1")
143
+
144
+ self.assertIn("moved/cfg1", result["created"])
145
+ self.assertTrue(Config.objects.filter(namespace=self.ns, path="moved/cfg1").exists())
146
+ self.assertTrue(Config.objects.filter(namespace=self.ns, path="app/cfg1").exists())
147
+
148
+ def test_copy_folder_subtree(self):
149
+ result = TreeManager(self.ns, "app", auth=None).copy_item("moved/app")
150
+
151
+ self.assertIn("moved/app/cfg1", result["created"])
152
+ self.assertIn("moved/app/cfg2", result["created"])
153
+ self.assertTrue(Config.objects.filter(namespace=self.ns, path="moved/app/cfg1").exists())
154
+ self.assertTrue(Config.objects.filter(namespace=self.ns, path="moved/app/cfg2").exists())
155
+
156
+ def test_copy_resolver_to_new_path(self):
157
+ from core.models import Resolver
158
+
159
+ TreeManager(self.ns, "app/svc", auth=None).create_item('{"scope":"app"}', "resolver")
160
+ source = Resolver.objects.get(namespace=self.ns, path="app/svc")
161
+
162
+ result = TreeManager(self.ns, "app/svc", auth=None).copy_item("other/svc")
163
+
164
+ self.assertIn("other/svc", result["created"])
165
+ copied = Resolver.objects.get(namespace=self.ns, path="other/svc")
166
+ self.assertEqual(copied.configuration, source.configuration)
167
+ self.assertTrue(Resolver.objects.filter(namespace=self.ns, path="app/svc").exists())
168
+ self.assertIsNotNone(copied.token1_lookup)
169
+ self.assertNotEqual(copied.token1_lookup, source.token1_lookup)
170
+
171
+ def test_delete_custom_config_tag(self):
172
+ from core.schemas.requests import TagPayload
173
+
174
+ TreeManager(self.ns, "app/cfg1", auth=None).set_item_tag(
175
+ TagPayload(tag="demo", version=1),
176
+ )
177
+ TreeManager(self.ns, "app/cfg1", auth=None).delete_config_tag("demo")
178
+
179
+ cfg = Config.objects.get(namespace=self.ns, path="app/cfg1")
180
+ self.assertNotIn("demo", cfg.tags)
181
+
182
+ def test_update_resolver_configuration(self):
183
+ TreeManager(self.ns, "app/svc", auth=None).create_item("{}", "resolver")
184
+
185
+ updated = TreeManager(self.ns, "app/svc", auth=None).update_item(
186
+ '{"cast": {"format": "raw"}}',
187
+ )
188
+
189
+ self.assertEqual(
190
+ json.loads(updated.configuration),
191
+ {"cast": {"format": "raw"}},
192
+ )
@@ -0,0 +1,334 @@
1
+ """Unit tests for the two-layer resolve cache.
2
+
3
+ Covers:
4
+ - L1 hit when only the cast format differs (no pipeline run).
5
+ - L1 hit writes L2 so the next identical-cast request is a pure L2 hit.
6
+ - Secret-bearing resolutions skip L1 (different cast forces a full pipeline run).
7
+ - _cache_status returns correct tier string.
8
+ """
9
+
10
+ from unittest.mock import patch
11
+
12
+ from django.core.cache import caches
13
+ from django.test import Client, TestCase, override_settings
14
+
15
+ from core.managers.resolution import ResolutionManager
16
+ from core.managers.tree import TreeManager
17
+ from core.tests.namespace_helpers import create_test_namespace
18
+ from core.utils.permissions_compiler import PermissionsCompiler
19
+
20
+ _TEST_MASTER_KEY = "ZDPuvW6Hx/1UxDK7K/CydLouVKtJl24nbHyb2EkvTzs="
21
+
22
+ _CACHE_HEADER = "X-Ocmo-Resolve-Cache"
23
+
24
+
25
+ class TwoLayerCacheApiTests(TestCase):
26
+ """Integration-style tests exercising the cache via the HTTP API."""
27
+
28
+ def setUp(self):
29
+ caches["resolve"].clear()
30
+ self.client = Client()
31
+ self.ns = create_test_namespace("twolayer-test", description="test")
32
+ self._create_config("app/cfg", "key: value\nnum: 1\n")
33
+
34
+ def _create_config(self, path: str, body: str):
35
+ r = self.client.post(
36
+ f"/api/v1/ns/{self.ns.name}/~config/~create/{path}",
37
+ data=body.encode(),
38
+ content_type="application/yaml",
39
+ )
40
+ self.assertEqual(r.status_code, 201, r.content)
41
+
42
+ def _resolve(self, path: str, **params):
43
+ from urllib.parse import urlencode
44
+
45
+ qs = urlencode(params)
46
+ url = f"/api/v1/ns/{self.ns.name}/~resolve/{path}"
47
+ if qs:
48
+ url += f"?{qs}"
49
+ return self.client.get(url)
50
+
51
+ def _cache_header(self, response) -> str | None:
52
+ return response.get(_CACHE_HEADER)
53
+
54
+ # ------------------------------------------------------------------
55
+
56
+ def test_second_resolve_same_cast_is_l2_hit(self):
57
+ """Baseline: two identical resolves → second is a full L2 hit."""
58
+ r1 = self._resolve("app/cfg", cast="json")
59
+ self.assertEqual(r1.status_code, 200, r1.content)
60
+
61
+ r2 = self._resolve("app/cfg", cast="json")
62
+ self.assertEqual(r2.status_code, 200, r2.content)
63
+
64
+ self.assertEqual(self._cache_header(r1), "miss")
65
+ self.assertEqual(self._cache_header(r2), "hit")
66
+
67
+ def test_different_cast_uses_l1_no_pipeline(self):
68
+ """After warm with cast=yaml, requesting cast=json skips the pipeline (L1 hit)."""
69
+ # Warm cache with default (yaml) cast.
70
+ r1 = self._resolve("app/cfg")
71
+ self.assertEqual(r1.status_code, 200, r1.content)
72
+ self.assertEqual(self._cache_header(r1), "miss")
73
+
74
+ # Request a different cast format — should hit L1, not run the pipeline.
75
+ with patch(
76
+ "core.managers.resolution.ResolvePipelineManager.resolve",
77
+ wraps=None,
78
+ ) as mock_pipeline:
79
+ mock_pipeline.side_effect = AssertionError("Pipeline should not have been called on an L1 hit")
80
+ r2 = self._resolve("app/cfg", cast="json")
81
+
82
+ self.assertEqual(r2.status_code, 200, r2.content)
83
+ self.assertEqual(self._cache_header(r2), "cast")
84
+
85
+ def test_l1_hit_writes_l2_so_third_call_is_hit(self):
86
+ """L1 hit populates L2; the next identical request is a pure L2 hit."""
87
+ # 1st call: full pipeline, warms L1.
88
+ r1 = self._resolve("app/cfg")
89
+ self.assertEqual(self._cache_header(r1), "miss")
90
+
91
+ # 2nd call: different cast → L1 hit, writes L2.
92
+ r2 = self._resolve("app/cfg", cast="json")
93
+ self.assertEqual(self._cache_header(r2), "cast")
94
+
95
+ # 3rd call: same cast as 2nd → L2 hit.
96
+ r3 = self._resolve("app/cfg", cast="json")
97
+ self.assertEqual(self._cache_header(r3), "hit")
98
+
99
+ def test_different_cast_produces_different_content(self):
100
+ """Resolving the same config with different casts yields different content."""
101
+ r_yaml = self._resolve("app/cfg", cast="yaml")
102
+ self.assertEqual(r_yaml.status_code, 200)
103
+
104
+ r_json = self._resolve("app/cfg", cast="json")
105
+ self.assertEqual(r_json.status_code, 200)
106
+
107
+ checksum_yaml = r_yaml.json()["items"][0]["checksum"]
108
+ checksum_json = r_json.json()["items"][0]["checksum"]
109
+ self.assertNotEqual(checksum_yaml, checksum_json)
110
+
111
+
112
+ _EXTEND_YAML = """\
113
+ _ocmo:
114
+ extend:
115
+ mode: accumulate
116
+ configs:
117
+ - ../shared/base@latest
118
+ key: child
119
+ """
120
+
121
+
122
+ @override_settings(OCMO_MASTER_KEY=_TEST_MASTER_KEY)
123
+ class CacheHitParticipantPermissionTests(TestCase):
124
+ """L1/L2 cache hits re-check permissions on all participants."""
125
+
126
+ def setUp(self):
127
+ caches["resolve"].clear()
128
+ PermissionsCompiler._policy_cache.clear()
129
+ self.client = Client()
130
+ self.ns = create_test_namespace("cache-participant-perm", description="test")
131
+ TreeManager(self.ns, "shared/base", auth=None).create_item("base: true\n", "config")
132
+ TreeManager(self.ns, "app/cfg", auth=None).create_item(_EXTEND_YAML, "config")
133
+
134
+ def _resolve(self, path: str, **params):
135
+ from urllib.parse import urlencode
136
+
137
+ qs = urlencode(params)
138
+ url = f"/api/v1/ns/{self.ns.name}/~resolve/{path}"
139
+ if qs:
140
+ url += f"?{qs}"
141
+ return self.client.get(url)
142
+
143
+ def test_l1_cast_hit_denies_when_nested_permission_revoked(self):
144
+ """L1 hit still enforces config:resolve on extend participants."""
145
+ r1 = self._resolve("app/cfg")
146
+ self.assertEqual(r1.status_code, 200, r1.content)
147
+ self.assertEqual(r1.get(_CACHE_HEADER), "miss")
148
+
149
+ r2 = self._resolve("app/cfg", cast="json")
150
+ self.assertEqual(r2.status_code, 200, r2.content)
151
+ self.assertEqual(r2.get(_CACHE_HEADER), "cast")
152
+
153
+ TreeManager(self.ns, "_permissions", auth=None).update_item(
154
+ """\
155
+ policies:
156
+ - effect: Allow
157
+ actors:
158
+ - kind: User
159
+ claims:
160
+ email: "*"
161
+ actions:
162
+ - config:resolve
163
+ resources:
164
+ - app/**
165
+ """
166
+ )
167
+ PermissionsCompiler._policy_cache.clear()
168
+
169
+ r3 = self._resolve("app/cfg", cast="json")
170
+ self.assertEqual(r3.status_code, 403, r3.content)
171
+
172
+
173
+ @override_settings(OCMO_MASTER_KEY=_TEST_MASTER_KEY)
174
+ class SecretBearingCacheTests(TestCase):
175
+ """Secret-bearing resolutions must not be stored at Layer 1."""
176
+
177
+ def setUp(self):
178
+ caches["resolve"].clear()
179
+ self.client = Client()
180
+ self.ns = create_test_namespace("secret-cache-test", description="test")
181
+ # Create a secret with a version.
182
+ r = self.client.post(
183
+ f"/api/v1/ns/{self.ns.name}/~secret/~create/creds/db",
184
+ data=b"password: hunter2\n",
185
+ content_type="application/yaml",
186
+ )
187
+ self.assertEqual(r.status_code, 201, r.content)
188
+ # Config references the secret via a parameter.
189
+ cfg_body = (
190
+ "_ocmo:\n"
191
+ " parameters:\n"
192
+ " db_password:\n"
193
+ " type: secret\n"
194
+ " value: creds/db@latest\n"
195
+ " description: Database password\n"
196
+ "db_pass: '{!db_password}'\n"
197
+ )
198
+ r2 = self.client.post(
199
+ f"/api/v1/ns/{self.ns.name}/~config/~create/app/secret-cfg",
200
+ data=cfg_body.encode(),
201
+ content_type="application/yaml",
202
+ )
203
+ self.assertEqual(r2.status_code, 201, r2.content)
204
+
205
+ def _resolve(self, cast: str | None = None):
206
+ url = f"/api/v1/ns/{self.ns.name}/~resolve/app/secret-cfg"
207
+ if cast:
208
+ url += f"?cast={cast}"
209
+ return self.client.get(url)
210
+
211
+ def test_secret_config_different_cast_reruns_pipeline(self):
212
+ """Secret-bearing config: changing cast must run the full pipeline (no L1)."""
213
+ r1 = self._resolve()
214
+ self.assertEqual(r1.status_code, 200, r1.content)
215
+ self.assertEqual(r1.get(_CACHE_HEADER), "miss")
216
+
217
+ # Different cast should be a miss, not 'cast', because L1 was skipped.
218
+ r2 = self._resolve(cast="json")
219
+ self.assertEqual(r2.status_code, 200, r2.content)
220
+ self.assertEqual(r2.get(_CACHE_HEADER), "miss")
221
+
222
+
223
+ @override_settings(OCMO_MASTER_KEY=_TEST_MASTER_KEY)
224
+ class NoCredsCacheTests(TestCase):
225
+ """no-creds resolutions skip secret participants and may use Layer 1."""
226
+
227
+ def setUp(self):
228
+ caches["resolve"].clear()
229
+ self.client = Client()
230
+ self.ns = create_test_namespace("nocreds-cache-test", description="test")
231
+ r = self.client.post(
232
+ f"/api/v1/ns/{self.ns.name}/~secret/~create/creds/db",
233
+ data=b"password: hunter2\n",
234
+ content_type="application/yaml",
235
+ )
236
+ self.assertEqual(r.status_code, 201, r.content)
237
+ cfg_body = (
238
+ "_ocmo:\n"
239
+ " parameters:\n"
240
+ " db_password:\n"
241
+ " type: secret\n"
242
+ " value: creds/db@latest\n"
243
+ " description: Database password\n"
244
+ "db_pass: '{!db_password}'\n"
245
+ )
246
+ r2 = self.client.post(
247
+ f"/api/v1/ns/{self.ns.name}/~config/~create/app/secret-cfg",
248
+ data=cfg_body.encode(),
249
+ content_type="application/yaml",
250
+ )
251
+ self.assertEqual(r2.status_code, 201, r2.content)
252
+
253
+ def _resolve(self, **params):
254
+ from urllib.parse import urlencode
255
+
256
+ qs = urlencode(params)
257
+ url = f"/api/v1/ns/{self.ns.name}/~resolve/app/secret-cfg"
258
+ if qs:
259
+ url += f"?{qs}"
260
+ return self.client.get(url)
261
+
262
+ def test_no_creds_secret_config_uses_l1_cache(self):
263
+ """no-creds: changing cast hits L1 (cast header), unlike full secret resolve."""
264
+ r1 = self._resolve(**{"no-creds": "true"})
265
+ self.assertEqual(r1.status_code, 200, r1.content)
266
+ self.assertEqual(r1.get(_CACHE_HEADER), "miss")
267
+
268
+ r2 = self._resolve(**{"no-creds": "true", "cast": "json"})
269
+ self.assertEqual(r2.status_code, 200, r2.content)
270
+ self.assertEqual(r2.get(_CACHE_HEADER), "cast")
271
+
272
+ def test_no_creds_cache_key_separate_from_full_resolve(self):
273
+ r_full = self._resolve()
274
+ self.assertEqual(r_full.status_code, 200, r_full.content)
275
+ checksum_full = r_full.json()["items"][0]["checksum"]
276
+
277
+ r_nocreds = self._resolve(**{"no-creds": "true"})
278
+ self.assertEqual(r_nocreds.status_code, 200, r_nocreds.content)
279
+ checksum_nocreds = r_nocreds.json()["items"][0]["checksum"]
280
+ self.assertNotEqual(checksum_full, checksum_nocreds)
281
+
282
+ r_nocreds2 = self._resolve(**{"no-creds": "true"})
283
+ self.assertEqual(r_nocreds2.get(_CACHE_HEADER), "hit")
284
+ self.assertEqual(
285
+ r_nocreds2.json()["items"][0]["checksum"],
286
+ checksum_nocreds,
287
+ )
288
+
289
+
290
+ from django.test import SimpleTestCase
291
+
292
+
293
+ class CacheKeyUnitTests(SimpleTestCase):
294
+ def test_no_creds_changes_resolution_key(self):
295
+ from core.managers.resolve_cache import ResolveCacheManager
296
+
297
+ k_false = ResolveCacheManager.make_resolution_key("ns", "path", "latest", {}, no_creds=False)
298
+ k_true = ResolveCacheManager.make_resolution_key("ns", "path", "latest", {}, no_creds=True)
299
+ self.assertNotEqual(k_false, k_true)
300
+
301
+ def test_no_creds_changes_artifact_key(self):
302
+ from core.managers.resolve_cache import ResolveCacheManager
303
+
304
+ k_false = ResolveCacheManager.make_artifact_key("ns", "path", "latest", "yaml", {}, {}, no_creds=False)
305
+ k_true = ResolveCacheManager.make_artifact_key("ns", "path", "latest", "yaml", {}, {}, no_creds=True)
306
+ self.assertNotEqual(k_false, k_true)
307
+
308
+
309
+ class CacheStatusUnitTests(SimpleTestCase):
310
+ """Unit tests for ResolutionManager._cache_status (no DB required)."""
311
+
312
+ def _status(self, cache_values: list[str]) -> str:
313
+ items = [{"_cache": v} for v in cache_values]
314
+ return ResolutionManager._cache_status(items)
315
+
316
+ def test_empty_is_miss(self):
317
+ self.assertEqual(ResolutionManager._cache_status([]), "miss")
318
+
319
+ def test_all_hit(self):
320
+ self.assertEqual(self._status(["hit", "hit"]), "hit")
321
+
322
+ def test_all_cast(self):
323
+ self.assertEqual(self._status(["cast", "cast"]), "cast")
324
+
325
+ def test_all_miss(self):
326
+ self.assertEqual(self._status(["miss"]), "miss")
327
+
328
+ def test_mixed_hit_and_cast_is_cast(self):
329
+ # All served from cache (no pipeline), but some re-cast → 'cast'.
330
+ self.assertEqual(self._status(["hit", "cast"]), "cast")
331
+
332
+ def test_any_miss_dominates(self):
333
+ self.assertEqual(self._status(["hit", "miss"]), "miss")
334
+ self.assertEqual(self._status(["cast", "miss"]), "miss")
@@ -0,0 +1,53 @@
1
+ """Static audit: API route paths follow the trailing-slash URL convention."""
2
+
3
+ import pathlib
4
+ import re
5
+
6
+ from django.test import SimpleTestCase
7
+
8
+ _API_DIR = pathlib.Path(__file__).resolve().parent.parent / "api"
9
+ _ROUTE_DECORATOR = re.compile(r'@router\.(get|post|put|patch|delete)\(\s*(?:\n\s*)?["\']([^"\']+)["\']')
10
+
11
+
12
+ def _iter_registered_route_paths():
13
+ for path in sorted(_API_DIR.glob("*.py")):
14
+ if path.name.startswith("_"):
15
+ continue
16
+ for match in _ROUTE_DECORATOR.finditer(path.read_text()):
17
+ yield match.group(2)
18
+
19
+
20
+ def _ends_in_path_param(route_path: str) -> bool:
21
+ last_segment = route_path.rstrip("/").split("/")[-1]
22
+ return last_segment.startswith("{") and last_segment.endswith("}")
23
+
24
+
25
+ _NO_TRAILING_SLASH_ROUTES = frozenset(
26
+ {
27
+ "/health",
28
+ "/version",
29
+ "/~config-metadata-schema",
30
+ "/~resolver-configuration-schema",
31
+ }
32
+ )
33
+
34
+
35
+ def _should_have_trailing_slash(route_path: str) -> bool:
36
+ if route_path in _NO_TRAILING_SLASH_ROUTES:
37
+ return False
38
+ return not _ends_in_path_param(route_path)
39
+
40
+
41
+ class ApiTrailingSlashConventionTests(SimpleTestCase):
42
+ def test_all_routes_follow_trailing_slash_rule(self):
43
+ violations = []
44
+ for route_path in _iter_registered_route_paths():
45
+ should_have_slash = _should_have_trailing_slash(route_path)
46
+ has_slash = route_path.endswith("/")
47
+ if has_slash != should_have_slash:
48
+ violations.append(f"{route_path!r}: has_trailing_slash={has_slash}, expected={should_have_slash}")
49
+ self.assertEqual(
50
+ violations,
51
+ [],
52
+ "Route paths must end with '/' for collections/actions only:\n" + "\n".join(violations),
53
+ )
@@ -0,0 +1,43 @@
1
+ """Integration tests for API validation error responses."""
2
+
3
+ import json
4
+
5
+ from django.test import TestCase
6
+
7
+ from core.tests.auth_helpers import authenticated_as
8
+
9
+
10
+ class TestValidationErrorResponses(TestCase):
11
+ def test_namespace_create_returns_field_specific_messages(self) -> None:
12
+ with authenticated_as():
13
+ response = self.client.post(
14
+ "/api/v1/ns/",
15
+ data=json.dumps({"name": "bad name!", "description": ""}),
16
+ content_type="application/json",
17
+ )
18
+
19
+ self.assertEqual(response.status_code, 422, response.content)
20
+ payload = response.json()
21
+ self.assertIn("error", payload)
22
+ self.assertIsInstance(payload["error"], list)
23
+ self.assertEqual(payload["errors"], payload["error"])
24
+ self.assertTrue(
25
+ any("name:" in message for message in payload["error"]),
26
+ payload["error"],
27
+ )
28
+
29
+ def test_can_i_rejects_invalid_operation_with_field_message(self) -> None:
30
+ with authenticated_as():
31
+ response = self.client.post(
32
+ "/api/v1/auth/can-i/",
33
+ data=json.dumps({"operations": ["not-an-operation"]}),
34
+ content_type="application/json",
35
+ )
36
+
37
+ self.assertEqual(response.status_code, 422, response.content)
38
+ payload = response.json()
39
+ self.assertIsInstance(payload["error"], list)
40
+ self.assertTrue(
41
+ any("operations" in message for message in payload["error"]),
42
+ payload["error"],
43
+ )