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'] == []