mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 04:20:45 +08:00
feat(webcrt): add CRT-style browser terminal for managed and UME NEs
Ship an ops WebCRT module with xterm.js UI, WebSocket session bridge reusing hop/bastion login, searchable paged targets, and session audit limits. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
88479245bb
commit
873f2bfe34
21 changed files with 1868 additions and 45 deletions
185
tests/test_webcrt.py
Normal file
185
tests/test_webcrt.py
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
"""Unit tests for WebCRT session service (mocked device connection)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from netx_api import webcrt_service as svc
|
||||
|
||||
|
||||
class _FakeConn:
|
||||
def __init__(self) -> None:
|
||||
self.written: list[str] = []
|
||||
self.remote_conn = MagicMock()
|
||||
self.remote_conn.recv_ready.return_value = False
|
||||
self.remote_conn.exit_status_ready.return_value = False
|
||||
self.remote_conn.resize_pty = MagicMock()
|
||||
|
||||
def write_channel(self, data: str) -> None:
|
||||
self.written.append(data)
|
||||
|
||||
def read_channel(self) -> str:
|
||||
return ""
|
||||
|
||||
def disconnect(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
class WebcrtServiceTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
with svc._sessions_lock:
|
||||
for s in list(svc._sessions.values()):
|
||||
s.close("test_cleanup")
|
||||
svc._sessions.clear()
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.setUp()
|
||||
|
||||
def test_session_write_resize_and_close(self) -> None:
|
||||
conn = _FakeConn()
|
||||
sess = svc.WebcrtSession(
|
||||
session_id="s1",
|
||||
ne_id="ne1",
|
||||
ne_name="lab",
|
||||
ne_ip="1.2.3.4",
|
||||
protocol="ssh",
|
||||
cols=80,
|
||||
rows=24,
|
||||
conn=conn, # type: ignore[arg-type]
|
||||
)
|
||||
sess.write_stdin("show ver\n")
|
||||
self.assertEqual(conn.written, ["show ver\n"])
|
||||
sess.resize(120, 40)
|
||||
conn.remote_conn.resize_pty.assert_called_with(width=120, height=40)
|
||||
sess.close("test")
|
||||
self.assertTrue(sess.closed)
|
||||
|
||||
@patch.object(svc, "_audit")
|
||||
@patch.object(svc, "open_netmiko_connection")
|
||||
@patch("netx_api.cli_resolve.resolve_cli_target")
|
||||
def test_create_session_limit(
|
||||
self,
|
||||
mock_resolve: MagicMock,
|
||||
mock_open: MagicMock,
|
||||
_mock_audit: MagicMock,
|
||||
) -> None:
|
||||
mock_resolve.return_value = (
|
||||
{
|
||||
"username": "u",
|
||||
"password": "p",
|
||||
"hop_enabled": False,
|
||||
"ip_address": "10.0.0.1",
|
||||
"protocol": "ssh",
|
||||
},
|
||||
{
|
||||
"id": "ne-a",
|
||||
"name": "A",
|
||||
"ip_address": "10.0.0.1",
|
||||
"protocol": "ssh",
|
||||
"source": "managed",
|
||||
},
|
||||
)
|
||||
mock_open.side_effect = lambda *a, **k: _FakeConn()
|
||||
db = MagicMock()
|
||||
|
||||
with patch.object(svc.settings, "webcrt_max_sessions", 1):
|
||||
out = svc.create_session(db, ne_id="ne-a", cols=80, rows=24, client="test")
|
||||
self.assertIn("session_id", out)
|
||||
with self.assertRaises(HTTPException) as ctx:
|
||||
svc.create_session(db, ne_id="ne-a", cols=80, rows=24, client="test")
|
||||
self.assertEqual(ctx.exception.status_code, 429)
|
||||
|
||||
@patch.object(svc, "_audit")
|
||||
@patch.object(svc, "open_netmiko_connection")
|
||||
@patch("netx_api.cli_resolve.resolve_cli_target")
|
||||
def test_create_session_passes_hop_creds(
|
||||
self,
|
||||
mock_resolve: MagicMock,
|
||||
mock_open: MagicMock,
|
||||
_mock_audit: MagicMock,
|
||||
) -> None:
|
||||
mock_resolve.return_value = (
|
||||
{
|
||||
"username": "u",
|
||||
"password": "p",
|
||||
"hop_enabled": True,
|
||||
"hop_vendor": "bastion",
|
||||
"hop_host": "jump.example",
|
||||
"ip_address": "10.0.0.2",
|
||||
"protocol": "ssh",
|
||||
},
|
||||
{
|
||||
"id": "ne-hop",
|
||||
"name": "HopNE",
|
||||
"ip_address": "10.0.0.2",
|
||||
"protocol": "ssh",
|
||||
"source": "managed",
|
||||
},
|
||||
)
|
||||
fake = _FakeConn()
|
||||
mock_open.return_value = fake
|
||||
|
||||
db = MagicMock()
|
||||
out = svc.create_session(db, ne_id="ne-hop", cols=100, rows=30, client="test")
|
||||
mock_open.assert_called_once()
|
||||
called_creds = mock_open.call_args.args[0]
|
||||
self.assertTrue(called_creds["hop_enabled"])
|
||||
self.assertEqual(called_creds["hop_vendor"], "bastion")
|
||||
fake.remote_conn.resize_pty.assert_called()
|
||||
self.assertEqual(out["ne_id"], "ne-hop")
|
||||
sess = svc.get_session(out["session_id"])
|
||||
assert sess is not None
|
||||
sess.write_stdin("\n")
|
||||
self.assertEqual(fake.written, ["\n"])
|
||||
svc.close_session(out["session_id"], reason="test")
|
||||
|
||||
@patch.object(svc, "_audit")
|
||||
def test_attach_timeout_reaper(self, _mock_audit: MagicMock) -> None:
|
||||
conn = _FakeConn()
|
||||
sess = svc.WebcrtSession(
|
||||
session_id="stale",
|
||||
ne_id="ne1",
|
||||
ne_name="lab",
|
||||
ne_ip="1.2.3.4",
|
||||
protocol="ssh",
|
||||
cols=80,
|
||||
rows=24,
|
||||
conn=conn, # type: ignore[arg-type]
|
||||
)
|
||||
sess.created_at = time.time() - 120
|
||||
with svc._sessions_lock:
|
||||
svc._sessions["stale"] = sess
|
||||
with patch.object(svc.settings, "webcrt_attach_timeout_sec", 30):
|
||||
with patch.object(svc.settings, "webcrt_idle_timeout_sec", 99999):
|
||||
svc._reap_sessions()
|
||||
self.assertIsNone(svc.get_session("stale"))
|
||||
|
||||
@patch.object(svc, "_audit")
|
||||
def test_idle_timeout_reaper(self, _mock_audit: MagicMock) -> None:
|
||||
conn = _FakeConn()
|
||||
sess = svc.WebcrtSession(
|
||||
session_id="idle",
|
||||
ne_id="ne1",
|
||||
ne_name="lab",
|
||||
ne_ip="1.2.3.4",
|
||||
protocol="ssh",
|
||||
cols=80,
|
||||
rows=24,
|
||||
conn=conn, # type: ignore[arg-type]
|
||||
)
|
||||
sess.attached = True
|
||||
sess.last_activity = time.time() - 9999
|
||||
with svc._sessions_lock:
|
||||
svc._sessions["idle"] = sess
|
||||
with patch.object(svc.settings, "webcrt_attach_timeout_sec", 99999):
|
||||
with patch.object(svc.settings, "webcrt_idle_timeout_sec", 60):
|
||||
svc._reap_sessions()
|
||||
self.assertIsNone(svc.get_session("idle"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue