Skip to content

Commit ab920aa

Browse files
authored
Test and fix empty action handling (#413)
### Requirements List - Bug fix for #412 ### Description List - Revised empty action handling in the staff-user permissions api Closes # #412
1 parent 0978070 commit ab920aa

File tree

7 files changed

+310
-191
lines changed

7 files changed

+310
-191
lines changed

backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/base_record.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ def __init__(self, *args, **kwargs):
3535
class Set(List):
3636
"""A Field that de/serializes to a Set (not compatible with JSON)"""
3737

38+
default_error_messages = {'invalid': 'Not a valid set.'}
39+
3840
def _serialize(self, *args, **kwargs):
3941
return set(super()._serialize(*args, **kwargs))
4042

backend/compact-connect/lambdas/python/common/cc_common/data_model/schema/user.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@ class CompactPermissionsRecordSchema(Schema):
1818
allow_none=False,
1919
)
2020

21+
@post_dump
22+
def drop_empty_actions(self, data, **kwargs): # noqa: ARG002 unused-kwargs
23+
"""
24+
DynamoDB doesn't like empty sets, so we will make a point to drop an actions field entirely,
25+
if it is empty.
26+
"""
27+
if not data.get('actions', {}):
28+
data.pop('actions', None)
29+
empty_jurisdictions = [jurisdiction for jurisdiction, actions in data['jurisdictions'].items() if not actions]
30+
for jurisdiction in empty_jurisdictions:
31+
del data['jurisdictions'][jurisdiction]
32+
return data
33+
2134

2235
class UserAttributesSchema(Schema):
2336
email = String(required=True, allow_none=False, validate=Length(1, 100))
@@ -63,7 +76,7 @@ class CompactActionPermissionAPISchema(Schema):
6376
actions = Dict(
6477
keys=String(), # Keys are actions
6578
values=Boolean(),
66-
required=True,
79+
required=False,
6780
allow_none=False,
6881
)
6982

@@ -90,7 +103,7 @@ class UserAPISchema(Schema):
90103
dateOfUpdate = Raw(required=True, allow_none=False)
91104
attributes = Nested(UserAttributesSchema(), required=True, allow_none=False)
92105
permissions = Dict(
93-
keys=String(validate=OneOf(config.compacts)),
106+
keys=String(validate=OneOf(config.compacts)), # Key is one compact
94107
values=Nested(CompactPermissionsAPISchema(), required=True, allow_none=False),
95108
validate=Length(equal=1),
96109
)
@@ -128,12 +141,14 @@ def transform_to_dynamo_permissions(self, data, **kwargs): # noqa: ARG002 unuse
128141
data['compact'] = compact
129142

130143
# { "actions": { "read": True } } -> { "actions": { "read" } }
131-
data['permissions']['actions'] = {key for key, value in data['permissions']['actions'].items() if value is True}
144+
data['permissions']['actions'] = {
145+
key for key, value in data['permissions'].get('actions', {}).items() if value is True
146+
}
132147

133148
# { "oh": { "actions": { "write": True } } } -> { "oh": { "write" } }
134149
for jurisdiction, jurisdiction_permissions in data['permissions']['jurisdictions'].items():
135150
data['permissions']['jurisdictions'][jurisdiction] = {
136-
key for key, value in jurisdiction_permissions['actions'].items() if value is True
151+
key for key, value in jurisdiction_permissions.get('actions', {}).items() if value is True
137152
}
138153

139154
return data

backend/compact-connect/lambdas/python/staff-users/tests/function/test_handlers/test_patch_user.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,79 @@ def test_patch_user_document_path_overlap(self):
105105
user,
106106
)
107107

108+
def test_patch_user_add_to_empty_actions(self):
109+
from handlers.users import patch_user, post_user
110+
111+
with open('tests/resources/api-event.json') as f:
112+
event = json.load(f)
113+
114+
with open('tests/resources/api/user-post.json') as f:
115+
api_user = json.load(f)
116+
# Create a user with no compact read or admin, no actions in a jurisdiction
117+
api_user['permissions'] = {'aslp': {'jurisdictions': {}}}
118+
event['body'] = json.dumps(api_user)
119+
120+
event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/admin aslp/aslp.admin'
121+
event['pathParameters'] = {'compact': 'aslp'}
122+
123+
resp = post_user(event, self.mock_context)
124+
self.assertEqual(200, resp['statusCode'])
125+
user = json.loads(resp['body'])
126+
user_id = user.pop('userId')
127+
128+
# Add compact read and oh admin permissions to the user
129+
event['pathParameters'] = {'compact': 'aslp', 'userId': user_id}
130+
api_user['permissions'] = {
131+
'aslp': {'actions': {'read': True}, 'jurisdictions': {'oh': {'actions': {'admin': True}}}}
132+
}
133+
event['body'] = json.dumps(api_user)
134+
135+
resp = patch_user(event, self.mock_context)
136+
self.assertEqual(200, resp['statusCode'])
137+
user = json.loads(resp['body'])
138+
139+
# Drop backend-generated fields from comparison
140+
del user['userId']
141+
del user['dateOfUpdate']
142+
143+
self.assertEqual(api_user, user)
144+
145+
def test_patch_user_remove_all_actions(self):
146+
from handlers.users import patch_user, post_user
147+
148+
with open('tests/resources/api-event.json') as f:
149+
event = json.load(f)
150+
151+
with open('tests/resources/api/user-post.json') as f:
152+
api_user = json.load(f)
153+
154+
event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/admin aslp/aslp.admin'
155+
event['pathParameters'] = {'compact': 'aslp'}
156+
event['body'] = json.dumps(api_user)
157+
158+
resp = post_user(event, self.mock_context)
159+
self.assertEqual(200, resp['statusCode'])
160+
user = json.loads(resp['body'])
161+
user_id = user.pop('userId')
162+
163+
# Remove all the permissions from the user
164+
event['pathParameters'] = {'compact': 'aslp', 'userId': user_id}
165+
api_user['permissions'] = {
166+
'aslp': {'actions': {'read': False}, 'jurisdictions': {'oh': {'actions': {'write': False}}}}
167+
}
168+
event['body'] = json.dumps(api_user)
169+
170+
resp = patch_user(event, self.mock_context)
171+
self.assertEqual(200, resp['statusCode'])
172+
user = json.loads(resp['body'])
173+
174+
# Drop backend-generated fields from comparison
175+
del user['userId']
176+
del user['dateOfUpdate']
177+
178+
api_user['permissions'] = {'aslp': {'jurisdictions': {}}}
179+
self.assertEqual(api_user, user)
180+
108181
def test_patch_user_forbidden(self):
109182
self._load_user_data()
110183

backend/compact-connect/lambdas/python/staff-users/tests/function/test_handlers/test_post_user.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,49 @@ def test_post_user(self):
3434

3535
self.assertEqual(api_user, user)
3636

