362 lines
12 KiB
Python
362 lines
12 KiB
Python
# dabmodel (c) by chacha
|
|
#
|
|
# dabmodel is licensed under a
|
|
# Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International Unported License.
|
|
#
|
|
# You should have received a copy of the license along with this
|
|
# work. If not, see <https://creativecommons.org/licenses/by-nc-sa/4.0/>.
|
|
|
|
import unittest
|
|
from os import chdir
|
|
from pathlib import Path
|
|
|
|
|
|
print(__name__)
|
|
print(__package__)
|
|
|
|
from src import dabmodel as dm
|
|
from typing import Annotated, Any, Optional
|
|
from uuid import uuid4
|
|
import json
|
|
from uuid import UUID
|
|
from datetime import datetime
|
|
|
|
testdir_path = Path(__file__).parent.resolve()
|
|
chdir(testdir_path.parent.resolve())
|
|
|
|
"""
|
|
class UUIDEncoder(json.JSONEncoder):
|
|
def default(self, obj):
|
|
if isinstance(obj, UUID):
|
|
# if the obj is uuid, we simply return the value of uuid
|
|
return obj.hex
|
|
elif isinstance(obj, datetime):
|
|
return str(obj)
|
|
return json.JSONEncoder.default(self, obj)
|
|
"""
|
|
from typing import Optional, Union
|
|
|
|
from pydantic import ValidationError, StrictInt, StrictStr, Field
|
|
|
|
|
|
# ---------- Base models to reuse in many tests ----------
|
|
|
|
|
|
class Router(dm.BaseAppliance):
|
|
class Features:
|
|
class Network(dm.BaseFeature):
|
|
mtu: StrictInt = 1500
|
|
|
|
class Defaults:
|
|
template_id = "00000000-0000-4000-8000-000000000001"
|
|
template_short_name = "net"
|
|
|
|
class Defaults:
|
|
template_id = "11111111-1111-4111-8111-111111111111"
|
|
template_short_name = "router"
|
|
|
|
class Network:
|
|
enabled = True
|
|
mtu = 9000
|
|
|
|
|
|
class RouterPlus(Router):
|
|
model_name: StrictStr = "plus"
|
|
|
|
class Features:
|
|
class Firewall(dm.BaseFeature):
|
|
enabled_by_default: bool = True
|
|
|
|
class Defaults:
|
|
template_id = "22222222-2222-4222-8222-222222222222"
|
|
template_short_name = "fw"
|
|
|
|
class Defaults:
|
|
class Firewall:
|
|
enabled = True
|
|
enabled_by_default = True
|
|
|
|
|
|
class RouterLite(RouterPlus):
|
|
class Defaults:
|
|
template_short_name = "router-lite"
|
|
model_name = "lite"
|
|
|
|
class Network:
|
|
enabled = False
|
|
|
|
class Firewall:
|
|
enabled = True
|
|
enabled_by_default = False
|
|
|
|
|
|
# ---------- happy path: defaults & merges ----------
|
|
|
|
|
|
class TestDefaultsAndMerges(unittest.TestCase):
|
|
def test_root_defaults_and_feature(self):
|
|
a = Router(dabinst_short_name="r1")
|
|
self.assertEqual(a.template_short_name, "router")
|
|
self.assertIsNotNone(a.Network)
|
|
self.assertEqual(a.Network.mtu, 9000)
|
|
# required feature defaults from Feature.Defaults are present
|
|
self.assertIsNotNone(a.Network.template_id)
|
|
self.assertEqual(str(a.Network.template_id), "00000000-0000-4000-8000-000000000001")
|
|
self.assertEqual(a.Network.template_short_name, "net")
|
|
|
|
def test_subclass_adds_field_and_feature(self):
|
|
a = RouterPlus(dabinst_short_name="rp1")
|
|
self.assertEqual(a.model_name, "plus")
|
|
self.assertIsNotNone(a.Network)
|
|
self.assertIsNotNone(a.Firewall)
|
|
self.assertTrue(a.Firewall.enabled_by_default)
|
|
|
|
def test_subclass_value_overrides(self):
|
|
a = RouterLite(dabinst_short_name="rl1")
|
|
self.assertEqual(a.template_short_name, "router-lite")
|
|
self.assertEqual(a.model_name, "lite")
|
|
self.assertIsNone(a.Network) # disabled by subclass default
|
|
self.assertIsNotNone(a.Firewall)
|
|
self.assertFalse(a.Firewall.enabled_by_default)
|
|
|
|
def test_user_input_overrides_enabled_and_values(self):
|
|
# user value merge (no 'enabled'): stays enabled from class default
|
|
a = Router(dabinst_short_name="u1", Network={"mtu": 4096})
|
|
self.assertEqual(a.Network.mtu, 4096)
|
|
|
|
# user disables feature
|
|
b = RouterPlus(dabinst_short_name="u2", Firewall={"enabled": False})
|
|
self.assertIsNone(b.Firewall)
|
|
|
|
# user enables & overrides a field
|
|
c = RouterPlus(dabinst_short_name="u3", Firewall={"enabled": True, "enabled_by_default": False})
|
|
self.assertIsNotNone(c.Firewall)
|
|
self.assertFalse(c.Firewall.enabled_by_default)
|
|
|
|
def test_enabling_without_config_pulls_feature_defaults(self):
|
|
class R(Router):
|
|
class Defaults:
|
|
class Network:
|
|
enabled = True # no extra values here
|
|
|
|
r = R(dabinst_short_name="x")
|
|
self.assertIsNotNone(r.Network)
|
|
# pulled from Network.Defaults (template fields & mtu default 1500 -> overridden by Router.Defaults to 9000)
|
|
self.assertEqual(r.Network.mtu, 9000)
|
|
self.assertEqual(r.Network.template_short_name, "net")
|
|
|
|
|
|
# ---------- invariants: schema monotonicity & declarations ----------
|
|
|
|
|
|
class TestInheritanceRules(unittest.TestCase):
|
|
def test_cannot_remove_parent_feature(self):
|
|
def make_bad():
|
|
class Bad(RouterPlus):
|
|
class Features:
|
|
class Firewall(dm.BaseFeature): # re-declare only one, drop 'Network'
|
|
enabled_by_default: bool = True
|
|
|
|
return Bad
|
|
|
|
with self.assertRaises(TypeError):
|
|
_ = make_bad()
|
|
|
|
def test_cannot_retarget_parent_feature_type(self):
|
|
def make_bad():
|
|
class Router2(Router):
|
|
class Features:
|
|
class Network(dm.BaseFeature): # Trying to retarget 'Network' to a new type
|
|
mtu: StrictInt = 1600
|
|
|
|
return Router2
|
|
|
|
with self.assertRaises(TypeError):
|
|
_ = make_bad()
|
|
|
|
def test_cannot_change_parent_field_type_or_constraints(self):
|
|
# Type change (StrictInt -> int)
|
|
def bad_type():
|
|
class Bad(Router):
|
|
cpu_cnt: int = 2 # loses StrictInt constraint
|
|
|
|
return Bad
|
|
|
|
with self.assertRaises(TypeError):
|
|
_ = bad_type()
|
|
|
|
# Constraint change (gt=0 -> ge=0)
|
|
def bad_constraint():
|
|
class Bad2(Router):
|
|
cpu_cnt: StrictInt = Field(1, ge=0)
|
|
|
|
return Bad2
|
|
|
|
with self.assertRaises(TypeError):
|
|
_ = bad_constraint()
|
|
|
|
def test_new_feature_must_be_declared_in_inner_Features(self):
|
|
# Add a new feature field but forget to declare it in inner Features
|
|
def bad_undeclared():
|
|
class Bad(Router):
|
|
class Features:
|
|
class Network(dm.BaseFeature):
|
|
mtu: StrictInt = 1500
|
|
|
|
class Extra(dm.BaseFeature):
|
|
foo: StrictInt = 1
|
|
|
|
Extra: Optional[Extra] = Extra()
|
|
|
|
return Bad
|
|
|
|
with self.assertRaises(TypeError):
|
|
_ = bad_undeclared()
|
|
|
|
def test_subclass_can_add_new_feature_when_declared(self):
|
|
class Good(Router):
|
|
class Features:
|
|
class QoS(dm.BaseFeature):
|
|
priority: StrictInt = 5
|
|
|
|
class Defaults:
|
|
template_id = "33333333-3333-4333-8333-333333333333"
|
|
template_short_name = "qos"
|
|
|
|
class Defaults:
|
|
class QoS:
|
|
enabled = True
|
|
priority = 7
|
|
|
|
g = Good(dabinst_short_name="g1")
|
|
self.assertIsNotNone(g.QoS)
|
|
self.assertEqual(g.QoS.priority, 7)
|
|
self.assertEqual(str(g.QoS.template_id), "33333333-3333-4333-8333-333333333333")
|
|
|
|
|
|
# ---------- type allowlist enforcement ----------
|
|
|
|
|
|
class TestAllowedTypes(unittest.TestCase):
|
|
def test_disallow_bad_appliance_field_type(self):
|
|
def bad_field():
|
|
class Bad(Router):
|
|
bad: float = 1.2 # float not in allowed set
|
|
|
|
return Bad
|
|
|
|
with self.assertRaises(TypeError):
|
|
_ = bad_field()
|
|
|
|
def test_disallow_bad_feature_field_type(self):
|
|
def bad_feature():
|
|
class Bad(Router):
|
|
class Features:
|
|
class BadFeat(dm.BaseFeature):
|
|
bad: float = 1.2
|
|
|
|
return Bad
|
|
|
|
with self.assertRaises(TypeError):
|
|
_ = bad_feature()
|
|
|
|
|
|
# ---------- extras=forbid & immutability ----------
|
|
|
|
|
|
class TestValidationAndImmutability(unittest.TestCase):
|
|
def test_appliance_extra_forbidden(self):
|
|
with self.assertRaises(ValidationError):
|
|
_ = Router(dabinst_short_name="r", unknown=1)
|
|
|
|
def test_feature_extra_forbidden(self):
|
|
# 'enabled' is stripped, but unknown keys should error
|
|
with self.assertRaises(ValidationError):
|
|
_ = Router(dabinst_short_name="r", Network={"enabled": True, "unknown": 42})
|
|
|
|
def test_instances_are_frozen(self):
|
|
r = Router(dabinst_short_name="f")
|
|
with self.assertRaises(TypeError):
|
|
r.template_short_name = "hack" # frozen
|
|
|
|
|
|
# ---------- Optional/Union handling ----------
|
|
|
|
|
|
class TestOptionalUnionHandling(unittest.TestCase):
|
|
def test_optional_feature_annotation_is_detected(self):
|
|
class R(dm.BaseAppliance):
|
|
class Features:
|
|
class Net(dm.BaseFeature):
|
|
mtu: StrictInt = 1500
|
|
|
|
class Defaults:
|
|
template_id = "44444444-4444-4444-8444-444444444444"
|
|
template_short_name = "net2"
|
|
|
|
# explicit Optional[...] annotation (the metaclass injects it too if missing)
|
|
Net: Optional[Features.Net] = None
|
|
|
|
class Defaults:
|
|
template_id = "55555555-5555-4555-8555-555555555551"
|
|
template_short_name = "R"
|
|
|
|
class Net:
|
|
enabled = True
|
|
mtu = 2000
|
|
|
|
r = R(dabinst_short_name="o1")
|
|
self.assertIsNotNone(r.Net)
|
|
self.assertEqual(r.Net.mtu, 2000)
|
|
self.assertEqual(str(r.Net.template_id), "44444444-4444-4444-8444-444444444444")
|
|
|
|
def test_union_with_none_is_detected(self):
|
|
class R(dm.BaseAppliance):
|
|
class Features:
|
|
class Net(dm.BaseFeature):
|
|
mtu: StrictInt = 1500
|
|
|
|
class Defaults:
|
|
template_id = "55555555-5555-4555-8555-555555555555"
|
|
template_short_name = "net3"
|
|
|
|
# fancier spelling of Optional
|
|
Net: Union[Features.Net, None] = None
|
|
|
|
class Defaults:
|
|
template_id = "55555555-5555-4555-8555-555555555551"
|
|
template_short_name = "R"
|
|
|
|
class Net:
|
|
enabled = True
|
|
|
|
r = R(dabinst_short_name="u1")
|
|
self.assertIsNotNone(r.Net)
|
|
self.assertEqual(str(r.Net.template_id), "55555555-5555-4555-8555-555555555555")
|
|
|
|
|
|
# ---------- class defaults MUST NOT mutate during instance creation ----------
|
|
|
|
|
|
class TestDefaultsMutationSafety(unittest.TestCase):
|
|
def test_defaults_map_not_mutated(self):
|
|
# Preconditions
|
|
self.assertIn("Firewall", RouterPlus.__defaults_map__)
|
|
self.assertIn("enabled", RouterPlus.__defaults_map__["Firewall"])
|
|
# Create an instance that disables Firewall, which previously used to mutate defaults
|
|
_ = RouterPlus(dabinst_short_name="x", Firewall={"enabled": False})
|
|
# Postconditions: class defaults unchanged
|
|
self.assertIn("Firewall", RouterPlus.__defaults_map__)
|
|
self.assertIn("enabled", RouterPlus.__defaults_map__["Firewall"])
|
|
self.assertTrue(RouterPlus.__defaults_map__["Firewall"]["enabled"])
|
|
|
|
|
|
# ---------- regression: providing config dict without flags still merges ----------
|
|
|
|
|
|
class TestConfigWithoutEnabledFlag(unittest.TestCase):
|
|
def test_merge_when_no_flags(self):
|
|
# Provide a dict (no 'enabled'), should merge and keep enabled from class default
|
|
r = Router(dabinst_short_name="m", Network={"mtu": 7777})
|
|
self.assertEqual(r.Network.mtu, 7777)
|