from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase from files.tests import FilesTestMixin from toolshed.models import InventoryItem, Category, StorageLocation from toolshed.tests import InventoryTestMixin, LocationTestMixin client = SignatureAuthClient() class LocationApiTestCase(UserTestMixin, InventoryTestMixin, LocationTestMixin, ToolshedTestCase): def setUp(self): super().setUp() self.prepare_users() self.prepare_categories() self.prepare_tags() self.prepare_properties() self.prepare_locations() self.prepare_inventory() self.own_handle = str(self.f['local_user1']) def test_locations(self): self.assertEqual("loc1", str(self.f['loc1'])) self.assertEqual("loc1", self.f['loc1'].name) self.assertEqual("loc2", str(self.f['loc2'])) self.assertEqual("loc2", self.f['loc2'].name) self.assertEqual("loc1/loc3", str(self.f['loc3'])) self.assertEqual("loc3", self.f['loc3'].name) self.assertEqual(self.f['loc1'], self.f['loc3'].parent) self.assertEqual("loc1/loc4", str(self.f['loc4'])) self.assertEqual("loc4", self.f['loc4'].name) self.assertEqual(self.f['loc1'], self.f['loc4'].parent) def test_get_inventory(self): reply = client.get('/api/v1/inventory_items/{}/'.format(self.own_handle), self.f['local_user1']) self.assertEqual(reply.status_code, 200) self.assertEqual(len(reply.json()), 2) self.assertEqual(reply.json()[0]['name'], 'test1') self.assertEqual(reply.json()[0]['description'], 'test') self.assertEqual(reply.json()[0]['owned_quantity'], 1) self.assertEqual(reply.json()[0]['tags'], []) self.assertEqual(reply.json()[0]['properties'], []) self.assertEqual(reply.json()[0]['category'], 'cat1') self.assertEqual(reply.json()[0]['availability_policy'], 'friends') self.assertEqual(reply.json()[1]['name'], 'test2') self.assertEqual(reply.json()[1]['description'], 'test2') self.assertEqual(reply.json()[1]['owned_quantity'], 1) self.assertEqual(reply.json()[1]['tags'], ['tag1', 'tag2']) self.assertEqual(reply.json()[1]['properties'], [{'name': 'prop1', 'value': 'value1'}, {'name': 'prop2', 'value': 'value2'}]) self.assertEqual(reply.json()[1]['category'], 'cat1') self.assertEqual(reply.json()[1]['availability_policy'], 'friends') def test_get_inventory_item(self): reply = client.get('/api/v1/storage_locations/{}/'.format(self.own_handle), self.f['local_user1']) self.assertEqual(reply.status_code, 200) self.assertEqual(len(reply.json()), 4) self.assertEqual(reply.json()[0]['name'], 'loc1') self.assertEqual(reply.json()[0]['description'], None) self.assertEqual(reply.json()[0]['category'], None) self.assertEqual(reply.json()[0]['path'], 'loc1') self.assertEqual(reply.json()[1]['name'], 'loc2') self.assertEqual(reply.json()[1]['description'], None) self.assertEqual(reply.json()[1]['category'], 'cat1') self.assertEqual(reply.json()[1]['path'], 'loc2') self.assertEqual(reply.json()[2]['name'], 'loc3') self.assertEqual(reply.json()[2]['description'], None) self.assertEqual(reply.json()[2]['category'], None) self.assertEqual(reply.json()[2]['path'], 'loc1/loc3') self.assertEqual(reply.json()[3]['name'], 'loc4') self.assertEqual(reply.json()[3]['description'], None) self.assertEqual(reply.json()[3]['category'], 'cat1') self.assertEqual(reply.json()[3]['path'], 'loc1/loc4') def test_post_new_location(self): reply = client.post('/api/v1/storage_locations/{}/'.format(self.own_handle), self.f['local_user1'], { 'name': 'loc5', 'description': 'a new location', }) self.assertEqual(reply.status_code, 201) self.assertEqual(StorageLocation.objects.count(), 5) location = StorageLocation.objects.get(name='loc5') self.assertEqual(location.description, 'a new location') self.assertEqual(location.owner, self.f['local_user1']) self.assertEqual(location.parent, None) self.assertEqual(reply.json()['path'], 'loc5') def test_post_new_nested_location(self): reply = client.post('/api/v1/storage_locations/{}/'.format(self.own_handle), self.f['local_user1'], { 'name': 'loc5', 'parent': self.f['loc3'].id, }) self.assertEqual(reply.status_code, 201) location = StorageLocation.objects.get(name='loc5') self.assertEqual(location.parent, self.f['loc3']) self.assertEqual(reply.json()['path'], 'loc1/loc3/loc5') def test_patch_location(self): reply = client.patch( '/api/v1/storage_locations/{}/{}/'.format(self.own_handle, self.f['loc2'].id), self.f['local_user1'], { 'name': 'loc2-renamed', 'parent': self.f['loc1'].id, }) self.assertEqual(reply.status_code, 200) location = StorageLocation.objects.get(id=self.f['loc2'].id) self.assertEqual(location.name, 'loc2-renamed') self.assertEqual(location.parent, self.f['loc1']) self.assertEqual(reply.json()['path'], 'loc1/loc2-renamed') def test_delete_location(self): reply = client.delete( '/api/v1/storage_locations/{}/{}/'.format(self.own_handle, self.f['loc4'].id), self.f['local_user1']) self.assertEqual(reply.status_code, 204) self.assertEqual(StorageLocation.objects.count(), 3) self.assertEqual(StorageLocation.objects.filter(id=self.f['loc4'].id).count(), 0) def test_delete_location_with_items_sets_null(self): item = InventoryItem.create_for_owner( owner=self.f['local_user1'], name='located_item', storage_location=self.f['loc3']) reply = client.delete( '/api/v1/storage_locations/{}/{}/'.format(self.own_handle, self.f['loc3'].id), self.f['local_user1']) self.assertEqual(reply.status_code, 204) item.refresh_from_db() self.assertIsNone(item.storage_location) self.assertEqual(InventoryItem.objects.filter(id=item.id).count(), 1) def test_locations_are_owner_scoped(self): reply = client.get('/api/v1/storage_locations/{}/'.format(str(self.f['local_user2'])), self.f['local_user2']) self.assertEqual(reply.status_code, 200) self.assertEqual(len(reply.json()), 0) def test_get_shared_location_visibility_private(self): private_location = StorageLocation.create_for_owner( owner=self.f['local_user1'], name='secret-loc', visibility_policy='private') reply = client.get( '/api/v1/storage_locations/{}/{}/'.format(self.own_handle, private_location.id), self.f['local_user2']) self.assertEqual(reply.status_code, 404) reply = client.get( '/api/v1/storage_locations/{}/{}/'.format(self.own_handle, private_location.id), self.f['local_user1']) self.assertEqual(reply.status_code, 200) def test_list_locations_excludes_visibility_private_for_friend(self): StorageLocation.create_for_owner(owner=self.f['local_user1'], name='secret-loc', visibility_policy='private') reply = client.get('/api/v1/storage_locations/{}/'.format(self.own_handle), self.f['local_user2']) self.assertEqual(reply.status_code, 200) self.assertEqual(len(reply.json()), 4) self.assertNotIn('secret-loc', [loc['name'] for loc in reply.json()]) def test_cannot_delete_other_users_location(self): # local_user2 is a friend of local_user1 (see prepare_inventory), so local_user1's own # handle resolves and the location is visible - friends can read but never write, so this # is a 403 (found, not authorized), not a 404. reply = client.delete( '/api/v1/storage_locations/{}/{}/'.format(self.own_handle, self.f['loc1'].id), self.f['local_user2']) self.assertEqual(reply.status_code, 403) self.assertEqual(StorageLocation.objects.filter(id=self.f['loc1'].id).count(), 1) class GroupOwnedLocationApiTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase): def setUp(self): super().setUp() self.prepare_users() self.prepare_groups() self.f['group1'].members.add(self.f['local_user2'].public_identity) # The '+' prefix is the '#'->'+' escape resolve_owner_handle expects for a group handle in # the owner_handle URL path parameter - the same escape encodeHandleForUrl uses. self.group_handle = '+' + str(self.f['group1'])[1:] def create_group_location(self, name='shelf'): return client.post('/api/v1/storage_locations/{}/'.format(self.group_handle), self.f['local_user1'], { 'name': name, }) def test_create_group_owned_location(self): reply = self.create_group_location() self.assertEqual(reply.status_code, 201) location = StorageLocation.objects.get(name='shelf') self.assertIsNone(location.owner) self.assertEqual(location.owner_group, self.f['group1']) self.assertEqual(reply.json()['owner_group'], str(self.f['group1'])) self.assertIsNone(reply.json()['owner']) def test_create_group_owned_location_non_member_denied(self): reply = client.post('/api/v1/storage_locations/{}/'.format(self.group_handle), self.f['ext_user1'], { 'name': 'shelf', }) self.assertEqual(reply.status_code, 403) self.assertEqual(StorageLocation.objects.count(), 0) def test_other_member_can_edit(self): location_id = self.create_group_location().json()['id'] reply = client.patch( '/api/v1/storage_locations/{}/{}/'.format(self.group_handle, location_id), self.f['local_user2'], { 'name': 'shelf-renamed' }) self.assertEqual(reply.status_code, 200) self.assertEqual(StorageLocation.objects.get(id=location_id).name, 'shelf-renamed') def test_other_member_can_delete(self): location_id = self.create_group_location().json()['id'] reply = client.delete( '/api/v1/storage_locations/{}/{}/'.format(self.group_handle, location_id), self.f['local_user2']) self.assertEqual(reply.status_code, 204) self.assertEqual(StorageLocation.objects.filter(id=location_id).count(), 0) def test_delete_group_location_when_id_collides_with_own_personal_location(self): # Mirrors InventoryItemViewSet's own regression test - id is only unique within its own # owner/owner_group scope, so a member's personal location and their group's location can # land on the same id. Every route being scoped by an explicit owner_handle - never a # bare, unscoped id - means this can no longer raise MultipleObjectsReturned. own_handle = str(self.f['local_user2']) personal_reply = client.post('/api/v1/storage_locations/{}/'.format(own_handle), self.f['local_user2'], {'name': 'personal-shelf'}) self.assertEqual(personal_reply.json()['id'], 1) location_id = self.create_group_location().json()['id'] self.assertEqual(location_id, 1) reply = client.delete( '/api/v1/storage_locations/{}/{}/'.format(self.group_handle, location_id), self.f['local_user2']) self.assertEqual(reply.status_code, 204) self.assertEqual(StorageLocation.objects.filter(name='shelf').count(), 0) self.assertEqual(StorageLocation.objects.filter(name='personal-shelf').count(), 1) def test_remote_member_without_local_account_can_edit(self): # A remote member (KnownIdentity, no ToolshedUser row) must still act on group-owned # locations - not unauthorized just because .user.exists() is False. self.f['group1'].members.add(self.f['ext_user1'].public_identity) location_id = self.create_group_location().json()['id'] reply = client.get( '/api/v1/storage_locations/{}/{}/'.format(self.group_handle, location_id), self.f['ext_user1']) self.assertEqual(reply.status_code, 200) reply = client.patch( '/api/v1/storage_locations/{}/{}/'.format(self.group_handle, location_id), self.f['ext_user1'], { 'name': 'shelf-renamed-by-remote-member' }) self.assertEqual(reply.status_code, 200) self.assertEqual(StorageLocation.objects.get(id=location_id).name, 'shelf-renamed-by-remote-member') def test_non_member_cannot_see_or_edit(self): location_id = self.create_group_location().json()['id'] reply = client.get( '/api/v1/storage_locations/{}/{}/'.format(self.group_handle, location_id), self.f['ext_user1']) self.assertEqual(reply.status_code, 404) def test_group_locations_excluded_from_personal_list(self): self.create_group_location() reply = client.get('/api/v1/storage_locations/{}/'.format(str(self.f['local_user1'])), self.f['local_user1']) self.assertEqual(reply.status_code, 200) self.assertEqual(len(reply.json()), 0) def test_group_locations_listed_by_owner_handle(self): self.create_group_location() reply = client.get('/api/v1/storage_locations/{}/'.format(self.group_handle), self.f['local_user2']) self.assertEqual(reply.status_code, 200) self.assertEqual(len(reply.json()), 1) self.assertEqual(reply.json()[0]['name'], 'shelf') def test_group_locations_not_listed_for_non_member(self): self.create_group_location() reply = client.get('/api/v1/storage_locations/{}/'.format(self.group_handle), self.f['ext_user1']) self.assertEqual(reply.status_code, 200) self.assertEqual(len(reply.json()), 0) def test_group_location_can_be_parent_for_group_member(self): parent_id = self.create_group_location('shelf').json()['id'] reply = client.post('/api/v1/storage_locations/{}/'.format(self.group_handle), self.f['local_user2'], { 'name': 'bin', 'parent': parent_id, }) self.assertEqual(reply.status_code, 201) self.assertEqual(reply.json()['path'], 'shelf/bin') def test_own_personal_location_can_be_parent_of_group_location(self): # OwnerScopedPrimaryKeyRelatedField (see serializers.py) scopes `parent` to every # location the requester can act on - own personal ones plus any group's - with no # further check that it matches the new location's own owner_group. Same behavior # InventoryItem.storage_location already had before group ownership existed here; not # something this feature narrows. personal = StorageLocation.create_for_owner(name='mine', owner=self.f['local_user1']) reply = client.post('/api/v1/storage_locations/{}/'.format(self.group_handle), self.f['local_user1'], { 'name': 'bin', 'parent': personal.id, }) self.assertEqual(reply.status_code, 201) def test_group_location_not_valid_parent_for_non_member(self): parent_id = self.create_group_location('shelf').json()['id'] reply = client.post( '/api/v1/storage_locations/{}/'.format(str(self.f['ext_user1'])), self.f['ext_user1'], { 'name': 'bin', 'parent': parent_id, }) self.assertEqual(reply.status_code, 400)