Skip to content

Commit 76264f8

Browse files
committed
unit tests + user resync logic
1 parent 395ea42 commit 76264f8

2 files changed

Lines changed: 161 additions & 53 deletions

File tree

src/mas/devops/users.py

Lines changed: 69 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -19,23 +19,12 @@
1919
import time
2020
import re
2121

22-
2322
'''
2423
TODO:
25-
idempotency
26-
handle user deletion if removed from config?
2724
MVI / other apps?
2825
unit tests
2926
30-
where are we going to run this from? Needs to run in cluster, so a Job in an app
31-
which app though? Manage?
32-
Perhaps we do the core bits in a core app (suite workspace?)
33-
And app-specific bits in the apps themselves
34-
but if we create a user before manage is ready, sync fails, and no way to trigger resync in MAS < 9.1.0?
35-
# could do it all from a core app, but check for readiness of apps
36-
Run in a dedicated instance-root app in the final syncwave
37-
38-
how ot cope with users that have been soft-deleted? tolerate / skip - ensure they are removed from secret so
27+
how to cope with users that have been soft-deleted? tolerate / skip - ensure they are removed from secret so
3928
we don't get caught in an infinite loop?
4029
4130
'''
@@ -282,6 +271,47 @@ def get_or_create_user(self, payload):
282271

283272
raise Exception(f"{response.status_code} {response.text}")
284273

