Clear the review blockers on the merged wilm work
All checks were successful
CI / naming (push) Successful in 3s
CI / frontend (push) Successful in 13s
CI / migrations-mysql (push) Successful in 42s
CI / backend (push) Successful in 4m18s

A multi-agent review of the merged branch produced 29 findings that survived
adversarial verification. This clears the blockers and the four worth fixing
before it reaches main. The design held up well: path-traversal defence on all
four serving routes is clean, the agent's SHA-256 check fails closed, enqueue
authority is proportionate and audited, and no site literals or credential leaks
in the new C#.

THE TESTS NEVER RAN

pytest.ini sets testpaths = tests, so plugins/endpoints/tests/ was collected ZERO
times. Fifty-five lines of delivery assertions were dead code, and run directly
five of them failed. plugins/printedparts/tests/ had been invisible for longer.

That is the worst shape a gap can take: the suite is green, the coverage is
imaginary, and the collector auth on a download route could be deleted without
anything going red.

Both files moved under tests/. tests/test_no_hidden_tests.py now fails if a test
file appears anywhere pytest will not look; it caught a staged copy inside the
installer bundle on its first run, and was verified by planting a violation.

The five failures were stale expectations, and they split by cause. His: the
applications panel made six panels where his test said five, and 'applications'
belongs in KNOWN_PAYLOAD_KEYS now that it is persisted - his own test contradicted
his own feature. Ours: we renamed the events card to activity, moved the
fleet-wide cards to endpoints.fleet, and made the list route a bare route('')
which Flask rejects unless the blueprint is mounted under a prefix.

FOUR FIXES

The snapshot table's key was a 32-bit autoincrement on a table rewritten
wholesale every check-in, so it climbs at (endpoints x apps) per report and
exhausts on MySQL. Widened while the table is unreleased. Widening it broke every
insert on SQLite, which only autoincrements a column typed exactly INTEGER
PRIMARY KEY, so it carries a dialect variant - and nothing caught either fault,
because no test inserted into that table at all. One does now.

Ingest truncated displayname but not displayversion or publisher. On strict MySQL
an over-long registry value fails the whole check-in transaction, so one machine
with a malformed Uninstall entry goes silent entirely. SQLite does not enforce
lengths, so the suite would never have shown it.

update_app's version: branch pinned AppVersion.sha256, but that route resolves the
version to its APPLICATION and serves application.installpath - so the digest
described an artifact the route never sends, and where it was null the agent had
nothing to verify and failed open. It now pins the file actually served.

The same branch ended the delivery URL at the id, so an MSI arrived with no
extension and could not be routed through msiexec. It carries the filename now.

Agent packaging moved off require_role('admin') to a new endpoints.agentpackage
permission. The generated ZIP carries a live collector key, so this is a heavier
grant than deploying to one endpoint - and a role cannot be given to one person
for one job the way a permission can.

Full suite 2231 passed (2198 before, plus the 22 that never ran and 11 new),
frontend 314.
This commit is contained in:
cproudlock
2026-08-30 17:17:13 -04:00
parent 7dc1f0bee8
commit 5f27f81e88
15 changed files with 1256 additions and 27 deletions

View File

