stash
This commit is contained in:
parent
3299c97392
commit
de7d9426be
10 changed files with 259 additions and 99 deletions
|
|
@ -36,6 +36,7 @@ urlpatterns = [
|
||||||
path('admin/', include('hostadmin.api')),
|
path('admin/', include('hostadmin.api')),
|
||||||
path('api/', include('toolshed.api.friend')),
|
path('api/', include('toolshed.api.friend')),
|
||||||
path('api/', include('toolshed.api.group')),
|
path('api/', include('toolshed.api.group')),
|
||||||
|
path('api/', include('toolshed.api.idmap')),
|
||||||
path('api/', include('toolshed.api.inventory')),
|
path('api/', include('toolshed.api.inventory')),
|
||||||
path('api/', include('toolshed.api.info')),
|
path('api/', include('toolshed.api.info')),
|
||||||
path('api/', include('toolshed.api.files')),
|
path('api/', include('toolshed.api.files')),
|
||||||
|
|
|
||||||
28
backend/toolshed/api/idmap.py
Normal file
28
backend/toolshed/api/idmap.py
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
from django.urls import path
|
||||||
|
from rest_framework.permissions import IsAuthenticated
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.viewsets import ViewSetMixin
|
||||||
|
|
||||||
|
from authentication.models import KnownIdentity
|
||||||
|
from authentication.signature_auth import SignatureAuthentication
|
||||||
|
from toolshed.serializers import FriendSerializer, GroupIdMapSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class IdMap(APIView, ViewSetMixin):
|
||||||
|
authentication_classes = [SignatureAuthentication]
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get(self, request, format=None): # /api/idmap/
|
||||||
|
identity = request.user
|
||||||
|
identities = identity.friends.all() | KnownIdentity.objects.filter(pk=identity.pk)
|
||||||
|
groups = identity.member_of_groups.all()
|
||||||
|
return Response({
|
||||||
|
'identities': FriendSerializer(identities, many=True).data,
|
||||||
|
'groups': GroupIdMapSerializer(groups, many=True).data,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('idmap/', IdMap.as_view(), name='idmap'),
|
||||||
|
]
|
||||||
|
|
@ -92,6 +92,17 @@ class GroupSerializer(serializers.ModelSerializer):
|
||||||
return str(obj)
|
return str(obj)
|
||||||
|
|
||||||
|
|
||||||
|
class GroupIdMapSerializer(serializers.ModelSerializer):
|
||||||
|
handle = serializers.SerializerMethodField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Group
|
||||||
|
fields = ['id', 'name', 'domain', 'handle']
|
||||||
|
|
||||||
|
def get_handle(self, obj):
|
||||||
|
return str(obj)
|
||||||
|
|
||||||
|
|
||||||
class GroupInviteIncomingSerializer(serializers.ModelSerializer):
|
class GroupInviteIncomingSerializer(serializers.ModelSerializer):
|
||||||
group = serializers.SerializerMethodField()
|
group = serializers.SerializerMethodField()
|
||||||
inviter = serializers.SerializerMethodField()
|
inviter = serializers.SerializerMethodField()
|
||||||
|
|
|
||||||
48
backend/toolshed/tests/test_idmap.py
Normal file
48
backend/toolshed/tests/test_idmap.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
from django.test import Client
|
||||||
|
|
||||||
|
from authentication.tests import SignatureAuthClient, UserTestMixin, GroupTestMixin, ToolshedTestCase
|
||||||
|
|
||||||
|
client = SignatureAuthClient()
|
||||||
|
|
||||||
|
|
||||||
|
class IdMapTestCase(UserTestMixin, GroupTestMixin, ToolshedTestCase):
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.prepare_users()
|
||||||
|
|
||||||
|
def test_idmap_includes_self(self):
|
||||||
|
reply = client.get('/api/idmap/', self.f['local_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
identities = reply.json()['identities']
|
||||||
|
self.assertIn(str(self.f['local_user1']), [i['username'] for i in identities])
|
||||||
|
|
||||||
|
def test_idmap_includes_friends(self):
|
||||||
|
self.f['local_user1'].friends.add(self.f['local_user2'].public_identity)
|
||||||
|
reply = client.get('/api/idmap/', self.f['local_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
identities = reply.json()['identities']
|
||||||
|
self.assertIn(str(self.f['local_user2']), [i['username'] for i in identities])
|
||||||
|
|
||||||
|
def test_idmap_excludes_non_friends(self):
|
||||||
|
reply = client.get('/api/idmap/', self.f['local_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
identities = reply.json()['identities']
|
||||||
|
self.assertNotIn(str(self.f['local_user2']), [i['username'] for i in identities])
|
||||||
|
|
||||||
|
def test_idmap_includes_member_groups(self):
|
||||||
|
self.prepare_groups()
|
||||||
|
reply = client.get('/api/idmap/', self.f['local_user1'])
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
groups = reply.json()['groups']
|
||||||
|
self.assertIn(str(self.f['group1']), [g['handle'] for g in groups])
|
||||||
|
|
||||||
|
def test_idmap_excludes_non_member_groups(self):
|
||||||
|
self.prepare_groups()
|
||||||
|
reply = client.get('/api/idmap/', self.f['local_user2'])
|
||||||
|
self.assertEqual(reply.status_code, 200)
|
||||||
|
groups = reply.json()['groups']
|
||||||
|
self.assertNotIn(str(self.f['group1']), [g['handle'] for g in groups])
|
||||||
|
|
||||||
|
def test_idmap_unauthenticated(self):
|
||||||
|
reply = Client().get('/api/idmap/')
|
||||||
|
self.assertEqual(reply.status_code, 403)
|
||||||
|
|
@ -28,6 +28,7 @@ import Scan from "@/views/Scan.vue";
|
||||||
import Workflows from '@/views/Workflows.vue';
|
import Workflows from '@/views/Workflows.vue';
|
||||||
import WorkflowDetail from '@/views/WorkflowDetail.vue';
|
import WorkflowDetail from '@/views/WorkflowDetail.vue';
|
||||||
import ShortId from '@/views/ShortId.vue';
|
import ShortId from '@/views/ShortId.vue';
|
||||||
|
import {encodeShortId, serializeShortId,decodeShortId, deserializeShortId} from '@/short-id';
|
||||||
|
|
||||||
import Account from '@/views/settings/Account.vue';
|
import Account from '@/views/settings/Account.vue';
|
||||||
import Password from '@/views/settings/Password.vue';
|
import Password from '@/views/settings/Password.vue';
|
||||||
|
|
@ -46,6 +47,23 @@ export function decodeHandleFromUrl(segment) {
|
||||||
return segment.replace(/\+/g, "#");
|
return segment.replace(/\+/g, "#");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EXPANDED_ROUTE_BUILDERS = {
|
||||||
|
item: ({item_local_id}) => `/inventory/${item_local_id}`,
|
||||||
|
group_item: ({item_local_id}) => `/inventory/${item_local_id}`,
|
||||||
|
group: ({group_id}) => `/groups/${group_id}`,
|
||||||
|
storage_location: ({storage_location_id}) => `/storage-locations/${storage_location_id}`,
|
||||||
|
workflow: ({workflow_id}) => `/workflows/${workflow_id}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function expandedRoute({kind, ...fields}) {
|
||||||
|
const buildRoute = EXPANDED_ROUTE_BUILDERS[kind];
|
||||||
|
return buildRoute ? buildRoute(fields) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function shortenedRoute(named) {
|
||||||
|
return '/' + encodeShortId(serializeShortId(named));
|
||||||
|
}
|
||||||
|
|
||||||
const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
||||||
path: '/inventory',
|
path: '/inventory',
|
||||||
component: Inventory,
|
component: Inventory,
|
||||||
|
|
@ -72,6 +90,16 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
||||||
const {id} = to.params
|
const {id} = to.params
|
||||||
return handle === store.state.user ? '/inventory/' + id : '/inventory/shared/' + encodeHandleForUrl(handle) + '/' + id
|
return handle === store.state.user ? '/inventory/' + id : '/inventory/shared/' + encodeHandleForUrl(handle) + '/' + id
|
||||||
}
|
}
|
||||||
|
}, {
|
||||||
|
path: '/:short_id',
|
||||||
|
redirect: to => {
|
||||||
|
console.log(to)
|
||||||
|
const p = deserializeShortId(decodeShortId(to.params.short_id))
|
||||||
|
console.log(p)
|
||||||
|
const url = expandedRoute(p)
|
||||||
|
console.log(url)
|
||||||
|
return url;
|
||||||
|
}
|
||||||
}, {path: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}}, {
|
}, {path: '/inventory/new', component: InventoryNew, meta: {requiresAuth: true}}, {
|
||||||
path: '/friends',
|
path: '/friends',
|
||||||
component: Friends,
|
component: Friends,
|
||||||
|
|
@ -161,9 +189,14 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
|
||||||
component: StorageLocationNew,
|
component: StorageLocationNew,
|
||||||
meta: {requiresAuth: true}
|
meta: {requiresAuth: true}
|
||||||
}, {
|
}, {
|
||||||
path: '/:short_id',
|
path: '/debug/:short_id',
|
||||||
component: ShortId,
|
component: ShortId,
|
||||||
props: true
|
props: true
|
||||||
|
}, {
|
||||||
|
path: '/:short_id',
|
||||||
|
redirect: to => {
|
||||||
|
|
||||||
|
}
|
||||||
}, {path: '/:pathMatch(.*)*', redirect: '/'}]
|
}, {path: '/:pathMatch(.*)*', redirect: '/'}]
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
|
|
|
||||||
|
|
@ -8,31 +8,37 @@ const SCHEMAS = {
|
||||||
fields: ['owner_identity_id', 'item_local_id'],
|
fields: ['owner_identity_id', 'item_local_id'],
|
||||||
interpret: ([owner_identity_id, item_local_id]) => ({kind: 'item', owner_identity_id, item_local_id})
|
interpret: ([owner_identity_id, item_local_id]) => ({kind: 'item', owner_identity_id, item_local_id})
|
||||||
},
|
},
|
||||||
|
//0-1 left free for later use
|
||||||
'1-2': {
|
'1-2': {
|
||||||
|
name: 'group_item',
|
||||||
|
fields: ['owner_group_id', 'item_local_id'],
|
||||||
|
interpret: ([owner_group_id, item_local_id]) => ({kind: 'group_item', owner_group_id, item_local_id})
|
||||||
|
},
|
||||||
|
'1-1': {
|
||||||
|
name: 'group',
|
||||||
|
fields: ['group_id'],
|
||||||
|
interpret: ([group_id]) => ({kind: 'group', group_id})
|
||||||
|
},
|
||||||
|
'2-2': {
|
||||||
name: 'storage_location',
|
name: 'storage_location',
|
||||||
fields: ['owner_identity_id', 'storage_location_id'],
|
fields: ['owner_identity_id', 'storage_location_id'],
|
||||||
interpret: ([owner_identity_id, storage_location_id]) =>
|
interpret: ([owner_identity_id, storage_location_id]) =>
|
||||||
({kind: 'storage_location', owner_identity_id, storage_location_id})
|
({kind: 'storage_location', owner_identity_id, storage_location_id})
|
||||||
},
|
},
|
||||||
'2-1': {
|
'2-1': {
|
||||||
name: 'category',
|
name: 'file',
|
||||||
fields: ['category_id'],
|
fields: ['file_id'],
|
||||||
interpret: ([category_id]) => ({kind: 'category', category_id})
|
interpret: ([file_id]) => ({kind: 'file', file_id})
|
||||||
},
|
},
|
||||||
'3-2': {
|
'3-2': {
|
||||||
name: 'workflow',
|
name: 'workflow',
|
||||||
fields: ['owner_identity_id', 'workflow_id'],
|
fields: ['owner_identity_id', 'workflow_id'],
|
||||||
interpret: ([owner_identity_id, workflow_id]) => ({kind: 'workflow', owner_identity_id, workflow_id})
|
interpret: ([owner_identity_id, workflow_id]) => ({kind: 'workflow', owner_identity_id, workflow_id})
|
||||||
},
|
},
|
||||||
'4-1': {
|
'3-1': {
|
||||||
name: 'group',
|
name: 'category',
|
||||||
fields: ['group_id'],
|
fields: ['category_id'],
|
||||||
interpret: ([group_id]) => ({kind: 'group', group_id})
|
interpret: ([category_id]) => ({kind: 'category', category_id})
|
||||||
},
|
|
||||||
'5-1': {
|
|
||||||
name: 'file',
|
|
||||||
fields: ['file_id'],
|
|
||||||
interpret: ([file_id]) => ({kind: 'file', file_id})
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -82,6 +88,10 @@ class BitReader {
|
||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bitsLeft() {
|
||||||
|
return this.bits.length - this.pos
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function base64UrlToBits(text) {
|
function base64UrlToBits(text) {
|
||||||
|
|
@ -128,25 +138,6 @@ function readChunkedInt(reader, chunkBits) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function entriesForKindIndex(kindIndex) {
|
|
||||||
return Object.entries(SCHEMAS).filter(([key]) => key.startsWith(`${kindIndex}-`))
|
|
||||||
}
|
|
||||||
|
|
||||||
function schemaForKindIndex(kindIndex) {
|
|
||||||
const matches = entriesForKindIndex(kindIndex)
|
|
||||||
if (matches.length === 0) {
|
|
||||||
throw new Error(`unknown short id kind index: ${kindIndex}`)
|
|
||||||
}
|
|
||||||
if (matches.length > 1) {
|
|
||||||
throw new Error(
|
|
||||||
`ambiguous short id kind index ${kindIndex}: base-level decoding needs exactly one `
|
|
||||||
+ `field count per kind, found arities ${matches.map(([, s]) => s.fields.length).join(', ')} `
|
|
||||||
+ `- only deserializeShortId's (kind, fields.length) lookup can tell those apart`
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return matches[0][1]
|
|
||||||
}
|
|
||||||
|
|
||||||
function schemaForKindAndArity(kindIndex, arity) {
|
function schemaForKindAndArity(kindIndex, arity) {
|
||||||
const schema = SCHEMAS[`${kindIndex}-${arity}`]
|
const schema = SCHEMAS[`${kindIndex}-${arity}`]
|
||||||
if (!schema) {
|
if (!schema) {
|
||||||
|
|
@ -160,10 +151,12 @@ export function encodeShortId(ints) {
|
||||||
throw new Error('short id must be a non-empty list of [kindIndex, ...fieldValues]')
|
throw new Error('short id must be a non-empty list of [kindIndex, ...fieldValues]')
|
||||||
}
|
}
|
||||||
const [kindIndex, ...fieldValues] = ints
|
const [kindIndex, ...fieldValues] = ints
|
||||||
const schema = schemaForKindIndex(kindIndex)
|
const schema = schemaForKindAndArity(kindIndex, fieldValues.length)
|
||||||
if (fieldValues.length !== schema.fields.length) {
|
if (fieldValues[fieldValues.length - 1] === 0) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`short id kind index ${kindIndex} ('${schema.name}') needs ${schema.fields.length} field value(s), got ${fieldValues.length}`
|
`short id kind index ${kindIndex} ('${schema.name}'): the last field's value must not be 0 - `
|
||||||
|
+ `reserved so decodeShortId can tell a real trailing field apart from zero-padding without `
|
||||||
|
+ `knowing the kind's arity up front (see docs/handles-and-shortids.md)`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const writer = new BitWriter()
|
const writer = new BitWriter()
|
||||||
|
|
@ -188,8 +181,14 @@ export function decodeShortId(token) {
|
||||||
if (kindIndex === DIRECT_KIND_COUNT) {
|
if (kindIndex === DIRECT_KIND_COUNT) {
|
||||||
kindIndex = DIRECT_KIND_COUNT + Number(readChunkedInt(reader, CHUNK_BITS))
|
kindIndex = DIRECT_KIND_COUNT + Number(readChunkedInt(reader, CHUNK_BITS))
|
||||||
}
|
}
|
||||||
const schema = schemaForKindIndex(kindIndex)
|
const fieldValues = []
|
||||||
const fieldValues = schema.fields.map(() => Number(readChunkedInt(reader, CHUNK_BITS)))
|
while (reader.bitsLeft() >= CHUNK_BITS + 1) {
|
||||||
|
const value = readChunkedInt(reader, CHUNK_BITS)
|
||||||
|
if (value === 0n && reader.bitsLeft() === 0) {
|
||||||
|
break // a trailing all-zero chunk with nothing after it is padding, not a real field
|
||||||
|
}
|
||||||
|
fieldValues.push(Number(value))
|
||||||
|
}
|
||||||
return [kindIndex, ...fieldValues]
|
return [kindIndex, ...fieldValues]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,7 @@ export default createStore({
|
||||||
friendProfiles: {},
|
friendProfiles: {},
|
||||||
groups: [],
|
groups: [],
|
||||||
groupInvites: [],
|
groupInvites: [],
|
||||||
|
idmap: {identities: [], groups: []},
|
||||||
item_map: {},
|
item_map: {},
|
||||||
home_servers: null,
|
home_servers: null,
|
||||||
all_friends_servers: null,
|
all_friends_servers: null,
|
||||||
|
|
@ -112,6 +113,9 @@ export default createStore({
|
||||||
setGroupInvites(state, invites) {
|
setGroupInvites(state, invites) {
|
||||||
state.groupInvites = invites;
|
state.groupInvites = invites;
|
||||||
},
|
},
|
||||||
|
setIdMap(state, idmap) {
|
||||||
|
state.idmap = idmap;
|
||||||
|
},
|
||||||
setHomeServers(state, home_servers) {
|
setHomeServers(state, home_servers) {
|
||||||
state.home_servers = home_servers;
|
state.home_servers = home_servers;
|
||||||
},
|
},
|
||||||
|
|
@ -498,6 +502,12 @@ export default createStore({
|
||||||
commit('setGroups', data)
|
commit('setGroups', data)
|
||||||
return data
|
return data
|
||||||
},
|
},
|
||||||
|
async fetchIdMap({commit, dispatch, getters}) {
|
||||||
|
const servers = await dispatch('getHomeServers')
|
||||||
|
const idmap = await servers.get(getters.signAuth, '/api/idmap/')
|
||||||
|
commit('setIdMap', idmap)
|
||||||
|
return idmap
|
||||||
|
},
|
||||||
async fetchGroup({dispatch, getters}, {id}) {
|
async fetchGroup({dispatch, getters}, {id}) {
|
||||||
const servers = await dispatch('getHomeServers')
|
const servers = await dispatch('getHomeServers')
|
||||||
return await servers.get(getters.signAuth, '/api/groups/' + id + '/')
|
return await servers.get(getters.signAuth, '/api/groups/' + id + '/')
|
||||||
|
|
@ -786,6 +796,12 @@ export default createStore({
|
||||||
return state.item_map['/'] || []
|
return state.item_map['/'] || []
|
||||||
},
|
},
|
||||||
groupInventoryItems: (state) => (groupId) => state.item_map['/group/' + groupId] || [],
|
groupInventoryItems: (state) => (groupId) => state.item_map['/group/' + groupId] || [],
|
||||||
|
identityIdByHandle(state) {
|
||||||
|
return Object.fromEntries(state.idmap.identities.map(i => [i.username, i.id]))
|
||||||
|
},
|
||||||
|
groupIdByHandle(state) {
|
||||||
|
return Object.fromEntries(state.idmap.groups.map(g => [g.handle, g.id]))
|
||||||
|
},
|
||||||
loaded_items(state) {
|
loaded_items(state) {
|
||||||
return Object.entries(state.item_map).reduce((acc, [url, items]) => {
|
return Object.entries(state.item_map).reduce((acc, [url, items]) => {
|
||||||
return acc.concat(items)
|
return acc.concat(items)
|
||||||
|
|
|
||||||
|
|
@ -5,23 +5,60 @@ test('encodes the worked example from docs/handles-and-shortids.md', () => {
|
||||||
expect(token).toBe('~DyU')
|
expect(token).toBe('~DyU')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('round-trips every kind as a flat [kindIndex, ...fieldValues] list', () => {
|
test('kind ids are scoped per arity: id 0 means item (arity 2) or category (arity 1)', () => {
|
||||||
|
expect(deserializeShortId(decodeShortId(encodeShortId([0, 7, 42])))).toEqual({
|
||||||
|
kind: 'item', owner_identity_id: 7, item_local_id: 42
|
||||||
|
})
|
||||||
|
expect(deserializeShortId(decodeShortId(encodeShortId([0, 5])))).toEqual({
|
||||||
|
kind: 'category', category_id: 5
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test('every kind round-trips through serialize/encode/decode/deserialize', () => {
|
||||||
const cases = [
|
const cases = [
|
||||||
[0, 7, 42],
|
{kind: 'item', owner_identity_id: 7, item_local_id: 42},
|
||||||
[1, 3, 1000],
|
{kind: 'category', category_id: 5},
|
||||||
[2, 0],
|
{kind: 'group_item', owner_group_id: 5, item_local_id: 42},
|
||||||
|
{kind: 'group', group_id: 11},
|
||||||
|
{kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000},
|
||||||
|
{kind: 'file', file_id: 123},
|
||||||
|
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
|
||||||
]
|
]
|
||||||
for (const ints of cases) {
|
for (const named of cases) {
|
||||||
const token = encodeShortId(ints)
|
const token = encodeShortId(serializeShortId(named))
|
||||||
expect(decodeShortId(token)).toEqual(ints)
|
expect(deserializeShortId(decodeShortId(token))).toEqual(named)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test('round-trips small and large values, including zero', () => {
|
test('a non-last field may be 0 - only the last field is restricted', () => {
|
||||||
const values = [0, 1, 7, 42, 100, 10000, 100000, 123456789]
|
const named = {kind: 'item', owner_identity_id: 0, item_local_id: 42}
|
||||||
|
const token = encodeShortId(serializeShortId(named))
|
||||||
|
expect(deserializeShortId(decodeShortId(token))).toEqual(named)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects a 0 last-field value, for a single-field kind', () => {
|
||||||
|
expect(() => encodeShortId(serializeShortId({kind: 'category', category_id: 0}))).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('rejects a 0 last-field value, for a multi-field kind', () => {
|
||||||
|
expect(() => encodeShortId(serializeShortId({kind: 'item', owner_identity_id: 7, item_local_id: 0}))).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('decoding never over-reads: a single-chunk field that exactly exhausts padding is not ' +
|
||||||
|
'mistaken for a second field', () => {
|
||||||
|
// category_id: 5 encodes as kind-tag(2 bits) + one 5-bit chunk = 7 bits, padded with exactly
|
||||||
|
// 5 zero bits - the same 5 bits as a genuine one-chunk field of value 0. This is the concrete
|
||||||
|
// case the last-field-nonzero rule exists to disambiguate.
|
||||||
|
const token = encodeShortId([0, 5])
|
||||||
|
expect(token).toBe('~Cg')
|
||||||
|
expect(decodeShortId(token)).toEqual([0, 5])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('round-trips small and large nonzero values, in both last and non-last field position', () => {
|
||||||
|
const values = [1, 7, 42, 100, 10000, 100000, 123456789]
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
const token = encodeShortId([2, value])
|
expect(decodeShortId(encodeShortId([0, value]))).toEqual([0, value])
|
||||||
expect(decodeShortId(token)).toEqual([2, value])
|
expect(decodeShortId(encodeShortId([0, value, 1]))).toEqual([0, value, 1])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -31,30 +68,11 @@ test('tokens are URL-safe: only unreserved characters, plus the leading ~', () =
|
||||||
expect(token.slice(1)).toMatch(/^[A-Za-z0-9\-_]+$/)
|
expect(token.slice(1)).toMatch(/^[A-Za-z0-9\-_]+$/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('deserializeShortId names a decoded list into a flat shape, serializeShortId reverses it', () => {
|
test('kinds at escaped ids (3+) round-trip through the escape tag, not just the direct 0-2 range', () => {
|
||||||
const cases = [
|
const token = encodeShortId(serializeShortId({kind: 'workflow', owner_identity_id: 2, workflow_id: 9}))
|
||||||
[[0, 7, 42], {kind: 'item', owner_identity_id: 7, item_local_id: 42}],
|
expect(deserializeShortId(decodeShortId(token))).toEqual({
|
||||||
[[1, 3, 1000], {kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000}],
|
kind: 'workflow', owner_identity_id: 2, workflow_id: 9
|
||||||
[[2, 0], {kind: 'category', category_id: 0}],
|
})
|
||||||
[[3, 2, 9], {kind: 'workflow', owner_identity_id: 2, workflow_id: 9}],
|
|
||||||
[[4, 11], {kind: 'group', group_id: 11}],
|
|
||||||
[[5, 123], {kind: 'file', file_id: 123}],
|
|
||||||
]
|
|
||||||
for (const [ints, named] of cases) {
|
|
||||||
expect(deserializeShortId(ints)).toEqual(named)
|
|
||||||
expect(serializeShortId(named)).toEqual(ints)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('kinds 3+ round-trip through the escape tag, not just the direct 0-2 range', () => {
|
|
||||||
for (const named of [
|
|
||||||
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
|
|
||||||
{kind: 'group', group_id: 11},
|
|
||||||
{kind: 'file', file_id: 123},
|
|
||||||
]) {
|
|
||||||
const token = encodeShortId(serializeShortId(named))
|
|
||||||
expect(deserializeShortId(decodeShortId(token))).toEqual(named)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('serializeShortId then deserializeShortId round-trips through encode/decode', () => {
|
test('serializeShortId then deserializeShortId round-trips through encode/decode', () => {
|
||||||
|
|
@ -75,30 +93,19 @@ test('rejects an unknown kind index', () => {
|
||||||
expect(() => encodeShortId([99, 1])).toThrow()
|
expect(() => encodeShortId([99, 1])).toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('deserializeShortId is keyed by (kind, fields.length), not kind alone', () => {
|
test('rejects an arity with no registered schema at that kind index', () => {
|
||||||
// kind=1 with the arity storage_location actually has (2 fields) is fine...
|
// kind 0 is registered for arity 2 (item) and arity 1 (category), but not arity 3.
|
||||||
expect(deserializeShortId([1, 3, 1000])).toEqual({
|
expect(() => encodeShortId([0, 7, 8, 9])).toThrow()
|
||||||
kind: 'storage_location',
|
|
||||||
owner_identity_id: 3,
|
|
||||||
storage_location_id: 1000
|
|
||||||
})
|
|
||||||
// ...but kind=1 with a different arity (1 field) isn't a registered schema at all, and must
|
|
||||||
// not be silently misread as a truncated storage_location.
|
|
||||||
expect(() => deserializeShortId([1, 359])).toThrow()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('rejects an unknown kind name', () => {
|
test('rejects an unknown kind name', () => {
|
||||||
expect(() => serializeShortId({kind: 'not_a_kind'})).toThrow()
|
expect(() => serializeShortId({kind: 'not_a_kind'})).toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('rejects a field count mismatch', () => {
|
|
||||||
expect(() => encodeShortId([0, 7])).toThrow()
|
|
||||||
})
|
|
||||||
|
|
||||||
test('rejects a missing field', () => {
|
test('rejects a missing field', () => {
|
||||||
expect(() => serializeShortId({kind: 'item', owner_identity_id: 7})).toThrow()
|
expect(() => serializeShortId({kind: 'item', owner_identity_id: 7})).toThrow()
|
||||||
})
|
})
|
||||||
|
|
||||||
test('rejects a negative field value', () => {
|
test('rejects a negative field value', () => {
|
||||||
expect(() => encodeShortId([2, -1])).toThrow()
|
expect(() => encodeShortId([0, -1, 42])).toThrow()
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@
|
||||||
<a :href="`/inventory/${item.id}/delete`" @click.prevent="deleteInventoryItem(item)">
|
<a :href="`/inventory/${item.id}/delete`" @click.prevent="deleteInventoryItem(item)">
|
||||||
<b-icon-trash></b-icon-trash>
|
<b-icon-trash></b-icon-trash>
|
||||||
</a>
|
</a>
|
||||||
<router-link :to="shortIdLink(item)">
|
<router-link v-if="shortIdLink(item)" :to="shortIdLink(item)">
|
||||||
<b-icon-link></b-icon-link>
|
<b-icon-link></b-icon-link>
|
||||||
</router-link>
|
</router-link>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -73,7 +73,8 @@
|
||||||
<router-link :to="`/inventory/${item.id}/edit`"
|
<router-link :to="`/inventory/${item.id}/edit`"
|
||||||
class="btn btn-primary btn-sm">Edit
|
class="btn btn-primary btn-sm">Edit
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link :to="shortIdLink(item)" class="btn btn-secondary btn-sm">
|
<router-link v-if="shortIdLink(item)" :to="shortIdLink(item)"
|
||||||
|
class="btn btn-secondary btn-sm">
|
||||||
<b-icon-link></b-icon-link>
|
<b-icon-link></b-icon-link>
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -100,7 +101,7 @@ import {mapActions, mapGetters, mapMutations, mapState} from "vuex";
|
||||||
import * as BIcons from "bootstrap-icons-vue";
|
import * as BIcons from "bootstrap-icons-vue";
|
||||||
import BaseLayout from "@/components/BaseLayout.vue";
|
import BaseLayout from "@/components/BaseLayout.vue";
|
||||||
import AuthenticatedImage from "../components/AuthenticatedImage.vue";
|
import AuthenticatedImage from "../components/AuthenticatedImage.vue";
|
||||||
import {encodeShortId, serializeShortId} from "@/short-id";
|
import {shortenedRoute} from "@/router";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "Inventory",
|
name: "Inventory",
|
||||||
|
|
@ -115,11 +116,11 @@ export default {
|
||||||
...BIcons
|
...BIcons
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters(["inventory_items", "loaded_items"]),
|
...mapGetters(["inventory_items", "loaded_items", "identityIdByHandle", "groupIdByHandle"]),
|
||||||
...mapState(["user", "storage_locations"]),
|
...mapState(["user", "storage_locations"]),
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
...mapActions(["fetchInventoryItems", "deleteInventoryItem", "fetchStorageLocations"]),
|
...mapActions(["fetchInventoryItems", "deleteInventoryItem", "fetchStorageLocations", "fetchIdMap"]),
|
||||||
only_images(files) {
|
only_images(files) {
|
||||||
return files.filter(file => file.mime_type.startsWith("image/"));
|
return files.filter(file => file.mime_type.startsWith("image/"));
|
||||||
},
|
},
|
||||||
|
|
@ -128,16 +129,20 @@ export default {
|
||||||
return loc ? loc.path : null
|
return loc ? loc.path : null
|
||||||
},
|
},
|
||||||
shortIdLink(item) {
|
shortIdLink(item) {
|
||||||
// owner_identity_id is a placeholder until the backend exposes a KnownIdentity pk
|
if (item.owner_group) {
|
||||||
// per item (see docs/handles-and-shortids.md) - it doesn't decode to a
|
const owner_group_id = this.groupIdByHandle[item.owner_group]
|
||||||
// meaningful owner yet.
|
if (owner_group_id === undefined) return null
|
||||||
const ints = serializeShortId({kind: 'item', owner_identity_id: 0, item_local_id: item.id});
|
return shortenedRoute({kind: 'group_item', owner_group_id, item_local_id: item.id})
|
||||||
return '/' + encodeShortId(ints);
|
}
|
||||||
|
const owner_identity_id = this.identityIdByHandle[item.owner]
|
||||||
|
if (owner_identity_id === undefined) return null
|
||||||
|
return shortenedRoute({kind: 'item', owner_identity_id, item_local_id: item.id})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async mounted() {
|
async mounted() {
|
||||||
await this.fetchInventoryItems()
|
await this.fetchInventoryItems()
|
||||||
await this.fetchStorageLocations()
|
await this.fetchStorageLocations()
|
||||||
|
await this.fetchIdMap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,13 @@
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">Expanded</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<router-link v-if="expandedRoute" :to="expandedRoute">{{ expandedRoute }}</router-link>
|
||||||
|
<span v-else>No page exists for kind '{{ deserialized.kind }}'</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header">Examples</div>
|
<div class="card-header">Examples</div>
|
||||||
<table class="table table-striped mb-0">
|
<table class="table table-striped mb-0">
|
||||||
|
|
@ -56,14 +63,16 @@
|
||||||
<script>
|
<script>
|
||||||
import BaseLayout from '@/components/BaseLayout.vue';
|
import BaseLayout from '@/components/BaseLayout.vue';
|
||||||
import {decodeShortId, deserializeShortId, encodeShortId, serializeShortId} from '@/short-id';
|
import {decodeShortId, deserializeShortId, encodeShortId, serializeShortId} from '@/short-id';
|
||||||
|
import {expandedRoute as buildExpandedRoute} from '@/router';
|
||||||
|
|
||||||
const EXAMPLES = [
|
const EXAMPLES = [
|
||||||
{kind: 'item', owner_identity_id: 7, item_local_id: 42},
|
{kind: 'item', owner_identity_id: 7, item_local_id: 42},
|
||||||
|
{kind: 'group', group_id: 11},
|
||||||
|
{kind: 'group_item', owner_group_id: 5, item_local_id: 42},
|
||||||
|
{kind: 'file', file_id: 123},
|
||||||
{kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000},
|
{kind: 'storage_location', owner_identity_id: 3, storage_location_id: 1000},
|
||||||
{kind: 'category', category_id: 5},
|
{kind: 'category', category_id: 5},
|
||||||
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
|
{kind: 'workflow', owner_identity_id: 2, workflow_id: 9},
|
||||||
{kind: 'group', group_id: 11},
|
|
||||||
{kind: 'file', file_id: 123},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Debug-only display helper, kept out of short-id.js's production library: mirrors its bit-packing
|
// Debug-only display helper, kept out of short-id.js's production library: mirrors its bit-packing
|
||||||
|
|
@ -130,6 +139,9 @@ export default {
|
||||||
const {kind, ...fields} = this.deserialized;
|
const {kind, ...fields} = this.deserialized;
|
||||||
return fields;
|
return fields;
|
||||||
},
|
},
|
||||||
|
expandedRoute() {
|
||||||
|
return buildExpandedRoute(this.deserialized);
|
||||||
|
},
|
||||||
examples() {
|
examples() {
|
||||||
return EXAMPLES.map(named => {
|
return EXAMPLES.map(named => {
|
||||||
const {kind, ...fields} = named;
|
const {kind, ...fields} = named;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue