feat: allow admins to bypass approval flow when joining networks

This commit is contained in:
Ubuntu
2026-05-07 20:04:08 +00:00
parent 32d517ea08
commit d100fdff3b
34 changed files with 2523 additions and 1637 deletions
@@ -0,0 +1,88 @@
"""Verify the structure of the Alembic migration that merges
user_network_approvals and device_network_memberships into network_access_requests.
These are STRUCTURAL tests only — no database connection is required.
"""
import importlib
import importlib.util
import os
import sys
# ── helpers ────────────────────────────────────────────────────────────────
def _load_migration_module():
"""Load the migration module by file path without executing Alembic."""
migration_path = os.path.join(
os.path.dirname(__file__),
'..', '..', 'migrations', 'versions',
'merge_approval_membership_tables.py',
)
migration_path = os.path.abspath(migration_path)
spec = importlib.util.spec_from_file_location(
'merge_approval_membership_tables', migration_path,
)
assert spec is not None, f'Could not create module spec for {migration_path}'
assert spec.loader is not None, f'Module spec has no loader for {migration_path}'
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
# ── structural tests ───────────────────────────────────────────────────────
def test_migration_file_can_be_imported():
"""The migration module MUST import without raising any exception."""
mod = _load_migration_module()
assert mod is not None
def test_upgrade_function_exists():
"""upgrade() must be a callable in the module."""
mod = _load_migration_module()
assert hasattr(mod, 'upgrade'), 'module is missing upgrade()'
assert callable(mod.upgrade), 'upgrade is not callable'
def test_downgrade_function_exists():
"""downgrade() must be a callable in the module."""
mod = _load_migration_module()
assert hasattr(mod, 'downgrade'), 'module is missing downgrade()'
assert callable(mod.downgrade), 'downgrade is not callable'
def test_revision_is_set_correctly():
"""revision must equal the documented value 'c0a1b2c3d4e5'."""
mod = _load_migration_module()
assert hasattr(mod, 'revision'), 'module is missing revision'
assert mod.revision == 'c0a1b2c3d4e5', (
f"Expected revision 'c0a1b2c3d4e5', got '{mod.revision}'"
)
def test_down_revision_is_set_correctly():
"""down_revision must equal the documented value 'a1b2c3d4e5f6'."""
mod = _load_migration_module()
assert hasattr(mod, 'down_revision'), 'module is missing down_revision'
assert mod.down_revision == 'a1b2c3d4e5f6', (
f"Expected down_revision 'a1b2c3d4e5f6', got '{mod.down_revision}'"
)
def test_branch_labels_is_none():
"""branch_labels should be None for a standard linear migration."""
mod = _load_migration_module()
assert mod.branch_labels is None, (
f"Expected branch_labels None, got {mod.branch_labels!r}"
)
def test_depends_on_is_none():
"""depends_on should be None — this migration has no cross-dependencies."""
mod = _load_migration_module()
assert mod.depends_on is None, (
f"Expected depends_on None, got {mod.depends_on!r}"
)
@@ -0,0 +1,340 @@
"""Unit tests for NetworkAccessRequest model structure.
WHAT: Verifies the model class can be imported, has the expected columns,
constraints, and enum types.
WHY: Structural correctness of the model is a prerequisite for Phase 2+
work; catching missing columns or constraints early prevents
migration/runtime failures.
APPROACH: gatehouse_app/__init__.py calls create_app() at module level which
requires psycopg2 (PostgreSQL driver). We prevent this by pre-loading
gatehouse_app as a bare namespace package, then selectively providing
the real submodules (utils.constants) and fakes (extensions, models.base).
We do NOT call db.create_all() — the table metadata is fully populated
during class definition. FK target tables don't exist in our test
metadata, so we check FK presence without table resolution.
"""
import sys
import importlib.util
import pytest
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
# ═══════════════════════════════════════════════════════════════════════════════
# Step 1: Pre-load gatehouse_app as a bare namespace (prevents __init__.py)
# ═══════════════════════════════════════════════════════════════════════════════
_gatehouse = type(sys)("gatehouse_app")
_gatehouse.__path__ = []
sys.modules["gatehouse_app"] = _gatehouse
# ═══════════════════════════════════════════════════════════════════════════════
# Step 2: Load the real gatehouse_app.utils.constants (self-contained, no deps)
# ═══════════════════════════════════════════════════════════════════════════════
_constants_spec = importlib.util.spec_from_file_location(
"gatehouse_app.utils.constants",
"/home/ubuntu/securid/gatehouse-api/gatehouse_app/utils/constants.py",
submodule_search_locations=[],
)
_constants_mod = importlib.util.module_from_spec(_constants_spec)
sys.modules["gatehouse_app.utils"] = type(sys)("gatehouse_app.utils")
sys.modules["gatehouse_app.utils.constants"] = _constants_mod
_constants_spec.loader.exec_module(_constants_mod)
ApprovalGrantType = _constants_mod.ApprovalGrantType
ApprovalState = _constants_mod.ApprovalState
# ═══════════════════════════════════════════════════════════════════════════════
# Step 3: Build fake extensions.db and models.base
# ═══════════════════════════════════════════════════════════════════════════════
_fake_db = SQLAlchemy()
class FakeBaseModel(_fake_db.Model):
"""Minimal BaseModel matching the real one's column definitions."""
__abstract__ = True
id = _fake_db.Column(_fake_db.String(36), primary_key=True, default=lambda: "test-uuid", nullable=False)
created_at = _fake_db.Column(_fake_db.DateTime, nullable=False)
updated_at = _fake_db.Column(_fake_db.DateTime, nullable=False)
deleted_at = _fake_db.Column(_fake_db.DateTime, nullable=True)
def to_dict(self, exclude=None):
"""Mimic the real BaseModel.to_dict — iterates __table__.columns."""
from datetime import datetime, timezone
exclude = exclude or []
result = {}
for column in self.__table__.columns:
if column.name not in exclude:
value = getattr(self, column.name)
if isinstance(value, datetime):
result[column.name] = value.isoformat()
else:
result[column.name] = value
return result
_fake_extensions = type(sys)("gatehouse_app.extensions")
_fake_extensions.db = _fake_db
_fake_models_base = type(sys)("gatehouse_app.models.base")
_fake_models_base.BaseModel = FakeBaseModel
sys.modules["gatehouse_app.extensions"] = _fake_extensions
sys.modules["gatehouse_app.models"] = type(sys)("gatehouse_app.models")
sys.modules["gatehouse_app.models.base"] = _fake_models_base
# ═══════════════════════════════════════════════════════════════════════════════
# Step 3b: Create stub models for relationship targets so ORM mapper
# can resolve 'Organization', 'User', 'Device', 'PortalNetwork'
# ═══════════════════════════════════════════════════════════════════════════════
class Organization(_fake_db.Model):
__tablename__ = "organizations"
id = _fake_db.Column(_fake_db.String(36), primary_key=True)
class User(_fake_db.Model):
__tablename__ = "users"
id = _fake_db.Column(_fake_db.String(36), primary_key=True)
class Device(_fake_db.Model):
__tablename__ = "devices"
id = _fake_db.Column(_fake_db.String(36), primary_key=True)
class PortalNetwork(_fake_db.Model):
__tablename__ = "portal_networks"
id = _fake_db.Column(_fake_db.String(36), primary_key=True)
# ═══════════════════════════════════════════════════════════════════════════════
# Step 4: Load the real network_access_request module from file
# ═══════════════════════════════════════════════════════════════════════════════
_model_spec = importlib.util.spec_from_file_location(
"gatehouse_app.models.zerotier.network_access_request",
"/home/ubuntu/securid/gatehouse-api/gatehouse_app/models/zerotier/network_access_request.py",
submodule_search_locations=[],
)
_model_mod = importlib.util.module_from_spec(_model_spec)
sys.modules["gatehouse_app.models.zerotier"] = type(sys)("gatehouse_app.models.zerotier")
sys.modules["gatehouse_app.models.zerotier.network_access_request"] = _model_mod
_model_spec.loader.exec_module(_model_mod)
NetworkAccessRequest = _model_mod.NetworkAccessRequest
# ═══════════════════════════════════════════════════════════════════════════════
# Fixture
# ═══════════════════════════════════════════════════════════════════════════════
@pytest.fixture(scope="module")
def model_class():
"""Return the model class — table metadata is already built at definition time."""
return NetworkAccessRequest
@pytest.fixture(scope="module")
def app():
"""Minimal Flask app for to_dict (BaseModel.to_dict iterates __table__.columns)."""
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///:memory:"
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
_fake_db.init_app(app)
return app
# ═══════════════════════════════════════════════════════════════════════════════
# Test data
# ═══════════════════════════════════════════════════════════════════════════════
EXPECTED_LOCAL_COLUMNS = {
"organization_id", "user_id", "device_id", "portal_network_id",
"granted_by_user_id", "grant_type", "status", "active",
"justification", "join_seen",
}
EXPECTED_INHERITED_COLUMNS = {"id", "created_at", "updated_at", "deleted_at"}
ALL_EXPECTED = EXPECTED_LOCAL_COLUMNS | EXPECTED_INHERITED_COLUMNS
# FK columns that should have foreign keys (table name, FK target)
EXPECTED_FKS = {
"organization_id": "organizations.id",
"user_id": "users.id",
"device_id": "devices.id",
"portal_network_id": "portal_networks.id",
"granted_by_user_id": "users.id",
}
# ═══════════════════════════════════════════════════════════════════════════════
# Test: Module importability
# ═══════════════════════════════════════════════════════════════════════════════
class TestImport:
def test_model_importable(self, model_class):
assert model_class is not None
assert isinstance(model_class, type)
def test_model_tablename(self, model_class):
assert model_class.__tablename__ == "network_access_requests"
def test_model_inherits_base(self, model_class):
assert issubclass(model_class, FakeBaseModel)
# ═══════════════════════════════════════════════════════════════════════════════
# Test: Columns
# ═══════════════════════════════════════════════════════════════════════════════
class TestColumns:
def test_all_expected_columns_present(self, model_class):
actual = {c.name for c in model_class.__table__.columns}
missing = ALL_EXPECTED - actual
assert missing == set(), f"Missing columns: {missing}"
def test_no_extra_columns(self, model_class):
actual = {c.name for c in model_class.__table__.columns}
extra = actual - ALL_EXPECTED
assert extra == set(), f"Unexpected columns: {extra}"
def test_exact_column_count(self, model_class):
assert len(model_class.__table__.columns) == 14, (
f"Expected 14 columns, got {len(model_class.__table__.columns)}: "
f"{sorted(c.name for c in model_class.__table__.columns)}"
)
def test_organization_id_is_fk_string_not_null(self, model_class):
col = model_class.__table__.columns["organization_id"]
assert not col.nullable
assert _has_foreign_key(col)
def test_user_id_is_fk_string_not_null(self, model_class):
col = model_class.__table__.columns["user_id"]
assert not col.nullable
assert _has_foreign_key(col)
def test_device_id_is_fk_string_not_null(self, model_class):
col = model_class.__table__.columns["device_id"]
assert not col.nullable
assert _has_foreign_key(col)
def test_portal_network_id_is_fk_string_not_null(self, model_class):
col = model_class.__table__.columns["portal_network_id"]
assert not col.nullable
assert _has_foreign_key(col)
def test_granted_by_user_id_nullable_fk(self, model_class):
col = model_class.__table__.columns["granted_by_user_id"]
assert col.nullable
assert _has_foreign_key(col)
def test_justification_is_text_nullable(self, model_class):
col = model_class.__table__.columns["justification"]
assert col.nullable
assert "TEXT" in str(col.type).upper()
def test_active_is_boolean_not_null(self, model_class):
col = model_class.__table__.columns["active"]
assert str(col.type) in ("BOOLEAN", "INTEGER")
assert not col.nullable
def test_join_seen_is_boolean_not_null(self, model_class):
col = model_class.__table__.columns["join_seen"]
assert str(col.type) in ("BOOLEAN", "INTEGER")
assert not col.nullable
def test_fk_count(self, model_class):
"""Verify exactly the expected FK columns have foreign keys."""
fk_cols = {c.name for c in model_class.__table__.columns if _has_foreign_key(c)}
assert fk_cols == set(EXPECTED_FKS.keys()), (
f"FK columns {sorted(fk_cols)} != expected {sorted(EXPECTED_FKS.keys())}"
)
def _has_foreign_key(column):
"""Check if column has at least one ForeignKey, without resolving target table."""
return bool(column.foreign_keys)
# ═══════════════════════════════════════════════════════════════════════════════
# Test: UniqueConstraint
# ═══════════════════════════════════════════════════════════════════════════════
class TestConstraints:
def test_unique_constraint_exists(self, model_class):
from sqlalchemy import UniqueConstraint
ucs = [c for c in model_class.__table__.constraints if isinstance(c, UniqueConstraint)]
assert len(ucs) >= 1, "No UniqueConstraint found"
def test_unique_constraint_columns(self, model_class):
from sqlalchemy import UniqueConstraint
ucs = [c for c in model_class.__table__.constraints if isinstance(c, UniqueConstraint)]
assert len(ucs) == 1, f"Expected 1, found {len(ucs)}"
cols = {col.name for col in ucs[0].columns}
expected = {"user_id", "device_id", "portal_network_id", "deleted_at"}
assert cols == expected, f"UniqueConstraint columns {cols} != {expected}"
def test_unique_constraint_name(self, model_class):
from sqlalchemy import UniqueConstraint
ucs = [c for c in model_class.__table__.constraints if isinstance(c, UniqueConstraint)]
assert len(ucs) == 1
assert ucs[0].name == "uix_user_device_network", (
f"Expected 'uix_user_device_network', got '{ucs[0].name}'"
)
# ═══════════════════════════════════════════════════════════════════════════════
# Test: Enum types
# ═══════════════════════════════════════════════════════════════════════════════
class TestEnumTypes:
def test_status_column_uses_approval_state_enum(self, model_class):
col = model_class.__table__.columns["status"]
assert hasattr(col.type, "enum_class"), (
f"status column type {type(col.type)} has no enum_class"
)
assert col.type.enum_class is ApprovalState, (
f"status enum is {col.type.enum_class}, expected ApprovalState"
)
def test_grant_type_column_uses_approval_grant_type_enum(self, model_class):
col = model_class.__table__.columns["grant_type"]
assert hasattr(col.type, "enum_class"), (
f"grant_type column type {type(col.type)} has no enum_class"
)
assert col.type.enum_class is ApprovalGrantType, (
f"grant_type enum is {col.type.enum_class}, expected ApprovalGrantType"
)
def test_status_column_not_nullable(self, model_class):
assert not model_class.__table__.columns["status"].nullable
def test_grant_type_column_not_nullable(self, model_class):
assert not model_class.__table__.columns["grant_type"].nullable
# ═══════════════════════════════════════════════════════════════════════════════
# Test: Properties and methods
# ═══════════════════════════════════════════════════════════════════════════════
class TestMethods:
def test_repr_returns_string(self, model_class):
instance = model_class()
result = repr(instance)
assert isinstance(result, str)
assert "NetworkAccessRequest" in result
def test_active_session_property_returns_none(self, model_class):
instance = model_class()
assert instance.active_session is None
def test_to_dict_returns_dict(self, model_class, app):
with app.app_context():
instance = model_class()
result = instance.to_dict()
assert isinstance(result, dict)
for col_name in EXPECTED_LOCAL_COLUMNS:
assert col_name in result, f"Missing '{col_name}' in to_dict output"