From 5c99e07548104c70b257501d5f4e4f66385672ca Mon Sep 17 00:00:00 2001
From: Benjamin Bertrand <benjamin.bertrand@esss.se>
Date: Thu, 19 Dec 2019 15:11:11 +0100
Subject: [PATCH] Prevent network overlapping

JIRA INFRA-1627 #action In Progress
---
 app/models.py                   |  30 +++++-
 app/network/views.py            |  32 ++++---
 app/utils.py                    |   8 ++
 tests/functional/factories.py   |   7 +-
 tests/functional/test_api.py    | 165 ++++++++++++++++++++++----------
 tests/functional/test_models.py |  94 ++++++++++++++++--
 tests/functional/test_web.py    |  44 +++++++--
 7 files changed, 292 insertions(+), 88 deletions(-)

diff --git a/app/models.py b/app/models.py
index fb3468f..f2b78d9 100644
--- a/app/models.py
+++ b/app/models.py
@@ -856,7 +856,12 @@ class Network(CreatedMixin, db.Model):
             # If domain_id is not passed, we set it to the network scope value
             if "domain_id" not in kwargs:
                 kwargs["domain_id"] = kwargs["scope"].domain_id
-        super().__init__(**kwargs)
+        # WARNING! Setting self.scope will call validate_networks in the NetworkScope class
+        # For the validation to work, self.address must be set before!
+        # Ensure that address and vlan_name are passed before scope
+        vlan_name = kwargs.pop("vlan_name")
+        address = kwargs.pop("address")
+        super().__init__(vlan_name=vlan_name, address=address, **kwargs)
 
     def __str__(self):
         return str(self.vlan_name)
@@ -1610,6 +1615,22 @@ class NetworkScope(CreatedMixin, db.Model):
     def __str__(self):
         return str(self.name)
 
+    @validates("networks")
+    def validate_networks(self, key, network):
+        """Ensure the network is included in the supernet and doesn't overlap
+        existing networks"""
+        if not network.network_ip.subnet_of(self.supernet_ip):
+            raise ValidationError(
+                f"{network.network_ip} is not a subnet of {self.supernet_ip}"
+            )
+        existing_networks = Network.query.filter_by(scope=self).all()
+        for existing_network in existing_networks:
+            if network.network_ip.overlaps(existing_network.network_ip):
+                raise ValidationError(
+                    f"{network.network_ip} overlaps {existing_network} ({existing_network.network_ip})"
+                )
+        return network
+
     @property
     def supernet_ip(self):
         return ipaddress.ip_network(self.supernet)
@@ -1648,11 +1669,14 @@ class NetworkScope(CreatedMixin, db.Model):
         return sorted(network.network_ip for network in self.networks)
 
     def available_subnets(self, prefix):
-        """Return the list of available subnets with the given prefix"""
+        """Return the list of available subnets with the given prefix
+
+        Overlapping subnets with existing networks are filtered"""
+        used = self.used_subnets()
         return [
             str(subnet)
             for subnet in self.supernet_ip.subnets(new_prefix=prefix)
-            if subnet not in self.used_subnets()
+            if not utils.overlaps(subnet, used)
         ]
 
     def to_dict(self, recursive=False):
diff --git a/app/network/views.py b/app/network/views.py
index 672bdbf..f16ecf7 100644
--- a/app/network/views.py
+++ b/app/network/views.py
@@ -651,18 +651,24 @@ def create_network():
         form = NetworkForm(request.form, scope_id=scope_id)
     if form.validate_on_submit():
         scope_id = form.scope_id.data
-        network = models.Network(
-            scope=models.NetworkScope.query.get(scope_id),
-            vlan_name=form.vlan_name.data,
-            vlan_id=form.vlan_id.data,
-            description=form.description.data or None,
-            address=form.address.data,
-            first_ip=form.first_ip.data,
-            last_ip=form.last_ip.data,
-            gateway=form.gateway.data,
-            domain=models.Domain.query.get(form.domain_id.data),
-            admin_only=form.admin_only.data,
-        )
+        try:
+            network = models.Network(
+                scope=models.NetworkScope.query.get(scope_id),
+                vlan_name=form.vlan_name.data,
+                vlan_id=form.vlan_id.data,
+                description=form.description.data or None,
+                address=form.address.data,
+                first_ip=form.first_ip.data,
+                last_ip=form.last_ip.data,
+                gateway=form.gateway.data,
+                domain=models.Domain.query.get(form.domain_id.data),
+                admin_only=form.admin_only.data,
+            )
+        except ValidationError as e:
+            # Check for error raised by model validation (not implemented in form vaildation)
+            current_app.logger.warning(f"{e}")
+            flash(f"{e}", "error")
+            return render_template("network/create_network.html", form=form)
         current_app.logger.debug(f"Trying to create: {network!r}")
         db.session.add(network)
         try:
