@@ -764,6 +789,15 @@ const bootstraptokens = ref([])
const bootstrapmessage = ref('')
const bootstraperror = ref('')
+// plugin.endpoints.bootstrapquerytoken, read and written through the core
+// settings API: the endpoints plugin has no settings route of its own for it,
+// and the key is declared in get_settings_defaults so the upsert on PUT gets
+// the boolean valuetype and the description from that declaration.
+const QUERY_TOKEN_SETTING = 'plugin.endpoints.bootstrapquerytoken'
+const querytokenallowed = ref(true)
+const querytokenbusy = ref(false)
+const querytokenerror = ref('')
+
function agentnextpininvalid() {
const pin = agentnextserverpin.value
return !!pin && !/^[0-9a-f]{64}$/.test(pin)
@@ -863,6 +897,7 @@ onMounted(async () => {
}
loadEnrollment()
loadBootstrapTokens()
+ loadQueryTokenSetting()
try {
const response = await api.get('/endpoints/siteagent')
const status = response.data.data || {}
@@ -878,6 +913,53 @@ onMounted(async () => {
}
})
+async function loadQueryTokenSetting() {
+ try {
+ const response = await api.get(`/settings/${QUERY_TOKEN_SETTING}`)
+ // The backend reads it as bool(Setting.get(key, True)), so only an
+ // explicit false is off. A row seeded as boolean comes back as a real
+ // false; an older row that was created as a string comes back as 'false'.
+ const value = response.data.data?.value
+ querytokenallowed.value = !(value === false || value === 'false')
+ } catch (loadError) {
+ // 404 = the row was never seeded (plugin installed before the key was
+ // declared). The backend default is on, so show on.
+ if (loadError.response?.status !== 404) console.error(loadError)
+ querytokenallowed.value = true
+ }
+}
+
+async function onQueryTokenToggle(event) {
+ const turningOn = event.target.checked
+ if (!turningOn) {
+ const answer = prompt(
+ 'Stop accepting the token in the URL?\n\n'
+ + 'Every install command already handed out uses ?t= and stops '
+ + 'working at once, including the Download .ps1 links. Re-issue them '
+ + 'before turning this off.\n\n'
+ + 'Type OFF to confirm.')
+ if (!answer || answer.trim().toUpperCase() !== 'OFF') {
+ event.target.checked = true
+ return
+ }
+ }
+ querytokenbusy.value = true
+ querytokenerror.value = ''
+ try {
+ // PUT upserts, so this works on a site whose row was never seeded.
+ await api.put(`/settings/${QUERY_TOKEN_SETTING}`, { value: turningOn })
+ querytokenallowed.value = turningOn
+ } catch (saveError) {
+ querytokenerror.value = saveError.response?.status === 403
+ ? 'Only an administrator can change this setting'
+ : 'Could not save the setting'
+ event.target.checked = querytokenallowed.value
+ console.error(saveError)
+ } finally {
+ querytokenbusy.value = false
+ }
+}
+
async function loadBootstrapTokens() {
try {
const response = await api.get('/endpoints/siteagent/bootstrap/tokens')
diff --git a/plugins/endpoints/plugin.py b/plugins/endpoints/plugin.py
index 9c417354..03276730 100644
--- a/plugins/endpoints/plugin.py
+++ b/plugins/endpoints/plugin.py
@@ -287,6 +287,15 @@ class EndpointsPlugin(BasePlugin):
making revocation an instant kill switch. `agentkeyprovisioned` records
that a UI-managed key has been minted so the settings page can render
the rotate/revoke state instead of the first-run generate state.
+
+ Every key an enforcement path reads belongs here, even when its default
+ is what the code already does with no row at all. Undeclared, the row
+ is only born when somebody writes it and the settings API has to guess
+ its type - which is how bootstrapquerytoken became a string row that
+ its boolean reader could never see turned off. Declared, the row is
+ seeded at install and enable (and by `flask plugin upgrade-all` on a
+ site that installed an earlier version), so the settings pages have
+ something to render and an operator has something to click.
"""
return [
{
@@ -318,6 +327,41 @@ class EndpointsPlugin(BasePlugin):
'approval when the hostname and serial number '
'match exactly one computer.',
},
+ {
+ 'key': 'plugin.endpoints.revokeddeniessharedkey',
+ 'value': 'false',
+ 'valuetype': 'boolean',
+ 'category': 'plugin.endpoints',
+ 'description': 'Also refuse the shared fleet key to a PC whose '
+ 'per-PC credential was revoked. Off (the '
+ 'default) is what the fleet does today: a '
+ 'revoked PC falls back to the shared key, so a '
+ 'credential rotation and the documented revoke '
+ 'plus blockreenroll rollback to an older agent '
+ 'both keep reporting. Turning it on makes a '
+ 'revoke an eviction - that PC reports nothing '
+ 'and receives no commands until it enrolls '
+ 'again - and it stops that rollback working. '
+ 'Safe only once every PC holds its own '
+ 'credential.',
+ },
+ {
+ 'key': 'plugin.endpoints.bootstrapquerytoken',
+ 'value': 'true',
+ 'valuetype': 'boolean',
+ 'category': 'plugin.endpoints',
+ 'description': 'Accept a one-line install bootstrap token in '
+ 'the ?t= query string as well as the '
+ 'X-Endpoint-Bootstrap-Token header. On (the '
+ 'default) '
+ 'keeps the pasted install commands and the '
+ 'Download .ps1 link working; the query form is '
+ 'deprecated because the token lands in IIS and '
+ 'proxy logs. Turn it off only after every '
+ 'outstanding install command has been re-issued '
+ '- an in-flight one stops working the moment it '
+ 'is off.',
+ },
{
'key': 'plugin.endpoints.agentkeyprovisioned',
'value': 'false',
diff --git a/tests/test_plugins/test_endpoints_bootstrap_install.py b/tests/test_plugins/test_endpoints_bootstrap_install.py
index 48f04d36..b063c1f1 100644
--- a/tests/test_plugins/test_endpoints_bootstrap_install.py
+++ b/tests/test_plugins/test_endpoints_bootstrap_install.py
@@ -11,6 +11,7 @@ import datetime
import hashlib
import io
import json
+import logging
import zipfile
import pytest
@@ -27,17 +28,51 @@ TOKENS_URL = '/api/endpoints/siteagent/bootstrap/tokens'
SCRIPT_URL = '/api/endpoints/siteagent/bootstrap.ps1'
PACKAGE_URL = '/api/endpoints/siteagent/bootstrap/package'
BASE_URL = 'https://shopdb.test/shopdb'
-TEMP_SCRIPT = r'C:\Windows\Temp\shopdb-bootstrap.ps1'
+TOKEN_HEADER = 'X-Endpoint-Bootstrap-Token'
+QUERY_SETTING = 'plugin.endpoints.bootstrapquerytoken'
+# Admin-only staging, NOT C:\Windows\Temp: a standard user can pre-plant a
+# path there and have it run elevated.
+STAGE_DIR = r'C:\Program Files\ShopDB\bootstrap'
+STAGED_SCRIPT = STAGE_DIR + r'\shopdb-bootstrap.ps1'
+ADMIN_SID = 'S-1-5-32-544'
+SYSTEM_SID = 'S-1-5-18'
+# The marker the admin "Download .ps1" link carries, so the server can tell an
+# admin's browser click apart from a bay still on a pre-change pasted command.
+UI_LINK_QUERY = 'via=download'
def _expected_command(secret, base=BASE_URL):
- """Download, run, delete. Never `irm | iex` - Defender kills that shape."""
- return ('powershell -ExecutionPolicy Bypass -c "'
- "try {{ Invoke-WebRequest -UseBasicParsing "
- "'{0}/api/endpoints/siteagent/bootstrap.ps1?t={1}' "
- "-OutFile '{2}'; & '{2}' }} finally {{ "
- "Remove-Item '{2}' -Force -ErrorAction SilentlyContinue }}\""
- ).format(base, secret, TEMP_SCRIPT)
+ """Stage admin-only, download with the token in a header, check the owner,
+ run, delete. Never `irm | iex` - Defender kills that shape."""
+ return (
+ 'powershell -ExecutionPolicy Bypass -c "'
+ "try {{ New-Item -ItemType Directory -Force -Path '{dir}' "
+ '| Out-Null; '
+ "icacls '{dir}' /inheritance:r /grant '*{admin}:(OI)(CI)F' "
+ "'*{system}:(OI)(CI)F' | Out-Null; "
+ 'if ((Get-Variable LASTEXITCODE -ValueOnly) -ne 0) '
+ "{{ Write-Host 'Refusing to run: could not restrict "
+ "{dir} to administrators (icacls failed). Fix the directory "
+ "or delete it, then run the command again.' -ForegroundColor Red; "
+ 'exit 6 }}; '
+ "Remove-Item '{path}' -Force -ErrorAction SilentlyContinue; "
+ "Invoke-WebRequest -UseBasicParsing "
+ "'{base}/api/endpoints/siteagent/bootstrap.ps1' "
+ "-Headers @{{'{header}'='{secret}'}} -OutFile '{path}'; "
+ "if ((Get-Acl '{path}').GetOwner("
+ '[Security.Principal.SecurityIdentifier]).Value -notin '
+ "@('{admin}','{system}',"
+ '[Security.Principal.WindowsIdentity]::GetCurrent().User.Value)) '
+ "{{ Write-Host 'Refusing to run: the staged bootstrap script is not "
+ "owned by an administrator.' -ForegroundColor Red; exit 5 }}; "
+ "& '{path}' }} finally {{ Remove-Item '{path}' -Force "
+ '-ErrorAction SilentlyContinue }}"'
+ ).format(dir=STAGE_DIR, path=STAGED_SCRIPT, base=base, header=TOKEN_HEADER,
+ secret=secret, admin=ADMIN_SID, system=SYSTEM_SID)
+
+
+def _token_header(secret):
+ return {TOKEN_HEADER: secret}
def _make_cert():
@@ -111,8 +146,8 @@ def test_generate_returns_the_secret_and_the_ready_made_command(
assert len(secret) == len('sdbb_') + 43
assert data['command'] == _expected_command(secret)
assert data['scripturl'] == (
- '{0}/api/endpoints/siteagent/bootstrap.ps1?t={1}'.format(BASE_URL,
- secret))
+ '{0}/api/endpoints/siteagent/bootstrap.ps1?t={1}&{2}'.format(
+ BASE_URL, secret, UI_LINK_QUERY))
token = data['token']
assert token['tokenprefix'] == secret[:12]
assert token['maxuses'] == 3
@@ -136,13 +171,74 @@ def test_the_command_downloads_and_runs_instead_of_piping_into_iex(
assert 'iex' not in command.lower()
assert '$' not in command
assert '\n' not in command
- assert data['scripturl'] in command
assert data['secret'] in command
- assert "Remove-Item '{0}' -Force".format(TEMP_SCRIPT) in command
+ assert "Remove-Item '{0}' -Force".format(STAGED_SCRIPT) in command
assert 'finally' in command
# The link the UI offers instead is the route that really serves it.
- assert client.get(SCRIPT_URL,
- query_string={'t': data['secret']}).status_code == 200
+ assert client.get(data['scripturl'].split(BASE_URL, 1)[1]
+ ).status_code == 200
+
+
+def test_the_command_stages_where_only_administrators_can_write(
+ client, db, auth_headers):
+ """C:\\Windows\\Temp lets any standard user create the exact path the
+ command runs elevated, so the staging directory moved under Program Files,
+ is created with an admin-only DACL, and the command refuses to run a staged
+ file owned by anyone but an administrator."""
+ command = _secret(client, auth_headers)['command']
+
+ assert r'C:\Windows\Temp' not in command
+ assert "-OutFile '{0}'".format(STAGED_SCRIPT) in command
+ assert "New-Item -ItemType Directory -Force -Path '{0}'".format(
+ STAGE_DIR) in command
+ assert "icacls '{0}' /inheritance:r".format(STAGE_DIR) in command
+ # Well-known SIDs, so a renamed or localized group cannot widen the grant.
+ assert "/grant '*{0}:(OI)(CI)F' '*{1}:(OI)(CI)F'".format(
+ ADMIN_SID, SYSTEM_SID) in command
+ assert "Get-Acl '{0}'".format(STAGED_SCRIPT) in command
+ assert 'GetOwner([Security.Principal.SecurityIdentifier]).Value' in command
+ assert "-notin @('{0}','{1}',".format(ADMIN_SID, SYSTEM_SID) in command
+ assert '[Security.Principal.WindowsIdentity]::GetCurrent().User.Value' \
+ in command
+ assert 'not owned by an administrator' in command
+ assert 'exit 5' in command
+ # A leftover from an earlier run is cleared before the download, so the
+ # owner test cannot pass on stale content.
+ assert command.index("Remove-Item '{0}'".format(STAGED_SCRIPT)) < \
+ command.index('Invoke-WebRequest')
+
+
+def test_the_command_sends_the_token_in_a_header_not_the_query_string(
+ client, db, auth_headers):
+ """A query string lands verbatim in the nginx and gunicorn access logs."""
+ data = _secret(client, auth_headers)
+ command = data['command']
+
+ assert '?t=' not in command
+ assert "-Headers @{{'{0}'='{1}'}}".format(TOKEN_HEADER,
+ data['secret']) in command
+ assert "'{0}/api/endpoints/siteagent/bootstrap.ps1'".format(
+ BASE_URL) in command
+ # The browser download link is the one place that cannot send a header,
+ # and it is marked so its own fetches are not counted as un-migrated bays.
+ assert data['scripturl'].endswith(
+ '?t={0}&{1}'.format(data['secret'], UI_LINK_QUERY))
+
+
+def test_a_failed_acl_hardening_stops_the_command(client, db, auth_headers):
+ """icacls is a native command: it raises nothing and $ErrorActionPreference
+ never applies to it, so an unchecked call leaves a staging directory that
+ still inherits the parent DACL and the line downloads into it anyway. The
+ exit code is read sigil-free, because the whole line must hold no `$`."""
+ command = _secret(client, auth_headers)['command']
+
+ assert '(Get-Variable LASTEXITCODE -ValueOnly) -ne 0' in command
+ assert 'LASTEXITCODE' in command and '$LASTEXITCODE' not in command
+ assert 'icacls failed' in command
+ assert 'exit 6' in command
+ # Checked before anything is fetched into the directory, not after.
+ assert command.index('LASTEXITCODE') > command.index('icacls')
+ assert command.index('LASTEXITCODE') < command.index('Invoke-WebRequest')
def test_defaults_are_a_day_and_twenty_five_uses(client, db, auth_headers):
@@ -226,8 +322,8 @@ def test_script_is_served_for_a_good_token(client, db, auth_headers):
assert response.mimetype == 'text/plain'
script = response.get_data(as_text=True)
script.encode('ascii')
- assert '{0}/api/endpoints/siteagent/bootstrap/package?t={1}'.format(
- BASE_URL, secret) in script
+ assert '"{0}/api/endpoints/siteagent/bootstrap/package"'.format(
+ BASE_URL) in script
assert 'Run as administrator' in script
assert 'GE-Enforce' in script
assert 'SHOPDB_BOOTSTRAP_FORCE' in script
@@ -251,6 +347,163 @@ def test_script_bakes_the_staged_binary_hashes(client, db, auth_headers,
assert hashlib.sha256(traypayload).hexdigest() in script
+def test_the_generated_script_fetches_the_package_with_the_header(
+ client, db, auth_headers):
+ """Same reason as the one-liner: the package URL must not carry the token,
+ or every agent install writes it into an access log."""
+ secret = _secret(client, auth_headers)['secret']
+ script = client.get(SCRIPT_URL,
+ headers=_token_header(secret)).get_data(as_text=True)
+
+ assert '?t=' not in script
+ assert '$BootstrapPackageHeaders = @{{ "{0}" = "{1}" }}'.format(
+ TOKEN_HEADER, secret) in script
+ assert ('Invoke-WebRequest -Uri $BootstrapPackageUrl '
+ '-Headers $BootstrapPackageHeaders') in script
+
+
+def test_both_routes_take_the_token_in_a_header(client, db, auth_headers,
+ servercert):
+ secret = _secret(client, auth_headers, maxuses=2)['secret']
+
+ script = client.get(SCRIPT_URL, headers=_token_header(secret))
+ assert script.status_code == 200
+ assert 'ShopDB endpoint agent bootstrap' in script.get_data(as_text=True)
+ assert 'install.ps1' in _files(
+ client.get(PACKAGE_URL, headers=_token_header(secret)))
+ assert EndpointBootstrapToken.query.one().usecount == 1
+
+
+@pytest.mark.parametrize('url', [SCRIPT_URL, PACKAGE_URL])
+def test_a_bad_header_token_is_not_found(client, db, auth_headers, url):
+ """The header is read first, so a good ?t= cannot rescue a bad header - one
+ request carries one token."""
+ secret = _secret(client, auth_headers)['secret']
+ response = client.get(url, query_string={'t': secret},
+ headers=_token_header('sdbb_' + 'a' * 43))
+ assert response.status_code == 404
+
+
+def test_the_query_form_still_works_and_is_audited_as_deprecated(
+ client, db, auth_headers, caplog):
+ """A bay running a command pasted before this change must keep working, so
+ the query form stays accepted; it is flagged in the audit row and warned
+ about in the log, with no token value in either."""
+ secret = _secret(client, auth_headers)['secret']
+ with caplog.at_level(logging.WARNING):
+ assert client.get(SCRIPT_URL, query_string={'t': secret},
+ environ_overrides={'REMOTE_ADDR': '10.0.0.6'}
+ ).status_code == 200
+
+ row = AuditLog.query.filter_by(entitytype='EndpointBootstrapToken',
+ action='requested').one()
+ assert 'query-string token, deprecated' in row.details
+ assert secret not in row.details
+ warnings = [record.getMessage() for record in caplog.records
+ if record.levelno >= logging.WARNING]
+ assert any('query string' in message and '10.0.0.6' in message
+ for message in warnings)
+ assert all(secret not in message for message in warnings)
+
+
+def test_the_header_form_is_not_flagged_as_deprecated(client, db, auth_headers,
+ caplog):
+ secret = _secret(client, auth_headers)['secret']
+ with caplog.at_level(logging.WARNING):
+ client.get(SCRIPT_URL, headers=_token_header(secret))
+
+ row = AuditLog.query.filter_by(entitytype='EndpointBootstrapToken',
+ action='requested').one()
+ assert 'deprecated' not in row.details
+ assert not [record for record in caplog.records
+ if record.levelno >= logging.WARNING]
+
+
+def test_the_admin_download_link_is_not_counted_as_an_un_migrated_bay(
+ client, db, auth_headers, caplog):
+ """The rollout finds un-migrated bays by the deprecation WARNING, and the
+ UI Download .ps1 link has to use the query form. Marked, it logs at info
+ and is audited as the admin path, so an admin testing a token does not
+ invent a bay that is not there."""
+ data = _secret(client, auth_headers)
+ path = data['scripturl'].split(BASE_URL, 1)[1]
+ with caplog.at_level(logging.INFO):
+ assert client.get(path, environ_overrides={'REMOTE_ADDR': '10.0.0.7'}
+ ).status_code == 200
+
+ row = AuditLog.query.filter_by(entitytype='EndpointBootstrapToken',
+ action='requested').one()
+ assert 'admin download link' in row.details
+ assert 'deprecated' not in row.details
+ assert data['secret'] not in row.details
+ assert not [record for record in caplog.records
+ if record.levelno >= logging.WARNING]
+ infos = [record.getMessage() for record in caplog.records
+ if record.levelno == logging.INFO]
+ assert any('Download .ps1' in message and '10.0.0.7' in message
+ for message in infos)
+ assert all(data['secret'] not in message for message in infos)
+
+
+def test_no_log_or_audit_row_ever_carries_the_token_value(
+ client, db, auth_headers, caplog, servercert):
+ secret = _secret(client, auth_headers, maxuses=4)['secret']
+ with caplog.at_level(logging.DEBUG):
+ client.get(SCRIPT_URL, query_string={'t': secret})
+ client.get(SCRIPT_URL, headers=_token_header(secret))
+ client.get(PACKAGE_URL, query_string={'t': secret})
+ client.get(PACKAGE_URL, headers=_token_header(secret))
+
+ assert all(secret not in record.getMessage()
+ for record in caplog.records)
+ rows = AuditLog.query.filter_by(
+ entitytype='EndpointBootstrapToken').all()
+ assert rows
+ for row in rows:
+ assert secret not in (row.details or '')
+ # The 12-char display prefix is the identifier the UI already shows.
+ assert row.entityname == secret[:12]
+
+
+@pytest.mark.parametrize('url', [SCRIPT_URL, PACKAGE_URL])
+def test_the_query_form_can_be_turned_off_once_a_site_has_re_issued(
+ client, db, auth_headers, url):
+ """Off is the hardened end state, not the default: the UI download link
+ uses the query form too, so a site only turns it off deliberately."""
+ secret = _secret(client, auth_headers)['secret']
+ Setting.set(QUERY_SETTING, False, valuetype='boolean')
+
+ refused = client.get(url, query_string={'t': secret})
+ assert refused.status_code == 400
+ assert TOKEN_HEADER in refused.get_json()['data']['error']['message']
+ # The admin Download .ps1 link is a query token too: the setting is about
+ # a live token landing in an access log, and that link puts one there.
+ assert client.get(url, query_string={'t': secret, 'via': 'download'}
+ ).status_code == 400
+ # Refused before any lookup, so it confirms nothing about the token.
+ assert EndpointBootstrapToken.query.one().usecount == 0
+ assert AuditLog.query.filter_by(
+ entitytype='EndpointBootstrapToken', action='requested').count() == 0
+ assert client.get(url, headers=_token_header(secret)).status_code == 200
+
+
+@pytest.mark.parametrize('url', [SCRIPT_URL, PACKAGE_URL])
+def test_no_token_is_still_not_found_when_the_query_form_is_off(
+ client, db, auth_headers, url):
+ """A bare GET presented no token, so it is unusable and answers 404 like
+ every other unusable request. Calling it a query token answered 400 naming
+ the header, which handed an anonymous caller the state of the switch."""
+ _secret(client, auth_headers)
+ Setting.set(QUERY_SETTING, False, valuetype='boolean')
+
+ assert client.get(url).status_code == 404
+ # An empty ?t= presented nothing either.
+ assert client.get(url, query_string={'t': ''}).status_code == 404
+ # And it is the same answer with the query form still on.
+ Setting.set(QUERY_SETTING, True, valuetype='boolean')
+ assert client.get(url).status_code == 404
+
+
def test_script_fetch_does_not_spend_a_use(client, db, auth_headers):
secret = _secret(client, auth_headers, maxuses=1)['secret']
for _ in range(3):
@@ -421,3 +674,4 @@ def test_the_command_falls_back_to_the_request_when_no_site_url_is_set(
base='https://localhost')
assert data['scripturl'].startswith(
'https://localhost/api/endpoints/siteagent/bootstrap.ps1?t=')
+ assert data['scripturl'].endswith('&' + UI_LINK_QUERY)
diff --git a/tests/test_plugins/test_endpoints_settings_declared.py b/tests/test_plugins/test_endpoints_settings_declared.py
new file mode 100644
index 00000000..8bf73036
--- /dev/null
+++ b/tests/test_plugins/test_endpoints_settings_declared.py
@@ -0,0 +1,145 @@
+"""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."""
+ pm = app.extensions.get('plugin_manager')
+ if pm is None:
+ pytest.skip('no plugin manager in this build')
+ assert Setting.query.filter_by(key=QUERY_KEY).first() is None
+
+ pm.sync_all_plugin_settings()
+
+ 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/ 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