cornflow 1.2.3a3__py3-none-any.whl → 1.2.3a5__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.
- cornflow/app.py +1 -1
- cornflow/cli/roles.py +1 -1
- cornflow/cli/service.py +32 -4
- cornflow/commands/access.py +14 -3
- cornflow/commands/auxiliar.py +106 -0
- cornflow/commands/permissions.py +183 -102
- cornflow/commands/roles.py +15 -14
- cornflow/commands/views.py +171 -41
- cornflow/shared/authentication/auth.py +3 -3
- cornflow/shared/const.py +1 -1
- cornflow/tests/unit/test_commands.py +0 -193
- cornflow/tests/unit/test_external_role_creation.py +785 -0
- {cornflow-1.2.3a3.dist-info → cornflow-1.2.3a5.dist-info}/METADATA +2 -2
- {cornflow-1.2.3a3.dist-info → cornflow-1.2.3a5.dist-info}/RECORD +17 -15
- {cornflow-1.2.3a3.dist-info → cornflow-1.2.3a5.dist-info}/WHEEL +0 -0
- {cornflow-1.2.3a3.dist-info → cornflow-1.2.3a5.dist-info}/entry_points.txt +0 -0
- {cornflow-1.2.3a3.dist-info → cornflow-1.2.3a5.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,785 @@
|
|
1
|
+
import unittest
|
2
|
+
import os
|
3
|
+
import json
|
4
|
+
from unittest.mock import patch, MagicMock
|
5
|
+
from cornflow.shared import db
|
6
|
+
from cornflow.tests.custom_test_case import CustomTestCase
|
7
|
+
from cornflow.models import ViewModel
|
8
|
+
from cornflow.commands.access import access_init_command
|
9
|
+
from cornflow.shared.const import (
|
10
|
+
VIEWER_ROLE,
|
11
|
+
PLANNER_ROLE,
|
12
|
+
POST_ACTION,
|
13
|
+
PATCH_ACTION,
|
14
|
+
DELETE_ACTION,
|
15
|
+
GET_ACTION,
|
16
|
+
PUT_ACTION,
|
17
|
+
)
|
18
|
+
|
19
|
+
|
20
|
+
class ExternalRoleCreationTestCase(CustomTestCase):
|
21
|
+
"""
|
22
|
+
Test cases for external app custom role creation and removal functionality
|
23
|
+
"""
|
24
|
+
|
25
|
+
def _load_expected_permissions(self, test_name):
|
26
|
+
"""Helper method to load expected permissions from JSON file"""
|
27
|
+
test_data_path = os.path.join(
|
28
|
+
os.path.dirname(__file__), "..", "data", "expected_permissions.json"
|
29
|
+
)
|
30
|
+
with open(test_data_path, "r") as f:
|
31
|
+
data = json.load(f)
|
32
|
+
return [
|
33
|
+
(role_id, action_id, endpoint_name)
|
34
|
+
for role_id, action_id, endpoint_name in data[test_name]
|
35
|
+
]
|
36
|
+
|
37
|
+
def _create_mock_external_app_resources(self):
|
38
|
+
"""Helper method to create mock external app resources"""
|
39
|
+
# Create mock endpoint classes for external app
|
40
|
+
mock_production_endpoint = MagicMock()
|
41
|
+
mock_production_endpoint.ROLES_WITH_ACCESS = [
|
42
|
+
888,
|
43
|
+
PLANNER_ROLE,
|
44
|
+
] # Custom role + standard role
|
45
|
+
mock_production_endpoint.DESCRIPTION = "Production planning endpoint"
|
46
|
+
|
47
|
+
mock_quality_endpoint = MagicMock()
|
48
|
+
mock_quality_endpoint.ROLES_WITH_ACCESS = [
|
49
|
+
777,
|
50
|
+
VIEWER_ROLE,
|
51
|
+
] # Custom role + standard role
|
52
|
+
mock_quality_endpoint.DESCRIPTION = "Quality control endpoint"
|
53
|
+
|
54
|
+
mock_scheduling_endpoint = MagicMock()
|
55
|
+
mock_scheduling_endpoint.ROLES_WITH_ACCESS = [
|
56
|
+
888,
|
57
|
+
777,
|
58
|
+
PLANNER_ROLE,
|
59
|
+
] # Multiple custom roles
|
60
|
+
mock_scheduling_endpoint.DESCRIPTION = "Scheduling optimizer endpoint"
|
61
|
+
|
62
|
+
# Create mock resources structure for external app endpoints
|
63
|
+
mock_resources = [
|
64
|
+
{
|
65
|
+
"endpoint": "production_planning", # External app endpoint
|
66
|
+
"urls": "/production-planning/",
|
67
|
+
"resource": mock_production_endpoint,
|
68
|
+
},
|
69
|
+
{
|
70
|
+
"endpoint": "quality_control", # External app endpoint
|
71
|
+
"urls": "/quality-control/",
|
72
|
+
"resource": mock_quality_endpoint,
|
73
|
+
},
|
74
|
+
{
|
75
|
+
"endpoint": "scheduling_optimizer", # External app endpoint
|
76
|
+
"urls": "/scheduling/",
|
77
|
+
"resource": mock_scheduling_endpoint,
|
78
|
+
},
|
79
|
+
]
|
80
|
+
|
81
|
+
return mock_resources
|
82
|
+
|
83
|
+
@patch("cornflow.commands.auxiliar.import_module")
|
84
|
+
@patch("cornflow.commands.views.import_module")
|
85
|
+
@patch.dict(
|
86
|
+
os.environ, {"EXTERNAL_APP": "1", "EXTERNAL_APP_MODULE": "external_test_app"}
|
87
|
+
)
|
88
|
+
def test_custom_role_creation_removal(
|
89
|
+
self, mock_import_views, mock_import_auxiliar
|
90
|
+
):
|
91
|
+
"""
|
92
|
+
Test that custom roles (like role 888) are properly created and removed
|
93
|
+
when external app is configured with EXTRA_PERMISSION_ASSIGNATION
|
94
|
+
"""
|
95
|
+
# Mock external app configuration
|
96
|
+
mock_external_app = MagicMock()
|
97
|
+
|
98
|
+
# Mock the shared.const module
|
99
|
+
mock_shared = MagicMock()
|
100
|
+
mock_const = MagicMock()
|
101
|
+
mock_const.EXTRA_PERMISSION_ASSIGNATION = [
|
102
|
+
# Try adding an existing permission and it works
|
103
|
+
(888, GET_ACTION, "production_planning"),
|
104
|
+
# Try adding additional permission
|
105
|
+
(888, POST_ACTION, "production_planning"),
|
106
|
+
(777, PATCH_ACTION, "quality_control"),
|
107
|
+
(VIEWER_ROLE, POST_ACTION, "scheduling_optimizer"),
|
108
|
+
(PLANNER_ROLE, DELETE_ACTION, "quality_control"),
|
109
|
+
]
|
110
|
+
# Define permissions for custom roles used in endpoints
|
111
|
+
mock_const.CUSTOM_ROLES_ACTIONS = {
|
112
|
+
# Default permission for role 888
|
113
|
+
888: [GET_ACTION],
|
114
|
+
# Default permission for role 777
|
115
|
+
777: [GET_ACTION],
|
116
|
+
}
|
117
|
+
mock_shared.const = mock_const
|
118
|
+
mock_external_app.shared = mock_shared
|
119
|
+
|
120
|
+
# Mock the endpoints.resources with fake external app endpoints
|
121
|
+
mock_endpoints = MagicMock()
|
122
|
+
mock_endpoints.resources = self._create_mock_external_app_resources()
|
123
|
+
mock_external_app.endpoints = mock_endpoints
|
124
|
+
|
125
|
+
mock_import_views.return_value = mock_external_app
|
126
|
+
mock_import_auxiliar.return_value = mock_external_app
|
127
|
+
|
128
|
+
# Run the permissions registration
|
129
|
+
from cornflow.commands.access import access_init_command
|
130
|
+
|
131
|
+
# Mock the database session for testing
|
132
|
+
with patch.object(db.session, "commit"):
|
133
|
+
with patch.object(db.session, "rollback"):
|
134
|
+
# Run the complete access initialization
|
135
|
+
access_init_command(verbose=True)
|
136
|
+
|
137
|
+
# Verify that custom permissions were created for the external roles
|
138
|
+
from cornflow.models import PermissionViewRoleModel
|
139
|
+
|
140
|
+
# Get all permissions for external app endpoints
|
141
|
+
all_permissions = PermissionViewRoleModel.query.all()
|
142
|
+
external_permissions = [
|
143
|
+
perm
|
144
|
+
for perm in all_permissions
|
145
|
+
if perm.api_view.name
|
146
|
+
in [
|
147
|
+
"production_planning",
|
148
|
+
"quality_control",
|
149
|
+
"scheduling_optimizer",
|
150
|
+
]
|
151
|
+
]
|
152
|
+
|
153
|
+
# Load expected permissions from JSON file
|
154
|
+
expected_permissions = self._load_expected_permissions(
|
155
|
+
"test_custom_role_creation_removal"
|
156
|
+
)
|
157
|
+
|
158
|
+
# Verify each expected permission exists
|
159
|
+
for role_id, action_id, endpoint_name in expected_permissions:
|
160
|
+
permission_exists = any(
|
161
|
+
p.role_id == role_id
|
162
|
+
and p.action_id == action_id
|
163
|
+
and p.api_view.name == endpoint_name
|
164
|
+
for p in external_permissions
|
165
|
+
)
|
166
|
+
self.assertTrue(
|
167
|
+
permission_exists,
|
168
|
+
f"Expected permission not found: role_id={role_id}, action_id={action_id}, endpoint={endpoint_name}",
|
169
|
+
)
|
170
|
+
|
171
|
+
# Verify we don't have unexpected permissions
|
172
|
+
actual_permission_tuples = {
|
173
|
+
(p.role_id, p.action_id, p.api_view.name)
|
174
|
+
for p in external_permissions
|
175
|
+
}
|
176
|
+
expected_permission_tuples = set(expected_permissions)
|
177
|
+
|
178
|
+
unexpected_permissions = (
|
179
|
+
actual_permission_tuples - expected_permission_tuples
|
180
|
+
)
|
181
|
+
self.assertEqual(
|
182
|
+
len(unexpected_permissions),
|
183
|
+
0,
|
184
|
+
f"Found unexpected permissions: {unexpected_permissions}",
|
185
|
+
)
|
186
|
+
|
187
|
+
missing_permissions = (
|
188
|
+
expected_permission_tuples - actual_permission_tuples
|
189
|
+
)
|
190
|
+
self.assertEqual(
|
191
|
+
len(missing_permissions),
|
192
|
+
0,
|
193
|
+
f"Missing expected permissions: {missing_permissions}",
|
194
|
+
)
|
195
|
+
|
196
|
+
@patch("cornflow.commands.auxiliar.import_module")
|
197
|
+
@patch("cornflow.commands.views.import_module")
|
198
|
+
@patch.dict(
|
199
|
+
os.environ, {"EXTERNAL_APP": "1", "EXTERNAL_APP_MODULE": "external_test_app"}
|
200
|
+
)
|
201
|
+
def test_role_removal_when_not_in_config(
|
202
|
+
self, mock_import_views, mock_import_auxiliar
|
203
|
+
):
|
204
|
+
"""
|
205
|
+
Test that roles are properly removed when they're no longer in EXTRA_PERMISSION_ASSIGNATION
|
206
|
+
"""
|
207
|
+
# First, create roles with permissions
|
208
|
+
mock_external_app = MagicMock()
|
209
|
+
|
210
|
+
# Mock the shared.const module
|
211
|
+
mock_shared = MagicMock()
|
212
|
+
mock_const = MagicMock()
|
213
|
+
mock_const.EXTRA_PERMISSION_ASSIGNATION = [
|
214
|
+
(10000, POST_ACTION, "production_planning"),
|
215
|
+
(999, PATCH_ACTION, "quality_control"),
|
216
|
+
]
|
217
|
+
# Define permissions for custom roles used in endpoints (888, 777) and test roles (10000, 999)
|
218
|
+
mock_const.CUSTOM_ROLES_ACTIONS = {
|
219
|
+
# Used in endpoints
|
220
|
+
888: [GET_ACTION],
|
221
|
+
# Used in endpoints
|
222
|
+
777: [GET_ACTION],
|
223
|
+
# Used in test
|
224
|
+
10000: [GET_ACTION],
|
225
|
+
# Used in test
|
226
|
+
999: [GET_ACTION],
|
227
|
+
}
|
228
|
+
mock_shared.const = mock_const
|
229
|
+
mock_external_app.shared = mock_shared
|
230
|
+
|
231
|
+
# Mock the endpoints.resources
|
232
|
+
mock_endpoints = MagicMock()
|
233
|
+
mock_endpoints.resources = self._create_mock_external_app_resources()
|
234
|
+
mock_external_app.endpoints = mock_endpoints
|
235
|
+
|
236
|
+
mock_import_views.return_value = mock_external_app
|
237
|
+
mock_import_auxiliar.return_value = mock_external_app
|
238
|
+
|
239
|
+
# Create initial roles
|
240
|
+
access_init_command(verbose=True)
|
241
|
+
|
242
|
+
# Now update config to remove role 777
|
243
|
+
mock_const.EXTRA_PERMISSION_ASSIGNATION = [
|
244
|
+
(10000, POST_ACTION, "production_planning"),
|
245
|
+
]
|
246
|
+
# Keep the same CUSTOM_ROLES_ACTIONS (role definitions don't change)
|
247
|
+
|
248
|
+
# Re-run permissions registration
|
249
|
+
access_init_command(verbose=True)
|
250
|
+
|
251
|
+
# Verify role 888 still has permissions but 777 does not
|
252
|
+
from cornflow.models import PermissionViewRoleModel
|
253
|
+
|
254
|
+
permissions_10000 = PermissionViewRoleModel.query.filter_by(role_id=10000).all()
|
255
|
+
permissions_999 = PermissionViewRoleModel.query.filter_by(role_id=999).all()
|
256
|
+
|
257
|
+
self.assertTrue(len(permissions_10000) > 0)
|
258
|
+
self.assertEqual(len(permissions_999), 0)
|
259
|
+
|
260
|
+
def test_fallback_when_no_external_config(self):
|
261
|
+
"""
|
262
|
+
Test that the system falls back gracefully when EXTRA_PERMISSION_ASSIGNATION is not available
|
263
|
+
"""
|
264
|
+
# Should not raise any exceptions
|
265
|
+
try:
|
266
|
+
access_init_command(verbose=True)
|
267
|
+
except Exception as e:
|
268
|
+
self.fail(f"access_init_command raised an exception: {e}")
|
269
|
+
|
270
|
+
@patch("cornflow.commands.auxiliar.import_module")
|
271
|
+
@patch("cornflow.commands.views.import_module")
|
272
|
+
@patch.dict(
|
273
|
+
os.environ, {"EXTERNAL_APP": "1", "EXTERNAL_APP_MODULE": "external_test_app"}
|
274
|
+
)
|
275
|
+
def test_external_app_missing_extra_permissions(
|
276
|
+
self, mock_import_views, mock_import_auxiliar
|
277
|
+
):
|
278
|
+
"""
|
279
|
+
Test graceful handling when external app doesn't have EXTRA_PERMISSION_ASSIGNATION
|
280
|
+
"""
|
281
|
+
# Mock external app without EXTRA_PERMISSION_ASSIGNATION
|
282
|
+
mock_external_app = MagicMock()
|
283
|
+
|
284
|
+
# Mock the shared module but without const.EXTRA_PERMISSION_ASSIGNATION
|
285
|
+
mock_shared = MagicMock()
|
286
|
+
mock_const = MagicMock()
|
287
|
+
# Don't set EXTRA_PERMISSION_ASSIGNATION to trigger AttributeError
|
288
|
+
del mock_const.EXTRA_PERMISSION_ASSIGNATION
|
289
|
+
# Define permissions for custom roles used in endpoints
|
290
|
+
mock_const.CUSTOM_ROLES_ACTIONS = {
|
291
|
+
# Used in endpoints
|
292
|
+
888: [GET_ACTION],
|
293
|
+
# Used in endpoints
|
294
|
+
777: [GET_ACTION],
|
295
|
+
}
|
296
|
+
mock_shared.const = mock_const
|
297
|
+
mock_external_app.shared = mock_shared
|
298
|
+
|
299
|
+
# Mock the endpoints.resources
|
300
|
+
mock_endpoints = MagicMock()
|
301
|
+
mock_endpoints.resources = self._create_mock_external_app_resources()
|
302
|
+
mock_external_app.endpoints = mock_endpoints
|
303
|
+
|
304
|
+
mock_import_views.return_value = mock_external_app
|
305
|
+
mock_import_auxiliar.return_value = mock_external_app
|
306
|
+
|
307
|
+
# Should not raise any exceptions, should fall back gracefully
|
308
|
+
try:
|
309
|
+
access_init_command(verbose=True)
|
310
|
+
except Exception as e:
|
311
|
+
self.fail(f"access_init_command raised an exception: {e}")
|
312
|
+
|
313
|
+
@patch("cornflow.commands.auxiliar.import_module")
|
314
|
+
@patch("cornflow.commands.views.import_module")
|
315
|
+
@patch.dict(
|
316
|
+
os.environ, {"EXTERNAL_APP": "1", "EXTERNAL_APP_MODULE": "external_test_app"}
|
317
|
+
)
|
318
|
+
def test_standard_role_extended_permissions(
|
319
|
+
self, mock_import_views, mock_import_auxiliar
|
320
|
+
):
|
321
|
+
"""
|
322
|
+
Test that standard roles (like VIEWER_ROLE) can get extended permissions from external app
|
323
|
+
"""
|
324
|
+
# Mock external app configuration with extended permissions for existing roles
|
325
|
+
mock_external_app = MagicMock()
|
326
|
+
|
327
|
+
# Mock the shared.const module
|
328
|
+
mock_shared = MagicMock()
|
329
|
+
mock_const = MagicMock()
|
330
|
+
mock_const.EXTRA_PERMISSION_ASSIGNATION = [
|
331
|
+
(VIEWER_ROLE, POST_ACTION, "production_planning"), # Extend standard role
|
332
|
+
(PLANNER_ROLE, DELETE_ACTION, "quality_control"), # Extend standard role
|
333
|
+
]
|
334
|
+
# Define permissions for custom roles used in endpoints
|
335
|
+
mock_const.CUSTOM_ROLES_ACTIONS = {
|
336
|
+
# Used in endpoints
|
337
|
+
888: [GET_ACTION],
|
338
|
+
# Used in endpoints
|
339
|
+
777: [GET_ACTION],
|
340
|
+
}
|
341
|
+
mock_shared.const = mock_const
|
342
|
+
mock_external_app.shared = mock_shared
|
343
|
+
|
344
|
+
# Mock the endpoints.resources
|
345
|
+
mock_endpoints = MagicMock()
|
346
|
+
mock_endpoints.resources = self._create_mock_external_app_resources()
|
347
|
+
mock_external_app.endpoints = mock_endpoints
|
348
|
+
|
349
|
+
mock_import_views.return_value = mock_external_app
|
350
|
+
mock_import_auxiliar.return_value = mock_external_app
|
351
|
+
|
352
|
+
# Mock the database session for testing
|
353
|
+
with patch.object(db.session, "commit"):
|
354
|
+
with patch.object(db.session, "rollback"):
|
355
|
+
# Run the complete access initialization
|
356
|
+
access_init_command(verbose=True)
|
357
|
+
|
358
|
+
# Verify that existing roles got the additional permissions
|
359
|
+
from cornflow.models import PermissionViewRoleModel
|
360
|
+
|
361
|
+
# Check that VIEWER_ROLE has additional permissions
|
362
|
+
viewer_permissions = PermissionViewRoleModel.query.filter_by(
|
363
|
+
role_id=VIEWER_ROLE
|
364
|
+
).all()
|
365
|
+
self.assertTrue(len(viewer_permissions) > 0)
|
366
|
+
|
367
|
+
# Check that PLANNER_ROLE has additional permissions
|
368
|
+
planner_permissions = PermissionViewRoleModel.query.filter_by(
|
369
|
+
role_id=PLANNER_ROLE
|
370
|
+
).all()
|
371
|
+
self.assertTrue(len(planner_permissions) > 0)
|
372
|
+
|
373
|
+
@patch("cornflow.commands.auxiliar.import_module")
|
374
|
+
@patch("cornflow.commands.views.import_module")
|
375
|
+
@patch.dict(
|
376
|
+
os.environ, {"EXTERNAL_APP": "1", "EXTERNAL_APP_MODULE": "external_test_app"}
|
377
|
+
)
|
378
|
+
def test_view_update_and_deletion(self, mock_import_views, mock_import_auxiliar):
|
379
|
+
"""
|
380
|
+
Test that views are updated when URLs change and deleted when resources are removed
|
381
|
+
"""
|
382
|
+
# === INITIAL SETUP ===
|
383
|
+
# Create initial mock external app with 3 endpoints
|
384
|
+
mock_external_app_initial = MagicMock()
|
385
|
+
|
386
|
+
# Initial mock endpoints
|
387
|
+
mock_production_endpoint = MagicMock()
|
388
|
+
mock_production_endpoint.ROLES_WITH_ACCESS = [888]
|
389
|
+
mock_production_endpoint.DESCRIPTION = "Production planning endpoint"
|
390
|
+
|
391
|
+
mock_quality_endpoint = MagicMock()
|
392
|
+
mock_quality_endpoint.ROLES_WITH_ACCESS = [777]
|
393
|
+
mock_quality_endpoint.DESCRIPTION = "Quality control endpoint"
|
394
|
+
|
395
|
+
mock_scheduling_endpoint = MagicMock()
|
396
|
+
mock_scheduling_endpoint.ROLES_WITH_ACCESS = [888, 777]
|
397
|
+
mock_scheduling_endpoint.DESCRIPTION = "Scheduling optimizer endpoint"
|
398
|
+
|
399
|
+
# Initial resources structure
|
400
|
+
initial_resources = [
|
401
|
+
{
|
402
|
+
"endpoint": "production_planning",
|
403
|
+
"urls": "/production-planning/",
|
404
|
+
"resource": mock_production_endpoint,
|
405
|
+
},
|
406
|
+
{
|
407
|
+
"endpoint": "quality_control",
|
408
|
+
"urls": "/quality-control/",
|
409
|
+
"resource": mock_quality_endpoint,
|
410
|
+
},
|
411
|
+
{
|
412
|
+
"endpoint": "scheduling_optimizer",
|
413
|
+
"urls": "/scheduling/",
|
414
|
+
"resource": mock_scheduling_endpoint,
|
415
|
+
},
|
416
|
+
]
|
417
|
+
|
418
|
+
# Mock the shared.const module (no extra permissions)
|
419
|
+
mock_shared_initial = MagicMock()
|
420
|
+
mock_const_initial = MagicMock()
|
421
|
+
mock_const_initial.EXTRA_PERMISSION_ASSIGNATION = []
|
422
|
+
# Define permissions for custom roles used in test endpoints
|
423
|
+
mock_const_initial.CUSTOM_ROLES_ACTIONS = {
|
424
|
+
# Used in test endpoints
|
425
|
+
888: [GET_ACTION],
|
426
|
+
# Used in test endpoints
|
427
|
+
777: [GET_ACTION],
|
428
|
+
}
|
429
|
+
mock_shared_initial.const = mock_const_initial
|
430
|
+
mock_external_app_initial.shared = mock_shared_initial
|
431
|
+
|
432
|
+
# Mock the endpoints.resources
|
433
|
+
mock_endpoints_initial = MagicMock()
|
434
|
+
mock_endpoints_initial.resources = initial_resources
|
435
|
+
mock_external_app_initial.endpoints = mock_endpoints_initial
|
436
|
+
|
437
|
+
mock_import_views.return_value = mock_external_app_initial
|
438
|
+
mock_import_auxiliar.return_value = mock_external_app_initial
|
439
|
+
|
440
|
+
# === INITIAL ACCESS INIT ===
|
441
|
+
|
442
|
+
# Mock the database session for testing - INITIAL SETUP ONLY
|
443
|
+
with patch.object(db.session, "commit"):
|
444
|
+
with patch.object(db.session, "rollback"):
|
445
|
+
# Run initial access initialization
|
446
|
+
access_init_command(verbose=True)
|
447
|
+
|
448
|
+
# Verify initial views were created
|
449
|
+
initial_views = ViewModel.query.filter(
|
450
|
+
ViewModel.name.in_(
|
451
|
+
[
|
452
|
+
"production_planning",
|
453
|
+
"quality_control",
|
454
|
+
"scheduling_optimizer",
|
455
|
+
]
|
456
|
+
)
|
457
|
+
).all()
|
458
|
+
|
459
|
+
self.assertEqual(
|
460
|
+
len(initial_views), 3, "Expected 3 initial views to be created"
|
461
|
+
)
|
462
|
+
|
463
|
+
# Get initial URLs
|
464
|
+
initial_views_dict = {
|
465
|
+
view.name: view.url_rule for view in initial_views
|
466
|
+
}
|
467
|
+
self.assertEqual(
|
468
|
+
initial_views_dict["production_planning"], "/production-planning/"
|
469
|
+
)
|
470
|
+
self.assertEqual(
|
471
|
+
initial_views_dict["quality_control"], "/quality-control/"
|
472
|
+
)
|
473
|
+
self.assertEqual(
|
474
|
+
initial_views_dict["scheduling_optimizer"], "/scheduling/"
|
475
|
+
)
|
476
|
+
|
477
|
+
# === MODIFY CONFIGURATION ===
|
478
|
+
# Create updated mock external app with:
|
479
|
+
# 1. Changed URL for production_planning
|
480
|
+
# 2. Changed URL for quality_control
|
481
|
+
# 3. Remove scheduling_optimizer entirely
|
482
|
+
mock_external_app_updated = MagicMock()
|
483
|
+
|
484
|
+
# Updated resources structure (scheduling_optimizer removed, URLs changed)
|
485
|
+
updated_resources = [
|
486
|
+
{
|
487
|
+
"endpoint": "production_planning",
|
488
|
+
"urls": "/new-production-planning/", # CHANGED URL
|
489
|
+
"resource": mock_production_endpoint,
|
490
|
+
},
|
491
|
+
{
|
492
|
+
"endpoint": "quality_control",
|
493
|
+
"urls": "/quality-control/",
|
494
|
+
"resource": mock_quality_endpoint,
|
495
|
+
},
|
496
|
+
# scheduling_optimizer REMOVED entirely
|
497
|
+
]
|
498
|
+
|
499
|
+
# Mock shared const (still no extra permissions)
|
500
|
+
mock_shared_updated = MagicMock()
|
501
|
+
mock_const_updated = MagicMock()
|
502
|
+
mock_const_updated.EXTRA_PERMISSION_ASSIGNATION = [] # Empty list
|
503
|
+
# Define permissions for custom roles used in test endpoints
|
504
|
+
mock_const_updated.CUSTOM_ROLES_ACTIONS = {
|
505
|
+
# Used in test endpoints
|
506
|
+
888: [GET_ACTION],
|
507
|
+
# Used in test endpoints
|
508
|
+
777: [GET_ACTION],
|
509
|
+
}
|
510
|
+
mock_shared_updated.const = mock_const_updated
|
511
|
+
mock_external_app_updated.shared = mock_shared_updated
|
512
|
+
|
513
|
+
# Mock updated endpoints.resources
|
514
|
+
mock_endpoints_updated = MagicMock()
|
515
|
+
mock_endpoints_updated.resources = updated_resources
|
516
|
+
mock_external_app_updated.endpoints = mock_endpoints_updated
|
517
|
+
|
518
|
+
# Update mocks to return updated configuration
|
519
|
+
mock_import_views.return_value = mock_external_app_updated
|
520
|
+
mock_import_auxiliar.return_value = mock_external_app_updated
|
521
|
+
|
522
|
+
# === SECOND ACCESS INIT (with updated config) ===
|
523
|
+
# Allow real commits for the update to be persisted
|
524
|
+
access_init_command(verbose=True)
|
525
|
+
|
526
|
+
# === VERIFY UPDATES ===
|
527
|
+
# Check that URLs were updated
|
528
|
+
updated_views = ViewModel.query.filter(
|
529
|
+
ViewModel.name.in_(["production_planning", "quality_control"])
|
530
|
+
).all()
|
531
|
+
|
532
|
+
self.assertEqual(
|
533
|
+
len(updated_views), 2, "Expected 2 views to remain after update"
|
534
|
+
)
|
535
|
+
|
536
|
+
updated_views_dict = {view.name: view.url_rule for view in updated_views}
|
537
|
+
self.assertEqual(
|
538
|
+
updated_views_dict["production_planning"],
|
539
|
+
"/new-production-planning/",
|
540
|
+
"production_planning URL should be updated",
|
541
|
+
)
|
542
|
+
self.assertEqual(
|
543
|
+
updated_views_dict["quality_control"],
|
544
|
+
"/quality-control/",
|
545
|
+
"quality_control URL should be updated",
|
546
|
+
)
|
547
|
+
|
548
|
+
# === VERIFY DELETION ===
|
549
|
+
# Check that scheduling_optimizer view was deleted
|
550
|
+
deleted_view = ViewModel.query.filter_by(name="scheduling_optimizer").first()
|
551
|
+
self.assertIsNone(deleted_view, "scheduling_optimizer view should be deleted")
|
552
|
+
|
553
|
+
# === VERIFY PERMISSIONS ARE CLEANED UP ===
|
554
|
+
from cornflow.models import PermissionViewRoleModel
|
555
|
+
|
556
|
+
# Check that permissions for deleted view are cleaned up
|
557
|
+
remaining_permissions = PermissionViewRoleModel.query.all()
|
558
|
+
scheduling_permissions = [
|
559
|
+
perm
|
560
|
+
for perm in remaining_permissions
|
561
|
+
if perm.api_view and perm.api_view.name == "scheduling_optimizer"
|
562
|
+
]
|
563
|
+
self.assertEqual(
|
564
|
+
len(scheduling_permissions),
|
565
|
+
0,
|
566
|
+
"All permissions for deleted scheduling_optimizer view should be removed",
|
567
|
+
)
|
568
|
+
|
569
|
+
# Check that permissions for remaining views still exist
|
570
|
+
remaining_external_permissions = [
|
571
|
+
perm
|
572
|
+
for perm in remaining_permissions
|
573
|
+
if perm.api_view
|
574
|
+
and perm.api_view.name in ["production_planning", "quality_control"]
|
575
|
+
]
|
576
|
+
|
577
|
+
all_view_names = [
|
578
|
+
perm.api_view.name for perm in remaining_permissions if perm.api_view
|
579
|
+
]
|
580
|
+
self.assertTrue(
|
581
|
+
len(remaining_external_permissions) > 0,
|
582
|
+
f"Permissions for remaining views should still exist. Found views: {set(all_view_names)}",
|
583
|
+
)
|
584
|
+
|
585
|
+
@patch("cornflow.commands.auxiliar.import_module")
|
586
|
+
@patch("cornflow.commands.views.import_module")
|
587
|
+
@patch.dict(
|
588
|
+
os.environ, {"EXTERNAL_APP": "1", "EXTERNAL_APP_MODULE": "external_test_app"}
|
589
|
+
)
|
590
|
+
def test_custom_roles_actions_success(
|
591
|
+
self, mock_import_views, mock_import_auxiliar
|
592
|
+
):
|
593
|
+
"""
|
594
|
+
Test that custom roles get their defined actions from CUSTOM_ROLES_ACTIONS
|
595
|
+
"""
|
596
|
+
# Mock external app configuration
|
597
|
+
mock_external_app = MagicMock()
|
598
|
+
|
599
|
+
# Mock the shared.const module with CUSTOM_ROLES_ACTIONS
|
600
|
+
mock_shared = MagicMock()
|
601
|
+
mock_const = MagicMock()
|
602
|
+
mock_const.EXTRA_PERMISSION_ASSIGNATION = []
|
603
|
+
# Define custom roles actions
|
604
|
+
mock_const.CUSTOM_ROLES_ACTIONS = {
|
605
|
+
# Custom role with multiple actions
|
606
|
+
888: [
|
607
|
+
GET_ACTION,
|
608
|
+
POST_ACTION,
|
609
|
+
PUT_ACTION,
|
610
|
+
],
|
611
|
+
# Another custom role with different actions
|
612
|
+
777: [
|
613
|
+
GET_ACTION,
|
614
|
+
PATCH_ACTION,
|
615
|
+
],
|
616
|
+
}
|
617
|
+
mock_shared.const = mock_const
|
618
|
+
mock_external_app.shared = mock_shared
|
619
|
+
|
620
|
+
# Mock the endpoints.resources with fake external app endpoints
|
621
|
+
mock_endpoints = MagicMock()
|
622
|
+
mock_endpoints.resources = self._create_mock_external_app_resources()
|
623
|
+
mock_external_app.endpoints = mock_endpoints
|
624
|
+
|
625
|
+
mock_import_views.return_value = mock_external_app
|
626
|
+
mock_import_auxiliar.return_value = mock_external_app
|
627
|
+
|
628
|
+
# Mock the database session for testing
|
629
|
+
with patch.object(db.session, "commit"):
|
630
|
+
with patch.object(db.session, "rollback"):
|
631
|
+
# Run the complete access initialization
|
632
|
+
access_init_command(verbose=True)
|
633
|
+
|
634
|
+
# Verify that custom permissions were created with the correct actions
|
635
|
+
from cornflow.models import PermissionViewRoleModel
|
636
|
+
|
637
|
+
# Get all permissions for role 888
|
638
|
+
permissions_888 = PermissionViewRoleModel.query.filter_by(
|
639
|
+
role_id=888
|
640
|
+
).all()
|
641
|
+
actions_888 = {perm.action_id for perm in permissions_888}
|
642
|
+
|
643
|
+
# Get all permissions for role 777
|
644
|
+
permissions_777 = PermissionViewRoleModel.query.filter_by(
|
645
|
+
role_id=777
|
646
|
+
).all()
|
647
|
+
actions_777 = {perm.action_id for perm in permissions_777}
|
648
|
+
|
649
|
+
# Verify role 888 has GET, POST, PUT actions
|
650
|
+
expected_actions_888 = {GET_ACTION, POST_ACTION, PUT_ACTION}
|
651
|
+
self.assertTrue(
|
652
|
+
expected_actions_888.issubset(actions_888),
|
653
|
+
f"Role 888 should have actions {expected_actions_888}, but got {actions_888}",
|
654
|
+
)
|
655
|
+
|
656
|
+
# Verify role 777 has GET, PATCH actions
|
657
|
+
expected_actions_777 = {GET_ACTION, PATCH_ACTION}
|
658
|
+
self.assertTrue(
|
659
|
+
expected_actions_777.issubset(actions_777),
|
660
|
+
f"Role 777 should have actions {expected_actions_777}, but got {actions_777}",
|
661
|
+
)
|
662
|
+
|
663
|
+
# Verify that role 888 does NOT have actions that were not defined
|
664
|
+
# We check that no DELETE or PATCH actions exist for role 888
|
665
|
+
forbidden_actions_888 = {DELETE_ACTION, PATCH_ACTION}
|
666
|
+
actual_forbidden_888 = actions_888.intersection(forbidden_actions_888)
|
667
|
+
self.assertEqual(
|
668
|
+
len(actual_forbidden_888),
|
669
|
+
0,
|
670
|
+
f"Role 888 should not have actions {forbidden_actions_888}, but found {actual_forbidden_888}",
|
671
|
+
)
|
672
|
+
|
673
|
+
# Verify that role 777 does NOT have actions that were not defined
|
674
|
+
# We check that no POST, PUT, DELETE actions exist for role 777
|
675
|
+
forbidden_actions_777 = {POST_ACTION, PUT_ACTION, DELETE_ACTION}
|
676
|
+
actual_forbidden_777 = actions_777.intersection(forbidden_actions_777)
|
677
|
+
self.assertEqual(
|
678
|
+
len(actual_forbidden_777),
|
679
|
+
0,
|
680
|
+
f"Role 777 should not have actions {forbidden_actions_777}, but found {actual_forbidden_777}",
|
681
|
+
)
|
682
|
+
|
683
|
+
@patch("cornflow.commands.auxiliar.import_module")
|
684
|
+
@patch("cornflow.commands.views.import_module")
|
685
|
+
@patch.dict(
|
686
|
+
os.environ, {"EXTERNAL_APP": "1", "EXTERNAL_APP_MODULE": "external_test_app"}
|
687
|
+
)
|
688
|
+
def test_custom_roles_actions_error_on_undefined_role(
|
689
|
+
self, mock_import_views, mock_import_auxiliar
|
690
|
+
):
|
691
|
+
"""
|
692
|
+
Test that an error is raised when a custom role is used but not defined in CUSTOM_ROLES_ACTIONS
|
693
|
+
"""
|
694
|
+
# Mock external app configuration
|
695
|
+
mock_external_app = MagicMock()
|
696
|
+
|
697
|
+
# Mock the shared.const module with CUSTOM_ROLES_ACTIONS that doesn't include role 888
|
698
|
+
mock_shared = MagicMock()
|
699
|
+
mock_const = MagicMock()
|
700
|
+
mock_const.EXTRA_PERMISSION_ASSIGNATION = []
|
701
|
+
# Define custom roles permissions but MISSING role 888 which is used in endpoints
|
702
|
+
mock_const.CUSTOM_ROLES_ACTIONS = {
|
703
|
+
# Only define role 777, but role 888 is used in endpoints
|
704
|
+
777: [
|
705
|
+
GET_ACTION,
|
706
|
+
PATCH_ACTION,
|
707
|
+
],
|
708
|
+
}
|
709
|
+
mock_shared.const = mock_const
|
710
|
+
mock_external_app.shared = mock_shared
|
711
|
+
|
712
|
+
# Mock the endpoints.resources with fake external app endpoints that use role 888
|
713
|
+
mock_endpoints = MagicMock()
|
714
|
+
mock_endpoints.resources = (
|
715
|
+
self._create_mock_external_app_resources()
|
716
|
+
) # This includes role 888
|
717
|
+
mock_external_app.endpoints = mock_endpoints
|
718
|
+
|
719
|
+
mock_import_views.return_value = mock_external_app
|
720
|
+
mock_import_auxiliar.return_value = mock_external_app
|
721
|
+
|
722
|
+
# Verify that a ValueError is raised for undefined role 888
|
723
|
+
with self.assertRaises(ValueError) as context:
|
724
|
+
access_init_command(verbose=True)
|
725
|
+
|
726
|
+
# Verify the error message contains the undefined role
|
727
|
+
error_message = str(context.exception)
|
728
|
+
self.assertIn("888", error_message)
|
729
|
+
self.assertIn("CUSTOM_ROLES_ACTIONS", error_message)
|
730
|
+
self.assertIn("not defined", error_message)
|
731
|
+
|
732
|
+
@patch("cornflow.commands.auxiliar.import_module")
|
733
|
+
@patch("cornflow.commands.views.import_module")
|
734
|
+
@patch.dict(
|
735
|
+
os.environ, {"EXTERNAL_APP": "1", "EXTERNAL_APP_MODULE": "external_test_app"}
|
736
|
+
)
|
737
|
+
def test_custom_roles_actions_fallback_when_not_defined(
|
738
|
+
self, mock_import_views, mock_import_auxiliar
|
739
|
+
):
|
740
|
+
"""
|
741
|
+
Test that the system falls back gracefully when CUSTOM_ROLES_ACTIONS is not defined
|
742
|
+
but no custom roles are used
|
743
|
+
"""
|
744
|
+
# Mock external app configuration
|
745
|
+
mock_external_app = MagicMock()
|
746
|
+
|
747
|
+
# Mock the shared.const module WITHOUT CUSTOM_ROLES_ACTIONS
|
748
|
+
mock_shared = MagicMock()
|
749
|
+
mock_const = MagicMock()
|
750
|
+
mock_const.EXTRA_PERMISSION_ASSIGNATION = []
|
751
|
+
# Don't set CUSTOM_ROLES_ACTIONS to trigger AttributeError
|
752
|
+
# This simulates an external app that doesn't define the new constant
|
753
|
+
mock_shared.const = mock_const
|
754
|
+
mock_external_app.shared = mock_shared
|
755
|
+
|
756
|
+
# Mock endpoints that only use standard roles (no custom roles)
|
757
|
+
mock_production_endpoint = MagicMock()
|
758
|
+
# Only standard role
|
759
|
+
mock_production_endpoint.ROLES_WITH_ACCESS = [PLANNER_ROLE]
|
760
|
+
mock_production_endpoint.DESCRIPTION = "Production planning endpoint"
|
761
|
+
|
762
|
+
mock_resources = [
|
763
|
+
{
|
764
|
+
"endpoint": "production_planning",
|
765
|
+
"urls": "/production-planning/",
|
766
|
+
"resource": mock_production_endpoint,
|
767
|
+
}
|
768
|
+
]
|
769
|
+
|
770
|
+
mock_endpoints = MagicMock()
|
771
|
+
mock_endpoints.resources = mock_resources
|
772
|
+
mock_external_app.endpoints = mock_endpoints
|
773
|
+
|
774
|
+
mock_import_views.return_value = mock_external_app
|
775
|
+
mock_import_auxiliar.return_value = mock_external_app
|
776
|
+
|
777
|
+
# Should not raise any exceptions since no custom roles are used
|
778
|
+
try:
|
779
|
+
access_init_command(verbose=True)
|
780
|
+
except Exception as e:
|
781
|
+
self.fail(f"access_init_command raised an exception when it shouldn't: {e}")
|
782
|
+
|
783
|
+
|
784
|
+
if __name__ == "__main__":
|
785
|
+
unittest.main()
|