Fix Huawei WebCRT hop login echo doubling and safer Change-now handling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-28 23:34:15 +08:00
parent cacd8c7b75
commit 0a550abcb0
9 changed files with 498 additions and 77 deletions

View file

@ -280,7 +280,9 @@ class BastionConnectImplTests(unittest.TestCase):
password="bastion-pass",
timeout=unittest.mock.ANY,
)
interact.assert_called_once_with(conn, "target-user", "target-pass", progress_cb=None)
interact.assert_called_once_with(
conn, "target-user", "target-pass", progress_cb=None, emit_raw=True
)
class BastionInteractiveHandlerTests(unittest.TestCase):

View file

@ -131,6 +131,30 @@ class HuaweiStelnetAuthTests(unittest.TestCase):
sent = [c.args[1] for c in mock_send.call_args_list]
self.assertEqual(sent, ["ipran", "Y", "N", "secret"])
@patch("netx_api.ne_session_connect._read_channel")
@patch("netx_api.ne_session_connect._send_line")
def test_answers_password_change_after_login(
self,
mock_send: MagicMock,
mock_read: MagicMock,
) -> None:
"""Huawei ``Change now? [Y/N]:`` after password must not hang auth until timeout."""
chunks = [
"Please input the username:",
"Enter password:",
"Change now? [Y/N]:",
"<HW-TARGET>\n",
]
mock_read.side_effect = lambda *_a, **_k: chunks.pop(0) if chunks else ""
conn = MagicMock()
seen: list[str] = []
_interactive_target_auth(conn, "admin", "secret", progress_cb=seen.append)
self.assertEqual(
[c.args[1] for c in mock_send.call_args_list],
["admin", "secret", "N"],
)
self.assertTrue(any("password-change" in p for p in seen))
if __name__ == "__main__":
unittest.main()

View file

@ -108,9 +108,27 @@ class WebcrtServiceTests(unittest.TestCase):
self.assertTrue(svc._looks_like_cli_prompt("<r1>"))
# Stray ':' after Huawei prompt must still count as prompted (no extra Enter).
self.assertTrue(svc._looks_like_cli_prompt("<r1>:"))
self.assertTrue(svc._looks_like_cli_prompt("<r1>N"))
# Trailing [netx] progress lines must not hide an already-visible CLI prompt.
self.assertTrue(svc._looks_like_cli_prompt("<HW>\r\n[netx] password-change → N\r\n"))
self.assertEqual(svc.prepare_bootstrap_output("banner\n<r1>:"), "banner\n<r1>")
self.assertTrue(svc._looks_like_password_change_prompt("Change now? [Y/N]:"))
self.assertFalse(svc._looks_like_password_change_prompt("Change now? [Y/N]:N"))
# Still pending only while sitting on Change-now with no answer/prompt after.
self.assertTrue(svc._password_change_still_pending("Change now? [Y/N]:"))
# Netmiko already sent N — do not send a second N (would become <r1>N).
self.assertFalse(
svc._password_change_still_pending(
"Change now? [Y/N]:\nN\nInfo: VTY\n<r1>"
)
)
self.assertFalse(svc._password_change_still_pending("Change now? [Y/N]:\nN"))
self.assertFalse(
svc._password_change_still_pending(
"The password needs to be changed. Change now? [Y/N]:\n"
"Info: The max number of VTY users is 5\n<r1>"
)
)
# Bare / stelnet host-key [Y/N] must not be treated as password-change.
self.assertFalse(svc._looks_like_password_change_prompt("[Y/N]:"))
self.assertFalse(
@ -173,9 +191,10 @@ class WebcrtServiceTests(unittest.TestCase):
)
sess = svc.get_session(out["session_id"])
assert sess is not None
boot = sess.bootstrap_output.decode("utf-8", errors="replace")
self.assertIn("****", boot)
self.assertIn("R2#", boot)
# Live connect-echo owns the login transcript (bootstrap stays empty to avoid double play).
echo = sess.connect_echo_text()
self.assertIn("****", echo)
self.assertIn("R2#", echo)
svc.close_session(out["session_id"], reason="test")
@patch.object(reg, "_audit")
@ -318,9 +337,9 @@ class WebcrtServiceTests(unittest.TestCase):
self.assertFalse(out.get("cli_hop")) # bastion hop is not vendor CLI hop guard
sess = svc.get_session(out["session_id"])
assert sess is not None
boot = sess.bootstrap_output.decode("utf-8", errors="replace")
self.assertIn("Username:huawei", boot)
self.assertIn("<r1>", boot)
echo = sess.connect_echo_text()
self.assertIn("Username:huawei", echo)
self.assertIn("<r1>", echo)
self.assertFalse(sess.needs_live_prompt)
before = list(fake.written)
sess.write_stdin("\n")