274+
def update_user(self, payload):
275+
user_id = payload["id"]
276+
self.logger.debug(f"Updating user {user_id}")
277+
url = f"{self.mas_api_url_internal}/v3/users/{user_id}"
278+
headers = {
279+
"Accept": "application/json",
280+
"x-access-token": self.superuser_auth_token
281+
}
282+
response = requests.put(
283+
url,
284+
headers=headers,
285+
json=payload,
286+
verify=self.core_internal_ca_pem_file_path
287+
)
288+
289+
if response.status_code == 200:
290+
return response.json()
291+
292+
raise Exception(f"{response.status_code} {response.text}")
293+
294+
def update_user_display_name(self, user_id, display_name):
295+
self.logger.debug(f"Updating user display name {user_id}")
296+
url = f"{self.mas_api_url_internal}/v3/users/{user_id}"
297+
headers = {
298+
"Accept": "application/json",
299+
"x-access-token": self.superuser_auth_token
300+
}
301+
response = requests.patch(
302+
url,
303+
headers=headers,
304+
json={
305+
"displayName": display_name
306+
},
307+
verify=self.core_internal_ca_pem_file_path
308+
)
309+
310+
if response.status_code == 200:
311+
return response.json()
312+
313+
raise Exception(f"{response.status_code} {response.text}")
314+
285315
def link_user_to_local_idp(self, user_id, email_password=False):
286316
'''
287317
Checks if user already has a local identity, no-op if so.
@@ -437,49 +467,41 @@ def set_user_application_permission(self, user_id, application_id, role):
437467

438468
raise Exception(f"{response.status_code} {response.text}")
439469

440-
def check_user_sync(self, user_id, application_id, timeout_secs=60 * 10):
470+
def check_user_sync(self, user_id, application_id, timeout_secs=60 * 10, retry_interval_secs=5):
441471
t_end = time.time() + timeout_secs
442472
self.logger.info(f"Awaiting user {user_id} sync status \"SUCCESS\" for app {application_id}: {t_end - time.time():.2f} seconds remaining")
443473
while time.time() < t_end:
444474
user = self.get_user(user_id)
445-
sync_state = user["applications"][application_id]["sync"]["state"]
446-
if sync_state == "SUCCESS":
447-
return
448-
elif sync_state == "ERROR":
449-
# coreapi >= 25.2.3, not in mas 9.0.9, mas >= 9.1 only?
450-
# self.resync_users([user_id])
451-
# time.sleep(8)
452-
# alternative mechanism to kick off a user resync?
453-
# if not, bomb out here since we'll never get SUCCESS?
454-
# TODO: I think you can just set user roles against to retrigger user sync
455-
raise Exception(f"User {user_id} sync failed, aborting")
475+
476+
if "applications" not in user or application_id not in user["applications"] or "sync" not in user["applications"][application_id] or "state" not in user["applications"][application_id]["sync"]:
477+
self.logger.warning(f"User {user_id} does not have any sync state for application {application_id}, triggering resync")
478+
self.resync_users([user_id])
479+
time.sleep(retry_interval_secs)
456480
else:
457-
self.logger.info(f"User {user_id} sync has not been completed yet for app {application_id} (currrently {sync_state}): {t_end - time.time():.2f} seconds remaining")
458-
time.sleep(5)
481+
sync_state = user["applications"][application_id]["sync"]["state"]
482+
if sync_state == "SUCCESS":
483+
return
484+
elif sync_state == "ERROR":
485+
self.logger.warning(f"User {user_id} sync state for {application_id} was {sync_state}, triggering resync")
486+
self.resync_users([user_id])
487+
time.sleep(retry_interval_secs)
488+
else:
489+
self.logger.info(f"User {user_id} sync has not been completed yet for app {application_id} (currrently {sync_state}): {t_end - time.time():.2f} seconds remaining")
490+
time.sleep(retry_interval_secs)
459491
raise Exception(f"User {user_id} sync failed to complete for app within {timeout_secs} seconds")
460492

461-
# coreapi >= 25.2.3, not in mas 9.0.9, mas >= 9.1 only?
493+
def resync_user(self, user_ids):
494+
self.logger.info(f"Issuing resync request(s) for user(s) {user_ids}")
462495

463-
def resync_users(self, user_ids):
464-
self.logger.info(f"Issuing resync request for user(s) {user_ids}")
465-
url = f"{self.mas_api_url_internal}/v3/users/utils/resync"
466-
querystring = {}
467-
payload = {
468-
"users": user_ids
469-
}
470-
headers = {
471-
"Content-Type": "application/json",
472-
"x-access-token": self.superuser_auth_token
473-
}
474-
response = requests.put(
475-
url,
476-
json=payload,
477-
headers=headers,
478-
params=querystring,
479-
verify=self.core_internal_ca_pem_file_path
480-
)
481-
if response.status_code != 204:
482-
raise Exception(response.text)
496+
# The "/v3/users/utils/resync" API is only available in MAS Core >= 9.1 (coreapi >= 25.2.3)
497+
# Until it is available in all supported versions of MAS,
498+
# we instead perform a no-op update to the user to achieve the same effect
499+
# (the "update user profile" API is used as this is this allows us to isolate the displayName field,
500+
# which reduces the impact of concurrent updates leading to race conditions)
501+
502+
for user_id in user_ids:
503+
user = self.get_user(user_id)
504+
self.update_user_display_name(user_id, user["displayName"])
483505

484506
def create_or_get_manage_api_key_for_user(self, user_id):
485507
self.logger.debug(f"Attempting to create Manage API Key for user {user_id}")

test/src/test_users.py

Lines changed: 92 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,14 +70,9 @@ def user_utils(mock_v1_secrets, requests_mock):
7070
coreapi_port=COREAPI_PORT,
7171
admin_dashboard_port=ADMIN_DASHBOARD_PORT
7272
)
73-
get_token = requests_mock.post(f"{MAS_ADMIN_URL}/logininitial", json=dict(token=TOKEN))
74-
assert get_token.call_count == 0
73+
requests_mock.post(f"{MAS_ADMIN_URL}/logininitial", json=dict(token=TOKEN))
7574
yield user_utils
7675

77-
# assuming the test calls any MAS Core API (all do)
78-
# we expect the token endpoint to have been called exactly once (and its response cached)
79-
assert get_token.call_count == 1
80-
8176

8277
def mock_get_user(requests_mock, user_id, json, status_code):
8378
return requests_mock.get(
@@ -113,6 +108,11 @@ def test_get_user_exists(user_utils, requests_mock):
113108
assert get.call_count == 1
114109

115110

111+
def test_mas_superuser_credentials():
112+
pass
113+
# TODO this and tests for other properties
114+
115+
116116
def test_get_user_notfound(user_utils, requests_mock):
117117
user_id = "user1"
118118
get = mock_get_user_404(requests_mock, user_id)
@@ -417,3 +417,89 @@ def test_set_user_application_permissions_alreadyset(user_utils, requests_mock):
417417
user_utils.set_user_application_permission(user_id, application_id, "USER")
418418
assert get.call_count == 1
419419
assert put.call_count == 0
420+
421+
422+
def test_check_user_sync(user_utils, requests_mock):
423+
user_id = "user1"
424+
application_id = "manage"
425+
426+
# transitions from PENDING -> SUCCESS on the third call
427+
attempts = 0
428+
429+
def json_callback(request, context):
430+
nonlocal attempts
431+
if attempts >= 2:
432+
state = "SUCCESS"
433+
else:
434+
state = "PENDING"
435+
attempts = attempts + 1
436+
return {
437+
"id": user_id,
438+
"applications": {
439+
"other": {
440+
"sync": {
441+
"state": "ERROR"
442+
}
443+
},
444+
"manage": {
445+
"sync": {
446+
"state": state
447+
}
448+
}
449+
}
450+
}
451+
452+
get = mock_get_user(
453+
requests_mock,
454+
user_id,
455+
json_callback,
456+
200
457+
)
458+
459+
user_utils.check_user_sync(user_id, application_id, timeout_secs=8, retry_interval_secs=0)
460+
assert get.call_count == 3
461+
462+
463+
def test_check_user_sync_timeout(user_utils, requests_mock):
464+
user_id = "user1"
465+
application_id = "manage"
466+
467+
get = mock_get_user(
468+
requests_mock,
469+
user_id,
470+
{
471+
"id": user_id,
472+
"applications": {
473+
"other": {
474+
"sync": {
475+
"state": "ERROR"
476+
}
477+
},
478+
"manage": {
479+
"sync": {
480+
"state": "PENDING"
481+
}
482+
}
483+
}
484+
},
485+
200
486+
)
487+
with pytest.raises(Exception) as excinfo:
488+
user_utils.check_user_sync(user_id, application_id, timeout_secs=0.3, retry_interval_secs=0.05)
489+
assert str(excinfo.value) == f"User {user_id} sync failed to complete for app within {0.3} seconds"
490+
assert get.call_count > 1
491+
492+
493+
def test_check_user_sync_appstate_notfound(user_utils, requests_mock):
494+
pass
495+
# TODO
496+
497+
498+
def test_check_user_sync_appstate_transient_error(user_utils, requests_mock):
499+
pass
500+
# TODO
501+
502+
503+
def test_check_user_sync_appstate_persistent_error(user_utils, requests_mock):
504+
pass
505+
# TODO

0 commit comments

Comments
 (0)