Validate what a form accepts, and keep a diff to one asset

The validation tail from the 2026-09-03 run. Each is small; together they are
the difference between a field that checks its input and one that looks like it
does.

Subnets accepted any string containing a slash, so 'notanip/xx' was stored -
and _devices_in_subnet then swallowed the ValueError from ip_network and
returned an empty device list, so the subnet looked real and permanently empty.
It is parsed with the same library that has to read it later, a prefix length is
required (ipaddress reads a bare 10.1.1.0 as a /32, which is legal and never
what somebody typing a subnet meant), and the stored form is normalised so
10.1.1.5/24 and 10.1.1.0/24 cannot become two rows nobody can tell apart.

VLAN numbers had no range check: 99999 and -5 were stored. Worse, a non-numeric
value reached a duplicate probe that compares against an integer column, which
MySQL coerces to 0 - so 'xyz' matched an existing VLAN 0 and the 409 named a row
that had nothing to do with it. 1-4094 per 802.1Q, checked before the probe.
Deleting a VLAN that subnets still reference now answers 409 like every sibling
in-use guard, rather than 400, which told the caller their request was malformed
when the request was fine and the state was the problem.

An appversion sha256 was stored raw, so an over-long value was truncated to the
column width and came back as 64 plausible hex characters - indistinguishable
from a real digest, and wrong. A digest is a claim the delivery path checks
against, so it is 64 hex characters or it is refused. The releasedate goes
through the strict parser for the same reason it did everywhere else.

PUT /api/settings/<key> ignored the declared valuetype. "A6-not-a-number" went
into an integer key verbatim and every consumer then read 0, because
get_typed_value swallows the ValueError - the setting looked set and the
behaviour was the default.

/api/maplevels told a caller who omitted the level name that 'buildingname and
buildingid are required'; buildingname is a field that handler never reads. It
now names the fields it actually wants.

Restoring a guide revision that is already current writes nothing, because
save() dedups on the content hash - and the response still said "Restored
version N as a new draft version", describing a revision that does not exist
while the unchanged revision list disagreed.

GET /api/backups/revisions/<id>/diff?against=<id> accepted any revision id, so
pointing it at another asset's revision rendered that asset's configuration as
the "before" side of this asset's history: a diff that reads as a change on this
machine and describes a different one. Same asset and same kind, or refused.
The default branch was always asset-scoped; only the explicit one was not.

Two frontend fixes: the per-page selector rendered blank on any list defaulting
to a size not in [10,20,50,100] (network devices uses 25), so the operator could
not see the current page size; and an emptied PC alias was dropped from the
payload instead of sent, so it could not be cleared - the form came back showing
the alias that had just been deleted.

Twenty-two tests. The backups guard verified by planting.

Scope: product
This commit is contained in:
cproudlock
2026-09-03 16:13:59 -04:00
parent 1a8f426e93
commit 8fee09e748
13 changed files with 531 additions and 96 deletions

View File

@@ -40,3 +40,53 @@ def test_version_delivery_metadata_defaults_to_null(client, db, auth_headers):
data = resp.get_json()['data']
assert data['sha256'] is None
assert data['silentargs'] is None
# --- a digest is a claim something checks against ---------------------------
#
# sha256 and releasedate were stored raw. An over-long digest was silently
# truncated to the column width and came back as 64 plausible hex characters -
# indistinguishable from a real SHA-256, and wrong. A malformed releasedate
# reached MySQL, which wrote 0000-00-00 and then broke every read of that
# application, with no API way to remove the row.
def _app(client, headers, name='Digest Test App'):
created = client.post('/api/applications', headers=headers,
json={'appname': name})
assert created.status_code in (200, 201), created.get_json()
data = created.get_json()['data']
return data.get('appid') or data.get('applicationid')
def test_a_digest_that_is_not_a_sha256_is_refused(client, db, auth_headers):
appid = _app(client, auth_headers, 'Digest refuse app')
for bad in ('c' * 100, 'nothex' * 10, 'abc', 'C' * 63):
response = client.post('/api/applications/%d/versions' % appid,
headers=auth_headers,
json={'version': 'v-%s' % bad[:4],
'sha256': bad})
assert response.status_code == 400, (bad, response.get_json())
def test_a_real_digest_is_accepted_and_lowercased(client, db, auth_headers):
appid = _app(client, auth_headers, 'Digest accept app')
digest = 'A' * 64
response = client.post('/api/applications/%d/versions' % appid,
headers=auth_headers,
json={'version': 'v1', 'sha256': digest})
assert response.status_code in (200, 201), response.get_json()
assert response.get_json()['data']['sha256'] == 'a' * 64
def test_a_malformed_release_date_is_refused_and_nothing_is_committed(
client, db, auth_headers):
from shopdb.core.models import AppVersion
appid = _app(client, auth_headers, 'Date refuse app')
response = client.post('/api/applications/%d/versions' % appid,
headers=auth_headers,
json={'version': 'poison-1',
'releasedate': 'tomorrow'})
assert response.status_code == 400, response.get_json()
assert AppVersion.query.filter_by(version='poison-1').first() is None, \
'a refused version must not be committed'

View File

@@ -54,3 +54,46 @@ def test_the_retention_setting_actually_drives_the_report(client, db,
assert report.status_code == 200, report.get_json()
assert report.get_json()['data']['retentiondays'] == 30, \
'the setting the page writes must be the one the service reads'
# --- a declared valuetype is a promise to every reader -----------------------
#
# PUT /api/settings/<key> stored whatever arrived. "A6-not-a-number" went into
# an integer key verbatim, and every consumer then read 0, because
# Setting.get_typed_value swallows the ValueError. The setting looked set, the
# behaviour was the default, and nothing said so.
def test_an_integer_setting_refuses_something_that_is_not_a_number(
client, db, auth_headers):
refused = client.put('/api/settings/audit_retentiondays',
json={'value': 'not-a-number'}, headers=auth_headers)
assert refused.status_code == 400, refused.get_json()
assert 'whole-number' in refused.get_json()['data']['error']['message']
# And the old value survives, rather than being replaced by a 0 nobody
# asked for.
reading = client.get('/api/settings/audit_retentiondays',
headers=auth_headers)
assert reading.status_code == 200
assert str(reading.get_json()['data']['value']) != 'not-a-number'
def test_an_integer_setting_still_accepts_a_number(client, db, auth_headers):
accepted = client.put('/api/settings/audit_retentiondays',
json={'value': 45}, headers=auth_headers)
assert accepted.status_code == 200, accepted.get_json()
def test_a_boolean_setting_refuses_a_word_that_is_not_true_or_false(
client, db, auth_headers):
refused = client.put('/api/settings/securesend_allow_guests',
json={'value': 'maybe'}, headers=auth_headers)
assert refused.status_code == 400, refused.get_json()
def test_a_boolean_setting_accepts_the_usual_spellings(client, db,
auth_headers):
for value in (True, False, 'true', 'false', '1', '0'):
response = client.put('/api/settings/securesend_allow_guests',
json={'value': value}, headers=auth_headers)
assert response.status_code == 200, (value, response.get_json())