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,90 @@
|
|
|
1
|
+
"""Tests for audit event id prefix resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
|
|
7
|
+
from django.test import TestCase
|
|
8
|
+
|
|
9
|
+
from core.exceptions import NotFound
|
|
10
|
+
from core.managers.audit import AuditManager
|
|
11
|
+
from core.managers.auth import AuthManager
|
|
12
|
+
from core.models import AuditEvent
|
|
13
|
+
from core.tests.namespace_helpers import create_test_namespace
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _admin_auth() -> AuthManager:
|
|
17
|
+
return AuthManager(
|
|
18
|
+
{"_type": "user", "sub": "admin-1", "email": "admin@example.com", "groups": "ocmo-global-admins"}
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class TestAuditEventIdResolution(TestCase):
|
|
23
|
+
def setUp(self) -> None:
|
|
24
|
+
AuditManager.unbind()
|
|
25
|
+
self.ns = create_test_namespace("audit-id-prefix")
|
|
26
|
+
self.event = AuditEvent.objects.create(
|
|
27
|
+
namespace_name=self.ns.name,
|
|
28
|
+
auth_id="user-1",
|
|
29
|
+
auth_type=AuditEvent.AUTH_TYPE_USER,
|
|
30
|
+
http_method="GET",
|
|
31
|
+
api_endpoint="/api/v1/test",
|
|
32
|
+
event_kind=AuditEvent.EVENT_KIND_OPERATION,
|
|
33
|
+
object_type="config",
|
|
34
|
+
object_id="app/cfg",
|
|
35
|
+
operation="Read config",
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def tearDown(self) -> None:
|
|
39
|
+
AuditManager.unbind()
|
|
40
|
+
|
|
41
|
+
def test_resolve_event_id_accepts_full_uuid(self) -> None:
|
|
42
|
+
mgr = AuditManager(auth=_admin_auth(), namespace=self.ns)
|
|
43
|
+
resolved = mgr.resolve_event_id(str(self.event.id))
|
|
44
|
+
self.assertEqual(resolved, self.event.id)
|
|
45
|
+
|
|
46
|
+
def test_resolve_event_id_accepts_unique_prefix(self) -> None:
|
|
47
|
+
mgr = AuditManager(auth=_admin_auth(), namespace=self.ns)
|
|
48
|
+
prefix = str(self.event.id).replace("-", "")[:8]
|
|
49
|
+
resolved = mgr.resolve_event_id(prefix)
|
|
50
|
+
self.assertEqual(resolved, self.event.id)
|
|
51
|
+
|
|
52
|
+
def test_resolve_event_id_rejects_ambiguous_prefix(self) -> None:
|
|
53
|
+
AuditEvent.objects.create(
|
|
54
|
+
id=uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"),
|
|
55
|
+
namespace_name=self.ns.name,
|
|
56
|
+
auth_id="user-1",
|
|
57
|
+
auth_type=AuditEvent.AUTH_TYPE_USER,
|
|
58
|
+
http_method="GET",
|
|
59
|
+
api_endpoint="/api/v1/test",
|
|
60
|
+
event_kind=AuditEvent.EVENT_KIND_OPERATION,
|
|
61
|
+
object_type="config",
|
|
62
|
+
object_id="app/other",
|
|
63
|
+
operation="Read config",
|
|
64
|
+
)
|
|
65
|
+
AuditEvent.objects.create(
|
|
66
|
+
id=uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaab"),
|
|
67
|
+
namespace_name=self.ns.name,
|
|
68
|
+
auth_id="user-1",
|
|
69
|
+
auth_type=AuditEvent.AUTH_TYPE_USER,
|
|
70
|
+
http_method="GET",
|
|
71
|
+
api_endpoint="/api/v1/test",
|
|
72
|
+
event_kind=AuditEvent.EVENT_KIND_OPERATION,
|
|
73
|
+
object_type="config",
|
|
74
|
+
object_id="app/other2",
|
|
75
|
+
operation="Read config",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
mgr = AuditManager(auth=_admin_auth(), namespace=self.ns)
|
|
79
|
+
with self.assertRaises(ValueError, msg="Ambiguous"):
|
|
80
|
+
mgr.resolve_event_id("aaaaaaaa")
|
|
81
|
+
|
|
82
|
+
def test_resolve_event_id_not_found(self) -> None:
|
|
83
|
+
mgr = AuditManager(auth=_admin_auth(), namespace=self.ns)
|
|
84
|
+
with self.assertRaises(NotFound):
|
|
85
|
+
mgr.resolve_event_id("deadbeef")
|
|
86
|
+
|
|
87
|
+
def test_resolve_event_id_rejects_short_prefix(self) -> None:
|
|
88
|
+
mgr = AuditManager(auth=_admin_auth(), namespace=self.ns)
|
|
89
|
+
with self.assertRaises(ValueError, msg="too short"):
|
|
90
|
+
mgr.resolve_event_id("abc")
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Unit tests for audit operation label helpers."""
|
|
2
|
+
|
|
3
|
+
from django.test import SimpleTestCase
|
|
4
|
+
|
|
5
|
+
from core.constants.audit_operations import OP_PROPAGATE_CONFIG
|
|
6
|
+
from core.managers.audit import AuditEventDraft
|
|
7
|
+
from core.managers.audit.timeline import format_timeline_note, infer_operation
|
|
8
|
+
from core.managers.tree import TreeManager
|
|
9
|
+
from core.models import AuditEvent
|
|
10
|
+
from core.shortcuts import is_version_number_ref, tag_subresource_from_ref
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestAuditOperations(SimpleTestCase):
|
|
14
|
+
def test_is_version_number_ref(self):
|
|
15
|
+
self.assertTrue(is_version_number_ref("11"))
|
|
16
|
+
self.assertFalse(is_version_number_ref("latest"))
|
|
17
|
+
self.assertFalse(is_version_number_ref("v11"))
|
|
18
|
+
|
|
19
|
+
def test_tag_subresource_from_ref(self):
|
|
20
|
+
self.assertEqual(tag_subresource_from_ref("latest"), ("tag", "latest"))
|
|
21
|
+
self.assertIsNone(tag_subresource_from_ref("3"))
|
|
22
|
+
|
|
23
|
+
def test_format_subresource_rejects_version(self):
|
|
24
|
+
with self.assertRaises(ValueError):
|
|
25
|
+
TreeManager.format_subresource(["version"], ["11"])
|
|
26
|
+
|
|
27
|
+
def test_infer_resolve_kinds(self):
|
|
28
|
+
draft = AuditEventDraft(event_kind=AuditEvent.EVENT_KIND_RESOLVE_REQUEST)
|
|
29
|
+
self.assertEqual(infer_operation(draft), "Resolve")
|
|
30
|
+
draft = AuditEventDraft(event_kind=AuditEvent.EVENT_KIND_RESOLVE_PARTICIPANT)
|
|
31
|
+
self.assertEqual(infer_operation(draft), "Referenced in resolve")
|
|
32
|
+
|
|
33
|
+
def test_infer_read_typed(self):
|
|
34
|
+
draft = AuditEventDraft(
|
|
35
|
+
event_kind=AuditEvent.EVENT_KIND_OPERATION,
|
|
36
|
+
http_method="GET",
|
|
37
|
+
api_endpoint="/api/v1/ns/qa/~get/app/cfg",
|
|
38
|
+
object_type="config",
|
|
39
|
+
)
|
|
40
|
+
self.assertEqual(infer_operation(draft), "Read config")
|
|
41
|
+
|
|
42
|
+
def test_infer_uses_explicit_operation(self):
|
|
43
|
+
draft = AuditEventDraft(operation="Set tag")
|
|
44
|
+
self.assertEqual(infer_operation(draft), "Set tag")
|
|
45
|
+
|
|
46
|
+
def test_format_timeline_note_resolve(self):
|
|
47
|
+
event = AuditEvent(
|
|
48
|
+
auth_id="user-1",
|
|
49
|
+
auth_email="bob@example.com",
|
|
50
|
+
auth_type=AuditEvent.AUTH_TYPE_USER,
|
|
51
|
+
http_method="POST",
|
|
52
|
+
api_endpoint="/api/v1/ns/qa/~resolve/app/cfg",
|
|
53
|
+
event_kind=AuditEvent.EVENT_KIND_RESOLVE_REQUEST,
|
|
54
|
+
object_type="config",
|
|
55
|
+
object_id="app/cfg",
|
|
56
|
+
operation="Resolve",
|
|
57
|
+
)
|
|
58
|
+
self.assertEqual(
|
|
59
|
+
format_timeline_note(event),
|
|
60
|
+
"User bob@example.com resolved this config",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def test_format_timeline_note_set_tag(self):
|
|
64
|
+
event = AuditEvent(
|
|
65
|
+
auth_id="user-1",
|
|
66
|
+
auth_email="admin@example.com",
|
|
67
|
+
auth_type=AuditEvent.AUTH_TYPE_USER,
|
|
68
|
+
http_method="POST",
|
|
69
|
+
api_endpoint="/api/v1/ns/qa/~tag/app/cfg",
|
|
70
|
+
event_kind=AuditEvent.EVENT_KIND_OPERATION,
|
|
71
|
+
object_type="config",
|
|
72
|
+
object_id="app/cfg",
|
|
73
|
+
operation="Set tag",
|
|
74
|
+
object_version=2,
|
|
75
|
+
subresource_type="tag",
|
|
76
|
+
subresource="tagme",
|
|
77
|
+
)
|
|
78
|
+
self.assertEqual(
|
|
79
|
+
format_timeline_note(event),
|
|
80
|
+
"User admin@example.com set tag `tagme` to version 2",
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
def test_format_timeline_note_update_item(self):
|
|
84
|
+
event = AuditEvent(
|
|
85
|
+
auth_id="user-1",
|
|
86
|
+
auth_email="admin@example.com",
|
|
87
|
+
auth_type=AuditEvent.AUTH_TYPE_USER,
|
|
88
|
+
http_method="PUT",
|
|
89
|
+
api_endpoint="/api/v1/ns/qa/~config/~update/app/cfg",
|
|
90
|
+
event_kind=AuditEvent.EVENT_KIND_OPERATION,
|
|
91
|
+
object_type="config",
|
|
92
|
+
object_id="app/cfg",
|
|
93
|
+
operation="Update item",
|
|
94
|
+
object_version=3,
|
|
95
|
+
)
|
|
96
|
+
self.assertEqual(
|
|
97
|
+
format_timeline_note(event),
|
|
98
|
+
"User admin@example.com created new version of config (3)",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
def test_format_timeline_note_with_tag_and_error(self):
|
|
102
|
+
event = AuditEvent(
|
|
103
|
+
auth_id="user-1",
|
|
104
|
+
auth_email="alice@example.com",
|
|
105
|
+
auth_type=AuditEvent.AUTH_TYPE_USER,
|
|
106
|
+
http_method="POST",
|
|
107
|
+
api_endpoint="/api/v1/ns/qa/~tag/app/cfg",
|
|
108
|
+
event_kind=AuditEvent.EVENT_KIND_OPERATION,
|
|
109
|
+
object_type="config",
|
|
110
|
+
object_id="app/cfg",
|
|
111
|
+
operation="Set tag",
|
|
112
|
+
object_version=3,
|
|
113
|
+
subresource_type="tag",
|
|
114
|
+
subresource="stable",
|
|
115
|
+
error="tag already exists",
|
|
116
|
+
)
|
|
117
|
+
self.assertEqual(
|
|
118
|
+
format_timeline_note(event),
|
|
119
|
+
"User alice@example.com set tag `stable` to version 3 — tag already exists",
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
def test_format_timeline_note_resolver_actor(self):
|
|
123
|
+
event = AuditEvent(
|
|
124
|
+
auth_id="resolver/app/cfg",
|
|
125
|
+
auth_email=None,
|
|
126
|
+
auth_type=AuditEvent.AUTH_TYPE_RESOLVER,
|
|
127
|
+
http_method="PUT",
|
|
128
|
+
api_endpoint="/api/v1/ns/qa/~config/~update/app/cfg",
|
|
129
|
+
event_kind=AuditEvent.EVENT_KIND_OPERATION,
|
|
130
|
+
object_type="config",
|
|
131
|
+
object_id="app/cfg",
|
|
132
|
+
operation="Update item",
|
|
133
|
+
object_version=4,
|
|
134
|
+
)
|
|
135
|
+
self.assertEqual(
|
|
136
|
+
format_timeline_note(event),
|
|
137
|
+
"Resolver resolver/app/cfg created new version of config (4)",
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def test_format_timeline_note_manual_propagation(self):
|
|
141
|
+
payload = '{"trigger":"manual","targets":[{"path":"env/dev","version":3},{"path":"env/staging","version":2}]}'
|
|
142
|
+
event = AuditEvent(
|
|
143
|
+
auth_id="user-1",
|
|
144
|
+
auth_email="admin@example.com",
|
|
145
|
+
auth_type=AuditEvent.AUTH_TYPE_USER,
|
|
146
|
+
http_method="POST",
|
|
147
|
+
api_endpoint="/api/v1/ns/qa/~propagate/proj/source",
|
|
148
|
+
event_kind=AuditEvent.EVENT_KIND_OPERATION,
|
|
149
|
+
object_type="config",
|
|
150
|
+
object_id="proj/source",
|
|
151
|
+
operation=OP_PROPAGATE_CONFIG,
|
|
152
|
+
object_version=5,
|
|
153
|
+
subresource_type="trigger",
|
|
154
|
+
subresource=payload,
|
|
155
|
+
)
|
|
156
|
+
self.assertEqual(
|
|
157
|
+
format_timeline_note(event),
|
|
158
|
+
"User admin@example.com manually propagated version 5 to `env/dev`@v3, `env/staging`@v2",
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def test_format_timeline_note_tag_propagation(self):
|
|
162
|
+
payload = '{"trigger":"tag","trigger_tag":"stable","targets":[{"path":"env/dev","version":3}]}'
|
|
163
|
+
event = AuditEvent(
|
|
164
|
+
auth_id="user-1",
|
|
165
|
+
auth_email="admin@example.com",
|
|
166
|
+
auth_type=AuditEvent.AUTH_TYPE_USER,
|
|
167
|
+
http_method="POST",
|
|
168
|
+
api_endpoint="/api/v1/ns/qa/~tag/proj/source",
|
|
169
|
+
event_kind=AuditEvent.EVENT_KIND_OPERATION,
|
|
170
|
+
object_type="config",
|
|
171
|
+
object_id="proj/source",
|
|
172
|
+
operation=OP_PROPAGATE_CONFIG,
|
|
173
|
+
object_version=5,
|
|
174
|
+
subresource_type="trigger",
|
|
175
|
+
subresource=payload,
|
|
176
|
+
)
|
|
177
|
+
self.assertEqual(
|
|
178
|
+
format_timeline_note(event),
|
|
179
|
+
"User admin@example.com propagated by setting tag `stable` to version 5, creating `env/dev`@v3",
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def test_format_timeline_note_legacy_propagation_trigger(self):
|
|
183
|
+
event = AuditEvent(
|
|
184
|
+
auth_id="user-1",
|
|
185
|
+
auth_email="admin@example.com",
|
|
186
|
+
auth_type=AuditEvent.AUTH_TYPE_USER,
|
|
187
|
+
http_method="POST",
|
|
188
|
+
api_endpoint="/api/v1/ns/qa/~propagate/proj/source",
|
|
189
|
+
event_kind=AuditEvent.EVENT_KIND_OPERATION,
|
|
190
|
+
object_type="config",
|
|
191
|
+
object_id="proj/source",
|
|
192
|
+
operation=OP_PROPAGATE_CONFIG,
|
|
193
|
+
object_version=5,
|
|
194
|
+
subresource_type="trigger",
|
|
195
|
+
subresource="manual",
|
|
196
|
+
)
|
|
197
|
+
self.assertEqual(
|
|
198
|
+
format_timeline_note(event),
|
|
199
|
+
"User admin@example.com manually propagated version 5",
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
def test_format_timeline_note_manual_propagation_all_unchanged(self):
|
|
203
|
+
payload = '{"trigger":"manual","unchanged":["env/dev","env/staging"]}'
|
|
204
|
+
event = AuditEvent(
|
|
205
|
+
auth_id="user-1",
|
|
206
|
+
auth_email="admin@example.com",
|
|
207
|
+
auth_type=AuditEvent.AUTH_TYPE_USER,
|
|
208
|
+
http_method="POST",
|
|
209
|
+
api_endpoint="/api/v1/ns/qa/~propagate/proj/source",
|
|
210
|
+
event_kind=AuditEvent.EVENT_KIND_OPERATION,
|
|
211
|
+
object_type="config",
|
|
212
|
+
object_id="proj/source",
|
|
213
|
+
operation=OP_PROPAGATE_CONFIG,
|
|
214
|
+
object_version=9,
|
|
215
|
+
subresource_type="trigger",
|
|
216
|
+
subresource=payload,
|
|
217
|
+
)
|
|
218
|
+
self.assertEqual(
|
|
219
|
+
format_timeline_note(event),
|
|
220
|
+
"User admin@example.com manually propagated version 9; "
|
|
221
|
+
"all targets already matched (`env/dev`, `env/staging`)",
|
|
222
|
+
)
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Tests for builtin namespace config JSON Schemas."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from django.test import Client, TestCase, override_settings
|
|
10
|
+
|
|
11
|
+
from core.constants.permission_actions import (
|
|
12
|
+
PERMISSION_ACTION_PATTERN,
|
|
13
|
+
PERMISSION_ACTIONS,
|
|
14
|
+
)
|
|
15
|
+
from core.constants.webhook_events import WEBHOOK_EVENTS, WEBHOOK_PAYLOAD_PRESETS
|
|
16
|
+
from core.managers.tree import TreeManager
|
|
17
|
+
from core.models import Namespace
|
|
18
|
+
from core.utils.namespace_special_configs import (
|
|
19
|
+
init_namespace_special_configs,
|
|
20
|
+
sync_permissions_webhooks_schema_configs,
|
|
21
|
+
)
|
|
22
|
+
from core.utils.permissions_schema_document import build_permissions_schema_document
|
|
23
|
+
|
|
24
|
+
_TEST_MASTER_KEY = "ZDPuvW6Hx/1UxDK7K/CydLouVKtJl24nbHyb2EkvTzs="
|
|
25
|
+
|
|
26
|
+
_BUILTIN_SCHEMA_DIR = Path(__file__).resolve().parent.parent / "data" / "builtin_schemas"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load_builtin_schema(name: str) -> dict:
|
|
30
|
+
if name == "_permissions.schema":
|
|
31
|
+
return build_permissions_schema_document()
|
|
32
|
+
return yaml.safe_load((_BUILTIN_SCHEMA_DIR / f"{name}.yaml").read_text(encoding="utf-8"))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class BuiltinPermissionsSchemaTests(TestCase):
|
|
36
|
+
def test_actions_enum_matches_pattern_constant(self):
|
|
37
|
+
schema = _load_builtin_schema("_permissions.schema")
|
|
38
|
+
action_items = schema["properties"]["policies"]["items"]["properties"]["actions"]["items"]
|
|
39
|
+
enum_values = action_items["enum"]
|
|
40
|
+
pattern = re.compile(action_items["pattern"])
|
|
41
|
+
|
|
42
|
+
self.assertEqual(tuple(enum_values), PERMISSION_ACTIONS)
|
|
43
|
+
self.assertEqual(action_items["pattern"], PERMISSION_ACTION_PATTERN)
|
|
44
|
+
for value in enum_values:
|
|
45
|
+
self.assertRegex(value, pattern)
|
|
46
|
+
|
|
47
|
+
def test_actions_enum_excludes_invalid_resource_verb_pairs(self):
|
|
48
|
+
schema = _load_builtin_schema("_permissions.schema")
|
|
49
|
+
enum_values = set(schema["properties"]["policies"]["items"]["properties"]["actions"]["items"]["enum"])
|
|
50
|
+
for invalid in (
|
|
51
|
+
"resolver:tag",
|
|
52
|
+
"resolver:resolve",
|
|
53
|
+
"resolver:describe",
|
|
54
|
+
"lock:audit",
|
|
55
|
+
"lock:tag",
|
|
56
|
+
"lock:resolve",
|
|
57
|
+
"lock:describe",
|
|
58
|
+
"folder:read",
|
|
59
|
+
"template:resolve",
|
|
60
|
+
):
|
|
61
|
+
self.assertNotIn(invalid, enum_values)
|
|
62
|
+
self.assertIn("template:read", enum_values)
|
|
63
|
+
self.assertIn("folder:audit", enum_values)
|
|
64
|
+
|
|
65
|
+
def test_uri_reference_markers(self):
|
|
66
|
+
schema = _load_builtin_schema("_permissions.schema")
|
|
67
|
+
policy_props = schema["properties"]["policies"]["items"]["properties"]
|
|
68
|
+
|
|
69
|
+
resolver_actor = policy_props["actors"]["items"]["oneOf"][1]["properties"]["path"]
|
|
70
|
+
self.assertEqual(resolver_actor["format"], "uri-reference")
|
|
71
|
+
self.assertEqual(resolver_actor["x-ocmo-uri-reference"], "resolver")
|
|
72
|
+
|
|
73
|
+
resource_items = policy_props["resources"]["items"]
|
|
74
|
+
self.assertEqual(resource_items["format"], "uri-reference")
|
|
75
|
+
self.assertEqual(resource_items["x-ocmo-uri-reference"], "resource")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class BuiltinWebhooksSchemaTests(TestCase):
|
|
79
|
+
def test_events_enum_matches_runtime_events(self):
|
|
80
|
+
schema = _load_builtin_schema("_webhooks.schema")
|
|
81
|
+
event_items = schema["properties"]["webhooks"]["items"]["properties"]["events"]["items"]
|
|
82
|
+
self.assertEqual(tuple(event_items["enum"]), WEBHOOK_EVENTS)
|
|
83
|
+
|
|
84
|
+
def test_payload_presets_enum(self):
|
|
85
|
+
schema = _load_builtin_schema("_webhooks.schema")
|
|
86
|
+
preset = schema["properties"]["webhooks"]["items"]["properties"]["payload"]["properties"]["preset"]
|
|
87
|
+
self.assertEqual(tuple(preset["enum"]), WEBHOOK_PAYLOAD_PRESETS)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@override_settings(OCMO_MASTER_KEY=_TEST_MASTER_KEY)
|
|
91
|
+
class BuiltinSchemaApiTests(TestCase):
|
|
92
|
+
@classmethod
|
|
93
|
+
def setUpTestData(cls):
|
|
94
|
+
cls.ns = Namespace.objects.create(name="builtin-schema-api", description="test")
|
|
95
|
+
init_namespace_special_configs(cls.ns)
|
|
96
|
+
cls.client = Client()
|
|
97
|
+
|
|
98
|
+
def test_permissions_schema_endpoint_includes_action_enum(self):
|
|
99
|
+
response = self.client.get(f"/api/v1/ns/{self.ns.name}/~config-schema/_permissions")
|
|
100
|
+
self.assertEqual(response.status_code, 200, response.content)
|
|
101
|
+
data = response.json()
|
|
102
|
+
actions = data["properties"]["policies"]["items"]["properties"]["actions"]["items"]
|
|
103
|
+
self.assertIn("config:read", actions["enum"])
|
|
104
|
+
self.assertIn("lock:read", actions["enum"])
|
|
105
|
+
self.assertIn("lock:write", actions["enum"])
|
|
106
|
+
self.assertIn("lock:delete", actions["enum"])
|
|
107
|
+
self.assertIn("*:audit", actions["enum"])
|
|
108
|
+
self.assertIn("*:*", actions["enum"])
|
|
109
|
+
self.assertNotIn("lock:tag", actions["enum"])
|
|
110
|
+
self.assertNotIn("resolver:tag", actions["enum"])
|
|
111
|
+
self.assertEqual(
|
|
112
|
+
data["properties"]["policies"]["items"]["properties"]["resources"]["items"]["format"],
|
|
113
|
+
"uri-reference",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def test_permissions_schema_endpoint_patches_stale_db_enum(self):
|
|
117
|
+
stale_schema = build_permissions_schema_document()
|
|
118
|
+
stale_actions = stale_schema["properties"]["policies"]["items"]["properties"]["actions"]["items"]
|
|
119
|
+
stale_actions["enum"] = list(stale_actions["enum"]) + ["lock:tag", "resolver:tag"]
|
|
120
|
+
TreeManager(self.ns, "_permissions.schema", auth=None).update_item(
|
|
121
|
+
yaml.safe_dump(stale_schema, sort_keys=False)
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
response = self.client.get(f"/api/v1/ns/{self.ns.name}/~config-schema/_permissions")
|
|
125
|
+
self.assertEqual(response.status_code, 200, response.content)
|
|
126
|
+
enum_values = response.json()["properties"]["policies"]["items"]["properties"]["actions"]["items"]["enum"]
|
|
127
|
+
self.assertEqual(tuple(enum_values), PERMISSION_ACTIONS)
|
|
128
|
+
|
|
129
|
+
def test_webhooks_schema_endpoint_includes_event_enum(self):
|
|
130
|
+
response = self.client.get(f"/api/v1/ns/{self.ns.name}/~config-schema/_webhooks")
|
|
131
|
+
self.assertEqual(response.status_code, 200, response.content)
|
|
132
|
+
data = response.json()
|
|
133
|
+
events = data["properties"]["webhooks"]["items"]["properties"]["events"]["items"]
|
|
134
|
+
self.assertIn("config.created", events["enum"])
|
|
135
|
+
self.assertIn("propagation.triggered", events["enum"])
|
|
136
|
+
|
|
137
|
+
def test_fresh_namespace_has_updated_builtin_schemas(self):
|
|
138
|
+
ns = Namespace.objects.create(name="schema-fresh-ns", description="schema test")
|
|
139
|
+
init_namespace_special_configs(ns)
|
|
140
|
+
schema_mgr = TreeManager(ns, "_permissions.schema", auth=None)
|
|
141
|
+
_, body = schema_mgr.load_config_version_document("latest")
|
|
142
|
+
actions = body["properties"]["policies"]["items"]["properties"]["actions"]["items"]
|
|
143
|
+
self.assertGreaterEqual(len(actions["enum"]), 35)
|
|
144
|
+
self.assertIn("lock:read", actions["enum"])
|
|
145
|
+
self.assertIn("lock:write", actions["enum"])
|
|
146
|
+
self.assertIn("lock:delete", actions["enum"])
|
|
147
|
+
|
|
148
|
+
def test_sync_updates_stale_permissions_and_webhooks_schemas(self):
|
|
149
|
+
ns = Namespace.objects.create(name="schema-sync-ns", description="sync test")
|
|
150
|
+
init_namespace_special_configs(ns)
|
|
151
|
+
|
|
152
|
+
stale_permissions_schema = """\
|
|
153
|
+
_ocmo:
|
|
154
|
+
is_json_schema: true
|
|
155
|
+
type: object
|
|
156
|
+
properties:
|
|
157
|
+
policies:
|
|
158
|
+
type: array
|
|
159
|
+
"""
|
|
160
|
+
TreeManager(ns, "_permissions.schema", auth=None).update_item(stale_permissions_schema)
|
|
161
|
+
|
|
162
|
+
updated = sync_permissions_webhooks_schema_configs(ns)
|
|
163
|
+
self.assertEqual(updated, ["_permissions.schema"])
|
|
164
|
+
|
|
165
|
+
response = self.client.get(f"/api/v1/ns/{ns.name}/~config-schema/_permissions")
|
|
166
|
+
self.assertEqual(response.status_code, 200, response.content)
|
|
167
|
+
actions = response.json()["properties"]["policies"]["items"]["properties"]["actions"]["items"]
|
|
168
|
+
self.assertIn("lock:read", actions["enum"])
|
|
169
|
+
self.assertIn("lock:write", actions["enum"])
|
|
170
|
+
self.assertIn("lock:delete", actions["enum"])
|
|
171
|
+
self.assertIn("*:*", actions["enum"])
|
|
172
|
+
|
|
173
|
+
# Idempotent: packaged content already applied.
|
|
174
|
+
self.assertEqual(sync_permissions_webhooks_schema_configs(ns), [])
|