diff --git a/app/api/network.py b/app/api/network.py index a07fbd871555cfcf7e75d59f3c53fc1026e7268a..ef2c28c888ea5405bda4910d958ea0dec85260af 100644 --- a/app/api/network.py +++ b/app/api/network.py @@ -66,3 +66,18 @@ def get_interfaces(): def create_interface(): """Create a new interface""" return create_generic_model(models.Interface, mandatory_fields=('network', 'ip', 'name')) + + +@bp.route('/macs') +@jwt_required +def get_macs(): + return get_generic_model(models.Mac, request.args, + order_by=models.Mac.address) + + +@bp.route('/macs', methods=['POST']) +@jwt_required +@jwt_groups_accepted('admin', 'create') +def create_macs(): + return create_generic_model(models.Mac, + mandatory_fields=('address',)) diff --git a/app/models.py b/app/models.py index 2570421c0f0e054868264bbfb1ce93b322bd7ce1..6c333c9ab757453e6ccbfe86ef5dcb9579688d33 100644 --- a/app/models.py +++ b/app/models.py @@ -24,7 +24,7 @@ from flask_login import UserMixin from wtforms import ValidationError from .extensions import db, login_manager, ldap_manager, cache from .plugins import FlaskUserPlugin -from .validators import ICS_ID_RE, HOST_NAME_RE, VLAN_NAME_RE +from .validators import ICS_ID_RE, HOST_NAME_RE, VLAN_NAME_RE, MAC_ADDRESS_RE from . import utils @@ -638,6 +638,15 @@ class Mac(db.Model): def __str__(self): return str(self.address) + @validates('address') + def validate_address(self, key, string): + """Ensure the address is a valid MAC address""" + if string is None: + return None + if MAC_ADDRESS_RE.fullmatch(string) is None: + raise ValidationError(f"'{string}' does not appear to be a MAC address") + return string + def to_dict(self): return { 'id': self.id, diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py index f976c8bc91a054d2c484683722d3d081cee3986c..492442fb8ca6904bf8ffdc06339131675d5d521f 100644 --- a/tests/functional/conftest.py +++ b/tests/functional/conftest.py @@ -28,6 +28,7 @@ register(factories.NetworkScopeFactory) register(factories.NetworkFactory) register(factories.InterfaceFactory) register(factories.HostFactory) +register(factories.MacFactory) @pytest.fixture(scope='session') diff --git a/tests/functional/factories.py b/tests/functional/factories.py index f71df8a0aede7ddfed2318cd7f722659157b0acb..09ad58be608c2fd37bdac2060d565519bb7453af 100644 --- a/tests/functional/factories.py +++ b/tests/functional/factories.py @@ -147,3 +147,12 @@ class HostFactory(factory.alchemy.SQLAlchemyModelFactory): name = factory.Sequence(lambda n: f'host{n}') user = factory.SubFactory(UserFactory) + + +class MacFactory(factory.alchemy.SQLAlchemyModelFactory): + class Meta: + model = models.Mac + sqlalchemy_session = common.Session + sqlalchemy_session_persistence = 'commit' + + address = factory.Faker('mac_address') diff --git a/tests/functional/test_api.py b/tests/functional/test_api.py index ee9d0f2706d5df12978486603201db6414629a6d..81a16f8fe3d72e1d55f898a9a59755620dc16f4a 100644 --- a/tests/functional/test_api.py +++ b/tests/functional/test_api.py @@ -25,11 +25,12 @@ ENDPOINT_MODEL = { 'inventory/items': models.Item, 'network/networks': models.Network, 'network/interfaces': models.Interface, + 'network/macs': models.Mac, } GENERIC_GET_ENDPOINTS = [key for key in ENDPOINT_MODEL.keys() - if key not in ('inventory/items', 'network/networks', 'network/interfaces')] + if key not in ('inventory/items', 'network/macs', 'network/networks', 'network/interfaces')] GENERIC_CREATE_ENDPOINTS = [key for key in ENDPOINT_MODEL.keys() - if key not in ('inventory/items', 'inventory/actions', 'network/networks', 'network/interfaces')] + if key not in ('inventory/items', 'inventory/actions', 'network/macs', 'network/networks', 'network/interfaces')] CREATE_AUTH_ENDPOINTS = [key for key in ENDPOINT_MODEL.keys() if key != 'inventory/actions'] @@ -650,3 +651,47 @@ def test_create_interface_ip_not_in_network(client, network_factory, user_token) 'name': 'hostname'} response = post(client, f'{API_URL}/network/interfaces', data=data, token=user_token) check_response_message(response, 'IP address 192.168.2.4 is not in network 192.168.1.0/24', 422) + + +def test_get_macs(client, mac_factory, readonly_token): + # Create some macs + mac1 = mac_factory() + mac2 = mac_factory() + + response = get(client, f'{API_URL}/network/macs', token=readonly_token) + assert response.status_code == 200 + assert len(response.json) == 2 + check_input_is_subset_of_response(response, (mac1.to_dict(), mac2.to_dict())) + + +def test_create_mac(client, item_factory, user_token): + item = item_factory() + # check that address is mandatory + response = post(client, f'{API_URL}/network/macs', data={}, token=user_token) + check_response_message(response, "Missing mandatory field 'address'", 422) + + data = {'address': 'b5:4b:7d:a4:23:43'} + response = post(client, f'{API_URL}/network/macs', data=data, token=user_token) + assert response.status_code == 201 + assert {'id', 'address', 'item', 'interfaces'} == set(response.json.keys()) + assert response.json['address'] == data['address'] + + # Check that address shall be unique + response = post(client, f'{API_URL}/network/macs', data=data, token=user_token) + check_response_message(response, '(psycopg2.IntegrityError) duplicate key value violates unique constraint', 422) + + # Check that all parameters can be passed + data2 = {'address': 'b5:4b:7d:a4:23:44', + 'item_id': item.id} + response = post(client, f'{API_URL}/network/macs', data=data2, token=user_token) + assert response.status_code == 201 + + # check that all items were created + assert models.Mac.query.count() == 2 + + +@pytest.mark.parametrize('address', ('', 'foo', 'b5:4b:7d:a4:23')) +def test_create_mac_invalid_address(address, client, user_token): + data = {'address': address} + response = post(client, f'{API_URL}/network/macs', data=data, token=user_token) + check_response_message(response, f"'{address}' does not appear to be a MAC address", 422) diff --git a/tests/functional/test_models.py b/tests/functional/test_models.py index 83a2fc8e63b8549c4b4dad8d50be91cff74d2535..4ec1ea5744dadf51c87420aee507237ce3663407 100644 --- a/tests/functional/test_models.py +++ b/tests/functional/test_models.py @@ -10,6 +10,8 @@ This module defines models tests. """ import ipaddress +import pytest +from wtforms import ValidationError def test_network_ip_properties(network_factory): @@ -62,3 +64,15 @@ def test_network_available_and_used_ips(network_factory, interface_factory): interface_factory(network=network2, ip='172.16.20.12') assert network2.used_ips() == [ipaddress.ip_address(f'172.16.20.{i}') for i in range(11, 15)] assert list(network2.available_ips()) == [] + + +def test_mac_address_validation(mac_factory): + mac = mac_factory(address='F4:A7:39:15:DA:01') + assert mac.address == 'f4:a7:39:15:da:01' + mac = mac_factory(address='F4-A7-39-15-DA-02') + assert mac.address == 'f4:a7:39:15:da:02' + mac = mac_factory(address='F4A73915DA06') + assert mac.address == 'f4:a7:39:15:da:06' + with pytest.raises(ValidationError) as excinfo: + mac = mac_factory(address='F4A73915DA') + assert "'F4A73915DA' does not appear to be a MAC address" in str(excinfo.value)