Merge remote-tracking branch 'origin/wj' into wilm
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / gates (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / lean-build (push) Has been cancelled

This commit is contained in:
benjamin.baker
2026-09-03 13:46:17 -04:00
44 changed files with 1984 additions and 129 deletions

View File

@@ -76,18 +76,18 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
# Connect to a persistent, out-of-cluster BuildKit deployment instead of
# spinning up a builder against a local Docker daemon. The arc-runner-set
# pods have no Docker daemon (no /var/run/docker.sock) and cannot run a
# privileged DinD sidecar under cluster policy, so we point Buildx at a
# remote buildkitd Service. The endpoint is provided as a repo/org secret
# (e.g. tcp://buildkit.buildkit.svc.cluster.local:1234), so it is not
# hardcoded here. See deploy/ci/buildkit-remote.yaml for the deployment.
- name: Set up Buildx (remote BuildKit)
uses: docker/setup-buildx-action@v3
with:
driver: remote
endpoint: ${{ secrets.BUILDKIT_ENDPOINT }}
# Connect to a persistent, out-of-cluster BuildKit deployment instead of
# spinning up a builder against a local Docker daemon. The arc-runner-set
# pods have no Docker daemon (no /var/run/docker.sock) and cannot run a
# privileged DinD sidecar under cluster policy, so we point Buildx at a
# remote buildkitd Service. The endpoint is provided as a repo/org secret
# (e.g. tcp://buildkit.buildkit.svc.cluster.local:1234), so it is not
# hardcoded here. See deploy/ci/buildkit-remote.yaml for the deployment.
- name: Set up Buildx (remote BuildKit)
uses: docker/setup-buildx-action@v3
with:
driver: remote
endpoint: ${{ secrets.BUILDKIT_ENDPOINT }}
- name: Build and push channel image
uses: docker/build-push-action@v6

View File

@@ -1973,6 +1973,194 @@
}
}
},
"/api/settings/asset-import/winc/preview": {
"post": {
"tags": [
"core-identity"
],
"summary": "Dry run a WINC machine-adoption import. Writes nothing; returns the plan, a summary, the needs-create groups an admin...",
"description": "Dry run a WINC machine-adoption import. Writes nothing; returns the plan, a summary, the needs-create groups an admin must acknowledge, and the planhash that authorises the matching commit\n\n**Auth:** jwt + require_role admin\n\n**Params:** multipart/form-data: files (one WINC/Maximo CSV export)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/settings/asset-import/winc/preview -H \"Authorization: Bearer $TOK\" -F files=@winc.csv\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "multipart/form-data: files (one WINC/Maximo CSV export)"
}
}
}
}
}
},
"/api/settings/asset-import/winc/commit": {
"post": {
"tags": [
"core-identity"
],
"summary": "Apply a previewed WINC plan in one transaction, journalled so it can be undone. planhash is required and must match a...",
"description": "Apply a previewed WINC plan in one transaction, journalled so it can be undone. planhash is required and must match a freshly rebuilt plan: without it the drift check compared a packet against the plan it was built from and could never fail, so an admin could approve one plan and apply another\n\n**Auth:** jwt + require_role admin\n\n**Params:** multipart/form-data: files (the same export); form: planhash (REQUIRED, from the preview), acknowledge (comma-separated needs-create group names)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/settings/asset-import/winc/commit -H \"Authorization: Bearer $TOK\" -F files=@winc.csv -F planhash=$HASH -F acknowledge=businessunit\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "multipart/form-data: files (the same export); form: planhash (REQUIRED, from the preview), acknowledge (comma-separated needs-create group names)"
}
}
}
}
}
},
"/api/settings/asset-import/winc/batches": {
"get": {
"tags": [
"core-identity"
],
"summary": "List applied and undone import batches, newest first, with kind, row count, who applied it and when",
"description": "List applied and undone import batches, newest first, with kind, row count, who applied it and when\n\n**Auth:** jwt + require_role admin\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/settings/asset-import/winc/batches -H \"Authorization: Bearer $TOK\"\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
}
}
},
"/api/settings/asset-import/winc/undo": {
"post": {
"tags": [
"core-identity"
],
"summary": "Reverse an applied batch by replaying its journal backwards. Conservative: a cell is put back only if it still holds...",
"description": "Reverse an applied batch by replaying its journal backwards. Conservative: a cell is put back only if it still holds what the import wrote, and a created row is deleted only when nothing outside the batch references it\n\n**Auth:** jwt + require_role admin\n\n**Params:** body: batchid (required)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/settings/asset-import/winc/undo -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"batchid\":\"...\"}'\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
}
},
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "body: batchid (required)"
}
}
}
}
}
},
"/api/reports": {
"get": {
"tags": [
@@ -23905,6 +24093,307 @@
}
}
}
},
"/api/endpoints/siteagent/binaries/{binkey}": {
"get": {
"tags": [
"endpoints-siteagent"
],
"summary": "List the staged versions of one agent binary, newest first, with the current one marked",
"description": "List the staged versions of one agent binary, newest first, with the current one marked\n\n**Auth:** jwt + require_permission endpoints.agentpackage\n\n**Params:** path: binkey (agent|tray)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/siteagent/binaries/agent -H \"Authorization: Bearer $TOK\"\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "binkey",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
]
},
"post": {
"tags": [
"endpoints-siteagent"
],
"summary": "Stage an uploaded binary as a new version. This supplies the executable every future package installs and runs as...",
"description": "Stage an uploaded binary as a new version. This supplies the executable every future package installs and runs as SYSTEM on the fleet, so it is audit-logged with the stored file's sha256\n\n**Auth:** jwt + require_permission endpoints.agentpackage\n\n**Params:** path: binkey (agent|tray); multipart/form-data: file (a Windows PE, 64 MB cap), version (strict dotted-numeric), makecurrent (default true)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/siteagent/binaries/agent -H \"Authorization: Bearer $TOK\" -F file=@shopdbagent.exe -F version=1.6.3\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "binkey",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "path: binkey (agent|tray); multipart/form-data: file (a Windows PE, 64 MB cap), version (strict dotted-numeric), makecurrent (default true)"
}
}
}
}
}
},
"/api/endpoints/siteagent/binaries/{binkey}/detect": {
"post": {
"tags": [
"endpoints-siteagent"
],
"summary": "Read the version out of an uploaded PE's resource table so the upload form can prefill it. Stores nothing",
"description": "Read the version out of an uploaded PE's resource table so the upload form can prefill it. Stores nothing\n\n**Auth:** jwt + require_permission endpoints.agentpackage\n\n**Params:** path: binkey; multipart/form-data: file\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/siteagent/binaries/agent/detect -H \"Authorization: Bearer $TOK\" -F file=@shopdbagent.exe\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "binkey",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "path: binkey; multipart/form-data: file"
}
}
}
}
}
},
"/api/endpoints/siteagent/binaries/{binkey}/adopt": {
"post": {
"tags": [
"endpoints-siteagent"
],
"summary": "Adopt a binary already staged on disk into the version manifest, for a build that predates the store",
"description": "Adopt a binary already staged on disk into the version manifest, for a build that predates the store\n\n**Auth:** jwt + require_permission endpoints.agentpackage\n\n**Params:** path: binkey; body: version\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/siteagent/binaries/agent/adopt -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"version\":\"1.6.2\"}'\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "binkey",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "path: binkey; body: version"
}
}
}
}
}
},
"/api/endpoints/siteagent/binaries/{binkey}/current": {
"put": {
"tags": [
"endpoints-siteagent"
],
"summary": "Point the current staged build at another version, so a package rolls back to it. The retention sweep never prunes the...",
"description": "Point the current staged build at another version, so a package rolls back to it. The retention sweep never prunes the current one\n\n**Auth:** jwt + require_permission endpoints.agentpackage\n\n**Params:** path: binkey; body: version (must already be staged)\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/endpoints/siteagent/binaries/agent/current -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"version\":\"1.6.2\"}'\n```",
"security": [
{
"bearerAuth": []
}
],
"responses": {
"200": {
"description": "Success. Body is the success_response envelope: {status, data, meta}.",
"content": {
"application/json": {
"$ref": "#/components/schemas/SuccessEnvelope"
}
}
},
"default": {
"description": "Error. Body is the error envelope; the code and message are nested under data.error.",
"content": {
"application/json": {
"$ref": "#/components/schemas/ErrorEnvelope"
}
}
},
"401": {
"description": "Missing or invalid credentials."
},
"403": {
"description": "Authenticated, but not permitted."
},
"404": {
"description": "No such record."
}
},
"parameters": [
{
"name": "binkey",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true,
"description": "path: binkey; body: version (must already be staged)"
}
}
}
}
}
}
}
}

View File

