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

@@ -47,7 +47,20 @@ const props = defineProps({
defineEmits(['update:page', 'update:perPage'])
const perPageOptions = [10, 20, 50, 100]
// A page size the caller actually uses must be selectable, or the control renders
// blank: NetworkDevicesList defaults to 25, which was in no option, so
// :value="25" matched nothing and the box showed empty - the operator could not
// see the current page size at all. Any size the component is handed is folded
// in rather than silently dropped.
const BASE_PER_PAGE_OPTIONS = [10, 20, 50, 100]
const perPageOptions = computed(() => {
const current = Number(props.perPage)
if (!current || BASE_PER_PAGE_OPTIONS.includes(current)) {
return BASE_PER_PAGE_OPTIONS
}
return [...BASE_PER_PAGE_OPTIONS, current].sort((a, b) => a - b)
})
const visiblePages = computed(() => {
const total = props.totalPages

View File

@@ -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=<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 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

View File

@@ -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
}

View File

@@ -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))

View File

@@ -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():

View File

@@ -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')
)

View File

@@ -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)

View File

@@ -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'

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())

View File

@@ -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=<id> 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()

View File

@@ -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

View File

@@ -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'