Shut the parts kiosk behind a device token, and stop the health probe leaking
Both from the second Darkwing scan. Every claim in it was verified against the
code before acting.
The kiosk was the product's only unauthenticated write, justified by a decision
record saying it was "physically rate-limited: it serves a touch screen on the
shop floor". Nothing in code enforced the physical half, and an HTTP route is
reachable from wherever the app is. What that left open:
- _kiosk_find_item falls back to matching bare digits against the numeric
tail of every active item, so 1, 2, 3 walk the catalogue without knowing a
barcode;
- a badge is a short numeric SSO, and the route answered 422 for an unknown
one and 200 for a real one - an oracle for sorting guesses into employees;
- the write then decremented stock and stamped the ledger, which IS the audit
trail, with whatever SSO the caller supplied;
- quantity was bounded only by what was on hand, so one request emptied a bin.
A badge is attribution, never authorization. Both kiosk routes now need the
site's kiosk flag AND a per-device token, and 404 rather than 403 without them
so a caller does not learn the door is there. The READ is gated too: gating only
the write would have left the enumeration oracle running against a kiosk a site
had switched off.
The token is per-DEVICE, not per-page, because a kiosk is a browser on a touch
screen and a token served inside the page is readable by anyone who can load it.
An admin opens the screen once as /parts-kiosk?token=..., the page moves it into
that browser's localStorage and strips it from the address bar.
Also: quantity is capped per take, JSON true no longer passes as 1
(isinstance(True, int) is True), and every badge failure on the anonymous route
returns one message. The gated restock and adjust handlers keep the specific
message - there the person is known and the detail is help, not an oracle. I
changed the wrong one of those three handlers first; caught by the test.
/api/dashboard/health formatted str(e) straight into the body of a route
PUBLIC_ENDPOINTS declares open because "monitoring has no account". A pymysql
OperationalError names the database account, the app host's address, the driver
and the failing statement. It is logged server-side now and the body carries a
coarse status. The version goes with it - it told an anonymous caller which
release to look up. Status stays 200 either way: a monitor keyed on the body's
status field is the contract here, and turning a degraded database into a
transport failure is a separate decision from not leaking the exception.
Nine tests. The three gate tests were confirmed to fail with the gate stubbed
out, rather than assumed to cover it. Suite 2325, frontend 316, build clean.
Scope: product
This commit is contained in:
@@ -1501,6 +1501,17 @@ export const measuringtoolsApi = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3D printed parts (printedparts plugin)
|
// 3D printed parts (printedparts plugin)
|
||||||
|
// Device token for the parts kiosk, provisioned once per screen and kept in
|
||||||
|
// that browser. Absent means the routes answer 404, which is what an
|
||||||
|
// unprovisioned browser should see.
|
||||||
|
export const KIOSK_TOKEN_KEY = 'printedparts-kiosk-token'
|
||||||
|
|
||||||
|
function kioskHeaders() {
|
||||||
|
let token = ''
|
||||||
|
try { token = localStorage.getItem(KIOSK_TOKEN_KEY) || '' } catch { token = '' }
|
||||||
|
return token ? { 'X-Kiosk-Token': token } : {}
|
||||||
|
}
|
||||||
|
|
||||||
export const printedpartsApi = {
|
export const printedpartsApi = {
|
||||||
list(params = {}) {
|
list(params = {}) {
|
||||||
return api.get('/printedparts/items', { params })
|
return api.get('/printedparts/items', { params })
|
||||||
@@ -1541,11 +1552,17 @@ export const printedpartsApi = {
|
|||||||
adjust(printeditemid, data) {
|
adjust(printeditemid, data) {
|
||||||
return api.post(`/printedparts/items/${printeditemid}/adjust`, data)
|
return api.post(`/printedparts/items/${printeditemid}/adjust`, data)
|
||||||
},
|
},
|
||||||
|
// The kiosk routes are the only unauthenticated write in the product, so they
|
||||||
|
// are gated on a per-DEVICE token rather than on the page. It lives in this
|
||||||
|
// browser's localStorage, put there once by an admin opening the kiosk with
|
||||||
|
// ?token=... - see PartsKiosk.vue. A token shipped inside the page would be
|
||||||
|
// readable by anyone who could load it, which is not a gate.
|
||||||
kioskItem(itemcode) {
|
kioskItem(itemcode) {
|
||||||
return api.get(`/printedparts/kiosk/item/${encodeURIComponent(itemcode)}`)
|
return api.get(`/printedparts/kiosk/item/${encodeURIComponent(itemcode)}`,
|
||||||
|
{ headers: kioskHeaders() })
|
||||||
},
|
},
|
||||||
kioskTake(data) {
|
kioskTake(data) {
|
||||||
return api.post('/printedparts/kiosk/take', data)
|
return api.post('/printedparts/kiosk/take', data, { headers: kioskHeaders() })
|
||||||
},
|
},
|
||||||
listFiles(printeditemid) {
|
listFiles(printeditemid) {
|
||||||
return api.get(`/printedparts/items/${printeditemid}/files`)
|
return api.get(`/printedparts/items/${printeditemid}/files`)
|
||||||
|
|||||||
@@ -431,11 +431,54 @@ def adjust_item(item_id: int):
|
|||||||
return success_response(item.to_dict(), message='Stock adjusted')
|
return success_response(item.to_dict(), message='Stock adjusted')
|
||||||
|
|
||||||
|
|
||||||
# --- kiosk: UNauthenticated by decision record --------------------------------
|
# --- kiosk: off by default, bound to a provisioned device token ---------------
|
||||||
# The take endpoint is the product's first open WRITE. The proposal's decision
|
# The take endpoint is the product's only open WRITE. The original decision
|
||||||
# record sets the bar it must meet: decrement-only, badge-attributed, bounded,
|
# record justified that with "physically rate-limited: it serves a touch screen
|
||||||
# physically rate-limited. It can reduce stock of an active item and nothing
|
# on the shop floor". Nothing in code enforced the physical part, and an HTTP
|
||||||
# else; identity comes from the badge resolved server-side, never the client.
|
# route is reachable from anywhere the app is, so the justification did not
|
||||||
|
# survive contact with a scanner.
|
||||||
|
#
|
||||||
|
# What was actually reachable with no credential: bare digits in
|
||||||
|
# _kiosk_find_item enumerate the catalogue without knowing a barcode, a badge is
|
||||||
|
# a short numeric SSO, and this route answered 422 for an unknown badge and 200
|
||||||
|
# for a known one - an oracle for finding real ones. The write then decremented
|
||||||
|
# stock and stamped the ledger, which is the audit trail, with whichever SSO the
|
||||||
|
# caller supplied.
|
||||||
|
#
|
||||||
|
# So a badge is ATTRIBUTION, never authorization. Both kiosk routes now require
|
||||||
|
# the kiosk to be switched on for this site AND the device token an administrator
|
||||||
|
# provisioned. The read is gated too, not just the write: leaving it open would
|
||||||
|
# keep the enumeration oracle running against a kiosk that is supposedly off.
|
||||||
|
#
|
||||||
|
# The token is per-DEVICE, not per-page. A kiosk is a browser on a touch screen,
|
||||||
|
# so a token embedded in the served page would be readable by anyone who can
|
||||||
|
# load it. An admin opens the kiosk once with ?token=..., the page keeps it in
|
||||||
|
# that browser's localStorage and sends it as X-Kiosk-Token thereafter.
|
||||||
|
|
||||||
|
import hmac
|
||||||
|
|
||||||
|
# Largest single take the kiosk records. A shop-floor grab is single digits; 25
|
||||||
|
# leaves real use untouched and denies draining a bin in one request.
|
||||||
|
KIOSK_MAX_TAKE_QUANTITY = 25
|
||||||
|
|
||||||
|
|
||||||
|
def _kiosk_open():
|
||||||
|
"""True when this site has the kiosk on AND the caller carries its token."""
|
||||||
|
enabled = Setting.get('printedparts_kiosk_enabled', default=False)
|
||||||
|
if not (enabled is True or str(enabled).strip().lower() in ('true', '1', 'yes', 'on')):
|
||||||
|
return False
|
||||||
|
expected = (Setting.get('printedparts_kiosk_token') or '').strip()
|
||||||
|
presented = (request.headers.get('X-Kiosk-Token') or '').strip()
|
||||||
|
if not expected or not presented:
|
||||||
|
return False
|
||||||
|
# compare_digest, not ==: the token is a secret and == leaks its length and
|
||||||
|
# matching prefix through timing.
|
||||||
|
return hmac.compare_digest(presented, expected)
|
||||||
|
|
||||||
|
|
||||||
|
def _kiosk_shut():
|
||||||
|
"""404, not 403 - a caller without the token does not learn the door exists."""
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'Not found', http_code=404)
|
||||||
|
|
||||||
def _kiosk_find_item(itemcode):
|
def _kiosk_find_item(itemcode):
|
||||||
"""Resolve a scanned or typed code to an active item.
|
"""Resolve a scanned or typed code to an active item.
|
||||||
@@ -468,7 +511,9 @@ def _kiosk_find_item(itemcode):
|
|||||||
|
|
||||||
@printedparts_bp.route('/kiosk/item/<itemcode>', methods=['GET'])
|
@printedparts_bp.route('/kiosk/item/<itemcode>', methods=['GET'])
|
||||||
def kiosk_item(itemcode):
|
def kiosk_item(itemcode):
|
||||||
"""Item summary for a scanned bin barcode (open read for the kiosk)."""
|
"""Item summary for a scanned bin barcode. Kiosk-token gated."""
|
||||||
|
if not _kiosk_open():
|
||||||
|
return _kiosk_shut()
|
||||||
item = _kiosk_find_item(itemcode)
|
item = _kiosk_find_item(itemcode)
|
||||||
if not item:
|
if not item:
|
||||||
return error_response(ErrorCodes.NOT_FOUND,
|
return error_response(ErrorCodes.NOT_FOUND,
|
||||||
@@ -478,7 +523,13 @@ def kiosk_item(itemcode):
|
|||||||
|
|
||||||
@printedparts_bp.route('/kiosk/take', methods=['POST'])
|
@printedparts_bp.route('/kiosk/take', methods=['POST'])
|
||||||
def kiosk_take():
|
def kiosk_take():
|
||||||
"""Take parts from a bin. Body: {itemcode, badge, quantity}."""
|
"""Take parts from a bin. Body: {itemcode, badge, quantity}.
|
||||||
|
|
||||||
|
The badge attributes the ledger row. It does not authorize the write - the
|
||||||
|
kiosk flag and the device token do.
|
||||||
|
"""
|
||||||
|
if not _kiosk_open():
|
||||||
|
return _kiosk_shut()
|
||||||
data = request.get_json() or {}
|
data = request.get_json() or {}
|
||||||
|
|
||||||
item = _kiosk_find_item(data.get('itemcode'))
|
item = _kiosk_find_item(data.get('itemcode'))
|
||||||
@@ -487,9 +538,17 @@ def kiosk_take():
|
|||||||
'No part matches that barcode', http_code=404)
|
'No part matches that barcode', http_code=404)
|
||||||
|
|
||||||
quantity = data.get('quantity')
|
quantity = data.get('quantity')
|
||||||
if not isinstance(quantity, int) or quantity < 1:
|
# isinstance(True, int) is True in Python, so True would have passed as a
|
||||||
|
# quantity of 1. Reject bool explicitly.
|
||||||
|
if (not isinstance(quantity, int) or isinstance(quantity, bool)
|
||||||
|
or quantity < 1):
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
'Enter how many you are taking')
|
'Enter how many you are taking')
|
||||||
|
if quantity > KIOSK_MAX_TAKE_QUANTITY:
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
|
f'Take at most {KIOSK_MAX_TAKE_QUANTITY} at a time - see the '
|
||||||
|
f'parts team')
|
||||||
if quantity > item.quantityonhand:
|
if quantity > item.quantityonhand:
|
||||||
return error_response(
|
return error_response(
|
||||||
ErrorCodes.VALIDATION_ERROR,
|
ErrorCodes.VALIDATION_ERROR,
|
||||||
@@ -498,8 +557,14 @@ def kiosk_take():
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
sso, name = resolve_badge(data.get('badge'))
|
sso, name = resolve_badge(data.get('badge'))
|
||||||
except BadgeError as badge_error:
|
except BadgeError:
|
||||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error),
|
# ONE message for every badge failure on the ANONYMOUS route. This is
|
||||||
|
# the only place it matters: distinguishing "malformed" from "not
|
||||||
|
# recognized" let a caller with no account sort guesses into real
|
||||||
|
# employee numbers. The gated restock/adjust handlers keep the specific
|
||||||
|
# message, because there the person is known and the detail is help.
|
||||||
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||||
|
'Badge not accepted - see the parts team',
|
||||||
http_code=422)
|
http_code=422)
|
||||||
|
|
||||||
# Print-file revision the scanned part carried: explicit field, else the
|
# Print-file revision the scanned part carried: explicit field, else the
|
||||||
|
|||||||
@@ -92,7 +92,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||||
import { printedpartsApi, settingsApi } from '@/api'
|
import { printedpartsApi, settingsApi, KIOSK_TOKEN_KEY } from '@/api'
|
||||||
import { withBase } from '@/utils/basePath'
|
import { withBase } from '@/utils/basePath'
|
||||||
import TouchKeypad from '@/components/TouchKeypad.vue'
|
import TouchKeypad from '@/components/TouchKeypad.vue'
|
||||||
|
|
||||||
@@ -116,7 +116,28 @@ const scannedRevision = ref(null)
|
|||||||
const entryInput = ref(null)
|
const entryInput = ref(null)
|
||||||
let resetTimer = null
|
let resetTimer = null
|
||||||
|
|
||||||
|
// Claim the device token, once, from the URL an admin used to set this screen
|
||||||
|
// up: /parts-kiosk?token=<value>. It is kept in THIS browser and stripped from
|
||||||
|
// the address bar immediately, so it does not sit in the URL for a shoulder to
|
||||||
|
// read, and does not travel in a bookmark or a screenshot. Every kiosk call
|
||||||
|
// then carries it as X-Kiosk-Token; without it the routes answer 404.
|
||||||
|
function claimTokenFromUrl() {
|
||||||
|
try {
|
||||||
|
const url = new URL(window.location.href)
|
||||||
|
const token = (url.searchParams.get('token') || '').trim()
|
||||||
|
if (!token) return
|
||||||
|
localStorage.setItem(KIOSK_TOKEN_KEY, token)
|
||||||
|
url.searchParams.delete('token')
|
||||||
|
window.history.replaceState({}, '', url.pathname + url.search + url.hash)
|
||||||
|
} catch {
|
||||||
|
// A browser refusing localStorage is a browser this kiosk cannot be
|
||||||
|
// provisioned in. Nothing to recover here; the routes will 404 and the
|
||||||
|
// operator sees the standard error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
claimTokenFromUrl()
|
||||||
// A kiosk left running for weeks reads this once at load, which is fine: the
|
// A kiosk left running for weeks reads this once at load, which is fine: the
|
||||||
// label format is a property of the site's physical tags, not something that
|
// label format is a property of the site's physical tags, not something that
|
||||||
// changes during a shift.
|
// changes during a shift.
|
||||||
|
|||||||
@@ -150,6 +150,24 @@ class PrintedpartsPlugin(BasePlugin):
|
|||||||
'category': 'printedparts',
|
'category': 'printedparts',
|
||||||
'description': 'Default low-stock threshold for new items',
|
'description': 'Default low-stock threshold for new items',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
'key': 'printedparts_kiosk_enabled',
|
||||||
|
'value': 'false',
|
||||||
|
'valuetype': 'boolean',
|
||||||
|
'category': 'printedparts',
|
||||||
|
'description': 'Serve the parts kiosk routes. Off by default: '
|
||||||
|
'they are the only unauthenticated write in the '
|
||||||
|
'product, so a site opts in',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'key': 'printedparts_kiosk_token',
|
||||||
|
'value': '',
|
||||||
|
'valuetype': 'secret',
|
||||||
|
'category': 'printedparts',
|
||||||
|
'description': 'Device token the kiosk sends as X-Kiosk-Token. '
|
||||||
|
'Generate one, then open the kiosk once as '
|
||||||
|
'/parts-kiosk?token=<value> on each screen',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
'key': 'printedparts_unknown_badge',
|
'key': 'printedparts_unknown_badge',
|
||||||
'value': 'deny',
|
'value': 'deny',
|
||||||
|
|||||||
@@ -317,17 +317,30 @@ def get_widgets():
|
|||||||
|
|
||||||
@dashboard_bp.route('/health', methods=['GET'])
|
@dashboard_bp.route('/health', methods=['GET'])
|
||||||
def health_check():
|
def health_check():
|
||||||
"""Health check endpoint (no auth required)."""
|
"""Health check endpoint (no auth required).
|
||||||
from shopdb import __version__
|
|
||||||
|
|
||||||
|
Anonymous callers get a coarse status and nothing else. The probe used to
|
||||||
|
format str(e) straight into the body, and a pymysql OperationalError names
|
||||||
|
the database account, the app host's address, the driver and the failing
|
||||||
|
statement - the internal topology, handed to whoever asks, on a route
|
||||||
|
PUBLIC_ENDPOINTS declares open because "monitoring has no account". The
|
||||||
|
detail is logged server-side instead, where the people who need it are.
|
||||||
|
|
||||||
|
The version goes the same way. It told an anonymous caller exactly which
|
||||||
|
release to look up known issues for, and monitoring does not need it.
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
db.session.execute(db.text('SELECT 1'))
|
db.session.execute(db.text('SELECT 1'))
|
||||||
db_status = 'healthy'
|
healthy = True
|
||||||
except Exception as e:
|
except Exception:
|
||||||
db_status = f'unhealthy: {str(e)}'
|
current_app.logger.exception('Health check database probe failed')
|
||||||
|
healthy = False
|
||||||
|
|
||||||
|
# Status code stays 200 either way. A monitor keyed on the body's status
|
||||||
|
# field is the documented contract here, and turning a degraded database
|
||||||
|
# into a transport-level failure would change what every existing check
|
||||||
|
# sees - a separate decision from not leaking the exception.
|
||||||
return success_response({
|
return success_response({
|
||||||
'status': 'ok' if db_status == 'healthy' else 'degraded',
|
'status': 'ok' if healthy else 'degraded',
|
||||||
'database': db_status,
|
'database': 'healthy' if healthy else 'unhealthy',
|
||||||
'version': __version__
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -46,10 +46,14 @@ EXEMPT_BLUEPRINTS = {'auth', 'collector', 'setup'}
|
|||||||
# the same blueprint are JWT+permission gated and ARE swept.
|
# the same blueprint are JWT+permission gated and ARE swept.
|
||||||
EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user',
|
EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user',
|
||||||
'geenforce.post_report',
|
'geenforce.post_report',
|
||||||
# Deliberately open kiosk write: decrement-only,
|
# Kiosk write. No JWT - the people at the bin have no
|
||||||
# badge-attributed server-side. Decision record in
|
# account - but no longer open: it needs the site's kiosk
|
||||||
# docs/proposals/printedparts-plugin.md; justification in
|
# flag AND the per-device token (X-Kiosk-Token), and 404s
|
||||||
# the plugin README.
|
# without them. The badge only attributes the ledger row.
|
||||||
|
# The old "physically rate-limited by being a touch screen"
|
||||||
|
# justification did not survive a scanner: bare digits
|
||||||
|
# enumerated the catalogue and the 422/200 split was an
|
||||||
|
# oracle for real employee numbers.
|
||||||
'printedparts.kiosk_take',
|
'printedparts.kiosk_take',
|
||||||
# Deliberately open kiosk writes. NOT the same bar as the
|
# Deliberately open kiosk writes. NOT the same bar as the
|
||||||
# parts kiosk, and the difference is the point: these are
|
# parts kiosk, and the difference is the point: these are
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ PUBLIC_ENDPOINTS = {
|
|||||||
'slides.feed', 'slides.serve_image',
|
'slides.feed', 'slides.serve_image',
|
||||||
|
|
||||||
# Parts kiosk: one item by its code, and that item's picture.
|
# Parts kiosk: one item by its code, and that item's picture.
|
||||||
|
# kiosk_item takes no JWT but is NOT open: same kiosk flag + device token
|
||||||
|
# as the write. Gating only the write would have left the enumeration
|
||||||
|
# oracle running against a kiosk a site had switched off.
|
||||||
'printedparts.kiosk_item', 'printedparts.serve_item_image',
|
'printedparts.kiosk_item', 'printedparts.serve_item_image',
|
||||||
|
|
||||||
# USB kiosk.
|
# USB kiosk.
|
||||||
|
|||||||
@@ -121,13 +121,35 @@ def test_anonymous_cannot_mutate(client, item):
|
|||||||
json={'quantity': 1, 'badge': '1'}).status_code == 401
|
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):
|
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('/api/printedparts/items').status_code == 401
|
||||||
assert client.get(f'/api/printedparts/items/{item}').status_code == 401
|
assert client.get(f'/api/printedparts/items/{item}').status_code == 401
|
||||||
assert client.get('/api/printedparts/items',
|
assert client.get('/api/printedparts/items',
|
||||||
headers=member_headers).status_code == 403
|
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):
|
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
|
headers=member_headers).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
def test_kiosk_take_is_open_decrement_only(client, auth_headers, app, item,
|
def test_kiosk_take_is_decrement_only(client, auth_headers, app, item,
|
||||||
directory_employee):
|
directory_employee, kiosk):
|
||||||
"""The kiosk endpoint needs no auth but only ever decrements stock."""
|
"""A provisioned kiosk needs no account but only ever decrements stock."""
|
||||||
itemcode = '3DP-9001'
|
itemcode = '3DP-9001'
|
||||||
stocked = client.post(f'/api/printedparts/items/{item}/restock',
|
stocked = client.post(f'/api/printedparts/items/{item}/restock',
|
||||||
json={'quantity': 5, 'badge': directory_employee},
|
json={'quantity': 5, 'badge': directory_employee},
|
||||||
headers=auth_headers)
|
headers=auth_headers)
|
||||||
assert stocked.status_code == 200
|
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
|
assert lookup.status_code == 200
|
||||||
|
|
||||||
take = client.post('/api/printedparts/kiosk/take', json={
|
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.status_code == 200, take.get_json()
|
||||||
assert take.get_json()['data']['quantityonhand'] == 3
|
assert take.get_json()['data']['quantityonhand'] == 3
|
||||||
|
|
||||||
too_many = client.post('/api/printedparts/kiosk/take', json={
|
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
|
assert too_many.status_code == 400
|
||||||
|
|
||||||
unknown = client.post('/api/printedparts/kiosk/take', json={
|
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
|
assert unknown.status_code == 422
|
||||||
|
|
||||||
with app.app_context():
|
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',
|
return client.post('/api/printedparts/kiosk/take',
|
||||||
json={'itemcode': '3DP-9001',
|
json={'itemcode': '3DP-9001',
|
||||||
'badge': directory_employee,
|
'badge': directory_employee,
|
||||||
'quantity': quantity}, headers=auth_headers)
|
'quantity': quantity}, headers={**auth_headers, **KIOSK_HEADERS})
|
||||||
|
|
||||||
restock(10) # 10 on hand, threshold 5
|
restock(10) # 10 on hand, threshold 5
|
||||||
assert take(3).status_code == 200 # 7: above threshold, no alert
|
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)
|
headers=auth_headers)
|
||||||
take = client.post('/api/printedparts/kiosk/take',
|
take = client.post('/api/printedparts/kiosk/take',
|
||||||
json={'itemcode': '3DP-9001',
|
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 take.status_code == 200 # 4 on hand: crossed threshold 5
|
||||||
|
|
||||||
assert captured['to'] == ['lead@site.test', 'extra@site.test']
|
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',
|
listed = client.get('/api/printedparts/items',
|
||||||
headers=auth_headers).get_json()['data']
|
headers=auth_headers).get_json()['data']
|
||||||
assert all(row['printeditemid'] != item for row in listed)
|
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
|
assert kiosk.status_code == 404
|
||||||
|
|
||||||
including = client.get('/api/printedparts/items?active=false',
|
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',
|
assert client.post(f'/api/printedparts/items/{item}/restore',
|
||||||
headers=auth_headers).status_code == 200
|
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):
|
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)
|
headers=auth_headers)
|
||||||
take = client.post('/api/printedparts/kiosk/take',
|
take = client.post('/api/printedparts/kiosk/take',
|
||||||
json={'itemcode': '3DP-9001',
|
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 take.status_code == 200
|
||||||
assert captured['to'] == ['crewone@site.test']
|
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']
|
headers=auth_headers).get_json()['data']
|
||||||
assert len(searched) == 1
|
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
|
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.status_code == 200
|
||||||
assert by_digits.get_json()['data']['gagelabtag'] == 'WJRP0117'
|
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
|
iid = row.printeditemid
|
||||||
|
|
||||||
take = client.post('/api/printedparts/kiosk/take', json={
|
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()
|
assert take.status_code == 200, take.get_json()
|
||||||
|
|
||||||
with app.app_context():
|
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.
|
# Manual entry (no |rev) records a null revision, still resolves.
|
||||||
take2 = client.post('/api/printedparts/kiosk/take', json={
|
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
|
assert take2.status_code == 200
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
last = (PrintedItemTransaction.query
|
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:
|
with patch('shopdb.api.send_webhook') as mock_send:
|
||||||
# take 2 -> 6 crosses to 4 (<= threshold 5) -> alert fires
|
# take 2 -> 6 crosses to 4 (<= threshold 5) -> alert fires
|
||||||
resp = client.post('/api/printedparts/kiosk/take', json={
|
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 resp.status_code == 200, resp.get_json()
|
||||||
assert mock_send.called
|
assert mock_send.called
|
||||||
assert mock_send.call_args.kwargs['url'] == 'https://teams.example/3dprint'
|
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