@@ -11,6 +11,54 @@ const api = axios.create({
})
// Add auth token and normalize pagination params
// Kiosk device tokens. The kiosk routes are the only ones that write without a
// login, so each is additionally gated on a token an administrator provisioned
// into THAT screen's browser - see the kiosk views, which claim it from a
// ?token= URL once and strip it from the address bar. A token served inside the
// page would be readable by anyone who could load the page, so it is per-device
// and never shipped with the bundle.
//
// One entry per kiosk: the two are separate screens in separate rooms and a
// shared token would mean provisioning one grants the other.
export const KIOSK_TOKEN_KEYS = {
'/printedparts/kiosk/': 'printedparts-kiosk-token',
'/usb/kiosk/': 'usb-kiosk-token',
}
function kioskTokenFor(url) {
const path = url || ''
for (const [prefix, storageKey] of Object.entries(KIOSK_TOKEN_KEYS)) {
if (path.includes(prefix)) {
try { return localStorage.getItem(storageKey) || '' } catch { return '' }
}
}
return ''
}
// Claim a kiosk's device token, once, from the URL an admin used to set the
// screen up. Kept in THIS browser and stripped from the address bar straight
// away, so it does not sit there for a shoulder to read or travel in a bookmark
// or a screenshot.
export function claimKioskToken(storageKey) {
try {
const url = new URL(window.location.href)
const token = (url.searchParams.get('token') || '').trim()
if (!token) return
localStorage.setItem(storageKey, token)
url.searchParams.delete('token')
window.history.replaceState({}, '', url.pathname + url.search + url.hash)
} catch {
// A browser refusing localStorage cannot be provisioned. Nothing to
// recover; the routes 404 and the operator sees the standard error.
}
}
api.interceptors.request.use(config => {
const token = kioskTokenFor(config.url)
if (token) config.headers['X-Kiosk-Token'] = token
return config
})
api.interceptors.request.use(config => {
const token = localStorage.getItem('token')
if (token) {
@@ -1215,14 +1263,20 @@ export const settingsApi = {
headers: { 'Content-Type': 'multipart/form-data' }
})
},
wincCommit(file, acknowledge = []) {
wincCommit(file, acknowledge = [], planhash = '') {
// Apply a WINC plan reversibly. acknowledge is the list of needs-create
// group names (e.g. ['businessunit']) the admin confirmed.
// group names (e.g. ['businessunit']) the admin confirmed. planhash is the
// one the PREVIEW returned - the server rejects a commit without it,
// because otherwise "approved" means only that these group names were
// ticked, and the plan applied could be a different one.
const form = new FormData()
form.append('files', file)
if (acknowledge.length) {
form.append('acknowledge', acknowledge.join(','))
}
if (planhash) {
form.append('planhash', planhash)
}
return api.post('/settings/asset-import/winc/commit', form, {
headers: { 'Content-Type': 'multipart/form-data' }
})

View File

@@ -1323,13 +1323,33 @@ td.actions .btn + .btn {
gap: 0.75rem;
margin-top: 0.4rem;
}
/* A branding preview has to show a logo whose colour we do not control. The
shipped GE mark is fill="white" because it lives on the dark sidebar, so on a
plain white tile it rendered white-on-white and read as a failed load. A
checker ground makes any logo visible - light, dark or transparent - and is
the usual convention for previewing artwork with alpha. */
.map-thumb {
height: 40px;
min-width: 40px;
border: 1px solid var(--border);
border-radius: 4px;
background: #fff;
/* MID-TONE, not white and not near-white. The tile has to show artwork whose
colour we do not control: the shipped GE mark is fill="white" for the dark
sidebar, the monogram is fill="black". A light ground hides the first, a
dark ground hides the second, so the checker sits either side of mid grey
and both read against it. */
background-color: #9aa1ad;
background-image:
linear-gradient(45deg, #6f7783 25%, transparent 25%, transparent 75%, #6f7783 75%),
linear-gradient(45deg, #6f7783 25%, transparent 25%, transparent 75%, #6f7783 75%);
background-size: 12px 12px;
background-position: 0 0, 6px 6px;
padding: 2px;
}
.map-thumb-dark {
background-color: #222;
background-image: none;
}
.map-thumb-dark { background: #222; }
.settings-success {
margin-top: 1rem;
padding: 0.75rem;

View File

@@ -24,7 +24,7 @@
>
<div class="map-upload-row">
<input type="file" :accept="logo.accept" @change="uploadLogo(logo.kind, logo.key, $event)" :disabled="brandingUploading" />
<img v-if="settings[logo.key]" :src="settings[logo.key]" class="map-thumb" :alt="logo.label" />
<img v-if="settings[logo.key]" :src="withBase(settings[logo.key])" class="map-thumb" :alt="logo.label" />
</div>
<small class="input-hint">{{ logo.hint }}</small>
</label>
@@ -132,6 +132,12 @@
<script setup>
import { onMounted } from 'vue'
import { useSystemSettings, brandingLogos } from '../../composables/systemSettings'
// The stored value is root-relative ('/ge-aerospace-logo.svg'). This app is
// mounted under a base path at every real deployment (/shopdb, /ops), so a raw
// src resolves against the SERVER root and 404s - which is what "error loading
// image" was. siteSettings.js already wraps every logo this way for the rest of
// the app; this preview was the one place that did not.
import { withBase } from '@/utils/basePath'
const {
settings, saving, brandingUploading, error, success,

View File

@@ -26,6 +26,14 @@
<button class="btn btn-small" @click="startAddLevel(building)">Add level</button>
</div>
<!-- Scroll rather than squeeze: nine columns of inputs do not fit a
narrow window, and without a wrapper the table ran off the right of
the page with the cells crushed out of shape.
NOT the global .table-container - that also sets white-space: nowrap,
and the sizing hint below is a full sentence, so nowrap made the
table WIDER instead of letting the paragraph wrap. Scroll here, wrap
inside the cells. -->
<div class="level-table-scroll">
<table class="data-table">
<thead>
<tr>
@@ -46,7 +54,7 @@
<td>
<input
v-model="level.levelname"
class="form-control"
class="form-control level-name-input"
@blur="renameLevel(level)"
:disabled="saving"
/>
@@ -123,6 +131,7 @@
</tr>
</tbody>
</table>
</div>
</div>
<!-- Add building -->
@@ -437,6 +446,17 @@ async function remove(level) {
}
.mono { font-family: monospace; }
/* Wide enough that every column keeps a usable width; past this the wrapper
scrolls instead of the inputs collapsing to a few pixels. */
.level-table-scroll { overflow-x: auto; }
.data-table { min-width: 58rem; }
/* The sizing note is prose and must wrap; it is the reason the shared
table-container wrapper is wrong for this table. */
.data-table .input-hint { white-space: normal; display: block; max-width: 22rem; }
.data-table td, .data-table th { vertical-align: top; }
/* A bare form-control in a table cell collapses to whatever width is left over,
which put "Ground floor" in a box one character wide. */
.level-name-input { width: 11rem; }
.order-input { width: 4.5rem; }
.size-input { width: 6rem; }
.size-fields { display: flex; align-items: center; gap: 0.35rem; }

View File

@@ -269,7 +269,7 @@
below if needed.
</template>
<template v-else-if="wincReport.blocking">
{{ wincReport.rowcount }} row(s) in scope, but unresolved values
{{ wincReport.rowcount }} row(s) in scope, but the problems below
block the import - nothing was written. Resolve them and preview
again.
</template>
@@ -314,6 +314,29 @@
</label>
</div>
<!-- Blocking and NOT acknowledgeable, unlike needscreate above.
The packet blocks on these, so the page must name them or the
banner points at nothing the admin can see. -->
<div
v-if="wincReport.uncreatable && Object.keys(wincReport.uncreatable).length"
class="problems"
>
<h4>Rows this import cannot create (blocking)</h4>
<p class="muted">
The export needs these, but this import only ever creates business
units. Create them in Settings first, or alias the export values
to rows that already exist, then preview again.
</p>
<ul>
<li v-for="(items, group) in wincReport.uncreatable" :key="group">
<strong>{{ group }}:</strong>
<span v-for="(count, name) in items" :key="name">
"{{ name }}" ({{ count }}){{ ' ' }}
</span>
</li>
</ul>
</div>
<div v-if="wincReport.unresolved && Object.keys(wincReport.unresolved).length" class="problems">
<h4>Unresolved values (blocking)</h4>
<p class="muted">
@@ -354,15 +377,27 @@
<td>{{ b.createdat }}</td>
<td>{{ b.createdby || '-' }}</td>
<td>{{ b.rowcount }}</td>
<td>{{ b.status }}</td>
<td>
{{ b.status }}
<small v-if="b.status === 'partially-undone'" class="muted">
- some rows could not be put back
</small>
</td>
<td>
<!-- 'partially-undone' is retryable and MUST offer the
button: that status exists because an undo stopped at a
conflict, and retrying once the conflict is resolved is
the whole point. Testing only for 'applied' left the
backend willing and the button hidden. -->
<button
v-if="b.status === 'applied'"
v-if="b.status === 'applied' || b.status === 'partially-undone'"
class="btn btn-secondary btn-sm"
:disabled="wincUndoingId === b.batchid"
@click="undoWincBatch(b.batchid)"
>
{{ wincUndoingId === b.batchid ? 'Undoing...' : 'Undo' }}
{{ wincUndoingId === b.batchid
? 'Undoing...'
: (b.status === 'partially-undone' ? 'Retry undo' : 'Undo') }}
</button>
<span v-else class="muted">undone</span>
</td>
@@ -922,7 +957,12 @@ async function wincCommit() {
wincError.value = ''
try {
const acknowledge = wincNeedsBusinessUnit.value ? ['businessunit'] : []
const response = await settingsApi.wincCommit(wincFile.value, acknowledge)
// The hash from the preview the admin actually looked at. Without it the
// server refuses: a commit has to be able to prove it is applying the plan
// that was approved, not whatever the file says now.
const planhash = wincReport.value?.planhash || ''
const response = await settingsApi.wincCommit(wincFile.value, acknowledge,
planhash)
wincReport.value = response.data?.data || response.data || null
await loadWincBatches()
} catch (err) {

View File

@@ -0,0 +1,51 @@
"""Give importbatches.status room for a value longer than 'undone'.
7d37 sized the column String(16), which fitted the two statuses it shipped with.
'partially-undone' - the status an undo writes when some entries reverted and
others hit conflicts - is exactly 16 characters. It fits, with nothing to spare:
the next status anyone adds, or a rename of this one, silently truncates on
MySQL in non-strict mode and errors in strict.
Widened to 32 rather than left at the exact fit, because a column that happens
to be wide enough is not the same as a column that is wide enough.
Revision ID: 7d38_widen_importbatch_status
Revises: 7d37_import_batches_and_journal
"""
from alembic import op
import sqlalchemy as sa
revision = '7d38_widen_importbatch_status'
down_revision = '7d37_import_batches_and_journal'
branch_labels = None
depends_on = None
def _has_table(bind, name):
return sa.inspect(bind).has_table(name)
def upgrade():
bind = op.get_bind()
if not _has_table(bind, 'importbatches'):
return
# SQLite cannot ALTER a column type; it also does not enforce VARCHAR
# length, so there is nothing to widen there.
if bind.dialect.name == 'sqlite':
return
op.alter_column('importbatches', 'status',
existing_type=sa.String(length=16),
type_=sa.String(length=32),
existing_nullable=False)
def downgrade():
bind = op.get_bind()
if not _has_table(bind, 'importbatches') or bind.dialect.name == 'sqlite':
return
op.alter_column('importbatches', 'status',
existing_type=sa.String(length=32),
type_=sa.String(length=16),
existing_nullable=False)

View File

@@ -63,7 +63,7 @@
:placeholder="createkind === 'service' ? 'e.g. Spooler' : 'e.g. agent.exe'"
v-model="createname"
@keydown.enter.prevent="submitCreate"
@keydown.esc.prevent="cancelCreate"
@keydown.esc.prevent.stop="cancelCreate"
/>
<template v-if="createkind === 'process'">
<label class="action-create-label">
@@ -75,7 +75,7 @@
placeholder="e.g. C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"
v-model="createlaunchpath"
@keydown.enter.prevent="submitCreate"
@keydown.esc.prevent="cancelCreate"
@keydown.esc.prevent.stop="cancelCreate"
/>
</template>
<div class="action-create-actions">

View File

@@ -10,6 +10,50 @@
is nothing in it to find and edit.
</p>
<div class="card form-card">
<h3>Collector behaviour</h3>
<div class="form-group">
<label>Corporate ranges (CIDR)</label>
<input v-model="extra.collector_corporate_ranges" type="text"
class="form-control" placeholder="10.0.0.0/8, 192.168.0.0/16" />
<p class="field-hint">
Comma-separated CIDRs naming this site's routable networks. The server
picks a PC's primary address from these when the agent reports several.
</p>
</div>
<div class="form-group">
<label>Quiet report hours</label>
<input v-model.number="extra.computers_quietreporthours" type="number"
min="1" class="form-control" />
<p class="field-hint">
Hours without a collector report before a PC counts as silent on the
dashboard.
</p>
</div>
<div class="form-group">
<label class="check-row">
<input type="checkbox" v-model="machinelinkAlerts" />
<span>Alert when a PC takes over another PC's machine</span>
</label>
</div>
<div class="form-group">
<label class="check-row">
<input type="checkbox" v-model="configPoliciesEnabled" />
<span>Configuration and business policies</span>
</label>
<p class="field-hint">
Master switch for the RMM-style policy pages. Off by default.
</p>
</div>
<button class="btn btn-primary" :disabled="savingExtra" @click="saveExtra">
{{ savingExtra ? 'Saving...' : 'Save' }}
</button>
</div>
<div class="card form-card">
<div v-if="message" class="settings-success">{{ message }}</div>
<div v-if="error" class="error-message">{{ error }}</div>
@@ -61,13 +105,57 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { settingsApi } from '@/api'
import api from '@/api'
const RANGES_KEY = 'computers_routableranges'
const ranges = ref('')
// The rest of the plugin's settings. They were declared and seeded but rendered
// nowhere, so a site could only change them with SQL - see
// tests/test_plugin_settings_reachable.py, written after that shipped twice.
const EXTRA_KEYS = [
'collector_corporate_ranges',
'computers_quietreporthours',
'computers_machinelink_alerts',
'computers_configpolicies_enabled',
]
const extra = ref({
collector_corporate_ranges: '',
computers_quietreporthours: 24,
computers_machinelink_alerts: 'false',
computers_configpolicies_enabled: 'false',
})
const savingExtra = ref(false)
// Settings store strings; bridge the checkboxes rather than letting a boolean
// round-trip into the string 'True'.
function boolSetting(key) {
return computed({
get: () => String(extra.value[key]).toLowerCase() === 'true',
set: (on) => { extra.value[key] = on ? 'true' : 'false' },
})
}
const machinelinkAlerts = boolSetting('computers_machinelink_alerts')
const configPoliciesEnabled = boolSetting('computers_configpolicies_enabled')
async function saveExtra() {
savingExtra.value = true
error.value = ''
message.value = ''
try {
for (const key of EXTRA_KEYS) {
await settingsApi.update(key, String(extra.value[key]))
}
message.value = 'Saved.'
} catch (saveError) {
error.value = saveError?.response?.data?.data?.error?.message || 'Save failed.'
} finally {
savingExtra.value = false
}
}
const saving = ref(false)
const downloading = ref(false)
const digest = ref('')
@@ -79,6 +167,9 @@ onMounted(async () => {
const response = await settingsApi.list({ category: 'computers' })
const row = (response.data.data || []).find(entry => entry.key === RANGES_KEY)
if (row) ranges.value = row.value || ''
for (const entry of response.data.data || []) {
if (EXTRA_KEYS.includes(entry.key)) extra.value[entry.key] = entry.value
}
} catch (loadError) {
error.value = 'Could not load settings'
console.error(loadError)

View File

@@ -29,6 +29,7 @@ so it can be unit-tested without a request context and reused by both the upload
routes and the packager. Callers own authorization.
"""
import hashlib
import io
import json
import os
@@ -435,6 +436,13 @@ def store_version(binkey, version, data, make_current=True):
'version': version,
'uploadedat': datetime.utcnow().isoformat() + 'Z',
'size': len(data),
# The identity of the BYTES, not of the label. A version string is
# chosen by whoever uploads, and re-uploading the same string replaces
# the file in place - without this, a swap under a trusted label leaves
# no trace. It is also what an operator compares against the build they
# meant to ship, and what package time can check before handing an exe
# to the fleet.
'sha256': hashlib.sha256(data).hexdigest(),
})
manifest['versions'] = versions
if make_current or not manifest.get('current'):

View File

@@ -20,7 +20,7 @@ Uploads are capped and PE-checked so a wrong or hostile file cannot be staged.
from flask import request
from shopdb.api import (success_response, error_response, ErrorCodes,
from shopdb.api import (success_response, error_response, ErrorCodes, AuditLog, db,
require_permission)
from .routes import endpoints_bp
@@ -96,6 +96,20 @@ def upload_agent_binary(binkey):
result = agentstore.store_version(binkey, version, data,
make_current=makecurrent)
# This is the binary every future package installs and runs as SYSTEM on
# the fleet. Without a record, an incident has no answer to "who staged
# this, and when" - the manifest holds the file, not the person. The digest
# identifies the BYTES, because a version string is chosen by whoever
# uploads and re-uploading one replaces the file in place.
entry = next((v for v in result['versions']
if v.get('version') == version), {})
AuditLog.log(
'uploaded', 'AgentBinary', entityname='%s %s' % (binkey, version),
changes={'sha256': entry.get('sha256'), 'size': entry.get('size'),
'madecurrent': makecurrent})
db.session.commit()
return success_response({
'binary': binkey,
'current': result['current'],

View File

@@ -176,9 +176,17 @@ class EndpointsPlugin(BasePlugin):
# this codebase otherwise reserves for the few genuinely
# role-shaped checks - and a role cannot be granted to one person
# for one job the way a permission can.
# It now also governs SUPPLYING the binary, not just wrapping it.
# Before the agent store, the exe could only be staged by someone
# with filesystem access to instance/siteagent/; the upload routes
# put that behind this same grant, so a holder chooses the file
# every future package installs and runs as SYSTEM. Whoever grants
# this has to be able to see that from the description.
('endpoints.agentpackage',
'Generate and download an agent or adoption-tool package '
'(contains a live collector key)', 'endpoints'),
'Upload the agent binary the fleet installs, and generate or '
'download an agent or adoption-tool package (supplies an '
'executable that runs as SYSTEM; the package contains a live '
'collector key)', 'endpoints'),
# Pushing an agent REPLACEMENT to running endpoints is a distinct,
# heavier job than either packaging (endpoints.agentpackage) or
# pushing an ordinary application installer (endpoints.deploy).

View File

@@ -1,5 +1,18 @@
<template>
<div class="geenforce-settings">
<h2>Reporting</h2>
<p class="muted">
How long a bay may go without posting an enforcement report before the
fleet view calls it stale.
</p>
<label class="field-label" for="stale">Stale after (minutes)</label>
<input id="stale" type="number" min="1" class="cidr-input"
v-model.number="staleminutes" @blur="saveStale">
<small class="input-hint">
Default 30. A bay enforces on its own cycle, so set this above the cycle
length or every bay reads as stale between runs.
</small>
<h2>Client Access</h2>
<p class="muted">
Fleet PCs on a trusted network can reach the GE-Enforce client endpoints
@@ -40,9 +53,12 @@
<script setup>
import { ref, onMounted } from 'vue'
import api from '@/api'
import api, { settingsApi } from '@/api'
const cidrs = ref('')
// Held here rather than in /geenforce/config: that endpoint owns the CIDR
// allowlist, and this is an ordinary site setting.
const staleminutes = ref(30)
const saving = ref(false)
const message = ref('')
const ok = ref(false)
@@ -57,6 +73,23 @@ async function load() {
message.value = 'Could not load config.'
ok.value = false
}
try {
const setting = unwrap(await settingsApi.get('geenforce_reportstaleminutes'))
staleminutes.value = Number(setting?.value ?? 30) || 30
} catch (e) {
staleminutes.value = 30
}
}
async function saveStale() {
try {
await settingsApi.update('geenforce_reportstaleminutes', String(staleminutes.value))
ok.value = true
message.value = 'Saved.'
} catch (e) {
ok.value = false
message.value = e?.response?.data?.data?.error?.message || 'Save failed.'
}
}
async function save() {

View File

@@ -431,11 +431,54 @@ def adjust_item(item_id: int):
return success_response(item.to_dict(), message='Stock adjusted')
# --- kiosk: UNauthenticated by decision record --------------------------------
# The take endpoint is the product's first open WRITE. The proposal's decision
# record sets the bar it must meet: decrement-only, badge-attributed, bounded,
# physically rate-limited. It can reduce stock of an active item and nothing
# else; identity comes from the badge resolved server-side, never the client.
# --- kiosk: off by default, bound to a provisioned device token ---------------
# The take endpoint is the product's only open WRITE. The original decision
# record justified that with "physically rate-limited: it serves a touch screen
# on the shop floor". Nothing in code enforced the physical part, and an HTTP
# 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):
"""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'])
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)
if not item:
return error_response(ErrorCodes.NOT_FOUND,
@@ -478,7 +523,13 @@ def kiosk_item(itemcode):
@printedparts_bp.route('/kiosk/take', methods=['POST'])
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 {}
item = _kiosk_find_item(data.get('itemcode'))
@@ -487,9 +538,17 @@ def kiosk_take():
'No part matches that barcode', http_code=404)
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,
'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:
return error_response(
ErrorCodes.VALIDATION_ERROR,
@@ -498,8 +557,14 @@ def kiosk_take():
try:
sso, name = resolve_badge(data.get('badge'))
except BadgeError as badge_error:
return error_response(ErrorCodes.VALIDATION_ERROR, str(badge_error),
except BadgeError:
# 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)
# Print-file revision the scanned part carried: explicit field, else the

View File

@@ -92,7 +92,7 @@
<script setup>
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'
import { printedpartsApi, settingsApi } from '@/api'
import { printedpartsApi, settingsApi, claimKioskToken, KIOSK_TOKEN_KEYS } from '@/api'
import { withBase } from '@/utils/basePath'
import TouchKeypad from '@/components/TouchKeypad.vue'
@@ -117,6 +117,7 @@ const entryInput = ref(null)
let resetTimer = null
onMounted(async () => {
claimKioskToken(KIOSK_TOKEN_KEYS['/printedparts/kiosk/'])
// 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
// changes during a shift.

View File

@@ -36,6 +36,38 @@
<p class="field-hint">Seed value for new items; each item can override.</p>
</div>
<div class="form-group">
<label>
<input type="checkbox" v-model="kioskEnabled" />
Serve the parts kiosk
</label>
<p class="field-hint">
Off by default. The kiosk routes are the only ones that write without
a login, so a site opts in.
</p>
</div>
<div class="form-group">
<label>Kiosk device token</label>
<div class="token-row">
<input v-model="values.printedparts_kiosk_token" type="text"
class="form-control" placeholder="not set" />
<button type="button" class="btn btn-secondary" @click="generateToken">
Generate
</button>
</div>
<p class="field-hint">
Each kiosk screen is provisioned once by opening
<code>/parts-kiosk?token=&lt;this value&gt;</code> on it. The browser
keeps it; the address bar does not. A screen without it gets a 404,
and so does anyone else who finds the route.
</p>
<p v-if="tokenJustGenerated" class="field-hint token-warn">
Copy this now and save. It is masked once stored, so this is the only
time it is shown.
</p>
</div>
<div class="form-group">
<label>Unknown badge at the kiosk</label>
<select v-model="values.printedparts_unknown_badge" class="form-control">
@@ -109,13 +141,15 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { settingsApi, usersApi, supportteamsApi } from '@/api'
const KEYS = [
'printedparts_code_prefix',
'printedparts_label_prefix',
'printedparts_default_threshold',
'printedparts_kiosk_enabled',
'printedparts_kiosk_token',
'printedparts_unknown_badge',
'printedparts_alert_email',
'printedparts_alert_userids',
@@ -127,12 +161,33 @@ const values = ref({
printedparts_code_prefix: '3DP',
printedparts_label_prefix: '',
printedparts_default_threshold: 5,
printedparts_kiosk_enabled: 'false',
printedparts_kiosk_token: '',
printedparts_unknown_badge: 'deny',
printedparts_alert_email: '',
printedparts_alert_userids: '',
printedparts_alert_roleids: '',
printedparts_alert_supportteamid: ''
})
// Settings are stored as strings. Bridge the checkbox to that rather than
// letting a boolean reach the API and come back as the string 'True'.
const kioskEnabled = computed({
get: () => String(values.value.printedparts_kiosk_enabled).toLowerCase() === 'true',
set: (on) => { values.value.printedparts_kiosk_enabled = on ? 'true' : 'false' },
})
const tokenJustGenerated = ref(false)
// A token an operator would not guess and does not have to invent. Generated in
// the browser so it never travels anywhere it did not need to.
function generateToken() {
const bytes = new Uint8Array(24)
crypto.getRandomValues(bytes)
values.value.printedparts_kiosk_token =
Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
tokenJustGenerated.value = true
}
const supportTeams = ref([])
const users = ref([])
const selectedUserids = ref([])
@@ -188,6 +243,10 @@ async function save() {
</script>
<style scoped>
.token-row { display: flex; gap: 8px; align-items: center; }
.token-row .form-control { flex: 1 1 auto; }
.token-warn { color: var(--warning-dark); }
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
.user-picker {
max-height: 12rem;

View File

@@ -150,6 +150,28 @@ class PrintedpartsPlugin(BasePlugin):
'category': 'printedparts',
'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': '',
# string, not 'secret': the column documents string/boolean/
# integer/json only. Masking is keyed on the NAME containing
# 'token' (settings.py::_is_secret), so this is still hidden in
# the API and the audit log.
'valuetype': 'string',
'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',
'value': 'deny',

View File

@@ -26,6 +26,16 @@
<input v-model.number="values.printers_alert_critical_threshold"
type="number" min="0" max="100" class="form-control" />
</div>
<div class="form-group">
<label>Dashboard threshold (% remaining)</label>
<input v-model.number="values.printers_dashboardpercent"
type="number" min="0" max="100" class="form-control" />
<p class="field-hint">
At or below this, a printer's supply shows on the dashboard. Separate
from the alert thresholds above: this one only decides what is
listed, it sends nothing.
</p>
</div>
</div>
<div class="form-group">
@@ -101,7 +111,8 @@ const KEYS = [
'printers_alert_roleids',
'printers_alert_supportteamid',
'printers_alert_warning_threshold',
'printers_alert_critical_threshold'
'printers_alert_critical_threshold',
'printers_dashboardpercent'
]
const values = ref({
@@ -110,6 +121,7 @@ const values = ref({
printers_alert_roleids: '',
printers_alert_supportteamid: '',
printers_alert_warning_threshold: 5,
printers_dashboardpercent: 5,
printers_alert_critical_threshold: 0
})
const supportTeams = ref([])

View File

@@ -24,6 +24,8 @@ import logging
import re
from datetime import datetime, timedelta
import hmac
from flask import Blueprint, request, Response
from flask_jwt_extended import jwt_required
@@ -741,6 +743,31 @@ def _kiosk_enabled():
return _kiosk_setting('usb_kiosk_enabled', False)
def _kiosk_open():
"""True when the kiosk is on for this site AND the caller has its token.
The flag alone was the whole gate, and a flag is a decision, not a
credential: with the kiosk on, anyone who could reach the app could move a
device between checked-out and checked-in and have it recorded against a
badge they guessed. A badge is a short numeric SSO, so it is attribution,
never authorization - the same reasoning that closed the parts kiosk.
The token is per-DEVICE. A kiosk is a browser on a screen by the cabinet, so
a token served inside the page would be readable by anyone who could load
it. An admin opens the screen once as /usb-kiosk?token=..., the page keeps
it in that browser and sends it as X-Kiosk-Token.
"""
if not _kiosk_enabled():
return False
expected = (Setting.get('usb_kiosk_token') or '').strip()
presented = (request.headers.get('X-Kiosk-Token') or '').strip()
if not expected or not presented:
return False
# compare_digest, not ==: == leaks the token's length and matching prefix
# through timing.
return hmac.compare_digest(presented, expected)
def _kiosk_disabled_response():
# 404, not 403: when the kiosk is off these routes should not read as
# "there is a door here and you may not use it".
@@ -765,7 +792,7 @@ def kiosk_device(device_id):
the person standing there cannot tell whether they are doing the right
thing. No badge number, no history, no contact details.
"""
if not _kiosk_enabled():
if not _kiosk_open():
return _kiosk_disabled_response()
if _usb_selfhosted():
return selfhosted.kiosk_device(device_id)
@@ -825,7 +852,7 @@ def kiosk_session():
COUNT of everything else - names for those are a separate, deliberate step,
so a passer-by does not read the whole cabinet's holders off a wall screen.
"""
if not _kiosk_enabled():
if not _kiosk_open():
return _kiosk_disabled_response()
data = request.get_json() or {}
if _usb_selfhosted():
@@ -881,7 +908,7 @@ def kiosk_others():
who is on leave - but the names that makes visible should not be the first
thing on a shopfloor screen.
"""
if not _kiosk_enabled():
if not _kiosk_open():
return _kiosk_disabled_response()
if not _kiosk_setting('usb_kiosk_allow_return_for_others', True):
return _kiosk_disabled_response()
@@ -925,7 +952,7 @@ def kiosk_others():
@usb_bp.route('/kiosk/checkout', methods=['POST'])
def kiosk_checkout():
"""Check a device out at the kiosk. Body: {device_id, badge}."""
if not _kiosk_enabled():
if not _kiosk_open():
return _kiosk_disabled_response()
data = request.get_json() or {}
if _usb_selfhosted():
@@ -998,7 +1025,7 @@ def kiosk_checkin():
handed the device back, which is the honest record and needs no change to a
schema this app does not own.
"""
if not _kiosk_enabled():
if not _kiosk_open():
return _kiosk_disabled_response()
data = request.get_json() or {}
if _usb_selfhosted():

View File

@@ -133,7 +133,7 @@
<script setup>
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import api, { settingsApi } from '@/api'
import api, { settingsApi, claimKioskToken, KIOSK_TOKEN_KEYS } from '@/api'
import TouchKeypad from '@/components/TouchKeypad.vue'
import { filterdrives } from '../drivefilter'
@@ -150,6 +150,7 @@ const entryInput = ref(null)
let resetTimer = null
onMounted(async () => {
claimKioskToken(KIOSK_TOKEN_KEYS['/usb/kiosk/'])
// The kiosk ships off. Reading the flag turns "everything 404s" into a screen
// that says so - a kiosk merely switched off must not look broken.
try {

View File

@@ -33,6 +33,28 @@
</p>
</div>
<div class="form-group">
<label>Kiosk device token</label>
<div class="token-row">
<input v-model="values.usb_kiosk_token" type="text"
class="form-control" placeholder="not set" />
<button type="button" class="btn btn-secondary" @click="generateToken">
Generate
</button>
</div>
<p class="field-hint">
Provision each screen once by opening
<code>/usb-kiosk?token=&lt;this value&gt;</code> on it. That browser
keeps it; the address bar does not. Turning the kiosk on is a
decision, not a credential - without this token the routes answer 404,
so nobody who merely finds the URL can move a drive and sign it with a
badge they guessed.
</p>
<p v-if="tokenJustGenerated" class="field-hint token-warn">
Copy this now and save. It is masked once stored.
</p>
</div>
<div class="form-group">
<label class="check-row">
<input type="checkbox" v-model="values.usb_kiosk_allow_return_for_others"
@@ -61,12 +83,25 @@ import { ref, onMounted } from 'vue'
import { settingsApi } from '@/api'
import { withBase } from '@/utils/basePath'
const KEYS = ['usb_kiosk_enabled', 'usb_kiosk_allow_return_for_others']
const KEYS = ['usb_kiosk_enabled', 'usb_kiosk_allow_return_for_others',
'usb_kiosk_token']
const values = ref({
usb_kiosk_enabled: false,
usb_kiosk_allow_return_for_others: true,
usb_kiosk_token: '',
})
const tokenJustGenerated = ref(false)
// Generated in the browser so it never travels anywhere it did not need to.
function generateToken() {
const bytes = new Uint8Array(24)
crypto.getRandomValues(bytes)
values.value.usb_kiosk_token =
Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
tokenJustGenerated.value = true
}
const saving = ref(false)
const message = ref('')
const error = ref('')
@@ -106,6 +141,10 @@ onMounted(load)
</script>
<style scoped>
.token-row { display: flex; gap: 8px; align-items: center; }
.token-row .form-control { flex: 1 1 auto; }
.token-warn { color: var(--warning-dark); }
.field-hint { color: var(--text-light); font-size: 0.85rem; margin-top: 0.25rem; }
.check-row { display: flex; align-items: center; gap: 0.5rem; font-weight: 600; }
.check-row input { width: auto; }

View File

@@ -138,6 +138,18 @@ class USBPlugin(BasePlugin):
'a device between checked-out and checked-in - it '
'cannot add, edit, retire or list devices.',
},
{
'key': 'usb_kiosk_token',
'value': '',
# string: the column documents string/boolean/integer/json.
# Masking keys on the NAME containing 'token'.
'valuetype': 'string',
'category': 'usb',
'description': 'Device token the USB kiosk sends as '
'X-Kiosk-Token. Generate one, then open the '
'kiosk once as /usb-kiosk?token=<value> on each '
'screen',
},
{
'key': 'usb_kiosk_allow_return_for_others',
'value': 'true',

View File

@@ -4,6 +4,23 @@
<h2>Dell Warranty Lookup</h2>
</div>
<div class="section-card">
<div class="setting-group">
<p class="setting-description">
How far ahead the dashboard counts a warranty as expiring.
</p>
<div class="setting-row">
<label>
<span>Expiring window (days)</span>
<input type="number" min="1"
v-model.number="settings.warranty_expiringdays"
@blur="saveSetting('warranty_expiringdays', settings.warranty_expiringdays)"
:disabled="saving">
</label>
</div>
</div>
</div>
<div class="section-card">
<div class="setting-group">
<p class="setting-description">

View File

@@ -41,6 +41,16 @@ EXCLUDES=(
--exclude-dir=devnotes
)
# Root-level gitignored scratch: review writeups and chat handoffs, free to hold
# pasted logs full of em-dashes and arrows. grep does not consult .gitignore, so
# it has to be dropped here.
#
# A POST-FILTER, not --exclude-dir=devnotes. That flag matches a directory of
# that NAME at any depth, so a force-added plugins/<plugin>/devnotes/ would be
# invisible to every check in this file, ADR-015 included - a hole in the gate
# that nothing would report. This drops only the path we mean.
drop_scratch() { grep -v '^\./devnotes/' || true; }
INCLUDES_CODE=(
--include='*.py'
--include='*.vue'
@@ -64,7 +74,7 @@ INCLUDES_ALL=(
# and did, including in files this repo's own convention forbids them in.
# Markdown, JSON and YAML are now covered too.
echo "==> Checking for non-ASCII characters..."
NON_ASCII=$(grep -rPn '[^\x00-\x7F]' "${EXCLUDES[@]}" "${INCLUDES_ALL[@]}" . 2>/dev/null || true)
NON_ASCII=$(grep -rPn '[^\x00-\x7F]' "${EXCLUDES[@]}" "${INCLUDES_ALL[@]}" . 2>/dev/null | drop_scratch || true)
if [ -n "$NON_ASCII" ]; then
echo "FAIL: non-ASCII characters found (em-dashes, smart quotes, arrows, emojis):"
echo "$NON_ASCII"
@@ -89,7 +99,7 @@ for word in cfg ctx mgr req res; do
done
echo "==> Checking for snake_case DB tablenames..."
SNAKE_TABLES=$(grep -rPn "__tablename__\s*=\s*['\"][^'\"]*_" "${EXCLUDES[@]}" --include='*.py' . 2>/dev/null || true)
SNAKE_TABLES=$(grep -rPn "__tablename__\s*=\s*['\"][^'\"]*_" "${EXCLUDES[@]}" --include='*.py' . 2>/dev/null | drop_scratch || true)
if [ -n "$SNAKE_TABLES" ]; then
echo "FAIL: snake_case __tablename__ found (must be lowercase concatenated):"
echo "$SNAKE_TABLES"
@@ -98,7 +108,7 @@ if [ -n "$SNAKE_TABLES" ]; then
fi
echo "==> Checking for snake_case DB column attrs..."
SNAKE_COLS=$(grep -rPn "^\s+[a-z]+_[a-z_]+\s*=\s*db\.Column" "${EXCLUDES[@]}" --include='*.py' . 2>/dev/null || true)
SNAKE_COLS=$(grep -rPn "^\s+[a-z]+_[a-z_]+\s*=\s*db\.Column" "${EXCLUDES[@]}" --include='*.py' . 2>/dev/null | drop_scratch || true)
if [ -n "$SNAKE_COLS" ]; then
echo "FAIL: snake_case db.Column attribute found (must match column name, no underscores):"
echo "$SNAKE_COLS"
@@ -107,7 +117,7 @@ if [ -n "$SNAKE_COLS" ]; then
fi
echo "==> Checking for snake_case ForeignKey targets..."
SNAKE_FK=$(grep -rPn "ForeignKey\(['\"][^'\"]*_[^'\"]*['\"]" "${EXCLUDES[@]}" --include='*.py' . 2>/dev/null || true)
SNAKE_FK=$(grep -rPn "ForeignKey\(['\"][^'\"]*_[^'\"]*['\"]" "${EXCLUDES[@]}" --include='*.py' . 2>/dev/null | drop_scratch || true)
if [ -n "$SNAKE_FK" ]; then
echo "FAIL: snake_case ForeignKey target found:"
echo "$SNAKE_FK"
@@ -116,7 +126,7 @@ if [ -n "$SNAKE_FK" ]; then
fi
echo "==> Checking for snake_case API params in frontend (DB-mirrored fields)..."
SNAKE_FE=$(grep -rPn "params\.(machine_id|location_id|vendor_id|type_id|business_unit_id|model_id|status_id|operating_system_id|asset_id|user_id|is_active|is_shopfloor)" "${EXCLUDES[@]}" --include='*.vue' --include='*.js' --include='*.ts' . 2>/dev/null || true)
SNAKE_FE=$(grep -rPn "params\.(machine_id|location_id|vendor_id|type_id|business_unit_id|model_id|status_id|operating_system_id|asset_id|user_id|is_active|is_shopfloor)" "${EXCLUDES[@]}" --include='*.vue' --include='*.js' --include='*.ts' . 2>/dev/null | drop_scratch || true)
if [ -n "$SNAKE_FE" ]; then
echo "FAIL: snake_case API params in frontend (must match DB column names without underscores):"
echo "$SNAKE_FE"

View File

@@ -57,7 +57,7 @@ def plan_hash(plan):
return hasher.hexdigest()
def build_packet(kind, plan, counts, groups):
def build_packet(kind, plan, counts, groups, creatable=None):
"""Build an approval packet from a plan.
kind a short label identifying the importer, stored on the packet.
@@ -69,6 +69,12 @@ def build_packet(kind, plan, counts, groups):
column is what a human would act on - the raw export value for
UNRESOLVED (so they know what to alias) and the canonical name for
NEEDS_CREATE (so they know what would be created).
creatable
the group names the caller's writer can actually create, or None
for "no opinion". A needs-create group outside this set is reported
under `uncreatable` and makes the packet blocking, because a plan
that promises rows nothing will create is as unrunnable as one with
unresolved values.
needscreate is keyed on the canonical name column when present; callers pass
the *_name column for that via the fourth tuple element when it differs from
@@ -86,6 +92,26 @@ def build_packet(kind, plan, counts, groups):
if needs_hits:
needscreate[group_name] = needs_hits
# A needs-create group the writer cannot actually create BLOCKS the plan,
# the same as an unresolved value. It is the same failure either way: the
# import cannot do what the preview says it would.
#
# It has to be decided HERE rather than at write time. The writer's own
# refusal sits behind the acknowledgement check, and the UI only ever
# acknowledges the groups it renders a control for - so a plan needing an
# uncreatable group reached Commit looking clean, and died on a message
# about acknowledging something the screen had no checkbox for. Blocking at
# plan time puts it in the preview, where the admin can act on it.
#
# creatable=None means "no opinion" - the CLI callers that do not pass it
# keep the old behaviour.
uncreatable = OrderedDict()
if creatable is not None:
allowed = set(creatable)
for group_name, hits in needscreate.items():
if group_name not in allowed:
uncreatable[group_name] = hits
return OrderedDict([
('kind', kind),
('batchid', str(uuid.uuid4())),
@@ -93,8 +119,9 @@ def build_packet(kind, plan, counts, groups):
('rowcount', len(plan)),
('counts', OrderedDict(counts)),
('needscreate', needscreate),
('uncreatable', uncreatable),
('unresolved', unresolved),
('blocking', bool(unresolved)),
('blocking', bool(unresolved) or bool(uncreatable)),
])
@@ -123,6 +150,20 @@ def format_packet(packet):
lines.append(' + %-28s (%d rows)' % (name, count))
lines.append(' total new %s rows: %d' % (group, total))
# An uncreatable group blocks, so it has to be ON SCREEN. Setting
# blocking without printing the reason left "BLOCKED (resolve the values
# above)" pointing at nothing.
uncreatable = packet.get('uncreatable') or {}
if uncreatable:
for group, items in uncreatable.items():
lines.append('')
lines.append(' BLOCKING: this import cannot create %s rows, but '
'%d are needed - create them first, or alias the '
'export values to rows that exist:'
% (group, len(items)))
for name, count in items.items():
lines.append(' x %-28s (%d rows)' % (name, count))
unresolved = packet.get('unresolved') or {}
if unresolved:
for group, items in unresolved.items():
@@ -134,7 +175,7 @@ def format_packet(packet):
lines.append('')
lines.append(' status : %s' % (
'BLOCKED (resolve the values above)' if packet.get('blocking')
'BLOCKED (resolve the problems above)' if packet.get('blocking')
else 'READY for acknowledgment'))
return '\n'.join(lines)

View File

@@ -34,6 +34,8 @@ from datetime import datetime, timezone
BATCH_APPLIED = 'applied'
BATCH_UNDONE = 'undone'
# See shopdb/core/models/importbatch.py for why this exists.
BATCH_PARTIAL = 'partially-undone'
OP_UPDATE = 'update'
OP_INSERT = 'insert'
@@ -150,6 +152,9 @@ def undo_batch(connection, batchid, references=None):
{'batchid': batchid}).fetchone()
if batch is None:
raise ValueError('unknown batchid: %s' % batchid)
# Only a FULLY undone batch is finished. A partially-undone one still has
# entries with isreverted = 0, and the whole point of retrying is to pick
# them up once whatever conflicted has been resolved.
if batch[0] == BATCH_UNDONE:
return UndoResult(batchid, 0, [])
@@ -184,10 +189,20 @@ def undo_batch(connection, batchid, references=None):
else:
conflicts.append(conflict)
# Record what actually happened, not what was attempted. Writing 'undone'
# unconditionally is what made a conflicted entry unrevertable: this call
# is the only way back, and the early return above refuses a second attempt
# on an undone batch.
#
# undoneat is stamped only when the batch is genuinely finished; on a
# partial it stays null, so "when was this reversed" keeps its meaning.
status = BATCH_PARTIAL if conflicts else BATCH_UNDONE
connection.execute(text(
'UPDATE importbatches SET status = :status, undoneat = :undoneat '
'WHERE batchid = :batchid'),
{'status': BATCH_UNDONE, 'undoneat': _utcnow(), 'batchid': batchid})
{'status': status,
'undoneat': None if conflicts else _utcnow(),
'batchid': batchid})
return UndoResult(batchid, reverted, conflicts)
@@ -218,10 +233,19 @@ def _revert_update(connection, table, targetid, column, oldvalue, newvalue):
def _revert_insert(connection, batchid, table, targetid, references):
"""Hard-delete a row this batch created, only if nothing outside the batch
still references it. Returns a Conflict when it is not safe, else None."""
from sqlalchemy import text
from sqlalchemy import inspect, text
pk = _primary_key(connection, table)
# A reference table may not exist here. Some referrers are plugin-owned -
# notifications, for one - and a lean site drops the tables of plugins it
# does not install (ADR-014). Querying an absent table raises 1146 on MySQL,
# which rolls the whole undo back and leaves the batch permanently
# un-undoable on that site. Skip what is not there; a table that does not
# exist cannot hold a reference.
present = set(inspect(connection).get_table_names())
for reftable, refcolumn in (references or {}).get(table, []):
if reftable not in present:
continue
referencing = connection.execute(text(
'SELECT COUNT(*) FROM %s WHERE %s = :targetid'
% (reftable, refcolumn)), {'targetid': targetid}).fetchone()[0]

View File

@@ -56,6 +56,7 @@ from dotenv import load_dotenv
load_dotenv(Path(__file__).resolve().parents[3] / '.env')
from scripts.site_imports.approval import build_packet, format_packet
from scripts.site_imports.winc import writer
from scripts.site_imports.suggest import suggest
from scripts.site_imports.resolver import (
BLANK,
@@ -375,7 +376,8 @@ def main(argv=None):
'winc-machine-adoption', plan, summary,
[('businessunit', 'businessunit_status', 'raw_eq1',
'businessunit_name'),
('location', 'location_status', 'raw_location', 'location_name')])
('location', 'location_status', 'raw_location', 'location_name')],
creatable=writer._CREATABLE_GROUPS)
print('\n' + format_packet(packet))
return 0

View File

@@ -51,17 +51,40 @@ _PACKET_GROUPS = [
('location', 'location_status', 'raw_location', 'location_name'),
]
# Groups apply_plan can actually create. _PACKET_GROUPS is what the plan
# REPORTS on; this is what the writer can act on, and the two are not the same.
_CREATABLE_GROUPS = ('businessunit',)
# References into a created row's id, so undo can tell whether an inserted
# business unit is still pointed at by something outside this batch before it
# hard-deletes it. Shared with the undo CLI and the web undo path.
_UNDO_REFERENCES = {
'businessunits': [('assets', 'businessunitid')],
# EVERY referrer, not just the obvious one. Undo hard-deletes a business
# unit this batch created, and it may only do that when nothing outside the
# batch still points at it. assets was the only entry, so a BU referenced
# from anywhere else was deleted anyway:
#
# - dashboarddefaults.businessunitid and businessunits.parentid are real
# FKs, so MySQL raises and the whole undo rolls back - consistent, but
# the batch then cannot be undone at all;
# - notifications.businessunitid has NO FK, so the delete succeeded and
# left the notification pointing at an id that no longer exists.
# Silently, on both engines.
#
# The guard itself is a SELECT COUNT(*), not FK enforcement, so it runs the
# same on SQLite - a wrong-but-real column name here would silently match
# nothing and delete the row anyway, which is why it is tested rather than
# trusted. What SQLite cannot show is the CONSEQUENCE of getting it wrong:
# MySQL raises on the FK cases, SQLite lets the dangling row through.
'businessunits': [
('assets', 'businessunitid'),
('dashboarddefaults', 'businessunitid'),
('businessunits', 'parentid'),
('notifications', 'businessunitid'),
],
}
# References into a created row's id, so undo can tell whether an inserted
# business unit is still pointed at by something outside this batch before it
# hard-deletes it.
# Max width of assets.name; the export description can exceed it (a few rows
# carry a whole safety notice in the field), so a set name is clamped rather
# than letting the whole transaction fail on one over-long row. The pre-image is
@@ -148,7 +171,8 @@ def apply_plan(connection, plan, approved_packet, acknowledge=None,
# (2)-(3) rebuild the packet and prove nothing drifted since approval.
current_packet = build_packet('winc-machine-adoption', plan,
approved_packet.get('counts', {}),
_PACKET_GROUPS)
_PACKET_GROUPS,
creatable=_CREATABLE_GROUPS)
if not packet_matches_plan(approved_packet, plan):
raise WriterError(
'plan has changed since approval (hash mismatch) - re-run the '
@@ -167,6 +191,24 @@ def apply_plan(connection, plan, approved_packet, acknowledge=None,
'needs-create not acknowledged for: %s - confirm these will be '
'created before importing' % ', '.join(sorted(missing_ack)))
# (5b) and the writer must actually be able to create what it just made the
# admin agree to. _PACKET_GROUPS lists location, so a plan needing a new
# location asked for acknowledgement - and then step 6a creates business
# units only, and 6b sets locationid only when the location already
# RESOLVED. The admin was told "this would create 3 locations", ticked it,
# and nothing was created, nothing was set, and nothing was reported.
#
# Refusing is the honest answer: better a plan that will not run than one
# that quietly does less than it said. Creating locations is a real feature
# and can be added here; until then this cannot pretend.
uncreatable = (set(current_packet.get('needscreate', {}))
- set(_CREATABLE_GROUPS))
if uncreatable:
raise WriterError(
'this import cannot create: %s - the rows must exist before it '
'runs, or be aliased to rows that do. Nothing was written.'
% ', '.join(sorted(uncreatable)))
batchid = approved_packet['batchid']
journal.open_batch(connection, approved_packet, createdby=createdby)
sequence = 1
@@ -244,7 +286,7 @@ def main(argv=None):
_app, connection = build_app_connection()
plan, summary, _lookups = analyze(connection, args.export)
packet = build_packet('winc-machine-adoption', plan, summary,
_PACKET_GROUPS)
_PACKET_GROUPS, creatable=_CREATABLE_GROUPS)
if not args.commit:
print('DRY: rebuilt plan of %d rows, hash %s' % (

View File

@@ -145,11 +145,12 @@ from shopdb.utils.cmmc_usb_db import cmmc_usb_connection
# the whole batch can be undone. ImportBatch is the identity approval, write, and
# undo all reference; ImportJournalEntry is one reversible mutation. Exposed so a
# plugin's import UI can list applied batches and reverse one without reaching
# into core internals. See docs/PLUGIN-HOOKS.md.
# into core internals. See the PLUGIN-HOOKS wiki page.
from shopdb.core.models import (
ImportBatch,
ImportJournalEntry,
BATCH_APPLIED,
BATCH_PARTIAL,
BATCH_UNDONE,
OP_UPDATE,
OP_INSERT,

View File

@@ -134,6 +134,22 @@ class Config:
DEFAULT_PAGE_SIZE = 20
MAX_PAGE_SIZE = 100
# Hard ceiling on a request body, enforced by Werkzeug before a view runs.
#
# Upload routes cap their own payloads, but every one of those checks runs
# AFTER the body has arrived: the first touch of request.form or
# request.files makes Werkzeug parse the whole multipart and spool the file
# part to a temp file, so a route can reject the content and still have paid
# for the transfer and the disk. Three separate reviews found that shape in
# three different routes, which makes it a property of the app rather than
# of any handler.
#
# 128 MB clears the largest thing anyone legitimately sends - the agent
# binary upload caps itself at 64 MB - with room for the multipart envelope.
# Over this, Werkzeug answers 413 without reading the body.
MAX_CONTENT_LENGTH = int(
os.environ.get('MAX_CONTENT_LENGTH', 128 * 1024 * 1024))
class DevelopmentConfig(Config):
"""Development configuration."""

View File

@@ -317,17 +317,30 @@ def get_widgets():
@dashboard_bp.route('/health', methods=['GET'])
def health_check():
"""Health check endpoint (no auth required)."""
from shopdb import __version__
"""Health check endpoint (no auth required).
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:
db.session.execute(db.text('SELECT 1'))
db_status = 'healthy'
except Exception as e:
db_status = f'unhealthy: {str(e)}'
healthy = True
except Exception:
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({
'status': 'ok' if db_status == 'healthy' else 'degraded',
'database': db_status,
'version': __version__
'status': 'ok' if healthy else 'degraded',
'database': 'healthy' if healthy else 'unhealthy',
})

View File

@@ -659,9 +659,20 @@ def winc_import_commit():
part.strip() for part in
(request.form.get('acknowledge') or '').split(',') if part.strip()]
# The hash the PREVIEW returned. Required here, because without it the
# writer's drift check compares a freshly built packet against the plan it
# was built from and can never fail - so "the admin approved this plan"
# meant only "the admin ticked these group names".
planhash = (request.form.get('planhash') or '').strip()
if not planhash:
return error_response(
ErrorCodes.VALIDATION_ERROR,
'planhash is required: send the value the preview returned, so the '
'commit can prove it is applying the plan that was approved.')
try:
report = winc_commit(export_text, acknowledge=acknowledge,
createdby=get_jwt_identity())
createdby=get_jwt_identity(), planhash=planhash)
AuditLog.log(
'imported', 'SiteAssets', entityname='winc-machine-adoption',
changes={'batchid': report.get('batchid'),
@@ -690,6 +701,7 @@ def winc_import_batches():
def winc_import_undo():
"""Reverse an applied WINC import batch by id. Conservative and reversible."""
from shopdb.core.services.wincimport import undo as winc_undo
from shopdb.core.services.wincimport import WincImportError
payload = request.get_json(silent=True) or {}
batchid = (payload.get('batchid') or request.form.get('batchid') or '').strip()
@@ -703,7 +715,10 @@ def winc_import_undo():
changes={'batchid': batchid, 'reverted': result['reverted'],
'conflicts': len(result['conflicts'])})
db.session.commit()
except ValueError as exc:
except (ValueError, WincImportError) as exc:
# WincImportError too: refusing a batch of another importer's kind is a
# bad request, not a server fault. Preview and commit already catch it;
# this route did not, so the refusal surfaced as a 500.
db.session.rollback()
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc))
except Exception:

View File

@@ -24,7 +24,7 @@ from .auditlog import AuditLog
from .customfield import CustomField, CustomFieldValue
from .apitoken import ApiToken
from .importbatch import (ImportBatch, ImportJournalEntry, BATCH_APPLIED,
BATCH_UNDONE, OP_UPDATE, OP_INSERT)
BATCH_PARTIAL, BATCH_UNDONE, OP_UPDATE, OP_INSERT)
__all__ = [
# Base
@@ -88,6 +88,7 @@ __all__ = [
'ImportBatch',
'ImportJournalEntry',
'BATCH_APPLIED',
'BATCH_PARTIAL',
'BATCH_UNDONE',
'OP_UPDATE',
'OP_INSERT',

View File

@@ -29,10 +29,37 @@ class BaseModel(db.Model):
)
isactive = db.Column(db.Boolean, default=True, nullable=False)
# Columns the default to_dict NEVER emits, whatever model carries them.
#
# The default serialiser walks __table__.columns and publishes every one, so
# a model that does not override it publishes whatever it has - including
# columns added to it later, by someone who was not thinking about this
# method. User is the live example: it does not override to_dict, so
# User.to_dict() returns passwordhash. Nothing calls it in a response path
# today, which is luck rather than design.
#
# A blanket allowlist would be the stronger rule, but it would also silently
# empty the output of all 34 models that rely on this default. This is the
# part that can be fixed without breaking anything: name the columns that
# must never be published, and let a model add its own.
#
# A model with something sensitive should still override to_dict and say
# exactly what it emits. This is a floor, not permission to skip that.
HIDDEN_COLUMNS = frozenset({
'passwordhash',
'tokenhash',
'apikey',
'clientsecret',
'collectorkey',
})
def to_dict(self):
"""Convert model to dictionary."""
"""Convert model to dictionary, minus anything secret-bearing."""
hidden = self.HIDDEN_COLUMNS
result = {}
for c in self.__table__.columns:
if c.name in hidden:
continue
value = getattr(self, c.name)
if isinstance(value, datetime):
value = value.isoformat() + 'Z'

View File

@@ -26,6 +26,12 @@ from shopdb.extensions import db
# Batch lifecycle.
BATCH_APPLIED = 'applied'
BATCH_UNDONE = 'undone'
# An undo that reverted some entries and hit conflicts on the rest. It is a
# distinct state because 'undone' is a door that locks: undo_batch returns
# immediately for an undone batch, so marking a partly-reverted one 'undone'
# made the leftovers permanently unrevertable - the human resolves the conflict,
# clicks undo again, and gets a no-op.
BATCH_PARTIAL = 'partially-undone'
# Journal operation kinds.
OP_UPDATE = 'update'
@@ -43,7 +49,9 @@ class ImportBatch(db.Model):
rowcount = db.Column(db.Integer, nullable=False, default=0)
createdat = db.Column(db.DateTime, nullable=False)
createdby = db.Column(db.String(100), nullable=True)
status = db.Column(db.String(16), nullable=False, default=BATCH_APPLIED)
# 32, not 16: BATCH_PARTIAL is exactly 16 characters, so the original
# width fitted it with nothing to spare. See migration 7d38.
status = db.Column(db.String(32), nullable=False, default=BATCH_APPLIED)
undoneat = db.Column(db.DateTime, nullable=True)
entries = db.relationship(

View File

@@ -12,6 +12,8 @@ units, and records every change to the reversible import journal. That contract
lives here and in the writer, not in the generic canonical-CSV importer.
"""
import sqlalchemy as sa
from shopdb.extensions import db
from scripts.site_imports import journal
from scripts.site_imports.approval import build_packet
@@ -38,7 +40,8 @@ def _plan_and_packet(export_text):
plan, summary, _lookups = analyze_rows(_connection(), rows)
packet = build_packet('winc-machine-adoption', plan, summary,
winc_writer._PACKET_GROUPS)
winc_writer._PACKET_GROUPS,
creatable=winc_writer._CREATABLE_GROUPS)
return plan, summary, packet
@@ -53,6 +56,11 @@ def _report(summary, packet):
for group, items in packet.get('needscreate', {}).items()},
'unresolved': {group: dict(items)
for group, items in packet.get('unresolved', {}).items()},
# Carried separately from needscreate: both are "rows that do not
# exist", but only this one cannot be acknowledged away, and the page
# has to say WHY it is blocked rather than only that it is.
'uncreatable': {group: dict(items)
for group, items in packet.get('uncreatable', {}).items()},
'blocking': packet.get('blocking', False),
}
@@ -70,7 +78,7 @@ def preview(export_text):
return report
def commit(export_text, acknowledge=None, createdby=None):
def commit(export_text, acknowledge=None, createdby=None, planhash=None):
"""Apply the plan reversibly inside the request transaction.
Rebuilds the plan from the same upload, re-checks it against a freshly built
@@ -78,10 +86,26 @@ def commit(export_text, acknowledge=None, createdby=None):
caller owns the commit/rollback of db.session. Returns the report plus the
batchid on success.
planhash is the one the PREVIEW returned. It is what makes the approval mean
anything: the packet handed to the writer is built from this same upload, so
the writer's drift check compared a hash against itself and could never fail.
An admin previewing file A and committing file B - or the same file after the
locations or aliases moved underneath it - got whatever the second plan said,
gated only by the needs-create group NAMES they happened to tick.
Passing it is optional so the CLI path, which builds and applies in one go,
is unaffected. The web route requires it.
Raises WincImportError (mapped to a 400 by the route) when the plan is
blocking or a needs-create group was not acknowledged.
blocking, a needs-create group was not acknowledged, or the plan no longer
matches the one that was approved.
"""
plan, summary, packet = _plan_and_packet(export_text)
if planhash is not None and packet['planhash'] != planhash:
raise WincImportError(
'This plan is not the one that was previewed - the upload or the '
'data behind it changed. Preview again and re-check what it would '
'create before committing.')
try:
batchid = winc_writer.apply_plan(
_connection(), plan, packet,
@@ -95,8 +119,27 @@ def commit(export_text, acknowledge=None, createdby=None):
return report
IMPORT_KIND = 'winc-machine-adoption'
def undo(batchid):
"""Reverse an applied batch. Caller owns the transaction. Returns a dict."""
"""Reverse an applied batch. Caller owns the transaction. Returns a dict.
Refuses a batch of another kind. The journal is a general primitive and
undo_batch does not filter on kind, so without this check the first other
importer's batches would be reversible through THIS endpoint - carrying
WINC's references map, which lists none of that importer's tables, so the
reference guard would pass on everything and the deletes would be
unconditional.
"""
kind = _connection().execute(
sa.text('SELECT kind FROM importbatches WHERE batchid = :batchid'),
{'batchid': batchid}).scalar()
if kind is not None and kind != IMPORT_KIND:
raise WincImportError(
'batch %s was created by a different importer (%s) and cannot be '
'reversed here' % (batchid, kind))
result = journal.undo_batch(_connection(), batchid,
references=winc_writer._UNDO_REFERENCES)
return {

View File

@@ -46,10 +46,14 @@ EXEMPT_BLUEPRINTS = {'auth', 'collector', 'setup'}
# the same blueprint are JWT+permission gated and ARE swept.
EXEMPT_ENDPOINTS = {'knowledgebase.track_click', 'users.update_user',
'geenforce.post_report',
# Deliberately open kiosk write: decrement-only,
# badge-attributed server-side. Decision record in
# docs/proposals/printedparts-plugin.md; justification in
# the plugin README.
# Kiosk write. No JWT - the people at the bin have no
# account - but no longer open: it needs the site's kiosk
# flag AND the per-device token (X-Kiosk-Token), and 404s
# 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',
# Deliberately open kiosk writes. NOT the same bar as the
# parts kiosk, and the difference is the point: these are

View File

@@ -60,6 +60,9 @@ PUBLIC_ENDPOINTS = {
'slides.feed', 'slides.serve_image',
# 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',
# USB kiosk.

View File

@@ -0,0 +1,69 @@
"""BaseModel.to_dict never publishes a secret-bearing column.
The default serialiser walks __table__.columns and emits every one, so a model
that does not override it publishes whatever it happens to carry - including
columns added later by someone not thinking about this method. User is the live
example: it has no override, so User.to_dict() returned its bcrypt hash. Nothing
called it in a response path, which was luck.
A blanket allowlist is the stronger rule and would empty the output of every
model relying on the default, so this pins the floor instead: named columns are
never emitted, and a model may extend the set.
"""
import pytest
from shopdb.core.models import User
from shopdb.core.models.base import BaseModel
def test_the_default_serialiser_hides_a_password_hash(app):
with app.app_context():
user = User(username='floor', email='floor@example.invalid',
passwordhash='$2b$12$notarealhashbutlookslikeone')
data = user.to_dict()
assert 'passwordhash' not in data
# Still a useful dict - this is a filter, not a mute button.
assert data['username'] == 'floor'
assert data['email'] == 'floor@example.invalid'
def test_every_hidden_name_is_actually_dropped(app):
"""Each entry earns its place: drop one and the column comes back."""
with app.app_context():
user = User(username='floor2', email='f2@example.invalid',
passwordhash='x')
assert 'passwordhash' in {c.name for c in User.__table__.columns}
assert 'passwordhash' in BaseModel.HIDDEN_COLUMNS
assert 'passwordhash' not in user.to_dict()
def test_a_model_can_extend_the_hidden_set(app):
"""A model with its own secret adds to the floor rather than replacing
to_dict wholesale."""
with app.app_context():
class Extended(User):
__abstract__ = True
HIDDEN_COLUMNS = BaseModel.HIDDEN_COLUMNS | {'email'}
assert 'passwordhash' in Extended.HIDDEN_COLUMNS
assert 'email' in Extended.HIDDEN_COLUMNS
@pytest.mark.parametrize('name', sorted(BaseModel.HIDDEN_COLUMNS))
def test_no_model_publishes_a_hidden_column_through_the_default(name, app):
"""Sweep every mapped model: if it carries a hidden column AND relies on the
default to_dict, the column must not come out."""
with app.app_context():
from shopdb.extensions import db
for mapper in db.Model.registry.mappers:
model = mapper.class_
table = getattr(model, '__table__', None)
if table is None or name not in table.columns:
continue
if 'to_dict' in vars(model):
continue # overrides the default; its own business
assert name in getattr(model, 'HIDDEN_COLUMNS', frozenset()), (
'%s carries %s and uses the default to_dict, so it would be '
'published' % (model.__name__, name))

View File

@@ -0,0 +1,88 @@
"""Every setting a plugin declares must be reachable in its settings page.
A setting exists so a site can change it. One that is seeded into the database
but rendered by no page is not configurable - it is a constant with extra steps,
and the only way to change it is direct SQL.
This was written after shipping exactly that twice in one afternoon:
printedparts_kiosk_enabled / _kiosk_token and then usb_kiosk_token were declared
in get_settings(), seeded correctly, and read correctly by the API - and none of
them appeared on any page, because each settings view carries a hardcoded KEYS
list that nobody remembered to extend. The instructions for turning the kiosk
back on were impossible to follow.
The check is deliberately crude: it greps the plugin's frontend for the key as a
literal. That is enough to catch the failure it exists for, and it cannot be
satisfied by accident.
"""
import pathlib
import re
import pytest
REPO = pathlib.Path(__file__).resolve().parents[1]
PLUGINS = REPO / 'plugins'
# Settings a site is not meant to edit through a page. Each needs a reason.
EXEMPT = {
# Written by the app itself when the agent key is minted, not typed.
'plugin.endpoints.agentkeyprovisioned',
'plugin.endpoints.envkeyfallback',
}
def _declared_settings(plugin_dir):
"""Keys the plugin declares in get_settings_defaults(), read from the source.
Importing the plugin would need an app context and a database; the
declaration is a literal list, so reading it is enough and much cheaper.
"""
source = (plugin_dir / 'plugin.py')
if not source.is_file():
return []
text = source.read_text(encoding='utf-8')
match = re.search(r'def get_settings_defaults\(self\).*?(?=\n def |\Z)', text, re.S)
if not match:
return []
return re.findall(r"'key':\s*'([^']+)'", match.group(0))
def _frontend_text(plugin_dir):
frontend = plugin_dir / 'frontend'
if not frontend.is_dir():
return ''
parts = []
for path in frontend.rglob('*'):
if path.suffix in ('.vue', '.js') and path.is_file():
parts.append(path.read_text(encoding='utf-8'))
return '\n'.join(parts)
PLUGIN_DIRS = sorted(
d for d in PLUGINS.iterdir()
if d.is_dir() and (d / 'manifest.json').is_file()
)
@pytest.mark.parametrize('plugin_dir', PLUGIN_DIRS, ids=lambda d: d.name)
def test_every_declared_setting_is_reachable_in_the_ui(plugin_dir):
declared = [k for k in _declared_settings(plugin_dir) if k not in EXEMPT]
if not declared:
pytest.skip('declares no settings')
frontend = _frontend_text(plugin_dir)
if not frontend:
pytest.skip('ships no frontend')
missing = [key for key in declared if key not in frontend]
assert not missing, (
'%s declares %d setting(s) that appear nowhere in its frontend, so a '
'site cannot change them without writing SQL:\n %s\n'
'Add them to the plugin settings view (its KEYS list AND a field), or '
'to EXEMPT in this file with a reason. There is no backlog list: the '
'seven this check found when it was written were all fixed, and it '
'stays that way.'
% (plugin_dir.name, len(missing), '\n '.join(missing)))

View File

@@ -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'])

View File

@@ -16,6 +16,12 @@ import pytest
from shopdb.core.models import Setting
KIOSK_TOKEN = 'usb-kiosk-device-token-for-tests'
# What a provisioned screen sends. Turning the kiosk on is a decision; this is
# the credential.
KIOSK_HEADERS = {'X-Kiosk-Token': KIOSK_TOKEN}
@pytest.fixture
def kiosk(db):
"""Self-hosted USB, kiosk ON, one employee and one available drive."""
@@ -26,6 +32,8 @@ def kiosk(db):
valuetype='string', category='site'))
db.session.add(Setting(key='usb_kiosk_enabled', value='true',
valuetype='boolean', category='usb'))
db.session.add(Setting(key='usb_kiosk_token', value=KIOSK_TOKEN,
valuetype='string', category='usb'))
db.session.add(DirectoryEmployee(sso=270015376, firstname='Dana',
lastname='Reyes'))
db.session.add(DirectoryEmployee(sso=270099999, firstname='Sam',
@@ -48,12 +56,12 @@ def _setting(db, key, value):
def _out(client, badge='270015376', **extra):
return client.post('/api/usb/kiosk/checkout',
json=dict({'device_id': 'USB-1', 'badge': badge}, **extra))
json=dict({'device_id': 'USB-1', 'badge': badge}, **extra), headers=KIOSK_HEADERS)
def _in(client, badge='270015376', **extra):
return client.post('/api/usb/kiosk/checkin',
json=dict({'device_id': 'USB-1', 'badge': badge}, **extra))
json=dict({'device_id': 'USB-1', 'badge': badge}, **extra), headers=KIOSK_HEADERS)
# --- the door itself --------------------------------------------------------
@@ -67,7 +75,7 @@ def test_the_kiosk_does_not_exist_until_a_site_turns_it_on(client, kiosk):
"""
_setting(kiosk, 'usb_kiosk_enabled', 'false')
assert client.get('/api/usb/kiosk/device/USB-1').status_code == 404
assert client.get('/api/usb/kiosk/device/USB-1', headers=KIOSK_HEADERS).status_code == 404
assert _out(client).status_code == 404
assert _in(client).status_code == 404
@@ -81,14 +89,14 @@ def test_it_ships_off(app):
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')
response = client.get('/api/usb/kiosk/device/USB-1', headers=KIOSK_HEADERS)
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.post('/api/usb/kiosk/device/USB-1', headers=KIOSK_HEADERS).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',
@@ -113,7 +121,7 @@ def test_a_badge_it_cannot_name_is_refused(client, kiosk):
def test_a_missing_badge_is_refused(client, kiosk):
assert client.post('/api/usb/kiosk/checkout',
json={'device_id': 'USB-1'}).status_code == 400
json={'device_id': 'USB-1'}, headers=KIOSK_HEADERS).status_code == 400
# --- taking one -------------------------------------------------------------
@@ -139,7 +147,7 @@ def test_a_drive_already_out_cannot_be_taken_again(client, kiosk):
def test_an_unknown_code_is_a_404(client, kiosk):
response = client.post('/api/usb/kiosk/checkout',
json={'device_id': 'NOPE', 'badge': '270015376'})
json={'device_id': 'NOPE', 'badge': '270015376'}, headers=KIOSK_HEADERS)
assert response.status_code == 404
@@ -198,7 +206,7 @@ def test_a_drive_that_is_not_out_cannot_be_returned(client, kiosk):
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']
data = client.get('/api/usb/kiosk/device/USB-1', headers=KIOSK_HEADERS).get_json()['data']
assert data['holdername'] == 'Dana Reyes'
assert '270015376' not in str(data)
@@ -211,11 +219,11 @@ def test_the_lookup_names_the_holder_but_not_their_badge(client, kiosk):
# 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})
return client.post('/api/usb/kiosk/session', json={'badge': badge}, headers=KIOSK_HEADERS)
def test_the_device_list_costs_a_valid_badge(client, kiosk):
assert client.post('/api/usb/kiosk/session', json={}).status_code == 400
assert client.post('/api/usb/kiosk/session', json={}, headers=KIOSK_HEADERS).status_code == 400
assert _session(client, badge='999999999').status_code == 422
@@ -223,7 +231,7 @@ 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
json={'badge': '270015376'}, headers=KIOSK_HEADERS).status_code == 404
def test_the_session_separates_yours_from_available(client, kiosk):
@@ -254,7 +262,7 @@ 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']
json={'badge': '270099999'}, headers=KIOSK_HEADERS).get_json()['data']
assert [r['device_id'] for r in rows] == ['USB-1']
assert rows[0]['holdername'] == 'Dana Reyes'
@@ -265,7 +273,7 @@ def test_the_others_list_is_gone_when_returns_for_others_are_refused(client, kio
_out(client)
assert client.post('/api/usb/kiosk/others',
json={'badge': '270099999'}).status_code == 404
json={'badge': '270099999'}, headers=KIOSK_HEADERS).status_code == 404
def test_a_retired_drive_is_not_offered(client, kiosk):
@@ -276,3 +284,33 @@ def test_a_retired_drive_is_not_offered(client, kiosk):
data = _session(client).get_json()['data']
assert data['available'] == []
# --- the flag is a decision, the token is the credential ---------------------
#
# usb_kiosk_enabled alone used to be the whole gate. With the kiosk on, anyone
# who could reach the app could move a drive between checked-out and checked-in
# and have it recorded against a badge they guessed.
def test_kiosk_is_shut_without_the_device_token(client, kiosk):
assert client.get('/api/usb/kiosk/device/USB-1').status_code == 404
assert client.post('/api/usb/kiosk/checkout',
json={'device_id': 'USB-1',
'badge': '270015376'}).status_code == 404
def test_kiosk_is_shut_with_the_wrong_device_token(client, kiosk):
wrong = {'X-Kiosk-Token': KIOSK_TOKEN + 'x'}
assert client.get('/api/usb/kiosk/device/USB-1',
headers=wrong).status_code == 404
assert client.post('/api/usb/kiosk/checkout', headers=wrong,
json={'device_id': 'USB-1',
'badge': '270015376'}).status_code == 404
def test_the_token_alone_is_not_enough_when_the_kiosk_is_off(client, kiosk):
"""Both halves are required: a provisioned screen at a site that has since
switched the kiosk off gets nothing."""
_setting(kiosk, 'usb_kiosk_enabled', 'false')
assert client.get('/api/usb/kiosk/device/USB-1',
headers=KIOSK_HEADERS).status_code == 404

View File

@@ -8,6 +8,7 @@ in-memory schema: seed a machine asset, upload an export that matches it, and
assert what preview reports, what commit writes, and that undo puts it back.
"""
import io
from datetime import datetime
import pytest
@@ -60,6 +61,26 @@ def _upload(client, headers, url, csv_text=EXPORT_CSV, extra=None):
headers=headers)
def _commit(client, headers, csv_text=EXPORT_CSV, extra=None, planhash=None):
"""Preview, then commit the plan the preview returned.
This is the real flow, and the tests have to use it: the commit route now
requires the planhash from the preview. Committing without previewing was
what made the approval meaningless - the writer compared a freshly built
packet against the plan it was built from, so its drift check could not
fail and an admin could approve one plan and apply another.
Pass planhash explicitly to simulate approving one plan and committing a
different one.
"""
if planhash is None:
preview = _upload(client, headers, PREVIEW, csv_text=csv_text)
planhash = preview.get_json()['data']['planhash']
payload = dict(extra or {})
payload['planhash'] = planhash
return _upload(client, headers, COMMIT, csv_text=csv_text, extra=payload)
PREVIEW = '/api/settings/asset-import/winc/preview'
COMMIT = '/api/settings/asset-import/winc/commit'
UNDO = '/api/settings/asset-import/winc/undo'
@@ -99,7 +120,7 @@ def test_commit_requires_acknowledgment_for_needs_create(client, db,
auth_headers):
_seed_machine(db)
# No acknowledge field: the needs-create business unit blocks the write.
response = _upload(client, auth_headers, COMMIT)
response = _commit(client, auth_headers)
assert response.status_code == 400
message = response.get_json()['data']['error']['message'].lower()
assert 'acknowledge' in message
@@ -114,8 +135,7 @@ def test_commit_applies_plan_and_creates_business_unit(client, db,
auth_headers):
assetid, locationid = _seed_machine(db)
response = _upload(client, auth_headers, COMMIT,
extra={'acknowledge': 'businessunit'})
response = _commit(client, auth_headers, extra={'acknowledge': 'businessunit'})
assert response.status_code == 200, response.get_json()
report = response.get_json()['data']
assert report['committed'] is True
@@ -141,7 +161,7 @@ def test_commit_uses_existing_business_unit_when_present(client, db,
assetid, _ = _seed_machine(db)
# Already resolved, so no needs-create and no acknowledgment needed.
response = _upload(client, auth_headers, COMMIT)
response = _commit(client, auth_headers)
assert response.status_code == 200, response.get_json()
from shopdb.core.models.asset import Asset
@@ -154,8 +174,7 @@ def test_commit_uses_existing_business_unit_when_present(client, db,
def test_undo_reverses_a_committed_batch(client, db, auth_headers):
assetid, _ = _seed_machine(db)
committed = _upload(client, auth_headers, COMMIT,
extra={'acknowledge': 'businessunit'})
committed = _commit(client, auth_headers, extra={'acknowledge': 'businessunit'})
batchid = committed.get_json()['data']['batchid']
from shopdb.core.models.asset import Asset
@@ -186,8 +205,7 @@ def test_undo_requires_batchid(client, db, auth_headers):
def test_batches_lists_committed_then_undone(client, db, auth_headers):
_seed_machine(db)
committed = _upload(client, auth_headers, COMMIT,
extra={'acknowledge': 'businessunit'})
committed = _commit(client, auth_headers, extra={'acknowledge': 'businessunit'})
batchid = committed.get_json()['data']['batchid']
listed = client.get(BATCHES, headers=auth_headers).get_json()['data']
@@ -208,3 +226,196 @@ def test_winc_routes_require_admin(client, db, member_headers):
response = _upload(client, member_headers, url)
assert response.status_code in (401, 403)
assert client.get(BATCHES, headers=member_headers).status_code in (401, 403)
# --- an approved plan has to be the plan that gets applied --------------------
#
# The writer's drift check compared a packet against the plan it was built from,
# so it could never fail. "Approved" meant only that the admin ticked the right
# needs-create group NAMES - the plan itself could be anything.
def test_commit_refuses_a_plan_that_was_not_the_one_previewed(client, db,
auth_headers):
_seed_machine(db)
response = _commit(client, auth_headers,
extra={'acknowledge': 'businessunit'},
planhash='a' * 64)
assert response.status_code == 400
assert 'previewed' in response.get_json()['data']['error']['message']
def test_commit_refuses_without_a_planhash_at_all(client, db, auth_headers):
"""Committing without previewing is what made the approval vacuous."""
_seed_machine(db)
response = _upload(client, auth_headers, COMMIT,
extra={'acknowledge': 'businessunit'})
assert response.status_code == 400
assert 'planhash' in response.get_json()['data']['error']['message']
def test_a_preview_of_one_file_does_not_approve_another(client, db,
auth_headers):
"""The concrete attack: preview file A, commit file B."""
from shopdb.core.models import Asset
_seed_machine(db)
other_csv = (
'assetnum,description,location,eq1\r\n'
'W0083A,Something Else Entirely,WM-J3,lg parts\r\n'
)
approved = _upload(client, auth_headers, PREVIEW).get_json()['data']['planhash']
response = _upload(client, auth_headers, COMMIT, csv_text=other_csv,
extra={'acknowledge': 'businessunit',
'planhash': approved})
assert response.status_code == 400
assert Asset.query.filter_by(name='Something Else Entirely').first() is None
# --- an undo that hit a conflict must stay retryable -------------------------
#
# undo_batch used to write 'undone' whatever happened, and its early return
# refuses a second undo on an undone batch - so the one entry that needed a
# retry was the only one that could never get it.
def test_a_conflicted_undo_is_marked_partial_and_can_be_retried(
client, db, auth_headers, app):
from shopdb.core.models import Asset, ImportBatch, BATCH_PARTIAL, BATCH_UNDONE
assetid, _ = _seed_machine(db)
committed = _commit(client, auth_headers, extra={'acknowledge': 'businessunit'})
assert committed.status_code == 200
batchid = committed.get_json()['data']['batchid']
# A human edits a cell the import wrote. Undo must refuse to clobber it.
with app.app_context():
asset = db.session.get(Asset, assetid)
asset.name = 'Edited By A Human'
db.session.commit()
first = client.post(UNDO, json={'batchid': batchid}, headers=auth_headers)
assert first.status_code == 200
body = first.get_json()['data']
assert body['conflicts'], 'expected the human edit to conflict'
with app.app_context():
assert db.session.get(ImportBatch, batchid).status == BATCH_PARTIAL
# The human resolves it. The retry must actually do something - this is
# what the old 'undone' status made impossible.
with app.app_context():
db.session.get(Asset, assetid).name = 'Renamed Machine'
db.session.commit()
second = client.post(UNDO, json={'batchid': batchid}, headers=auth_headers)
assert second.status_code == 200
assert second.get_json()['data']['reverted'] >= 1, 'retry did nothing'
with app.app_context():
assert db.session.get(ImportBatch, batchid).status == BATCH_UNDONE
def test_a_clean_undo_is_still_marked_undone(client, db, auth_headers, app):
from shopdb.core.models import ImportBatch, BATCH_UNDONE
_seed_machine(db)
committed = _commit(client, auth_headers, extra={'acknowledge': 'businessunit'})
batchid = committed.get_json()['data']['batchid']
response = client.post(UNDO, json={'batchid': batchid}, headers=auth_headers)
assert response.status_code == 200
assert not response.get_json()['data']['conflicts']
with app.app_context():
batch = db.session.get(ImportBatch, batchid)
assert batch.status == BATCH_UNDONE
assert batch.undoneat is not None
# --- the guards themselves, not just the paths through them ------------------
def test_undo_refuses_a_business_unit_something_else_points_at(
client, db, auth_headers, app):
"""_UNDO_REFERENCES named only assets, so a BU referenced from anywhere
else was hard-deleted anyway. The guard is a SELECT COUNT(*), so SQLite
exercises it exactly as MySQL does."""
from shopdb.core.models import BusinessUnit, DashboardDefault
_seed_machine(db)
committed = _commit(client, auth_headers, extra={'acknowledge': 'businessunit'})
assert committed.status_code == 200
batchid = committed.get_json()['data']['batchid']
with app.app_context():
created = BusinessUnit.query.filter_by(businessunit='Large Parts').first()
assert created is not None, 'the import should have created the BU'
db.session.add(DashboardDefault(businessunitid=created.businessunitid))
db.session.commit()
buid = created.businessunitid
response = client.post(UNDO, json={'batchid': batchid}, headers=auth_headers)
assert response.status_code == 200
reasons = [c['reason'] for c in response.get_json()['data']['conflicts']]
assert any('dashboarddefaults' in r for r in reasons), reasons
with app.app_context():
assert db.session.get(BusinessUnit, buid) is not None, \
'the BU was deleted despite still being referenced'
def test_undo_refuses_a_batch_from_another_importer(client, db, auth_headers, app):
from shopdb.core.models import ImportBatch, BATCH_APPLIED
with app.app_context():
db.session.add(ImportBatch(batchid='not-ours', kind='some-other-import',
planhash='0' * 64, rowcount=0,
createdat=datetime.utcnow(),
status=BATCH_APPLIED))
db.session.commit()
response = client.post(UNDO, json={'batchid': 'not-ours'}, headers=auth_headers)
assert response.status_code == 400, response.get_json()
assert 'different importer' in response.get_json()['data']['error']['message']
# --- a group the writer cannot create must block, visibly ---------------------
#
# _PACKET_GROUPS reports on location, but apply_plan only ever creates business
# units: it sets locationid only for a location that already RESOLVED. So a plan
# needing a new location used to preview clean, get acknowledged, commit, and
# silently do none of it.
# 'WM-T6/U6' is an alias override onto the canonical location 'T6'. Seeding the
# machine without a T6 location is what makes the location NEEDS_CREATE rather
# than UNRESOLVED - an unaliased bay is merely unresolved, which already blocked.
NEEDS_LOCATION_CSV = (
'assetnum,description,location,eq1\r\n'
'W0083A,Renamed Machine,WM-T6/U6,lg parts\r\n'
)
def test_preview_blocks_and_names_a_group_it_cannot_create(client, db,
auth_headers):
_seed_machine(db)
response = _upload(client, auth_headers, PREVIEW,
csv_text=NEEDS_LOCATION_CSV)
assert response.status_code == 200, response.get_json()
report = response.get_json()['data']
assert report['needscreate'].get('location') == {'T6': 1}, report
assert report['blocking'] is True, report
# Named, not merely blocked: the page renders this list, and without it the
# banner says "blocked" with nothing on screen to act on.
assert report['uncreatable'].get('location') == {'T6': 1}, report
def test_commit_refuses_a_group_it_cannot_create(client, db, auth_headers):
from shopdb.core.models.asset import Asset
assetid, _ = _seed_machine(db)
response = _commit(client, auth_headers, csv_text=NEEDS_LOCATION_CSV,
extra={'acknowledge': 'businessunit,location'})
assert response.status_code == 400, response.get_json()
with client.application.app_context():
assert db.session.get(Asset, assetid).name == 'Old Name', \
'nothing may be written'