Files
shopdb-flask/tests/test_plugins/test_endpoints_settings_declared.py
cproudlock 534f179ee6 Make main the lean product a site receives
main was 16 days and 259 commits behind the line the product is actually built
on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It
was neither current nor curated - just stale. This brings it forward and, at the
same time, makes it mean something: main is what a receiving site installs, and
nothing else.

WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides,
knowledgebase, machines, network, notifications, printers, slides, tools,
warranty - plus applications, which is manifest-less core and always ships. The
only manifest dependency inside that set is endpoints on computers, and both are
in it, so the closure is self-contained.

WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb
stay on the site line. So do the Windows installer tree, the VM rehearsal
helpers, the agent tooling config, one site's data loader, the internal audit
and findings records, and the planner spreadsheet. 1,135 tracked files against
1,438.

A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped:

deploy/site-profile-universal.json still named all seventeen plugins. It is the
file docs/DEPLOY.md tells a site to provision from, so a fresh install would
have failed at `flask plugin apply-profile` with PluginError - the first thing a
receiving site does, broken by the branch meant to serve them. It now names the
twelve that exist, and its comment says the two must agree.

agent/ was never refreshed. Whoever populated this branch copied shopdb/,
plugins/, frontend/ and tests/ from the site line and left the agent tree at its
September state: 22 C# and PowerShell sources missing, including AgentCredentials,
EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the
site line had deleted still present. The old tree is internally consistent, so it
compiles, and nothing in CI builds it - every gate was green over a server that
speaks enrollment and per-PC credentials to a bundled agent source that cannot.
Resynced whole.

DECISIONS THIS ENCODES, rather than defers:

- The plugin-authoring lab builds printedparts, whose manifest depends on
  employees, so it could not stay. The lab remains on the site line and main
  ships no build-along tutorial. That is a real loss for an adopting developer
  and is recorded here rather than discovered later.
- Recognition, Training and Recertification notifications are no longer seeded.
  Each splits one notification into a card per employee and renders a photo,
  which needs a plugin this branch does not bundle. Seeding them would offer an
  author a type that cannot resolve anybody. A site that installs employees gets
  them from that plugin's own seed.
- Windows/IIS is documented as the legacy path throughout, and the README leads
  with Docker, because that is what a receiving site gets.

The API description is regenerated for THIS branch - 347 paths and 483
operations, down from 415 and 573 - so the published contract describes what is
here rather than what the site line serves. docs/CONFIG.md lost the two
measuring-tool label keys the code no longer declares, which two lanes had
decided opposite ways.

Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run
rather than skip, and every index line count is derived from the file it names.
2026-09-19 11:24:40 -04:00

163 lines
6.7 KiB
Python

