Initial commit: Tailscale Funnel plugin for OctoPrint with build documentation

This commit is contained in:
Markus F.J. Busche
2025-09-20 13:29:20 +02:00
commit 87f0792eb7
18 changed files with 1724 additions and 0 deletions

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env python
# coding=utf-8
from __future__ import absolute_import
import unittest
import mock
from octoprint_tailscale_funnel import TailscaleFunnelPlugin
class TestTailscaleFunnelPlugin(unittest.TestCase):
def setUp(self):
self.plugin = TailscaleFunnelPlugin()
def test_plugin_name(self):
# Test that the plugin has the correct name
self.assertEqual(self.plugin._plugin_name, "Tailscale Funnel Plugin")
def test_get_settings_defaults(self):
# Test that the default settings are correct
defaults = self.plugin.get_settings_defaults()
self.assertIn("enabled", defaults)
self.assertIn("port", defaults)
self.assertIn("confirm_enable", defaults)
self.assertEqual(defaults["enabled"], False)
self.assertEqual(defaults["port"], 80)
self.assertEqual(defaults["confirm_enable"], True)
def test_get_assets(self):
# Test that the assets are correctly defined
assets = self.plugin.get_assets()
self.assertIn("js", assets)
self.assertIn("css", assets)
self.assertIn("less", assets)
self.assertIn("js/tailscale_funnel.js", assets["js"])
self.assertIn("css/tailscale_funnel.css", assets["css"])
self.assertIn("less/tailscale_funnel.less", assets["less"])
def test_get_template_configs(self):
# Test that the template configs are correctly defined
configs = self.plugin.get_template_configs()
self.assertIsInstance(configs, list)
self.assertEqual(len(configs), 1)
self.assertIn("type", configs[0])
self.assertIn("custom_bindings", configs[0])
self.assertEqual(configs[0]["type"], "settings")
self.assertEqual(configs[0]["custom_bindings"], False)
@mock.patch('octoprint_tailscale_funnel.TailscaleFunnelPlugin._run_command')
def test_get_update_information(self, mock_run_command):
# Setup
self.plugin._plugin_version = "1.0.0"
# Execute
update_info = self.plugin.get_update_information()
# Assert
self.assertIn("tailscale_funnel", update_info)
plugin_info = update_info["tailscale_funnel"]
self.assertEqual(plugin_info["displayName"], "Tailscale Funnel Plugin")
self.assertEqual(plugin_info["displayVersion"], "1.0.0")
self.assertEqual(plugin_info["type"], "github_release")
self.assertEqual(plugin_info["user"], "markus")
self.assertEqual(plugin_info["repo"], "OctoPrint-Tailscale-Funnel")
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env python
# coding=utf-8
from __future__ import absolute_import
import unittest
import mock
import threading
import time
from octoprint_tailscale_funnel.status_monitor import StatusMonitor
class TestStatusMonitor(unittest.TestCase):
def setUp(self):
# Create a mock plugin
self.mock_plugin = mock.Mock()
self.mock_plugin._logger = mock.Mock()
self.mock_plugin.tailscale_interface = mock.Mock()
self.mock_plugin._settings = mock.Mock()
self.mock_plugin._plugin_manager = mock.Mock()
self.status_monitor = StatusMonitor(self.mock_plugin, interval=0.1) # Short interval for testing
def test_init(self):
# Test that the status monitor is initialized correctly
self.assertEqual(self.status_monitor.plugin, self.mock_plugin)
self.assertEqual(self.status_monitor.interval, 0.1)
self.assertIsNone(self.status_monitor._thread)
def test_start(self):
# Test that start creates and starts a thread
self.status_monitor.start()
self.assertIsNotNone(self.status_monitor._thread)
self.assertTrue(self.status_monitor._thread.is_alive())
# Clean up
self.status_monitor.stop()
def test_stop(self):
# Test that stop stops the thread
self.status_monitor.start()
self.assertTrue(self.status_monitor._thread.is_alive())
self.status_monitor.stop()
self.assertIsNone(self.status_monitor._thread)
@mock.patch('octoprint_tailscale_funnel.status_monitor.time')
def test_monitor_loop(self, mock_time):
# Setup mocks
mock_time.sleep = mock.Mock() # Don't actually sleep during testing
# Mock the tailscale interface
self.mock_plugin.tailscale_interface.is_funnel_enabled.return_value = True
self.mock_plugin._settings.get_boolean.return_value = False # Different from funnel status
# Start the monitor
self.status_monitor.start()
# Give it a moment to run
time.sleep(0.2)
# Stop the monitor
self.status_monitor.stop()
# Assert that the methods were called
self.mock_plugin.tailscale_interface.is_funnel_enabled.assert_called()
self.mock_plugin._settings.set_boolean.assert_called_with(["enabled"], True)
self.mock_plugin._settings.save.assert_called()
self.mock_plugin._plugin_manager.send_plugin_message.assert_called()
if __name__ == '__main__':
unittest.main()

View File

