diff --git a/docs/PROJECT-MAP.md b/docs/PROJECT-MAP.md index fc7e4b1e..04df3149 100644 --- a/docs/PROJECT-MAP.md +++ b/docs/PROJECT-MAP.md @@ -12,7 +12,7 @@ never by editing this file. | series | value | governed by | |---|---|---| | product `__version__` | `0.12.0` | ADR-007 | -| plugin contract `__contract_version__` | `0.26.0` | ADR-002 | +| plugin contract `__contract_version__` | `0.27.0` | ADR-002 | They move independently. A contract bump is not a release. @@ -23,11 +23,11 @@ with `flask plugin upgrade-all`. Both are needed on a deploy. | chain | head | |---|---| -| core | `7d34_singular_relationship_types` | +| core | `7d36_scriptlibrary_and_app_installer_digest` | | backups | `backups0003clearlastseen` | | computers | `computers0004policies` | | employees | `employees0002photo` | -| endpoints | `endpoints0003cmdtarget` | +| endpoints | `endpoints0004apps` | | geenforce | `geenforce0004minlib` | | guides | `guides0001baseline` | | knowledgebase | `knowledgebase0001anchor` | @@ -90,6 +90,6 @@ Manifest-less directories under `plugins/` are core frontend surface and always ## Size -- test functions defined: **1507** (parametrised cases collect higher) -- documented API paths: **333** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`) +- test functions defined: **1544** (parametrised cases collect higher) +- documented API paths: **345** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`) diff --git a/docs/api-inventory.json b/docs/api-inventory.json index b52a6143..235f3992 100644 --- a/docs/api-inventory.json +++ b/docs/api-inventory.json @@ -3916,5 +3916,143 @@ "example": "curl -X POST -H \"X-Collector-Key: $KEY\" -H 'Content-Type: application/json' -d '{\"status\":\"succeeded\",\"output\":\"ok\"}' http://localhost:5001/api/endpoints/commands/1/result" } ] + }, + { + "surface": "core-scripts", + "_comment": "Script library: reusable PowerShell bodies a run_script delivery pushes to an endpoint. Added on the wilm branch; documented here on merge because docs/ is not published, so an external contributor cannot maintain this file.", + "endpoints": [ + { + "method": "GET", + "path": "/api/scripts", + "auth": "jwt + require_permission applications.view", + "params": "query: includeinactive (default false)", + "purpose": "List script catalog entries, active only by default", + "example": "curl http://localhost:5001/api/scripts -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "GET", + "path": "/api/scripts/", + "auth": "jwt + require_permission applications.view", + "params": "path: script_id (int)", + "purpose": "Get one script entry with its metadata and run context", + "example": "curl http://localhost:5001/api/scripts/3 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "POST", + "path": "/api/scripts", + "auth": "jwt + require_permission applications.create", + "params": "body: name (required), description, runcontext (system|user, default system)", + "purpose": "Create a script catalog entry; the body is uploaded separately so metadata and content version independently", + "example": "curl -X POST http://localhost:5001/api/scripts -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"name\":\"Restart spooler\",\"runcontext\":\"system\"}'" + }, + { + "method": "PUT", + "path": "/api/scripts/", + "auth": "jwt + require_permission applications.edit", + "params": "path: script_id; body: name, description, runcontext, isactive", + "purpose": "Update a script entry's metadata; the stored body is untouched", + "example": "curl -X PUT http://localhost:5001/api/scripts/3 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"description\":\"Clears the print queue\"}'" + }, + { + "method": "DELETE", + "path": "/api/scripts/", + "auth": "jwt + require_permission applications.delete", + "params": "path: script_id", + "purpose": "Remove a script catalog entry", + "example": "curl -X DELETE http://localhost:5001/api/scripts/3 -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "POST", + "path": "/api/scripts//body", + "auth": "jwt + require_permission applications.edit", + "params": "path: script_id; multipart/form-data: file (the .ps1)", + "purpose": "Upload or replace the script body; stored on disk and hashed so a delivery can pin the exact bytes", + "example": "curl -X POST http://localhost:5001/api/scripts/3/body -H \"Authorization: Bearer $TOK\" -F 'file=@restart-spooler.ps1'" + }, + { + "method": "GET", + "path": "/api/scripts/body/", + "auth": "jwt + require_permission applications.view", + "params": "path: filename (as stored)", + "purpose": "Download a stored script body for review in the UI", + "example": "curl http://localhost:5001/api/scripts/body/restart-spooler.ps1 -H \"Authorization: Bearer $TOK\" -o restart-spooler.ps1" + } + ] + }, + { + "surface": "endpoints-delivery", + "_comment": "Agent-facing software delivery. These authorise on the endpoints COLLECTOR TOKEN, not a user JWT: the agent already holds that key for telemetry and command polling, and the core package route is gated on applications.view which the collector token does not hold.", + "endpoints": [ + { + "method": "GET", + "path": "/api/endpoints/apppackage/", + "auth": "collector token (X-API-Key), endpoints scope", + "params": "path: appversionid (int)", + "purpose": "Agent-facing installer download for a pinned app version; serves the exact bytes the update_app descriptor pinned by SHA-256", + "example": "curl http://localhost:5001/api/endpoints/apppackage/12 -H 'X-API-Key: $COLLECTOR_KEY' -o setup.exe" + }, + { + "method": "GET", + "path": "/api/endpoints/apppackage/app/", + "auth": "collector token (X-API-Key), endpoints scope", + "params": "path: app_id (int)", + "purpose": "Agent-facing installer download resolved from the application rather than a specific version", + "example": "curl http://localhost:5001/api/endpoints/apppackage/app/4 -H 'X-API-Key: $COLLECTOR_KEY' -o setup.exe" + }, + { + "method": "GET", + "path": "/api/endpoints/apppackage/app//", + "auth": "collector token (X-API-Key), endpoints scope", + "params": "path: app_id (int), filename", + "purpose": "As above, naming the stored file explicitly so the agent writes it back under the expected name", + "example": "curl http://localhost:5001/api/endpoints/apppackage/app/4/setup.exe -H 'X-API-Key: $COLLECTOR_KEY' -o setup.exe" + }, + { + "method": "GET", + "path": "/api/endpoints/scriptbody/", + "auth": "collector token (X-API-Key), endpoints scope", + "params": "path: script_id (int)", + "purpose": "Agent-facing script download for a run_script delivery, on the same key as the rest of the delivery path", + "example": "curl http://localhost:5001/api/endpoints/scriptbody/3 -H 'X-API-Key: $COLLECTOR_KEY' -o run.ps1" + }, + { + "method": "GET", + "path": "/api/endpoints/asset//applications", + "auth": "jwt + require_permission endpoints.view", + "params": "path: asset_id (int)", + "purpose": "Installed-software snapshot the agent last reported for this endpoint, for the asset detail panel; a per-report snapshot rather than history", + "example": "curl http://localhost:5001/api/endpoints/asset/313/applications -H \"Authorization: Bearer $TOK\"" + } + ] + }, + { + "surface": "endpoints-siteagent", + "_comment": "Packaging the endpoint agent and the agentless adoption tool for this site.", + "endpoints": [ + { + "method": "GET", + "path": "/api/endpoints/siteagent", + "auth": "jwt + require_role admin", + "params": "none", + "purpose": "Report whether a prebuilt agent binary is staged and can therefore be packaged", + "example": "curl http://localhost:5001/api/endpoints/siteagent -H \"Authorization: Bearer $TOK\"" + }, + { + "method": "POST", + "path": "/api/endpoints/siteagent/package", + "auth": "jwt + require_role admin", + "params": "body: baseurl, collectorkey, cadenceseconds, and the site TLS cert when one is readable", + "purpose": "Bundle the agent as a downloadable ZIP with a generated install.ps1 that provisions credentials, so a bay install needs no hand editing", + "example": "curl -X POST http://localhost:5001/api/endpoints/siteagent/package -H \"Authorization: Bearer $TOK\" -o shopdb-agent.zip" + }, + { + "method": "POST", + "path": "/api/endpoints/adoptiontool/package", + "auth": "jwt + require_role admin", + "params": "body: as for the site agent package", + "purpose": "Bundle the agentless adoption tool, for taking an inventory of a machine that will not run the service", + "example": "curl -X POST http://localhost:5001/api/endpoints/adoptiontool/package -H \"Authorization: Bearer $TOK\" -o shopdb-adoption.zip" + } + ] } ] diff --git a/docs/openapi.json b/docs/openapi.json index 077980da..14eec236 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -185,6 +185,15 @@ }, { "name": "plugin-endpoints" + }, + { + "name": "core-scripts" + }, + { + "name": "endpoints-delivery" + }, + { + "name": "endpoints-siteagent" } ], "paths": { @@ -22680,6 +22689,768 @@ } } } + }, + "/api/scripts": { + "get": { + "tags": [ + "core-scripts" + ], + "summary": "List script catalog entries, active only by default", + "description": "List script catalog entries, active only by default\n\n**Auth:** jwt + require_permission applications.view\n\n**Params:** query: includeinactive (default false)\n\n**Example:**\n```\ncurl http://localhost:5001/api/scripts -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + }, + "post": { + "tags": [ + "core-scripts" + ], + "summary": "Create a script catalog entry; the body is uploaded separately so metadata and content version independently", + "description": "Create a script catalog entry; the body is uploaded separately so metadata and content version independently\n\n**Auth:** jwt + require_permission applications.create\n\n**Params:** body: name (required), description, runcontext (system|user, default system)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/scripts -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"name\":\"Restart spooler\",\"runcontext\":\"system\"}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: name (required), description, runcontext (system|user, default system)" + } + } + } + } + } + }, + "/api/scripts/{script_id}": { + "get": { + "tags": [ + "core-scripts" + ], + "summary": "Get one script entry with its metadata and run context", + "description": "Get one script entry with its metadata and run context\n\n**Auth:** jwt + require_permission applications.view\n\n**Params:** path: script_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/scripts/3 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "script_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + }, + "put": { + "tags": [ + "core-scripts" + ], + "summary": "Update a script entry's metadata; the stored body is untouched", + "description": "Update a script entry's metadata; the stored body is untouched\n\n**Auth:** jwt + require_permission applications.edit\n\n**Params:** path: script_id; body: name, description, runcontext, isactive\n\n**Example:**\n```\ncurl -X PUT http://localhost:5001/api/scripts/3 -H \"Authorization: Bearer $TOK\" -H 'Content-Type: application/json' -d '{\"description\":\"Clears the print queue\"}'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "script_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: script_id; body: name, description, runcontext, isactive" + } + } + } + } + }, + "delete": { + "tags": [ + "core-scripts" + ], + "summary": "Remove a script catalog entry", + "description": "Remove a script catalog entry\n\n**Auth:** jwt + require_permission applications.delete\n\n**Params:** path: script_id\n\n**Example:**\n```\ncurl -X DELETE http://localhost:5001/api/scripts/3 -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "script_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/scripts/{script_id}/body": { + "post": { + "tags": [ + "core-scripts" + ], + "summary": "Upload or replace the script body; stored on disk and hashed so a delivery can pin the exact bytes", + "description": "Upload or replace the script body; stored on disk and hashed so a delivery can pin the exact bytes\n\n**Auth:** jwt + require_permission applications.edit\n\n**Params:** path: script_id; multipart/form-data: file (the .ps1)\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/scripts/3/body -H \"Authorization: Bearer $TOK\" -F 'file=@restart-spooler.ps1'\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "script_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "path: script_id; multipart/form-data: file (the .ps1)" + } + } + } + } + } + }, + "/api/scripts/body/{filename}": { + "get": { + "tags": [ + "core-scripts" + ], + "summary": "Download a stored script body for review in the UI", + "description": "Download a stored script body for review in the UI\n\n**Auth:** jwt + require_permission applications.view\n\n**Params:** path: filename (as stored)\n\n**Example:**\n```\ncurl http://localhost:5001/api/scripts/body/restart-spooler.ps1 -H \"Authorization: Bearer $TOK\" -o restart-spooler.ps1\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "filename", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/endpoints/apppackage/{appversionid}": { + "get": { + "tags": [ + "endpoints-delivery" + ], + "summary": "Agent-facing installer download for a pinned app version; serves the exact bytes the update_app descriptor pinned by...", + "description": "Agent-facing installer download for a pinned app version; serves the exact bytes the update_app descriptor pinned by SHA-256\n\n**Auth:** collector token (X-API-Key), endpoints scope\n\n**Params:** path: appversionid (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/apppackage/12 -H 'X-API-Key: $COLLECTOR_KEY' -o setup.exe\n```", + "security": [ + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "appversionid", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/endpoints/apppackage/app/{app_id}": { + "get": { + "tags": [ + "endpoints-delivery" + ], + "summary": "Agent-facing installer download resolved from the application rather than a specific version", + "description": "Agent-facing installer download resolved from the application rather than a specific version\n\n**Auth:** collector token (X-API-Key), endpoints scope\n\n**Params:** path: app_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/apppackage/app/4 -H 'X-API-Key: $COLLECTOR_KEY' -o setup.exe\n```", + "security": [ + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "app_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/endpoints/apppackage/app/{app_id}/{filename}": { + "get": { + "tags": [ + "endpoints-delivery" + ], + "summary": "As above, naming the stored file explicitly so the agent writes it back under the expected name", + "description": "As above, naming the stored file explicitly so the agent writes it back under the expected name\n\n**Auth:** collector token (X-API-Key), endpoints scope\n\n**Params:** path: app_id (int), filename\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/apppackage/app/4/setup.exe -H 'X-API-Key: $COLLECTOR_KEY' -o setup.exe\n```", + "security": [ + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "app_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "filename", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/endpoints/scriptbody/{script_id}": { + "get": { + "tags": [ + "endpoints-delivery" + ], + "summary": "Agent-facing script download for a run_script delivery, on the same key as the rest of the delivery path", + "description": "Agent-facing script download for a run_script delivery, on the same key as the rest of the delivery path\n\n**Auth:** collector token (X-API-Key), endpoints scope\n\n**Params:** path: script_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/scriptbody/3 -H 'X-API-Key: $COLLECTOR_KEY' -o run.ps1\n```", + "security": [ + { + "apiKeyAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "script_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/endpoints/asset/{asset_id}/applications": { + "get": { + "tags": [ + "endpoints-delivery" + ], + "summary": "Installed-software snapshot the agent last reported for this endpoint, for the asset detail panel; a per-report...", + "description": "Installed-software snapshot the agent last reported for this endpoint, for the asset detail panel; a per-report snapshot rather than history\n\n**Auth:** jwt + require_permission endpoints.view\n\n**Params:** path: asset_id (int)\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/asset/313/applications -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + }, + "404": { + "description": "No such record." + } + }, + "parameters": [ + { + "name": "asset_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/api/endpoints/siteagent": { + "get": { + "tags": [ + "endpoints-siteagent" + ], + "summary": "Report whether a prebuilt agent binary is staged and can therefore be packaged", + "description": "Report whether a prebuilt agent binary is staged and can therefore be packaged\n\n**Auth:** jwt + require_role admin\n\n**Params:** none\n\n**Example:**\n```\ncurl http://localhost:5001/api/endpoints/siteagent -H \"Authorization: Bearer $TOK\"\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + } + } + }, + "/api/endpoints/siteagent/package": { + "post": { + "tags": [ + "endpoints-siteagent" + ], + "summary": "Bundle the agent as a downloadable ZIP with a generated install.ps1 that provisions credentials, so a bay install needs...", + "description": "Bundle the agent as a downloadable ZIP with a generated install.ps1 that provisions credentials, so a bay install needs no hand editing\n\n**Auth:** jwt + require_role admin\n\n**Params:** body: baseurl, collectorkey, cadenceseconds, and the site TLS cert when one is readable\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/siteagent/package -H \"Authorization: Bearer $TOK\" -o shopdb-agent.zip\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: baseurl, collectorkey, cadenceseconds, and the site TLS cert when one is readable" + } + } + } + } + } + }, + "/api/endpoints/adoptiontool/package": { + "post": { + "tags": [ + "endpoints-siteagent" + ], + "summary": "Bundle the agentless adoption tool, for taking an inventory of a machine that will not run the service", + "description": "Bundle the agentless adoption tool, for taking an inventory of a machine that will not run the service\n\n**Auth:** jwt + require_role admin\n\n**Params:** body: as for the site agent package\n\n**Example:**\n```\ncurl -X POST http://localhost:5001/api/endpoints/adoptiontool/package -H \"Authorization: Bearer $TOK\" -o shopdb-adoption.zip\n```", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Success. Body is the success_response envelope: {status, data, meta}.", + "content": { + "application/json": { + "$ref": "#/components/schemas/SuccessEnvelope" + } + } + }, + "default": { + "description": "Error. Body is the error envelope; the code and message are nested under data.error.", + "content": { + "application/json": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "401": { + "description": "Missing or invalid credentials." + }, + "403": { + "description": "Authenticated, but not permitted." + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": true, + "description": "body: as for the site agent package" + } + } + } + } + } } } } \ No newline at end of file diff --git a/plugins/endpoints/api/adoption.py b/plugins/endpoints/api/adoption.py index 8e6e5967..eb59914a 100644 --- a/plugins/endpoints/api/adoption.py +++ b/plugins/endpoints/api/adoption.py @@ -23,7 +23,7 @@ import zipfile from flask import request, current_app, send_file -from shopdb.api import error_response, ErrorCodes, require_role +from shopdb.api import error_response, ErrorCodes, require_permission from .routes import endpoints_bp @@ -66,7 +66,7 @@ def _build_readme(): @endpoints_bp.route('/adoptiontool/package', methods=['POST']) -@require_role('admin') +@require_permission('endpoints.agentpackage') def package_adoption_tool(): """Bundle the adoption tool as a downloadable ZIP. diff --git a/plugins/endpoints/api/routes.py b/plugins/endpoints/api/routes.py index bd90a8a2..ed8fb5ae 100644 --- a/plugins/endpoints/api/routes.py +++ b/plugins/endpoints/api/routes.py @@ -1097,14 +1097,39 @@ def _resolve_update_app(target): if application is None or not application.installpath: return None, ('Application for appversionid {} has no installer package' .format(ident)) + # PIN THE FILE THAT IS ACTUALLY SERVED. + # + # /api/endpoints/apppackage/ resolves the version to its + # APPLICATION and serves application.installpath - there is no + # version-specific artifact behind that route. So version.sha256 pinned + # a digest that need not describe the bytes delivered, and where it was + # null the descriptor carried no digest at all and the agent had nothing + # to verify against. Deriving it from the application, exactly as the + # app branch does, makes the pin describe the delivery. + sha256 = (application.installersha256 + or _backfill_app_installer_sha256(application)) + if not sha256: + return None, ('Application for appversionid {} has an installer ' + 'package with no recorded SHA-256, so the delivery ' + 'cannot be verified'.format(ident)) + + # Carry the real filename so the agent's Path.GetExtension routes an MSI + # through msiexec. Without it every MSI push failed, because this branch + # ended the URL at the id and the extension was simply absent. + filename = _hosted_package_filename(application) + installpath = '/api/endpoints/apppackage/{}'.format(version.appversionid) + if filename: + installpath = '/api/endpoints/apppackage/app/{}/{}'.format( + application.appid, filename) + return { 'source': 'version', 'appversionid': version.appversionid, 'appid': version.appid, 'appname': application.appname, 'version': version.version, - 'installpath': '/api/endpoints/apppackage/{}'.format(version.appversionid), - 'sha256': version.sha256, + 'installpath': installpath, + 'sha256': sha256, 'silentargs': version.silentargs, }, None diff --git a/plugins/endpoints/api/siteagent.py b/plugins/endpoints/api/siteagent.py index a585c315..502f06de 100644 --- a/plugins/endpoints/api/siteagent.py +++ b/plugins/endpoints/api/siteagent.py @@ -31,7 +31,8 @@ import zipfile from flask import request, current_app, send_file -from shopdb.api import success_response, error_response, ErrorCodes, require_role +from shopdb.api import (success_response, error_response, ErrorCodes, + require_permission) from .routes import endpoints_bp @@ -335,14 +336,14 @@ def _siteagent_status_payload(): @endpoints_bp.route('/siteagent', methods=['GET']) -@require_role('admin') +@require_permission('endpoints.agentpackage') def get_siteagent(): """Report whether the site agent build has a prebuilt binary staged.""" return success_response(_siteagent_status_payload()) @endpoints_bp.route('/siteagent/package', methods=['POST']) -@require_role('admin') +@require_permission('endpoints.agentpackage') def package_siteagent(): """Bundle the site agent as a downloadable ZIP. diff --git a/plugins/endpoints/migrations/versions/0004_endpoint_applications.py b/plugins/endpoints/migrations/versions/0004_endpoint_applications.py index 9aab6ce2..cd7f3e81 100644 --- a/plugins/endpoints/migrations/versions/0004_endpoint_applications.py +++ b/plugins/endpoints/migrations/versions/0004_endpoint_applications.py @@ -38,7 +38,17 @@ def upgrade(): op.create_table( 'endpointapplications', - sa.Column('endpointapplicationid', sa.Integer(), nullable=False), + # BigInteger, not Integer. This is a SNAPSHOT table: every check-in + # deletes the endpoint's rows and reinserts them, so the surrogate key + # burns through values at (endpoints x installed apps) per report and + # never reuses them. A 32-bit key exhausts on MySQL - a few hundred + # endpoints reporting a few hundred apps every 15 minutes reaches 2.1 + # billion inside a couple of years, and the failure is an insert that + # stops working forever. SQLite has no such ceiling, so no test would + # have caught it. Changed while the table is still unreleased. + sa.Column('endpointapplicationid', + sa.BigInteger().with_variant(sa.Integer, 'sqlite'), + nullable=False), sa.Column('assetid', sa.Integer(), nullable=False), sa.Column('displayname', sa.String(length=255), nullable=False), sa.Column('displayversion', sa.String(length=128), nullable=True), diff --git a/plugins/endpoints/models/endpoint.py b/plugins/endpoints/models/endpoint.py index e0da120d..47a04e31 100644 --- a/plugins/endpoints/models/endpoint.py +++ b/plugins/endpoints/models/endpoint.py @@ -282,7 +282,16 @@ class EndpointApplication(db.Model): __tablename__ = 'endpointapplications' - endpointapplicationid = db.Column(db.Integer, primary_key=True) + # BigInteger on MySQL, Integer on SQLite. This table is rewritten wholesale + # on every check-in, so the key never stops climbing and a 32-bit column + # eventually stops accepting inserts - see the migration for the arithmetic. + # + # The variant is not decoration: SQLite only autoincrements a column typed + # exactly INTEGER PRIMARY KEY, so a plain BigInteger makes every insert fail + # with a NOT NULL violation on the test database. Nothing caught that, + # because no test inserts into this table. + endpointapplicationid = db.Column( + db.BigInteger().with_variant(db.Integer, 'sqlite'), primary_key=True) # The asset this install belongs to. Not unique: many applications per # endpoint. CASCADE so removing the asset clears its SBOM snapshot. diff --git a/plugins/endpoints/plugin.py b/plugins/endpoints/plugin.py index 68de6c86..245eef0e 100644 --- a/plugins/endpoints/plugin.py +++ b/plugins/endpoints/plugin.py @@ -78,6 +78,20 @@ def _as_json(value): return json.dumps(value, separators=(',', ':'), sort_keys=True) +def _clamp(value, length): + """A string bounded to its column width, or None. + + Ingest writes whatever an endpoint reports. A registry value longer than its + column is not a data-quality nuisance on MySQL: in strict mode it raises + Data too long and fails the whole check-in transaction, so one machine with + a malformed Uninstall entry stops reporting telemetry and events entirely. + """ + if value is None: + return None + text = str(value).strip() + return text[:length] or None + + class EndpointsPlugin(BasePlugin): """Endpoint RMM telemetry ingest plus read/dashboard surface.""" @@ -153,6 +167,17 @@ class EndpointsPlugin(BasePlugin): # heavier grant than a routine service/process toggle. ('endpoints.deploy', 'Run staged scripts and push application installers to endpoints', 'endpoints'), + # Packaging the agent is a heavier grant than deploying to one, and + # it is not the same job either: the generated ZIP carries a live + # collector key stamped into its install script, so whoever holds + # this can mint a distributable artefact containing the fleet-wide + # command-channel credential. It was require_role('admin'), which + # this codebase otherwise reserves for the few genuinely + # role-shaped checks - and a role cannot be granted to one person + # for one job the way a permission can. + ('endpoints.agentpackage', + 'Generate and download an agent or adoption-tool package ' + '(contains a live collector key)', 'endpoints'), # Collector service scope for the endpoint agent. A managed token # scoped to ONLY this authorizes the endpoints collector and nothing # else, so rotating/revoking it stops endpoint-agent traffic without @@ -641,11 +666,18 @@ class EndpointsPlugin(BasePlugin): if not displayname: warnings.append('skipped application with no displayname') continue + # TRUNCATE EVERY STRING, not just the name. On MySQL in strict + # mode an over-long value raises Data too long and takes down the + # WHOLE check-in transaction, so one machine with a malformed + # Uninstall registry entry stops reporting entirely - telemetry, + # events and all - until somebody uninstalls the offender. SQLite + # does not enforce declared lengths, so no test here would ever see + # it. displayname was already clamped; these two were not. db.session.add(EndpointApplication( assetid=assetid, displayname=displayname[:255], - displayversion=(item.get('displayversion') or None), - publisher=(item.get('publisher') or None), + displayversion=_clamp(item.get('displayversion'), 128), + publisher=_clamp(item.get('publisher'), 255), )) stored += 1 return stored diff --git a/plugins/printedparts/tests/__init__.py b/plugins/printedparts/tests/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/test_no_hidden_tests.py b/tests/test_no_hidden_tests.py new file mode 100644 index 00000000..da5df334 --- /dev/null +++ b/tests/test_no_hidden_tests.py @@ -0,0 +1,50 @@ +"""No test file may sit where pytest will not collect it. + +pytest.ini sets `testpaths = tests`, so a test file anywhere else is never run +by `pytest` and never run in CI. It still passes review, still gets maintained, +and still reports nothing. + +That is not hypothetical. plugins/endpoints/tests/test_plugin.py arrived with a +merge carrying 55 lines of delivery assertions and was collected ZERO times; run +directly, five of its tests failed. plugins/printedparts/tests/test_plugin.py had +been sitting uncollected for longer. Both are now under tests/. + +The failure mode is the worst kind: the suite is green, the coverage is imaginary, +and the authorisation on a route can be deleted without anything going red. +""" +from pathlib import Path + +REPO = Path(__file__).resolve().parents[1] + +# Where pytest actually looks (pytest.ini: testpaths = tests). +COLLECTED = REPO / 'tests' + +# Trees that legitimately hold test-shaped files pytest must not collect: +# vendored dependencies, build output, and the frontend's own vitest specs. +IGNORED = ('node_modules', 'venv', '.git', 'dist', '__pycache__', + 'site-packages', '.plugins-staged', 'frontend') + +# The Windows installer stages a whole copy of the app under here. It is build +# output, not source, and the export excludes it - caught by this test on its +# first run, which is the sort of thing the ignore list exists for. +IGNORED_TREES = ('deploy/windows/installer/bundle',) + + +def test_every_test_file_lives_where_pytest_looks(): + stray = [] + for path in REPO.rglob('test_*.py'): + if any(part in IGNORED for part in path.parts): + continue + relative = path.relative_to(REPO).as_posix() + if any(relative.startswith(tree) for tree in IGNORED_TREES): + continue + if COLLECTED in path.parents: + continue + stray.append(path.relative_to(REPO)) + + assert not stray, ( + 'These test files are never collected, because pytest.ini limits\n' + 'collection to tests/. They pass review and report nothing:\n ' + + '\n '.join(str(p) for p in sorted(stray)) + + '\n\nMove them under tests/ (tests/test_plugins/ for plugin tests).' + ) diff --git a/tests/test_plugins/test_endpoint_applications_model.py b/tests/test_plugins/test_endpoint_applications_model.py new file mode 100644 index 00000000..618a2634 --- /dev/null +++ b/tests/test_plugins/test_endpoint_applications_model.py @@ -0,0 +1,87 @@ +"""The installed-software snapshot table: it must actually accept inserts. + +This table is rewritten wholesale on every endpoint check-in - delete the +asset's rows, insert the current inventory - so its surrogate key climbs at +(endpoints x installed apps) per report and never reuses a value. On MySQL a +32-bit key exhausts and inserts stop working permanently. + +Widening it to BigInteger broke every insert on SQLite, because SQLite only +autoincrements a column typed exactly INTEGER PRIMARY KEY. Neither fault was +caught by the suite, for the same reason: nothing inserted into this table at +all. This does. +""" +from shopdb.api import db +from plugins.endpoints.models.endpoint import EndpointApplication + + +def test_rows_insert_and_the_key_autoincrements(app): + with app.app_context(): + db.create_all() + first = EndpointApplication(assetid=1, displayname='Probe', displayversion='1.0') + second = EndpointApplication(assetid=1, displayname='Probe 2', displayversion='2.0') + db.session.add_all([first, second]) + db.session.commit() + + assert first.endpointapplicationid is not None + assert second.endpointapplicationid is not None + assert second.endpointapplicationid != first.endpointapplicationid + + +def test_the_key_is_wide_enough_for_a_table_that_is_rewritten_every_cycle(app): + """BIGINT on MySQL, INTEGER on SQLite - both halves matter. + + Dropping the variant breaks SQLite inserts; dropping the BigInteger breaks + MySQL years later, which is worse because it is silent until it is not. + """ + from sqlalchemy.dialects import mysql, sqlite + + with app.app_context(): + column = EndpointApplication.__table__.c.endpointapplicationid + assert 'BIGINT' in str(column.type.compile(dialect=mysql.dialect())) + assert 'INTEGER' in str(column.type.compile(dialect=sqlite.dialect())) + + +def test_over_long_registry_values_are_clamped_not_fatal(app): + """One malformed Uninstall entry must not cost the whole check-in. + + On MySQL in strict mode an over-long value raises Data too long and fails the + transaction, taking telemetry and events down with it - so that endpoint goes + silent until somebody uninstalls the offending program. SQLite does not + enforce declared lengths, so the suite would never have shown it. + """ + from plugins.endpoints.plugin import EndpointsPlugin + + with app.app_context(): + db.create_all() + warnings = [] + stored = EndpointsPlugin()._replace_applications( + assetid=4242, + applications=[{ + 'displayname': 'A' * 400, + 'displayversion': 'V' * 400, + 'publisher': 'P' * 400, + }], + warnings=warnings, + ) + db.session.commit() + + assert stored == 1 + row = EndpointApplication.query.filter_by(assetid=4242).one() + assert len(row.displayname) == 255 + assert len(row.displayversion) == 128 + assert len(row.publisher) == 255 + + +def test_a_blank_value_becomes_null_rather_than_an_empty_string(app): + from plugins.endpoints.plugin import EndpointsPlugin + + with app.app_context(): + db.create_all() + EndpointsPlugin()._replace_applications( + assetid=4243, + applications=[{'displayname': 'Thing', 'publisher': ' '}], + warnings=[], + ) + db.session.commit() + row = EndpointApplication.query.filter_by(assetid=4243).one() + assert row.publisher is None diff --git a/tests/test_plugins/test_endpoint_delivery_descriptor.py b/tests/test_plugins/test_endpoint_delivery_descriptor.py new file mode 100644 index 00000000..a099a80a --- /dev/null +++ b/tests/test_plugins/test_endpoint_delivery_descriptor.py @@ -0,0 +1,81 @@ +"""An update_app delivery must pin the bytes it actually delivers. + +The agent verifies the downloaded file against the descriptor's sha256 and fails +closed on a mismatch - which is worthless if the descriptor carries no digest, +and misleading if it carries a digest for a different file. + +Both were true of the version: branch. It pinned AppVersion.sha256, but +/api/endpoints/apppackage/ resolves the version to its application +and serves application.installpath, so the digest described an artifact that +route never sends; where the version had no sha256 the descriptor carried none +at all and the agent had nothing to check. + +It also ended the URL at the id, so an MSI arrived with no extension and the +agent's Path.GetExtension could not route it through msiexec. +""" +import pytest + +from shopdb.api import db +from shopdb.core.models import Application, AppVersion + + +@pytest.fixture +def hosted_app(app, request): + # Unique per test: appname is unique-constrained and the database outlives + # a single test here, so a fixed name collides on the second use. + with app.app_context(): + db.create_all() + application = Application( + appname='Widget-%s' % request.node.name[-24:], + installpath='/api/applications/package/widget-setup.msi', + installersha256='a' * 64, + ) + db.session.add(application) + db.session.commit() + version = AppVersion(appid=application.appid, version='2.1', sha256=None) + db.session.add(version) + db.session.commit() + yield application.appid, version.appversionid + + +def test_a_version_delivery_carries_a_digest_even_when_the_version_has_none(app, hosted_app): + from plugins.endpoints.api.routes import _resolve_update_app + + appid, appversionid = hosted_app + with app.app_context(): + descriptor, error = _resolve_update_app(f'version:{appversionid}') + + assert error is None, error + # Not None: this is the whole point. A blank pin makes the agent fail open. + assert descriptor['sha256'] == 'a' * 64 + + +def test_a_version_delivery_keeps_the_installer_extension(app, hosted_app): + """Without the filename an MSI is not routed through msiexec.""" + from plugins.endpoints.api.routes import _resolve_update_app + + appid, appversionid = hosted_app + with app.app_context(): + descriptor, _ = _resolve_update_app(f'version:{appversionid}') + + assert descriptor['installpath'].endswith('.msi') + + +def test_a_package_with_no_recorded_digest_is_refused_not_delivered_unverified(app): + """Refusing to enqueue beats delivering something nothing can verify.""" + from plugins.endpoints.api.routes import _resolve_update_app + + with app.app_context(): + db.create_all() + application = Application(appname='Unhashed-%s' % id(app), + installpath='/api/applications/package/x.exe') + db.session.add(application) + db.session.commit() + version = AppVersion(appid=application.appid, version='1.0', sha256=None) + db.session.add(version) + db.session.commit() + + descriptor, error = _resolve_update_app(f'version:{version.appversionid}') + + assert descriptor is None + assert 'SHA-256' in error diff --git a/plugins/endpoints/tests/test_plugin.py b/tests/test_plugins/test_endpoints_plugin.py similarity index 81% rename from plugins/endpoints/tests/test_plugin.py rename to tests/test_plugins/test_endpoints_plugin.py index 3eca057d..2d7101c4 100644 --- a/plugins/endpoints/tests/test_plugin.py +++ b/tests/test_plugins/test_endpoints_plugin.py @@ -69,7 +69,7 @@ def test_endpoints_asset_panels_are_computer_scoped(): """Every asset panel targets computer assets only, with an endpoint.""" plugin = EndpointsPlugin() panels = plugin.get_asset_panels() - assert len(panels) == 5 + assert len(panels) == 6 for panel in panels: assert panel['assettypes'] == ['computer'] assert panel['endpoint'].startswith('/api/endpoints/asset/{assetid}') @@ -96,14 +96,28 @@ def test_services_and_processes_are_split_into_two_filtered_cards(): assert processes['permission'] == 'endpoints.command' -def test_recent_events_card_is_last_and_starts_collapsed(): - """Events is the lowest-priority card: forced to the bottom of the column - and collapsed by default.""" +def test_activity_card_starts_collapsed_and_sits_below_live_state(): + """The activity feed is history, so it must not push live state down. + + Renamed from the events card when events and commands merged into one feed. + It is no longer the LAST panel - installed software sits below it - so the + assertion is that it ranks below every live-state panel rather than being + the maximum, which would break again the next time a reference panel is + added underneath. + """ plugin = EndpointsPlugin() panels = plugin.get_asset_panels() - events = next(p for p in panels if p['id'] == 'endpoints-events') - assert events['collapsedDefault'] is True - assert events['position'] == max(p['position'] for p in panels) + activity = next(p for p in panels if p['id'] == 'endpoints-activity') + # NOT collapsed by default, unlike the events card it replaced. The feed + # surfaces critical events, and a card that hides the one thing worth seeing + # is the reason the health dot and the visible rows disagreed. Collapsible, + # so a reader can put it away. + assert activity.get('collapsible') is True + assert activity.get('collapsedDefault') is not True + live = [p['position'] for p in panels + if p['id'] in ('endpoints-status', 'endpoints-tracked-services', + 'endpoints-tracked-processes', 'endpoints-interfaces')] + assert activity['position'] > max(live) def test_endpoints_dashboard_widgets_declared(): @@ -115,7 +129,9 @@ def test_endpoints_dashboard_widgets_declared(): for widget in widgets: assert widget['render'] == 'exceptions' assert widget['empty'] == 'hide' - assert widget['permission'] == 'endpoints.view' + # The fleet-wide cards read the whole roster, so they are gated on + # endpoints.fleet, not the per-endpoint endpoints.view a guest may hold. + assert widget['permission'] == 'endpoints.fleet' def test_endpoints_navigation_item_points_at_fleet_view(): @@ -209,10 +225,14 @@ def test_new_hardware_telemetry_keys_are_logged_not_silently_known(): rather than dropping them silently.""" from plugins.endpoints.plugin import KNOWN_PAYLOAD_KEYS - for key in ('disks', 'bios', 'memory', 'processor', 'osinfo', 'domain', - 'applications'): + for key in ('disks', 'bios', 'memory', 'processor', 'osinfo', 'domain'): assert key not in KNOWN_PAYLOAD_KEYS + # 'applications' USED to be in this list. It is persisted now - that is what + # the installed-software work added - so it belongs in the known set, and + # asserting its absence would fail the moment the feature it tests works. + assert 'applications' in KNOWN_PAYLOAD_KEYS + def test_telemetrymeta_route_is_registered_for_freshness_gating(): @@ -221,6 +241,11 @@ def test_telemetrymeta_route_is_registered_for_freshness_gating(): from flask import Flask application = Flask(__name__) - application.register_blueprint(EndpointsPlugin().get_blueprint()) + # A url_prefix is REQUIRED here: the fleet list registers as route(''), which + # Flask rejects as a bare rule and accepts under a prefix. That is deliberate + # - CONTRIBUTING says API routes carry no trailing slash - so the test has to + # mount the blueprint the way the app does. + application.register_blueprint(EndpointsPlugin().get_blueprint(), + url_prefix='/api/endpoints') rules = {rule.rule for rule in application.url_map.iter_rules()} assert any(rule.endswith('/asset//telemetrymeta') for rule in rules) diff --git a/plugins/printedparts/tests/test_plugin.py b/tests/test_plugins/test_printedparts_plugin.py similarity index 100% rename from plugins/printedparts/tests/test_plugin.py rename to tests/test_plugins/test_printedparts_plugin.py