One badge vocabulary, and images a browser can actually load
Two operator-visible defects and three doc corrections, closing the tail of the 2026-09-03 run. THE BADGE. The two kiosks read the same physical badges and disagreed. The parts kiosk understood both shapes a scanner emits - the bare SSO and the PayNo wrapper 0<digits>BZ - while the USB kiosk compared the raw string to an SSO, so the wrapper resolved to nobody. An operator whose keyboard-wedge scanner emits it 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 shape now lives in shopdb/utils/badges.py on the contract surface, because a plugin may not import another plugin and because a badge is a property of the site rather than of a cabinet. Both kiosks use it, and both store the DIGITS rather than the string that arrived: one row per spelling makes a custody history that cannot be joined to itself. That closed a subtler hole in the same place. str.isdigit() is true for Arabic-Indic and other decimal forms, and int() parses them, so a badge written in those characters was accepted, matched a REAL employee, and was then stored as an SSO string nothing else matches. ASCII digits only. An empty scan and an unreadable one now get different answers, because they are different problems for the person at the cabinet. THE IMAGES. /api/applications/image/<filename> and /api/models/image/<filename> required a permission, and a browser fetching <img src="..."> sends no Authorization header - so the gate did not protect the file, it guaranteed the picture never appeared. Both are ungated now, like the four sibling image routes (branding logo, map blueprint, employee photo, part picture), and registered in PUBLIC_ENDPOINTS with that reasoning. /api/printers/lookup?fqdn= never matched anything: both arguments were compared against Communication.ipaddress, so a hostname lookup 404d on printers whose hostname was on the record. An FQDN now matches the hostname, with a fallback to the short name. The slide manager gained the per-slide hold time. The PATCH route and the feed have always carried `seconds`; nothing called it, so the one documented per-slide control was curl-only. Docs: API-REFERENCE listed the employee lookups and the whole notifications surface as unauthenticated when they answer 401, and called both kiosks "deliberately open" when they are off by default and refuse anything without the site's device token. The installer download says applications.download, which is what the code requires. api-inventory.json said printers/lookup was jwt-optional. Fourteen tests. The badge rule verified by planting the old per-kiosk behaviour: three fail, one in each kiosk. Scope: product
This commit is contained in:
@@ -109,7 +109,7 @@ carries value or liability of its own:
|
||||
|
||||
| Endpoint | Requires | Why |
|
||||
|---|---|---|
|
||||
| `GET /api/applications/package/<filename>` | `applications.view` | Licensed vendor installers. An anonymous URL is a redistribution channel. |
|
||||
| `GET /api/applications/package/<filename>` | `applications.download` | Licensed vendor installers. A separate permission from `applications.view` on purpose: seeing that an application exists is not the same as being handed the vendor's installer, and an anonymous URL is a redistribution channel. The script-body download is gated the same way. |
|
||||
| `GET /api/warranty/proof/<filename>` | `warranty.view` | Invoices and certificates carry pricing and a service tag. |
|
||||
|
||||
Their sibling upload/delete routes are `applications.edit` / `warranty.edit`
|
||||
@@ -135,14 +135,23 @@ like any other mutation.
|
||||
| `GET /api/plugins/enabled` | List enabled plugins (no claims used). |
|
||||
| `GET /api/dashboarddefaults/display-role` | Display role resolution (see section 1). |
|
||||
| `GET /api/dashboarddefaults/visitor-location` | Lobby business-unit resolution (see section 1). |
|
||||
| `GET /api/employees/search`, `/lookup/<sso>`, `/lookup` | Employee directory lookups (kiosk / sign-in flows). |
|
||||
| ~~`GET /api/employees/search`, `/lookup/<sso>`, `/lookup`~~ | NOT public. These need `employees.view` and answer 401 anonymously. A kiosk resolves a badge through its own gated endpoint, not through these. |
|
||||
| `GET /api/employees/photo/<filename>` | Serve an employee photo. |
|
||||
| `GET /api/notifications` (and `/types`, `/<id>`, `/active`, `/calendar`, `/dashboard/summary`, `/employee/<sso>`, `/shopfloor`) | Read shop-floor notifications for the public display and kiosks. |
|
||||
| `GET /api/notifications/shopfloor`, `GET /api/notifications/employee/<sso>` | The two the public display and kiosks actually read. The rest of `/api/notifications` (the list, `/types`, `/<id>`, `/active`, `/calendar`, `/dashboard/summary`) needs `notifications.view` and answers 401 anonymously. |
|
||||
| `GET /api/slides/feed` | Slide feed for the lobby display and screensaver. |
|
||||
| `GET /api/slides/img/<surface>/<filename>` | Serve a slide image. |
|
||||
| `GET /api/printedparts/image/<filename>` | Serve a printed-part image. |
|
||||
| `GET /api/printedparts/kiosk/item/<itemcode>` | Kiosk part lookup (deliberately open; a kiosk carries no JWT). |
|
||||
| `POST /api/printedparts/kiosk/take` | Kiosk part checkout (deliberately open, per decision record). |
|
||||
| `GET /api/printedparts/kiosk/item/<itemcode>` | Kiosk part lookup. Carries no JWT, but is NOT open: the routes 404 unless `printedparts_kiosk_enabled` is on AND the request carries the site's `X-Kiosk-Token`. |
|
||||
| `POST /api/printedparts/kiosk/take` | Kiosk part checkout, behind the same flag and device token. |
|
||||
| `GET /api/usb/kiosk/device/<device_id>` | USB kiosk device lookup, behind `usb_kiosk_enabled` plus `X-Kiosk-Token`. |
|
||||
| `POST /api/usb/kiosk/checkout`, `/checkin` | USB kiosk custody moves, same gate. |
|
||||
|
||||
**"No authentication" is not "open."** Both kiosks are off by default and refuse
|
||||
every request without the site's device token; with the flag off they answer 404
|
||||
rather than 403, so a kiosk a site has not turned on does not announce itself.
|
||||
The token is a credential for a screen, not authorization for a person: it says
|
||||
which cabinet is asking, and the badge on the request says who is standing at
|
||||
it.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3235,8 +3235,8 @@
|
||||
{
|
||||
"method": "GET",
|
||||
"path": "/api/printers/lookup",
|
||||
"auth": "jwt-optional",
|
||||
"params": "ip or fqdn (one required; value matched against communication ipaddress)",
|
||||
"auth": "printers.view",
|
||||
"params": "ip or fqdn (one required; ip matches a communication address, fqdn matches the printer's hostname)",
|
||||
"purpose": "Find a printer by IP/FQDN (parity with classic printerlookup.asp; used by Zabbix to deep-link to a printer record).",
|
||||
"example": "curl 'http://localhost:5001/api/printers/lookup?ip=10.1.2.42'"
|
||||
},
|
||||
@@ -4215,4 +4215,4 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
@@ -1306,6 +1306,11 @@ export const slidesApi = {
|
||||
},
|
||||
remove(surface, files) {
|
||||
return api.post(`/slides/${surface}/delete`, { files })
|
||||
},
|
||||
// Per-slide hold time. The route has always existed; nothing called it, so
|
||||
// the documented per-slide seconds could only be set with curl.
|
||||
update(surface, slideid, data) {
|
||||
return api.patch(`/slides/${surface}/${slideid}`, data)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,16 +36,24 @@ class BadgeError(ValueError):
|
||||
|
||||
|
||||
def _parse_badge(badge):
|
||||
"""Return ('sso'|'payno', digits) or raise BadgeError on unknown shape."""
|
||||
badge = (badge or '').strip()
|
||||
if not badge:
|
||||
raise BadgeError('Scan or enter a badge')
|
||||
if badge.isdigit():
|
||||
return 'sso', badge
|
||||
match = _PAYNO_BADGE.match(badge)
|
||||
if match:
|
||||
return 'payno', match.group(1)
|
||||
raise BadgeError('Unrecognized badge format')
|
||||
"""Return ('sso'|'payno', digits) or raise BadgeError on unknown shape.
|
||||
|
||||
The shapes themselves live in shopdb.api (normalise_badge), because the USB
|
||||
kiosk reads the same physical badges and used to disagree: it took the
|
||||
string raw, so the PayNo wrapper resolved to nobody and an operator whose
|
||||
scanner emits it could take a part and not check out a drive.
|
||||
|
||||
That also closed a subtler hole. This used str.isdigit(), which is true for
|
||||
Arabic-Indic and other decimal forms, so a badge written in them was
|
||||
accepted, resolved against int() to a REAL employee, and then stored as an
|
||||
SSO string that joins to nothing.
|
||||
"""
|
||||
from shopdb.api import BadgeShapeError, badge_kind, normalise_badge
|
||||
|
||||
try:
|
||||
return badge_kind(badge), normalise_badge(badge)
|
||||
except BadgeShapeError as bad:
|
||||
raise BadgeError(str(bad))
|
||||
|
||||
|
||||
def _selfhosted_lookup(digits):
|
||||
|
||||
@@ -2329,15 +2329,28 @@ def printer_lookup():
|
||||
'Provide ip or fqdn'
|
||||
)
|
||||
|
||||
# match the IP against any active printer communication
|
||||
row = (
|
||||
# An IP matches a communication row; an FQDN matches the printer's
|
||||
# hostname. Both were compared against Communication.ipaddress, so
|
||||
# ?fqdn=<hostname> never found anything - it 404d on printers whose
|
||||
# hostname was right there on the record. Zabbix uses this to jump to a
|
||||
# printer, and it knows names as often as addresses.
|
||||
query = (
|
||||
db.session.query(Printer, Asset)
|
||||
.join(Asset, Asset.assetid == Printer.assetid)
|
||||
.join(Communication, Communication.assetid == Asset.assetid)
|
||||
.filter(Asset.isactive == True)
|
||||
.filter(Communication.ipaddress == lookup_value)
|
||||
.first()
|
||||
)
|
||||
if ip:
|
||||
row = (query
|
||||
.join(Communication, Communication.assetid == Asset.assetid)
|
||||
.filter(Communication.ipaddress == ip)
|
||||
.first())
|
||||
else:
|
||||
row = query.filter(Printer.hostname == fqdn).first()
|
||||
if row is None:
|
||||
# A caller holding a fully-qualified name against a site that
|
||||
# stores the short one, or the reverse.
|
||||
shortname = fqdn.split('.', 1)[0]
|
||||
row = query.filter(Printer.hostname == shortname).first()
|
||||
|
||||
if not row:
|
||||
return error_response(
|
||||
|
||||
@@ -47,6 +47,15 @@
|
||||
<img :src="withBase(slide.url)" :alt="slide.filename" class="thumb"
|
||||
@mouseenter="previewSlide = slide" @mouseleave="previewSlide = null" />
|
||||
<span class="fname" :title="slide.filename">{{ slide.filename }}</span>
|
||||
<!-- Per-slide hold time. The API and the feed have always carried
|
||||
`seconds`, and the manager offered no way to set it, so the one
|
||||
documented per-slide control was API-only. 0 means "use the
|
||||
default interval", which is what the feed already does. -->
|
||||
<label class="seconds" title="Seconds to hold this slide (0 = default)">
|
||||
<input type="number" min="0" max="600" :value="slide.seconds"
|
||||
@change="setSeconds(slide, $event.target.value)" />
|
||||
<span class="seconds-unit">s</span>
|
||||
</label>
|
||||
<div class="move">
|
||||
<button class="btn btn-sm btn-secondary" :disabled="idx === 0" @click="move(idx, -1)" title="Move up">↑</button>
|
||||
<button class="btn btn-sm btn-secondary" :disabled="idx === slides.length - 1" @click="move(idx, 1)" title="Move down">↓</button>
|
||||
@@ -73,6 +82,8 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { slidesApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
|
||||
// Slide currently hovered, shown enlarged. Null when the cursor is elsewhere.
|
||||
const previewSlide = ref(null)
|
||||
@@ -133,6 +144,7 @@ const surfaces = [
|
||||
{ key: 'shopfloor', label: 'Shopfloor Screensaver' }
|
||||
]
|
||||
const surface = ref('lobby')
|
||||
const toast = useToast()
|
||||
const slides = ref([])
|
||||
const selected = ref([])
|
||||
const loading = ref(true)
|
||||
@@ -176,6 +188,22 @@ async function onUpload(event) {
|
||||
}
|
||||
}
|
||||
|
||||
async function setSeconds(slide, raw) {
|
||||
const seconds = Math.max(0, Math.min(600, Number(raw) || 0))
|
||||
if (seconds === slide.seconds) return
|
||||
try {
|
||||
await slidesApi.update(surface.value, slide.slideid, { seconds })
|
||||
slide.seconds = seconds
|
||||
toast.success(seconds
|
||||
? `${slide.filename} holds for ${seconds}s`
|
||||
: `${slide.filename} uses the default interval`)
|
||||
} catch (err) {
|
||||
console.error('Error setting slide seconds:', err)
|
||||
toast.error(apiError(err, 'Could not set the hold time'))
|
||||
await load()
|
||||
}
|
||||
}
|
||||
|
||||
async function move(idx, delta) {
|
||||
const target = idx + delta
|
||||
if (target < 0 || target >= slides.value.length) return
|
||||
@@ -197,6 +225,17 @@ onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.seconds {
|
||||
align-items: center;
|
||||
display: inline-flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
.seconds input {
|
||||
width: 4.5rem;
|
||||
}
|
||||
.seconds-unit {
|
||||
color: var(--text-light);
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
|
||||
@@ -33,18 +33,56 @@ _FILTER = {'available': STATUS_CHECKED_IN, 'checkedout': STATUS_CHECKED_OUT, 're
|
||||
|
||||
|
||||
def _resolve_name(sso):
|
||||
"""Best-effort name from the self-hosted employee directory."""
|
||||
"""Best-effort name from the self-hosted employee directory.
|
||||
|
||||
The badge goes through the shared shape rule first. This compared the raw
|
||||
string, so the PayNo wrapper some scanners emit (0<digits>BZ) resolved to
|
||||
nobody and the kiosk answered "Badge not recognized - see IT" - for a badge
|
||||
the parts kiosk a few metres away accepted. It also used str.isdigit(),
|
||||
true for Arabic-Indic digits, so a badge in those characters reached int()
|
||||
and matched a real employee.
|
||||
"""
|
||||
from shopdb.api import BadgeShapeError, normalise_badge
|
||||
|
||||
try:
|
||||
digits = normalise_badge(sso)
|
||||
except BadgeShapeError:
|
||||
return None
|
||||
try:
|
||||
from plugins.employees.models import DirectoryEmployee
|
||||
if sso and str(sso).isdigit():
|
||||
emp = db.session.get(DirectoryEmployee, int(sso))
|
||||
if emp:
|
||||
return f'{emp.firstname} {emp.lastname}'.strip()
|
||||
emp = db.session.get(DirectoryEmployee, int(digits))
|
||||
if emp:
|
||||
return f'{emp.firstname} {emp.lastname}'.strip()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _kiosk_badge_digits(raw):
|
||||
"""Return (digits, errorresponse) for a scanned badge.
|
||||
|
||||
Every kiosk write stores the DIGITS rather than the string that arrived:
|
||||
the PayNo wrapper (0<digits>BZ) and the bare number are the same person,
|
||||
and recording one row under each spelling makes a custody history that
|
||||
cannot be joined to itself.
|
||||
|
||||
An empty scan and an unreadable one get different answers, because they
|
||||
are different problems for the person standing at the cabinet: one has not
|
||||
scanned yet, the other has scanned something this site does not know.
|
||||
"""
|
||||
from shopdb.api import BadgeShapeError, normalise_badge
|
||||
|
||||
if not (raw or '').strip():
|
||||
return None, error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'Scan your badge')
|
||||
try:
|
||||
return normalise_badge(raw), None
|
||||
except BadgeShapeError:
|
||||
return None, error_response(
|
||||
ErrorCodes.VALIDATION_ERROR,
|
||||
'Badge not recognized - see IT', http_code=422)
|
||||
|
||||
|
||||
def _status(device):
|
||||
if not device.isactive:
|
||||
return STATUS_RETIRED
|
||||
@@ -307,9 +345,9 @@ def kiosk_device(device_id):
|
||||
|
||||
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')
|
||||
badge, badgeerror = _kiosk_badge_digits(data.get('badge'))
|
||||
if badgeerror:
|
||||
return badgeerror
|
||||
name = _resolve_name(badge)
|
||||
if not name:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
@@ -335,9 +373,9 @@ def kiosk_session(data):
|
||||
|
||||
|
||||
def kiosk_others(data):
|
||||
badge = (data.get('badge') or '').strip()
|
||||
if not badge:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, 'Scan your badge')
|
||||
badge, badgeerror = _kiosk_badge_digits(data.get('badge'))
|
||||
if badgeerror:
|
||||
return badgeerror
|
||||
if not _resolve_name(badge):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'Badge not recognized - see IT', http_code=422)
|
||||
@@ -358,9 +396,9 @@ def kiosk_others(data):
|
||||
|
||||
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')
|
||||
badge, badgeerror = _kiosk_badge_digits(data.get('badge'))
|
||||
if badgeerror:
|
||||
return badgeerror
|
||||
|
||||
device = _find(device_id)
|
||||
if not device:
|
||||
@@ -424,9 +462,9 @@ def kiosk_checkout(data):
|
||||
|
||||
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')
|
||||
badge, badgeerror = _kiosk_badge_digits(data.get('badge'))
|
||||
if badgeerror:
|
||||
return badgeerror
|
||||
forsomeoneelse = bool(data.get('forsomeoneelse'))
|
||||
|
||||
device = _find(device_id)
|
||||
|
||||
@@ -114,6 +114,9 @@ from shopdb.utils.clientip import client_ip, strip_port
|
||||
# a date needs it and the two that rolled their own both swallowed the error.
|
||||
from shopdb.utils.queryargs import QueryArgError, intarg, intargs
|
||||
|
||||
# One badge vocabulary for the site: both kiosks read the same physical badges.
|
||||
from shopdb.utils.badges import BadgeShapeError, badge_kind, normalise_badge
|
||||
|
||||
from shopdb.utils.datevalues import (
|
||||
DateValueError,
|
||||
parse_date,
|
||||
@@ -362,6 +365,9 @@ __all__ = [
|
||||
'error_response',
|
||||
'DateValueError',
|
||||
'QueryArgError',
|
||||
'BadgeShapeError',
|
||||
'badge_kind',
|
||||
'normalise_badge',
|
||||
'intarg',
|
||||
'intargs',
|
||||
'parse_date',
|
||||
|
||||
@@ -578,9 +578,20 @@ def upload_application_image(app_id: int):
|
||||
|
||||
|
||||
@applications_bp.route('/image/<path:filename>', methods=['GET'])
|
||||
@require_permission('applications.view')
|
||||
def serve_application_image(filename):
|
||||
"""Serve an uploaded application image (public - lists and tiles read it)."""
|
||||
"""Serve an uploaded application image.
|
||||
|
||||
Ungated, like every other image route in the product (the branding logo,
|
||||
the map blueprint, an employee photo, a printed-part picture). A browser
|
||||
fetching <img src="/api/applications/image/application-3.png"> sends no
|
||||
Authorization header, so a permission check here does not protect the file
|
||||
- it just guarantees the picture never appears. The tile fell back to its
|
||||
broken-image handler on every page, including for an admin.
|
||||
|
||||
What is exposed is an application icon to somebody who already knows the
|
||||
filename, which is the same posture as its four siblings. Registered in
|
||||
tests/test_core/test_public_read_surface.py with that reasoning.
|
||||
"""
|
||||
return send_upload(_appimage_dir(), filename)
|
||||
|
||||
|
||||
|
||||
@@ -235,7 +235,6 @@ def upload_model_image(model_id: int):
|
||||
|
||||
|
||||
@models_bp.route('/image/<path:filename>', methods=['GET'])
|
||||
@require_permission('assets.view')
|
||||
def serve_model_image(filename):
|
||||
"""Serve an uploaded model image (public - asset detail pages read it)."""
|
||||
return send_upload(_modelimage_dir(), filename)
|
||||
|
||||
70
shopdb/utils/badges.py
Normal file
70
shopdb/utils/badges.py
Normal 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')
|
||||
@@ -44,6 +44,12 @@ PUBLIC_ENDPOINTS = {
|
||||
'settings.serve_branding_logo',
|
||||
'settings.serve_map_blueprint',
|
||||
'employees.serve_employee_photo',
|
||||
'applications.serve_application_image',
|
||||
# Same reasoning: a machine detail page renders the catalog model's photo
|
||||
# with <img :src="withBase(imageurl)">, which sends no Authorization
|
||||
# header. A gate here does not protect the file, it only guarantees the
|
||||
# photo never appears.
|
||||
'models.serve_model_image',
|
||||
|
||||
# API documentation: shapes, not data. The docs page loads its own script.
|
||||
'docs.docs_index', 'docs.openapi_spec', 'docs.redoc_js', 'docs.llms_txt',
|
||||
|
||||
@@ -396,3 +396,44 @@ def test_a_check_in_closes_every_open_row_for_that_device(client, kiosk, db):
|
||||
|
||||
assert _in(client, badge='270015376').status_code == 200
|
||||
assert _open_checkouts(USBDevice) == []
|
||||
|
||||
|
||||
# --- one badge vocabulary for the whole site --------------------------------
|
||||
#
|
||||
# The parts kiosk understood both shapes a scanner emits; this one took the
|
||||
# string raw and compared it to an SSO. So an operator whose keyboard-wedge
|
||||
# scanner emits the PayNo wrapper 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 same change closed a subtler hole: str.isdigit() is true for Arabic-Indic
|
||||
# digits, so a badge written in them resolved through int() to a REAL employee
|
||||
# and was then stored as an SSO string that joins to nothing.
|
||||
|
||||
def test_the_payno_wrapper_works_at_this_kiosk_too(client, kiosk):
|
||||
from plugins.usb.models import USBCheckout
|
||||
|
||||
response = _out(client, badge='0270015376BZ')
|
||||
assert response.status_code == 200, response.get_json()
|
||||
|
||||
row = USBCheckout.query.order_by(USBCheckout.checkoutid.desc()).first()
|
||||
assert row.sso == '270015376', \
|
||||
'the custody row records the SSO, not the wrapper it arrived in'
|
||||
assert 'Dana' in (row.checkoutname or '')
|
||||
|
||||
|
||||
def test_a_badge_in_non_ascii_digits_is_refused(client, kiosk):
|
||||
"""It looks like a number to str.isdigit() and parses through int(), so it
|
||||
resolved to a real employee under an SSO nothing else matches."""
|
||||
# Written as escapes so this file stays ASCII, which the style gate
|
||||
# enforces: these are Arabic-Indic digits, U+0669 and friends.
|
||||
arabic_indic = '\u0669\u0667\u0660\u0660\u0660\u0667\u0661'
|
||||
response = _out(client, badge=arabic_indic)
|
||||
assert response.status_code == 422, response.get_json()
|
||||
|
||||
|
||||
def test_the_same_person_scanning_either_shape_is_one_holder(client, kiosk):
|
||||
"""Wrapper out, bare number back: the check-in has to recognise it as the
|
||||
same person, or the drive can never be returned by its holder."""
|
||||
assert _out(client, badge='0270015376BZ').status_code == 200
|
||||
returned = _in(client, badge='270015376')
|
||||
assert returned.status_code == 200, returned.get_json()
|
||||
|
||||
Reference in New Issue
Block a user