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