mirror of
https://github.com/GNS3/gns3-server.git
synced 2026-10-03 09:10:11 +03:00
Preserve image reconciliation, locking, and regression coverage while integrating upstream release, dependency, typing, and formatting updates. Align feature code with the new Ruff checks.
502 lines
15 KiB
Python
502 lines
15 KiB
Python
import pytest
|
|
import asyncio
|
|
import pytest_asyncio
|
|
import tempfile
|
|
import shutil
|
|
import sys
|
|
import os
|
|
import uuid
|
|
import configparser
|
|
import base64
|
|
import stat
|
|
import resource
|
|
import platform
|
|
|
|
from fastapi import FastAPI
|
|
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
|
from httpx import AsyncClient
|
|
from httpx_ws.transport import ASGIWebSocketTransport
|
|
from unittest.mock import MagicMock, patch
|
|
from pathlib import Path
|
|
from typing import AsyncGenerator, Any
|
|
|
|
from gns3server.controller import Controller
|
|
from gns3server.config import Config
|
|
from gns3server.compute import MODULES
|
|
from gns3server.compute.port_manager import PortManager
|
|
from gns3server.compute.project_manager import ProjectManager
|
|
from gns3server.db.models import Base, User, Compute
|
|
from gns3server.db.repositories.users import UsersRepository
|
|
from gns3server.db.repositories.computes import ComputesRepository
|
|
from gns3server.api.routes.controller.dependencies.database import get_db_session
|
|
from gns3server import schemas
|
|
from gns3server.schemas.controller.computes import Protocol
|
|
from gns3server.services import auth_service
|
|
from gns3server.services.authentication import DEFAULT_JWT_SECRET_KEY
|
|
|
|
sys._called_from_test = True
|
|
sys.original_platform = sys.platform
|
|
|
|
|
|
def pytest_configure(config):
|
|
"""
|
|
Increases the maximum number of open file descriptors before running tests (Unix only).
|
|
"""
|
|
|
|
if platform.system() != "Windows":
|
|
soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
|
|
# Set soft limit to hard limit, or a safe high number like 4096
|
|
new_soft = min(4096, hard)
|
|
if soft < new_soft:
|
|
resource.setrlimit(resource.RLIMIT_NOFILE, (new_soft, hard))
|
|
|
|
|
|
@pytest_asyncio.fixture(loop_scope="class", scope="class")
|
|
async def app() -> AsyncGenerator[Any, Any]:
|
|
|
|
from gns3server.api.server import app as gns3app
|
|
|
|
yield gns3app
|
|
|
|
|
|
@pytest_asyncio.fixture(loop_scope="class", scope="class")
|
|
async def db_engine():
|
|
|
|
db_url = os.getenv("GNS3_TEST_DATABASE_URI", "sqlite+aiosqlite:///:memory:") # "sqlite:///./sql_test_app.db"
|
|
engine = create_async_engine(db_url, connect_args={"check_same_thread": False}, future=True)
|
|
try:
|
|
yield engine
|
|
finally:
|
|
await engine.dispose()
|
|
|
|
|
|
@pytest_asyncio.fixture(loop_scope="class", scope="class")
|
|
async def db_session(db_engine):
|
|
|
|
# recreate database tables for each class
|
|
# preferred and faster way would be to rollback the session/transaction
|
|
# but it doesn't work for some reason
|
|
async with db_engine.connect() as conn:
|
|
# Speed up tests by avoiding to hash the 'admin' password everytime the default super admin is added
|
|
# to the database using the "after_create" sqlalchemy event
|
|
hashed_password = "$2b$12$jPsNU9IS7.EWEqXahtDfo.26w6VLOLCuFEHKNvDpOjxs5e0WpqJfa"
|
|
with patch("gns3server.services.authentication.AuthService.hash_password", return_value=hashed_password):
|
|
await conn.run_sync(Base.metadata.drop_all)
|
|
await conn.run_sync(Base.metadata.create_all)
|
|
|
|
session = AsyncSession(db_engine, expire_on_commit=False)
|
|
try:
|
|
yield session
|
|
finally:
|
|
await session.close()
|
|
|
|
|
|
@pytest_asyncio.fixture(loop_scope="class", scope="class")
|
|
async def base_client(app: FastAPI, db_session: AsyncSession) -> AsyncGenerator[AsyncClient, Any]:
|
|
|
|
async def _get_test_db():
|
|
try:
|
|
yield db_session
|
|
finally:
|
|
pass
|
|
|
|
app.dependency_overrides[get_db_session] = _get_test_db
|
|
|
|
# AnyIO cancel scopes must be entered and exited by the same task. Recent
|
|
# pytest-asyncio versions finalize yield fixtures in a different task.
|
|
ready = asyncio.get_running_loop().create_future()
|
|
finished = asyncio.Event()
|
|
|
|
async def own_client():
|
|
try:
|
|
async with AsyncClient(
|
|
base_url="http://test-api",
|
|
headers={"Content-Type": "application/json"},
|
|
transport=ASGIWebSocketTransport(app=app),
|
|
) as async_client:
|
|
ready.set_result(async_client)
|
|
await finished.wait()
|
|
except BaseException as e:
|
|
if not ready.done():
|
|
ready.set_exception(e)
|
|
raise
|
|
|
|
owner = asyncio.create_task(own_client())
|
|
try:
|
|
yield await ready
|
|
finally:
|
|
finished.set()
|
|
await owner
|
|
app.dependency_overrides.pop(get_db_session, None)
|
|
|
|
|
|
@pytest_asyncio.fixture(loop_scope="class", scope="class")
|
|
async def test_user(db_session: AsyncSession) -> User:
|
|
|
|
new_user = schemas.UserCreate(
|
|
username="user1",
|
|
email="user1@email.com",
|
|
password="user1_password",
|
|
)
|
|
user_repo = UsersRepository(db_session)
|
|
existing_user = await user_repo.get_user_by_username(new_user.username)
|
|
if existing_user:
|
|
return existing_user
|
|
user = await user_repo.create_user(new_user)
|
|
|
|
# add new user to the "Users" group
|
|
group = await user_repo.get_user_group_by_name("Users")
|
|
await user_repo.add_member_to_user_group(group.user_group_id, user)
|
|
return user
|
|
|
|
|
|
@pytest_asyncio.fixture(loop_scope="class", scope="class")
|
|
async def test_compute(db_session: AsyncSession) -> Compute:
|
|
|
|
new_compute = schemas.ComputeCreate(
|
|
compute_id=uuid.uuid4(), protocol=Protocol.http, host="localhost", port=4242, user="julien", password="secure"
|
|
)
|
|
|
|
compute_repo = ComputesRepository(db_session)
|
|
existing_compute = await compute_repo.get_compute(new_compute.compute_id)
|
|
if existing_compute:
|
|
return existing_compute
|
|
return await compute_repo.create_compute(new_compute)
|
|
|
|
|
|
@pytest_asyncio.fixture(loop_scope="class", scope="class")
|
|
def unauthorized_client(base_client: AsyncClient, test_user: User) -> AsyncClient:
|
|
return base_client
|
|
|
|
|
|
@pytest_asyncio.fixture(loop_scope="class", scope="class")
|
|
def authorized_client(base_client: AsyncClient, test_user: User) -> AsyncClient:
|
|
|
|
# Sign with the default secret key: the class-scoped token must stay valid
|
|
# across every test, but "run_around_tests" resets the config and forces
|
|
# jwt_secret_key back to the default for each test function.
|
|
access_token = auth_service.create_access_token(test_user.username, secret_key=DEFAULT_JWT_SECRET_KEY)
|
|
base_client.headers = {
|
|
**base_client.headers,
|
|
"Authorization": f"Bearer {access_token}",
|
|
}
|
|
return base_client
|
|
|
|
|
|
@pytest_asyncio.fixture(loop_scope="class", scope="class")
|
|
async def client(base_client: AsyncClient) -> AsyncClient:
|
|
|
|
# The super admin is automatically created when the users table is created
|
|
# this account that can access all endpoints without restrictions.
|
|
# Sign with the default secret key so the token matches the one enforced
|
|
# by "run_around_tests" when the config is reset for each test function.
|
|
access_token = auth_service.create_access_token("admin", secret_key=DEFAULT_JWT_SECRET_KEY)
|
|
base_client.headers = {
|
|
**base_client.headers,
|
|
"Authorization": f"Bearer {access_token}",
|
|
}
|
|
return base_client
|
|
|
|
|
|
@pytest_asyncio.fixture(loop_scope="class", scope="class")
|
|
async def compute_client(base_client: AsyncClient) -> AsyncClient:
|
|
|
|
# default compute username is 'gns3'
|
|
base64_credentials = base64.b64encode(b"gns3:").decode("ascii")
|
|
base_client.headers = {
|
|
**base_client.headers,
|
|
"Authorization": f"Basic {base64_credentials}",
|
|
}
|
|
return base_client
|
|
|
|
|
|
@pytest.fixture
|
|
def controller_config_path(tmpdir):
|
|
|
|
return str(tmpdir / "config" / "gns3_controller.conf")
|
|
|
|
|
|
@pytest.fixture
|
|
def controller(tmpdir, controller_config_path):
|
|
|
|
Controller._instance = None
|
|
controller = Controller.instance()
|
|
os.makedirs(os.path.dirname(controller_config_path), exist_ok=True)
|
|
Path(controller_config_path).touch()
|
|
controller._config_file = controller_config_path
|
|
controller._config_loaded = True
|
|
return controller
|
|
|
|
|
|
@pytest.fixture
|
|
def compute(controller):
|
|
|
|
compute = MagicMock()
|
|
compute.id = "example.com"
|
|
controller._computes = {"example.com": compute}
|
|
return compute
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def project(tmpdir, controller):
|
|
|
|
return await controller.add_project(name="Test")
|
|
|
|
|
|
@pytest.fixture
|
|
def compute_project(tmpdir):
|
|
|
|
return ProjectManager.instance().create_project(project_id="a1e920ca-338a-4e9f-b363-aa607b09dd80")
|
|
|
|
|
|
@pytest.fixture
|
|
def config(tmpdir):
|
|
|
|
path = str(tmpdir / "server.conf")
|
|
config = configparser.ConfigParser()
|
|
with open(path, "w+") as f:
|
|
config.write(f)
|
|
Config.reset()
|
|
config = Config.instance(files=[path])
|
|
config.clear()
|
|
return config
|
|
|
|
|
|
@pytest.fixture
|
|
def images_dir(config):
|
|
"""
|
|
Get the location of images
|
|
"""
|
|
|
|
path = config.settings.Server.images_path
|
|
os.makedirs(path, exist_ok=True)
|
|
os.makedirs(os.path.join(path, "QEMU"), exist_ok=True)
|
|
os.makedirs(os.path.join(path, "IOU"), exist_ok=True)
|
|
return path
|
|
|
|
|
|
@pytest.fixture
|
|
def symbols_dir(config):
|
|
"""
|
|
Get the location of symbols
|
|
"""
|
|
|
|
path = config.settings.Server.symbols_path
|
|
os.makedirs(path, exist_ok=True)
|
|
return path
|
|
|
|
|
|
@pytest.fixture
|
|
def projects_dir(config):
|
|
"""
|
|
Get the location of images
|
|
"""
|
|
|
|
path = config.settings.Server.projects_path
|
|
os.makedirs(path, exist_ok=True)
|
|
return path
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
def port_manager():
|
|
"""An instance of port manager"""
|
|
|
|
PortManager._instance = None
|
|
p = PortManager.instance()
|
|
p.console_host = "127.0.0.1"
|
|
return p
|
|
|
|
|
|
@pytest.fixture(scope="function")
|
|
def free_console_port(port_manager, compute_project):
|
|
"""Get a free TCP port"""
|
|
|
|
# In case of already use ports we will raise an exception
|
|
port = port_manager.get_free_tcp_port(compute_project)
|
|
# We release the port immediately in order to allow
|
|
# the test do whatever the test want
|
|
port_manager.release_tcp_port(port, compute_project)
|
|
return port
|
|
|
|
|
|
@pytest.fixture
|
|
def darwin_platform():
|
|
"""
|
|
Change sys.plaform to Darwin
|
|
"""
|
|
|
|
old_platform = sys.platform
|
|
sys.platform = "darwin10.10"
|
|
yield
|
|
sys.plaform = old_platform
|
|
|
|
|
|
@pytest.fixture
|
|
def windows_platform():
|
|
"""
|
|
Change sys.platform to Windows
|
|
"""
|
|
|
|
old_platform = sys.platform
|
|
sys.platform = "win10"
|
|
yield
|
|
sys.plaform = old_platform
|
|
|
|
|
|
@pytest.fixture
|
|
def linux_platform():
|
|
"""
|
|
Change sys.platform to Linux
|
|
"""
|
|
|
|
old_platform = sys.platform
|
|
sys.platform = "linuxdebian"
|
|
yield
|
|
sys.plaform = old_platform
|
|
|
|
|
|
@pytest.fixture
|
|
def on_gns3vm(linux_platform):
|
|
"""
|
|
Mock the hostname to emulate the GNS3 VM
|
|
"""
|
|
|
|
with patch(
|
|
"gns3server.utils.interfaces.interfaces",
|
|
return_value=[
|
|
{
|
|
"name": "eth0",
|
|
"special": False,
|
|
"type": "ethernet",
|
|
"ip_addresses": [],
|
|
"status": "up",
|
|
"speed": 1000,
|
|
"mtu": 1500,
|
|
"flags": ["up", "broadcast", "running", "multicast"],
|
|
},
|
|
{
|
|
"name": "eth1",
|
|
"special": False,
|
|
"type": "ethernet",
|
|
"ip_addresses": [],
|
|
"status": "down",
|
|
"speed": 0,
|
|
"mtu": 1500,
|
|
"flags": ["broadcast"],
|
|
},
|
|
{
|
|
"name": "virbr0",
|
|
"special": True,
|
|
"type": "ethernet",
|
|
"ip_addresses": [],
|
|
"status": "up",
|
|
"speed": 10000,
|
|
"mtu": 1500,
|
|
"flags": ["up", "broadcast", "running", "multicast"],
|
|
},
|
|
],
|
|
):
|
|
with patch("socket.gethostname", return_value="gns3vm"):
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def ethernet_device():
|
|
|
|
import psutil
|
|
|
|
return sorted(psutil.net_if_addrs().keys())[0]
|
|
|
|
|
|
@pytest.fixture
|
|
def ubridge_path(config):
|
|
"""
|
|
Get the location of a fake ubridge
|
|
"""
|
|
|
|
path = config.settings.Server.ubridge_path
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
open(path, "w+").close()
|
|
return path
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def run_around_tests(monkeypatch, config, port_manager):
|
|
"""
|
|
This setup a temporary project file environment around tests
|
|
"""
|
|
|
|
tmppath = tempfile.mkdtemp()
|
|
|
|
for module in MODULES:
|
|
module._instance = None
|
|
|
|
config.settings.Controller.jwt_secret_key = DEFAULT_JWT_SECRET_KEY
|
|
|
|
secrets_dir = os.path.join(tmppath, "secrets")
|
|
os.makedirs(secrets_dir)
|
|
config.settings.Server.secrets_dir = secrets_dir
|
|
|
|
projects_dir = os.path.join(tmppath, "projects")
|
|
os.makedirs(projects_dir)
|
|
config.settings.Server.projects_path = projects_dir
|
|
|
|
symbols_dir = os.path.join(tmppath, "symbols")
|
|
os.makedirs(symbols_dir)
|
|
config.settings.Server.symbols_path = symbols_dir
|
|
|
|
images_dir = os.path.join(tmppath, "images")
|
|
os.makedirs(images_dir)
|
|
config.settings.Server.images_path = images_dir
|
|
|
|
appliances_dir = os.path.join(tmppath, "appliances")
|
|
os.makedirs(appliances_dir)
|
|
config.settings.Server.appliances_path = appliances_dir
|
|
|
|
config.settings.Server.ubridge_path = os.path.join(tmppath, "bin", "ubridge")
|
|
|
|
# Prevent executions of the VM if we forgot to mock something
|
|
config.settings.VirtualBox.vboxmanage_path = tmppath
|
|
config.settings.VPCS.vpcs_path = tmppath
|
|
config.settings.VMware.vmrun_path = tmppath
|
|
config.settings.Dynamips.dynamips_path = tmppath
|
|
|
|
# Force turn off KVM because it's not available on CI
|
|
config.settings.Qemu.enable_hardware_acceleration = False
|
|
|
|
# avoid monitoring for new images while testing
|
|
config.settings.Server.auto_discover_images = False
|
|
|
|
# Resolve the projects directory from the Config singleton at call time instead of
|
|
# closing over this test's tmppath: a from-import executed anywhere while this
|
|
# patch is active (e.g. the first import of gns3server.api.server from inside a
|
|
# test body) freezes the patched object into the importing module's namespace
|
|
# forever, and a closed-over path would then point at a deleted directory in
|
|
# every later test (order-dependent FileNotFoundError in psutil.disk_usage).
|
|
monkeypatch.setattr(
|
|
"gns3server.utils.path.get_default_project_directory",
|
|
lambda *args: Config.instance().settings.Server.projects_path,
|
|
)
|
|
|
|
# Force sys.platform to the original value. Because it seems not be restored correctly after each test
|
|
sys.platform = sys.original_platform
|
|
|
|
yield
|
|
|
|
# A helper should not raise Exception
|
|
try:
|
|
shutil.rmtree(tmppath)
|
|
except BaseException:
|
|
pass
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_executable(monkeypatch, tmpdir) -> str:
|
|
|
|
monkeypatch.setenv("PATH", str(tmpdir))
|
|
executable_path = os.path.join(os.environ["PATH"], "fake_executable")
|
|
with open(executable_path, "w+") as f:
|
|
f.write("1")
|
|
os.chmod(executable_path, stat.S_IRUSR | stat.S_IWUSR | stat.S_IXUSR)
|
|
return executable_path
|