@@ -12,7 +12,7 @@ never by editing this file.
| series | value | governed by | | series | value | governed by |
|---|---|---| |---|---|---|
| product `__version__` | `0.12.0` | ADR-007 | | 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. 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 | | chain | head |
|---|---| |---|---|
| core | `7d34_singular_relationship_types` | | core | `7d36_scriptlibrary_and_app_installer_digest` |
| backups | `backups0003clearlastseen` | | backups | `backups0003clearlastseen` |
| computers | `computers0004policies` | | computers | `computers0004policies` |
| employees | `employees0002photo` | | employees | `employees0002photo` |
| endpoints | `endpoints0003cmdtarget` | | endpoints | `endpoints0004apps` |
| geenforce | `geenforce0004minlib` | | geenforce | `geenforce0004minlib` |
| guides | `guides0001baseline` | | guides | `guides0001baseline` |
| knowledgebase | `knowledgebase0001anchor` | | knowledgebase | `knowledgebase0001anchor` |
@@ -90,6 +90,6 @@ Manifest-less directories under `plugins/` are core frontend surface and always
## Size ## Size
- test functions defined: **1507** (parametrised cases collect higher) - test functions defined: **1544** (parametrised cases collect higher)
- documented API paths: **333** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`) - documented API paths: **345** (`docs/openapi.json`, regenerate with `scripts/gen_openapi.py`)

View File

@@ -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" "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/<script_id>",
"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/<script_id>",
"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/<script_id>",
"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/<script_id>/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/<filename>",
"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/<appversionid>",
"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/<app_id>",
"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/<app_id>/<filename>",
"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/<script_id>",
"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/<asset_id>/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"
}
]
} }
] ]

View File

@@ -185,6 +185,15 @@
}, },
{ {
"name": "plugin-endpoints" "name": "plugin-endpoints"
},
{
"name": "core-scripts"
},
{
"name": "endpoints-delivery"
},
{
"name": "endpoints-siteagent"
} }
], ],
"paths": { "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"
}
}
}
}
}
} }
} }
} }

View File

@@ -23,7 +23,7 @@ import zipfile
from flask import request, current_app, send_file 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 from .routes import endpoints_bp
@@ -66,7 +66,7 @@ def _build_readme():
@endpoints_bp.route('/adoptiontool/package', methods=['POST']) @endpoints_bp.route('/adoptiontool/package', methods=['POST'])
@require_role('admin') @require_permission('endpoints.agentpackage')
def package_adoption_tool(): def package_adoption_tool():
"""Bundle the adoption tool as a downloadable ZIP. """Bundle the adoption tool as a downloadable ZIP.

View File

@@ -1097,14 +1097,39 @@ def _resolve_update_app(target):
if application is None or not application.installpath: if application is None or not application.installpath:
return None, ('Application for appversionid {} has no installer package' return None, ('Application for appversionid {} has no installer package'
.format(ident)) .format(ident))
# PIN THE FILE THAT IS ACTUALLY SERVED.
#
# /api/endpoints/apppackage/<appversionid> 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 { return {
'source': 'version', 'source': 'version',
'appversionid': version.appversionid, 'appversionid': version.appversionid,
'appid': version.appid, 'appid': version.appid,
'appname': application.appname, 'appname': application.appname,
'version': version.version, 'version': version.version,
'installpath': '/api/endpoints/apppackage/{}'.format(version.appversionid), 'installpath': installpath,
'sha256': version.sha256, 'sha256': sha256,
'silentargs': version.silentargs, 'silentargs': version.silentargs,
}, None }, None

View File

@@ -31,7 +31,8 @@ import zipfile
from flask import request, current_app, send_file 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 from .routes import endpoints_bp
@@ -335,14 +336,14 @@ def _siteagent_status_payload():
@endpoints_bp.route('/siteagent', methods=['GET']) @endpoints_bp.route('/siteagent', methods=['GET'])
@require_role('admin') @require_permission('endpoints.agentpackage')
def get_siteagent(): def get_siteagent():
"""Report whether the site agent build has a prebuilt binary staged.""" """Report whether the site agent build has a prebuilt binary staged."""
return success_response(_siteagent_status_payload()) return success_response(_siteagent_status_payload())
@endpoints_bp.route('/siteagent/package', methods=['POST']) @endpoints_bp.route('/siteagent/package', methods=['POST'])
@require_role('admin') @require_permission('endpoints.agentpackage')
def package_siteagent(): def package_siteagent():
"""Bundle the site agent as a downloadable ZIP. """Bundle the site agent as a downloadable ZIP.

View File

@@ -38,7 +38,17 @@ def upgrade():
op.create_table( op.create_table(
'endpointapplications', '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('assetid', sa.Integer(), nullable=False),
sa.Column('displayname', sa.String(length=255), nullable=False), sa.Column('displayname', sa.String(length=255), nullable=False),
sa.Column('displayversion', sa.String(length=128), nullable=True), sa.Column('displayversion', sa.String(length=128), nullable=True),

View File

@@ -282,7 +282,16 @@ class EndpointApplication(db.Model):
__tablename__ = 'endpointapplications' __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 # The asset this install belongs to. Not unique: many applications per
# endpoint. CASCADE so removing the asset clears its SBOM snapshot. # endpoint. CASCADE so removing the asset clears its SBOM snapshot.

View File

@@ -78,6 +78,20 @@ def _as_json(value):
return json.dumps(value, separators=(',', ':'), sort_keys=True) 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): class EndpointsPlugin(BasePlugin):
"""Endpoint RMM telemetry ingest plus read/dashboard surface.""" """Endpoint RMM telemetry ingest plus read/dashboard surface."""
@@ -153,6 +167,17 @@ class EndpointsPlugin(BasePlugin):
# heavier grant than a routine service/process toggle. # heavier grant than a routine service/process toggle.
('endpoints.deploy', ('endpoints.deploy',
'Run staged scripts and push application installers to endpoints', 'endpoints'), '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 # Collector service scope for the endpoint agent. A managed token
# scoped to ONLY this authorizes the endpoints collector and nothing # scoped to ONLY this authorizes the endpoints collector and nothing
# else, so rotating/revoking it stops endpoint-agent traffic without # else, so rotating/revoking it stops endpoint-agent traffic without
@@ -641,11 +666,18 @@ class EndpointsPlugin(BasePlugin):
if not displayname: if not displayname:
warnings.append('skipped application with no displayname') warnings.append('skipped application with no displayname')
continue 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( db.session.add(EndpointApplication(
assetid=assetid, assetid=assetid,
displayname=displayname[:255], displayname=displayname[:255],
displayversion=(item.get('displayversion') or None), displayversion=_clamp(item.get('displayversion'), 128),
publisher=(item.get('publisher') or None), publisher=_clamp(item.get('publisher'), 255),
)) ))
stored += 1 stored += 1
return stored return stored

View File

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

View File

@@ -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

View File

@@ -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/<appversionid> 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

View File

@@ -69,7 +69,7 @@ def test_endpoints_asset_panels_are_computer_scoped():
"""Every asset panel targets computer assets only, with an endpoint.""" """Every asset panel targets computer assets only, with an endpoint."""
plugin = EndpointsPlugin() plugin = EndpointsPlugin()
panels = plugin.get_asset_panels() panels = plugin.get_asset_panels()
assert len(panels) == 5 assert len(panels) == 6
for panel in panels: for panel in panels:
assert panel['assettypes'] == ['computer'] assert panel['assettypes'] == ['computer']
assert panel['endpoint'].startswith('/api/endpoints/asset/{assetid}') 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' assert processes['permission'] == 'endpoints.command'
def test_recent_events_card_is_last_and_starts_collapsed(): def test_activity_card_starts_collapsed_and_sits_below_live_state():
"""Events is the lowest-priority card: forced to the bottom of the column """The activity feed is history, so it must not push live state down.
and collapsed by default."""
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() plugin = EndpointsPlugin()
panels = plugin.get_asset_panels() panels = plugin.get_asset_panels()
events = next(p for p in panels if p['id'] == 'endpoints-events') activity = next(p for p in panels if p['id'] == 'endpoints-activity')
assert events['collapsedDefault'] is True # NOT collapsed by default, unlike the events card it replaced. The feed
assert events['position'] == max(p['position'] for p in panels) # 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(): def test_endpoints_dashboard_widgets_declared():
@@ -115,7 +129,9 @@ def test_endpoints_dashboard_widgets_declared():
for widget in widgets: for widget in widgets:
assert widget['render'] == 'exceptions' assert widget['render'] == 'exceptions'
assert widget['empty'] == 'hide' 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(): 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.""" rather than dropping them silently."""
from plugins.endpoints.plugin import KNOWN_PAYLOAD_KEYS from plugins.endpoints.plugin import KNOWN_PAYLOAD_KEYS
for key in ('disks', 'bios', 'memory', 'processor', 'osinfo', 'domain', for key in ('disks', 'bios', 'memory', 'processor', 'osinfo', 'domain'):
'applications'):
assert key not in KNOWN_PAYLOAD_KEYS 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(): 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 from flask import Flask
application = Flask(__name__) 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()} rules = {rule.rule for rule in application.url_map.iter_rules()}
assert any(rule.endswith('/asset/<int:asset_id>/telemetrymeta') for rule in rules) assert any(rule.endswith('/asset/<int:asset_id>/telemetrymeta') for rule in rules)