From 265ca2eb0187ff6751f0c415f9bf738ac9e4d628 Mon Sep 17 00:00:00 2001 From: Cam Proudlock Date: Fri, 14 Aug 2026 16:28:34 -0400 Subject: [PATCH] Security hardening, multi-site portability, and a documentation and API-surface sweep --- CHANGELOG.md | 126 ++++ deploy/windows/installer/shopdb-install.ps1 | 72 ++ frontend/src/utils/codes.js | 202 ++++++ frontend/src/utils/codes.spec.js | 93 +++ frontend/src/utils/siteSettings.js | 2 +- frontend/src/views/SetupWizard.vue | 2 +- frontend/src/views/print/AssetLabel.vue | 7 +- frontend/src/views/print/AssetLabelBatch.vue | 8 +- frontend/src/views/print/MachineBadge.vue | 12 +- frontend/src/views/print/qrLogo.js | 62 -- frontend/src/views/settings/EmailSettings.vue | 2 +- frontend/src/views/settings/SiteSettings.vue | 2 +- plugins/backups/api/routes.py | 2 +- .../0003_backups_clear_backfilled_lastseen.py | 29 +- plugins/backups/plugin.py | 16 +- plugins/backups/services/registry.py | 25 +- plugins/computers/api/routes.py | 4 +- plugins/computers/plugin.py | 313 +++++++-- plugins/employees/api/routes.py | 5 +- plugins/geenforce/api/routes.py | 11 +- .../geenforce/client/Install-GEEnforce.ps1 | 4 +- .../geenforce/client/Invoke-ShopdbShadow.ps1 | 51 +- .../geenforce/client/ShopdbEnforceClient.psm1 | 2 +- .../frontend/views/GeEnforceSettings.vue | 4 +- .../versions/0003_report_subtype.py | 18 + .../versions/0004_scope_minlibversion.py | 49 ++ plugins/geenforce/models/manifest.py | 9 + plugins/geenforce/seed_display_scope.py | 41 +- plugins/geenforce/service.py | 45 +- plugins/network/api/routes.py | 18 +- .../frontend/views/NetworkDeviceForm.vue | 34 +- .../frontend/views/NetworkTypesList.vue | 25 +- .../versions/0003_networkdevicetype_prefix.py | 69 ++ plugins/network/models/network_device.py | 10 + plugins/network/services/__init__.py | 1 + plugins/network/services/assetnumbers.py | 70 ++ plugins/printedparts/api/routes.py | 7 +- .../frontend/views/PartsKiosk.vue | 2 +- .../frontend/views/PrintedPartsLabels.vue | 6 +- plugins/printedparts/plugin.py | 4 +- .../frontend/views/PrinterQRBatch.vue | 614 +++++++++--------- .../frontend/views/PrinterQRSingle.vue | 374 +++++------ plugins/slides/api/routes.py | 5 +- .../tools/frontend/views/CodeGenerator.vue | 54 +- plugins/usb/frontend/views/USBLabelBatch.vue | 19 +- plugins/warranty/api/routes.py | 5 +- scripts/check-naming-and-style.sh | 66 +- scripts/gen_openapi.py | 225 ++++++- scripts/gen_project_map.py | 244 +++++++ scripts/site_imports/wjf/PILOT-DEPLOY.md | 188 ++++++ shopdb/__init__.py | 46 +- shopdb/api/__init__.py | 8 + shopdb/cli/__init__.py | 7 + shopdb/core/api/applications.py | 7 +- shopdb/core/api/assets.py | 41 +- shopdb/core/api/models.py | 5 +- shopdb/core/api/settings.py | 13 +- shopdb/core/models/dashboarddefault.py | 4 +- shopdb/plugins/alembic_template.py | 12 + shopdb/plugins/base.py | 34 +- shopdb/utils/mysql_charset.py | 4 +- shopdb/utils/uploads.py | 46 ++ tests/test_api_inventory_parity.py | 95 +++ tests/test_core/test_clientip.py | 4 +- .../test_relationship_soft_delete.py | 76 +++ tests/test_core/test_security_headers.py | 57 ++ tests/test_docs_publishable.py | 23 + tests/test_docs_versions.py | 115 ++++ tests/test_openapi_spec.py | 123 ++++ tests/test_plugin_migrations.py | 4 +- tests/test_plugins/test_backups.py | 31 +- .../test_collector_measuringtool_adoption.py | 232 +++++++ .../test_collector_partmarker_links.py | 97 +++ tests/test_plugins/test_geenforce_manifest.py | 6 +- .../test_geenforce_publish_gate.py | 39 ++ .../test_network_assetnumber_generation.py | 108 +++ 76 files changed, 3684 insertions(+), 811 deletions(-) create mode 100644 frontend/src/utils/codes.js create mode 100644 frontend/src/utils/codes.spec.js delete mode 100644 frontend/src/views/print/qrLogo.js create mode 100644 plugins/geenforce/migrations/versions/0004_scope_minlibversion.py create mode 100644 plugins/network/migrations/versions/0003_networkdevicetype_prefix.py create mode 100644 plugins/network/services/__init__.py create mode 100644 plugins/network/services/assetnumbers.py create mode 100644 scripts/gen_project_map.py create mode 100644 scripts/site_imports/wjf/PILOT-DEPLOY.md create mode 100644 shopdb/utils/uploads.py create mode 100644 tests/test_api_inventory_parity.py create mode 100644 tests/test_core/test_relationship_soft_delete.py create mode 100644 tests/test_core/test_security_headers.py create mode 100644 tests/test_docs_versions.py create mode 100644 tests/test_openapi_spec.py create mode 100644 tests/test_plugins/test_collector_measuringtool_adoption.py create mode 100644 tests/test_plugins/test_collector_partmarker_links.py create mode 100644 tests/test_plugins/test_network_assetnumber_generation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e1024741..ae4ccab6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,132 @@ ADR-007 and ADR-002. components nobody wrote. Cards are ordered by severity, hide themselves when there is nothing to report, gate on a permission, and each fetches independently so one broken endpoint cannot blank the board. +- Tech Tools: a section for the small conversions and code generation that + otherwise happen in a browser tab on somebody's phone. Starts with barcode and + QR codes laid out for the label stock actually in the printers. +- Kiosks report what they are. The dispatcher already read + `C:\Enrollment\display-type.txt` to pick its target; now it tells ShopDB, so + the Asset column on an enforcement report shows a reported value instead of + one inferred from a mapping table nobody filled in. +- Enforcement reports carry what the PC is, where it is, and whether it is + backed up: the host links to its own page, the Asset column names the machine + or instrument the PC controls, a map pin shows the location on hover, and the + backup column judges the chain rather than printing its last date. +- `flask relationships audit`, read-only, listing links that cannot both be + true, plus an inverse guard that refuses to create one. +- An employees endpoint that says WHY a name did not resolve, rather than + returning nothing and leaving the caller to guess. +- The display client module updates itself through the manifest, so a client + change no longer means hands on every kiosk. +- `docs/START-HERE.md` routes a reader by what they came to do, and is now the + wiki's landing page - which previously recommended the one install document + the docs themselves forbid for a new site. `docs/FLEET-ARCHITECTURE.md` is the + page nothing else could assume: how the server, GE-Enforce, the asset + reporter, the backup collectors and EventSaver relate, where credentials live + at both ends, and which of the five to open for a given symptom. +- `docs/ADOPTING-AT-ANOTHER-SITE.md`: how another site repoints the asset + reporter and the EventSaver screensaver at its own ShopDB, with worked + examples for Intune (including Machine Configuration/DSC), a GE-Enforce + manifest entry, and manual installation. Both tools were already built to be + repointed - the URL, the key and the targeting are inputs - but nothing said + so, and the two things that catch people out are written down: EventSaver's + compiled-in fallback path is not neutral, and a config enforced by hash + reverts a hand edit by design. +- `docs/PROJECT-MAP.md`, generated by `scripts/gen_project_map.py`: versions, + the plugin inventory, every Alembic chain head, the ADR index and the size of + the codebase, derived from the code. The hand-written equivalents had drifted + by two contract releases and 400 tests. + +### Changed + +- Plugin contract **0.19.0**: `get_dashboard_widgets` returns data and shape, + not a component name. The old shape named a Vue component per widget, which + cannot survive a lean build (ADR-013) and had five plugins pointing at + components nobody wrote. The change itself shipped earlier without a bump and + with `BasePlugin` and PLUGIN-HOOKS.md still teaching the dead shape; this + records it and corrects both. +- Plugin contract **0.17.0**: `DashboardDefault` is part of the `shopdb.api` + surface, so a plugin can resolve a display without reaching into core. +- Plugin contract **0.18.0**: one name per display role, and it is the kiosk's + own (`Dashboard`, `Lobby`, `3DPrintRoom`). Server and dispatcher had three + vocabularies for the same three kiosks, and a display could report a role core + could not store. `normalize_display_role` resolves case and the retired + spelling; an unknown value is kept verbatim so a typo stays visible. + Contract versions are their own series (ADR-002) and belong in this file; + 0.17.0 and 0.18.0 shipped without an entry, which is what this note corrects. +- Publishing a GE-Enforce manifest is refused when PCs reporting for that scope + run an older client library than the manifest needs, naming the hosts. A minor + version bump that NARROWS behaviour is not backward compatible: an old library + reads a gated entry as ungated and installs every version it cannot detect. +- Shadow mode runs inside the enforce cycle instead of on its own schedule. The + share is mounted only for the length of a cycle, so a task on its own clock + woke to a missing drive and reported 0 installed / 0 skipped / 0 failed, which + is indistinguishable from a healthy no-op. +- A GE-Enforce report is judged stale on both clocks, the server's and the + client's asserted check-in, so a PC cannot mask real silence with a future + timestamp. A PC that has gone quiet stops reading as healthy. + +### Fixed + +- Documentation a reader could not follow. 98 curl examples single-quoted + `$TOKEN`, so the shell never expanded it and the server answered 422; a JSON + body carried a `//` comment, which made the server report a missing hostname + about a body that has one; the IIS install omitted `flask plugin upgrade-all`, + which is the 1054 error a deploy hits days later; a pilot loop exited 1 on + every iteration; and the external-plugin guide pinned a contract version six + releases stale, so a plugin built by following it is refused at startup. Nine + documents now point at the generated project map rather than typing a version, + and a test enforces that. The API inventory gained the 22 routes that were + serving traffic undocumented, with a parity test against the live URL map so + the next one cannot slip in silently. + +- **Every plugin migration chain re-ran its head on each MySQL deploy.** Core's + Alembic env got a `connection.commit()`; the per-plugin template did not, so + the last migration of every run lost its version stamp while its DDL survived. + Invisible for as long as every head happened to be idempotent - and two were + not. The backups head cleared a column for every row, and the geenforce head + added a column unconditionally and failed on the second attempt. Both are now + bounded and guarded, and the template commits. +- A part-marker PC reported 200 on the cycle that created its marker and 500 on + every cycle after. Both of the links it makes were looked up by label, so a row + made by hand or by the legacy import was invisible and the insert violated the + uniqueness of (source, target, type); and the marker's asset number is derived + from the PC, so it could already be taken. +- A CMM reported the same way: the instrument IS the reported bay, so adoption + was relabelling the row the machine sync owns and finding it by that label. +- A reported identifier is matched exactly rather than with `ilike`, so `_` and + `%` in a file on a shopfloor PC are no longer wildcards. `MT-600_` had been + adopting MT-6001, and a bare `%` adopted whatever active asset came first. +- Measuring tools are adopted before they are minted. Minting derived the asset + number from the HOSTNAME, so a permanent instrument took the identity of + whichever PC drove it that week, and the collector could not see a tool it had + not created - which left legacy instruments shadowed by minted twins. +- An unanchored rsync exclude in the export was matching at any depth, so a + whole plugin was silently dropped from the published payload. Root excludes + are anchored and the export now aborts if a plugin manifest fails to survive. +- Backup revisions are ordered in Python, not with `ORDER BY ... NULLS LAST`, + which SQLite accepts and MySQL rejects. + +### Security + +- The Windows installer breaks ACL inheritance on the roots it creates. A + directory made directly under `C:\` inherits an inherit-only Modify grant for + Authenticated Users, so `C:\shopdb-flask`, `C:\Python314` and `C:\MySQL84` + were writable by any logged-on user. Two files inside them make that a local + privilege escalation: `shopdb-admin.ps1`, which self-elevates, and + `instance/config.py`, which the app executes unconditionally. The MySQL data + directory is locked down as well, since it holds the password hashes. A Python + installation this installer did not create is reported rather than seized. +- Site-specific values no longer ship in product code (ADR-015). The backups + share root defaults to blank instead of one site's file server, the shadow + client no longer overwrites a site's own ShopDB URL in the registry on every + cycle, the kiosk dispatcher takes its URL from the `site_base_url` setting and + refuses rather than guessing, and one site's hostname, FQDN and internal + subnets are out of the documentation, the generated API specs and the UI + placeholders. The ADR-015 scanner now covers PowerShell, the installer, the + seeds and generated JSON, case-insensitively, and FAILS the build; deliberate + organisation-wide defaults are declared with an `ADR-015-OK` marker. Both + publication gates carry the site patterns. ## [0.9.0] - 2026-08-11 diff --git a/deploy/windows/installer/shopdb-install.ps1 b/deploy/windows/installer/shopdb-install.ps1 index b3ffb528..afe9e052 100644 --- a/deploy/windows/installer/shopdb-install.ps1 +++ b/deploy/windows/installer/shopdb-install.ps1 @@ -436,6 +436,43 @@ function Protect-File { Invoke-Native 'icacls.exe' @($Path,'/inheritance:r','/grant',$admins,'/grant',$system) 'ACL' } +function Protect-InstallRoot { + # A directory created directly under C:\ INHERITS the root's DACL, and the + # default C:\ DACL carries an inherit-only Modify grant for Authenticated + # Users. So C:\shopdb-flask, C:\Python314 and C:\MySQL84 are writable by + # every logged-on user until inheritance is broken. + # + # That is a local privilege escalation here specifically, because this + # installer puts two things a non-admin could then replace inside those + # roots: shopdb-admin.ps1, which self-elevates with -Verb RunAs, and + # instance\config.py, which the app loads unconditionally through + # from_pyfile. Overwrite either, wait for an administrator or the app pool + # to run it, and the code runs as them. + # + # RE-APPLIED EVERY TIME, not only on creation - same reasoning as the + # ProgramData backups directory. An upgrade over an installation that + # predates this must repair the ACL, or the hole survives the fix. + # + # The app pool's own RX/Modify grants are applied in stage 4, AFTER this, + # so breaking inheritance here does not lock the site out of its own tree. + param([string] $Path) + if (-not (Test-Path $Path)) { return } + Write-Log "hardening $Path (breaking inheritance from its drive root)" + Protect-File $Path -Directory +} + +function Test-UserWritableRoot { + # Whether a non-admin can write to a path. Used ONLY on roots this + # installer did not create: hardening a Python another application already + # depends on would break that application, so a pre-existing root is + # reported rather than seized. Silence would be worse - the app would be + # running on an interpreter any user can replace. + param([string] $Path) + if (-not (Test-Path $Path)) { return $false } + $acl = & icacls.exe $Path 2>$null + return [bool]($acl -match '(Authenticated Users|BUILTIN\\Users|Everyone):\([^)]*\)?\(?(M|F|W)\)') +} + function New-Secret { param([int] $Bytes = 48) $b = New-Object byte[] $Bytes @@ -461,6 +498,9 @@ function Test-SamePath { } # Paths derived once. +# Default so a stage that runs on its own still knows where the interpreter is; +# the Python stage overwrites it with what it actually installed. +$script:PyRoot = 'C:\Python314' $Py = Join-Path $AppRoot 'venv\Scripts\python.exe' $Flask = Join-Path $AppRoot 'venv\Scripts\flask.exe' $Pip = Join-Path $AppRoot 'venv\Scripts\pip.exe' @@ -956,6 +996,10 @@ Microsoft Visual C++ 2015-2022 Redistributable (x64) on this server by hand. # bootstrapper returned 0 while installing to the wrong directory. Verify. if (-not (Test-Path $mysqld)) { Fail "MSI reported success but $mysqld is missing" } Track 'mysql-files' $MysqlRoot + # The service is registered with `mysqld --install` and no obj=, so it runs + # as LocalSystem - the SYSTEM grant covers it. Nothing else needs to write + # here; a user who can replace mysqld.exe owns the service account. + Protect-InstallRoot $MysqlRoot Write-Log 'MySQL binaries installed' 'OK' # --- config ------------------------------------------------------------- @@ -1003,6 +1047,11 @@ data, STOP: you are about to destroy a database. Write-Log 'initializing data directory' Invoke-Native $mysqld @("--defaults-file=$MysqlIni", '--initialize-insecure', '--console') 'MySQL initialize' Track 'mysql-datadir' $MysqlDataDir + # The data directory holds the users table and its password hashes, and it + # sits under ProgramData, which grants Users read by inheritance - the same + # exposure the backup directory already had to be locked against. mysqld + # runs as LocalSystem, so Administrators + SYSTEM is sufficient. + Protect-File $MysqlDataDir -Directory # --- service ------------------------------------------------------------ # Argument ORDER MATTERS: --install BEFORE --defaults-file. Verified @@ -1048,6 +1097,9 @@ directory to install cleanly. # with default ACLs and populated afterwards is readable in the gap. $sqlPath = Join-Path $AppRoot 'mysql-bootstrap.sql' New-Item -ItemType Directory -Force -Path $AppRoot | Out-Null + # Before the file goes in: this may be the first thing that creates AppRoot, + # and the file below holds the root and app database passwords. + Protect-InstallRoot $AppRoot New-Item -ItemType File -Force -Path $sqlPath | Out-Null Protect-File $sqlPath $sql = @" @@ -1150,8 +1202,17 @@ function Invoke-Stage2 { # Same class of failure as MySQL's --defaults-file. Never put a space in a # path this installer controls. $pyTarget = 'C:\Python314' + # Stage 4 needs it to grant the app pool read access; a venv's python.exe is + # a copy, but python314.dll and the stdlib still come from here. + $script:PyRoot = $pyTarget if (Test-Path (Join-Path $pyTarget 'python.exe')) { Write-Log "Python already present at $pyTarget" 'OK' + # NOT hardened: something else installed it and may depend on the ACL it + # has. Say so instead - the app would otherwise run on an interpreter + # any logged-on user can replace. + if (Test-UserWritableRoot $pyTarget) { + Write-Log "$pyTarget is writable by non-administrators; this installer did not create it, so it is left alone. Harden it or reinstall Python under an ACL only administrators can write." 'WARN' + } } elseif ($null -eq $pyInstaller) { Fail "no Python installer in $BundleRoot\python" } else { @@ -1164,6 +1225,9 @@ function Invoke-Stage2 { 'Include_test=0','AssociateFiles=0',"TargetDir=$pyTarget" ) 'Python install' -TimeoutSec 900 -OkExit 0,3010,1641 Track 'python' $pyTarget + # Ours, so ours to lock down. InstallAllUsers=1 puts it at C:\Python314 + # with the drive root's inherited Authenticated Users Modify. + Protect-InstallRoot $pyTarget } } $sysPy = Join-Path $pyTarget 'python.exe' @@ -1174,6 +1238,7 @@ function Invoke-Stage2 { New-Item -ItemType Directory -Path $AppRoot -Force | Out-Null Track 'dir' $AppRoot } + Protect-InstallRoot $AppRoot # --- upgrade detection --------------------------------------------------- $script:BundleVersion = Get-BundleVersion $script:InstalledVersion = Get-InstalledVersion @@ -2022,6 +2087,13 @@ has to come from the bundle either way. $ident = "IIS AppPool\$AppPool" Write-Log "granting $ident access" Invoke-Native 'icacls.exe' @($AppRoot,'/grant',"${ident}:(OI)(CI)RX",'/T','/C','/Q') 'ACL on AppRoot' + # The Python root no longer inherits from the drive root, so the pool has + # no access to it by default. The venv's python.exe is a copy; the DLL + # and the standard library are still read from the base install, so + # without this grant the site 500s on every request. + if ($script:PyRoot -and (Test-Path $script:PyRoot)) { + Invoke-Native 'icacls.exe' @($script:PyRoot,'/grant',"${ident}:(OI)(CI)RX",'/T','/C','/Q') 'ACL on Python root' + } foreach ($sub in @('logs','instance')) { Invoke-Native 'icacls.exe' @((Join-Path $AppRoot $sub),'/grant',"${ident}:(OI)(CI)M",'/T','/C','/Q') "ACL on $sub" } diff --git a/frontend/src/utils/codes.js b/frontend/src/utils/codes.js new file mode 100644 index 00000000..381cba5c --- /dev/null +++ b/frontend/src/utils/codes.js @@ -0,0 +1,202 @@ +// One place that knows how to draw a barcode or a QR code for a label. +// +// Seven views were generating codes - three in core, four in plugins - each +// importing qrcode/jsbarcode directly and each carrying its own answer to the +// same questions: what margin, what width, which error correction, how big a +// module has to be before a scanner can read it. The answers had already +// drifted (margin 0 in one place and 2 in another, width 150 against 160), and +// none of the label pages knew the printer-resolution arithmetic the Tech Tools +// generator worked out. +// +// So the knowledge lives here and the views pass what they need. Nothing about +// a specific label belongs in this file; nothing about how to render a code +// belongs anywhere else. +// +// WHY SVG, MOSTLY. A code renders to an SVG data URI rather than a canvas or a +// PNG for two reasons found the hard way on the label pages: an prints +// reliably where a live canvas or inline SVG does not, and SVG rasterises at +// the printer's resolution with hard module edges. A PNG gets downscaled to the +// label size and smears exactly the edges a scanner reads. The one exception is +// a QR with a logo composited into it, which needs a canvas to composite on. + +import QRCode from 'qrcode' +import JsBarcode from 'jsbarcode' + +import { getQrLogo } from '@/utils/siteSettings' + +// Label stock actually loaded in the printers, with the code size, padding and +// quiet zone that fit each one. Sizes are inches, because that is what the +// stock is sold as and what @page takes. +export const LABEL_PRESETS = [ + { id: 'zebra1x05', name: '1.00 x 0.50 in (Zebra gap)', labelwidth: 1.0, labelheight: 0.5, codesize: 0.4286, padding: 0, quiet: 0.035, labelfont: 7 }, + { id: 'zebra2x1', name: '2.00 x 1.00 in', labelwidth: 2.0, labelheight: 1.0, codesize: 0.85, padding: 0.03, quiet: 0.05, labelfont: 10 }, + { id: 'zebra225x125', name: '2.25 x 1.25 in', labelwidth: 2.25, labelheight: 1.25, codesize: 1.05, padding: 0.04, quiet: 0.06, labelfont: 11 }, + { id: 'zebra4x6', name: '4.00 x 6.00 in (shipping)', labelwidth: 4.0, labelheight: 6.0, codesize: 3.0, padding: 0.15, quiet: 0.12, labelfont: 20 }, + { id: 'badge', name: '2.13 x 3.38 in (badge)', labelwidth: 2.13, labelheight: 3.38, codesize: 1.5, padding: 0.15, quiet: 0.1, labelfont: 12 }, +] + +// CODE128 is what the scanners on the floor are configured for, and a label +// carries its text separately, so the barcode never renders its own. +export const BARCODE_DEFAULTS = { + format: 'CODE128', + displayValue: false, + margin: 0, + width: 2, + height: 100, +} + +export function svgDataUri(svg) { + return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg) +} + +/** + * Modules across a QR for this content and error correction, quiet zone + * excluded. 0 when the content cannot be encoded at all. + */ +export function qrModuleCount(text, errorCorrection = 'M') { + if (!text) return 0 + try { + return QRCode.create(text, { errorCorrectionLevel: errorCorrection }).modules.size + } catch { + return 0 + } +} + +/** + * Whether a QR of `sizeInches` can actually be printed at `dpi`. + * + * A scanner reads modules, not inches. Below about two printer dots per module + * the edges blur into each other and the code stops scanning reliably - and a + * fractional dots-per-module means some modules are a dot wider than others, + * which is the difference between a label that always works and one that works + * on most printers. `snapped` is the nearest size that divides evenly. + */ +export function qrFit(text, { sizeInches, dpi = 203, errorCorrection = 'M' } = {}) { + const modules = qrModuleCount(text, errorCorrection) + if (!modules || !sizeInches) return null + const dotsPerModule = (sizeInches * dpi) / modules + const whole = Math.floor(dotsPerModule) + return { + modules, + dotsPerModule, + wholeDots: whole, + snapped: whole > 0 ? (modules * whole) / dpi : 0, + moduleMm: whole > 0 ? (whole / dpi) * 25.4 : 0, + // Below two dots per module the code is not reliably scannable, whatever + // it looks like on screen. + tooSmall: whole < 2, + even: whole > 0 && Math.abs((modules * whole) / dpi - sizeInches) < 0.002, + } +} + +/** + * A QR as an SVG data URI. + * + * margin 0 by default: on a label the quiet zone is blank label supplied by the + * layout, so spending the code box on white the page cannot then adjust makes + * the printed code smaller for no benefit. A caller rendering to a screen, or + * onto something with no controlled surround, should pass a margin. + */ +export async function qrSvgDataUri(text, { errorCorrection = 'M', margin = 0 } = {}) { + const svg = await QRCode.toString(text, { + type: 'svg', + errorCorrectionLevel: errorCorrection, + margin, + }) + return svgDataUri(svg) +} + +/** + * A QR as a PNG data URL. Use only where a raster is genuinely needed - a + * composited logo, or an existing page built around . + */ +export async function qrPngDataUrl(text, { width = 160, margin = 0, errorCorrection = 'M' } = {}) { + return QRCode.toDataURL(text, { width, margin, errorCorrectionLevel: errorCorrection }) +} + +/** + * Render a barcode into an existing SVG or canvas element. + * + * Kept as a thin pass-through because several pages hold an element ref and + * want JsBarcode to draw straight into it; the value here is the shared + * defaults, not the indirection. + */ +export function barcodeInto(element, text, options = {}) { + JsBarcode(element, text, { ...BARCODE_DEFAULTS, ...options }) + return element +} + +/** A barcode as an SVG data URI, for pages that print an . */ +export function barcodeSvgDataUri(text, options = {}) { + const element = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + barcodeInto(element, text, options) + return svgDataUri(new XMLSerializer().serializeToString(element)) +} + +// -- QR with a logo composited in the middle --------------------------------- +// +// Moved here from views/print/qrLogo.js, which core label pages and the printer +// QR pages both reached into. The overlay image is a site setting; the monogram +// below is the last-resort fallback when that setting is empty or unreachable, +// so a label still carries a mark rather than a hole. + +const FALLBACK_LOGO_SVG = `` + +let logoImage = null + +function loadLogo(url) { + if (logoImage) return Promise.resolve(logoImage) + return new Promise(resolve => { + const img = new Image() + img.onload = () => { logoImage = img; resolve(img) } + img.onerror = () => { + const fallback = new Image() + fallback.onload = () => { logoImage = fallback; resolve(fallback) } + fallback.onerror = () => resolve(null) + fallback.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(FALLBACK_LOGO_SVG) + } + img.src = url + }) +} + +function drawLogoOverlay(canvas, logo) { + const canvasContext = canvas.getContext('2d') + const size = canvas.width + const logoSize = Math.round(size * 0.22) + const x = (size - logoSize) / 2 + const y = (size - logoSize) / 2 + + // White circle behind the mark, so it sits on the code rather than in it. + canvasContext.beginPath() + canvasContext.arc(size / 2, size / 2, logoSize / 2 + 4, 0, Math.PI * 2) + canvasContext.fillStyle = '#fff' + canvasContext.fill() + + if (logo) { + canvasContext.drawImage(logo, x, y, logoSize, logoSize) + } +} + +/** + * A QR with the configured logo composited in the centre, as a PNG data URL. + * + * Error correction H, because up to 30% of the code can be obscured and still + * decode - which is what makes covering the middle of it survivable at all. + * Empty qr_logo setting means no overlay. Returns '' on failure: a label with + * no code is better than a page that throws while printing a batch. + */ +export async function renderQrDataUrl(url, { width = 144, margin = 0 } = {}) { + const qrLogoUrl = await getQrLogo() + const canvas = document.createElement('canvas') + try { + await QRCode.toCanvas(canvas, url, { width, margin, errorCorrectionLevel: 'H' }) + if (qrLogoUrl) { + const logo = await loadLogo(qrLogoUrl) + drawLogoOverlay(canvas, logo) + } + return canvas.toDataURL('image/png') + } catch (err) { + console.error('QR error:', err) + return '' + } +} diff --git a/frontend/src/utils/codes.spec.js b/frontend/src/utils/codes.spec.js new file mode 100644 index 00000000..cf21818d --- /dev/null +++ b/frontend/src/utils/codes.spec.js @@ -0,0 +1,93 @@ +// The arithmetic in codes.js decides whether a printed label scans, and it is +// the part no amount of looking at a screen will verify - a QR that is fine at +// 96 dpi on a monitor can be unreadable at 203 dpi on 0.5in stock. + +import { describe, it, expect } from 'vitest' + +import { + LABEL_PRESETS, BARCODE_DEFAULTS, qrModuleCount, qrFit, svgDataUri, +} from './codes' + +describe('qrModuleCount', () => { + it('grows with the content', () => { + const short = qrModuleCount('AP-01') + const long = qrModuleCount('AP-01/'.repeat(20)) + expect(short).toBeGreaterThan(0) + expect(long).toBeGreaterThan(short) + }) + + it('grows with error correction, because the redundancy is stored in modules', () => { + expect(qrModuleCount('WKSTN0042', 'H')).toBeGreaterThanOrEqual( + qrModuleCount('WKSTN0042', 'L')) + }) + + it('returns 0 rather than throwing on empty content', () => { + expect(qrModuleCount('')).toBe(0) + expect(qrModuleCount(null)).toBe(0) + }) +}) + +describe('qrFit', () => { + it('calls a code too small when a module gets under two printer dots', () => { + // A long payload squeezed onto the 1.00 x 0.50in stock: the case the Tech + // Tools generator exists to warn about before 300 labels are printed. + const fit = qrFit('https://shopdb.example.net/assets/1234?from=label&sig=abcdef123456', { + sizeInches: 0.4286, dpi: 203, errorCorrection: 'H', + }) + expect(fit.tooSmall).toBe(true) + expect(fit.dotsPerModule).toBeLessThan(2) + }) + + it('reports a whole number of dots per module as even', () => { + const modules = qrModuleCount('WKSTN0042', 'M') + // Choose the size that divides exactly at 4 dots per module. + const fit = qrFit('WKSTN0042', { + sizeInches: (modules * 4) / 203, dpi: 203, errorCorrection: 'M', + }) + expect(fit.wholeDots).toBe(4) + expect(fit.even).toBe(true) + expect(fit.snapped).toBeCloseTo(fit.snapped, 5) + }) + + it('offers the nearest evenly-dividing size when the chosen one is uneven', () => { + const fit = qrFit('WKSTN0042', { sizeInches: 0.9, dpi: 203, errorCorrection: 'M' }) + expect(fit.even).toBe(false) + expect(fit.snapped).toBeLessThanOrEqual(0.9) + expect(fit.snapped * 203 / fit.modules).toBeCloseTo(fit.wholeDots, 6) + }) + + it('is null when there is nothing to measure', () => { + expect(qrFit('', { sizeInches: 1 })).toBeNull() + expect(qrFit('WKSTN0042', {})).toBeNull() + }) +}) + +describe('shared defaults', () => { + it('barcodes are CODE128 with no margin and no printed text', () => { + // The label draws its own caption, and the quiet zone is blank label. Both + // had drifted per-view before this module existed. + expect(BARCODE_DEFAULTS.format).toBe('CODE128') + expect(BARCODE_DEFAULTS.margin).toBe(0) + expect(BARCODE_DEFAULTS.displayValue).toBe(false) + }) + + it('every label preset is a complete, usable stock definition', () => { + expect(LABEL_PRESETS.length).toBeGreaterThan(0) + for (const preset of LABEL_PRESETS) { + expect(preset.id).toBeTruthy() + expect(preset.labelwidth).toBeGreaterThan(0) + expect(preset.labelheight).toBeGreaterThan(0) + // A code has to fit inside its label with its quiet zone on both sides. + expect(preset.codesize + 2 * preset.quiet).toBeLessThanOrEqual(preset.labelwidth) + } + }) +}) + +describe('svgDataUri', () => { + it('escapes markup so the URI survives being put in a src attribute', () => { + const uri = svgDataUri('') + expect(uri.startsWith('data:image/svg+xml;charset=utf-8,')).toBe(true) + expect(uri).not.toContain('<') + expect(uri).not.toContain('"') + }) +}) diff --git a/frontend/src/utils/siteSettings.js b/frontend/src/utils/siteSettings.js index 22180db2..c7ec67ef 100644 --- a/frontend/src/utils/siteSettings.js +++ b/frontend/src/utils/siteSettings.js @@ -119,7 +119,7 @@ export async function getServicenowUrls() { // Printer hostname template. {ip} is replaced with the dash-separated IP. export async function getPrinterHostnameTemplate() { - return getSetting('printer_hostname_template', 'Printer-{ip}.printer.geaerospace.net') + return getSetting('printer_hostname_template', 'Printer-{ip}.printer.geaerospace.net') // ADR-015-OK: GE Aerospace-wide domain, and only the DEFAULT of a documented setting every site can override. } // Email domain appended to a contact SSO to build email/Teams links diff --git a/frontend/src/views/SetupWizard.vue b/frontend/src/views/SetupWizard.vue index 4b943968..a4898dd6 100644 --- a/frontend/src/views/SetupWizard.vue +++ b/frontend/src/views/SetupWizard.vue @@ -19,7 +19,7 @@

