Shut the parts kiosk behind a device token, and stop the health probe leaking
This commit is contained in:
@@ -121,13 +121,35 @@ def test_anonymous_cannot_mutate(client, item):
|
||||
json={'quantity': 1, 'badge': '1'}).status_code == 401
|
||||
|
||||
|
||||
KIOSK_TOKEN = 'kiosk-device-token-for-tests'
|
||||
# The header a provisioned screen sends. A module constant rather than the
|
||||
# fixture's return value, so a test does not have to name the fixture just to
|
||||
# reach the header.
|
||||
KIOSK_HEADERS = {'X-Kiosk-Token': KIOSK_TOKEN}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def kiosk(app, db):
|
||||
"""Provision the kiosk the way an administrator would, and hand back the
|
||||
header a provisioned screen sends. Without this every kiosk route 404s,
|
||||
which is the point: the routes are the only unauthenticated write in the
|
||||
product, so they are off until a site opts in."""
|
||||
with app.app_context():
|
||||
Setting.set('printedparts_kiosk_enabled', 'true')
|
||||
Setting.set('printedparts_kiosk_token', KIOSK_TOKEN)
|
||||
db.session.commit()
|
||||
return KIOSK_HEADERS
|
||||
|
||||
|
||||
def test_catalog_reads_require_view_permission(client, member_headers, item):
|
||||
"""Browsing the catalog is printedparts.view-gated; the kiosk stays open."""
|
||||
"""Browsing the catalog is printedparts.view-gated; a PROVISIONED kiosk
|
||||
reads without an account."""
|
||||
assert client.get('/api/printedparts/items').status_code == 401
|
||||
assert client.get(f'/api/printedparts/items/{item}').status_code == 401
|
||||
assert client.get('/api/printedparts/items',
|
||||
headers=member_headers).status_code == 403
|
||||
assert client.get('/api/printedparts/kiosk/item/3DP-9001').status_code == 200
|
||||
assert client.get('/api/printedparts/kiosk/item/3DP-9001',
|
||||
headers=KIOSK_HEADERS).status_code == 200
|
||||
|
||||
|
||||
def test_member_without_permission_gets_403(client, member_headers, item):
|
||||
@@ -139,29 +161,32 @@ def test_member_without_permission_gets_403(client, member_headers, item):
|
||||
headers=member_headers).status_code == 403
|
||||
|
||||
|
||||
def test_kiosk_take_is_open_decrement_only(client, auth_headers, app, item,
|
||||
directory_employee):
|
||||
"""The kiosk endpoint needs no auth but only ever decrements stock."""
|
||||
def test_kiosk_take_is_decrement_only(client, auth_headers, app, item,
|
||||
directory_employee, kiosk):
|
||||
"""A provisioned kiosk needs no account but only ever decrements stock."""
|
||||
itemcode = '3DP-9001'
|
||||
stocked = client.post(f'/api/printedparts/items/{item}/restock',
|
||||
json={'quantity': 5, 'badge': directory_employee},
|
||||
headers=auth_headers)
|
||||
assert stocked.status_code == 200
|
||||
|
||||
lookup = client.get(f'/api/printedparts/kiosk/item/{itemcode}', headers=auth_headers)
|
||||
lookup = client.get(f'/api/printedparts/kiosk/item/{itemcode}', headers={**auth_headers, **KIOSK_HEADERS})
|
||||
assert lookup.status_code == 200
|
||||
|
||||
take = client.post('/api/printedparts/kiosk/take', json={
|
||||
'itemcode': itemcode, 'badge': directory_employee, 'quantity': 2}, headers=auth_headers)
|
||||
'itemcode': itemcode, 'badge': directory_employee, 'quantity': 2},
|
||||
headers={**auth_headers, **KIOSK_HEADERS})
|
||||
assert take.status_code == 200, take.get_json()
|
||||
assert take.get_json()['data']['quantityonhand'] == 3
|
||||
|
||||
too_many = client.post('/api/printedparts/kiosk/take', json={
|
||||
'itemcode': itemcode, 'badge': directory_employee, 'quantity': 99}, headers=auth_headers)
|
||||
'itemcode': itemcode, 'badge': directory_employee, 'quantity': 99},
|
||||
headers={**auth_headers, **KIOSK_HEADERS})
|
||||
assert too_many.status_code == 400
|
||||
|
||||
unknown = client.post('/api/printedparts/kiosk/take', json={
|
||||
'itemcode': itemcode, 'badge': '111111111', 'quantity': 1}, headers=auth_headers)
|
||||
'itemcode': itemcode, 'badge': '111111111', 'quantity': 1},
|
||||
headers={**auth_headers, **KIOSK_HEADERS})
|
||||
assert unknown.status_code == 422
|
||||
|
||||
with app.app_context():
|
||||
@@ -197,7 +222,7 @@ def test_lowstock_alert_fires_on_crossing_only(client, auth_headers, app, item,
|
||||
return client.post('/api/printedparts/kiosk/take',
|
||||
json={'itemcode': '3DP-9001',
|
||||
'badge': directory_employee,
|
||||
'quantity': quantity}, headers=auth_headers)
|
||||
'quantity': quantity}, headers={**auth_headers, **KIOSK_HEADERS})
|
||||
|
||||
restock(10) # 10 on hand, threshold 5
|
||||
assert take(3).status_code == 200 # 7: above threshold, no alert
|
||||
@@ -245,7 +270,7 @@ def test_alert_recipients_merge_users_and_freetext(client, auth_headers, app,
|
||||
headers=auth_headers)
|
||||
take = client.post('/api/printedparts/kiosk/take',
|
||||
json={'itemcode': '3DP-9001',
|
||||
'badge': directory_employee, 'quantity': 6}, headers=auth_headers)
|
||||
'badge': directory_employee, 'quantity': 6}, headers={**auth_headers, **KIOSK_HEADERS})
|
||||
assert take.status_code == 200 # 4 on hand: crossed threshold 5
|
||||
|
||||
assert captured['to'] == ['lead@site.test', 'extra@site.test']
|
||||
@@ -260,7 +285,7 @@ def test_retire_hides_and_restore_returns(client, auth_headers, item):
|
||||
listed = client.get('/api/printedparts/items',
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert all(row['printeditemid'] != item for row in listed)
|
||||
kiosk = client.get('/api/printedparts/kiosk/item/3DP-9001', headers=auth_headers)
|
||||
kiosk = client.get('/api/printedparts/kiosk/item/3DP-9001', headers={**auth_headers, **KIOSK_HEADERS})
|
||||
assert kiosk.status_code == 404
|
||||
|
||||
including = client.get('/api/printedparts/items?active=false',
|
||||
@@ -270,7 +295,7 @@ def test_retire_hides_and_restore_returns(client, auth_headers, item):
|
||||
|
||||
assert client.post(f'/api/printedparts/items/{item}/restore',
|
||||
headers=auth_headers).status_code == 200
|
||||
assert client.get('/api/printedparts/kiosk/item/3DP-9001', headers=auth_headers).status_code == 200
|
||||
assert client.get('/api/printedparts/kiosk/item/3DP-9001', headers={**auth_headers, **KIOSK_HEADERS}).status_code == 200
|
||||
|
||||
|
||||
def test_file_revisions_append_and_download(client, auth_headers, item, tmp_path):
|
||||
@@ -335,7 +360,7 @@ def test_alert_role_members_receive(client, auth_headers, app, item,
|
||||
headers=auth_headers)
|
||||
take = client.post('/api/printedparts/kiosk/take',
|
||||
json={'itemcode': '3DP-9001',
|
||||
'badge': directory_employee, 'quantity': 6}, headers=auth_headers)
|
||||
'badge': directory_employee, 'quantity': 6}, headers={**auth_headers, **KIOSK_HEADERS})
|
||||
assert take.status_code == 200
|
||||
assert captured['to'] == ['crewone@site.test']
|
||||
|
||||
@@ -362,9 +387,9 @@ def test_gagelabtag_assigned_searched_and_kiosk_resolved(client, auth_headers):
|
||||
headers=auth_headers).get_json()['data']
|
||||
assert len(searched) == 1
|
||||
|
||||
by_tag = client.get('/api/printedparts/kiosk/item/WJRP0117', headers=auth_headers)
|
||||
by_tag = client.get('/api/printedparts/kiosk/item/WJRP0117', headers={**auth_headers, **KIOSK_HEADERS})
|
||||
assert by_tag.status_code == 200
|
||||
by_digits = client.get('/api/printedparts/kiosk/item/117', headers=auth_headers)
|
||||
by_digits = client.get('/api/printedparts/kiosk/item/117', headers={**auth_headers, **KIOSK_HEADERS})
|
||||
assert by_digits.status_code == 200
|
||||
assert by_digits.get_json()['data']['gagelabtag'] == 'WJRP0117'
|
||||
|
||||
@@ -380,7 +405,7 @@ def test_kiosk_take_records_revision_from_qr(client, app, db, directory_employee
|
||||
iid = row.printeditemid
|
||||
|
||||
take = client.post('/api/printedparts/kiosk/take', json={
|
||||
'itemcode': 'WJRP7000|3', 'badge': directory_employee, 'quantity': 1}, headers=auth_headers)
|
||||
'itemcode': 'WJRP7000|3', 'badge': directory_employee, 'quantity': 1}, headers={**auth_headers, **KIOSK_HEADERS})
|
||||
assert take.status_code == 200, take.get_json()
|
||||
|
||||
with app.app_context():
|
||||
@@ -391,7 +416,7 @@ def test_kiosk_take_records_revision_from_qr(client, app, db, directory_employee
|
||||
|
||||
# Manual entry (no |rev) records a null revision, still resolves.
|
||||
take2 = client.post('/api/printedparts/kiosk/take', json={
|
||||
'itemcode': 'WJRP7000', 'badge': directory_employee, 'quantity': 1}, headers=auth_headers)
|
||||
'itemcode': 'WJRP7000', 'badge': directory_employee, 'quantity': 1}, headers={**auth_headers, **KIOSK_HEADERS})
|
||||
assert take2.status_code == 200
|
||||
with app.app_context():
|
||||
last = (PrintedItemTransaction.query
|
||||
@@ -418,7 +443,92 @@ def test_lowstock_routes_to_support_team_webhook(client, app, db, directory_empl
|
||||
with patch('shopdb.api.send_webhook') as mock_send:
|
||||
# take 2 -> 6 crosses to 4 (<= threshold 5) -> alert fires
|
||||
resp = client.post('/api/printedparts/kiosk/take', json={
|
||||
'itemcode': 'WJRP7100', 'badge': directory_employee, 'quantity': 2}, headers=auth_headers)
|
||||
'itemcode': 'WJRP7100', 'badge': directory_employee, 'quantity': 2}, headers={**auth_headers, **KIOSK_HEADERS})
|
||||
assert resp.status_code == 200, resp.get_json()
|
||||
assert mock_send.called
|
||||
assert mock_send.call_args.kwargs['url'] == 'https://teams.example/3dprint'
|
||||
|
||||
|
||||
# --- the kiosk is off until a site opts in -----------------------------------
|
||||
#
|
||||
# These are the cases the Darkwing scan turned into an exploit path. Every one
|
||||
# of them returned 200 and wrote to the ledger before the gate existed.
|
||||
|
||||
def _clear_kiosk(app, db):
|
||||
with app.app_context():
|
||||
Setting.set('printedparts_kiosk_enabled', 'false')
|
||||
Setting.set('printedparts_kiosk_token', '')
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def test_kiosk_is_shut_when_the_site_has_not_enabled_it(client, app, db, item):
|
||||
_clear_kiosk(app, db)
|
||||
assert client.get('/api/printedparts/kiosk/item/3DP-9001').status_code == 404
|
||||
assert client.post('/api/printedparts/kiosk/take',
|
||||
json={'itemcode': '3DP-9001', 'badge': '1',
|
||||
'quantity': 1}).status_code == 404
|
||||
|
||||
|
||||
def test_kiosk_is_shut_without_the_device_token(client, item):
|
||||
"""Enabled is not enough: an anonymous caller has no token."""
|
||||
assert client.get('/api/printedparts/kiosk/item/3DP-9001').status_code == 404
|
||||
assert client.post('/api/printedparts/kiosk/take',
|
||||
json={'itemcode': '3DP-9001', 'badge': '1',
|
||||
'quantity': 1}).status_code == 404
|
||||
|
||||
|
||||
def test_kiosk_is_shut_with_the_wrong_device_token(client, item):
|
||||
wrong = {'X-Kiosk-Token': KIOSK_TOKEN + 'x'}
|
||||
assert client.get('/api/printedparts/kiosk/item/3DP-9001',
|
||||
headers=wrong).status_code == 404
|
||||
assert client.post('/api/printedparts/kiosk/take', headers=wrong,
|
||||
json={'itemcode': '3DP-9001', 'badge': '1',
|
||||
'quantity': 1}).status_code == 404
|
||||
|
||||
|
||||
def test_a_single_take_cannot_drain_a_bin(client, app, item, auth_headers,
|
||||
directory_employee):
|
||||
"""quantity was bounded only by what was on hand, so one request emptied
|
||||
the bin. It is capped per transaction now."""
|
||||
from plugins.printedparts.api.routes import KIOSK_MAX_TAKE_QUANTITY
|
||||
|
||||
client.post(f'/api/printedparts/items/{item}/restock',
|
||||
json={'quantity': KIOSK_MAX_TAKE_QUANTITY + 10,
|
||||
'badge': directory_employee}, headers=auth_headers)
|
||||
over = client.post('/api/printedparts/kiosk/take', headers=KIOSK_HEADERS,
|
||||
json={'itemcode': '3DP-9001',
|
||||
'badge': directory_employee,
|
||||
'quantity': KIOSK_MAX_TAKE_QUANTITY + 1})
|
||||
assert over.status_code == 400
|
||||
assert str(KIOSK_MAX_TAKE_QUANTITY) in over.get_json()['data']['error']['message']
|
||||
|
||||
|
||||
def test_true_is_not_a_quantity(client, item, directory_employee):
|
||||
"""isinstance(True, int) is True in Python, so a JSON true used to sail
|
||||
through the type check and take one."""
|
||||
resp = client.post('/api/printedparts/kiosk/take', headers=KIOSK_HEADERS,
|
||||
json={'itemcode': '3DP-9001',
|
||||
'badge': directory_employee, 'quantity': True})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
def test_the_badge_route_is_not_an_enumeration_oracle(client, item, auth_headers,
|
||||
directory_employee):
|
||||
"""An unknown badge and a malformed one must be indistinguishable, or the
|
||||
route tells an anonymous caller which numbers are real employees.
|
||||
|
||||
Stock first: the on-hand check runs BEFORE the badge is resolved, so an
|
||||
empty bin would answer 400 for both and the test would pass without ever
|
||||
reaching the code it is about."""
|
||||
client.post(f'/api/printedparts/items/{item}/restock',
|
||||
json={'quantity': 5, 'badge': directory_employee},
|
||||
headers=auth_headers)
|
||||
unknown = client.post('/api/printedparts/kiosk/take', headers=KIOSK_HEADERS,
|
||||
json={'itemcode': '3DP-9001', 'badge': '999999999',
|
||||
'quantity': 1})
|
||||
malformed = client.post('/api/printedparts/kiosk/take', headers=KIOSK_HEADERS,
|
||||
json={'itemcode': '3DP-9001', 'badge': 'not-a-badge',
|
||||
'quantity': 1})
|
||||
assert unknown.status_code == malformed.status_code == 422
|
||||
assert (unknown.get_json()['data']['error']['message']
|
||||
== malformed.get_json()['data']['error']['message'])
|
||||
|
||||
Reference in New Issue
Block a user