@@ -676,6 +682,8 @@ def create_network():
         # Save scope_id to the session to retrieve it after the redirect
         session["scope_id"] = scope_id
         return redirect(url_for("network.create_network"))
+    else:
+        current_app.logger.info(form.errors)
     return render_template("network/create_network.html", form=form)
 
 
diff --git a/app/utils.py b/app/utils.py
index cf52ea1..4ab053f 100644
--- a/app/utils.py
+++ b/app/utils.py
@@ -555,3 +555,11 @@ def validate_ip(ip, network):
             raise ValidationError(
                 f"IP address {ip} is not in range {network.first} - {network.last}"
             )
+
+
+def overlaps(subnet, subnets):
+    """Return True if the subnet overlaps with any of the subnets"""
+    for network in subnets:
+        if subnet.overlaps(network):
+            return True
+    return False
diff --git a/tests/functional/factories.py b/tests/functional/factories.py
index be0c0e1..60bbb8c 100644
--- a/tests/functional/factories.py
+++ b/tests/functional/factories.py
@@ -114,7 +114,7 @@ class NetworkScopeFactory(factory.alchemy.SQLAlchemyModelFactory):
     name = factory.Sequence(lambda n: f"scope{n}")
     first_vlan = factory.Sequence(lambda n: 1600 + 10 * n)
     last_vlan = factory.Sequence(lambda n: 1609 + 10 * n)
-    supernet = factory.Faker("ipv4", network=True)
+    supernet = factory.Sequence(lambda n: str(ipaddress.ip_network(f"172.{n}.0.0/16")))
     user = factory.SubFactory(UserFactory)
     domain = factory.SubFactory(DomainFactory)
 
@@ -127,7 +127,6 @@ class NetworkFactory(factory.alchemy.SQLAlchemyModelFactory):
 
     vlan_name = factory.Sequence(lambda n: f"vlan{n}")
     vlan_id = factory.Sequence(lambda n: 1600 + n)
-    address = factory.Sequence(lambda n: f"192.168.{n}.0/24")
     scope = factory.SubFactory(NetworkScopeFactory)
     user = factory.SubFactory(UserFactory)
     domain = factory.SubFactory(DomainFactory)
@@ -150,6 +149,10 @@ class NetworkFactory(factory.alchemy.SQLAlchemyModelFactory):
         hosts = list(net.hosts())
         return str(hosts[-1])
 
+    @factory.lazy_attribute
+    def address(self):
+        return self.scope.available_subnets(24)[0]
+
 
 class DeviceTypeFactory(factory.alchemy.SQLAlchemyModelFactory):
     class Meta:
diff --git a/tests/functional/test_api.py b/tests/functional/test_api.py
index 316c1dc..a33542f 100644
--- a/tests/functional/test_api.py
+++ b/tests/functional/test_api.py
@@ -615,16 +615,26 @@ def test_get_items(client, location_factory, item_factory, readonly_token):
     check_response_message(response, "Invalid query arguments", 422)
 
 
-def test_get_networks(client, network_factory, readonly_token):
+def test_get_networks(client, network_scope_factory, network_factory, readonly_token):
     # Create some networks