"""The two endpoints switches are declared, seeded, and have a control.
Both were readable by their enforcement code before they were declared:
plugin.endpoints.revokeddeniessharedkey agentauth.revoked_denies_shared_key
plugin.endpoints.bootstrapquerytoken bootstrap._query_token_allowed
An undeclared key has no row until somebody writes one, and until it was
declared the settings API upserted it with valuetype 'string' - so
bool(Setting.get(key, True)) read the text 'false' as True and the switch did
nothing. Declaring it fixes both halves: the row is seeded at plugin
install/enable, and a PUT lands as a real boolean.
The UI half is checked by reading the pages. A switch with no control on an
air-gapped box can only be changed with SQL, which is not a setting.
"""
from pathlib import Path
import pytest
from plugins.endpoints.api import agentauth, bootstrap
from plugins.endpoints.plugin import EndpointsPlugin
from shopdb.core.models import Setting
REPO = Path(__file__).resolve().parents[2]
VIEWS = REPO / 'plugins' / 'endpoints' / 'frontend' / 'views'
REVOKED_KEY = agentauth.REVOKED_DENIES_SHARED_KEY_SETTING
QUERY_KEY = bootstrap.QUERY_TOKEN_SETTING
def _declared():
return {entry['key']: entry for entry in
EndpointsPlugin().get_settings_defaults()}
@pytest.mark.parametrize('key,value', [(REVOKED_KEY, 'false'),
(QUERY_KEY, 'true')])
def test_the_switch_is_declared_with_todays_behaviour(key, value):
entry = _declared().get(key)
assert entry is not None, key + ' is read but never declared'
assert entry['valuetype'] == 'boolean'
assert entry['category'] == 'plugin.endpoints'
# The declared default must be what the code does with no row at all, or
# seeding a site silently changes its behaviour.
assert entry['value'] == value
@pytest.mark.parametrize('key', [REVOKED_KEY, QUERY_KEY])
def test_the_description_names_what_turning_it_on_costs(key):
"""An operator on the shop floor decides from this text alone."""
description = _declared()[key]['description']
assert len(description) > 120, description
if key == REVOKED_KEY:
# The revoke + blockreenroll rollback onto an older agent stops working.
assert 'rollback' in description
assert 'enrolls again' in description
else:
assert 're-issued' in description or 're-issue' in description
def test_the_declared_defaults_match_what_the_code_reads_with_no_row(db):
"""No Setting rows at all: the readers fall back to these same values."""
assert Setting.query.filter_by(key=REVOKED_KEY).first() is None
assert Setting.query.filter_by(key=QUERY_KEY).first() is None
assert agentauth.revoked_denies_shared_key() is False
assert bootstrap._query_token_allowed() is True
def test_the_upgrade_pass_seeds_them_on_a_site_that_installed_earlier(app, db):
"""install/enable fire only on a state transition, so a fleet already
running this plugin gets these rows from `flask plugin upgrade-all`, which
calls sync_all_plugin_settings -> _seed_plugin_settings.
This drives _seed_plugin_settings on a manager built for this test rather
than the process-wide plugin_manager singleton, and that is deliberate.
create_app() re-points that singleton, eight test modules build their own
app, and whatever ran last on this xdist worker owns it: the symptoms are
"no such table: settings" (seeding against another app's in-memory SQLite)
and "'NoneType' object has no attribute 'get_all'" (registry left None).
Both land on whichever test follows, so they move with the split - this one
passed locally and failed on CI for exactly that reason.
init_app is not called here either: it re-registers the plugin blueprints,
and twice on one app raises "The name 'backups' is already registered".
Setting _app and _db is all the seeding routine reads.
"""
from shopdb.plugins import PluginManager
from plugins.endpoints.plugin import EndpointsPlugin
assert Setting.query.filter_by(key=QUERY_KEY).first() is None
manager = PluginManager()
manager._app = app
manager._db = db
assert manager._seed_plugin_settings(EndpointsPlugin()) is True
for key, value in ((REVOKED_KEY, False), (QUERY_KEY, True)):
row = Setting.query.filter_by(key=key).one()
assert row.valuetype == 'boolean'
assert row.category == 'plugin.endpoints'
assert row.get_typed_value() is value
def test_a_put_lands_as_a_boolean_and_flips_the_query_token(client, db,
auth_headers):
"""End to end for bootstrapquerytoken through the API the page uses.
The declaration is what makes the upsert boolean; as a string row the read
below would stay True whatever was saved.
"""
saved = client.put('/api/settings/' + QUERY_KEY, json={'value': False},
headers=auth_headers)
assert saved.status_code == 200, saved.get_json()
body = saved.get_json()['data']
assert body['value'] is False
assert body['valuetype'] == 'boolean'
assert body['category'] == 'plugin.endpoints'
assert bootstrap._query_token_allowed() is False
back = client.put('/api/settings/' + QUERY_KEY, json={'value': True},
headers=auth_headers)
assert back.get_json()['data']['value'] is True
assert bootstrap._query_token_allowed() is True
def test_the_settings_api_lists_the_declared_switch(client, db, auth_headers):
"""The agent-key page reads GET /api/settings/<key> to render the box."""
client.put('/api/settings/' + QUERY_KEY, json={'value': True},
headers=auth_headers)
resp = client.get('/api/settings/' + QUERY_KEY, headers=auth_headers)
assert resp.status_code == 200, resp.get_json()
assert resp.get_json()['data']['value'] is True
def test_the_enrollment_page_carries_the_eviction_checkbox():
page = (VIEWS / 'EnrollmentSettings.vue').read_text(encoding='utf-8')
assert 'toggle-revokeddenies' in page, 'no checkbox for the eviction switch'
assert 'onRevokedDeniesToggle' in page
# It sits with the two switches it belongs beside.
assert 'toggle-sharedkey' in page and 'toggle-autoenroll' in page
# The operator has to be told what it breaks before clicking it.
assert 'rollback' in page
assert 'enrolls again' in page
def test_the_agent_key_page_carries_the_query_token_control():
page = (VIEWS / 'AgentKeySettings.vue').read_text(encoding='utf-8')
assert QUERY_KEY in page, 'the page does not touch the setting'
assert 'toggle-querytoken' in page
assert 'onQueryTokenToggle' in page
# Turning it off breaks commands already handed out, so say so.
assert 're-issue' in page