feat: add NetBox plugin store
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
import sys
|
||||
import types
|
||||
|
||||
|
||||
if "netbox.plugins" not in sys.modules:
|
||||
netbox = types.ModuleType("netbox")
|
||||
plugins = types.ModuleType("netbox.plugins")
|
||||
|
||||
class PluginConfig:
|
||||
def ready(self):
|
||||
return None
|
||||
|
||||
class PluginMenu:
|
||||
def __init__(self, label, groups, icon_class=None):
|
||||
self.label = label
|
||||
self.groups = groups
|
||||
self.icon_class = icon_class
|
||||
|
||||
class PluginMenuItem:
|
||||
def __init__(
|
||||
self,
|
||||
link,
|
||||
link_text,
|
||||
auth_required=False,
|
||||
staff_only=False,
|
||||
permissions=None,
|
||||
buttons=None,
|
||||
):
|
||||
self.link = link
|
||||
self.link_text = link_text
|
||||
self.auth_required = auth_required
|
||||
self.staff_only = staff_only
|
||||
self.permissions = permissions or []
|
||||
self.buttons = buttons or []
|
||||
|
||||
plugins.PluginConfig = PluginConfig
|
||||
plugins.PluginMenu = PluginMenu
|
||||
plugins.PluginMenuItem = PluginMenuItem
|
||||
netbox.plugins = plugins
|
||||
sys.modules["netbox"] = netbox
|
||||
sys.modules["netbox.plugins"] = plugins
|
||||
@@ -0,0 +1,34 @@
|
||||
ALLOWED_HOSTS = ["localhost"]
|
||||
SECRET_KEY = "netbox-plugin-store-compatibility-test-key-2026-08-24!"
|
||||
API_TOKEN_PEPPERS = {
|
||||
1: "netbox-plugin-store-compatibility-test-pepper-2026-08-24!",
|
||||
}
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": "netbox_compatibility_test",
|
||||
"USER": "netbox",
|
||||
"PASSWORD": "unused",
|
||||
"HOST": "127.0.0.1",
|
||||
"PORT": 5432,
|
||||
"CONN_MAX_AGE": 0,
|
||||
}
|
||||
}
|
||||
|
||||
REDIS = {
|
||||
"tasks": {"HOST": "127.0.0.1", "PORT": 6379, "DATABASE": 0},
|
||||
"caching": {"HOST": "127.0.0.1", "PORT": 6379, "DATABASE": 1},
|
||||
}
|
||||
|
||||
PLUGINS = ["netbox_plugin_store"]
|
||||
PLUGINS_CONFIG = {
|
||||
"netbox_plugin_store": {
|
||||
"store_url": "https://store.example",
|
||||
"allowed_store_urls": ["https://store.example"],
|
||||
"allowed_artifact_urls": ["https://store.example"],
|
||||
}
|
||||
}
|
||||
|
||||
CENSUS_REPORTING_ENABLED = False
|
||||
RELEASE_CHECK_URL = None
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Smoke tests executed in an environment containing an official NetBox tag."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
NETBOX_SOURCE = Path(os.environ["NETBOX_SOURCE"]).resolve()
|
||||
EXPECTED_VERSION = os.environ["NETBOX_EXPECTED_VERSION"]
|
||||
sys.path.insert(0, str(NETBOX_SOURCE / "netbox"))
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings")
|
||||
os.environ.setdefault("NETBOX_CONFIGURATION", "netbox_test_configuration")
|
||||
|
||||
import django # noqa: E402
|
||||
|
||||
django.setup()
|
||||
|
||||
from django.apps import apps # noqa: E402
|
||||
from django.db.migrations.autodetector import MigrationAutodetector # noqa: E402
|
||||
from django.db.migrations.loader import MigrationLoader # noqa: E402
|
||||
from django.db.migrations.questioner import MigrationQuestioner # noqa: E402
|
||||
from django.db.migrations.state import ProjectState # noqa: E402
|
||||
from django.template.loader import get_template # noqa: E402
|
||||
from django.urls import resolve, reverse # noqa: E402
|
||||
|
||||
from core.models import Job # noqa: E402
|
||||
from netbox.jobs import JobRunner # noqa: E402
|
||||
from netbox_plugin_store import config # noqa: E402
|
||||
from netbox_plugin_store.jobs import PluginLifecycleJob # noqa: E402
|
||||
from netbox_plugin_store.navigation import menu # noqa: E402
|
||||
from users.models import User # noqa: E402
|
||||
|
||||
|
||||
class OfficialNetBoxCompatibilityTests(unittest.TestCase):
|
||||
def test_exact_netbox_and_django_runtime(self):
|
||||
from django.conf import settings
|
||||
|
||||
self.assertEqual(settings.RELEASE.version, EXPECTED_VERSION)
|
||||
self.assertGreaterEqual(sys.version_info, (3, 12))
|
||||
self.assertEqual(django.get_version(), "6.0.7" if EXPECTED_VERSION == "4.6.5" else "6.0.8")
|
||||
|
||||
def test_plugin_config_is_registered(self):
|
||||
app = apps.get_app_config("netbox_plugin_store")
|
||||
self.assertIsInstance(app, config)
|
||||
self.assertEqual(config.min_version, "4.6.5")
|
||||
self.assertEqual(config.max_version, "4.6.8")
|
||||
|
||||
def test_jobrunner_queue_metadata_and_run_arguments_are_compatible(self):
|
||||
self.assertIn("queue_name", inspect.signature(Job.enqueue).parameters)
|
||||
self.assertTrue(issubclass(PluginLifecycleJob, JobRunner))
|
||||
with patch.object(Job, "enqueue", return_value="queued") as enqueue:
|
||||
result = PluginLifecycleJob.enqueue(
|
||||
user="operator",
|
||||
queue_name="compatibility",
|
||||
audit_id=12,
|
||||
slug="example-plugin",
|
||||
action="install",
|
||||
version="1.0.0",
|
||||
dry_run=True,
|
||||
actor_id=34,
|
||||
)
|
||||
self.assertEqual(result, "queued")
|
||||
args, kwargs = enqueue.call_args
|
||||
self.assertIs(args[0].__func__, PluginLifecycleJob.handle.__func__)
|
||||
self.assertEqual(kwargs["queue_name"], "compatibility")
|
||||
self.assertEqual(kwargs["audit_id"], 12)
|
||||
self.assertEqual(kwargs["actor_id"], 34)
|
||||
|
||||
def test_navigation_urls_and_permission_semantics(self):
|
||||
self.assertFalse(hasattr(User(), "is_staff"))
|
||||
urls = {
|
||||
"catalog": reverse("plugins:netbox_plugin_store:catalog"),
|
||||
"status": reverse("plugins:netbox_plugin_store:status"),
|
||||
"audit-list": reverse("plugins:netbox_plugin_store:audit-list"),
|
||||
"audit-detail": reverse("plugins:netbox_plugin_store:audit-detail", kwargs={"pk": 7}),
|
||||
"plugin-detail": reverse(
|
||||
"plugins:netbox_plugin_store:plugin-detail", kwargs={"slug": "example-plugin"}
|
||||
),
|
||||
"lifecycle-confirm": reverse(
|
||||
"plugins:netbox_plugin_store:lifecycle-confirm",
|
||||
kwargs={"slug": "example-plugin", "action": "install"},
|
||||
),
|
||||
"lifecycle-action": reverse(
|
||||
"plugins:netbox_plugin_store:lifecycle-action",
|
||||
kwargs={"slug": "example-plugin", "action": "install"},
|
||||
),
|
||||
}
|
||||
for name, url in urls.items():
|
||||
self.assertEqual(resolve(url).url_name, name)
|
||||
|
||||
items = [item for group in menu.groups for item in group.items]
|
||||
self.assertEqual(len(items), 3)
|
||||
for item in items:
|
||||
self.assertTrue(item.auth_required)
|
||||
self.assertFalse(item.staff_only)
|
||||
self.assertEqual(item.permissions, ["netbox_plugin_store.manage_plugin"])
|
||||
|
||||
def test_templates_resolve_and_compile_from_installed_wheel(self):
|
||||
for name in (
|
||||
"audit_detail.html",
|
||||
"audit_list.html",
|
||||
"catalog.html",
|
||||
"confirm.html",
|
||||
"detail.html",
|
||||
"status.html",
|
||||
):
|
||||
template = get_template(f"netbox_plugin_store/{name}")
|
||||
self.assertIsNotNone(template.template)
|
||||
self.assertIsNotNone(get_template("base/layout.html").template)
|
||||
|
||||
def test_migration_graph_and_model_state_have_no_drift(self):
|
||||
loader = MigrationLoader(None, ignore_no_migrations=True)
|
||||
self.assertIn(("netbox_plugin_store", "0001_initial"), loader.disk_migrations)
|
||||
from_state = loader.project_state()
|
||||
to_state = ProjectState.from_apps(apps)
|
||||
changes = MigrationAutodetector(
|
||||
from_state,
|
||||
to_state,
|
||||
MigrationQuestioner(specified_apps={"netbox_plugin_store"}),
|
||||
).changes(graph=loader.graph, trim_to_apps={"netbox_plugin_store"})
|
||||
self.assertNotIn("netbox_plugin_store", changes)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,81 @@
|
||||
import json
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from uuid import UUID
|
||||
|
||||
import _bootstrap # noqa: F401
|
||||
|
||||
from netbox_plugin_store.agent import AgentClient
|
||||
from netbox_plugin_store.commands import SubprocessRunner
|
||||
|
||||
|
||||
class FakeSocket:
|
||||
def __init__(self, response):
|
||||
self.response = response
|
||||
self.sent = b""
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return None
|
||||
|
||||
def settimeout(self, timeout):
|
||||
self.timeout = timeout
|
||||
|
||||
def connect(self, path):
|
||||
self.path = path
|
||||
|
||||
def sendall(self, value):
|
||||
self.sent += value
|
||||
|
||||
def shutdown(self, how):
|
||||
return None
|
||||
|
||||
def recv(self, size):
|
||||
value, self.response = self.response, b""
|
||||
return value
|
||||
|
||||
|
||||
class AgentTests(unittest.TestCase):
|
||||
def test_json_line_operation_protocol(self):
|
||||
operation_id = "12345678-1234-5678-1234-567812345678"
|
||||
response = (
|
||||
json.dumps({"protocol_version": 1, "status": 202, "body": {"operation_id": operation_id}}).encode()
|
||||
+ b"\n"
|
||||
)
|
||||
fake = FakeSocket(response)
|
||||
with (
|
||||
patch("netbox_plugin_store.agent.socket.AF_UNIX", 1, create=True),
|
||||
patch("netbox_plugin_store.agent.socket.SOCK_STREAM", 1),
|
||||
patch("netbox_plugin_store.agent.socket.socket", return_value=fake),
|
||||
):
|
||||
result = AgentClient(Path("/run/store.sock")).submit_operation(
|
||||
request_id="request-id",
|
||||
action="install",
|
||||
plugin_slug="example-plugin",
|
||||
version="1.0.0",
|
||||
approved_payload_sha256="c" * 64,
|
||||
requested_by="netbox-user:1",
|
||||
)
|
||||
self.assertEqual(result, UUID(operation_id))
|
||||
request = json.loads(fake.sent.decode().strip())
|
||||
self.assertEqual(request["method"], "POST")
|
||||
self.assertEqual(request["path"], "/v1/operations")
|
||||
self.assertEqual(request["body"]["plugin_slug"], "example-plugin")
|
||||
UUID(request["idempotency_key"])
|
||||
|
||||
|
||||
class CommandTests(unittest.TestCase):
|
||||
def test_subprocess_boundary_never_uses_shell(self):
|
||||
completed = subprocess.CompletedProcess(["python", "-V"], 0, "Python", "")
|
||||
with patch("netbox_plugin_store.commands.subprocess.run", return_value=completed) as run:
|
||||
result = SubprocessRunner().run(["python", "-V"], timeout=5)
|
||||
self.assertEqual(result.stdout, "Python")
|
||||
self.assertIs(run.call_args.kwargs["shell"], False)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,122 @@
|
||||
import hashlib
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import _bootstrap # noqa: F401
|
||||
|
||||
from netbox_plugin_store.client import CatalogPlugin, StoreClient, StoreClientError, URLPolicy
|
||||
|
||||
|
||||
def plugin_payload():
|
||||
artifact = b"verified artifact"
|
||||
return {
|
||||
"slug": "example-plugin",
|
||||
"name": "Example",
|
||||
"summary": "Example plugin",
|
||||
"description": "README",
|
||||
"repository_url": "https://git.mrblake.cc/team/example",
|
||||
"latest_version": "1.2.0",
|
||||
"package_name": "netbox-example",
|
||||
"import_name": "netbox_example",
|
||||
"min_netbox_version": "4.6.5",
|
||||
"max_netbox_version": "4.6.8",
|
||||
"approved": True,
|
||||
"releases": [
|
||||
{
|
||||
"version": "1.2.0",
|
||||
"download_url": "https://store.example/artifacts/example.whl",
|
||||
"sha256": hashlib.sha256(artifact).hexdigest(),
|
||||
"approved_payload_sha256": "a" * 64,
|
||||
"approved": True,
|
||||
"immutable": True,
|
||||
"min_netbox_version": "4.6.5",
|
||||
"max_netbox_version": "4.6.8",
|
||||
}
|
||||
],
|
||||
}, artifact
|
||||
|
||||
|
||||
class URLPolicyTests(unittest.TestCase):
|
||||
def test_exact_origin_and_path_prefix(self):
|
||||
policy = URLPolicy(["https://store.example/internal"])
|
||||
self.assertEqual(
|
||||
policy.check("https://store.example/internal/api/v1/plugins/"),
|
||||
"https://store.example/internal/api/v1/plugins/",
|
||||
)
|
||||
with self.assertRaises(StoreClientError):
|
||||
policy.check("https://store.example.evil/internal")
|
||||
with self.assertRaises(StoreClientError):
|
||||
policy.check("https://store.example/other")
|
||||
|
||||
def test_release_selection_checks_netbox_version(self):
|
||||
payload, _ = plugin_payload()
|
||||
plugin = CatalogPlugin.from_mapping(payload)
|
||||
self.assertEqual(plugin.select_release("4.6.8").version, "1.2.0")
|
||||
with self.assertRaises(StoreClientError):
|
||||
plugin.select_release("4.6.9")
|
||||
|
||||
|
||||
class _Response(io.BytesIO):
|
||||
def __init__(self, body):
|
||||
super().__init__(body)
|
||||
self.headers = {"Content-Length": str(len(body))}
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.close()
|
||||
|
||||
|
||||
class _Opener:
|
||||
def __init__(self, body):
|
||||
self.body = body
|
||||
|
||||
def open(self, request, timeout):
|
||||
return _Response(self.body)
|
||||
|
||||
|
||||
class DownloadTests(unittest.TestCase):
|
||||
def test_download_is_hashed_before_use(self):
|
||||
_, artifact = plugin_payload()
|
||||
client = StoreClient(
|
||||
"https://store.example", ("https://store.example",), ("https://store.example",)
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as temp_name:
|
||||
target = Path(temp_name) / "plugin.whl"
|
||||
with patch("netbox_plugin_store.client.build_opener", return_value=_Opener(artifact)):
|
||||
actual, size = client.download_artifact(
|
||||
"https://store.example/artifacts/example.whl",
|
||||
target,
|
||||
expected_sha256=hashlib.sha256(artifact).hexdigest(),
|
||||
timeout=10,
|
||||
max_bytes=1_000,
|
||||
)
|
||||
self.assertEqual(actual, hashlib.sha256(artifact).hexdigest())
|
||||
self.assertEqual(size, len(artifact))
|
||||
self.assertEqual(target.read_bytes(), artifact)
|
||||
|
||||
def test_mismatched_hash_removes_download(self):
|
||||
_, artifact = plugin_payload()
|
||||
client = StoreClient(
|
||||
"https://store.example", ("https://store.example",), ("https://store.example",)
|
||||
)
|
||||
with tempfile.TemporaryDirectory() as temp_name:
|
||||
target = Path(temp_name) / "plugin.whl"
|
||||
with patch("netbox_plugin_store.client.build_opener", return_value=_Opener(artifact)):
|
||||
with self.assertRaises(StoreClientError):
|
||||
client.download_artifact(
|
||||
"https://store.example/artifacts/example.whl",
|
||||
target,
|
||||
expected_sha256="0" * 64,
|
||||
timeout=10,
|
||||
max_bytes=1_000,
|
||||
)
|
||||
self.assertFalse(target.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,52 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import _bootstrap # noqa: F401
|
||||
|
||||
from netbox_plugin_store.editors import EditorError, PluginConfigurationEditor, RequirementsEditor
|
||||
|
||||
|
||||
class ConfigurationEditorTests(unittest.TestCase):
|
||||
def test_atomic_change_backup_and_rollback(self):
|
||||
with tempfile.TemporaryDirectory() as temp_name:
|
||||
root = Path(temp_name)
|
||||
config = root / "configuration.py"
|
||||
original = "SECRET_KEY = 'unchanged'\nPLUGINS = [\n 'netbox_plugin_store',\n]\n"
|
||||
config.write_text(original, encoding="utf-8")
|
||||
editor = PluginConfigurationEditor(config, root / "backups", 5)
|
||||
receipt = editor.set_enabled("netbox_example", True)
|
||||
self.assertTrue(receipt.changed)
|
||||
self.assertTrue(receipt.backup_path.is_file())
|
||||
self.assertIn("'netbox_example'", config.read_text(encoding="utf-8"))
|
||||
editor.rollback(receipt)
|
||||
self.assertEqual(config.read_text(encoding="utf-8"), original)
|
||||
|
||||
def test_dynamic_plugins_assignment_is_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as temp_name:
|
||||
root = Path(temp_name)
|
||||
config = root / "configuration.py"
|
||||
config.write_text("PLUGINS = load_plugins()\n", encoding="utf-8")
|
||||
editor = PluginConfigurationEditor(config, root / "backups", 5)
|
||||
with self.assertRaises(EditorError):
|
||||
editor.set_enabled("netbox_example", True)
|
||||
|
||||
|
||||
class RequirementsEditorTests(unittest.TestCase):
|
||||
def test_pin_and_remove_preserve_other_lines(self):
|
||||
with tempfile.TemporaryDirectory() as temp_name:
|
||||
root = Path(temp_name)
|
||||
requirements = root / "local_requirements.txt"
|
||||
requirements.write_text("# managed manually\nother-package==2\n", encoding="utf-8")
|
||||
editor = RequirementsEditor(requirements, root / "backups", 5)
|
||||
line = "netbox-example @ https://store.example/example.whl#sha256=" + "a" * 64
|
||||
editor.set_requirement("netbox-example", line)
|
||||
text = requirements.read_text(encoding="utf-8")
|
||||
self.assertIn(line, text)
|
||||
self.assertIn("other-package==2", text)
|
||||
editor.set_requirement("netbox-example", None)
|
||||
self.assertNotIn("netbox-example", requirements.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,169 @@
|
||||
import hashlib
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import _bootstrap # noqa: F401
|
||||
|
||||
from netbox_plugin_store.client import CatalogPlugin
|
||||
from netbox_plugin_store.commands import CommandResult
|
||||
from netbox_plugin_store.lifecycle import LifecycleError, LifecycleRequest, LifecycleService
|
||||
from netbox_plugin_store.runtime import RuntimeSettings
|
||||
|
||||
|
||||
ARTIFACT = b"approved wheel bytes"
|
||||
|
||||
|
||||
def catalog_plugin():
|
||||
return CatalogPlugin.from_mapping(
|
||||
{
|
||||
"slug": "example-plugin",
|
||||
"name": "Example",
|
||||
"package_name": "netbox-example",
|
||||
"import_name": "netbox_example",
|
||||
"latest_version": "1.0.0",
|
||||
"approved": True,
|
||||
"releases": [
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://store.example/example.whl",
|
||||
"sha256": hashlib.sha256(ARTIFACT).hexdigest(),
|
||||
"approved_payload_sha256": "b" * 64,
|
||||
"approved": True,
|
||||
"immutable": True,
|
||||
"min_netbox_version": "4.6.5",
|
||||
"max_netbox_version": "4.6.8",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, plugin):
|
||||
self.plugin = plugin
|
||||
self.downloads = 0
|
||||
|
||||
def get_plugin(self, slug):
|
||||
return self.plugin
|
||||
|
||||
def download_artifact(self, url, destination, **kwargs):
|
||||
self.downloads += 1
|
||||
destination.write_bytes(ARTIFACT)
|
||||
return hashlib.sha256(ARTIFACT).hexdigest(), len(ARTIFACT)
|
||||
|
||||
|
||||
class FakeRepository:
|
||||
def __init__(self):
|
||||
self.audits = {}
|
||||
self.statuses = []
|
||||
|
||||
def create_audit(self, request, actor_id):
|
||||
self.audits[1] = {"status": "queued"}
|
||||
return 1
|
||||
|
||||
def mark_running(self, audit_id):
|
||||
self.audits[audit_id]["status"] = "running"
|
||||
|
||||
def mark_success(self, audit_id, result):
|
||||
self.audits[audit_id].update(status="succeeded", result=result)
|
||||
|
||||
def mark_handed_off(self, audit_id, operation_id, result):
|
||||
self.audits[audit_id].update(status="handed-off", operation_id=operation_id, result=result)
|
||||
|
||||
def mark_failed(self, audit_id, error, result):
|
||||
self.audits[audit_id].update(status="failed", error=error)
|
||||
|
||||
def update_status(self, plugin, **values):
|
||||
self.statuses.append(values)
|
||||
|
||||
def has_other_pending_mutation(self, slug, audit_id):
|
||||
return False
|
||||
|
||||
|
||||
class FakeRunner:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def run(self, argv, **kwargs):
|
||||
self.calls.append((list(argv), kwargs))
|
||||
return CommandResult(0, "ok", "")
|
||||
|
||||
|
||||
def runtime(root: Path, *, execution_mode="direct", allow=True):
|
||||
config = root / "configuration.py"
|
||||
config.write_text("PLUGINS = ['netbox_plugin_store']\n", encoding="utf-8")
|
||||
return RuntimeSettings.from_mapping(
|
||||
{
|
||||
"store_url": "https://store.example",
|
||||
"allowed_store_urls": ["https://store.example"],
|
||||
"allowed_artifact_urls": ["https://store.example"],
|
||||
"configuration_path": str(config),
|
||||
"requirements_path": str(root / "local_requirements.txt"),
|
||||
"manage_path": str(root / "manage.py"),
|
||||
"lock_path": str(root / "operation.lock"),
|
||||
"backup_dir": str(root / "backups"),
|
||||
"execution_mode": execution_mode,
|
||||
"allow_lifecycle_mutations": allow,
|
||||
"run_migrations": False,
|
||||
"collect_static": False,
|
||||
"allow_package_index": False,
|
||||
},
|
||||
configuration_dir=root,
|
||||
netbox_root=root,
|
||||
base_dir=root,
|
||||
netbox_version="4.6.8",
|
||||
)
|
||||
|
||||
|
||||
class LifecycleTests(unittest.TestCase):
|
||||
def test_dry_run_has_no_download_or_subprocess(self):
|
||||
with tempfile.TemporaryDirectory() as temp_name:
|
||||
root = Path(temp_name)
|
||||
client = FakeClient(catalog_plugin())
|
||||
repository = FakeRepository()
|
||||
runner = FakeRunner()
|
||||
service = LifecycleService(runtime(root), client, repository, runner=runner, version_provider=lambda _: "")
|
||||
result = service.execute(LifecycleRequest("example-plugin", "install", "1.0.0", True))
|
||||
self.assertEqual(result.state, "dry-run")
|
||||
self.assertEqual(client.downloads, 0)
|
||||
self.assertEqual(runner.calls, [])
|
||||
|
||||
def test_direct_install_is_disabled_and_uses_no_index_and_pip_check(self):
|
||||
with tempfile.TemporaryDirectory() as temp_name:
|
||||
root = Path(temp_name)
|
||||
client = FakeClient(catalog_plugin())
|
||||
repository = FakeRepository()
|
||||
runner = FakeRunner()
|
||||
service = LifecycleService(runtime(root), client, repository, runner=runner, version_provider=lambda _: "")
|
||||
result = service.execute(LifecycleRequest("example-plugin", "install", "1.0.0", False))
|
||||
self.assertFalse(result.enabled)
|
||||
self.assertFalse(result.restart_required)
|
||||
self.assertNotIn("netbox_example", (root / "configuration.py").read_text(encoding="utf-8"))
|
||||
requirement = (root / "local_requirements.txt").read_text(encoding="utf-8")
|
||||
self.assertIn("#sha256=", requirement)
|
||||
self.assertIn("--no-index", runner.calls[0][0])
|
||||
self.assertEqual(runner.calls[1][0][-2:], ["pip", "check"])
|
||||
|
||||
def test_uninstall_requires_explicit_disable_first(self):
|
||||
with tempfile.TemporaryDirectory() as temp_name:
|
||||
root = Path(temp_name)
|
||||
settings = runtime(root)
|
||||
settings.configuration_path.write_text(
|
||||
"PLUGINS = ['netbox_plugin_store', 'netbox_example']\n", encoding="utf-8"
|
||||
)
|
||||
runner = FakeRunner()
|
||||
service = LifecycleService(
|
||||
settings,
|
||||
FakeClient(catalog_plugin()),
|
||||
FakeRepository(),
|
||||
runner=runner,
|
||||
version_provider=lambda _: "1.0.0",
|
||||
)
|
||||
with self.assertRaises(LifecycleError):
|
||||
service.execute(LifecycleRequest("example-plugin", "uninstall", dry_run=False))
|
||||
self.assertEqual(runner.calls, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,50 @@
|
||||
import unittest
|
||||
|
||||
import _bootstrap # noqa: F401
|
||||
|
||||
from netbox_plugin_store.access import has_store_access
|
||||
from netbox_plugin_store.navigation import menu
|
||||
|
||||
|
||||
PERMISSION = "netbox_plugin_store.manage_plugin"
|
||||
|
||||
|
||||
class NetBoxUser:
|
||||
"""NetBox 4.6 users deliberately have no is_staff attribute."""
|
||||
|
||||
def __init__(self, *, authenticated=True, superuser=False, permissions=()):
|
||||
self.is_authenticated = authenticated
|
||||
self.is_superuser = superuser
|
||||
self.permissions = set(permissions)
|
||||
|
||||
def has_perm(self, permission_name):
|
||||
return permission_name in self.permissions
|
||||
|
||||
|
||||
class AccessCompatibilityTests(unittest.TestCase):
|
||||
def test_permission_user_without_is_staff_is_authorized(self):
|
||||
user = NetBoxUser(permissions=(PERMISSION,))
|
||||
self.assertTrue(has_store_access(user, PERMISSION))
|
||||
|
||||
def test_unauthenticated_and_unprivileged_users_are_rejected(self):
|
||||
self.assertFalse(
|
||||
has_store_access(NetBoxUser(authenticated=False, permissions=(PERMISSION,)), PERMISSION)
|
||||
)
|
||||
self.assertFalse(has_store_access(NetBoxUser(), PERMISSION))
|
||||
|
||||
def test_superuser_is_authorized_without_explicit_permission(self):
|
||||
self.assertTrue(has_store_access(NetBoxUser(superuser=True), PERMISSION))
|
||||
|
||||
|
||||
class NavigationCompatibilityTests(unittest.TestCase):
|
||||
def test_menu_uses_permission_not_staff_only(self):
|
||||
items = [item for _label, group_items in menu.groups for item in group_items]
|
||||
self.assertEqual(len(items), 3)
|
||||
for item in items:
|
||||
self.assertTrue(item.auth_required)
|
||||
self.assertFalse(item.staff_only)
|
||||
self.assertEqual(item.permissions, [PERMISSION])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user