diff --git a/plugins/usb/frontend/views/USBSettings.vue b/plugins/usb/frontend/views/USBSettings.vue index 8a71afa7..62f22a95 100644 --- a/plugins/usb/frontend/views/USBSettings.vue +++ b/plugins/usb/frontend/views/USBSettings.vue @@ -114,7 +114,12 @@ async function load () { try { const response = await settingsApi.list({ category: 'usb' }) for (const row of (response.data.data || [])) { - if (KEYS.includes(row.key)) values.value[row.key] = asBool(row.value) + if (!KEYS.includes(row.key)) continue + // The token is text (the server sends a mask once set); only the two + // switches are booleans. + values.value[row.key] = row.key === 'usb_kiosk_token' + ? (row.value || '') + : asBool(row.value) } } catch (loadError) { error.value = 'Could not load USB settings.' @@ -127,7 +132,13 @@ async function save () { error.value = '' try { for (const key of KEYS) { - await settingsApi.update(key, values.value[key] ? 'true' : 'false') + // Saving the token as true/false replaced the real token with the word + // 'true', and every provisioned kiosk then got 404. The server keeps the + // stored token when it receives the mask back unchanged. + const value = key === 'usb_kiosk_token' + ? String(values.value[key] ?? '') + : (values.value[key] ? 'true' : 'false') + await settingsApi.update(key, value) } message.value = 'Settings saved' } catch (saveError) { diff --git a/plugins/usb/frontend/views/usbSettings.spec.js b/plugins/usb/frontend/views/usbSettings.spec.js new file mode 100644 index 00000000..bacb8b00 --- /dev/null +++ b/plugins/usb/frontend/views/usbSettings.spec.js @@ -0,0 +1,73 @@ +/** + * USB kiosk settings save. + * + * The page used to send every field as 'true'/'false', token included, so one + * save replaced the kiosk device token with the word 'true' and every + * provisioned kiosk then got 404. The token must travel as text: the mask back + * unchanged (the server keeps the stored token) or a newly generated value. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' + +vi.mock('@/api', () => ({ + settingsApi: { list: vi.fn(), update: vi.fn() } +})) +vi.mock('@/utils/basePath', () => ({ withBase: (path) => path })) + +import { settingsApi } from '@/api' +import USBSettings from './USBSettings.vue' + +const MASK = '********' + +async function render(rows) { + settingsApi.list.mockResolvedValue({ data: { data: rows } }) + settingsApi.update.mockResolvedValue({ data: { status: 'success' } }) + const wrapper = mount(USBSettings) + await flushPromises() + return wrapper +} + +function saved() { + return Object.fromEntries(settingsApi.update.mock.calls) +} + +describe('USBSettings save', () => { + beforeEach(() => { + settingsApi.list.mockReset() + settingsApi.update.mockReset() + }) + + it('sends the masked token back unchanged, not true', async () => { + const wrapper = await render([ + { key: 'usb_kiosk_enabled', value: 'true' }, + { key: 'usb_kiosk_allow_return_for_others', value: 'false' }, + { key: 'usb_kiosk_token', value: MASK }, + ]) + await wrapper.find('button.btn-primary').trigger('click') + await flushPromises() + expect(saved()).toEqual({ + usb_kiosk_enabled: 'true', + usb_kiosk_allow_return_for_others: 'false', + usb_kiosk_token: MASK, + }) + }) + + it('saves a generated token as the token itself', async () => { + const wrapper = await render([ + { key: 'usb_kiosk_enabled', value: 'true' }, + { key: 'usb_kiosk_token', value: '' }, + ]) + const generate = wrapper.findAll('button').find(b => b.text() === 'Generate') + await generate.trigger('click') + await wrapper.find('button.btn-primary').trigger('click') + await flushPromises() + expect(saved().usb_kiosk_token).toMatch(/^[0-9a-f]{48}$/) + }) + + it('keeps an unset token empty', async () => { + const wrapper = await render([{ key: 'usb_kiosk_token', value: '' }]) + await wrapper.find('button.btn-primary').trigger('click') + await flushPromises() + expect(saved().usb_kiosk_token).toBe('') + }) +})