37+
def test_post_user_no_compact_perms_round_trip(self):
38+
from handlers.users import get_one_user, post_user
39+
40+
with open('tests/resources/api-event.json') as f:
41+
event = json.load(f)
42+
43+
with open('tests/resources/api/user-post.json') as f:
44+
api_user = json.load(f)
45+
# A user with no compact read or admin, no actions in a jurisdiction
46+
api_user['permissions'] = {'aslp': {'actions': {}, 'jurisdictions': {'oh': {'actions': {}}}}}
47+
event['body'] = json.dumps(api_user)
48+
49+
# The user has admin permission for aslp admin
50+
event['requestContext']['authorizer']['claims']['scope'] = (
51+
'openid email aslp/admin aslp/aslp.admin aslp/oh.admin'
52+
)
53+
event['pathParameters'] = {'compact': 'aslp'}
54+
55+
resp = post_user(event, self.mock_context)
56+
self.assertEqual(200, resp['statusCode'])
57+
user = json.loads(resp['body'])
58+
59+
# Drop backend-generated fields from comparison
60+
user_id = user.pop('userId')
61+
del user['dateOfUpdate']
62+
# The aslp.actions and aslp.jurisdictions.oh fields should be removed, since they are empty
63+
api_user['permissions'] = {'aslp': {'jurisdictions': {}}}
64+
65+
self.assertEqual(api_user, user)
66+
67+
# Get the user back out via the API to check GET vs POST consistency
68+
del event['body']
69+
event['pathParameters'] = {'compact': 'aslp', 'userId': user_id}
70+
resp = get_one_user(event, self.mock_context)
71+
self.assertEqual(200, resp['statusCode'])
72+
user = json.loads(resp['body'])
73+
74+
# Drop backend-generated fields from comparison
75+
del user['userId']
76+
del user['dateOfUpdate']
77+
78+
self.assertEqual(api_user, user)
79+
3780
def test_post_user_unauthorized(self):
3881
from handlers.users import post_user
3982

backend/compact-connect/stacks/api_stack/v1_api/api.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,4 +124,5 @@ def __init__(self, root: IResource, persistent_stack: ps.PersistentStack):
124124
self_resource=staff_users_self_resource,
125125
admin_scopes=admin_scopes,
126126
persistent_stack=persistent_stack,
127+
api_model=self.api_model,
127128
)

backend/compact-connect/stacks/api_stack/v1_api/api_model.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,166 @@ def bulk_upload_response_model(self) -> Model:
171171
)
172172
return self.api.bulk_upload_response_model
173173

