toolshed/backend/toolshed/tests/test_locations.py
2026-09-02 22:01:05 +02:00

290 lines
15 KiB
Python

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)