Files
shopdb-flask/plugins/usb/frontend/views/usbSettings.spec.js
cproudlock da91a29155 Admit kiosk callers by network position, not only by device token
The device token assumes a handful of fixed screens an administrator
provisions once. That is not how these kiosks are used: most people open the
page on their own PC, and there is no login, so per-browser provisioning would
mean touching every PC on the floor and losing it again on the next reimage or
cleared profile. A screen in that state did not look refused either - the USB
kiosk rendered a working page whose every call answered 404.

Both kiosks now admit a caller on either ground, the same shape the GE-Enforce
client endpoints already use: the per-device token, or a source address inside
an allowlist. Wall screens keep the token and are not confined to the
allowlist; everyone else is admitted by where they are on the network. Still
gated on the site's enable flag, so nothing opens by itself, and both
allowlists ship empty - token-only until a site enters its ranges.

- shopdb.api gains ip_in_cidrs and request_ip_allowlisted (contract 0.35.0).
  On the surface because both plugins need the identical gate, and a CIDR
  parser copied into two of them is where a fail-open creeps in later. They
  fail closed on an empty setting, an unparseable caller address and a
  malformed entry, and they read remote_addr rather than raw X-Forwarded-For -
  so the IIS rewrite rule is what makes them meaningful, which the module says
  at length.
- Separate settings per kiosk, usb_kiosk_allowed_cidrs and
  printedparts_kiosk_allowed_cidrs. One must not grant the other, for the same
  reason the tokens are separate.
- Both settings pages get a picker listing the network plugin's subnets with
  their VLANs. It stores resolved CIDR strings, not subnet ids: the setting has
  to work at a site that does not run that plugin and must not break when an
  inventory row is deleted. Entries matching no subnet are shown rather than
  hidden, since a stale one left by an inventory edit looks exactly like a
  hand-typed range.
- USBKiosk says it was not admitted instead of pretending to work, which
  PartsKiosk already did.
- The USB settings page keyed text-vs-boolean off the token alone. Adding a
  second text setting to the boolean path would have saved it as the word
  'true' - the bug that replaced the kiosk token and 404'd every provisioned
  screen. Both load and save now key off a list, with a spec on it.

The trade, stated plainly in the admin UI: anyone on an allowed network can
check a drive in or out against a badge number they guess, and the record
carries that person's name. Badges are attribution, not authorization. Keeping
the ranges narrow is what bounds it; login is the alternative and was ruled out
because these users do not sign in.
2026-09-16 14:33:22 -04:00

88 lines
3.0 KiB
JavaScript

/**
* 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,
usb_kiosk_allowed_cidrs: '',
})
})
it('saves the network allowlist as text, not as true/false', async () => {
// Same trap the token fell into: a text setting sent through the boolean
// path becomes the word 'true', and the gate then admits nobody while the
// settings page still shows something plausible.
const wrapper = await render([
{ key: 'usb_kiosk_enabled', value: 'true' },
{ key: 'usb_kiosk_allowed_cidrs', value: '192.0.2.0/24, 198.51.100.0/24' },
])
await wrapper.find('button.btn-primary').trigger('click')
await flushPromises()
expect(saved().usb_kiosk_allowed_cidrs).toBe('192.0.2.0/24, 198.51.100.0/24')
})
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('')
})
})