@@ -0,0 +1,153 @@
#!/usr/bin/env python
# coding=utf-8
from __future__ import absolute_import
import unittest
import mock
from octoprint_tailscale_funnel.tailscale_interface import TailscaleInterface, TailscaleError
class TestTailscaleInterface(unittest.TestCase):
def setUp(self):
self.tailscale_interface = TailscaleInterface()
@mock.patch('octoprint_tailscale_funnel.tailscale_interface.subprocess.run')
def test_run_command_success(self, mock_run):
# Setup mock
mock_result = mock.Mock()
mock_result.returncode = 0
mock_result.stdout = "command output"
mock_result.stderr = ""
mock_run.return_value = mock_result
# Execute
result = self.tailscale_interface._run_command("test command")
# Assert
self.assertTrue(result["success"])
self.assertEqual(result["output"], "command output")
self.assertIsNone(result["error"])
mock_run.assert_called_once_with("test command", shell=True, capture_output=True, text=True, timeout=30)
@mock.patch('octoprint_tailscale_funnel.tailscale_interface.subprocess.run')
def test_run_command_failure(self, mock_run):
# Setup mock
mock_result = mock.Mock()
mock_result.returncode = 1
mock_result.stdout = ""
mock_result.stderr = "error message"
mock_run.return_value = mock_result
# Execute
result = self.tailscale_interface._run_command("test command")
# Assert
self.assertFalse(result["success"])
self.assertIsNone(result["output"])
self.assertEqual(result["error"], "error message")
@mock.patch('octoprint_tailscale_funnel.tailscale_interface.subprocess.run')
def test_is_tailscale_installed_true(self, mock_run):
# Setup mock
mock_result = mock.Mock()
mock_result.returncode = 0
mock_result.stdout = "/usr/bin/tailscale"
mock_result.stderr = ""
mock_run.return_value = mock_result
# Execute
result = self.tailscale_interface.is_tailscale_installed()
# Assert
self.assertTrue(result)
@mock.patch('octoprint_tailscale_funnel.tailscale_interface.subprocess.run')
def test_is_tailscale_installed_false(self, mock_run):
# Setup mock
mock_result = mock.Mock()
mock_result.returncode = 1
mock_result.stdout = ""
mock_result.stderr = "which: no tailscale in (/usr/bin:/bin)"
mock_run.return_value = mock_result
# Execute
result = self.tailscale_interface.is_tailscale_installed()
# Assert
self.assertFalse(result)
@mock.patch('octoprint_tailscale_funnel.tailscale_interface.TailscaleInterface.is_tailscale_installed')
@mock.patch('octoprint_tailscale_funnel.tailscale_interface.subprocess.run')
def test_is_tailscale_running_true(self, mock_run, mock_installed):
# Setup mocks
mock_installed.return_value = True
mock_result = mock.Mock()
mock_result.returncode = 0
mock_result.stdout = '{"Status": "running"}'
mock_result.stderr = ""
mock_run.return_value = mock_result
# Execute
result = self.tailscale_interface.is_tailscale_running()
# Assert
self.assertTrue(result)
@mock.patch('octoprint_tailscale_funnel.tailscale_interface.TailscaleInterface.is_tailscale_installed')
@mock.patch('octoprint_tailscale_funnel.tailscale_interface.subprocess.run')
def test_is_tailscale_running_false(self, mock_run, mock_installed):
# Setup mocks
mock_installed.return_value = True
mock_result = mock.Mock()
mock_result.returncode = 1
mock_result.stdout = ""
mock_result.stderr = "not running"
mock_run.return_value = mock_result
# Execute
result = self.tailscale_interface.is_tailscale_running()
# Assert
self.assertFalse(result)
@mock.patch('octoprint_tailscale_funnel.tailscale_interface.TailscaleInterface.is_tailscale_installed')
@mock.patch('octoprint_tailscale_funnel.tailscale_interface.subprocess.run')
def test_enable_funnel_success(self, mock_run, mock_installed):
# Setup mocks
mock_installed.return_value = True
mock_result = mock.Mock()
mock_result.returncode = 0
mock_result.stdout = ""
mock_result.stderr = ""
mock_run.return_value = mock_result
# Execute
result = self.tailscale_interface.enable_funnel(80)
# Assert
self.assertTrue(result)
mock_run.assert_called_once_with("tailscale funnel 80 on", shell=True, capture_output=True, text=True, timeout=30)
@mock.patch('octoprint_tailscale_funnel.tailscale_interface.TailscaleInterface.is_tailscale_installed')
@mock.patch('octoprint_tailscale_funnel.tailscale_interface.subprocess.run')
def test_disable_funnel_success(self, mock_run, mock_installed):
# Setup mocks
mock_installed.return_value = True
mock_result = mock.Mock()
mock_result.returncode = 0
mock_result.stdout = ""
mock_result.stderr = ""
mock_run.return_value = mock_result
# Execute
result = self.tailscale_interface.disable_funnel(80)
# Assert
self.assertTrue(result)
mock_run.assert_called_once_with("tailscale funnel 80 off", shell=True, capture_output=True, text=True, timeout=30)
if __name__ == '__main__':
unittest.main()