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
81 lines
3.6 KiB
Python
81 lines
3.6 KiB
Python
"""Network reference data has to survive being typed at.
|
|
|
|
Three findings from the 2026-09-03 run, all in the same family: a validation
|
|
that looked like one and was not.
|
|
|
|
* cidr was checked for a '/' and nothing else, so 'notanip/xx' was stored.
|
|
_devices_in_subnet then swallowed the ValueError from ip_network and
|
|
returned an empty list, so the subnet looked real and permanently empty.
|
|
* vlannumber had no range check, so 99999 and -5 were accepted - and a
|
|
non-numeric value was worse, because the duplicate probe compared a string
|
|
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.
|
|
* deleting a VLAN that subnets still reference answered 400, while every
|
|
sibling in-use guard in the product answers 409.
|
|
"""
|
|
import pytest
|
|
|
|
|
|
def _vlan(client, headers, number, name='Test VLAN'):
|
|
return client.post('/api/network/vlans', headers=headers,
|
|
json={'vlannumber': number, 'name': name})
|
|
|
|
|
|
@pytest.mark.parametrize('cidr', [
|
|
'notanip/xx',
|
|
'10.1.1.0/33',
|
|
'hello/24',
|
|
'10.1.1.0',
|
|
'/24',
|
|
])
|
|
def test_a_subnet_refuses_something_that_is_not_a_network(client, db,
|
|
auth_headers, cidr):
|
|
response = client.post('/api/network/subnets', headers=auth_headers,
|
|
json={'cidr': cidr, 'name': 'Bad subnet'})
|
|
assert response.status_code == 400, (cidr, response.get_json())
|
|
|
|
|
|
def test_a_real_subnet_is_accepted_and_normalised(client, db, auth_headers):
|
|
"""A host address with a prefix is the same network; storing both spellings
|
|
would make two rows nobody can tell apart."""
|
|
response = client.post('/api/network/subnets', headers=auth_headers,
|
|
json={'cidr': '10.1.1.5/24', 'name': 'Real subnet'})
|
|
assert response.status_code in (200, 201), response.get_json()
|
|
assert response.get_json()['data']['cidr'] == '10.1.1.0/24'
|
|
|
|
|
|
@pytest.mark.parametrize('number', [0, 4095, 99999, -5, 'xyz'])
|
|
def test_a_vlan_number_outside_the_standard_is_refused(client, db,
|
|
auth_headers, number):
|
|
response = _vlan(client, auth_headers, number)
|
|
assert response.status_code == 400, (number, response.get_json())
|
|
|
|
|
|
@pytest.mark.parametrize('number', [1, 100, 4094])
|
|
def test_a_valid_vlan_number_is_accepted(client, db, auth_headers, number):
|
|
response = _vlan(client, auth_headers, number, name='VLAN %s' % number)
|
|
assert response.status_code in (200, 201), response.get_json()
|
|
|
|
|
|
def test_a_duplicate_vlan_names_the_number_it_actually_clashed_with(
|
|
client, db, auth_headers):
|
|
assert _vlan(client, auth_headers, 42, name='First').status_code in (200, 201)
|
|
clash = _vlan(client, auth_headers, 42, name='Second')
|
|
assert clash.status_code == 409
|
|
assert '42' in clash.get_json()['data']['error']['message']
|
|
|
|
|
|
def test_deleting_a_vlan_in_use_is_a_conflict_like_every_other_guard(
|
|
client, db, auth_headers):
|
|
created = _vlan(client, auth_headers, 77, name='In use')
|
|
vlanid = created.get_json()['data']['vlanid']
|
|
subnet = client.post('/api/network/subnets', headers=auth_headers,
|
|
json={'cidr': '10.7.7.0/24', 'name': 'Attached',
|
|
'vlanid': vlanid})
|
|
assert subnet.status_code in (200, 201), subnet.get_json()
|
|
|
|
refused = client.delete('/api/network/vlans/%d' % vlanid,
|
|
headers=auth_headers)
|
|
assert refused.status_code == 409, refused.get_json()
|
|
assert refused.get_json()['data']['error']['code'] == 'CONFLICT'
|