Merge remote-tracking branch 'origin/wj' into wilm
This commit is contained in:
24
.github/workflows/publish-images.yml
vendored
24
.github/workflows/publish-images.yml
vendored
@@ -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
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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' }
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
51
migrations/versions/7d38_widen_importbatch_status.py
Normal file
51
migrations/versions/7d38_widen_importbatch_status.py
Normal 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)
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'):
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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=<this value></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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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=<this value></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; }
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' % (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
69
tests/test_core/test_to_dict_floor.py
Normal file
69
tests/test_core/test_to_dict_floor.py
Normal 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))
|
||||
88
tests/test_plugin_settings_reachable.py
Normal file
88
tests/test_plugin_settings_reachable.py
Normal 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)))
|
||||
@@ -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'])
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user