174+
@property
175+
def post_staff_user_model(self):
176+
"""Return the Post User Model, which should only be created once per API"""
177+
if hasattr(self.api, 'v1_post_user_request_model'):
178+
return self.api.v1_post_user_request_model
179+
180+
self.api.v1_post_user_request_model = self.api.add_model(
181+
'V1PostUserRequestModel',
182+
description='Post user request model',
183+
schema=JsonSchema(
184+
type=JsonSchemaType.OBJECT,
185+
required=['attributes', 'permissions'],
186+
additional_properties=False,
187+
properties=self._common_staff_user_properties,
188+
),
189+
)
190+
return self.api.v1_post_user_request_model
191+
192+
@property
193+
def get_staff_user_me_model(self):
194+
"""Return the Get Me Model, which should only be created once per API"""
195+
if hasattr(self.api, 'v1_get_me_model'):
196+
return self.api.v1_get_me_model
197+
198+
self.api.v1_get_me_model = self.api.add_model(
199+
'V1GetMeModel',
200+
description='Get me response model',
201+
schema=self._staff_user_response_schema,
202+
)
203+
return self.api.v1_get_me_model
204+
205+
@property
206+
def get_staff_users_response_model(self):
207+
"""Return the Get Users Model, which should only be created once per API"""
208+
if hasattr(self.api, 'v1_get_staff_users_response_model'):
209+
return self.api.v1_get_staff_users_response_model
210+
211+
self.api.v1_get_staff_users_response_model = self.api.add_model(
212+
'V1GetStaffUsersModel',
213+
description='Get staff users response model',
214+
schema=JsonSchema(
215+
type=JsonSchemaType.OBJECT,
216+
additional_properties=False,
217+
properties={
218+
'users': JsonSchema(type=JsonSchemaType.ARRAY, items=self._staff_user_response_schema),
219+
'pagination': self._pagination_response_schema,
220+
},
221+
),
222+
)
223+
return self.api.v1_get_staff_users_response_model
224+
225+
@property
226+
def patch_staff_user_me_model(self):
227+
"""Return the Get Me Model, which should only be created once per API"""
228+
if hasattr(self.api, 'v1_patch_me_request_model'):
229+
return self.api.v1_patch_me_request_model
230+
231+
self.api.v1_patch_me_request_model = self.api.add_model(
232+
'V1PatchMeRequestModel',
233+
description='Patch me request model',
234+
schema=JsonSchema(
235+
type=JsonSchemaType.OBJECT,
236+
additional_properties=False,
237+
properties={
238+
'attributes': self._staff_user_patch_attributes_schema,
239+
},
240+
),
241+
)
242+
return self.api.v1_patch_me_request_model
243+
244+
@property
245+
def patch_staff_user_model(self):
246+
"""Return the Patch User Model, which should only be created once per API"""
247+
if hasattr(self.api, 'v1_patch_user_request_model'):
248+
return self.api.v1_patch_user_request_model
249+
250+
self.api.v1_patch_user_request_model = self.api.add_model(
251+
'V1PatchUserRequestModel',
252+
description='Patch user request model',
253+
schema=JsonSchema(
254+
type=JsonSchemaType.OBJECT,
255+
additional_properties=False,
256+
properties={'permissions': self._staff_user_permissions_schema},
257+
),
258+
)
259+
return self.api.v1_patch_user_request_model
260+
261+
@property
262+
def _staff_user_attributes_schema(self):
263+
return JsonSchema(
264+
type=JsonSchemaType.OBJECT,
265+
required=['email', 'givenName', 'familyName'],
266+
additional_properties=False,
267+
properties={
268+
'email': JsonSchema(type=JsonSchemaType.STRING, min_length=5, max_length=100),
269+
'givenName': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100),
270+
'familyName': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100),
271+
},
272+
)
273+
274+
@property
275+
def _staff_user_patch_attributes_schema(self):
276+
"""No support for changing a user's email address."""
277+
return JsonSchema(
278+
type=JsonSchemaType.OBJECT,
279+
additional_properties=False,
280+
properties={
281+
'givenName': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100),
282+
'familyName': JsonSchema(type=JsonSchemaType.STRING, min_length=1, max_length=100),
283+
},
284+
)
285+
286+
@property
287+
def _staff_user_permissions_schema(self):
288+
return JsonSchema(
289+
type=JsonSchemaType.OBJECT,
290+
additional_properties=JsonSchema(
291+
type=JsonSchemaType.OBJECT,
292+
additional_properties=False,
293+
properties={
294+
'actions': JsonSchema(
295+
type=JsonSchemaType.OBJECT,
296+
properties={
297+
'read': JsonSchema(type=JsonSchemaType.BOOLEAN),
298+
'admin': JsonSchema(type=JsonSchemaType.BOOLEAN),
299+
},
300+
),
301+
'jurisdictions': JsonSchema(
302+
type=JsonSchemaType.OBJECT,
303+
additional_properties=JsonSchema(
304+
type=JsonSchemaType.OBJECT,
305+
properties={
306+
'actions': JsonSchema(
307+
type=JsonSchemaType.OBJECT,
308+
additional_properties=False,
309+
properties={
310+
'write': JsonSchema(type=JsonSchemaType.BOOLEAN),
311+
'admin': JsonSchema(type=JsonSchemaType.BOOLEAN),
312+
},
313+
),
314+
},
315+
),
316+
),
317+
},
318+
),
319+
)
320+
321+
@property
322+
def _common_staff_user_properties(self):
323+
return {'attributes': self._staff_user_attributes_schema, 'permissions': self._staff_user_permissions_schema}
324+
325+
@property
326+
def _staff_user_response_schema(self):
327+
return JsonSchema(
328+
type=JsonSchemaType.OBJECT,
329+
required=['userId', 'attributes', 'permissions'],
330+
additional_properties=False,
331+
properties={'userId': JsonSchema(type=JsonSchemaType.STRING), **self._common_staff_user_properties},
332+
)
333+
174334
@property
175335
def post_provider_user_military_affiliation_request_model(self) -> Model:
176336
"""Return the post payment processor credentials request model, which should only be created once per API"""

0 commit comments

Comments
 (0)