+    scope = network_scope_factory(supernet="172.16.0.0/16")
     network1 = network_factory(
-        address="172.16.1.0/24", first_ip="172.16.1.1", last_ip="172.16.1.254"
+        address="172.16.1.0/24",
+        first_ip="172.16.1.1",
+        last_ip="172.16.1.254",
+        scope=scope,
     )
     network2 = network_factory(
-        address="172.16.20.0/22", first_ip="172.16.20.11", last_ip="172.16.20.250"
+        address="172.16.20.0/22",
+        first_ip="172.16.20.11",
+        last_ip="172.16.20.250",
+        scope=scope,
     )
     network3 = network_factory(
-        address="172.16.5.0/24", first_ip="172.16.5.10", last_ip="172.16.5.254"
+        address="172.16.5.0/24",
+        first_ip="172.16.5.10",
+        last_ip="172.16.5.254",
+        scope=scope,
     )
 
     response = get(client, f"{API_URL}/network/networks", token=readonly_token)
@@ -751,21 +761,13 @@ def test_create_network(client, admin_token, network_scope_factory):
     assert response.get_json()["broadcast"] == "172.16.1.255"
 
     # Check that address and name shall be unique
-    response = post(client, f"{API_URL}/network/networks", data=data, token=admin_token)
-    check_response_message(
-        response,
-        "(psycopg2.IntegrityError) duplicate key value violates unique constraint",
-        422,
-    )
     data_same_address = data.copy()
     data_same_address["vlan_name"] = "networkX"
     response = post(
         client, f"{API_URL}/network/networks", data=data_same_address, token=admin_token
     )
     check_response_message(
-        response,
-        "(psycopg2.IntegrityError) duplicate key value violates unique constraint",
-        422,
+        response, "172.16.1.0/24 overlaps network1 (172.16.1.0/24)", 422,
     )
     data_same_name = {
         "vlan_name": "network1",
@@ -913,13 +915,22 @@ def test_create_network_invalid_range(client, session, admin_token, network_scop
     )
 
 
-def test_get_interfaces(client, network_factory, interface_factory, readonly_token):
+def test_get_interfaces(
+    client, network_scope_factory, network_factory, interface_factory, readonly_token
+):
     # Create some interfaces
+    scope = network_scope_factory(supernet="192.168.0.0/16")
     network1 = network_factory(
-        address="192.168.1.0/24", first_ip="192.168.1.10", last_ip="192.168.1.250"
+        address="192.168.1.0/24",
+        first_ip="192.168.1.10",
+        last_ip="192.168.1.250",
+        scope=scope,
     )
     network2 = network_factory(
-        address="192.168.2.0/24", first_ip="192.168.2.10", last_ip="192.168.2.250"
+        address="192.168.2.0/24",
+        first_ip="192.168.2.10",
+        last_ip="192.168.2.250",
+        scope=scope,
     )
     interface1 = interface_factory(network=network1, ip="192.168.1.10")
     interface2 = interface_factory(
@@ -946,8 +957,14 @@ def test_get_interfaces(client, network_factory, interface_factory, readonly_tok
 
 
 def test_get_interfaces_by_domain(
-    client, domain_factory, network_factory, interface_factory, readonly_token
+    client,
+    domain_factory,
+    network_scope_factory,
+    network_factory,
+    interface_factory,
+    readonly_token,
 ):
+    scope = network_scope_factory(supernet="192.168.0.0/16")
     # Create some interfaces
     domain1 = domain_factory(name="tn.esss.lu.se")
     domain2 = domain_factory(name="ics.esss.lu.se")
@@ -956,12 +973,14 @@ def test_get_interfaces_by_domain(
         first_ip="192.168.1.10",
         last_ip="192.168.1.250",
         domain=domain1,
+        scope=scope,
     )
     network2 = network_factory(
         address="192.168.2.0/24",
         first_ip="192.168.2.10",
         last_ip="192.168.2.250",
         domain=domain2,
+        scope=scope,
     )
     interface1 = interface_factory(network=network1, ip="192.168.1.10")
     interface2 = interface_factory(network=network1, ip="192.168.1.11")
@@ -990,20 +1009,23 @@ def test_get_interfaces_by_domain(
 
 
 def test_get_interfaces_by_network(
-    client, network_factory, interface_factory, readonly_token
+    client, network_scope_factory, network_factory, interface_factory, readonly_token
 ):
+    scope = network_scope_factory(supernet="192.168.0.0/16")
     # Create some interfaces
     network1 = network_factory(
         vlan_name="MyNetwork1",
         address="192.168.1.0/24",
         first_ip="192.168.1.10",
         last_ip="192.168.1.250",
+        scope=scope,
     )
     network2 = network_factory(
         vlan_name="MyNetwork2",
         address="192.168.2.0/24",
         first_ip="192.168.2.10",
         last_ip="192.168.2.250",
+        scope=scope,
     )
     interface1 = interface_factory(network=network1, ip="192.168.1.10")
     interface2 = interface_factory(network=network1, ip="192.168.1.11")
@@ -1038,9 +1060,15 @@ def test_get_interfaces_with_model(
     assert response.get_json()[0]["model"] == "EX3400"
 
 
-def test_create_interface_fails(client, host, network_factory, no_login_check_token):
+def test_create_interface_fails(
+    client, host, network_scope_factory, network_factory, no_login_check_token
+):
+    scope = network_scope_factory(supernet="192.168.0.0/16")
     network = network_factory(
-        address="192.168.1.0/24", first_ip="192.168.1.10", last_ip="192.168.1.250"
+        address="192.168.1.0/24",
+        first_ip="192.168.1.10",
+        last_ip="192.168.1.250",
+        scope=scope,
     )
     # check that network_id and ip are mandatory
     response = post(
@@ -1077,9 +1105,15 @@ def test_create_interface_fails(client, host, network_factory, no_login_check_to
     )
 
 
-def test_create_interface(client, host, network_factory, no_login_check_token):
+def test_create_interface(
+    client, host, network_scope_factory, network_factory, no_login_check_token
+):
+    scope = network_scope_factory(supernet="192.168.0.0/16")
     network = network_factory(
-        address="192.168.1.0/24", first_ip="192.168.1.10", last_ip="192.168.1.250"
+        address="192.168.1.0/24",
+        first_ip="192.168.1.10",
+        last_ip="192.168.1.250",
+        scope=scope,
     )
     data = {
         "network": network.vlan_name,
@@ -1129,10 +1163,14 @@ def test_create_interface(client, host, network_factory, no_login_check_token):
 
 @pytest.mark.parametrize("ip", ("", "foo", "192.168"))
 def test_create_interface_invalid_ip(
-    ip, client, host, network_factory, no_login_check_token
+    ip, client, host, network_scope_factory, network_factory, no_login_check_token
 ):
+    scope = network_scope_factory(supernet="192.168.0.0/16")
     network = network_factory(
-        address="192.168.1.0/24", first_ip="192.168.1.10", last_ip="192.168.1.250"
+        address="192.168.1.0/24",
+        first_ip="192.168.1.10",
+        last_ip="192.168.1.250",
+        scope=scope,
     )
     # invalid IP address
     data = {
@@ -1150,10 +1188,14 @@ def test_create_interface_invalid_ip(
 
 
 def test_create_interface_ip_not_in_network(
-    client, host, network_factory, no_login_check_token
+    client, host, network_scope_factory, network_factory, no_login_check_token
 ):
+    scope = network_scope_factory(supernet="192.168.0.0/16")
     network = network_factory(
-        address="192.168.1.0/24", first_ip="192.168.1.10", last_ip="192.168.1.250"
+        address="192.168.1.0/24",
+        first_ip="192.168.1.10",
+        last_ip="192.168.1.250",
+        scope=scope,
     )
     # IP address not in range
     data = {
@@ -1171,10 +1213,14 @@ def test_create_interface_ip_not_in_network(
 
 
 def test_create_interface_ip_not_in_range(
-    client, host, network_factory, no_login_check_token
+    client, host, network_scope_factory, network_factory, no_login_check_token
 ):
+    scope = network_scope_factory(supernet="192.168.0.0/16")
     network = network_factory(
-        address="192.168.1.0/24", first_ip="192.168.1.10", last_ip="192.168.1.250"
+        address="192.168.1.0/24",
+        first_ip="192.168.1.10",
+        last_ip="192.168.1.250",
+        scope=scope,
     )
     # IP address not in range
     data = {
@@ -1194,10 +1240,14 @@ def test_create_interface_ip_not_in_range(
 
 
 def test_create_interface_ip_not_in_range_as_admin(
-    client, host, network_factory, admin_token
+    client, host, network_scope_factory, network_factory, admin_token
 ):
+    scope = network_scope_factory(supernet="192.168.0.0/16")
     network = network_factory(
-        address="192.168.1.0/24", first_ip="192.168.1.10", last_ip="192.168.1.250"
+        address="192.168.1.0/24",
+        first_ip="192.168.1.10",
+        last_ip="192.168.1.250",
+        scope=scope,
     )
     # IP address not in range
     data = {
@@ -1215,7 +1265,7 @@ def test_create_interface_ip_not_in_range_as_admin(
 def test_normal_user_can_create_interface_on_empty_host(
     client, host, network_scope_factory, network_factory, user_prod_token
 ):
-    scope = network_scope_factory(name="ProdNetworks")
+    scope = network_scope_factory(name="ProdNetworks", supernet="192.168.0.0/16")
     network = network_factory(
         address="192.168.1.0/24",
         first_ip="192.168.1.10",
@@ -2010,7 +2060,7 @@ def test_patch_host_network_permission(
     interface_factory,
     user_token,
 ):
-    scope = network_scope_factory(name="FooNetworks")
+    scope = network_scope_factory(name="FooNetworks", supernet="192.168.0.0/16")
     network = network_factory(
         address="192.168.1.0/24",
         first_ip="192.168.1.10",
@@ -2034,7 +2084,7 @@ def test_patch_host_invalid_network_permission(
     interface_factory,
     user_token,
 ):
-    scope = network_scope_factory(name="ProdNetworks")
+    scope = network_scope_factory(name="ProdNetworks", supernet="192.168.0.0/16")
     network = network_factory(
         address="192.168.1.0/24",
         first_ip="192.168.1.10",
@@ -2092,9 +2142,15 @@ def test_patch_interface_mac(client, interface_factory, admin_token):
     assert updated_interface.mac == data["mac"]
 
 
-def test_patch_interface_ip(client, interface_factory, network_factory, admin_token):
+def test_patch_interface_ip(
+    client, interface_factory, network_scope_factory, network_factory, admin_token
+):
+    scope = network_scope_factory(supernet="192.168.0.0/16")
     network = network_factory(
-        address="192.168.1.0/24", first_ip="192.168.1.10", last_ip="192.168.1.250"
+        address="192.168.1.0/24",
+        first_ip="192.168.1.10",
+        last_ip="192.168.1.250",
+        scope=scope,
     )
     interface = interface_factory(network=network, ip="192.168.1.11")
     data = {"ip": "192.168.2.12"}
@@ -2147,19 +2203,22 @@ def test_patch_interface_name(client, host_factory, interface_factory, admin_tok
 
 
 def test_patch_interface_network(
-    client, network_factory, interface_factory, admin_token
+    client, network_scope_factory, network_factory, interface_factory, admin_token
 ):
+    scope = network_scope_factory(supernet="192.168.0.0/16")
     network1 = network_factory(
         vlan_name="mynetwork",
         address="192.168.1.0/24",
         first_ip="192.168.1.10",
         last_ip="192.168.1.250",
+        scope=scope,
     )
     network2 = network_factory(
         vlan_name="new-network",
         address="192.168.2.0/24",
         first_ip="192.168.2.10",
         last_ip="192.168.2.250",
+        scope=scope,
     )
     interface = interface_factory(network=network1, ip="192.168.1.20")
     data = {"network": "unknown"}
@@ -2207,8 +2266,8 @@ def test_patch_interface_network(
 def test_patch_interface_current_network_permission(
     client, network_scope_factory, network_factory, interface_factory, user_token
 ):
-    scope_prod = network_scope_factory(name="ProdNetworks")
-    scope_foo = network_scope_factory(name="FooNetworks")
+    scope_prod = network_scope_factory(name="ProdNetworks", supernet="192.168.0.0/22")
+    scope_foo = network_scope_factory(name="FooNetworks", supernet="192.168.4.0/22")
     network_prod = network_factory(
         vlan_name="prod-network",
         address="192.168.1.0/24",
@@ -2218,9 +2277,9 @@ def test_patch_interface_current_network_permission(
     )
     network_foo = network_factory(
         vlan_name="foo-network",
-        address="192.168.2.0/24",
-        first_ip="192.168.2.10",
-        last_ip="192.168.2.250",
+        address="192.168.4.0/24",
+        first_ip="192.168.4.10",
+        last_ip="192.168.4.250",
         scope=scope_foo,
     )
     # User can't update an interface part of the ProdNetworks
@@ -2234,8 +2293,8 @@ def test_patch_interface_current_network_permission(
     )
     check_response_message(response, "User doesn't have the required group", 403)
     # but can on the FooNetworks
-    interface_foo = interface_factory(network=network_foo, ip="192.168.2.20")
-    data = {"ip": "192.168.2.21"}
+    interface_foo = interface_factory(network=network_foo, ip="192.168.4.20")
+    data = {"ip": "192.168.4.21"}
     response = patch(
         client,
         f"{API_URL}/network/interfaces/{interface_foo.id}",
@@ -2248,8 +2307,8 @@ def test_patch_interface_current_network_permission(
 def test_patch_interface_new_network_permission(
     client, network_scope_factory, network_factory, interface_factory, user_token
 ):
-    scope_prod = network_scope_factory(name="ProdNetworks")
-    scope_foo = network_scope_factory(name="FooNetworks")
+    scope_prod = network_scope_factory(name="ProdNetworks", supernet="192.168.0.0/22")
+    scope_foo = network_scope_factory(name="FooNetworks", supernet="192.168.4.0/22")
     network_prod = network_factory(
         vlan_name="prod-network",
         address="192.168.1.0/24",
@@ -2259,19 +2318,19 @@ def test_patch_interface_new_network_permission(
     )
     network_foo1 = network_factory(
         vlan_name="foo-network1",
-        address="192.168.2.0/24",
-        first_ip="192.168.2.10",
-        last_ip="192.168.2.250",
+        address="192.168.4.0/24",
+        first_ip="192.168.4.10",
+        last_ip="192.168.4.250",
         scope=scope_foo,
     )
     network_foo2 = network_factory(
         vlan_name="foo-network2",
-        address="192.168.3.0/24",
-        first_ip="192.168.3.10",
-        last_ip="192.168.3.250",
+        address="192.168.5.0/24",
+        first_ip="192.168.5.10",
+        last_ip="192.168.5.250",
         scope=scope_foo,
     )
-    interface_foo = interface_factory(network=network_foo1, ip="192.168.2.20")
+    interface_foo = interface_factory(network=network_foo1, ip="192.168.4.20")
     # User can't change the network to the ProdNetworks
     data = {"network": network_prod.vlan_name}
     response = patch(
@@ -2282,7 +2341,7 @@ def test_patch_interface_new_network_permission(
     )
     # but can on the same scope it has access to
     check_response_message(response, "User doesn't have the required group", 403)
-    data = {"network": network_foo2.vlan_name, "ip": "192.168.3.10"}
+    data = {"network": network_foo2.vlan_name, "ip": "192.168.5.10"}
     response = patch(
         client,
         f"{API_URL}/network/interfaces/{interface_foo.id}",
diff --git a/tests/functional/test_models.py b/tests/functional/test_models.py
index 6471a22..fe5daff 100644
--- a/tests/functional/test_models.py
+++ b/tests/functional/test_models.py
@@ -235,13 +235,20 @@ def test_user_can_set_boot_profile(
     assert not user.can_set_boot_profile(non_physical_ioc)
 
 
-def test_network_ip_properties(network_factory):
+def test_network_ip_properties(network_scope_factory, network_factory):
+    scope = network_scope_factory(supernet="172.16.0.0/16")
     # Create some networks
     network1 = network_factory(
-        address="172.16.1.0/24", first_ip="172.16.1.10", last_ip="172.16.1.250"
+        address="172.16.1.0/24",
+        first_ip="172.16.1.10",
+        last_ip="172.16.1.250",
+        scope=scope,
     )
     network2 = network_factory(
-        address="172.16.20.0/26", first_ip="172.16.20.11", last_ip="172.16.20.14"
+        address="172.16.20.0/26",
+        first_ip="172.16.20.11",
+        last_ip="172.16.20.14",
+        scope=scope,
     )
 
     assert network1.network_ip == ipaddress.ip_network("172.16.1.0/24")
@@ -265,13 +272,22 @@ def test_network_ip_properties(network_factory):
     assert network2.used_ips() == []
 
 
-def test_network_available_and_used_ips(network_factory, interface_factory):
+def test_network_available_and_used_ips(
+    network_factory, interface_factory, network_scope_factory
+):
+    scope = network_scope_factory(supernet="172.16.0.0/16")
     # Create some networks and interfaces
     network1 = network_factory(
-        address="172.16.1.0/24", first_ip="172.16.1.10", last_ip="172.16.1.250"
+        address="172.16.1.0/24",
+        first_ip="172.16.1.10",
+        last_ip="172.16.1.250",
+        scope=scope,
     )
     network2 = network_factory(
-        address="172.16.20.0/26", first_ip="172.16.20.11", last_ip="172.16.20.14"
+        address="172.16.20.0/26",
+        first_ip="172.16.20.11",
+        last_ip="172.16.20.14",
+        scope=scope,
     )
     for i in range(10, 20):
         interface_factory(network=network1, ip=f"172.16.1.{i}")
@@ -309,10 +325,16 @@ def test_network_available_and_used_ips(network_factory, interface_factory):
     assert list(network2.available_ips()) == []
 
 
-def test_network_gateway(network_factory):
-    network = network_factory(address="192.168.0.0/24", gateway="192.168.0.1")
+def test_network_gateway(network_scope_factory, network_factory):
+    scope1 = network_scope_factory(supernet="192.168.0.0/16")
+    scope2 = network_scope_factory(supernet="172.16.0.0/16")
+    network = network_factory(
+        address="192.168.0.0/24", gateway="192.168.0.1", scope=scope1
+    )
     assert str(network.gateway) == "192.168.0.1"
-    network = network_factory(address="172.16.110.0/23", gateway="172.16.111.254")
+    network = network_factory(
+        address="172.16.110.0/23", gateway="172.16.111.254", scope=scope2
+    )
     assert str(network.gateway) == "172.16.111.254"
 
 
@@ -843,3 +865,57 @@ def test_item_invalid_ics_id(db, item_factory, ics_id):
     with pytest.raises(ValidationError) as excinfo:
         item_factory(ics_id=ics_id)
     assert r"ICS id shall match [A-Z]{3}[0-9]{3}" in str(excinfo.value)
+
+
+@pytest.mark.parametrize(
+    "address", ("172.30.0.0/25", "172.30.1.0/24", "172.30.0.0/22", "172.30.0.192/26")
+)
+def test_network_overlapping(address, network_scope_factory, network_factory):
+    scope = network_scope_factory(
+        first_vlan=3800, last_vlan=4000, supernet="172.30.0.0/16"
+    )
+    network1 = network_factory(
+        vlan_id=3800,
+        address="172.30.0.0/23",
+        first_ip="172.30.0.3",
+        last_ip="172.30.1.240",
+        scope=scope,
+    )
+    with pytest.raises(ValidationError) as excinfo:
+        network_factory(vlan_id=3801, address=address, scope=scope)
+    assert f"{address} overlaps {network1} ({network1.network_ip})" in str(
+        excinfo.value
+    )
+
+
+@pytest.mark.parametrize("address", ("172.30.2.0/25", "172.30.0.0/16"))
+def test_network_not_subnet_of_scope(address, network_scope_factory, network_factory):
+    scope = network_scope_factory(
+        first_vlan=3800, last_vlan=4000, supernet="172.30.0.0/23"
+    )
+    with pytest.raises(ValidationError) as excinfo:
+        network_factory(vlan_id=3800, address=address, scope=scope)
+    assert f"{address} is not a subnet of 172.30.0.0/23" in str(excinfo.value)
+
+
+def test_scope_available_subnets(network_scope_factory, network_factory):
+    address = "172.30.0.0/16"
+    scope_ip = ipaddress.ip_network(address)
+    scope = network_scope_factory(first_vlan=3800, last_vlan=4000, supernet=address)
+    full_24 = [str(subnet) for subnet in scope_ip.subnets(new_prefix=24)]
+    assert scope.available_subnets(24) == full_24
+    network1 = network_factory(vlan_id=3800, address="172.30.60.0/24", scope=scope)
+    expected1 = [subnet for subnet in full_24 if subnet != network1.address]
+    assert scope.available_subnets(24) == expected1
+    network_factory(vlan_id=3801, address="172.30.244.0/22", scope=scope)
+    network2_24 = [
+        "172.30.244.0/24",
+        "172.30.245.0/24",
+        "172.30.246.0/24",
+        "172.30.247.0/24",
+    ]
+    expected2 = [subnet for subnet in expected1 if subnet not in network2_24]
+    assert scope.available_subnets(24) == expected2
+    network_factory(vlan_id=3802, address="172.30.238.64/26", scope=scope)
+    expected3 = [subnet for subnet in expected2 if subnet != "172.30.238.0/24"]
+    assert scope.available_subnets(24) == expected3
diff --git a/tests/functional/test_web.py b/tests/functional/test_web.py
index 1999560..5966191 100644
--- a/tests/functional/test_web.py
+++ b/tests/functional/test_web.py
@@ -300,7 +300,7 @@ def test_edit_item_comment_in_index(
 
 
 def test_create_host(client, network_scope_factory, network_factory, device_type):
-    scope = network_scope_factory(name="ProdNetworks")
+    scope = network_scope_factory(name="ProdNetworks", supernet="192.168.0.0/16")
     network = network_factory(
         address="192.168.1.0/24",
         first_ip="192.168.1.10",
@@ -349,7 +349,7 @@ def test_create_host(client, network_scope_factory, network_factory, device_type
 def test_create_host_invalid_fields(
     session, client, network_scope_factory, network_factory, device_type
 ):
-    scope = network_scope_factory(name="ProdNetworks")
+    scope = network_scope_factory(name="ProdNetworks", supernet="192.168.0.0/16")
     network = network_factory(
         address="192.168.1.0/24",
         first_ip="192.168.1.10",
@@ -396,7 +396,7 @@ def test_create_interface(
     client, host_factory, network_scope_factory, network_factory, interface_factory
 ):
     host = host_factory(name="myhost")
-    scope = network_scope_factory(name="ProdNetworks")
+    scope = network_scope_factory(name="ProdNetworks", supernet="192.168.0.0/16")
     network1 = network_factory(scope=scope)
     interface_factory(network=network1, host=host)
     network2 = network_factory(
@@ -439,10 +439,10 @@ def test_create_interface(
 
 
 def test_add_interface_to_empty_host(
-    client, host_factory, network_scope_factory, network_factory,
+    client, host_factory, network_scope_factory, network_factory
 ):
     host = host_factory(name="myhost")
-    scope = network_scope_factory(name="ProdNetworks")
+    scope = network_scope_factory(name="ProdNetworks", supernet="192.168.0.0/16")
     network = network_factory(
         address="192.168.2.0/24",
         first_ip="192.168.2.10",
@@ -699,10 +699,7 @@ def test_create_network_no_vlan(
 
 def test_create_item_invalid_ics_id(logged_rw_client):
     ics_id = "AAA1100"
-    form = {
-        "ics_id": ics_id,
-        "serial_number": "12345",
-    }
+    form = {"ics_id": ics_id, "serial_number": "12345"}
     response = logged_rw_client.post(
         f"/inventory/items/create", data=form, follow_redirects=True
     )
@@ -762,3 +759,32 @@ def test_ansible_groups_no_recursive_dependency(
     )
     assert response.status_code == 200
     assert b"creates a recursive dependency loop" in response.data
+
+
+def test_create_network_overlapping(
+    network_scope_factory, network_factory, logged_admin_client
+):
+    scope = network_scope_factory(
+        first_vlan=3800, last_vlan=4000, supernet="172.30.0.0/16"
+    )
+    network_factory(
+        vlan_name="network1",
+        vlan_id=3800,
+        address="172.30.0.0/23",
+        first_ip="172.30.0.3",
+        last_ip="172.30.1.240",
+        scope=scope,
+    )
+    form = {
+        "vlan_name": "network2",
+        "vlan_id": 3842,
+        "scope_id": scope.id,
+        "address": "172.30.1.0/24",
+        "first_ip": "172.30.1.5",
+        "last_ip": "172.30.1.245",
+        "gateway": "172.30.1.248",
+        "domain_id": scope.domain_id,
+    }
+    response = logged_admin_client.post("/network/networks/create", data=form)
+    assert response.status_code == 200
+    assert b"172.30.1.0/24 overlaps network1 (172.30.0.0/23)" in response.data
-- 
GitLab