diff --git a/docs/ADOPTING-AT-ANOTHER-SITE.md b/docs/ADOPTING-AT-ANOTHER-SITE.md index 562d7ad..a36e13b 100644 --- a/docs/ADOPTING-AT-ANOTHER-SITE.md +++ b/docs/ADOPTING-AT-ANOTHER-SITE.md @@ -23,11 +23,11 @@ at all yet. |---|---| | GE-Enforce client (`Install-GEEnforce.ps1`, `Invoke-ShopdbEnforce.ps1`, `ShopdbEnforceClient.psm1`) | This repository, `plugins/geenforce/client/`. Present on any installed server under the install directory. | | `Report-AssetToShopDB.ps1` | `plugins/computers/client/`, so it versions with the collector contract it implements. It names no site: the server comes from `HKLM:\SOFTWARE\GE\ShopDB` `BaseUrl` (which Install-GEEnforce.ps1 writes) or `-ApiUrl`, and the NIC it reports is the one carrying the default route unless the site names its ranges via `-AllowedRanges` or the `CollectorRanges` registry value. | -| EventSaver (`EventSaver.scr`, `EventSaver.ini`, `EventSaver.cs`) | **Not in this repository.** Provided on request; the source is a single C# file that builds with the in-box .NET Framework compiler, so a site can rebuild it rather than trust a binary. See [EVENTSAVER.md](EVENTSAVER.md). | +| EventSaver (`EventSaver.cs`, `EventSaver.ini`) | `plugins/slides/client/`. The source is a single C# file that builds with the in-box .NET Framework compiler, so a site can rebuild it rather than trust a binary. The compiled `EventSaver.scr` is a release asset, not a file in the repository. Neither file names a site. See [EVENTSAVER.md](EVENTSAVER.md). | -Ask the maintainers for the two that are not here. A site that would rather not -run a binary it cannot rebuild should take EventSaver's source and compile it -locally - the build needs no SDK and is one command. +A site that would rather not run a binary it cannot rebuild should take +EventSaver's source and compile it locally - the build needs no SDK and is one +command. --- @@ -52,8 +52,13 @@ is simply absent; the server upserts on `hostname` and leaves the rest alone. | Input | Where it comes from | |---|---| -| Server URL | `-ApiUrl https:///api/collector/computers` | -| API key | `-ApiKey`, or `HKLM:\SOFTWARE\GE\ShopDB` value `CollectorKey` | +| Server URL | `-ApiUrl`, or `HKLM:\SOFTWARE\GE\ShopDB` value `BaseUrl`. A script downloaded from Settings has your URL already in it | +| API key | `-ApiKey`, or `HKLM:\SOFTWARE\GE\ShopDB` value `CollectorKey`. Never stamped into the script | +| Routable ranges | Optional. `-AllowedRanges '10.20.0.0/23,10.21.4.0/26'`, the `CollectorRanges` registry value, or the `computers_routableranges` setting. Unset reports the NIC carrying the default route, which is right at most sites | + +**Shortcut: download it pre-configured.** Settings > Computers > Asset reporter +generates this script with your server's URL and ranges already in the parameter +defaults, and shows its SHA-256. The key is deliberately not included. ### Minting the key on your ShopDB @@ -142,13 +147,27 @@ Configuration ShopdbCollector Ensure = 'Present' } + # Optional. Only for a site whose bays carry both a controller NIC and a + # corporate one AND whose default route is not the corporate NIC. Leave + # this resource out otherwise - the script picks the default-route NIC. + Registry CollectorRanges + { + Key = 'HKEY_LOCAL_MACHINE\SOFTWARE\GE\ShopDB' + ValueName = 'CollectorRanges' + ValueData = '10.20.0.0/23,10.21.4.0/26' + ValueType = 'String' + Ensure = 'Present' + } + Script ReportingTask { GetScript = { @{ Result = (Get-ScheduledTask -TaskName 'ShopDB asset report' -ErrorAction SilentlyContinue) } } TestScript = { [bool](Get-ScheduledTask -TaskName 'ShopDB asset report' -ErrorAction SilentlyContinue) } SetScript = { + # No -ApiUrl: BaseUrl above is where the script reads it from, + # and one source beats two that can disagree. $action = New-ScheduledTaskAction -Execute 'powershell.exe' ` - -Argument '-NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\ShopDB\Report-AssetToShopDB.ps1" -ApiUrl "https://shopdb.example.net/api/collector/computers"' + -Argument '-NoProfile -ExecutionPolicy Bypass -File "C:\ProgramData\ShopDB\Report-AssetToShopDB.ps1"' $trigger = New-ScheduledTaskTrigger -Daily -At 7am Register-ScheduledTask -TaskName 'ShopDB asset report' -Action $action ` -Trigger $trigger -User 'SYSTEM' -RunLevel Highest -Force diff --git a/docs/EVENTSAVER.md b/docs/EVENTSAVER.md index 6ced26b..2e42609 100644 --- a/docs/EVENTSAVER.md +++ b/docs/EVENTSAVER.md @@ -38,10 +38,12 @@ because the failure would otherwise be visible to the whole floor. **Folder mode** predates the server and stays as a fallback for a site with no ShopDB instance yet, or for content nobody wants in the database. -> If `EventSaver.ini` is missing, or both `url` and `folder` are blank, the -> binary falls back to a path compiled into `EventSaver.cs` - and that path -> belongs to the site it was first built for. Ship the ini. A missing ini is not -> a neutral default. +> If `EventSaver.ini` is missing, or both `url` and `folder` are blank, there is +> no source to read and the screensaver shows nothing. That is deliberate: the +> compiled-in fallback used to be the path of the site it was first built for, so +> a missing ini silently pointed a new site at someone else's file server. It is +> now empty, and failing visibly beats displaying the wrong site's slides. Ship +> the ini. ## What decides the running order @@ -98,8 +100,9 @@ covering something someone needed to see. ## Building it -No SDK required - it compiles with the in-box .NET Framework compiler on any -Windows 10 or 11 machine: +The source is `plugins/slides/client/EventSaver.cs`. No SDK required - it +compiles with the in-box .NET Framework compiler on any Windows 10 or 11 +machine: ``` C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe ^ diff --git a/docs/api-inventory.json b/docs/api-inventory.json index a5cea00..679e2db 100644 --- a/docs/api-inventory.json +++ b/docs/api-inventory.json @@ -1697,6 +1697,14 @@ "params": "protocol_id in path; body: any of protocolname, port, isactive", "purpose": "Update one remote-access protocol in the catalog (VNC, WinRM, RDP) that PCs report against", "example": "curl -X PATCH -H \"Authorization: Bearer $TOKEN\" -H 'Content-Type: application/json' -d '{\"port\":5900}' http://localhost:5001/api/computers/protocols/3" + }, + { + "method": "GET", + "path": "/api/computers/client-script", + "auth": "jwt + role:admin", + "params": "none", + "purpose": "The collector reporter (Report-AssetToShopDB.ps1) stamped with THIS site's values: the site base URL becomes the -ApiUrl default and computers_routableranges becomes -AllowedRanges, so it downloads ready to deploy. Only the parameter DEFAULTS are substituted - the copy in plugins/computers/client/ stays runnable, so there is no second version to drift - and everything stamped stays overridable by argument or registry. The collector key is deliberately NOT included: this file lands on every shop-floor PC, and a token spread across hundreds of bays cannot be rotated quietly. Serves text/plain as an attachment, with the SHA-256 in X-Script-Sha256 so a deployment can verify what it fetched", + "example": "curl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/client-script" } ], "surface": "plugin-computers" diff --git a/docs/openapi.json b/docs/openapi.json index 8dc18a2..5f86613 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "ShopDB Flask API", - "version": "0.10.0", + "version": "0.11.2", "description": "Asset-management API (core + plugins). Responses use a `success_response` envelope: `{status, data, meta}`. Auth: Bearer JWT (login or a managed PAT) for `jwt`/`admin`/`permission:*`; `X-API-Key` for collector/managed-token endpoints; public endpoints need neither." }, "servers": [ @@ -9892,6 +9892,44 @@ } } }, + "/api/computers/client-script": { + "get": { + "tags": [ + "plugin-computers" + ], + "summary": "The collector reporter (Report-AssetToShopDB.ps1) stamped with THIS site's values: the site base URL becomes the...", + "description": "The collector reporter (Report-AssetToShopDB.ps1) stamped with THIS site's values: the site base URL becomes the -ApiUrl default and computers_routableranges becomes -AllowedRanges, so it downloads ready to deploy. Only the parameter DEFAULTS are substituted - the copy in plugins/computers/client/ stays runnable, so there is no second version to drift - and everything stamped stays overridable by argument or registry. The collector key is deliberately NOT included: this file lands on every shop-floor PC, and a token spread across hundreds of bays cannot be rotated quietly. Serves text/plain as an attachment, with the SHA-256 in X-Script-Sha256 so a deployment can verify what it fetched\n\n**Auth:** jwt + role:admin\n\n**Params:** none\n\n**Example:**\n```\ncurl -H \"Authorization: Bearer $TOKEN\" http://localhost:5001/api/computers/client-script\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/measuringtools/types": { "get": { "tags": [ diff --git a/docs/proposals/printer-assignment.md b/docs/proposals/printer-assignment.md new file mode 100644 index 0000000..b6d466b --- /dev/null +++ b/docs/proposals/printer-assignment.md @@ -0,0 +1,161 @@ +# Proposal: assign printers to a PC in ShopDB, let the PC install them + +Status: PROPOSED. Not built. +Author: planning session 2026-08-18. + +## 1. What this is + +Today a printer reaches a shop-floor PC because a person walks up to it, opens +the printer installer, finds the printer on a floor plan and clicks it. That is +fine for someone choosing a printer, and wrong for a bay whose printers are a +property of the bay. + +This proposal makes the assignment data: edit a PC in ShopDB, tick the printers +that belong on it, mark one default. The PC converges on its next GE-Enforce +cycle - installing what is missing and setting the default - and keeps +converging, so a reimaged bay comes back with its printers and a bay that drifts +is corrected. + +The map installer stays, for the case it is actually good at: a person at an +unmanaged or office PC picking a printer that nobody assigned. + +## 2. Why it is worth doing + +- **The assignment becomes a record.** "Which printers does bay 2107 have" is a + question ShopDB can answer, and today it cannot. +- **A reimage stops costing a visit.** The bay reinstalls its own printers. +- **Drift is corrected, not just detected.** A queue deleted by a user comes + back. +- **It removes the walk-up from the common case.** The installer's map remains + for the uncommon one. + +## 3. What already exists + +Most of the model is in place, which is why this is a small feature rather than +a project. + +| piece | state | +|---|---| +| PC to printer link | `defaultprinter` asset relationship, seeded by `flask seed reference-data` | +| Default lookup | `GET /api/printers/pc-default?machine=NNNN` | +| Host lookup | `GET /api/computers/by-hostname/` | +| Printer model | `Printer.modelnumberid` - populated for 44 of 44 printers at the reference site | +| Driver record | `PrinterDriver` (name, `location` as SMB path or URL, optional `modelnumberid`) | +| Per-printer install path | `Printer.installpath` | +| Batch install | `GET /api/printers/install-batch?printerids=1,2,3` | +| Client transport | GE-Enforce manifest entries, `Type=PS1`, running as SYSTEM every cycle | +| Silent driver staging | Proven in `PrinterInstaller.iss`: trust the catalog's signing cert, then `pnputil /add-driver` | + +## 4. What has to be built + +### 4.1 One endpoint + +``` +GET /api/printers/for-host/ +``` + +Returns the printers assigned to that PC and which is default, each with what a +client needs to install it: queue name, host or IP, port, driver name, driver +location. + +Resolved by hostname, not machine number: the collector already upserts PCs by +hostname, and an office PC has no machine number. + +**Per-PC assignments must NOT go in the manifest.** Manifests are keyed by scope +and PC type and sync broadly; putting per-PC rows there would leak every bay's +configuration to every bay and grow without limit. One manifest entry runs one +script that asks the API what THIS host gets - the mirror image of +`Report-AssetToShopDB.ps1`. + +### 4.2 Two columns on `printerdrivers` + +- `drivername` - the driver's exact name as the INF declares it, e.g. + `HP Universal Printing PCL 6`. `Add-PrinterDriver` needs it verbatim, and a + mismatch is the usual failure. Deriving it by parsing the INF on hundreds of + bays is fragile; a human confirming it once in ShopDB is not. +- `installmethod` - `pnputil` or `dpinst`. See section 6: if Brother really is + absent from the fleet, everything is `pnputil` and this column can wait. + +### 4.3 UI on the PC form + +A printer picker writing `defaultprinter` (one) and an assignment list (many). +`AssetRelationships.vue` already edits relationships; this is a narrowed case of +it. + +### 4.4 One client script, in two contexts + +`Set-ShopdbPrinters.ps1`, shipped in `plugins/printers/client/` beside the +contract it consumes, and run as a manifest entry with `DetectionMethod=Always`. + +*As SYSTEM, every cycle:* + +1. `GET /api/printers/for-host/$env:COMPUTERNAME` +2. For each assigned printer with no queue: trust the driver catalog's cert, + `pnputil /add-driver`, create the port, create the queue +3. Write the desired default to `HKLM:\SOFTWARE\GE\ShopDB DefaultPrinter` +4. Ensure the per-user task exists + +*In the user's context, at logon and on a repeat:* + +5. Read that value, compare with the current default, set it if it differs, and + clear "Let Windows manage my default printer" - otherwise Windows silently + overrides the choice the next time someone prints elsewhere + +The default printer is per-user state, which is the only reason this needs two +contexts. Everything else is machine state and belongs to the cycle. + +Converge, do not reinstall: when the state matches, the script does nothing. +Nothing here needs the manifest to know when a printer changes, because the +desired state is fetched, not declared. + +## 5. Decisions to take before writing code + +1. **Never remove a queue by default.** A transient API failure would otherwise + strip printers fleet-wide. Deletion is an explicit opt-in, per PC. +2. **Enforced or set-once for the default?** Re-applying every cycle overrides a + user who chose their own default - correct for a locked bay, irritating on an + office PC. Set-once is Active Setup or RunOnce. Make it a per-PC-type flag + rather than one global answer. +3. **Failure is silent and safe**: unreachable API means change nothing, log, + exit 0 - the convention `Report-AssetToShopDB.ps1` already follows. + +## 6. What the fleet data says, and the one prerequisite + +The reference site's 44 printers are HP 26, Xerox 15, Zebra 1, HID 1, Epson 1. + +- **HP and Xerox are 41 of 44, and both have true universal drivers** (HP UPD, + Xerox Global Print Driver). One driver record each serves every queue of that + make. +- **There are no Brother printers at all**, yet the installer carries 208 files + of per-model Brother MFC-J inkjet drivers. Those are host-based GDI devices + with no Printer-class INF, which is the only reason a second staging method + (DPInst) exists. If production confirms no Brother, that payload and that code + path can both go. +- **Zebra, HID and Epson are one printer each**, and the HP DesignJet plotter is + a fourth special case - a PostScript device the UPD does not cover. + +**Prerequisite: populate `printerdrivers`.** It currently holds ONE row, and it +points at a per-model folder (`HP LaserJet Pro M607 Driver`) rather than the +universal driver - the opposite of how a UPD should be used. The table needs +roughly four rows: HP UPD, Xerox GPD, one per oddity, and DesignJet when its +payload is restored. Nothing in this proposal works until a printer can resolve +to a driver. + +## 7. Deployment constraint that shapes the design + +**The SFLD share is mounted only during GE-Enforce's cycle.** Any work touching +a share path must run as a manifest entry inside that cycle, never as its own +scheduled task. The failure is silent - the task reports 0 processed, 0 +installed, 0 failed - and it has cost a session before. + +This is why driver staging belongs in the cycle even though the per-user default +does not, and why "the assignment script schedules a task that installs drivers" +is the wrong shape. + +## 8. What this does not change + +- The printer installer keeps working, for walk-up and self-service. +- Nothing about how printers are modelled, mapped or reported. +- The collector contract. +- Sites not running GE-Enforce: the same endpoint suits an Intune remediation or + a DSC `Script` resource, since it is a plain HTTP GET and a PowerShell script. diff --git a/plugins/computers/api/routes.py b/plugins/computers/api/routes.py index 79d0a73..fc9f26a 100644 --- a/plugins/computers/api/routes.py +++ b/plugins/computers/api/routes.py @@ -1,13 +1,13 @@ """Computers plugin API endpoints.""" -from flask import Blueprint, request +from flask import Blueprint, request, Response, current_app from flask_jwt_extended import jwt_required from shopdb.api import db, Asset, AssetType, OperatingSystem, Application, AuditLog, Communication, CommunicationType, Setting, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query from ..models import Computer, ComputerType, ComputerInstalledApp, AccessProtocol, ComputerAccess -from shopdb.api import require_permission, apply_import_timestamps +from shopdb.api import require_permission, require_role, apply_import_timestamps computers_bp = Blueprint('computers', __name__) @@ -1064,3 +1064,112 @@ def dashboard_sharedmachines(): out.sort(key=lambda r: -r['pccount']) return success_response(out) + + +# ============================================================================= +# Collector client script +# ============================================================================= + +CLIENT_SCRIPT_NAME = 'Report-AssetToShopDB.ps1' + + +def _client_script_path(): + """The reporter shipped with this plugin, which is the single source.""" + import os + return os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + 'client', CLIENT_SCRIPT_NAME) + + +def _setting_value(key): + row = Setting.query.filter_by(key=key).first() + return ((row.value if row else '') or '').strip() + + +def _generate_client_script(source: str, baseurl: str, ranges: str, + version: str, generatedon: str) -> str: + """Stamp a site's own values into the reporter's parameter defaults. + + ONLY the defaults are substituted, never the body: the file in the repo + stays runnable as-is, so there is no second copy to drift. Everything + stamped here is overridable at runtime - the parameter still wins, then the + registry - because a bay may need to differ from its site. + + The collector key is NOT stamped in. This file lands on every shop-floor PC, + and a token in a file on hundreds of bays cannot be rotated quietly; it is + read from HKLM:\\SOFTWARE\\GE\\ShopDB CollectorKey, provisioned per + ADOPTING-AT-ANOTHER-SITE.md. + """ + apiurl = baseurl.rstrip('/') + '/api/collector/computers' if baseurl else '' + header = ( + '# GENERATED by ShopDB {version} on {generatedon}\n' + '# for {baseurl}\n' + '#\n' + '# Re-download after upgrading ShopDB: this copy matches that server\'s\n' + '# collector contract. Edits here are lost on the next download - change\n' + '# the site settings instead, or pass -ApiUrl / -AllowedRanges.\n' + '#\n' + '# The collector key is deliberately NOT in this file. Provision it as\n' + '# HKLM:\\SOFTWARE\\GE\\ShopDB CollectorKey - see the adoption guide.\n' + '\n' + ).format(version=version, generatedon=generatedon, + baseurl=baseurl or 'an unconfigured site (set site_base_url)') + + out = source + if apiurl: + old = "[string]$ApiUrl = ''" + assert old in out, 'the reporter no longer declares $ApiUrl as expected' + out = out.replace(old, "[string]$ApiUrl = '{0}'".format(apiurl), 1) + if ranges: + old = "[string]$AllowedRanges = ''" + assert old in out, 'the reporter no longer declares $AllowedRanges as expected' + out = out.replace(old, "[string]$AllowedRanges = '{0}'".format(ranges), 1) + return header + out + + +@computers_bp.route('/client-script', methods=['GET']) +@jwt_required() +@require_role('admin') +def download_client_script(): + """The collector reporter, stamped with THIS site's values. + + Admin-only. It carries no secret, but it does state a site's URL and its + internal ranges, which is configuration rather than something to hand out. + """ + import datetime + import hashlib + import os + + path = _client_script_path() + if not os.path.isfile(path): + return error_response(ErrorCodes.NOT_FOUND, + 'The collector script is not present in this install', + http_code=404) + + with open(path, 'r', encoding='utf-8') as handle: + source = handle.read() + + # A site that has not set its public URL still gets a usable script: the + # browsing origin is the server the admin is talking to right now. + baseurl = _setting_value('site_base_url') or request.url_root + # From config, not an import: a plugin reaching into core is an ADR-002 + # violation and the contract test fails the build for it. + version = current_app.config.get('VERSION') or 'unknown' + generatedon = datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d') + + try: + body = _generate_client_script( + source, baseurl.strip(), _setting_value('computers_routableranges'), + version, generatedon) + except AssertionError as exc: + return error_response(ErrorCodes.INTERNAL_ERROR, str(exc), http_code=500) + + digest = hashlib.sha256(body.encode('utf-8')).hexdigest() + return Response( + body, + mimetype='text/plain; charset=utf-8', + headers={ + 'Content-Disposition': 'attachment; filename={0}'.format(CLIENT_SCRIPT_NAME), + # Published so a deployment can verify what it fetched, the same way + # the installer publishes one. + 'X-Script-Sha256': digest, + }) diff --git a/plugins/computers/frontend/routes.js b/plugins/computers/frontend/routes.js index 360ecc9..cd724a0 100644 --- a/plugins/computers/frontend/routes.js +++ b/plugins/computers/frontend/routes.js @@ -39,6 +39,12 @@ export default [ meta: { requiresAuth: true, plugin: 'computers' } }, // Computer-specific settings + { + path: 'settings/collector', + name: 'collector-settings', + component: () => import('./views/CollectorSettings.vue'), + meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' } + }, { path: 'settings/pctypes', name: 'pctypes', diff --git a/plugins/computers/frontend/views/CollectorSettings.vue b/plugins/computers/frontend/views/CollectorSettings.vue new file mode 100644 index 0000000..c530c76 --- /dev/null +++ b/plugins/computers/frontend/views/CollectorSettings.vue @@ -0,0 +1,134 @@ + + + + + diff --git a/plugins/computers/plugin.py b/plugins/computers/plugin.py index 8ca4c61..f174b76 100644 --- a/plugins/computers/plugin.py +++ b/plugins/computers/plugin.py @@ -162,6 +162,25 @@ class ComputersPlugin(BasePlugin): }, } + def get_settings_cards(self) -> List[dict]: + """The asset reporter's own settings page. + + It is a settings card rather than a docs page because it does two things + an operator needs at the same moment: name this site's routable ranges, + and download the reporter that carries them. + """ + return [ + { + 'group': 'Computers', + 'to': '/settings/collector', + 'icon': 'download', + 'title': 'Asset reporter', + 'description': 'Download the collector script stamped with this ' + 'site\'s URL and ranges', + 'position': 26, + }, + ] + def get_settings_defaults(self) -> List[dict]: """Settings this plugin owns. @@ -178,6 +197,19 @@ class ComputersPlugin(BasePlugin): 'description': 'Hours without a collector report before a PC ' 'is listed as not reporting on the dashboard.', }, + { + 'key': 'computers_routableranges', + 'value': '', + 'valuetype': 'string', + 'category': 'computers', + 'description': 'Comma-separated CIDRs naming this site\'s ' + 'routable ranges, e.g. 10.20.0.0/23,10.21.4.0/26. ' + 'Stamped into the collector script this server ' + 'generates, so a bay with a controller NIC and a ' + 'corporate NIC reports the right one. Blank uses ' + 'the NIC carrying the default route, which needs ' + 'no knowledge of a site\'s addressing.', + }, { 'key': 'computers_machinelink_alerts', 'value': 'false', diff --git a/plugins/slides/client/EventSaver.cs b/plugins/slides/client/EventSaver.cs new file mode 100644 index 0000000..0147975 --- /dev/null +++ b/plugins/slides/client/EventSaver.cs @@ -0,0 +1,495 @@ +// EventSaver - shopfloor event-advert screensaver. +// Two source modes, set in EventSaver.ini next to the .scr (no recompile): +// url=https://.../shopdb/api/slides/feed?surface=shopfloor -> pull from shopdb over +// HTTP, cache images locally, rotate the cache (no file share needed). +// folder=\\server\share\path -> read an SMB/local folder. +// url wins if both set. Strict order + per-slide seconds via order.txt (or the +// API's slides[].seconds). Cache survives a network blip (keeps last-good). +// +// Screensaver arg contract: +// /s show (fullscreen) +// /p preview (we no-op - keeps Windows happy) +// /c config (points user at the ini) +// +// Build (in-box .NET Framework, no SDK): +// C:\Windows\Microsoft.NET\Framework64\v4.0.30319\csc.exe ^ +// /target:winexe /out:EventSaver.scr ^ +// /reference:System.dll,System.Drawing.dll,System.Windows.Forms.dll,System.Web.Extensions.dll ^ +// EventSaver.cs + +using System; +using System.Collections.Generic; +using System.Drawing; +using System.Drawing.Drawing2D; +using System.IO; +using System.Net; +using System.Runtime.InteropServices; +using System.Text; +using System.Web.Script.Serialization; +using System.Windows.Forms; + +namespace EventSaver +{ + internal static class Program + { + [STAThread] + private static void Main(string[] args) + { + string mode = "/s"; + if (args.Length > 0) mode = args[0].ToLowerInvariant().Trim(); + // strip a trailing ":hwnd" some callers append (e.g. /p:12345) + if (mode.StartsWith("/p")) mode = "/p"; + if (mode.StartsWith("/c")) mode = "/c"; + if (mode.StartsWith("/s")) mode = "/s"; + + if (mode == "/test") + { + // headless self-test: print the resolved playlist order and exit. + // lets CI / a display-less VM verify folder-read + order.txt + sort. + // winexe has no console in session 0, so write results to a + // file next to the exe (and Console too, for interactive runs). + Config tc = Config.Load(); + string tf = tc.SourceFolder(true); // http mode: sync cache first + List pl = Playlist.Build(tf, tc.Shuffle); + List lines = new List(); + lines.Add((tc.Url.Length > 0 ? "url=" + tc.Url + " cache=" : "folder=") + tf); + lines.Add("interval=" + tc.IntervalSeconds + " shuffle=" + tc.Shuffle); + lines.Add("count=" + pl.Count); + for (int i = 0; i < pl.Count; i++) + lines.Add(string.Format("{0,2}: {1} (secs={2})", i + 1, Path.GetFileName(pl[i].Path), pl[i].Seconds)); + foreach (string l in lines) Console.WriteLine(l); + try + { + string outDir = Path.GetDirectoryName(Application.ExecutablePath); + File.WriteAllLines(Path.Combine(outDir, "eventsaver-test-out.txt"), lines.ToArray()); + } + catch { } + return; + } + if (mode == "/render") + { + // headless render check: draw the first slide onto a 1280x720 + // black canvas with the same fit logic as OnPaint, save a PNG. + // Lets a display-less VM prove decode + letterbox actually work. + Config rc = Config.Load(); + List rpl = Playlist.Build(rc.SourceFolder(true), rc.Shuffle); + string outPng = args.Length > 1 ? args[1] : Path.Combine( + Path.GetDirectoryName(Application.ExecutablePath), "eventsaver-render.png"); + using (Bitmap canvas = new Bitmap(1280, 720)) + using (Graphics g = Graphics.FromImage(canvas)) + { + g.Clear(Color.Black); + if (rpl.Count > 0) + { + using (FileStream fs = new FileStream(rpl[0].Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + using (Image img = Image.FromStream(fs)) + { + g.InterpolationMode = InterpolationMode.HighQualityBicubic; + Rectangle r = SaverForm.FitZoomPublic(img.Size, canvas.Size); + g.DrawImage(img, r); + } + } + canvas.Save(outPng, System.Drawing.Imaging.ImageFormat.Png); + } + return; + } + if (mode == "/c") + { + MessageBox.Show( + "Edit EventSaver.ini next to EventSaver.scr to set the image folder, interval, and order.", + "EventSaver", MessageBoxButtons.OK, MessageBoxIcon.Information); + return; + } + if (mode == "/p") + { + // preview pane - do nothing, exit clean + return; + } + + Application.EnableVisualStyles(); + Application.SetCompatibleTextRenderingDefault(false); + + Config cfg = Config.Load(); + + // one form per screen: primary shows slideshow, others stay black + List
forms = new List(); + Screen primary = Screen.PrimaryScreen; + foreach (Screen scr in Screen.AllScreens) + { + bool isPrimary = scr.Equals(primary); + SaverForm f = new SaverForm(scr, isPrimary ? cfg : null); + forms.Add(f); + } + foreach (Form f in forms) f.Show(); + + // Keep the monitor awake while the screensaver shows, so a shorter + // monitor-sleep policy can't blank the ads out from under us. Held + // for the life of the message loop, released on exit. + SetThreadExecutionState(ES_CONTINUOUS | ES_DISPLAY_REQUIRED | ES_SYSTEM_REQUIRED); + Application.Run(forms[0]); + SetThreadExecutionState(ES_CONTINUOUS); + } + + [DllImport("kernel32.dll")] + private static extern uint SetThreadExecutionState(uint esFlags); + private const uint ES_CONTINUOUS = 0x80000000; + private const uint ES_DISPLAY_REQUIRED = 0x00000002; + private const uint ES_SYSTEM_REQUIRED = 0x00000001; + } + + // ------------------------------------------------------------------ config + internal sealed class Config + { + // No default: a share path belongs to a site, not to this program + // (ADR-015). Folder mode is the fallback for a site with no HTTP + // reach to ShopDB, and it must name its own path in EventSaver.ini. + public string Folder = ""; + public string Url = ""; // set -> HTTP mode (pull from shopdb) + public string CacheDir = ""; // local cache for HTTP mode (computed) + public int IntervalSeconds = 10; + public bool Shuffle = false; + public int FadeMs = 600; + + public static Config Load() + { + Config c = new Config(); + try + { + string dir = Path.GetDirectoryName(Application.ExecutablePath); + string ini = Path.Combine(dir, "EventSaver.ini"); + if (!File.Exists(ini)) return c; + + foreach (string raw in File.ReadAllLines(ini)) + { + string line = raw.Trim(); + if (line.Length == 0 || line.StartsWith("#") || line.StartsWith(";")) continue; + int eq = line.IndexOf('='); + if (eq <= 0) continue; + string key = line.Substring(0, eq).Trim().ToLowerInvariant(); + string val = line.Substring(eq + 1).Trim(); + + if (key == "url" && val.Length > 0) c.Url = val; + else if (key == "folder" && val.Length > 0) c.Folder = val; + else if (key == "interval") { int n; if (int.TryParse(val, out n) && n > 0) c.IntervalSeconds = n; } + else if (key == "shuffle") c.Shuffle = (val == "1" || val.ToLowerInvariant() == "true"); + else if (key == "fadems") { int n; if (int.TryParse(val, out n) && n >= 0) c.FadeMs = n; } + } + } + catch { /* bad ini - fall back to defaults */ } + return c; + } + + // Folder the playlist reads: the local cache in HTTP mode (synced first + // when sync=true), else the configured share/folder. HTTP failures leave + // the last-good cache in place. + public string SourceFolder(bool sync) + { + if (Url.Length == 0) return Folder; + if (CacheDir.Length == 0) + CacheDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "EventSaver", "cache"); + if (sync) { try { HttpSync.Sync(Url, CacheDir); } catch { } } + return CacheDir; + } + } + + // ------------------------------------------------------------- http sync + // Pull the slide list from shopdb (/api/slides/feed), download images into a + // local cache dir, and write order.txt there so the normal Playlist logic + // reads the cache exactly like a folder. Idempotent: only downloads images + // not already cached, prunes ones no longer listed, keeps last-good on error. + internal static class HttpSync + { + public static void Sync(string apiUrl, string cacheDir) + { + try { ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12 | SecurityProtocolType.Tls11 | SecurityProtocolType.Tls; } catch { } + if (!Directory.Exists(cacheDir)) Directory.CreateDirectory(cacheDir); + + string json; + using (WebClient wc = new WebClient()) { wc.Encoding = Encoding.UTF8; json = wc.DownloadString(apiUrl); } + + JavaScriptSerializer js = new JavaScriptSerializer(); + IDictionary root = js.DeserializeObject(json) as IDictionary; + if (root == null) return; + object ok; + if (!root.TryGetValue("success", out ok) || !(ok is bool) || !((bool)ok)) return; + string basepath = root.ContainsKey("basepath") ? Convert.ToString(root["basepath"]) : ""; + object slidesObj; + if (!root.TryGetValue("slides", out slidesObj)) return; + object[] arr = slidesObj as object[]; + if (arr == null) return; + + Uri apiUri = new Uri(apiUrl); + // The feed's basepath is host-absolute (/api/slides/img/...) and omits + // the app's mount (e.g. /shopdb) - the web client adds it via withBase, + // so we must too, else images resolve to the host root and 404. Derive + // the mount from the feed URL's path (everything before "/api/"). + string mount = ""; + int apiIdx = apiUri.AbsolutePath.IndexOf("/api/", StringComparison.OrdinalIgnoreCase); + if (apiIdx > 0) mount = apiUri.AbsolutePath.Substring(0, apiIdx); + HashSet keep = new HashSet(StringComparer.OrdinalIgnoreCase); + List order = new List(); + order.Add("# generated by EventSaver from " + apiUrl); + + foreach (object o in arr) + { + IDictionary s = o as IDictionary; + if (s == null) continue; + string fn = s.ContainsKey("filename") ? Convert.ToString(s["filename"]) : null; + if (string.IsNullOrEmpty(fn)) continue; + string safe = Path.GetFileName(fn); // strip any path component + if (safe.Length == 0) continue; + int secs = 0; + if (s.ContainsKey("seconds")) { int n; if (int.TryParse(Convert.ToString(s["seconds"]), out n) && n > 0) secs = n; } + + string local = Path.Combine(cacheDir, safe); + if (!File.Exists(local)) + { + try + { + // Host-absolute basepath -> prepend the mount; a full URL passes through. + string imgRef = basepath.StartsWith("/") ? mount + basepath : basepath; + Uri img = new Uri(apiUri, imgRef + Uri.EscapeDataString(safe)); + using (WebClient wc = new WebClient()) { byte[] b = wc.DownloadData(img); File.WriteAllBytes(local, b); } + } + catch { continue; } // couldn't fetch this one - skip it this round + } + keep.Add(safe); + order.Add(secs > 0 ? safe + "|" + secs : safe); + } + + try { File.WriteAllLines(Path.Combine(cacheDir, "order.txt"), order.ToArray()); } catch { } + + // prune cache images no longer referenced + foreach (string f in Directory.GetFiles(cacheDir)) + { + string n = Path.GetFileName(f); + if (string.Equals(n, "order.txt", StringComparison.OrdinalIgnoreCase)) continue; + if (!keep.Contains(n)) { try { File.Delete(f); } catch { } } + } + } + } + + // ------------------------------------------------------------- playlist + // Builds ordered file list. order.txt wins (strict sequence, one name per + // line, optional "name|seconds" per-slide duration). Else sort by name. + internal sealed class Slide + { + public string Path; + public int Seconds; // 0 = use default interval + } + + internal static class Playlist + { + private static readonly string[] Exts = { ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp" }; + + public static List Build(string folder, bool shuffle) + { + List list = new List(); + try + { + if (!Directory.Exists(folder)) return list; + + string orderFile = Path.Combine(folder, "order.txt"); + if (File.Exists(orderFile)) + { + // strict sequence from order.txt + foreach (string raw in File.ReadAllLines(orderFile)) + { + string line = raw.Trim(); + if (line.Length == 0 || line.StartsWith("#") || line.StartsWith(";")) continue; + int secs = 0; + string name = line; + int bar = line.IndexOf('|'); + if (bar > 0) + { + name = line.Substring(0, bar).Trim(); + int n; if (int.TryParse(line.Substring(bar + 1).Trim(), out n) && n > 0) secs = n; + } + string full = Path.Combine(folder, name); + if (IsImage(full) && File.Exists(full)) + list.Add(new Slide { Path = full, Seconds = secs }); + } + return list; // order.txt is authoritative - do not append extras + } + + // no order.txt - all images, sorted by filename + List files = new List(); + foreach (string f in Directory.GetFiles(folder)) + if (IsImage(f)) files.Add(f); + files.Sort(StringComparer.OrdinalIgnoreCase); + if (shuffle) Shuf(files); + foreach (string f in files) list.Add(new Slide { Path = f, Seconds = 0 }); + } + catch { /* share unreachable - return what we have (maybe empty) */ } + return list; + } + + private static bool IsImage(string path) + { + string e = Path.GetExtension(path).ToLowerInvariant(); + foreach (string x in Exts) if (x == e) return true; + return false; + } + + // deterministic-enough shuffle; screensaver so exact randomness irrelevant + private static void Shuf(List l) + { + Random r = new Random(); + for (int i = l.Count - 1; i > 0; i--) + { + int j = r.Next(i + 1); + string t = l[i]; l[i] = l[j]; l[j] = t; + } + } + } + + // ------------------------------------------------------------- saver form + internal sealed class SaverForm : Form + { + private readonly Config cfg; // null on non-primary screens (black only) + private readonly Timer timer; + private List slides = new List(); + private int idx = -1; + private Image current; + private Point lastMouse = Point.Empty; + private bool mouseSeen = false; + private DateTime lastScan = DateTime.MinValue; + + public SaverForm(Screen screen, Config c) + { + cfg = c; + FormBorderStyle = FormBorderStyle.None; + Bounds = screen.Bounds; + StartPosition = FormStartPosition.Manual; + BackColor = Color.Black; + TopMost = true; + ShowInTaskbar = false; + DoubleBuffered = true; + Cursor.Hide(); + + KeyPreview = true; + // Left/Right step through the slides by hand; ANY other key still + // wakes the machine, which is what a screensaver must do. Without + // that exception an operator tapping an arrow to get back to work + // would be stuck watching slides. + KeyDown += (s, e) => + { + if (e.KeyCode == Keys.Left) { e.Handled = true; Step(-1); return; } + if (e.KeyCode == Keys.Right) { e.Handled = true; Step(1); return; } + Quit(); + }; + MouseDown += (s, e) => Quit(); + MouseMove += OnMove; + + if (cfg != null) + { + cfg.SourceFolder(true); // HTTP mode: initial sync + set CacheDir + Rescan(); + timer = new Timer(); + timer.Interval = 1000; // tick every second; advance when slide's time is up + timer.Tick += OnTick; + timer.Start(); + Advance(); // show first immediately + } + } + + private int slideElapsed = 0; + private DateTime lastSync = DateTime.Now; // ctor already did the first sync + private void OnTick(object sender, EventArgs e) + { + // HTTP mode: re-pull from shopdb every 60s so manager edits propagate. + if (cfg.Url.Length > 0 && (DateTime.Now - lastSync).TotalSeconds >= 60) + { + lastSync = DateTime.Now; + try { HttpSync.Sync(cfg.Url, cfg.CacheDir); } catch { } + lastScan = DateTime.MinValue; // force the rescan below + } + // periodic rescan so edits appear without restarting the saver + if ((DateTime.Now - lastScan).TotalSeconds >= 30) Rescan(); + + slideElapsed++; + int want = (slides.Count > 0 && idx >= 0 && slides[idx].Seconds > 0) + ? slides[idx].Seconds : cfg.IntervalSeconds; + if (slideElapsed >= want) Advance(); + } + + private void Rescan() + { + lastScan = DateTime.Now; + List fresh = Playlist.Build(cfg.SourceFolder(false), cfg.Shuffle); + slides = fresh; + if (idx >= slides.Count) idx = -1; + } + + private void Advance() + { + Step(1); + } + + // delta of +1 is the timer advancing, -1 is the operator going back. + // Resets the dwell timer either way: stepping by hand and then having it + // move again a moment later, because the tick was nearly up, reads as + // the screensaver ignoring the keypress. + private void Step(int delta) + { + slideElapsed = 0; + if (slides.Count == 0) { SetImage(null); return; } + if (idx < 0) idx = (delta < 0) ? 0 : -1; // first Step lands on slide 0 + idx = ((idx + delta) % slides.Count + slides.Count) % slides.Count; + try + { + // load without locking the file on the share + Image img; + using (FileStream fs = new FileStream(slides[idx].Path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) + img = Image.FromStream(fs); + SetImage(img); + } + catch { SetImage(null); } + } + + private void SetImage(Image img) + { + Image old = current; + current = img; + if (old != null) old.Dispose(); + Invalidate(); + } + + protected override void OnPaint(PaintEventArgs e) + { + e.Graphics.Clear(Color.Black); + if (current == null) return; + e.Graphics.InterpolationMode = InterpolationMode.HighQualityBicubic; + Rectangle r = FitZoom(current.Size, ClientSize); + e.Graphics.DrawImage(current, r); + } + + // test hook - same math as OnPaint, exposed for the /render self-check. + public static Rectangle FitZoomPublic(Size img, Size box) { return FitZoom(img, box); } + + // scale image to fit while preserving aspect (letterbox) + private static Rectangle FitZoom(Size img, Size box) + { + if (img.Width == 0 || img.Height == 0) return new Rectangle(0, 0, box.Width, box.Height); + double s = Math.Min((double)box.Width / img.Width, (double)box.Height / img.Height); + int w = (int)(img.Width * s); + int h = (int)(img.Height * s); + return new Rectangle((box.Width - w) / 2, (box.Height - h) / 2, w, h); + } + + private void OnMove(object sender, MouseEventArgs e) + { + // ignore the first synthetic move; require real movement to exit + if (!mouseSeen) { mouseSeen = true; lastMouse = e.Location; return; } + if (Math.Abs(e.X - lastMouse.X) > 8 || Math.Abs(e.Y - lastMouse.Y) > 8) Quit(); + } + + private void Quit() + { + try { Cursor.Show(); } catch { } + Application.Exit(); + } + } +} diff --git a/plugins/slides/client/EventSaver.ini b/plugins/slides/client/EventSaver.ini new file mode 100644 index 0000000..771a8d9 --- /dev/null +++ b/plugins/slides/client/EventSaver.ini @@ -0,0 +1,19 @@ +# EventSaver config. Lives next to EventSaver.scr. +# Change these without recompiling. Screensaver re-reads on each launch. + +# HTTP mode (recommended): pull slides from shopdb over HTTP, cache locally. +# No file share needed. Point at the shopdb slides feed (/api/slides/feed) for this +# surface. FIX THE BASE URL if the shopdb path differs on the live box. +url=https://shopdb.example.net/api/slides/feed?surface=shopfloor + +# Folder mode (fallback): used only if url is blank. SMB/local path. +# folder=\\fileserver.example.net\share\tv\shopfloor + +# Seconds per image (default when a slide has no per-slide time). +interval=10 + +# 1 = random order, 0 = ordered. Ignored when order.txt / API order is present. +shuffle=0 + +# Crossfade length in ms (0 = hard cut). Reserved - hard cut in v1. +fadems=600 diff --git a/shopdb/__init__.py b/shopdb/__init__.py index d404fb3..79b045d 100644 --- a/shopdb/__init__.py +++ b/shopdb/__init__.py @@ -89,6 +89,13 @@ def create_app(config_name: str = None) -> Flask: app.config.from_object(config_class) + # The product and contract versions, in config so a PLUGIN can read them + # through current_app instead of importing core (ADR-002 forbids that, and + # the contract test enforces it). Core code may still use the module + # constants directly. + app.config['VERSION'] = __version__ + app.config['CONTRACT_VERSION'] = __contract_version__ + # Load instance config if exists app.config.from_pyfile('config.py', silent=True) diff --git a/tests/test_plugins/test_collector_client_script.py b/tests/test_plugins/test_collector_client_script.py new file mode 100644 index 0000000..ed470c9 --- /dev/null +++ b/tests/test_plugins/test_collector_client_script.py @@ -0,0 +1,122 @@ +"""The collector reporter, generated with a site's own values. + +A site used to receive a script with the reference site's server and VLANs in +it, which it had to find and edit. The server now stamps its own values into the +parameter defaults, so the file downloads ready to deploy. + +What must stay true: + +- ONLY the defaults are substituted. The copy in the repo stays runnable, so + there is never a second version to drift from the first. +- The collector key is NEVER in the file. It lands on every shop-floor PC, and a + token spread across hundreds of bays cannot be rotated quietly. +- Everything stamped is still overridable, because a bay may need to differ from + its site. +""" + +import pytest + +from shopdb.core.models import Setting + + +SCRIPT = '/api/computers/client-script' + + +def _set(db, key, value): + row = Setting.query.filter_by(key=key).first() + if row is None: + row = Setting(key=key, value=value, valuetype='string', category='computers') + db.session.add(row) + else: + row.value = value + db.session.commit() + + +def test_an_anonymous_caller_gets_nothing(client): + """It states a site's URL and internal ranges - configuration, not a + handout.""" + assert client.get(SCRIPT).status_code in (401, 422) + + +def test_it_serves_the_reporter_as_a_download(client, auth_headers): + resp = client.get(SCRIPT, headers=auth_headers) + assert resp.status_code == 200, resp.get_data(as_text=True)[:200] + assert 'attachment' in resp.headers['Content-Disposition'] + assert 'Report-AssetToShopDB.ps1' in resp.headers['Content-Disposition'] + body = resp.get_data(as_text=True) + assert 'param(' in body + assert 'api/collector/computers' in body + + +def test_it_publishes_a_hash_of_what_it_served(client, auth_headers): + """So a deployment can verify what it fetched, like the installer does.""" + import hashlib + resp = client.get(SCRIPT, headers=auth_headers) + digest = hashlib.sha256(resp.get_data()).hexdigest() + assert resp.headers['X-Script-Sha256'] == digest + + +def test_it_stamps_the_sites_own_url(client, db, auth_headers): + _set(db, 'site_base_url', 'https://shopdb.example.net') + body = client.get(SCRIPT, headers=auth_headers).get_data(as_text=True) + assert "[string]$ApiUrl = 'https://shopdb.example.net/api/collector/computers'" in body + assert 'for https://shopdb.example.net' in body + + +def test_a_site_with_no_url_configured_still_gets_a_usable_script(client, db, + auth_headers): + """Blank site_base_url falls back to the origin the admin is talking to, + which is by definition a reachable address for this server.""" + _set(db, 'site_base_url', '') + body = client.get(SCRIPT, headers=auth_headers).get_data(as_text=True) + assert "[string]$ApiUrl = ''" not in body + assert 'api/collector/computers' in body + + +def test_it_stamps_the_sites_ranges(client, db, auth_headers): + """The replacement for the two VLANs that used to be source code.""" + _set(db, 'computers_routableranges', '10.20.0.0/23,10.21.4.0/26') + body = client.get(SCRIPT, headers=auth_headers).get_data(as_text=True) + assert "[string]$AllowedRanges = '10.20.0.0/23,10.21.4.0/26'" in body + + +def test_no_ranges_configured_leaves_the_default_route_fallback(client, db, + auth_headers): + """An unconfigured site must not be given someone else's addressing: the + script falls back to the NIC carrying the default route.""" + _set(db, 'computers_routableranges', '') + body = client.get(SCRIPT, headers=auth_headers).get_data(as_text=True) + assert "[string]$AllowedRanges = ''" in body + assert 'default route' in body + + +def test_the_collector_key_is_never_in_the_file(client, db, auth_headers): + """The one thing that must not be stamped in. A token in a file on every bay + is a token nobody can rotate quietly.""" + _set(db, 'site_base_url', 'https://shopdb.example.net') + body = client.get(SCRIPT, headers=auth_headers).get_data(as_text=True) + assert 'shopdb_pat_' not in body + assert "$ApiKey = ''" in body or "[string]$ApiKey = ''" in body + # It says where the key comes from instead. + assert 'CollectorKey' in body + + +def test_the_body_is_not_rewritten_only_the_defaults(client, auth_headers): + """The repo copy stays runnable. If generation started editing the body, + the file on disk and the file a site runs would diverge.""" + from plugins.computers.api.routes import _client_script_path + with open(_client_script_path(), 'r', encoding='utf-8') as handle: + source = handle.read() + body = client.get(SCRIPT, headers=auth_headers).get_data(as_text=True) + + # Every line of the source survives except the two parameter defaults. + changed = [line for line in source.splitlines() + if line.strip() and line not in body.splitlines()] + assert all('$ApiUrl' in line or '$AllowedRanges' in line for line in changed), changed + + +def test_it_names_the_version_that_generated_it(client, auth_headers): + """So a script found on a bay can be traced to the server that made it.""" + from shopdb import __version__ + body = client.get(SCRIPT, headers=auth_headers).get_data(as_text=True) + assert __version__ in body