Wave 1 of the remediation plan for the 2026-09-03 feature test run. Two kiosks, one shape of defect, one critical consequence. Both kiosks read the state, decided in Python, and then wrote. Nothing stops two requests interleaving between the read and the write, so both see the same pre-state and both proceed. Reproduced on MySQL 5.6 over HTTP, five concurrent takes of 5 from a bin holding 5: all five were accepted and quantityonhand landed on -20, because each request wrote its own arithmetic. The ledger had recorded every one of them, so the cached quantity and the ledger disagreed permanently, which is the number the shop floor actually reads. The USB kiosk had the same shape: concurrent scans of one drive all succeeded and inserted several open custody rows, so one drive was recorded out to two people and the documented 409 never fired. The comparison now lives in the WHERE clause, where the database settles it at write time. printedparts._move_quantity issues UPDATE ... WHERE quantityonhand >= :n and treats rowcount 0 as the refusal, raising InsufficientStock, which take and adjust turn back into the message they already gave - carrying the quantity the database holds now, not the stale one the request read. The low-stock alert derives its before-value from what the write produced for the same reason. Both USB paths claim the device with ischeckedout (self-hosted) or status (external cmmc_usb) in the WHERE clause; the external one claims BEFORE writing the log line, so a refused claim leaves nothing to undo. Check-in is guarded the same way. After the fix, the same five concurrent takes: one accepted, four refused, quantityonhand 0 and the ledger sum 0. Check-in also closes EVERY open row for the device rather than the newest. One device can only be out once, so a second open row is corruption, and closing only the newest left the older ones open forever - the device then read as returned while its custody history said otherwise. Separately, usbcheckouts now records who handed a drive back (checkinsso/checkinname, migration usb0004checkinidentity). Returning for a colleague is a supported flow that the kiosk asks to confirm, but the row it wrote attributed the check-in to the HOLDER, so the custody history and the audit report both named somebody who was not there. Nullable and deliberately not backfilled: copying the holder's badge into old rows would manufacture evidence for the exact claim this stops making. Four tests. Both guards verified by planting: strip the condition out of either UPDATE and its test fails. Scope: product
399 lines
16 KiB
Python
399 lines
16 KiB
Python
"""The USB kiosk: an UNAUTHENTICATED write, so the guards are the feature.
|
|
|
|
The kiosk exists so somebody at the drive cabinet - with no ShopDB login - can
|
|
take a drive and put it back, with the action attributed to their badge instead
|
|
of to a signed-in operator. Everything here is about the bar that opens the door
|
|
only as far as it has to: it can move a device between checked-out and
|
|
checked-in and nothing else, it refuses a badge it cannot put a name to, and it
|
|
does not exist at all until a site turns it on.
|
|
|
|
Self-hosted mode. The external cmmc_usb path is the same contract against raw
|
|
SQL and needs that database to exercise.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from shopdb.core.models import Setting
|
|
|
|
|
|
KIOSK_TOKEN = 'usb-kiosk-device-token-for-tests'
|
|
# What a provisioned screen sends. Turning the kiosk on is a decision; this is
|
|
# the credential.
|
|
KIOSK_HEADERS = {'X-Kiosk-Token': KIOSK_TOKEN}
|
|
|
|
|
|
@pytest.fixture
|
|
def kiosk(db):
|
|
"""Self-hosted USB, kiosk ON, one employee and one available drive."""
|
|
from plugins.employees.models import DirectoryEmployee
|
|
from plugins.usb.models import USBDevice
|
|
|
|
db.session.add(Setting(key='usb_directory_mode', value='selfhosted',
|
|
valuetype='string', category='site'))
|
|
db.session.add(Setting(key='usb_kiosk_enabled', value='true',
|
|
valuetype='boolean', category='usb'))
|
|
db.session.add(Setting(key='usb_kiosk_token', value=KIOSK_TOKEN,
|
|
valuetype='string', category='usb'))
|
|
db.session.add(DirectoryEmployee(sso=270015376, firstname='Dana',
|
|
lastname='Reyes'))
|
|
db.session.add(DirectoryEmployee(sso=270099999, firstname='Sam',
|
|
lastname='Okafor'))
|
|
db.session.add(USBDevice(serialnumber='USB-1', label='Blue Kingston',
|
|
ischeckedout=False))
|
|
db.session.commit()
|
|
return db
|
|
|
|
|
|
def _setting(db, key, value):
|
|
row = Setting.query.filter_by(key=key).first()
|
|
if row is None:
|
|
db.session.add(Setting(key=key, value=value, valuetype='boolean',
|
|
category='usb'))
|
|
else:
|
|
row.value = value
|
|
db.session.commit()
|
|
|
|
|
|
def _out(client, badge='270015376', **extra):
|
|
return client.post('/api/usb/kiosk/checkout',
|
|
json=dict({'device_id': 'USB-1', 'badge': badge}, **extra), headers=KIOSK_HEADERS)
|
|
|
|
|
|
def _in(client, badge='270015376', **extra):
|
|
return client.post('/api/usb/kiosk/checkin',
|
|
json=dict({'device_id': 'USB-1', 'badge': badge}, **extra), headers=KIOSK_HEADERS)
|
|
|
|
|
|
# --- the door itself --------------------------------------------------------
|
|
|
|
def test_the_kiosk_does_not_exist_until_a_site_turns_it_on(client, kiosk):
|
|
"""Default off, and 404 rather than 403.
|
|
|
|
An unauthenticated write is a decision about the door. A site that has not
|
|
made that decision should not have these routes answering at all, and a
|
|
disabled kiosk should not advertise that there is a door here.
|
|
"""
|
|
_setting(kiosk, 'usb_kiosk_enabled', 'false')
|
|
|
|
assert client.get('/api/usb/kiosk/device/USB-1', headers=KIOSK_HEADERS).status_code == 404
|
|
assert _out(client).status_code == 404
|
|
assert _in(client).status_code == 404
|
|
|
|
|
|
def test_it_ships_off(app):
|
|
"""The declared default is what a fresh site gets."""
|
|
from plugins.usb.plugin import USBPlugin
|
|
defaults = {s['key']: s for s in USBPlugin().get_settings_defaults()}
|
|
assert defaults['usb_kiosk_enabled']['value'] == 'false'
|
|
|
|
|
|
def test_no_login_is_required(client, kiosk):
|
|
"""The whole point: no Authorization header anywhere in this file."""
|
|
response = client.get('/api/usb/kiosk/device/USB-1', headers=KIOSK_HEADERS)
|
|
assert response.status_code == 200
|
|
assert response.get_json()['data']['device_id'] == 'USB-1'
|
|
|
|
|
|
def test_the_kiosk_cannot_create_edit_or_delete(client, kiosk):
|
|
"""It moves a device between two states. It is not an admin surface."""
|
|
assert client.post('/api/usb/kiosk/device/USB-1', headers=KIOSK_HEADERS).status_code in (404, 405)
|
|
assert client.delete('/api/usb/kiosk/device/USB-1').status_code in (404, 405)
|
|
# And the authenticated write routes are still gated.
|
|
assert client.post('/api/usb/USB-1/checkout',
|
|
json={'badge': '270015376'}).status_code == 401
|
|
|
|
|
|
# --- badges -----------------------------------------------------------------
|
|
|
|
def test_a_badge_it_cannot_name_is_refused(client, kiosk):
|
|
"""A checkout nobody can trace to a person defeats the tracking.
|
|
|
|
The signed-in route creates the user row for an unknown badge; the kiosk
|
|
must not, because there is no operator standing there to vouch for it.
|
|
"""
|
|
response = _out(client, badge='999999999')
|
|
assert response.status_code == 422
|
|
assert 'not recognized' in response.get_json()['data']['error']['message']
|
|
|
|
from plugins.usb.models import USBDevice
|
|
assert USBDevice.query.filter_by(serialnumber='USB-1').first().ischeckedout is False
|
|
|
|
|
|
def test_a_missing_badge_is_refused(client, kiosk):
|
|
assert client.post('/api/usb/kiosk/checkout',
|
|
json={'device_id': 'USB-1'}, headers=KIOSK_HEADERS).status_code == 400
|
|
|
|
|
|
# --- taking one -------------------------------------------------------------
|
|
|
|
def test_taking_a_drive_records_who_took_it(client, kiosk):
|
|
from plugins.usb.models import USBDevice
|
|
|
|
response = _out(client)
|
|
assert response.status_code == 200, response.get_json()
|
|
|
|
device = USBDevice.query.filter_by(serialnumber='USB-1').first()
|
|
assert device.ischeckedout is True
|
|
assert device.currentuserid == '270015376'
|
|
assert device.currentusername == 'Dana Reyes'
|
|
|
|
|
|
def test_a_drive_already_out_cannot_be_taken_again(client, kiosk):
|
|
_out(client)
|
|
response = _out(client, badge='270099999')
|
|
assert response.status_code == 409
|
|
assert 'Dana Reyes' in response.get_json()['data']['error']['message']
|
|
|
|
|
|
def test_an_unknown_code_is_a_404(client, kiosk):
|
|
response = client.post('/api/usb/kiosk/checkout',
|
|
json={'device_id': 'NOPE', 'badge': '270015376'}, headers=KIOSK_HEADERS)
|
|
assert response.status_code == 404
|
|
|
|
|
|
# --- putting one back -------------------------------------------------------
|
|
|
|
def test_the_holder_returns_their_own_drive(client, kiosk):
|
|
from plugins.usb.models import USBDevice
|
|
_out(client)
|
|
|
|
assert _in(client).status_code == 200
|
|
device = USBDevice.query.filter_by(serialnumber='USB-1').first()
|
|
assert device.ischeckedout is False
|
|
assert device.currentuserid is None
|
|
|
|
|
|
def test_returning_somebody_elses_drive_asks_first(client, kiosk):
|
|
"""Not a silent success: the record is about to say they handed it back."""
|
|
from plugins.usb.models import USBDevice
|
|
_out(client)
|
|
|
|
response = _in(client, badge='270099999')
|
|
assert response.status_code == 409
|
|
details = response.get_json()['data']['error']['details']
|
|
assert details['needsconfirm'] is True
|
|
assert details['holdername'] == 'Dana Reyes'
|
|
assert USBDevice.query.filter_by(serialnumber='USB-1').first().ischeckedout is True
|
|
|
|
|
|
def test_confirming_returns_it_for_them(client, kiosk):
|
|
"""A drive left on a bench by somebody on leave still gets put back.
|
|
|
|
Refusing outright just moves the problem to a person with a login.
|
|
"""
|
|
from plugins.usb.models import USBDevice
|
|
_out(client)
|
|
|
|
response = _in(client, badge='270099999', forsomeoneelse=True)
|
|
assert response.status_code == 200, response.get_json()
|
|
assert USBDevice.query.filter_by(serialnumber='USB-1').first().ischeckedout is False
|
|
|
|
|
|
def test_a_site_can_refuse_returns_for_others_outright(client, kiosk):
|
|
_setting(kiosk, 'usb_kiosk_allow_return_for_others', 'false')
|
|
_out(client)
|
|
|
|
response = _in(client, badge='270099999', forsomeoneelse=True)
|
|
assert response.status_code == 403
|
|
|
|
|
|
def test_a_drive_that_is_not_out_cannot_be_returned(client, kiosk):
|
|
assert _in(client).status_code == 400
|
|
|
|
|
|
# --- what the screen is allowed to know -------------------------------------
|
|
|
|
def test_the_lookup_names_the_holder_but_not_their_badge(client, kiosk):
|
|
"""The confirm step has to show whose it is; it does not need their SSO."""
|
|
_out(client)
|
|
data = client.get('/api/usb/kiosk/device/USB-1', headers=KIOSK_HEADERS).get_json()['data']
|
|
|
|
assert data['holdername'] == 'Dana Reyes'
|
|
assert '270015376' not in str(data)
|
|
|
|
|
|
# --- the picker -------------------------------------------------------------
|
|
# The drives carry no barcode, so the kiosk cannot ask somebody to present one -
|
|
# it has to offer a LIST. That is a real widening of what a logged-out screen
|
|
# exposes, so the list is not anonymous: it costs a badge that resolves to a
|
|
# real person, the same bar taking a drive already had to clear.
|
|
|
|
def _session(client, badge='270015376'):
|
|
return client.post('/api/usb/kiosk/session', json={'badge': badge}, headers=KIOSK_HEADERS)
|
|
|
|
|
|
def test_the_device_list_costs_a_valid_badge(client, kiosk):
|
|
assert client.post('/api/usb/kiosk/session', json={}, headers=KIOSK_HEADERS).status_code == 400
|
|
assert _session(client, badge='999999999').status_code == 422
|
|
|
|
|
|
def test_the_list_is_not_reachable_when_the_kiosk_is_off(client, kiosk):
|
|
_setting(kiosk, 'usb_kiosk_enabled', 'false')
|
|
assert _session(client).status_code == 404
|
|
assert client.post('/api/usb/kiosk/others',
|
|
json={'badge': '270015376'}, headers=KIOSK_HEADERS).status_code == 404
|
|
|
|
|
|
def test_the_session_separates_yours_from_available(client, kiosk):
|
|
from plugins.usb.models import USBDevice
|
|
kiosk.session.add(USBDevice(serialnumber='USB-2', label='Second',
|
|
ischeckedout=False))
|
|
kiosk.session.commit()
|
|
_out(client) # 270015376 takes USB-1
|
|
|
|
data = _session(client).get_json()['data']
|
|
assert data['name'] == 'Dana Reyes'
|
|
assert [d['device_id'] for d in data['yours']] == ['USB-1']
|
|
assert [d['device_id'] for d in data['available']] == ['USB-2']
|
|
assert data['otherscount'] == 0
|
|
|
|
|
|
def test_somebody_elses_drive_is_a_count_not_a_name(client, kiosk):
|
|
"""A passer-by should not read the cabinet's holders off a wall screen."""
|
|
_out(client) # Dana takes USB-1
|
|
|
|
data = _session(client, badge='270099999').get_json()['data']
|
|
assert data['yours'] == []
|
|
assert data['otherscount'] == 1
|
|
assert 'Dana' not in str(data)
|
|
|
|
|
|
def test_names_appear_only_on_the_deliberate_second_step(client, kiosk):
|
|
_out(client)
|
|
|
|
rows = client.post('/api/usb/kiosk/others',
|
|
json={'badge': '270099999'}, headers=KIOSK_HEADERS).get_json()['data']
|
|
assert [r['device_id'] for r in rows] == ['USB-1']
|
|
assert rows[0]['holdername'] == 'Dana Reyes'
|
|
|
|
|
|
def test_the_others_list_is_gone_when_returns_for_others_are_refused(client, kiosk):
|
|
"""No point showing a list of drives the kiosk will not let anyone return."""
|
|
_setting(kiosk, 'usb_kiosk_allow_return_for_others', 'false')
|
|
_out(client)
|
|
|
|
assert client.post('/api/usb/kiosk/others',
|
|
json={'badge': '270099999'}, headers=KIOSK_HEADERS).status_code == 404
|
|
|
|
|
|
def test_a_retired_drive_is_not_offered(client, kiosk):
|
|
from plugins.usb.models import USBDevice
|
|
device = USBDevice.query.filter_by(serialnumber='USB-1').first()
|
|
device.isactive = False
|
|
kiosk.session.commit()
|
|
|
|
data = _session(client).get_json()['data']
|
|
assert data['available'] == []
|
|
|
|
|
|
# --- the flag is a decision, the token is the credential ---------------------
|
|
#
|
|
# usb_kiosk_enabled alone used to be the whole gate. With the kiosk on, anyone
|
|
# who could reach the app could move a drive between checked-out and checked-in
|
|
# and have it recorded against a badge they guessed.
|
|
|
|
def test_kiosk_is_shut_without_the_device_token(client, kiosk):
|
|
assert client.get('/api/usb/kiosk/device/USB-1').status_code == 404
|
|
assert client.post('/api/usb/kiosk/checkout',
|
|
json={'device_id': 'USB-1',
|
|
'badge': '270015376'}).status_code == 404
|
|
|
|
|
|
def test_kiosk_is_shut_with_the_wrong_device_token(client, kiosk):
|
|
wrong = {'X-Kiosk-Token': KIOSK_TOKEN + 'x'}
|
|
assert client.get('/api/usb/kiosk/device/USB-1',
|
|
headers=wrong).status_code == 404
|
|
assert client.post('/api/usb/kiosk/checkout', headers=wrong,
|
|
json={'device_id': 'USB-1',
|
|
'badge': '270015376'}).status_code == 404
|
|
|
|
|
|
def test_the_token_alone_is_not_enough_when_the_kiosk_is_off(client, kiosk):
|
|
"""Both halves are required: a provisioned screen at a site that has since
|
|
switched the kiosk off gets nothing."""
|
|
_setting(kiosk, 'usb_kiosk_enabled', 'false')
|
|
assert client.get('/api/usb/kiosk/device/USB-1',
|
|
headers=KIOSK_HEADERS).status_code == 404
|
|
|
|
|
|
# --- the race, and who returned it ------------------------------------------
|
|
#
|
|
# The kiosk decided "is this drive available?" in Python and then wrote. Two
|
|
# scans of the same drive that interleaved both read "available", so both
|
|
# inserted a custody row: one drive recorded out to two people, and the
|
|
# documented 409 never fired. The fix puts ischeckedout in the WHERE clause, so
|
|
# the database settles it at write time.
|
|
|
|
def test_a_checkout_that_passed_its_availability_check_is_still_refused(
|
|
client, kiosk, monkeypatch):
|
|
"""Simulate the race window: the request holds a device object that says
|
|
available while the row has already been claimed by somebody else."""
|
|
from plugins.usb.api import selfhosted
|
|
from plugins.usb.models import USBDevice
|
|
|
|
first = _out(client, badge='270015376')
|
|
assert first.status_code == 200
|
|
|
|
# What the losing request sees: its own read, taken before the winner
|
|
# committed, still says the drive is on the shelf.
|
|
stale = USBDevice.query.filter_by(serialnumber='USB-1').first()
|
|
monkeypatch.setattr(
|
|
selfhosted, '_find',
|
|
lambda device_id: _stale_copy(stale))
|
|
|
|
second = _out(client, badge='270099999')
|
|
assert second.status_code == 409, second.get_json()
|
|
|
|
open_rows = _open_checkouts(USBDevice)
|
|
assert len(open_rows) == 1, 'one drive, one open custody row'
|
|
assert open_rows[0].sso == '270015376'
|
|
|
|
|
|
def _stale_copy(device):
|
|
"""A detached stand-in that still believes the drive is available."""
|
|
class Stale:
|
|
usbdeviceid = device.usbdeviceid
|
|
serialnumber = device.serialnumber
|
|
isactive = True
|
|
ischeckedout = False
|
|
currentuserid = None
|
|
currentusername = None
|
|
return Stale()
|
|
|
|
|
|
def _open_checkouts(_):
|
|
from plugins.usb.models import USBCheckout
|
|
return USBCheckout.query.filter_by(checkintime=None).all()
|
|
|
|
|
|
def test_a_return_records_who_handed_it_back_not_only_the_holder(client, kiosk):
|
|
"""Returning for a colleague is a supported flow, so the check-in has to
|
|
name the person who actually walked it back."""
|
|
from plugins.usb.models import USBCheckout
|
|
|
|
assert _out(client, badge='270015376').status_code == 200
|
|
returned = _in(client, badge='270099999', forsomeoneelse=True)
|
|
assert returned.status_code == 200, returned.get_json()
|
|
|
|
row = USBCheckout.query.order_by(USBCheckout.checkoutid.desc()).first()
|
|
assert row.sso == '270015376', 'the holder still owns the checkout'
|
|
assert row.checkinsso == '270099999', 'the returner is recorded'
|
|
assert row.checkinname and 'Sam' in row.checkinname
|
|
|
|
|
|
def test_a_check_in_closes_every_open_row_for_that_device(client, kiosk, db):
|
|
"""Defence in depth for history written before the guard existed: a device
|
|
can only be out once, so a second open row is corruption, and closing only
|
|
the newest left the older ones open forever."""
|
|
from plugins.usb.models import USBCheckout, USBDevice
|
|
|
|
assert _out(client, badge='270015376').status_code == 200
|
|
device = USBDevice.query.filter_by(serialnumber='USB-1').first()
|
|
db.session.add(USBCheckout(usbdeviceid=device.usbdeviceid, sso='270099999',
|
|
checkoutname='Sam Okafor'))
|
|
db.session.commit()
|
|
assert len(_open_checkouts(USBDevice)) == 2
|
|
|
|
assert _in(client, badge='270015376').status_code == 200
|
|
assert _open_checkouts(USBDevice) == []
|