def _update_user(self, req: Dict[str, Any], scim_id: UUID, version: Optional[ObjectId], expect_success: bool = True) -> UserApiResult: if 'schemas' not in req: _schemas = [SCIMSchema.CORE_20_USER.value] if SCIMSchema.NUTID_USER_V1.value in req: _schemas += [SCIMSchema.NUTID_USER_V1.value] req['schemas'] = _schemas if 'id' not in req: req['id'] = str(scim_id) _headers = dict(self.headers) # copy if version: _headers['IF-MATCH'] = make_etag(version) result = self.client.simulate_put(path=f'/Users/{scim_id}', body=self.as_json(req), headers=_headers) if expect_success: self._assertResponse(result) response: UserResponse = UserResponseSchema().load(result.json) return UserApiResult(request=req, nutid_user=response.nutid_user_v1, result=result, response=response)
def test_update_user_set_external_id(self): db_user = self.add_user(identifier=str(uuid4()), profiles={'test': self.test_profile}) req = { 'schemas': [SCIMSchema.CORE_20_USER.value, SCIMSchema.NUTID_USER_V1.value], 'id': str(db_user.scim_id), 'externalId': 'test-id-1', SCIMSchema.NUTID_USER_V1.value: { 'profiles': { 'test': { 'attributes': { 'displayName': 'New display name' }, 'data': { 'test_key': 'new value' } } }, }, } self.headers['IF-MATCH'] = make_etag(db_user.version) response = self.client.simulate_put(path=f'/Users/{db_user.scim_id}', body=self.as_json(req), headers=self.headers) self._assertResponse(response) db_user = self.userdb.get_user_by_scim_id(response.json['id']) self._assertUserUpdateSuccess(req, response, db_user)
def test_delete_group(self): group = self.add_group(uuid4(), 'Test Group 1') # Verify we can find the group in the database db_group1 = self.groupdb.get_group_by_scim_id(str(group.scim_id)) self.assertIsNotNone(db_group1) req = {'schemas': [SCIMSchema.CORE_20_GROUP.value]} self.headers['IF-MATCH'] = make_etag(group.version) response = self.client.simulate_delete(path=f'/Groups/{group.scim_id}', body=self.as_json(req), headers=self.headers) self.assertEqual(204, response.status_code) # Verify the group is no longer in the database db_group2 = self.groupdb.get_group_by_scim_id(group.scim_id) self.assertIsNone(db_group2) # check that the action resulted in an event in the database events = self.eventdb.get_events_by_resource(SCIMResourceType.GROUP, db_group1.scim_id) assert len(events) == 1 event = events[0] assert event.resource.external_id is None assert event.data['status'] == EventStatus.DELETED.value
def test_removing_group_member(self): db_group = self.add_group(uuid4(), 'Test Group 1') subgroup = self.add_group(uuid4(), 'Test Group 2') db_group = self.add_member(db_group, subgroup, 'Test User') user = self.add_user(identifier=str(uuid4()), external_id='not-used') db_group = self.add_member(db_group, user, 'Test User') # Load group to verify it has two members _g1 = self.groupdb.get_group_by_scim_id(str(db_group.scim_id)) self.assertEqual( 2, len(_g1.graph.members), 'Group loaded from database does not have two members') self.assertEqual( 1, len(_g1.graph.member_users), 'Group loaded from database does not have one member user') self.assertEqual( 1, len(_g1.graph.member_groups), 'Group loaded from database does not have one member group') members = [ { 'value': str(user.scim_id), '$ref': f'http://localhost:8000/Users/{user.scim_id}', 'display': 'Test User 1', }, ] req = { 'schemas': [SCIMSchema.CORE_20_GROUP.value, SCIMSchema.NUTID_GROUP_V1.value], 'id': str(db_group.scim_id), 'displayName': db_group.display_name, 'members': members, SCIMSchema.NUTID_GROUP_V1.value: { 'data': { 'test': 'updated' } }, } self.headers['IF-MATCH'] = make_etag(db_group.version) response = self.client.simulate_put(path=f'/Groups/{db_group.scim_id}', body=self.as_json(req), headers=self.headers) self._assertGroupUpdateSuccess(req, response, db_group) # Load group to verify it has one less member now _g2 = self.groupdb.get_group_by_scim_id(str(db_group.scim_id)) self.assertEqual( 1, len(_g2.graph.members), 'Group loaded from database does not have two members') self.assertEqual( 1, len(_g2.graph.member_users), 'Group loaded from database does not have one member user') self.assertEqual( 0, len(_g2.graph.member_groups), 'Group loaded from database does not have one member group')
def _db_group_to_response(self, resp: Response, db_group: ScimApiGroup) -> None: members = self._get_group_members(db_group) location = self.url_for("Groups", str(db_group.scim_id)) meta = Meta( location=location, last_modified=db_group.last_modified or db_group.created, resource_type=SCIMResourceType.GROUP, created=db_group.created, version=db_group.version, ) schemas = [SCIMSchema.CORE_20_GROUP] if db_group.extensions.data: schemas.append(SCIMSchema.NUTID_GROUP_V1) group = GroupResponse( display_name=db_group.graph.display_name, members=members, id=db_group.scim_id, external_id=db_group.external_id, meta=meta, schemas=list( schemas ), # extra list() needed to work with _both_ mypy and marshmallow nutid_group_v1=NutidGroupExtensionV1( data=db_group.extensions.data), ) resp.set_header("Location", location) resp.set_header("ETag", make_etag(db_group.version)) dumped_group = GroupResponseSchema().dump(group) if SCIMSchema.NUTID_GROUP_V1 not in group.schemas and SCIMSchema.NUTID_GROUP_V1.value in dumped_group: # Serialization will always put the NUTID_GROUP_V1 in the dumped_group, even if there was no data del dumped_group[SCIMSchema.NUTID_GROUP_V1.value] resp.media = dumped_group
def test_update_group(self): db_group = self.add_group(uuid4(), 'Test Group 1') subgroup = self.add_group(uuid4(), 'Test Group 2') user = self.add_user(identifier=str(uuid4()), external_id='not-used') members = [ { 'value': str(user.scim_id), '$ref': f'http://localhost:8000/Users/{user.scim_id}', 'display': 'Test User 1', }, { 'value': str(subgroup.scim_id), '$ref': f'http://localhost:8000/Groups/{subgroup.scim_id}', 'display': 'Test Group 2', }, ] req = { 'schemas': [SCIMSchema.CORE_20_GROUP.value], 'id': str(db_group.scim_id), 'displayName': db_group.display_name, 'members': members, } self.headers['IF-MATCH'] = make_etag(db_group.version) response = self.client.simulate_put(path=f'/Groups/{db_group.scim_id}', body=self.as_json(req), headers=self.headers) self._assertGroupUpdateSuccess(req, response, db_group)
def test_update_existing_group(self): db_group = self.add_group(uuid4(), 'Test Group 1') subgroup = self.add_group(uuid4(), 'Test Group 2') user = self.add_user(identifier=str(uuid4()), external_id='not-used') members = [ { 'value': str(user.scim_id), '$ref': f'http://localhost:8000/Users/{user.scim_id}', 'display': 'Test User 1', }, { 'value': str(subgroup.scim_id), '$ref': f'http://localhost:8000/Groups/{subgroup.scim_id}', 'display': 'Test Group 2', }, ] db_group.display_name = 'Another display name' db_group.external_id = 'external id' req = { 'schemas': [SCIMSchema.CORE_20_GROUP.value, SCIMSchema.NUTID_GROUP_V1.value], 'id': str(db_group.scim_id), 'externalId': db_group.external_id, 'displayName': db_group.display_name, 'members': members, SCIMSchema.NUTID_GROUP_V1.value: { 'data': { 'test': 'updated' } }, } self.headers['IF-MATCH'] = make_etag(db_group.version) response = self.client.simulate_put(path=f'/Groups/{db_group.scim_id}', body=self.as_json(req), headers=self.headers) self._assertGroupUpdateSuccess(req, response, db_group) members[0]['display'] += ' (updated)' members[1]['display'] += ' (also updated)' self.headers['IF-MATCH'] = response.headers['Etag'] response = self.client.simulate_put(path=f'/Groups/{db_group.scim_id}', body=self.as_json(req), headers=self.headers) self._assertGroupUpdateSuccess(req, response, db_group) # check that the action resulted in an event in the database events = self.eventdb.get_events_by_resource(SCIMResourceType.GROUP, db_group.scim_id) assert len(events) == 2 event = events[0] assert event.resource.external_id == req['externalId'] assert event.data['status'] == EventStatus.UPDATED.value
def _check_version( self, req: Request, db_obj: Union[ScimApiGroup, ScimApiUser, ScimApiInvite]) -> bool: if req.headers.get('IF-MATCH') == make_etag(db_obj.version): return True self.context.logger.error(f'Version mismatch') self.context.logger.debug( f'{req.headers.get("IF-MATCH")} != {make_etag(db_obj.version)}') return False
def test_version_mismatch(self): group = self.add_group(uuid4(), 'Test Group 1') req = {'schemas': [SCIMSchema.CORE_20_GROUP.value]} self.headers['IF-MATCH'] = make_etag(ObjectId()) response = self.client.simulate_delete(path=f'/Groups/{group.scim_id}', body=self.as_json(req), headers=self.headers) self._assertScimError(response.json, detail='Version mismatch')
def test_update_user(self): db_user = self.add_user(identifier=str(uuid4()), external_id='test-id-1', profiles={'test': self.test_profile}) req = { 'schemas': [SCIMSchema.CORE_20_USER.value, SCIMSchema.NUTID_USER_V1.value], 'id': str(db_user.scim_id), 'externalId': 'test-id-1', 'name': { 'familyName': 'Testsson', 'givenName': 'Test', 'middleName': 'Testaren' }, 'emails': [{ 'primary': True, 'type': 'home', 'value': '*****@*****.**' }], 'phoneNumbers': [{ 'primary': True, 'type': 'mobile', 'value': 'tel:+1-202-456-1414' }], 'preferredLanguage': 'en', SCIMSchema.NUTID_USER_V1.value: { 'profiles': { 'test': { 'attributes': { 'displayName': 'New display name' }, 'data': { 'test_key': 'new value' } } }, }, } self.headers['IF-MATCH'] = make_etag(db_user.version) response = self.client.simulate_put(path=f'/Users/{db_user.scim_id}', body=self.as_json(req), headers=self.headers) self._assertResponse(response) db_user = self.userdb.get_user_by_scim_id(response.json['id']) self._assertUserUpdateSuccess(req, response, db_user) # check that the action resulted in an event in the database events = self.eventdb.get_events_by_resource(SCIMResourceType.USER, db_user.scim_id) assert len(events) == 1 event = events[0] assert event.resource.external_id == req['externalId'] assert event.data['status'] == EventStatus.UPDATED.value
def _db_invite_to_response(self, req: Request, resp: Response, db_invite: ScimApiInvite, signup_invite: SignupInvite): location = self.url_for("Invites", db_invite.scim_id) meta = Meta( location=location, last_modified=db_invite.last_modified, resource_type=SCIMResourceType.INVITE, created=db_invite.created, version=db_invite.version, ) schemas = [ SCIMSchema.NUTID_INVITE_CORE_V1, SCIMSchema.NUTID_INVITE_V1, SCIMSchema.NUTID_USER_V1 ] _profiles = { k: Profile(attributes=v.attributes, data=v.data) for k, v in db_invite.profiles.items() } invite_extension = NutidInviteExtensionV1( completed=db_invite.completed, name=Name(**asdict(db_invite.name)), emails=[Email(**asdict(email)) for email in db_invite.emails], phone_numbers=[ PhoneNumber(**asdict(number)) for number in db_invite.phone_numbers ], national_identity_number=db_invite.nin, preferred_language=db_invite.preferred_language, groups=db_invite.groups, send_email=signup_invite.send_email, finish_url=signup_invite.finish_url, expires_at=signup_invite.expires_at, inviter_name=signup_invite.inviter_name, ) # Only add invite url in response if no email should be sent to the invitee if signup_invite.send_email is False: invite_url = f'{self.context.config.invite_url}/{signup_invite.invite_code}' invite_extension = replace(invite_extension, invite_url=invite_url) scim_invite = InviteResponse( id=db_invite.scim_id, external_id=db_invite.external_id, meta=meta, schemas=list( schemas ), # extra list() needed to work with _both_ mypy and marshmallow nutid_invite_v1=invite_extension, nutid_user_v1=NutidUserExtensionV1(profiles=_profiles), ) resp.set_header("Location", location) resp.set_header("ETag", make_etag(db_invite.version)) resp.media = InviteResponseSchema().dump(scim_invite)
def test_version_mismatch(self): db_group = self.add_group(uuid4(), 'Test Group 1') req = { 'schemas': [SCIMSchema.CORE_20_GROUP.value], 'id': str(db_group.scim_id), 'displayName': 'Another display name', } self.headers['IF-MATCH'] = make_etag(ObjectId()) response = self.client.simulate_put(path=f'/Groups/{db_group.scim_id}', body=self.as_json(req), headers=self.headers) self._assertScimError(response.json, detail='Version mismatch')
def test_delete_invite(self): db_invite = self.add_invite() self.headers['IF-MATCH'] = make_etag(db_invite.version) self.client.simulate_delete(path=f'/Invites/{db_invite.scim_id}', headers=self.headers) reference = SCIMReference(data_owner=self.data_owner, scim_id=db_invite.scim_id) self.assertIsNone( self.invitedb.get_invite_by_scim_id(str(db_invite.scim_id))) self.assertIsNone( self.signup_invitedb.get_invite_by_reference(reference)) # check that the action resulted in an event in the database events = self.eventdb.get_events_by_resource(SCIMResourceType.INVITE, db_invite.scim_id) assert len(events) == 1 event = events[0] assert event.data['status'] == EventStatus.DELETED.value
def _db_user_to_response(self, req: Request, resp: Response, db_user: ScimApiUser): location = self.url_for("Users", db_user.scim_id) meta = Meta( location=location, last_modified=db_user.last_modified, resource_type=SCIMResourceType.USER, created=db_user.created, version=db_user.version, ) schemas = [SCIMSchema.CORE_20_USER] if db_user.profiles: schemas.append(SCIMSchema.NUTID_USER_V1) # Convert one type of Profile into another _profiles = { k: Profile(attributes=v.attributes, data=v.data) for k, v in db_user.profiles.items() } user = UserResponse( id=db_user.scim_id, external_id=db_user.external_id, name=Name(**asdict(db_user.name)), emails=[Email(**asdict(email)) for email in db_user.emails], phone_numbers=[ PhoneNumber(**asdict(number)) for number in db_user.phone_numbers ], preferred_language=db_user.preferred_language, groups=self._get_user_groups(req=req, db_user=db_user), meta=meta, schemas=list( schemas ), # extra list() needed to work with _both_ mypy and marshmallow nutid_user_v1=NutidUserExtensionV1(profiles=_profiles), ) resp.set_header("Location", location) resp.set_header("ETag", make_etag(db_user.version)) resp.media = UserResponseSchema().dump(user)
def test_update_group_subgroup_does_not_exist(self): db_group = self.add_group(uuid4(), 'Test Group 1') _subgroup_scim_id = str(uuid4()) members = [{ 'value': _subgroup_scim_id, '$ref': f'http://localhost:8000/Groups/{_subgroup_scim_id}', 'display': 'Test Group 2', }] req = { 'schemas': [SCIMSchema.CORE_20_GROUP.value], 'id': str(db_group.scim_id), 'displayName': 'Another display name', 'members': members, } self.headers['IF-MATCH'] = make_etag(db_group.version) response = self.client.simulate_put(path=f'/Groups/{db_group.scim_id}', body=self.as_json(req), headers=self.headers) self._assertScimError(response.json, detail=f'Group {_subgroup_scim_id} not found')
def test_update_user_duplicated_external_id(self): external_id = 'test-id-1' # Create two existing users with different external_id self.add_user(identifier=str(uuid4()), external_id=external_id, profiles={'test': self.test_profile}) db_user = self.add_user(identifier=str(uuid4()), external_id='test-id-2', profiles={'test': self.test_profile}) # Try to update the second user with the external_id of the first req = { 'schemas': [SCIMSchema.CORE_20_USER.value, SCIMSchema.NUTID_USER_V1.value], 'id': str(db_user.scim_id), 'externalId': external_id, SCIMSchema.NUTID_USER_V1.value: { 'profiles': { 'test': { 'attributes': { 'displayName': 'New display name' }, 'data': { 'test_key': 'new value' } } } }, } self.headers['IF-MATCH'] = make_etag(db_user.version) response = self.client.simulate_put(path=f'/Users/{db_user.scim_id}', body=self.as_json(req), headers=self.headers) self._assertScimError( response.json, schemas=['urn:ietf:params:scim:api:messages:2.0:Error'], detail='externalID must be unique')
def _db_event_to_response(self, req: Request, resp: Response, db_event: ScimApiEvent): location = self.resource_url(SCIMResourceType.EVENT, db_event.scim_id) meta = Meta( location=location, last_modified=db_event.last_modified, resource_type=SCIMResourceType.EVENT, created=db_event.created, version=db_event.version, ) schemas = [SCIMSchema.NUTID_EVENT_CORE_V1, SCIMSchema.NUTID_EVENT_V1] response = EventResponse( id=db_event.scim_id, meta=meta, schemas=list( schemas ), # extra list() needed to work with _both_ mypy and marshmallow nutid_event_v1=NutidEventExtensionV1( level=db_event.level, data=db_event.data, source=db_event.source, expires_at=db_event.expires_at, timestamp=db_event.timestamp, resource=NutidEventResource( resource_type=db_event.resource.resource_type, scim_id=db_event.resource.scim_id, external_id=db_event.resource.external_id, location=self.resource_url(db_event.resource.resource_type, db_event.resource.scim_id), ), ), ) resp.set_header("Location", location) resp.set_header("ETag", make_etag(db_event.version)) resp.media = EventResponseSchema().dump(response)
def test_add_member_to_existing_group(self): db_group = self.add_group(uuid4(), 'Test Group 1') user = self.add_user(identifier=str(uuid4()), external_id='not-used') members = [{ 'value': str(user.scim_id), '$ref': f'http://localhost:8000/Users/{user.scim_id}', 'display': 'Test User 1', }] req = { 'schemas': [SCIMSchema.CORE_20_GROUP.value], 'id': str(db_group.scim_id), 'displayName': db_group.display_name, 'members': members, } self.headers['IF-MATCH'] = make_etag(db_group.version) response = self.client.simulate_put(path=f'/Groups/{db_group.scim_id}', body=self.as_json(req), headers=self.headers) self._assertGroupUpdateSuccess(req, response, db_group) # Now, add another user and make a new request added_user = self.add_user(identifier=str(uuid4()), external_id='not-used-2') members += [{ 'value': str(added_user.scim_id), '$ref': f'http://localhost:8000/Users/{added_user.scim_id}', 'display': 'Added User', }] self.headers['IF-MATCH'] = response.headers['Etag'] response = self.client.simulate_put(path=f'/Groups/{db_group.scim_id}', body=self.as_json(req), headers=self.headers) self._assertGroupUpdateSuccess(req, response, db_group)
def test_create_and_fetch_event(self): user = self.add_user(identifier=str(uuid4()), external_id='*****@*****.**') event = { 'resource': { 'resourceType': SCIMResourceType.USER.value, 'id': str(user.scim_id) }, 'level': EventLevel.DEBUG.value, 'data': { 'create_fetch_test': True }, } created = self._create_event(event=event) # check that the create resulted in an event in the database events = self.eventdb.get_events_by_resource(SCIMResourceType.USER, scim_id=user.scim_id) assert len(events) == 1 db_event = events[0] # Now fetch the event using SCIM fetched = self._fetch_event(created.response.id) assert fetched.response.id == db_event.scim_id # Verify that create and fetch returned the same data. # Compare as dict first because the output is easier to read. assert asdict(created.event) == asdict(fetched.event) assert created.event == fetched.event # For once, verify the actual SCIM message format too expected = { 'schemas': [ 'https://scim.eduid.se/schema/nutid/event/core-v1', 'https://scim.eduid.se/schema/nutid/event/v1', ], 'id': str(db_event.scim_id), 'meta': { 'created': db_event.created.isoformat(), 'lastModified': db_event.last_modified.isoformat(), 'location': f'http://localhost:8000/Events/{db_event.scim_id}', 'resourceType': 'Event', 'version': make_etag(db_event.version), }, 'https://scim.eduid.se/schema/nutid/event/v1': { 'data': { 'create_fetch_test': True }, 'expiresAt': (db_event.timestamp + timedelta(days=1)).isoformat(), 'level': 'debug', 'source': 'eduid.se', 'timestamp': db_event.timestamp.isoformat(), 'resource': { 'resourceType': 'User', 'id': str(user.scim_id), 'externalId': user.external_id, 'location': f'http://localhost:8000/Users/{user.scim_id}', }, }, } assert fetched.result.json == expected
def _serialize(self, value: ObjectId, attr, obj, **kwargs): if value is None: return missing return make_etag(value)