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'