Used for the dashboard header and QR / absolute links.

- +
diff --git a/frontend/src/views/print/AssetLabel.vue b/frontend/src/views/print/AssetLabel.vue index 14604f9d..b0767ae7 100644 --- a/frontend/src/views/print/AssetLabel.vue +++ b/frontend/src/views/print/AssetLabel.vue @@ -128,8 +128,7 @@ - - + + + + + diff --git a/plugins/printers/frontend/views/PrinterQRSingle.vue b/plugins/printers/frontend/views/PrinterQRSingle.vue index c41c91a9..b5b35053 100644 --- a/plugins/printers/frontend/views/PrinterQRSingle.vue +++ b/plugins/printers/frontend/views/PrinterQRSingle.vue @@ -1,187 +1,187 @@ - - - - - + + + + + diff --git a/plugins/slides/api/routes.py b/plugins/slides/api/routes.py index 40c5ccdf..dc42a993 100644 --- a/plugins/slides/api/routes.py +++ b/plugins/slides/api/routes.py @@ -9,12 +9,13 @@ have it by default). import os import re -from flask import Blueprint, request, current_app, jsonify, send_from_directory +from flask import Blueprint, request, current_app, jsonify from flask_jwt_extended import jwt_required from werkzeug.utils import secure_filename from shopdb.api import (db, success_response, error_response, ErrorCodes, require_permission) +from shopdb.api import send_upload from ..models import TvSlide @@ -81,7 +82,7 @@ def serve_image(surface, filename): directory = _surface_dir(surface) if not os.path.isfile(os.path.join(directory, safe)): return error_response(ErrorCodes.NOT_FOUND, 'Slide not found', http_code=404) - return send_from_directory(directory, safe) + return send_upload(directory, safe) # ============================================================================= diff --git a/plugins/tools/frontend/views/CodeGenerator.vue b/plugins/tools/frontend/views/CodeGenerator.vue index 3b15fca6..6be1b832 100644 --- a/plugins/tools/frontend/views/CodeGenerator.vue +++ b/plugins/tools/frontend/views/CodeGenerator.vue @@ -163,20 +163,14 @@ ') + path = directory / 'probe-headers.svg' if hasattr(directory, '__truediv__') \ + else None + if path is None: + import os + path = os.path.join(directory, 'probe-headers.svg') + with open(path, 'w') as handle: + handle.write(svg) + else: + path.write_text(svg) + + try: + resp = client.get('/api/applications/image/probe-headers.svg') + assert resp.status_code == 200 + assert resp.headers.get('Content-Security-Policy') == UPLOAD_CSP + assert resp.headers.get('X-Content-Type-Options') == 'nosniff' + finally: + import os + os.remove(path) diff --git a/tests/test_docs_publishable.py b/tests/test_docs_publishable.py index 84f43394..ddaffeca 100644 --- a/tests/test_docs_publishable.py +++ b/tests/test_docs_publishable.py @@ -49,6 +49,29 @@ FORBIDDEN = [ ('root' + 'password', 'contains a database root password'), (r'\b' + 'cla' + 'ude' + r'\b', 'names an LLM assistant'), (r'\b' + 'anthro' + 'pic' + r'\b', 'names an LLM vendor'), + # ADR-015. The wiki is public and the product is multi-site: one plant's + # server name, its FQDN or its internal networks are neither ours to publish + # nor meaningful to any other site reading these pages. Assembled from + # fragments for the same reason as the terms above. + ('tsg' + 'wp00525', 'names a production server'), + (r'\b' + r'172\.16\.' + r'\d', 'names an internal network'), + # A tool-call fragment reached the end of an ACCEPTED ADR and published to + # the wiki. It says nothing to a reader and everything about how the + # document was produced, which is not what a decision record is for. + ('', 'contains a tool-call artifact'), + ('', 'contains a tool-call artifact'), + # The site the product is developed at. Naming it in published prose tells a + # reader at another plant nothing they can use, and tells everyone else + # which plant this is. Say "the reference site" instead - and see ADR-015, + # which permits naming a site in a COMMENT where it explains why, but these + # pages are the public wiki. + ('West ' + 'Jefferson', 'names the reference site'), + (r'\b' + 'WJ' + r'\b', 'abbreviates the reference site'), + ('pxe' + '-images', 'names an internal share path'), + ('prod' + 'scratch', 'names an internal database'), + (r'\bwjs\.' + r'geaerospace\.net\b', 'names a site FQDN'), + (r'10\.134\.48\.', 'names an internal network'), + (r'10\.48\.249\.', 'names an internal network'), ] # Generated API surface. Not prose, not hand-edited, and regenerated from the diff --git a/tests/test_docs_versions.py b/tests/test_docs_versions.py new file mode 100644 index 00000000..c4ea061b --- /dev/null +++ b/tests/test_docs_versions.py @@ -0,0 +1,115 @@ +"""No document types a version or a count that the code already knows. + +Nine documents carried a hand-typed contract version. Every one was stale, and +one of them was load-bearing: PLUGIN-EXTERNAL-REPO.md told an external author to +pin `>=0.13.0,<0.14.0` against a contract at 0.19.0, so a plugin built by +following the guide is refused by the loader at startup. The plugin count was +wrong in six more. + +A number that is copied is a number that goes stale, and prose gives no signal +about which lines are still true. the PROJECT-MAP wiki page is generated for exactly +this, so a document points at it instead of restating it. + +This test is the rule. Where a version genuinely belongs in prose - the contract +history table, a changelog entry, an ADR recording what was decided when - it is +recording the PAST, which does not go stale. +""" + +import re +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[1] +DOCS = REPO / 'docs' + +pytestmark = pytest.mark.skipif( + not DOCS.is_dir(), + reason='no docs/ in this checkout - it is excluded from publication') + +# The map is generated and the changelog records history; both are allowed to +# carry literal versions. CONTRACT-STABILITY's table is a history of what each +# contract version DID, which is the past and stays true. +EXEMPT = {'PROJECT-MAP.md', 'CONTRACT-STABILITY.md'} + +ASSIGNMENT = re.compile(r"__(?:contract_)?version__\s*=\s*['\"]([0-9]+\.[0-9]+\.[0-9]+)['\"]") +NARRATED = re.compile( + r"__(?:contract_)?version__[^\n]{0,60}?\b([0-9]+\.[0-9]+\.[0-9]+)\b" + r"|\b(?:contract|version) is (?:at|currently)\s+\*{0,2}([0-9]+\.[0-9]+\.[0-9]+)") +PLUGIN_COUNT = re.compile(r'\b(\d+)\s+bundled plugins\b', re.I) + + +def documentation_files(): + return sorted(p for p in DOCS.rglob('*.md') if p.name not in EXEMPT) + + +def current_versions(): + text = (REPO / 'shopdb' / '__init__.py').read_text() + return { + name: re.search(r"^%s\s*=\s*['\"]([^'\"]+)['\"]" % name, text, re.M).group(1) + for name in ('__version__', '__contract_version__') + } + + +def bundled_plugin_count(): + return len(list((REPO / 'plugins').glob('*/manifest.json'))) + + +def test_no_document_declares_a_version_literal(): + """`__contract_version__ = '0.13.0'` in prose is a promise the code breaks.""" + offenders = [] + for path in documentation_files(): + for number, line in enumerate(path.read_text(errors='replace').splitlines(), 1): + if ASSIGNMENT.search(line): + offenders.append('%s:%d %s' % (path.relative_to(REPO), number, line.strip()[:90])) + assert not offenders, ( + 'These documents declare a version literal. Point at the PROJECT-MAP wiki page, ' + 'which is generated:\n ' + '\n '.join(offenders)) + + +def test_a_narrated_version_matches_the_code(): + """Prose that states the CURRENT version has to be right.""" + versions = set(current_versions().values()) + offenders = [] + for path in documentation_files(): + # An ADR states the version a decision was taken AT. That is a record of + # the past, not a claim about today, and rewriting it would falsify the + # record this project keeps ADRs for. + if path.parent.name == 'adr': + continue + for number, line in enumerate(path.read_text(errors='replace').splitlines(), 1): + for match in NARRATED.finditer(line): + found = match.group(1) or match.group(2) + if found and found not in versions: + offenders.append('%s:%d says %s %s' + % (path.relative_to(REPO), number, found, line.strip()[:70])) + assert not offenders, ( + 'These lines state a current version that no longer matches ' + 'shopdb/__init__.py:\n ' + '\n '.join(offenders)) + + +def test_a_stated_plugin_count_matches_the_tree(): + actual = bundled_plugin_count() + offenders = [] + for path in documentation_files(): + for number, line in enumerate(path.read_text(errors='replace').splitlines(), 1): + for match in PLUGIN_COUNT.finditer(line): + if int(match.group(1)) != actual: + offenders.append('%s:%d says %s, tree has %d' + % (path.relative_to(REPO), number, match.group(1), actual)) + assert not offenders, ( + 'These documents count plugins by hand. The count is in ' + 'the PROJECT-MAP wiki page:\n ' + '\n '.join(offenders)) + + +def test_the_map_itself_is_current(): + """The pointer target has to be right, or every document pointing at it is + wrong at one remove.""" + mapfile = DOCS / 'PROJECT-MAP.md' + assert mapfile.is_file(), 'the PROJECT-MAP wiki page is missing; run scripts/gen_project_map.py' + text = mapfile.read_text() + for name, version in current_versions().items(): + assert version in text, ( + '%s is %s in the code but the generated map does not carry it. ' + 'Run: venv/bin/python scripts/gen_project_map.py' % (name, version)) + assert str(bundled_plugin_count()) in text diff --git a/tests/test_openapi_spec.py b/tests/test_openapi_spec.py new file mode 100644 index 00000000..82a8191f --- /dev/null +++ b/tests/test_openapi_spec.py @@ -0,0 +1,123 @@ +"""The generated spec has to be usable by a machine, not just readable. + +docs/openapi.json is the only machine-readable description of this API, and it +is what the MCP server builds its tools from. It was published with no +`parameters` and no `requestBody` on any operation - invalid OpenAPI 3.1, and +worse in practice: a tool built from an operation with no parameters has nowhere +to put the id its caller supplied, so every argument was dropped in silence. A +call for one asset returned the list. + +These tests are the floor. They check the spec's shape, not its prose. +""" + +import json +import re +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[1] +SPEC = REPO / 'docs' / 'openapi.json' + +pytestmark = pytest.mark.skipif( + not SPEC.is_file(), + reason='no generated spec in this checkout') + + +@pytest.fixture(scope='module') +def spec(): + return json.loads(SPEC.read_text()) + + +def operations(spec): + for path, item in spec['paths'].items(): + for verb, operation in item.items(): + yield path, verb, operation + + +def test_every_templated_path_declares_its_parameters(spec): + """`/api/assets/{id}` with no parameters is not a description of anything.""" + offenders = [ + '%s %s' % (verb.upper(), path) + for path, verb, operation in operations(spec) + if '{' in path and not operation.get('parameters') + ] + assert not offenders, ( + 'These operations have a templated path and no parameters, so a ' + 'generated client cannot fill it in:\n ' + '\n '.join(offenders[:15])) + + +def test_declared_parameters_match_the_template(spec): + """A parameter that is not in the path, or a placeholder with no parameter, + is the same defect wearing different clothes.""" + offenders = [] + for path, verb, operation in operations(spec): + intemplate = set(re.findall(r'\{([^}]+)\}', path)) + declared = {p['name'] for p in operation.get('parameters', []) + if p.get('in') == 'path'} + if intemplate != declared: + offenders.append('%s %s: template %s, declared %s' + % (verb.upper(), path, sorted(intemplate), sorted(declared))) + assert not offenders, '\n '.join(offenders[:15]) + + +def test_write_verbs_can_carry_a_body(spec): + offenders = [ + '%s %s' % (verb.upper(), path) + for path, verb, operation in operations(spec) + if verb in ('post', 'put') and 'requestBody' not in operation + ] + assert not offenders, ( + 'These write operations declare no request body, so a client built ' + 'from this spec cannot send one:\n ' + '\n '.join(offenders[:15])) + + +def test_optional_auth_is_expressed_as_optional(spec): + """An endpoint that works logged out must not be published as requiring a + token - that is a lie to every reader, and it is the majority case here.""" + optional = [operation for _, _, operation in operations(spec) + if any(item == {} for item in operation.get('security') or [])] + assert len(optional) > 50, ( + 'The inventory documents over a hundred jwt-optional endpoints; the ' + 'spec should express them with an empty security requirement ' + 'alongside the scheme, not as bearer-required.') + + +def test_an_endpoint_that_needs_no_token_cannot_fail_with_401(spec): + offenders = [ + '%s %s' % (verb.upper(), path) + for path, verb, operation in operations(spec) + if not operation.get('security') and '401' in operation.get('responses', {}) + ] + assert not offenders, '\n '.join(offenders[:15]) + + +def test_every_operation_documents_failure(spec): + offenders = [ + '%s %s' % (verb.upper(), path) + for path, verb, operation in operations(spec) + if 'default' not in operation.get('responses', {}) + ] + assert not offenders, ( + 'These operations document only success:\n ' + '\n '.join(offenders[:15])) + + +def test_the_envelopes_are_defined_and_referenced(spec): + schemas = spec.get('components', {}).get('schemas', {}) + assert 'SuccessEnvelope' in schemas and 'ErrorEnvelope' in schemas + # The error shape is the one people get wrong: nested under data. + error = schemas['ErrorEnvelope']['properties']['data']['properties']['error'] + assert set(error['required']) == {'code', 'message'} + + +def test_summaries_are_not_cut_mid_word(spec): + """A truncated summary is what a tool picker shows as the whole description + of a call.""" + offenders = [] + for path, verb, operation in operations(spec): + summary = operation.get('summary', '') + if summary.endswith('...'): + body = summary[:-3] + if body and not body[-1].isalnum() and body[-1] not in ')]"\'': + offenders.append('%s %s: %r' % (verb.upper(), path, summary[-40:])) + assert not offenders, '\n '.join(offenders[:15]) diff --git a/tests/test_plugin_migrations.py b/tests/test_plugin_migrations.py index 15bc4068..198add13 100644 --- a/tests/test_plugin_migrations.py +++ b/tests/test_plugin_migrations.py @@ -52,7 +52,7 @@ EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline' EXPECTED_HEAD_REVISION['backups'] = 'backups0003clearlastseen' # geenforce adds the content-addressed blob store (manifestblobs) on top of its # baseline. -EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0003subtype' +EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0004minlib' # printers adds the printersupplyalerts crossing-state table on top of its anchor. EXPECTED_HEAD_REVISION['printers'] = 'printers0002supplyalerts' # machines (renamed from equipment) keeps its original anchor id and adds the @@ -64,7 +64,7 @@ EXPECTED_HEAD_REVISION['employees'] = 'employees0002photo' EXPECTED_HEAD_REVISION['usb'] = 'usb0002dropmachineid' # network links a device to a catalog model, which is where its photo comes # from - machines, PCs and printers already had that link. -EXPECTED_HEAD_REVISION['network'] = 'network0002model' +EXPECTED_HEAD_REVISION['network'] = 'network0003prefix' # printedparts is post-cutover: its 0001 really creates its tables; 0004 adds # the per-transaction revision column. EXPECTED_HEAD_REVISION['printedparts'] = 'printedparts0004txnrev' diff --git a/tests/test_plugins/test_backups.py b/tests/test_plugins/test_backups.py index 6b086802..e2123cc3 100644 --- a/tests/test_plugins/test_backups.py +++ b/tests/test_plugins/test_backups.py @@ -206,12 +206,19 @@ def test_partmarker_kind_is_share_backed_and_not_renderable(): assert kind.parse(b'anything') is None -def test_partmarker_sharedir_is_under_the_sfld_backups_root(): - path = registry.getkind('partmarker').sharedir('lathe', '3204') - assert path.startswith(registry.DEFAULTSHAREROOT) +def test_partmarker_sharedir_is_under_the_configured_backups_root(): + root = r'\\fileserver\shopfloor\backups' + path = registry.getkind('partmarker').sharedir('lathe', '3204', shareroot=root) + assert path.startswith(root) assert path.endswith(r'lathe\3204\partmarker') +def test_sharedir_is_empty_when_no_root_is_configured(): + """No default root ships (ADR-015), so an unconfigured site gets nothing + rather than a path composed from another site's file server.""" + assert registry.getkind('partmarker').sharedir('lathe', '3204') == '' + + def test_getkind_is_case_insensitive_and_returns_none_when_unknown(): assert registry.getkind('NTLARS') is not None assert registry.getkind('nosuchkind') is None @@ -981,3 +988,21 @@ def test_the_panel_shows_verified_beside_captured(bk_app, bk_plugin): listpanel = next(p for p in panels if p.get('render') == 'list') labels = [m['label'] for m in listpanel['map']['meta']] assert 'Captured' in labels and 'Verified' in labels + + +def test_retention_reads_the_key_the_settings_page_writes(app, db): + """The plugin helper namespaces to `plugin.backups.`; the declared key + is bare. Reading through the helper meant retention was configurable and + inert - it always fell back to 0, which means keep everything.""" + from shopdb.core.models import Setting + from plugins.backups.plugin import BackupsPlugin + + declared = {entry['key'] for entry in BackupsPlugin().get_settings_defaults()} + assert 'backups_retentioncount' in declared + + Setting.set('backups_retentioncount', 5) + db.session.commit() + assert int(Setting.get('backups_retentioncount', 0)) == 5 + # The helper cannot see it, which is the whole defect: it looks under + # plugin.backups.backups_retentioncount, which nothing writes. + assert int(BackupsPlugin().get_setting('backups_retentioncount', 0) or 0) == 0 diff --git a/tests/test_plugins/test_collector_measuringtool_adoption.py b/tests/test_plugins/test_collector_measuringtool_adoption.py new file mode 100644 index 00000000..dc2a4daf --- /dev/null +++ b/tests/test_plugins/test_collector_measuringtool_adoption.py @@ -0,0 +1,232 @@ +"""The collector ADOPTS an existing instrument instead of minting a twin. + +Minting derived the asset number from the HOSTNAME, so a permanent instrument +inherited the identity of whichever PC drove it that week. And because the +collector keyed idempotency on its OWN label, it could not see a tool it had not +created - on prod that left 43 legacy MT-#### tools shadowed by minted +-CMM twins, three records deep for some CMMs. +""" + +import pytest + +from shopdb.core.models import Asset, AssetType +from shopdb.core.models.relationship import RelationshipType, AssetRelationship +from plugins.measuringtools.models import MeasuringTool + +KEY = 'collector-adoption-key' + + +@pytest.fixture +def collector_key(app): + old = app.config.get('COLLECTOR_API_KEY') + app.config['COLLECTOR_API_KEY'] = KEY + yield KEY + app.config['COLLECTOR_API_KEY'] = old + + +@pytest.fixture +def rig(db): + """Asset types, a controls relationship type, and nothing else.""" + for name in ('computer', 'measuring_tool'): + if not AssetType.query.filter_by(assettype=name).first(): + db.session.add(AssetType(assettype=name)) + if not RelationshipType.query.filter_by(relationshiptype='controls').first(): + db.session.add(RelationshipType(relationshiptype='controls')) + db.session.commit() + + +def _tool(db, assetnumber): + """A measuring tool asset the collector did NOT create.""" + assettype = AssetType.query.filter_by(assettype='measuring_tool').first() + asset = Asset(assetnumber=assetnumber, assettypeid=assettype.assettypeid) + db.session.add(asset) + db.session.flush() + db.session.add(MeasuringTool(assetid=asset.assetid)) + db.session.commit() + return asset + + +def _report(client, key, hostname, **extra): + payload = {'hostname': hostname, 'pctype': 'gea-shopfloor-cmm'} + payload.update(extra) + return client.post('/api/collector/computers', json=payload, + headers={'X-API-Key': key}) + + +def _tools_controlled(hostname): + pc = Asset.query.filter_by(assetnumber=hostname).first() + controls = RelationshipType.query.filter_by(relationshiptype='controls').first() + toolids = {r.assetid for r in MeasuringTool.query.with_entities(MeasuringTool.assetid)} + rows = AssetRelationship.query.filter( + AssetRelationship.sourceassetid == pc.assetid, + AssetRelationship.relationshiptypeid == controls.relationshiptypeid, + AssetRelationship.isactive.is_(True)).all() + return [Asset.query.get(r.targetassetid).assetnumber + for r in rows if r.targetassetid in toolids] + + +def test_a_named_instrument_is_adopted_not_minted(client, db, rig, collector_key): + """measuringtool-id.txt names the instrument; nothing new is created.""" + _tool(db, 'MT-6227') + assert _report(client, collector_key, 'FB7FB1V3', + measuringtoolid='MT-6227').status_code in (200, 201) + + assert _tools_controlled('FB7FB1V3') == ['MT-6227'] + assert Asset.query.filter_by(assetnumber='FB7FB1V3-CMM').first() is None + + +def test_a_tool_already_controlled_is_adopted(client, db, rig, collector_key): + """The legacy MT-#### case: linked by the import, invisible to the collector. + + The link must pre-date the first report - that is the real sequence, and it + is the whole point. Reporting first would mint the twin before adoption ever + had a chance, which is exactly the bug. + """ + from plugins.computers.models import Computer + tool = _tool(db, 'MT-6224') + computertype = AssetType.query.filter_by(assettype='computer').first() + pc = Asset(assetnumber='FJPX1GT3', assettypeid=computertype.assettypeid) + db.session.add(pc) + db.session.flush() + db.session.add(Computer(assetid=pc.assetid, hostname='FJPX1GT3')) + controls = RelationshipType.query.filter_by(relationshiptype='controls').first() + db.session.add(AssetRelationship( + sourceassetid=pc.assetid, targetassetid=tool.assetid, + relationshiptypeid=controls.relationshiptypeid, label=None)) + db.session.commit() + + assert _report(client, collector_key, 'FJPX1GT3').status_code in (200, 201) + assert _tools_controlled('FJPX1GT3') == ['MT-6224'] + assert Asset.query.filter_by(assetnumber='FJPX1GT3-CMM').first() is None + + +def test_the_reported_bay_id_is_adopted(client, db, rig, collector_key): + """The CMM case: cmmid.txt already reports CMM4, which is a real asset.""" + _tool(db, 'CMM4') + assert _report(client, collector_key, 'FB7FB1V3', + machinenumber='CMM4').status_code in (200, 201) + + assert 'CMM4' in _tools_controlled('FB7FB1V3') + assert Asset.query.filter_by(assetnumber='FB7FB1V3-CMM').first() is None + + +def test_minting_still_happens_when_nothing_exists(client, db, rig, collector_key): + """A genuinely new instrument has no identity to adopt - last resort.""" + assert _report(client, collector_key, 'FNEWPC01').status_code in (200, 201) + assert _tools_controlled('FNEWPC01') == ['FNEWPC01-CMM'] + + +def test_a_named_instrument_that_does_not_exist_warns_and_mints_nothing_wrong( + client, db, rig, collector_key): + """A typo must not mint a phantom instrument nobody can account for.""" + resp = _report(client, collector_key, 'FTYPO001', measuringtoolid='MT-NOPE') + assert resp.status_code in (200, 201) + body = resp.get_json()['data'] + assert any('MT-NOPE' in w for w in body.get('warnings', [])) + + +def test_adoption_is_idempotent(client, db, rig, collector_key): + _tool(db, 'MT-6331') + for _ in range(3): + _report(client, collector_key, 'F9LQSDB4', measuringtoolid='MT-6331') + assert _tools_controlled('F9LQSDB4') == ['MT-6331'] + + +def test_a_named_instrument_supersedes_a_minted_twin( + client, db, rig, collector_key): + """The migration case: the twin already exists when the file names the real + one. + + This is the state prod is actually in - 43 minted twins already linked - so + adoption that only works on a clean PC fixes nothing. The twin's link has to + be archived, or the PC reads as controlling two instruments. + """ + _tool(db, 'MT-6227') + _report(client, collector_key, 'FB7FB1V3') + assert _tools_controlled('FB7FB1V3') == ['FB7FB1V3-CMM'] + + _report(client, collector_key, 'FB7FB1V3', measuringtoolid='MT-6227') + assert _tools_controlled('FB7FB1V3') == ['MT-6227'] + + +def test_a_named_id_that_is_not_a_measuring_tool_is_refused( + client, db, rig, collector_key): + """A machine number in measuringtool-id.txt must not link the machine. + + The name resolving is not enough: linked under the measuring-tool label it + would read as an instrument everywhere downstream, on a row the machine sync + also owns. + """ + machinetype = AssetType.query.filter_by(assettype='machine').first() + if machinetype is None: + machinetype = AssetType(assettype='machine') + db.session.add(machinetype) + db.session.flush() + db.session.add(Asset(assetnumber='2335', assettypeid=machinetype.assettypeid)) + db.session.commit() + + resp = _report(client, collector_key, 'FTYPO777', measuringtoolid='2335') + assert resp.status_code in (200, 201) + body = resp.get_json()['data'] + assert any('2335' in w for w in body.get('warnings', [])) + assert '2335' not in _all_controlled('FTYPO777') + + +def test_a_cmm_reporting_its_bay_survives_a_second_cycle( + client, db, rig, collector_key): + """The instrument IS the reported bay, so both syncs want the same triple. + + Adoption used to relabel the machine sync's row. The machine sync finds its + rows BY that label, so cycle 2 built a second row for the same + (pc, tool, controls) triple and MySQL rejected it on the unique constraint: + 200, then 500 on every report after. The machine link must survive as a + machine link. + """ + _tool(db, 'CMM4') + for _ in range(3): + resp = _report(client, collector_key, 'FB7FB1V3', machinenumber='CMM4') + assert resp.status_code in (200, 201) + + assert _all_controlled('FB7FB1V3') == ['CMM4'] + assert Asset.query.filter_by(assetnumber='FB7FB1V3-CMM').first() is None + + pc = Asset.query.filter_by(assetnumber='FB7FB1V3').first() + tool = Asset.query.filter_by(assetnumber='CMM4').first() + rows = AssetRelationship.query.filter_by( + sourceassetid=pc.assetid, targetassetid=tool.assetid).all() + assert len(rows) == 1 + assert rows[0].label == 'collector:machine' + + +def test_a_reported_identifier_is_not_a_wildcard(client, db, rig, collector_key): + """`_` and `%` in a reported identifier must match themselves, nothing else. + + These values come from a text file on a shopfloor PC. Resolved with ilike, + 'MT-600_' matched MT-6001 and a bare '%' matched whatever active asset came + first - an identifier resolving to an asset it does not name, then linked to + as though it did. + """ + _tool(db, 'MT-6001') + resp = _report(client, collector_key, 'FWILD001', measuringtoolid='MT-600_') + assert resp.status_code in (200, 201) + assert 'MT-6001' not in _tools_controlled('FWILD001') + + resp = _report(client, collector_key, 'FWILD002', measuringtoolid='%') + assert resp.status_code in (200, 201) + assert 'MT-6001' not in _tools_controlled('FWILD002') + + +def _all_controlled(hostname): + """Every active controls target, tool or not - the refusal cases need to see + a link the tool-only helper would filter out.""" + pc = Asset.query.filter_by(assetnumber=hostname).first() + controls = RelationshipType.query.filter_by(relationshiptype='controls').first() + rows = AssetRelationship.query.filter( + AssetRelationship.sourceassetid == pc.assetid, + AssetRelationship.relationshiptypeid == controls.relationshiptypeid, + AssetRelationship.isactive.is_(True)).all() + targets = [r.targetassetid for r in rows] + if not targets: + return [] + return sorted(a.assetnumber for a in + Asset.query.filter(Asset.assetid.in_(targets)).all()) diff --git a/tests/test_plugins/test_collector_partmarker_links.py b/tests/test_plugins/test_collector_partmarker_links.py new file mode 100644 index 00000000..151acc28 --- /dev/null +++ b/tests/test_plugins/test_collector_partmarker_links.py @@ -0,0 +1,97 @@ +"""A part-marker PC keeps reporting after the first cycle. + +Both links a part-marker PC produces are rows in assetrelationships, which is +unique on (source, target, type). Both were looked up by LABEL, so a row made by +hand or by the legacy import was invisible and the insert violated the +constraint; and the minted marker's asset number is derived from the PC, so it +could already be taken. Either way the PC reported 200 on the cycle that minted +the marker and 500 on every cycle after - the same shape as the CMM adoption bug +in test_collector_measuringtool_adoption.py. +""" + +import pytest + +from shopdb.core.models import Asset, AssetType +from shopdb.core.models.relationship import RelationshipType, AssetRelationship + +KEY = 'partmarker-links-key' + + +@pytest.fixture +def collector_key(app): + old = app.config.get('COLLECTOR_API_KEY') + app.config['COLLECTOR_API_KEY'] = KEY + yield KEY + app.config['COLLECTOR_API_KEY'] = old + + +@pytest.fixture +def rig(db): + for name in ('computer', 'machine'): + if not AssetType.query.filter_by(assettype=name).first(): + db.session.add(AssetType(assettype=name)) + for name in ('controls', 'partof'): + if not RelationshipType.query.filter_by(relationshiptype=name).first(): + db.session.add(RelationshipType(relationshiptype=name)) + db.session.commit() + + +def _report(client, key, hostname, **extra): + payload = {'hostname': hostname, 'pctype': 'gea-shopfloor-partmarker'} + payload.update(extra) + return client.post('/api/collector/computers', json=payload, + headers={'X-API-Key': key}) + + +def _operation(db, assetnumber): + machinetype = AssetType.query.filter_by(assettype='machine').first() + asset = Asset(assetnumber=assetnumber, assettypeid=machinetype.assettypeid) + db.session.add(asset) + db.session.commit() + return asset + + +def test_repeat_cycles_do_not_500(client, db, rig, collector_key): + _operation(db, '0613') + codes = [_report(client, collector_key, 'FMARK001', machinenumber='0613').status_code + for _ in range(3)] + assert codes == [codes[0]] * 3, codes + assert codes[0] in (200, 201), codes + + +def test_a_partof_row_under_another_label_is_not_duplicated( + client, db, rig, collector_key): + """A partof row made by hand or by the legacy import carries no label.""" + operation = _operation(db, '0615') + # First cycle mints the marker and links it. + assert _report(client, collector_key, 'FMARK002', + machinenumber='0615').status_code in (200, 201) + marker = Asset.query.filter_by(assetnumber='FMARK002-PARTMARKER').first() + assert marker is not None + partof = RelationshipType.query.filter_by(relationshiptype='partof').first() + row = AssetRelationship.query.filter_by( + sourceassetid=marker.assetid, targetassetid=operation.assetid, + relationshiptypeid=partof.relationshiptypeid).first() + assert row is not None + # Somebody relabels it (or it came from the import unlabelled). + row.label = None + db.session.commit() + + resp = _report(client, collector_key, 'FMARK002', machinenumber='0615') + assert resp.status_code in (200, 201), resp.status_code + rows = AssetRelationship.query.filter_by( + sourceassetid=marker.assetid, targetassetid=operation.assetid, + relationshiptypeid=partof.relationshiptypeid).all() + assert len(rows) == 1 + + +def test_a_taken_marker_asset_number_does_not_500(client, db, rig, collector_key): + """The minted number is derived from the PC, so it can already exist.""" + machinetype = AssetType.query.filter_by(assettype='machine').first() + db.session.add(Asset(assetnumber='FMARK003-PARTMARKER', + assettypeid=machinetype.assettypeid)) + db.session.commit() + _operation(db, '0617') + + resp = _report(client, collector_key, 'FMARK003', machinenumber='0617') + assert resp.status_code in (200, 201), resp.status_code diff --git a/tests/test_plugins/test_geenforce_manifest.py b/tests/test_plugins/test_geenforce_manifest.py index 32c5d1ea..9a1dbf62 100644 --- a/tests/test_plugins/test_geenforce_manifest.py +++ b/tests/test_plugins/test_geenforce_manifest.py @@ -125,7 +125,7 @@ def _set_allowlist(app, value): def test_ip_allowlist_allows_without_token(client, db, app): # An allowlisted caller reaches the manifest with NO token (vault trust). _seed_and_publish(app) - _set_allowlist(app, '127.0.0.0/8, 10.134.48.0/23') # test client is 127.0.0.1 + _set_allowlist(app, '127.0.0.0/8, 192.0.2.0/24') # test client is 127.0.0.1 resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm') assert resp.status_code == 200, resp.data assert b'Alpha' in resp.data @@ -152,9 +152,9 @@ def test_spoofed_forwarded_for_does_not_bypass_allowlist(client, db, app): # whose real IP (127.0.0.1) is NOT allowlisted must NOT gain token-less access # by forging X-Forwarded-For to an allowlisted address. _seed_and_publish(app) - _set_allowlist(app, '10.134.48.0/23') # test client 127.0.0.1 is NOT in it + _set_allowlist(app, '192.0.2.0/24') # test client 127.0.0.1 is NOT in it resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm', - headers={'X-Forwarded-For': '10.134.48.10'}) + headers={'X-Forwarded-For': '192.0.2.10'}) assert resp.status_code == 401, 'spoofed X-Forwarded-For bypassed the allowlist' diff --git a/tests/test_plugins/test_geenforce_publish_gate.py b/tests/test_plugins/test_geenforce_publish_gate.py index 0cf02ec1..88e98e90 100644 --- a/tests/test_plugins/test_geenforce_publish_gate.py +++ b/tests/test_plugins/test_geenforce_publish_gate.py @@ -263,3 +263,42 @@ def test_reported_subtype_keeps_an_unknown_value_verbatim(db): stored = ManifestEnforcementReport.query.filter_by( hostname='WJKIOSKODD', iscurrent=True).one() assert stored.subtype == 'Lobbby' + + +# -- what the gate compares against ------------------------------------------ +# +# The gate used manifestversion as the library floor. That is the manifest's own +# 'Version' field: for a share-imported manifest it happens to share the +# library's numbering, but a code-authored scope sets it freely - +# seed_display_scope writes '2.0' - so the fleet was compared against a number +# that says nothing about library features, and the gate passed on the very +# scope that most needed it. + +def test_a_declared_minlibversion_is_what_the_fleet_is_judged_against(db): + scope = _scope(manifestversion='2.0') + scope.minlibversion = '2.6' + db.session.flush() + assert service.required_libversion(scope) == '2.6' + + _report('KIOSK01', '2.4') + with pytest.raises(service.LibVersionTooOldError): + service.publish_scope('gea-shopfloor-cmm', 'runtime') + + +def test_the_floor_is_derived_from_the_features_the_manifest_uses(db): + """A scope written before minlibversion existed is still gated on what it + contains: a version-gated entry needs lib 2.6, whatever its own Version + field claims.""" + from plugins.geenforce.models import ManifestEntry + + scope = _scope(manifestversion='2.0') + scope.entries.append(ManifestEntry( + name='PC-DMIS 2023.2', entrytype='MSI', sortorder=0, + cmmversion='2023')) + db.session.flush() + assert service.required_libversion(scope) == '2.6' + + +def test_a_scope_using_nothing_special_falls_back_to_its_own_version(db): + scope = _scope(manifestversion='2.4') + assert service.required_libversion(scope) == '2.4' diff --git a/tests/test_plugins/test_network_assetnumber_generation.py b/tests/test_plugins/test_network_assetnumber_generation.py new file mode 100644 index 00000000..c77b0f9f --- /dev/null +++ b/tests/test_plugins/test_network_assetnumber_generation.py @@ -0,0 +1,108 @@ +"""Asset numbers generated from the device type's prefix. + +Every network device on this fleet already followed one convention applied by +hand - AP-, SW-, SVR-, IDF-, 45 records, no exceptions. +Generation codifies it so the same value is not typed twice, and must reproduce +those records exactly rather than inventing a new scheme. +""" + +import pytest + +from shopdb.extensions import db +from shopdb.core.models import AssetType +from plugins.network.models import NetworkDeviceType + + +def _devicetype(db, name, prefix): + """A device type, with the core asset type the create path needs present.""" + if not AssetType.query.filter_by(assettype='network_device').first(): + db.session.add(AssetType(assettype='network_device')) + devicetype = NetworkDeviceType(networkdevicetype=name, prefix=prefix) + db.session.add(devicetype) + db.session.commit() + return devicetype +from plugins.network.services.assetnumbers import generate, sanitize + + +@pytest.mark.parametrize('prefix,name,expected', [ + ('AP', 'AIRwdMUSwestj02', 'AP-AIRwdMUSwestj02'), + ('SW', 'AIRsdMUSwestj01', 'SW-AIRsdMUSwestj01'), + ('SVR', 'AVEWP4546V01', 'SVR-AVEWP4546V01'), +]) +def test_reproduces_the_existing_convention(prefix, name, expected): + assert generate(prefix, name) == expected + + +def test_a_type_with_no_prefix_gives_the_bare_name(): + """Nullable on purpose - nobody invents a prefix for a type that wants none.""" + assert generate(None, 'Bare Name') == 'Bare-Name' + assert generate('', 'Bare Name') == 'Bare-Name' + + +def test_an_already_prefixed_name_is_not_stacked(): + """IDF-03 under type IDF is IDF-03, never IDF-IDF-03. Real row on this data.""" + assert generate('IDF', 'IDF-03') == 'IDF-03' + assert generate('idf', 'idf-03') == 'idf-03' + + +def test_characters_that_break_a_business_key_are_stripped(): + """assetnumber lands in URLs and in joins. The existing IDF-Telco-Demarc-#1 + row shows a '#' reaching a unique key, which is what this prevents.""" + assert generate('IDF', 'Telco Demarc #1') == 'IDF-Telco-Demarc-1' + assert generate('APNL', 'Door 4 / West') == 'APNL-Door-4-West' + assert sanitize(' spaced out ') == 'spaced-out' + + +def test_nothing_to_build_from_returns_empty(): + """So the caller demands an explicit value instead of inventing one.""" + assert generate('AP', '') == '' + assert generate('AP', None) == '' + + +def test_create_generates_when_assetnumber_is_blank(client, db, auth_headers): + devicetype = _devicetype(db, 'Access Panel', 'APNL') + + resp = client.post('/api/network', json={ + 'name': 'Dock Door 4', + 'networkdevicetypeid': devicetype.networkdevicetypeid, + }, headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + assert resp.get_json()['data']['assetnumber'] == 'APNL-Dock-Door-4' + + +def test_an_explicit_assetnumber_always_wins(client, db, auth_headers): + """Adopt an external identifier where one exists - a vendor tag beats a + generated value, which is why generation only fills a BLANK field.""" + devicetype = _devicetype(db, 'Access Panel 2', 'APNL') + + resp = client.post('/api/network', json={ + 'assetnumber': 'VENDORTAG-99', + 'name': 'Dock Door 5', + 'networkdevicetypeid': devicetype.networkdevicetypeid, + }, headers=auth_headers) + assert resp.status_code == 201, resp.get_json() + assert resp.get_json()['data']['assetnumber'] == 'VENDORTAG-99' + + +def test_no_name_and_no_assetnumber_is_refused(client, db, auth_headers): + devicetype = _devicetype(db, 'Access Panel 3', 'APNL') + + resp = client.post('/api/network', json={ + 'networkdevicetypeid': devicetype.networkdevicetypeid, + }, headers=auth_headers) + assert resp.status_code == 400 + assert 'assetnumber is required' in resp.get_json()['data']['error']['message'] + + +def test_prefix_round_trips_through_the_type_api(client, db, auth_headers): + created = client.post('/api/network/types', json={ + 'networkdevicetype': 'Door Controller', 'prefix': 'DOOR', + }, headers=auth_headers) + assert created.status_code == 201, created.get_json() + assert created.get_json()['data']['prefix'] == 'DOOR' + + typeid = created.get_json()['data']['networkdevicetypeid'] + updated = client.put(f'/api/network/types/{typeid}', json={'prefix': 'DR'}, + headers=auth_headers) + assert updated.status_code == 200, updated.get_json() + assert updated.get_json()['data']['prefix'] == 'DR'