From 871e575706fd0f33290d2c63dcd403653c5ce45e Mon Sep 17 00:00:00 2001
From: Benjamin Bertrand <benjamin.bertrand@esss.se>
Date: Tue, 22 May 2018 16:00:41 +0200
Subject: [PATCH] Add tag and device type validation

Tags and device_types are used as key in Ansible roles.
They shouldn't contain any spaces to avoid issues.

JIRA INFRA-334
---
 app/defaults.py                               |  4 +--
 app/models.py                                 | 19 ++++++++++++-
 app/validators.py                             |  2 ++
 ...05c0c835_remove_spaces_from_device_type.py | 28 +++++++++++++++++++
 tests/functional/conftest.py                  |  1 +
 tests/functional/factories.py                 |  9 ++++++
 tests/functional/test_models.py               | 16 +++++++++++
 7 files changed, 76 insertions(+), 3 deletions(-)
 create mode 100644 migrations/versions/f5a605c0c835_remove_spaces_from_device_type.py

diff --git a/app/defaults.py b/app/defaults.py
index afc5efa..737f7d6 100644
--- a/app/defaults.py
+++ b/app/defaults.py
@@ -21,8 +21,8 @@ defaults = [
     models.Action(name='Set as parent'),
     models.Action(name='Update'),
 
-    models.DeviceType(name='Physical Machine'),
-    models.DeviceType(name='Virtual Machine'),
+    models.DeviceType(name='PhysicalMachine'),
+    models.DeviceType(name='VirtualMachine'),
     models.DeviceType(name='Network'),
     models.DeviceType(name='MicroTCA'),
     models.DeviceType(name='VME'),
diff --git a/app/models.py b/app/models.py
index ceb3e77..dc1cf05 100644
--- a/app/models.py
+++ b/app/models.py
@@ -23,7 +23,8 @@ 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, MAC_ADDRESS_RE
+from .validators import (ICS_ID_RE, HOST_NAME_RE, VLAN_NAME_RE, MAC_ADDRESS_RE,
+                         DEVICE_TYPE_RE, TAG_RE)
 from . import utils
 
 
@@ -594,6 +595,14 @@ interfacetags_table = db.Table(
 class Tag(QRCodeMixin, db.Model):
     admin_only = db.Column(db.Boolean, nullable=False, default=False)
 
+    @validates('name')
+    def validate_name(self, key, string):
+        """Ensure the name field matches the required format"""
+        if string is not None:
+            if TAG_RE.fullmatch(string) is None:
+                raise ValidationError(f"'{string}' is an invalid tag name")
+        return string
+
 
 class DeviceType(db.Model):
     __tablename__ = 'device_type'
@@ -602,6 +611,14 @@ class DeviceType(db.Model):
 
     hosts = db.relationship('Host', backref='device_type')
 
+    @validates('name')
+    def validate_name(self, key, string):
+        """Ensure the name field matches the required format"""
+        if string is not None:
+            if DEVICE_TYPE_RE.fullmatch(string) is None:
+                raise ValidationError(f"'{string}' is an invalid device type name")
+        return string
+
     def __str__(self):
         return self.name
 
diff --git a/app/validators.py b/app/validators.py
index c73c3d9..1228f50 100644
--- a/app/validators.py
+++ b/app/validators.py
@@ -18,6 +18,8 @@ ICS_ID_RE = re.compile('[A-Z]{3}[0-9]{3}')
 HOST_NAME_RE = re.compile('^[a-z0-9\-]{2,20}$')
 VLAN_NAME_RE = re.compile('^[A-Za-z0-9\-]{3,25}$')
 MAC_ADDRESS_RE = re.compile('^(?:[0-9a-fA-F]{2}[:-]?){5}[0-9a-fA-F]{2}$')
+DEVICE_TYPE_RE = re.compile('^[A-Za-z0-9]{3,25}$')
+TAG_RE = DEVICE_TYPE_RE
 
 
 class NoValidateSelectField(SelectField):
diff --git a/migrations/versions/f5a605c0c835_remove_spaces_from_device_type.py b/migrations/versions/f5a605c0c835_remove_spaces_from_device_type.py
new file mode 100644
index 0000000..6373968
--- /dev/null
+++ b/migrations/versions/f5a605c0c835_remove_spaces_from_device_type.py
@@ -0,0 +1,28 @@
+"""remove spaces from device_type
+
+Revision ID: f5a605c0c835
+Revises: ea606be23b95
+Create Date: 2018-05-22 13:41:28.137611
+
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'f5a605c0c835'
+down_revision = 'ea606be23b95'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+    device_type = sa.sql.table('device_type', sa.sql.column('id'), sa.sql.column('name'))
+    op.execute(device_type.update().where(device_type.c.name == 'Physical Machine').values(name='PhysicalMachine'))
+    op.execute(device_type.update().where(device_type.c.name == 'Virtual Machine').values(name='VirtualMachine'))
+
+
+def downgrade():
+    device_type = sa.sql.table('device_type', sa.sql.column('id'), sa.sql.column('name'))
+    op.execute(device_type.update().where(device_type.c.name == 'PhysicalMachine').values(name='Physical Machine'))
+    op.execute(device_type.update().where(device_type.c.name == 'VirtualMachine').values(name='Virtual Machine'))
diff --git a/tests/functional/conftest.py b/tests/functional/conftest.py
index 3b69217..c0d6e2c 100644
--- a/tests/functional/conftest.py
+++ b/tests/functional/conftest.py
@@ -32,6 +32,7 @@ register(factories.HostFactory)
 register(factories.MacFactory)
 register(factories.DomainFactory)
 register(factories.CnameFactory)
+register(factories.TagFactory)
 
 
 @pytest.fixture(scope='session')
diff --git a/tests/functional/factories.py b/tests/functional/factories.py
index 787fbdc..50de310 100644
--- a/tests/functional/factories.py
+++ b/tests/functional/factories.py
@@ -189,3 +189,12 @@ class CnameFactory(factory.alchemy.SQLAlchemyModelFactory):
     name = factory.Sequence(lambda n: f'host{n}')
     interface = factory.SubFactory(InterfaceFactory)
     user = factory.SubFactory(UserFactory)
+
+
+class TagFactory(factory.alchemy.SQLAlchemyModelFactory):
+    class Meta:
+        model = models.Tag
+        sqlalchemy_session = common.Session
+        sqlalchemy_session_persistence = 'commit'
+
+    name = factory.Sequence(lambda n: f'Tag{n}')
diff --git a/tests/functional/test_models.py b/tests/functional/test_models.py
index bb84d7c..fd5063d 100644
--- a/tests/functional/test_models.py
+++ b/tests/functional/test_models.py
@@ -121,3 +121,19 @@ def test_manufacturer_favorite_users(user_factory, manufacturer_factory):
     assert user2 in manufacturer2.favorite_users
     assert user2 not in manufacturer1.favorite_users
     assert user3 in manufacturer1.favorite_users
+
+
+def test_device_type_validation(device_type_factory):
+    device_type = device_type_factory(name='PhysicalMachine')
+    assert device_type.name == 'PhysicalMachine'
+    with pytest.raises(ValidationError) as excinfo:
+        device_type = device_type_factory(name='Physical Machine')
+    assert "'Physical Machine' is an invalid device type name" in str(excinfo.value)
+
+
+def test_tag_validation(tag_factory):
+    tag = tag_factory(name='IOC')
+    assert tag.name == 'IOC'
+    with pytest.raises(ValidationError) as excinfo:
+        tag = tag_factory(name='My tag')
+    assert "'My tag' is an invalid tag name" in str(excinfo.value)
-- 
GitLab