Remediate the 2026-09-03 feature-test findings

This commit is contained in:
2026-09-03 17:14:53 -04:00
parent ddf41c3e65
commit ff9ae693a6
112 changed files with 5182 additions and 857 deletions

View File

@@ -102,14 +102,27 @@ def caller_holds(permission_name: str) -> bool:
Guests are included on purpose: a signed-out visitor holds whatever the
guest role was granted, so the answer must come from the same place
require_permission gets it.
Scoped personal tokens are included for the same reason, and this is the
half that was missing: the answer has to be about the CALLER, and a scoped
token is a narrower caller than its owner. Reading the owner's permissions
let a token scoped to endpoints.command queue a remote restart, because the
elevated check asked the owner rather than the token - the one bypass
require_permission exists to prevent, reintroduced by a route that did its
own check.
"""
user = currentuser()
if user is None:
return guestholds(permission_name)
try:
return bool(user.haspermission(permission_name))
if not user.haspermission(permission_name):
return False
patscopes = get_jwt().get('patscopes')
except Exception:
return False
if patscopes is not None:
return permission_name in patscopes
return True
def require_permission(permission_name: str):

70
shopdb/utils/badges.py Normal file
View File

@@ -0,0 +1,70 @@
"""What a scanned badge looks like, in one place.
Two kiosks read the same physical badges and disagreed about them. The parts
kiosk understood both shapes a scanner emits; the USB kiosk took the string
raw and compared it to an SSO, so the wrapped form was simply "not recognized".
An operator whose keyboard-wedge scanner emits `0270015376BZ` could take a
3D-printed part and could not check out a drive - with the same badge, at two
cabinets a few metres apart.
The shapes:
270015376 the SSO as printed, what a keypad entry looks like
0270015376BZ the PayNo wrapper some scanners emit around it
Both mean the same person. Nothing else is a badge - and in particular a string
of NON-ASCII digits is not, however much it looks like one: Arabic-Indic digits
pass Python's str.isdigit(), and a badge written in them was accepted, stored as
the employee's SSO, and joined to nothing afterwards.
This lives in core rather than in either plugin because a plugin may not import
another plugin, and because a badge is a property of the site, not of a
cabinet.
"""
import re
# The PayNo wrapper: a leading zero, the digits, and a BZ suffix.
PAYNO_BADGE = re.compile(r'^0(\d+)BZ$', re.IGNORECASE)
# ASCII digits only. str.isdigit() is true for Arabic-Indic and other decimal
# forms, and int() happily parses them, so a badge in those characters resolved
# to a real employee and was then written back in a form nothing else matches.
ASCII_DIGITS = re.compile(r'^[0-9]+$')
class BadgeShapeError(ValueError):
"""The scanned string is not a badge this site recognises."""
def normalise_badge(raw):
"""Return the plain SSO digits for a scanned badge.
Accepts the bare number and the PayNo wrapper, and raises BadgeShapeError
for anything else. The caller decides what to do about a badge that is
well-formed but matches no employee - that is a directory question, not a
shape one.
"""
badge = (raw or '').strip()
if not badge:
raise BadgeShapeError('Scan or enter a badge')
if ASCII_DIGITS.match(badge):
return badge
match = PAYNO_BADGE.match(badge)
if match and ASCII_DIGITS.match(match.group(1)):
return match.group(1)
raise BadgeShapeError('Unrecognized badge format')
def badge_kind(raw):
"""'sso' or 'payno' for a recognised badge; raises for anything else.
The external HR lookup searches a different column for each, so the shape
survives normalisation for that one caller.
"""
badge = (raw or '').strip()
if ASCII_DIGITS.match(badge):
return 'sso'
if PAYNO_BADGE.match(badge):
return 'payno'
raise BadgeShapeError('Unrecognized badge format')

View File

@@ -0,0 +1,82 @@
"""Parsing a date out of a request body, strictly.
WHY THIS EXISTS
Two plugins carried an identical private `_parse_date` that swallowed the
error:
try:
return datetime.strptime(str(value)[:10], '%Y-%m-%d').date()
except (ValueError, TypeError):
return None
So an unparseable date and a deliberately cleared one produced the same
result, and the write went ahead. `PUT /api/warranty/<id>` with enddate
'not-a-date' answered 200 "Warranty updated" and stored NULL - the warranty
then vanished from every expiry surface, silently, on the strength of a typo.
The measuring-tool calibration date did the same thing and dropped the tool out
of the calibration report.
The rule here: an EXPLICIT empty value clears the field, and anything that is
not a date is refused. Those are different intents and they get different
answers. Callers turn `DateValueError` into a 400 naming the field, so the
person who mistyped it finds out at the point of the mistake rather than at the
next audit.
Accepted forms are deliberately narrow - 'YYYY-MM-DD', and the ISO-8601
datetime forms whose leading ten characters are a date, since that is what the
frontend's date inputs and every vendor API in the tree emit. Anything looser
is a guess about what somebody meant.
"""
from datetime import date, datetime
class DateValueError(ValueError):
"""A supplied value was neither empty nor a date this accepts.
Carries the field name so the error message can say which one, which is the
whole point of refusing rather than nulling.
"""
def __init__(self, field, value):
self.field = field
self.value = value
super().__init__(
'%s must be a date as YYYY-MM-DD (got %r)' % (field, value))
def parse_date(value, field='date'):
"""Return a date, or None for an explicitly empty value.
Raises DateValueError for anything else. None and '' mean "clear it";
they are the only values that produce None.
"""
if value is None:
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
if not isinstance(value, str):
# A number or a list is not a mistyped date, it is the wrong type.
raise DateValueError(field, value)
text = value.strip()
if not text:
return None
try:
return datetime.strptime(text[:10], '%Y-%m-%d').date()
except ValueError:
raise DateValueError(field, value)
def parse_date_fields(data, fields):
"""Parse several date fields out of a payload at once.
Only keys PRESENT in the payload are returned, so a caller can tell "not
mentioned" (leave it alone) from "sent as empty" (clear it) - a distinction
a plain `data.get(...)` throws away, and the reason the alias-clearing bug
existed on the PC form.
"""
return {field: parse_date(data[field], field)
for field in fields if field in data}

64
shopdb/utils/queryargs.py Normal file
View File

@@ -0,0 +1,64 @@
"""Reading a typed value out of the query string, without a 500.
WHY THIS EXISTS
List endpoints filtered like this:
if type_id := request.args.get('typeid'):
query = query.filter(Machine.machinetypeid == int(type_id))
`int('abc')` raises, nothing catches it, and the caller gets
500 INTERNAL_ERROR with a traceback in the log. A wrong query argument is the
caller's mistake and deserves a 400 saying which argument; a 500 says the server
is broken and sends whoever is reading the log looking for a fault that is not
there. The feature test run found the same shape in six places across five
plugins, which makes it a property of how list routes are written rather than of
any one route.
The helpers raise QueryArgError. Routes turn it into a 400 - or, more usually,
nothing does anything at all, because the app-level handler in
shopdb/__init__.py answers it. That is the point: a route should not have to
remember.
Deliberately NOT the same policy as get_pagination_params, which falls back to
a default for a bad page number. A nonsense page is harmless and the caller
still wants their list; a nonsense FILTER is not, because silently ignoring it
returns rows the caller believes they excluded.
"""
from flask import request
class QueryArgError(ValueError):
"""A query argument was present but not of the type the route needs."""
def __init__(self, name, value, expected='a whole number'):
self.name = name
self.value = value
super().__init__('%s must be %s (got %r)' % (name, expected, value))
def intarg(name, default=None, request_obj=None):
"""Read an integer query argument.
Absent or empty -> default. Present but not an integer -> QueryArgError,
which the app answers as a 400 naming the argument.
"""
source = request_obj if request_obj is not None else request
raw = source.args.get(name)
if raw is None or raw == '':
return default
try:
return int(raw)
except (TypeError, ValueError):
raise QueryArgError(name, raw)
def intargs(*names, request_obj=None):
"""Read several integer arguments at once; returns a dict of those present."""
found = {}
for name in names:
value = intarg(name, request_obj=request_obj)
if value is not None:
found[name] = value
return found