diff --git a/frontend/src/components/PaginationBar.vue b/frontend/src/components/PaginationBar.vue
index 6bced812..0f892c64 100644
--- a/frontend/src/components/PaginationBar.vue
+++ b/frontend/src/components/PaginationBar.vue
@@ -1,83 +1,96 @@
-
-
-
-
-
+
+
+
+
+
diff --git a/plugins/backups/api/routes.py b/plugins/backups/api/routes.py
index a45e6e87..06cec5fb 100644
--- a/plugins/backups/api/routes.py
+++ b/plugins/backups/api/routes.py
@@ -309,6 +309,23 @@ def diff_revision(backuprevisionid):
againstid = request.args.get('against', type=int)
if againstid:
other = db.session.get(BackupRevision, againstid)
+ # It has to belong to the SAME asset. Any revision id was accepted, so
+ # ?against= rendered that asset's
+ # configuration values as the "before" side of this asset's history -
+ # a diff that reads as a change on this machine and describes a
+ # different one. The default branch below has always been asset-scoped;
+ # only the explicit one was not.
+ if other is not None and other.assetid != revision.assetid:
+ return error_response(
+ ErrorCodes.VALIDATION_ERROR,
+ 'Revision {} belongs to a different asset and cannot be '
+ 'compared against this one'.format(againstid))
+ if other is not None and other.backupkind != revision.backupkind:
+ return error_response(
+ ErrorCodes.VALIDATION_ERROR,
+ 'Revision {} is a {} backup and this one is {} - there is '
+ 'nothing to compare'.format(againstid, other.backupkind,
+ revision.backupkind))
else:
# The previous revision FROM THE SAME PC. Several PCs can share a
# machine number, and diffing across them reported one device's COM port
diff --git a/plugins/computers/frontend/views/PCForm.vue b/plugins/computers/frontend/views/PCForm.vue
index 13b64e20..8b291d7b 100644
--- a/plugins/computers/frontend/views/PCForm.vue
+++ b/plugins/computers/frontend/views/PCForm.vue
@@ -661,8 +661,14 @@ async function savePC() {
// had never sent.
levelid: form.value.levelid
}
- // only set display name when an alias is given, so we don't clobber it
- if (form.value.alias) {
+ // An emptied alias has to be SENT, or it cannot be cleared: the payload
+ // simply omitted `name`, the old value survived, and the form came back
+ // showing the alias the operator had just deleted, with no error and no
+ // toast. On CREATE the field is still omitted when blank, because there
+ // the server may fill it in and sending null would overwrite that.
+ if (isEdit.value) {
+ payload.name = form.value.alias || null
+ } else if (form.value.alias) {
payload.name = form.value.alias
}
diff --git a/plugins/guides/api/routes.py b/plugins/guides/api/routes.py
index 04a64737..f0c11262 100644
--- a/plugins/guides/api/routes.py
+++ b/plugins/guides/api/routes.py
@@ -503,8 +503,19 @@ def restore_revision(guideid, revisionid):
'You do not have permission to edit this guide',
http_code=403)
+ before = guide.revision
authoring.restore(guide, revision, userid=_userid())
db.session.commit()
+
+ # save() dedups on the content hash, so restoring the version that is
+ # already current writes nothing. Saying "Restored version N as a new draft
+ # version" in that case describes a revision that does not exist - and the
+ # revision list, unchanged, quietly disagrees with the message.
+ if guide.revision == before:
+ return success_response(
+ guide.to_dict(includeblocks=True),
+ message='Version {} is already the current content, so nothing '
+ 'changed'.format(revision.revision))
return success_response(guide.to_dict(includeblocks=True),
message='Restored version {} as a new draft version'
.format(revision.revision))
diff --git a/plugins/network/api/routes.py b/plugins/network/api/routes.py
index 93e9c8a0..6cc4b3a3 100644
--- a/plugins/network/api/routes.py
+++ b/plugins/network/api/routes.py
@@ -3,6 +3,8 @@
from flask import Blueprint, request
from flask_jwt_extended import jwt_required
+import ipaddress
+
from shopdb.api import db, Asset, AssetType, Vendor, Communication, CommunicationType, AuditLog, success_response, error_response, paginated_response, ErrorCodes, get_pagination_params, paginate_query
from ..services.assetnumbers import generate_for_type
@@ -697,11 +699,27 @@ def create_vlan():
if not data.get('name'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
+ # 802.1Q says 1-4094, and nothing checked. 99999 and -5 were stored, and a
+ # non-numeric value was worse: the duplicate probe below compared a string
+ # against an integer column, which MySQL coerces to 0, so 'xyz' matched an
+ # existing VLAN 0 and the error said "VLAN xyz already exists" about a row
+ # that had nothing to do with it.
+ try:
+ vlannumber = int(data['vlannumber'])
+ except (TypeError, ValueError):
+ return error_response(ErrorCodes.VALIDATION_ERROR,
+ 'vlannumber must be a whole number')
+ if not 1 <= vlannumber <= 4094:
+ return error_response(
+ ErrorCodes.VALIDATION_ERROR,
+ 'vlannumber must be between 1 and 4094 (802.1Q)')
+ data['vlannumber'] = vlannumber
+
# Check for duplicate VLAN number
- if VLAN.query.filter_by(vlannumber=data['vlannumber']).first():
+ if VLAN.query.filter_by(vlannumber=vlannumber).first():
return error_response(
ErrorCodes.CONFLICT,
- f"VLAN {data['vlannumber']} already exists",
+ f"VLAN {vlannumber} already exists",
http_code=409
)
@@ -784,10 +802,14 @@ def delete_vlan(vlan_id: int):
# Check if VLAN has associated subnets
if vlan.subnets.filter_by(isactive=True).count() > 0:
+ # 409 like every other in-use guard in the product (network types,
+ # machine types, vendors). It answered 400, which tells the caller
+ # their request was malformed when the request was fine and the state
+ # was the problem.
return error_response(
- ErrorCodes.VALIDATION_ERROR,
+ ErrorCodes.CONFLICT,
'Cannot delete VLAN with associated subnets',
- http_code=400
+ http_code=409
)
vlan.isactive = False
@@ -936,10 +958,30 @@ def create_subnet():
if not data.get('name'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'name is required')
- # Validate CIDR format (basic check)
- cidr = data['cidr']
+ # Parsed, not eyeballed for a slash. 'notanip/xx' passed the old check and
+ # was stored; _devices_in_subnet then caught the ValueError from
+ # ip_network and returned an empty device list, so the subnet looked real
+ # and permanently empty. ipaddress is the same library that has to read it
+ # later, so agreeing with it is the whole test.
+ cidr = (data['cidr'] or '').strip()
+ # An explicit prefix is required. ipaddress reads a bare '10.1.1.0' as a
+ # /32, which is legal and almost never what somebody typing a SUBNET meant
+ # - it would silently create a subnet holding one address.
if '/' not in cidr:
- return error_response(ErrorCodes.VALIDATION_ERROR, 'cidr must be in CIDR notation (e.g., 10.1.1.0/24)')
+ return error_response(
+ ErrorCodes.VALIDATION_ERROR,
+ 'cidr needs a prefix length, e.g. 10.1.1.0/24')
+ try:
+ network = ipaddress.ip_network(cidr, strict=False)
+ except ValueError as bad:
+ return error_response(
+ ErrorCodes.VALIDATION_ERROR,
+ 'cidr must be a network in CIDR notation, e.g. 10.1.1.0/24 (%s)'
+ % bad)
+ # Store the normalised form so 10.1.1.5/24 and 10.1.1.0/24 are one subnet
+ # rather than two rows nobody can tell apart.
+ cidr = str(network)
+ data['cidr'] = cidr
# Check for duplicate CIDR
if Subnet.query.filter_by(cidr=cidr).first():
diff --git a/shopdb/core/api/applications.py b/shopdb/core/api/applications.py
index 023a12dd..a93e35f2 100644
--- a/shopdb/core/api/applications.py
+++ b/shopdb/core/api/applications.py
@@ -2,6 +2,7 @@
import glob
import hashlib
+import re
import os
from flask import Blueprint, request, current_app
@@ -20,6 +21,7 @@ from shopdb.utils.responses import (
)
from shopdb.utils.pagination import get_pagination_params, paginate_query
from shopdb.utils.import_mode import apply_import_timestamps
+from shopdb.utils.datevalues import DateValueError, parse_date
# Uploads live in the instance dir, like model images and part photos, so a
@@ -326,6 +328,24 @@ def create_version(app_id: int):
if not data or not data.get('version'):
return error_response(ErrorCodes.VALIDATION_ERROR, 'version is required')
+ # A digest is a claim the delivery path CHECKS against, so a wrong one is
+ # worse than none: an over-long value was silently truncated to the column
+ # width and came back as 64 plausible hex characters, indistinguishable
+ # from a real SHA-256. Either it is a SHA-256 or it is refused.
+ digest = (data.get('sha256') or '').strip().lower()
+ if digest and not re.fullmatch(r'[0-9a-f]{64}', digest):
+ return error_response(
+ ErrorCodes.VALIDATION_ERROR,
+ 'sha256 must be 64 hexadecimal characters')
+
+ # Refused rather than stored raw: a malformed date used to reach MySQL,
+ # which (before STRICT_TRANS_TABLES) wrote 0000-00-00 and then broke every
+ # read of this application.
+ try:
+ releasedate = parse_date(data.get('releasedate'), 'releasedate')
+ except DateValueError as bad:
+ return error_response(ErrorCodes.VALIDATION_ERROR, str(bad))
+
if AppVersion.query.filter_by(appid=app_id, version=data['version']).first():
return error_response(
ErrorCodes.CONFLICT,
@@ -336,9 +356,9 @@ def create_version(app_id: int):
version = AppVersion(
appid=app_id,
version=data['version'],
- releasedate=data.get('releasedate'),
+ releasedate=releasedate,
notes=data.get('notes'),
- sha256=data.get('sha256'),
+ sha256=digest or None,
silentargs=data.get('silentargs')
)
diff --git a/shopdb/core/api/maplevels.py b/shopdb/core/api/maplevels.py
index a67e03d6..7f1d6968 100644
--- a/shopdb/core/api/maplevels.py
+++ b/shopdb/core/api/maplevels.py
@@ -177,9 +177,17 @@ def create_level():
data = request.get_json() or {}
name = (data.get('levelname') or '').strip()
buildingid = data.get('buildingid')
+ # Names the fields this route actually reads. It said 'buildingname',
+ # which this handler never looks at, so somebody who omitted the level name
+ # was told to supply something else entirely.
if not name or not buildingid:
- return error_response(ErrorCodes.VALIDATION_ERROR,
- 'buildingname and buildingid are required')
+ missing = [field for field, value in
+ (('levelname', name), ('buildingid', buildingid))
+ if not value]
+ return error_response(
+ ErrorCodes.VALIDATION_ERROR,
+ '%s %s required' % (' and '.join(missing),
+ 'is' if len(missing) == 1 else 'are'))
if not db.session.get(Building, buildingid):
return error_response(ErrorCodes.NOT_FOUND, 'No such building',
http_code=404)
diff --git a/shopdb/core/api/settings.py b/shopdb/core/api/settings.py
index 063e50ed..ef774a86 100644
--- a/shopdb/core/api/settings.py
+++ b/shopdb/core/api/settings.py
@@ -811,6 +811,34 @@ def update_setting(key: str):
if _is_secret(key) and value == SECRET_MASK:
return success_response(_serialize_setting(setting), message='Setting unchanged')
+ # The declared valuetype is a promise about what every reader will get, and
+ # nothing checked it: "A6-not-a-number" was stored verbatim on an integer
+ # key 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.
+ valuetype = (setting.valuetype or 'string').lower()
+ if value is not None and not isinstance(value, bool):
+ text = str(value).strip()
+ if valuetype == 'integer' and text:
+ try:
+ int(text)
+ except ValueError:
+ return error_response(
+ ErrorCodes.VALIDATION_ERROR,
+ '%s is a whole-number setting (got %r)' % (key, value))
+ elif valuetype == 'float' and text:
+ try:
+ float(text)
+ except ValueError:
+ return error_response(
+ ErrorCodes.VALIDATION_ERROR,
+ '%s is a number setting (got %r)' % (key, value))
+ elif valuetype == 'boolean' and text:
+ if text.lower() not in ('true', 'false', '1', '0', 'yes', 'no'):
+ return error_response(
+ ErrorCodes.VALIDATION_ERROR,
+ '%s is a true/false setting (got %r)' % (key, value))
+
# Convert value to string for storage
if isinstance(value, bool):
setting.value = 'true' if value else 'false'
diff --git a/tests/test_core/test_appversion_delivery.py b/tests/test_core/test_appversion_delivery.py
index f012a4de..cebd1c7b 100644
--- a/tests/test_core/test_appversion_delivery.py
+++ b/tests/test_core/test_appversion_delivery.py
@@ -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'
diff --git a/tests/test_core/test_audit_retention_key.py b/tests/test_core/test_audit_retention_key.py
index fe8d09e2..b0a1655b 100644
--- a/tests/test_core/test_audit_retention_key.py
+++ b/tests/test_core/test_audit_retention_key.py
@@ -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/ 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())
diff --git a/tests/test_plugins/test_backups.py b/tests/test_plugins/test_backups.py
index 0d53c21a..588dffac 100644
--- a/tests/test_plugins/test_backups.py
+++ b/tests/test_plugins/test_backups.py
@@ -1568,3 +1568,99 @@ def test_each_reporting_pc_keeps_its_own_current_revision(bk_app, bk_plugin):
assert set(current) == {'GGBX0NH3ESF', 'NEWPC001'}, \
'each reporting PC has a current revision of its own'
assert _summary(revisions[0], islatest=True)['islatest'] is True
+
+
+# --- a diff has to be about ONE asset ---------------------------------------
+#
+# ?against= accepted any revision id. Pointing it at another asset's
+# revision rendered that asset's configuration values as the "before" side of
+# this asset's history: a diff that reads as a change on this machine while
+# describing a different one. The default (no ?against) has always been
+# asset-scoped; only the explicit form was not.
+
+def _bk_admin_headers(bk_app):
+ """An admin in the BACKUPS app's own database.
+
+ The shared `client` fixture talks to the session app, which is a different
+ database entirely - a revision created through bk_plugin is invisible to
+ it, which is why a route test has to be driven from this app.
+ """
+ from werkzeug.security import generate_password_hash
+
+ from shopdb.core.models import Permission, Role, User
+
+ with bk_app.app_context():
+ Permission.seed()
+ user = User.query.filter_by(username='bkadmin').first()
+ if user is None:
+ admin = Role.query.filter_by(rolename='admin').first()
+ if admin is None:
+ admin = Role(rolename='admin', description='Admin')
+ _db.session.add(admin)
+ _db.session.flush()
+ admin.permissions = Permission.query.all()
+ user = User(username='bkadmin', email='bkadmin@test.local',
+ passwordhash=generate_password_hash('bkpass123'),
+ isactive=True)
+ user.roles = [admin]
+ _db.session.add(user)
+ _db.session.commit()
+
+ client = bk_app.test_client()
+ login = client.post('/api/auth/login',
+ json={'username': 'bkadmin', 'password': 'bkpass123'})
+ assert login.status_code == 200, login.get_json()
+ token = login.get_json()['data']['access_token']
+ return client, {'Authorization': 'Bearer %s' % token}
+
+
+def test_a_diff_against_another_assets_revision_is_refused(bk_app, bk_plugin):
+ from shopdb.core.models import Asset, AssetType
+ from plugins.backups.models import BackupRevision
+
+ with bk_app.app_context():
+ bk_plugin.apply_collector_payload(_payload())
+ mine = (_db.session.query(BackupRevision)
+ .order_by(BackupRevision.backuprevisionid.desc()).first())
+
+ # A revision belonging to a different asset entirely.
+ assettype = AssetType.query.first() or AssetType(assettype='machine')
+ _db.session.add(assettype)
+ _db.session.flush()
+ other_asset = Asset(assetnumber='OTHER-ASSET-1',
+ assettypeid=assettype.assettypeid)
+ _db.session.add(other_asset)
+ _db.session.flush()
+ theirs = BackupRevision(assetid=other_asset.assetid,
+ backupkind=mine.backupkind,
+ storagebackend='shopdb',
+ contenthash='a' * 64)
+ theirs.payload = {'schema': ntlars.SCHEMA, 'keys': []}
+ _db.session.add(theirs)
+ _db.session.commit()
+ mineid, theirsid = mine.backuprevisionid, theirs.backuprevisionid
+
+ client, headers = _bk_admin_headers(bk_app)
+ refused = client.get('/api/backups/revisions/%d/diff?against=%d'
+ % (mineid, theirsid), headers=headers)
+ assert refused.status_code == 400, refused.get_json()
+ assert 'different asset' in refused.get_json()['data']['error']['message']
+
+
+def test_a_diff_against_a_sibling_revision_still_works(bk_app, bk_plugin):
+ """Scoping it must not break the case the feature exists for."""
+ from plugins.backups.models import BackupRevision
+
+ with bk_app.app_context():
+ bk_plugin.apply_collector_payload(_payload())
+ changed = CONFIGUREDREG.replace('"Cnc"=""', '"Cnc"="OKUMA"')
+ bk_plugin.apply_collector_payload(_payload(reg=changed))
+ _db.session.commit()
+ rows = (_db.session.query(BackupRevision)
+ .order_by(BackupRevision.backuprevisionid.desc()).limit(2).all())
+ newer, older = rows[0].backuprevisionid, rows[1].backuprevisionid
+
+ client, headers = _bk_admin_headers(bk_app)
+ allowed = client.get('/api/backups/revisions/%d/diff?against=%d'
+ % (newer, older), headers=headers)
+ assert allowed.status_code == 200, allowed.get_json()
diff --git a/tests/test_plugins/test_guides.py b/tests/test_plugins/test_guides.py
index f4eda24a..68873d13 100644
--- a/tests/test_plugins/test_guides.py
+++ b/tests/test_plugins/test_guides.py
@@ -576,3 +576,24 @@ def test_a_section_counts_as_something_to_tick():
]
# heading + step + two checklist items
assert checklistitemcount(blocks) == 4
+
+
+def test_restoring_the_current_version_says_nothing_changed(gd_app, gd_clean):
+ """save() dedups on the content hash, so restoring the version that is
+ already current writes nothing. Reporting "Restored version N as a new
+ draft version" described a revision that did not exist, and the unchanged
+ revision list quietly disagreed with the message."""
+ from plugins.guides.models import GuideRevision
+ from plugins.guides.services import authoring
+ with gd_app.app_context():
+ guide = _newguide()
+ _, first = authoring.save(guide, title='T',
+ blocks=[_block('b1', 'paragraph', text='only')])
+ _db.session.commit()
+ before = guide.revision
+
+ authoring.restore(guide, first)
+ _db.session.commit()
+
+ assert guide.revision == before, 'nothing new was written'
+ assert GuideRevision.query.filter_by(guideid=guide.guideid).count() == 1
diff --git a/tests/test_plugins/test_network_validation.py b/tests/test_plugins/test_network_validation.py
new file mode 100644
index 00000000..1ca6ad99
--- /dev/null
+++ b/tests/test_plugins/test_network_validation.py
@@ -0,0 +1,80 @@
+"""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'