From 7c47ae1f7c8a2ea5c92747c0b40602f475b483f3 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Thu, 27 Aug 2026 08:13:31 -0400 Subject: [PATCH] USB kiosk: take and return a drive without a ShopDB login The people who actually take USB drives are standing at the cabinet, and most of them have no account here. Check-out and check-in were gated behind a login that was not doing much anyway: both modes already attribute the action to a scanned BADGE rather than to the signed-in user, so the login was gating the screen, not the record. /usb-kiosk is a full-screen touch page modelled on the parts kiosk, off unless a site sets usb_kiosk_enabled - every route 404s until then, rather than 403, so a disabled kiosk does not advertise that there is a door here. It works against the self-hosted tables or an existing cmmc_usb database. THE LIST IS THE TRADE. These drives carry no barcode, so unlike the parts kiosk there is nothing to present and the screen has to offer a list - which exposes more than a scan-first design, where you had to already hold a drive to learn anything. So the list is not anonymous: an SSO comes first and must resolve to a real person before any drive is named, which is the same bar taking a drive already had to clear. It is a POST so the badge stays out of proxy access logs. The main screen shows the caller's own drives, what is free, and a COUNT of everything else; holders of other people's drives appear only behind a second deliberate tap, so nobody reads the whole cabinet off a wall panel in passing. A badge that resolves to no employee is REFUSED, unlike the signed-in route which creates the user row. A checkout nobody can trace to a person defeats the tracking, and at a kiosk there is no operator standing there to vouch for it. Returning somebody else's drive asks first and then records the badge that was SCANNED, so the log says who actually handed it back. That is the honest record and it needs no change to a schema this app does not own. Refusing a mismatch outright is a setting but not the default: a drive left behind by somebody on leave would otherwise be unreturnable at the kiosk, which just moves the problem to a person with a login. The authz sweep caught these writes, twice - once for the two mutations and again for the two list endpoints. Both are exempted with the reasoning written out beside the parts-kiosk entry rather than silenced. The list endpoints are POSTs that change nothing. Verified by driving the real page: SSO on the keypad, tap Take, database moves. --- frontend/src/router/index.js | 1 + plugins/usb/api/routes.py | 347 ++++++++++++++++++++++++ plugins/usb/api/selfhosted.py | 178 ++++++++++++ plugins/usb/frontend/routes.js | 10 + plugins/usb/frontend/views/USBKiosk.vue | 327 ++++++++++++++++++++++ plugins/usb/plugin.py | 38 +++ tests/test_core/test_authz.py | 24 ++ tests/test_plugins/test_usb_kiosk.py | 278 +++++++++++++++++++ 8 files changed, 1203 insertions(+) create mode 100644 plugins/usb/frontend/views/USBKiosk.vue create mode 100644 tests/test_plugins/test_usb_kiosk.py diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 37562394..93dc2df6 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -131,6 +131,7 @@ const TITLE_SPELLINGS = { 'knowledgebase': 'Knowledge Base', 'printedparts': '3D Printed Parts', 'parts-kiosk': 'Parts Kiosk', + 'usb-kiosk': 'USB Kiosk', 'tv': 'TV Slideshow', 'shopfloor': 'Shopfloor Dashboard', 'measuringtools': 'Measuring Tools', diff --git a/plugins/usb/api/routes.py b/plugins/usb/api/routes.py index b721b533..86c213e4 100644 --- a/plugins/usb/api/routes.py +++ b/plugins/usb/api/routes.py @@ -721,3 +721,350 @@ def list_active_checkouts(): conn.close() return success_response(data) + + +# ============================================================================= +# Kiosk: UNAUTHENTICATED check-out / check-in +# ============================================================================= +# The bar these have to meet, same as the parts kiosk: they can move a device +# between checked-out and checked-in and NOTHING else. No create, no edit, no +# retire, no browsing the fleet, no reading who holds what without presenting +# the device. Identity is the scanned badge, resolved server-side; the client +# never asserts who it is. +# +# Off unless a site turns it on. An unauthenticated write is a decision about +# the door, not about the person walking through it. + +def _kiosk_setting(key, default=False): + row = Setting.query.filter_by(key=key).first() + if row is None or row.value is None or row.value == '': + return default + return str(row.value).strip().lower() in ('true', '1', 'yes', 'on') + + +def _kiosk_enabled(): + return _kiosk_setting('usb_kiosk_enabled', False) + + +def _kiosk_disabled_response(): + # 404, not 403: when the kiosk is off these routes should not read as + # "there is a door here and you may not use it". + return error_response(ErrorCodes.NOT_FOUND, 'Not found', http_code=404) + + +def _kiosk_badge(data): + """The scanned badge, or an error response. Never trusts a client name.""" + badge = (data.get('badge') or '').strip() + if not badge: + return None, error_response(ErrorCodes.VALIDATION_ERROR, + 'Scan your badge') + return badge, None + + +@usb_bp.route('/kiosk/device/', methods=['GET']) +def kiosk_device(device_id): + """What the kiosk shows after a device is scanned. + + Deliberately narrow: the description, whether it is out, and the NAME of + whoever holds it - which the return-for-someone-else confirm has to show, or + the person standing there cannot tell whether they are doing the right + thing. No badge number, no history, no contact details. + """ + if not _kiosk_enabled(): + return _kiosk_disabled_response() + if _usb_selfhosted(): + return selfhosted.kiosk_device(device_id) + + conn = cmmc_usb_connection() + try: + with conn.cursor() as cur: + device = _fetch_device(cur, device_id) + if not device: + return error_response(ErrorCodes.NOT_FOUND, + 'No device matches that code', + http_code=404) + holder = '' + if device.get('status') == _STATUS_CHECKED_OUT: + holder = _kiosk_holder_name(cur, device_id) + finally: + conn.close() + + return success_response({ + 'device_id': device.get('device_id'), + 'device_desc': device.get('device_desc'), + 'status': device.get('status'), + 'locker_location': device.get('locker_location'), + 'holdername': holder, + }) + + +def _kiosk_holder_badge(cur, device_id): + """Badge on the most recent check-out for this device, or ''.""" + cur.execute( + 'SELECT badge_number FROM checkinoutlog ' + "WHERE device_id = %s AND action = 'check-out' " + 'ORDER BY timestamp DESC, log_id DESC LIMIT 1', + (device_id,), + ) + row = cur.fetchone() + return (row.get('badge_number') or '').strip() if row else '' + + +def _kiosk_holder_name(cur, device_id): + badge = _kiosk_holder_badge(cur, device_id) + return _resolve_badge_name(cur, badge) if badge else '' + + +@usb_bp.route('/kiosk/session', methods=['POST']) +def kiosk_session(): + """What one badge may do right now. Body: {badge}. + + The drives here carry no barcode, so the kiosk cannot ask somebody to + present one - it has to offer a LIST. That is a real widening: before, you + had to already hold a drive to learn anything. So the list is not anonymous. + It costs a badge that resolves to a real person, which is the same bar + check-out itself has to clear, and it is a POST so the badge does not end up + in a proxy access log the way a query string would. + + Returns the caller's own drives (to return), what is free (to take), and a + COUNT of everything else - names for those are a separate, deliberate step, + so a passer-by does not read the whole cabinet's holders off a wall screen. + """ + if not _kiosk_enabled(): + return _kiosk_disabled_response() + data = request.get_json() or {} + if _usb_selfhosted(): + return selfhosted.kiosk_session(data) + + badge, failure = _kiosk_badge(data) + if failure: + return failure + + conn = cmmc_usb_connection() + try: + with conn.cursor() as cur: + name = _resolve_badge_name(cur, badge) + if not name: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'Badge not recognized - see IT', + http_code=422) + + cur.execute( + 'SELECT ' + _DEVICE_COLS + ' FROM devices ' + 'WHERE status IN (%s, %s) ORDER BY device_id', + (_STATUS_CHECKED_IN, _STATUS_CHECKED_OUT), + ) + rows = cur.fetchall() or [] + + yours, available, others = [], [], 0 + for row in rows: + entry = { + 'device_id': row.get('device_id'), + 'device_desc': row.get('device_desc'), + 'locker_location': row.get('locker_location'), + } + if row.get('status') == _STATUS_CHECKED_IN: + available.append(entry) + elif _kiosk_holder_badge(cur, row.get('device_id')) == badge: + yours.append(entry) + else: + others += 1 + finally: + conn.close() + + return success_response({'name': name, 'yours': yours, + 'available': available, 'otherscount': others}) + + +@usb_bp.route('/kiosk/others', methods=['POST']) +def kiosk_others(): + """Drives checked out to OTHER people, with holder names. Body: {badge}. + + Behind its own tap, and only when returning for somebody else is allowed at + all. Somebody has to be able to put back a drive left on a bench by a person + who is on leave - but the names that makes visible should not be the first + thing on a shopfloor screen. + """ + if not _kiosk_enabled(): + return _kiosk_disabled_response() + if not _kiosk_setting('usb_kiosk_allow_return_for_others', True): + return _kiosk_disabled_response() + data = request.get_json() or {} + if _usb_selfhosted(): + return selfhosted.kiosk_others(data) + + badge, failure = _kiosk_badge(data) + if failure: + return failure + + conn = cmmc_usb_connection() + try: + with conn.cursor() as cur: + if not _resolve_badge_name(cur, badge): + return error_response(ErrorCodes.VALIDATION_ERROR, + 'Badge not recognized - see IT', + http_code=422) + cur.execute( + 'SELECT ' + _DEVICE_COLS + ' FROM devices WHERE status = %s ' + 'ORDER BY device_id', + (_STATUS_CHECKED_OUT,), + ) + out = [] + for row in cur.fetchall() or []: + holderbadge = _kiosk_holder_badge(cur, row.get('device_id')) + if holderbadge == badge: + continue + out.append({ + 'device_id': row.get('device_id'), + 'device_desc': row.get('device_desc'), + 'locker_location': row.get('locker_location'), + 'holdername': _resolve_badge_name(cur, holderbadge), + }) + finally: + conn.close() + + return success_response(out) + + +@usb_bp.route('/kiosk/checkout', methods=['POST']) +def kiosk_checkout(): + """Check a device out at the kiosk. Body: {device_id, badge}.""" + if not _kiosk_enabled(): + return _kiosk_disabled_response() + data = request.get_json() or {} + if _usb_selfhosted(): + return selfhosted.kiosk_checkout(data) + + device_id = (data.get('device_id') or '').strip() + badge, failure = _kiosk_badge(data) + if failure: + return failure + + conn = cmmc_usb_connection() + try: + with conn.cursor() as cur: + device = _fetch_device(cur, device_id) + if not device: + return error_response(ErrorCodes.NOT_FOUND, + 'No device matches that code', + http_code=404) + if device.get('status') == _STATUS_RETIRED: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'That device is retired - see IT') + if device.get('status') == _STATUS_CHECKED_OUT: + holder = _kiosk_holder_name(cur, device_id) + return error_response( + ErrorCodes.CONFLICT, + 'That device is already checked out{}'.format( + ' to {}'.format(holder) if holder else ''), + http_code=409) + + # An unresolvable badge is REFUSED here, unlike the signed-in route + # which creates the user row. A checkout nobody can trace to a + # person is the one thing this tracking exists to prevent, and at a + # kiosk there is no signed-in operator to vouch for it. + name = _resolve_badge_name(cur, badge) + if not name: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'Badge not recognized - see IT to check this device out', + http_code=422) + + cur.execute( + 'INSERT INTO checkinoutlog ' + '(badge_number, device_id, action, timestamp, locker_location) ' + "VALUES (%s, %s, 'check-out', %s, %s)", + (badge, device_id, _now(), device.get('locker_location')), + ) + cur.execute( + 'UPDATE devices SET status = %s WHERE device_id = %s', + (_STATUS_CHECKED_OUT, device_id), + ) + conn.commit() + finally: + conn.close() + + return success_response({'device_id': device_id, 'holdername': name}, + message='{} checked out to {}'.format( + device_id, name)) + + +@usb_bp.route('/kiosk/checkin', methods=['POST']) +def kiosk_checkin(): + """Check a device in at the kiosk. Body: {device_id, badge, forsomeoneelse}. + + Holder by default. A mismatch is refused with the holder's name and a flag + the screen uses to offer "returning it for them", because the alternative - + a drive whose owner is on leave that literally cannot be returned at the + kiosk - just moves the problem to somebody with a login. + + The badge RECORDED is the one that was scanned: the log says who actually + handed the device back, which is the honest record and needs no change to a + schema this app does not own. + """ + if not _kiosk_enabled(): + return _kiosk_disabled_response() + data = request.get_json() or {} + if _usb_selfhosted(): + return selfhosted.kiosk_checkin(data) + + device_id = (data.get('device_id') or '').strip() + badge, failure = _kiosk_badge(data) + if failure: + return failure + forsomeoneelse = bool(data.get('forsomeoneelse')) + + conn = cmmc_usb_connection() + try: + with conn.cursor() as cur: + device = _fetch_device(cur, device_id) + if not device: + return error_response(ErrorCodes.NOT_FOUND, + 'No device matches that code', + http_code=404) + if device.get('status') != _STATUS_CHECKED_OUT: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'That device is not checked out') + + name = _resolve_badge_name(cur, badge) + if not name: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'Badge not recognized - see IT to return this device', + http_code=422) + + holderbadge = _kiosk_holder_badge(cur, device_id) + if holderbadge and holderbadge != badge: + if not _kiosk_setting('usb_kiosk_allow_return_for_others', True): + return error_response( + ErrorCodes.FORBIDDEN, + 'That device is checked out to somebody else - they ' + 'need to return it, or see IT', + http_code=403) + if not forsomeoneelse: + holdername = _resolve_badge_name(cur, holderbadge) + return error_response( + ErrorCodes.CONFLICT, + 'That device is checked out to {}. Confirm you are ' + 'returning it for them.'.format(holdername or 'somebody else'), + details={'needsconfirm': True, + 'holdername': holdername}, + http_code=409) + + cur.execute( + 'INSERT INTO checkinoutlog ' + '(badge_number, device_id, action, timestamp, locker_location) ' + "VALUES (%s, %s, 'check-in', %s, %s)", + (badge, device_id, _now(), device.get('locker_location')), + ) + cur.execute( + 'UPDATE devices SET status = %s WHERE device_id = %s', + (_STATUS_CHECKED_IN, device_id), + ) + conn.commit() + finally: + conn.close() + + return success_response({'device_id': device_id}, + message='{} checked in'.format(device_id)) diff --git a/plugins/usb/api/selfhosted.py b/plugins/usb/api/selfhosted.py index 4254da32..25a87d27 100644 --- a/plugins/usb/api/selfhosted.py +++ b/plugins/usb/api/selfhosted.py @@ -274,3 +274,181 @@ def list_checkouts(request, active_only): event['device_status'] = _status(checkout.device) if checkout.device else None data.append(event) return success_response(data) + + +# --------------------------------------------------------------------------- +# Kiosk (self-hosted mode). Same contract as the external path in routes.py: +# move a device between checked-out and checked-in, attributed to a badge that +# resolved to a real person, and nothing else. +# --------------------------------------------------------------------------- + +def kiosk_device(device_id): + device = _find(device_id) + if not device: + return error_response(ErrorCodes.NOT_FOUND, + 'No device matches that code', http_code=404) + return success_response({ + 'device_id': device.serialnumber, + 'device_desc': device.label or device.productname or '', + 'status': _status(device), + 'locker_location': device.storagelocation, + # Name only. The kiosk needs it for the return-for-someone-else + # confirm; it does not need their badge. + 'holdername': device.currentusername or '' if device.ischeckedout else '', + }) + + +def kiosk_session(data): + """See routes.kiosk_session for why the list costs a valid badge.""" + badge = (data.get('badge') or '').strip() + if not badge: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge') + name = _resolve_name(badge) + if not name: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'Badge not recognized - see IT', http_code=422) + + yours, available, others = [], [], 0 + for device in USBDevice.query.filter_by(isactive=True).order_by( + USBDevice.serialnumber).all(): + entry = { + 'device_id': device.serialnumber, + 'device_desc': device.label or device.productname or '', + 'locker_location': device.storagelocation, + } + if not device.ischeckedout: + available.append(entry) + elif (device.currentuserid or '').strip() == badge: + yours.append(entry) + else: + others += 1 + + return success_response({'name': name, 'yours': yours, + 'available': available, 'otherscount': others}) + + +def kiosk_others(data): + badge = (data.get('badge') or '').strip() + if not badge: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge') + if not _resolve_name(badge): + return error_response(ErrorCodes.VALIDATION_ERROR, + 'Badge not recognized - see IT', http_code=422) + + out = [] + for device in USBDevice.query.filter_by(isactive=True, ischeckedout=True + ).order_by(USBDevice.serialnumber).all(): + if (device.currentuserid or '').strip() == badge: + continue + out.append({ + 'device_id': device.serialnumber, + 'device_desc': device.label or device.productname or '', + 'locker_location': device.storagelocation, + 'holdername': device.currentusername or '', + }) + return success_response(out) + + +def kiosk_checkout(data): + device_id = (data.get('device_id') or '').strip() + badge = (data.get('badge') or '').strip() + if not badge: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge') + + device = _find(device_id) + if not device: + return error_response(ErrorCodes.NOT_FOUND, + 'No device matches that code', http_code=404) + if not device.isactive: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'That device is retired - see IT') + if device.ischeckedout: + return error_response( + ErrorCodes.CONFLICT, + 'That device is already checked out{}'.format( + ' to {}'.format(device.currentusername) + if device.currentusername else ''), + http_code=409) + + # Refused rather than recorded anonymously - see the note on the external + # path. A checkout nobody can trace to a person defeats the tracking. + name = _resolve_name(badge) + if not name: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'Badge not recognized - see IT to check this device out', + http_code=422) + + now = datetime.now(timezone.utc).replace(tzinfo=None) + db.session.add(USBCheckout(usbdeviceid=device.usbdeviceid, sso=badge, + checkoutname=name, checkouttime=now)) + device.ischeckedout = True + device.currentuserid = badge + device.currentusername = name + device.currentcheckoutdate = now + db.session.commit() + return success_response({'device_id': device.serialnumber, + 'holdername': name}, + message='{} checked out to {}'.format( + device.serialnumber, name)) + + +def kiosk_checkin(data): + device_id = (data.get('device_id') or '').strip() + badge = (data.get('badge') or '').strip() + if not badge: + return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge') + forsomeoneelse = bool(data.get('forsomeoneelse')) + + device = _find(device_id) + if not device: + return error_response(ErrorCodes.NOT_FOUND, + 'No device matches that code', http_code=404) + if not device.ischeckedout: + return error_response(ErrorCodes.VALIDATION_ERROR, + 'That device is not checked out') + + name = _resolve_name(badge) + if not name: + return error_response( + ErrorCodes.VALIDATION_ERROR, + 'Badge not recognized - see IT to return this device', + http_code=422) + + holderbadge = (device.currentuserid or '').strip() + if holderbadge and holderbadge != badge: + if not _kiosk_flag('usb_kiosk_allow_return_for_others', True): + return error_response( + ErrorCodes.FORBIDDEN, + 'That device is checked out to somebody else - they need to ' + 'return it, or see IT', + http_code=403) + if not forsomeoneelse: + return error_response( + ErrorCodes.CONFLICT, + 'That device is checked out to {}. Confirm you are returning ' + 'it for them.'.format(device.currentusername or 'somebody else'), + details={'needsconfirm': True, + 'holdername': device.currentusername or ''}, + http_code=409) + + open_checkout = (USBCheckout.query + .filter_by(usbdeviceid=device.usbdeviceid, checkintime=None) + .order_by(USBCheckout.checkouttime.desc()).first()) + if open_checkout: + open_checkout.checkintime = datetime.now(timezone.utc).replace(tzinfo=None) + device.ischeckedout = False + device.currentuserid = None + device.currentusername = None + device.currentcheckoutdate = None + db.session.commit() + return success_response({'device_id': device.serialnumber}, + message='{} checked in'.format(device.serialnumber)) + + +def _kiosk_flag(key, default): + from shopdb.api import Setting + row = Setting.query.filter_by(key=key).first() + if row is None or row.value is None or row.value == '': + return default + return str(row.value).strip().lower() in ('true', '1', 'yes', 'on') diff --git a/plugins/usb/frontend/routes.js b/plugins/usb/frontend/routes.js index 4f224b23..cade9108 100644 --- a/plugins/usb/frontend/routes.js +++ b/plugins/usb/frontend/routes.js @@ -29,6 +29,16 @@ export default [ ] export const toplevel = [ + { + // Touch kiosk for taking and returning USB drives: scan the drive, scan a + // badge. Full-screen and OUTSIDE AppLayout, and deliberately not behind + // requiresAuth - the whole point is that a guest at the cabinet can use it. + // The API refuses every kiosk route unless usb_kiosk_enabled is on. + path: '/usb-kiosk', + name: 'usb-kiosk', + component: () => import('./views/USBKiosk.vue'), + meta: { plugin: 'usb' } + }, { path: '/print/usb-labels', name: 'print-usb-labels', diff --git a/plugins/usb/frontend/views/USBKiosk.vue b/plugins/usb/frontend/views/USBKiosk.vue new file mode 100644 index 00000000..d76a76ae --- /dev/null +++ b/plugins/usb/frontend/views/USBKiosk.vue @@ -0,0 +1,327 @@ + + + + + diff --git a/plugins/usb/plugin.py b/plugins/usb/plugin.py index 85054cae..bb6572cb 100644 --- a/plugins/usb/plugin.py +++ b/plugins/usb/plugin.py @@ -70,6 +70,44 @@ class USBPlugin(BasePlugin): 'docs': 'plugins/usb/README.md', } + def get_settings_defaults(self) -> List[Dict]: + """Kiosk settings. + + The kiosk is an UNAUTHENTICATED write, so it ships off and a site turns + it on deliberately. Opening that door is a decision about the door. + """ + return [ + { + 'key': 'usb_kiosk_enabled', + 'value': 'false', + 'valuetype': 'boolean', + 'category': 'usb', + # Public: the kiosk page runs logged out and has to be able to + # tell "turned off" from "broken" before anyone signs in. + 'public': True, + 'description': 'Allow check-out and check-in from the logged-out ' + 'USB kiosk screen (/usb-kiosk). Off by default: the ' + 'kiosk writes without a login, attributing every ' + 'action to a scanned badge instead. It can only move ' + 'a device between checked-out and checked-in - it ' + 'cannot add, edit, retire or list devices.', + }, + { + 'key': 'usb_kiosk_allow_return_for_others', + 'value': 'true', + 'valuetype': 'boolean', + 'category': 'usb', + 'public': True, + 'description': 'At the kiosk, allow somebody to return a device ' + 'checked out to a DIFFERENT person after confirming. ' + 'The returning badge is what gets recorded, so the ' + 'log shows who actually handed it back. Turn this ' + 'off to refuse any mismatch outright - a device left ' + 'behind by someone on leave then needs a signed-in ' + 'user to return it.', + }, + ] + def get_config_schema(self) -> List[Dict]: """CMMC USB check-in/out database connection. Host/name/user are settings the wizard can edit; the password stays in .env (emitted, not stored).""" diff --git a/tests/test_core/test_authz.py b/tests/test_core/test_authz.py index 476bb0f8..0b9a072e 100644 --- a/tests/test_core/test_authz.py +++ b/tests/test_core/test_authz.py @@ -51,6 +51,30 @@ EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user', # docs/proposals/printedparts-plugin.md; justification in # the plugin README. 'printedparts.kiosk_take', + # Deliberately open kiosk writes, same bar as the parts + # kiosk: they can ONLY move a USB device between + # checked-out and checked-in, and only when a site has set + # usb_kiosk_enabled (off by default - every one of these is + # a 404 until then). Identity is a scanned badge resolved + # server-side, and a badge that resolves to no employee is + # refused rather than recorded, so nothing here can create + # an untraceable checkout. The people who take drives stand + # at the cabinet and mostly have no account, and both modes + # already attributed the action to the badge rather than to + # the signed-in user - the login was gating the screen, not + # the record. Covered by tests/test_plugins/test_usb_kiosk.py. + 'usb.kiosk_checkout', + 'usb.kiosk_checkin', + # The kiosk's two list endpoints. POSTs that CHANGE NOTHING: + # they are reads, shaped as POSTs so the badge stays out of + # query strings and proxy access logs. Same gate as the + # writes above - 404 unless usb_kiosk_enabled, and they name + # no drive until the badge resolves to a real person. The + # drives have no barcode, so a list is the only way to pick + # one; kiosk_others additionally 404s when returning for + # somebody else is switched off. + 'usb.kiosk_session', + 'usb.kiosk_others', # Redeeming a shared secret. The recipient usually has no # account here, so the unguessable 128-bit token IS the # authorisation - gating this on a role would make the diff --git a/tests/test_plugins/test_usb_kiosk.py b/tests/test_plugins/test_usb_kiosk.py new file mode 100644 index 00000000..9f8e753f --- /dev/null +++ b/tests/test_plugins/test_usb_kiosk.py @@ -0,0 +1,278 @@ +"""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 + + +@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(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)) + + +def _in(client, badge='270015376', **extra): + return client.post('/api/usb/kiosk/checkin', + json=dict({'device_id': 'USB-1', 'badge': badge}, **extra)) + + +# --- 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').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') + 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').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'}).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'}) + 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').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}) + + +def test_the_device_list_costs_a_valid_badge(client, kiosk): + assert client.post('/api/usb/kiosk/session', json={}).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'}).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'}).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'}).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'] == []