test_spoolbuddy_ssh.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719
  1. """Unit tests for SpoolBuddy SSH update service."""
  2. import asyncio
  3. import os
  4. from unittest.mock import AsyncMock, MagicMock, patch
  5. import pytest
  6. from backend.app.services.spoolbuddy_ssh import (
  7. _get_ssh_key_dir,
  8. _run_ssh_command,
  9. detect_current_branch,
  10. get_or_create_keypair,
  11. get_public_key,
  12. perform_ssh_update,
  13. )
  14. # -- _get_ssh_key_dir ---------------------------------------------------------
  15. def test_get_ssh_key_dir_creates_directory(tmp_path):
  16. with patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings:
  17. mock_settings.base_dir = tmp_path
  18. key_dir = _get_ssh_key_dir()
  19. assert key_dir == tmp_path / "spoolbuddy" / "ssh"
  20. assert key_dir.exists()
  21. def test_get_ssh_key_dir_returns_existing(tmp_path):
  22. ssh_dir = tmp_path / "spoolbuddy" / "ssh"
  23. ssh_dir.mkdir(parents=True)
  24. with patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings:
  25. mock_settings.base_dir = tmp_path
  26. assert _get_ssh_key_dir() == ssh_dir
  27. # -- get_or_create_keypair -----------------------------------------------------
  28. @pytest.mark.asyncio
  29. async def test_get_or_create_keypair_returns_existing(tmp_path):
  30. ssh_dir = tmp_path / "spoolbuddy" / "ssh"
  31. ssh_dir.mkdir(parents=True)
  32. priv = ssh_dir / "id_ed25519"
  33. pub = ssh_dir / "id_ed25519.pub"
  34. priv.write_text("PRIVATE")
  35. pub.write_text("PUBLIC")
  36. with patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings:
  37. mock_settings.base_dir = tmp_path
  38. result = await get_or_create_keypair()
  39. assert result == (priv, pub)
  40. @pytest.mark.asyncio
  41. async def test_get_or_create_keypair_generates_new(tmp_path):
  42. """Key generation runs in-process via `cryptography` — no ssh-keygen subprocess.
  43. This matters in Docker: when the container runs under an arbitrary PUID
  44. that isn't in /etc/passwd, `ssh-keygen` aborts with "no user exists for uid
  45. <N>". Generating the keypair in-process avoids the getpwuid() lookup.
  46. """
  47. from cryptography.hazmat.primitives import serialization
  48. from cryptography.hazmat.primitives.asymmetric import ed25519
  49. with patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings:
  50. mock_settings.base_dir = tmp_path
  51. priv, pub = await get_or_create_keypair()
  52. assert priv.exists()
  53. assert pub.exists()
  54. # Private key permissions — no world/group access
  55. assert (priv.stat().st_mode & 0o077) == 0
  56. # Public key is a valid OpenSSH ed25519 key with our comment
  57. pub_text = pub.read_text()
  58. assert pub_text.startswith("ssh-ed25519 ")
  59. assert pub_text.rstrip().endswith("bambuddy-spoolbuddy")
  60. # Private key is a valid OpenSSH-format ed25519 key we can load back
  61. loaded = serialization.load_ssh_private_key(priv.read_bytes(), password=None)
  62. assert isinstance(loaded, ed25519.Ed25519PrivateKey)
  63. @pytest.mark.asyncio
  64. async def test_get_or_create_keypair_does_not_shell_out(tmp_path):
  65. """Regression guard: must not invoke any subprocess (fixes Docker PUID bug)."""
  66. with (
  67. patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
  68. patch("asyncio.create_subprocess_exec") as mock_exec,
  69. ):
  70. mock_settings.base_dir = tmp_path
  71. await get_or_create_keypair()
  72. mock_exec.assert_not_called()
  73. # -- get_public_key ------------------------------------------------------------
  74. @pytest.mark.asyncio
  75. async def test_get_public_key(tmp_path):
  76. ssh_dir = tmp_path / "spoolbuddy" / "ssh"
  77. ssh_dir.mkdir(parents=True)
  78. (ssh_dir / "id_ed25519").write_text("PRIVATE")
  79. (ssh_dir / "id_ed25519.pub").write_text("ssh-ed25519 AAAA bambuddy-spoolbuddy\n")
  80. with patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings:
  81. mock_settings.base_dir = tmp_path
  82. key = await get_public_key()
  83. assert key == "ssh-ed25519 AAAA bambuddy-spoolbuddy"
  84. # -- detect_current_branch ----------------------------------------------------
  85. def test_detect_branch_from_git_head(tmp_path):
  86. """Read branch directly from .git/HEAD in the application root — no subprocess."""
  87. git_dir = tmp_path / ".git"
  88. git_dir.mkdir()
  89. (git_dir / "HEAD").write_text("ref: refs/heads/dev\n")
  90. with (
  91. patch("backend.app.services.spoolbuddy_ssh._APP_DIR", tmp_path),
  92. patch("asyncio.create_subprocess_exec") as mock_exec,
  93. patch("subprocess.run") as mock_run,
  94. ):
  95. assert detect_current_branch() == "dev"
  96. # Regression guard: must not shell out (fails with getpwuid under
  97. # arbitrary Docker PUIDs if ever reintroduced).
  98. mock_exec.assert_not_called()
  99. mock_run.assert_not_called()
  100. def test_detect_branch_uses_app_dir_not_data_dir(tmp_path):
  101. """Branch detection must look in the application root, not the data dir.
  102. Regression guard for the Docker bug where `.git` was being looked up in
  103. `settings.base_dir` (which is `DATA_DIR=/app/data` in Docker), so it was
  104. never found and the fallback always returned "main" — even when the user
  105. was on a feature branch bind-mounted at `/app`.
  106. """
  107. app_dir = tmp_path / "app"
  108. data_dir = tmp_path / "app" / "data"
  109. app_dir.mkdir()
  110. data_dir.mkdir()
  111. # Real .git lives at the application root (bind-mount style).
  112. (app_dir / ".git").mkdir()
  113. (app_dir / ".git" / "HEAD").write_text("ref: refs/heads/dev\n")
  114. # Decoy .git in the data dir — if the code ever regresses to reading
  115. # from settings.base_dir, this would be returned instead.
  116. (data_dir / ".git").mkdir()
  117. (data_dir / ".git" / "HEAD").write_text("ref: refs/heads/wrong-branch\n")
  118. with (
  119. patch("backend.app.services.spoolbuddy_ssh._APP_DIR", app_dir),
  120. patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
  121. ):
  122. mock_settings.base_dir = data_dir
  123. assert detect_current_branch() == "dev"
  124. def test_detect_branch_worktree_gitdir_file(tmp_path):
  125. """Git worktrees store a `gitdir:` pointer instead of a dir — follow it."""
  126. real_git_dir = tmp_path / "real-git"
  127. real_git_dir.mkdir()
  128. (real_git_dir / "HEAD").write_text("ref: refs/heads/feature-x\n")
  129. (tmp_path / ".git").write_text(f"gitdir: {real_git_dir}\n")
  130. with patch("backend.app.services.spoolbuddy_ssh._APP_DIR", tmp_path):
  131. assert detect_current_branch() == "feature-x"
  132. def test_detect_branch_detached_head_falls_back(tmp_path):
  133. """Detached HEAD (raw commit hash) should fall through to the env var."""
  134. git_dir = tmp_path / ".git"
  135. git_dir.mkdir()
  136. (git_dir / "HEAD").write_text("deadbeef1234\n")
  137. with (
  138. patch("backend.app.services.spoolbuddy_ssh._APP_DIR", tmp_path),
  139. patch.dict(os.environ, {"GIT_BRANCH": "release"}),
  140. ):
  141. assert detect_current_branch() == "release"
  142. def test_detect_branch_env_fallback(tmp_path):
  143. with (
  144. patch("backend.app.services.spoolbuddy_ssh._APP_DIR", tmp_path),
  145. patch.dict(os.environ, {"GIT_BRANCH": "staging"}),
  146. ):
  147. assert detect_current_branch() == "staging"
  148. def test_detect_branch_default_main(tmp_path):
  149. with (
  150. patch("backend.app.services.spoolbuddy_ssh._APP_DIR", tmp_path),
  151. patch.dict(os.environ, {}, clear=True),
  152. ):
  153. # Remove GIT_BRANCH if present
  154. os.environ.pop("GIT_BRANCH", None)
  155. assert detect_current_branch() == "main"
  156. # -- _run_ssh_command ----------------------------------------------------------
  157. #
  158. # _run_ssh_command uses asyncssh (pure Python) rather than the OpenSSH `ssh`
  159. # binary. Both `ssh` and `ssh-keygen` call getpwuid(getuid()) during startup
  160. # and abort with "No user exists for uid <N>" when the container runs under
  161. # an arbitrary PUID that is not listed in /etc/passwd — asyncssh avoids the
  162. # subprocess entirely.
  163. @pytest.mark.asyncio
  164. async def test_run_ssh_command_success(tmp_path):
  165. key_file = tmp_path / "key"
  166. key_file.write_text("KEY")
  167. mock_result = MagicMock()
  168. mock_result.stdout = "hello\n"
  169. mock_result.stderr = ""
  170. mock_result.exit_status = 0
  171. mock_server_key = MagicMock()
  172. mock_server_key.export_public_key.return_value = b"ssh-ed25519 AAAA test"
  173. mock_conn = AsyncMock()
  174. mock_conn.run = AsyncMock(return_value=mock_result)
  175. mock_conn.get_server_host_key = MagicMock(return_value=mock_server_key)
  176. mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
  177. mock_conn.__aexit__ = AsyncMock(return_value=False)
  178. with patch("backend.app.services.spoolbuddy_ssh.asyncssh.connect", return_value=mock_conn) as mock_connect:
  179. rc, stdout, stderr, observed_key = await _run_ssh_command("10.0.0.1", "echo hello", key_file)
  180. assert rc == 0
  181. assert stdout == "hello\n"
  182. assert stderr == ""
  183. # TOFU mode (no known_hosts): returns observed key
  184. assert observed_key == "ssh-ed25519 AAAA test"
  185. kwargs = mock_connect.call_args.kwargs
  186. assert kwargs["host"] == "10.0.0.1"
  187. assert kwargs["username"] == "spoolbuddy"
  188. assert kwargs["client_keys"] == [str(key_file)]
  189. # TOFU default: known_hosts=None on first connect
  190. assert kwargs["known_hosts"] is None
  191. # ~/.ssh/config loading is disabled — HOME may not resolve under arbitrary Docker PUIDs
  192. assert kwargs["config"] == []
  193. mock_conn.run.assert_awaited_once()
  194. run_args = mock_conn.run.call_args
  195. assert run_args.args[0] == "echo hello"
  196. # check=False — we handle non-zero exit codes ourselves
  197. assert run_args.kwargs.get("check") is False
  198. @pytest.mark.asyncio
  199. async def test_run_ssh_command_with_known_hosts_skips_capture(tmp_path):
  200. """When known_hosts is provided, observed_host_key must be None."""
  201. import asyncssh
  202. key_file = tmp_path / "key"
  203. key_file.write_text("KEY")
  204. mock_result = MagicMock()
  205. mock_result.stdout = ""
  206. mock_result.stderr = ""
  207. mock_result.exit_status = 0
  208. mock_conn = AsyncMock()
  209. mock_conn.run = AsyncMock(return_value=mock_result)
  210. mock_conn.get_server_host_key = MagicMock()
  211. mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
  212. mock_conn.__aexit__ = AsyncMock(return_value=False)
  213. fake_kh = MagicMock(spec=asyncssh.SSHKnownHosts)
  214. with patch("backend.app.services.spoolbuddy_ssh.asyncssh.connect", return_value=mock_conn):
  215. rc, _, _, observed_key = await _run_ssh_command("10.0.0.1", "echo hi", key_file, known_hosts=fake_kh)
  216. assert rc == 0
  217. assert observed_key is None
  218. mock_conn.get_server_host_key.assert_not_called()
  219. @pytest.mark.asyncio
  220. async def test_run_ssh_command_host_key_mismatch(tmp_path):
  221. """HostKeyNotVerifiable must surface as rc=255 with a safe message (H1)."""
  222. import asyncssh
  223. key_file = tmp_path / "key"
  224. key_file.write_text("KEY")
  225. with patch(
  226. "backend.app.services.spoolbuddy_ssh.asyncssh.connect",
  227. side_effect=asyncssh.HostKeyNotVerifiable(asyncssh.DISC_HOST_KEY_NOT_VERIFIABLE, "key mismatch"),
  228. ):
  229. rc, _, stderr, observed_key = await _run_ssh_command("10.0.0.1", "echo hello", key_file)
  230. assert rc == 255
  231. assert "mismatch" in stderr.lower()
  232. assert observed_key is None
  233. @pytest.mark.asyncio
  234. async def test_run_ssh_command_no_subprocess(tmp_path):
  235. """Regression guard: _run_ssh_command must not spawn any subprocess."""
  236. key_file = tmp_path / "key"
  237. key_file.write_text("KEY")
  238. mock_result = MagicMock()
  239. mock_result.stdout = ""
  240. mock_result.stderr = ""
  241. mock_result.exit_status = 0
  242. mock_conn = AsyncMock()
  243. mock_conn.run = AsyncMock(return_value=mock_result)
  244. mock_conn.get_server_host_key = MagicMock(return_value=None)
  245. mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
  246. mock_conn.__aexit__ = AsyncMock(return_value=False)
  247. with (
  248. patch("backend.app.services.spoolbuddy_ssh.asyncssh.connect", return_value=mock_conn),
  249. patch("asyncio.create_subprocess_exec") as mock_exec,
  250. ):
  251. await _run_ssh_command("10.0.0.1", "echo hi", key_file)
  252. mock_exec.assert_not_called()
  253. @pytest.mark.asyncio
  254. async def test_run_ssh_command_connection_failure(tmp_path):
  255. """Connection errors should surface as rc=255 with the asyncssh message."""
  256. import asyncssh
  257. key_file = tmp_path / "key"
  258. key_file.write_text("KEY")
  259. with patch(
  260. "backend.app.services.spoolbuddy_ssh.asyncssh.connect",
  261. side_effect=asyncssh.Error(code=0, reason="Connection refused"),
  262. ):
  263. rc, stdout, stderr, _ = await _run_ssh_command("10.0.0.1", "echo hello", key_file)
  264. assert rc == 255
  265. assert stdout == ""
  266. assert "Connection refused" in stderr
  267. @pytest.mark.asyncio
  268. async def test_run_ssh_command_os_error(tmp_path):
  269. """OS-level connection errors (DNS, route) also map to rc=255."""
  270. key_file = tmp_path / "key"
  271. key_file.write_text("KEY")
  272. with patch(
  273. "backend.app.services.spoolbuddy_ssh.asyncssh.connect",
  274. side_effect=OSError("Network is unreachable"),
  275. ):
  276. rc, _, stderr, _ = await _run_ssh_command("10.0.0.1", "echo hello", key_file)
  277. assert rc == 255
  278. assert "Network is unreachable" in stderr
  279. @pytest.mark.asyncio
  280. async def test_run_ssh_command_timeout(tmp_path):
  281. """asyncio.timeout should convert long-running commands into rc=-1."""
  282. key_file = tmp_path / "key"
  283. key_file.write_text("KEY")
  284. mock_conn = AsyncMock()
  285. async def hang_enter():
  286. await asyncio.sleep(10)
  287. mock_conn.__aenter__ = AsyncMock(side_effect=hang_enter)
  288. mock_conn.__aexit__ = AsyncMock(return_value=False)
  289. with patch("backend.app.services.spoolbuddy_ssh.asyncssh.connect", return_value=mock_conn):
  290. rc, _, stderr, _ = await _run_ssh_command("10.0.0.1", "sleep 999", key_file, timeout=0.05)
  291. assert rc == -1
  292. assert "timed out" in stderr
  293. # -- perform_ssh_update --------------------------------------------------------
  294. def _make_update_mocks(tmp_path):
  295. """Create common mocks for perform_ssh_update tests."""
  296. mock_db_device = MagicMock()
  297. mock_db_device.update_status = None
  298. mock_db_device.update_message = None
  299. mock_db_device.pending_command = None
  300. mock_db_device.ssh_host_key = None # TOFU: no stored key
  301. mock_result = MagicMock()
  302. mock_result.scalar_one_or_none.return_value = mock_db_device
  303. mock_session = AsyncMock()
  304. mock_session.execute = AsyncMock(return_value=mock_result)
  305. mock_session.commit = AsyncMock()
  306. mock_ctx = AsyncMock()
  307. mock_ctx.__aenter__ = AsyncMock(return_value=mock_session)
  308. mock_ctx.__aexit__ = AsyncMock(return_value=False)
  309. mock_ws = MagicMock()
  310. mock_ws.broadcast = AsyncMock()
  311. return mock_db_device, mock_ctx, mock_ws
  312. @pytest.mark.asyncio
  313. async def test_perform_ssh_update_success(tmp_path):
  314. """Full update flow: all SSH commands succeed."""
  315. ssh_dir = tmp_path / "spoolbuddy" / "ssh"
  316. ssh_dir.mkdir(parents=True)
  317. (ssh_dir / "id_ed25519").write_text("PRIVATE")
  318. (ssh_dir / "id_ed25519.pub").write_text("PUBLIC")
  319. ssh_calls = []
  320. async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60):
  321. ssh_calls.append(cmd)
  322. return 0, "ok", "", "ssh-ed25519 AAAA fakehostkey"
  323. _, mock_ctx, mock_ws = _make_update_mocks(tmp_path)
  324. with (
  325. patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
  326. patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh),
  327. patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value="dev"),
  328. patch("backend.app.services.spoolbuddy_ssh.asyncssh.import_known_hosts", return_value=MagicMock()),
  329. patch("backend.app.core.database.async_session", return_value=mock_ctx),
  330. patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws),
  331. ):
  332. mock_settings.base_dir = tmp_path
  333. await perform_ssh_update("sb-test", "10.0.0.1")
  334. # Should have run: echo ok, git fetch, git checkout+reset, pip install,
  335. # systemctl restart, find (SW cleanup), systemctl restart getty
  336. assert len(ssh_calls) == 7
  337. assert "echo ok" in ssh_calls[0]
  338. assert "fetch" in ssh_calls[1]
  339. assert "checkout" in ssh_calls[2]
  340. assert "pip" in ssh_calls[3]
  341. assert "spoolbuddy.service" in ssh_calls[4]
  342. assert "Service Worker" in ssh_calls[5]
  343. assert "getty" in ssh_calls[6]
  344. assert mock_ws.broadcast.call_count >= 4
  345. @pytest.mark.asyncio
  346. async def test_perform_ssh_update_branch_is_shell_quoted(tmp_path):
  347. """Branch name with shell-special chars must be quoted in all git commands (L1 fix)."""
  348. import shlex
  349. ssh_dir = tmp_path / "spoolbuddy" / "ssh"
  350. ssh_dir.mkdir(parents=True)
  351. (ssh_dir / "id_ed25519").write_text("PRIVATE")
  352. (ssh_dir / "id_ed25519.pub").write_text("PUBLIC")
  353. # A branch name containing a semicolon — shell-injection without quoting
  354. dangerous_branch = "dev; echo pwned"
  355. safe_branch = shlex.quote(dangerous_branch) # expected: "'dev; echo pwned'"
  356. ssh_calls = []
  357. async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60):
  358. ssh_calls.append(cmd)
  359. return 0, "ok", "", None
  360. _, mock_ctx, mock_ws = _make_update_mocks(tmp_path)
  361. with (
  362. patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
  363. patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh),
  364. patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value=dangerous_branch),
  365. patch("backend.app.services.spoolbuddy_ssh.asyncssh.import_known_hosts", return_value=MagicMock()),
  366. patch("backend.app.core.database.async_session", return_value=mock_ctx),
  367. patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws),
  368. ):
  369. mock_settings.base_dir = tmp_path
  370. await perform_ssh_update("sb-test", "10.0.0.1")
  371. # All git commands must use the shell-quoted form, never the raw dangerous string
  372. git_cmds = [c for c in ssh_calls if "fetch" in c or "checkout" in c or "reset" in c]
  373. for cmd in git_cmds:
  374. assert safe_branch in cmd, f"Branch not shell-quoted in: {cmd}"
  375. assert dangerous_branch not in cmd.replace(safe_branch, ""), f"Raw dangerous branch in: {cmd}"
  376. @pytest.mark.asyncio
  377. async def test_perform_ssh_update_tofu_stores_host_key(tmp_path):
  378. """On first connect (no stored key), the observed host key must be persisted (H1)."""
  379. ssh_dir = tmp_path / "spoolbuddy" / "ssh"
  380. ssh_dir.mkdir(parents=True)
  381. (ssh_dir / "id_ed25519").write_text("PRIVATE")
  382. (ssh_dir / "id_ed25519.pub").write_text("PUBLIC")
  383. FAKE_HOST_KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 fakehostkey"
  384. call_count = 0
  385. async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60):
  386. nonlocal call_count
  387. call_count += 1
  388. # Only first call returns the observed host key (TOFU)
  389. observed = FAKE_HOST_KEY if call_count == 1 else None
  390. return 0, "ok", "", observed
  391. mock_device, mock_ctx, mock_ws = _make_update_mocks(tmp_path)
  392. mock_device.ssh_host_key = None # no stored key
  393. with (
  394. patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
  395. patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh),
  396. patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value="main"),
  397. patch("backend.app.services.spoolbuddy_ssh.asyncssh.import_known_hosts", return_value=MagicMock()),
  398. patch("backend.app.core.database.async_session", return_value=mock_ctx),
  399. patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws),
  400. ):
  401. mock_settings.base_dir = tmp_path
  402. await perform_ssh_update("sb-test", "10.0.0.1")
  403. # Device's ssh_host_key should have been set to the observed key
  404. assert mock_device.ssh_host_key == FAKE_HOST_KEY
  405. @pytest.mark.asyncio
  406. async def test_perform_ssh_update_ssh_failure(tmp_path):
  407. """SSH connectivity check fails — should set error status."""
  408. ssh_dir = tmp_path / "spoolbuddy" / "ssh"
  409. ssh_dir.mkdir(parents=True)
  410. (ssh_dir / "id_ed25519").write_text("PRIVATE")
  411. (ssh_dir / "id_ed25519.pub").write_text("PUBLIC")
  412. async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60):
  413. if "echo ok" in cmd:
  414. return 255, "", "Connection refused", None
  415. return 0, "", "", None
  416. mock_device, mock_ctx, mock_ws = _make_update_mocks(tmp_path)
  417. with (
  418. patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
  419. patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh),
  420. patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value="main"),
  421. patch("backend.app.core.database.async_session", return_value=mock_ctx),
  422. patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws),
  423. ):
  424. mock_settings.base_dir = tmp_path
  425. await perform_ssh_update("sb-test", "10.0.0.1")
  426. # Should broadcast error status
  427. error_broadcasts = [c for c in mock_ws.broadcast.call_args_list if c[0][0].get("update_status") == "error"]
  428. assert len(error_broadcasts) >= 1
  429. assert "SSH connection failed" in error_broadcasts[0][0][0]["update_message"]
  430. @pytest.mark.asyncio
  431. async def test_perform_ssh_update_git_fetch_failure(tmp_path):
  432. """Git fetch fails — should set error and stop."""
  433. ssh_dir = tmp_path / "spoolbuddy" / "ssh"
  434. ssh_dir.mkdir(parents=True)
  435. (ssh_dir / "id_ed25519").write_text("PRIVATE")
  436. (ssh_dir / "id_ed25519.pub").write_text("PUBLIC")
  437. ssh_calls = []
  438. async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60):
  439. ssh_calls.append(cmd)
  440. if "fetch" in cmd:
  441. return 1, "", "fatal: could not read from remote", None
  442. return 0, "ok", "", None
  443. _, mock_ctx, mock_ws = _make_update_mocks(tmp_path)
  444. with (
  445. patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
  446. patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh),
  447. patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value="main"),
  448. patch("backend.app.services.spoolbuddy_ssh.asyncssh.import_known_hosts", return_value=MagicMock()),
  449. patch("backend.app.core.database.async_session", return_value=mock_ctx),
  450. patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws),
  451. ):
  452. mock_settings.base_dir = tmp_path
  453. await perform_ssh_update("sb-test", "10.0.0.1")
  454. # Should stop after git fetch — no checkout, pip, restart
  455. assert len(ssh_calls) == 2 # echo ok + git fetch
  456. assert not any("checkout" in c for c in ssh_calls)
  457. @pytest.mark.asyncio
  458. async def test_perform_ssh_update_uses_stored_host_key(tmp_path):
  459. """When device already has ssh_host_key set, all SSH calls must receive non-None known_hosts (Gap 1)."""
  460. ssh_dir = tmp_path / "spoolbuddy" / "ssh"
  461. ssh_dir.mkdir(parents=True)
  462. (ssh_dir / "id_ed25519").write_text("PRIVATE")
  463. (ssh_dir / "id_ed25519.pub").write_text("PUBLIC")
  464. STORED_KEY = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 storedkey"
  465. SENTINEL_KNOWN_HOSTS = MagicMock(name="known_hosts_sentinel")
  466. received_known_hosts = []
  467. async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60):
  468. received_known_hosts.append(known_hosts)
  469. return 0, "ok", "", None # no new observed key (already stored)
  470. mock_device, mock_ctx, mock_ws = _make_update_mocks(tmp_path)
  471. mock_device.ssh_host_key = STORED_KEY
  472. with (
  473. patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
  474. patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh),
  475. patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value="main"),
  476. patch(
  477. "backend.app.services.spoolbuddy_ssh.asyncssh.import_known_hosts",
  478. return_value=SENTINEL_KNOWN_HOSTS,
  479. ),
  480. patch("backend.app.core.database.async_session", return_value=mock_ctx),
  481. patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws),
  482. ):
  483. mock_settings.base_dir = tmp_path
  484. await perform_ssh_update("sb-test", "10.0.0.1")
  485. # Every SSH call must have received the sentinel known_hosts object (not None)
  486. assert len(received_known_hosts) >= 2, "Expected at least 2 SSH calls"
  487. for kh in received_known_hosts:
  488. assert kh is SENTINEL_KNOWN_HOSTS, f"Expected sentinel known_hosts but got: {kh}"
  489. @pytest.mark.asyncio
  490. async def test_perform_ssh_update_corrupt_stored_key_falls_back_to_tofu(tmp_path):
  491. """When stored ssh_host_key can't be parsed, update continues with known_hosts=None (Gap 2)."""
  492. ssh_dir = tmp_path / "spoolbuddy" / "ssh"
  493. ssh_dir.mkdir(parents=True)
  494. (ssh_dir / "id_ed25519").write_text("PRIVATE")
  495. (ssh_dir / "id_ed25519.pub").write_text("PUBLIC")
  496. ssh_calls = []
  497. async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60):
  498. ssh_calls.append(cmd)
  499. return 0, "ok", "", None
  500. mock_device, mock_ctx, mock_ws = _make_update_mocks(tmp_path)
  501. mock_device.ssh_host_key = "THIS-IS-NOT-A-VALID-KEY"
  502. with (
  503. patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
  504. patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh),
  505. patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value="main"),
  506. patch(
  507. "backend.app.services.spoolbuddy_ssh.asyncssh.import_known_hosts",
  508. side_effect=ValueError("Malformed key"),
  509. ),
  510. patch("backend.app.core.database.async_session", return_value=mock_ctx),
  511. patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws),
  512. ):
  513. mock_settings.base_dir = tmp_path
  514. # Must not raise — corrupt key degrades gracefully
  515. await perform_ssh_update("sb-test", "10.0.0.1")
  516. # Update must have completed all steps despite the corrupt key
  517. assert any("echo ok" in c for c in ssh_calls)
  518. assert any("fetch" in c for c in ssh_calls)
  519. assert any("checkout" in c for c in ssh_calls)
  520. # Broadcast must show success, not error
  521. error_broadcasts = [c for c in mock_ws.broadcast.call_args_list if c[0][0].get("update_status") == "error"]
  522. assert not error_broadcasts, f"Got unexpected error broadcast: {error_broadcasts}"
  523. @pytest.mark.asyncio
  524. async def test_perform_ssh_update_passes_str_not_bytes_to_import_known_hosts(tmp_path):
  525. """asyncssh.import_known_hosts() is a str-only API — passing bytes crashes
  526. inside its line parser (`line.startswith('#')` against a bytes line raises
  527. TypeError). Pin both call sites — the stored-key parse and the just-stored
  528. TOFU re-parse — to ensure we never re-introduce the .encode() bug."""
  529. ssh_dir = tmp_path / "spoolbuddy" / "ssh"
  530. ssh_dir.mkdir(parents=True)
  531. (ssh_dir / "id_ed25519").write_text("PRIVATE")
  532. (ssh_dir / "id_ed25519.pub").write_text("PUBLIC")
  533. captured_args: list[object] = []
  534. def capture_import(arg):
  535. captured_args.append(arg)
  536. return MagicMock(name="known_hosts")
  537. async def mock_ssh(ip, cmd, key, *, known_hosts=None, timeout=60):
  538. # Surface a freshly observed key on the first call so the TOFU branch
  539. # also re-imports — exercises the second call site too.
  540. observed = "ssh-rsa AAAAOBSERVED first-tofu" if not captured_args else None
  541. return 0, "ok", "", observed
  542. mock_device, mock_ctx, mock_ws = _make_update_mocks(tmp_path)
  543. mock_device.ssh_host_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5 storedkey"
  544. with (
  545. patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
  546. patch("backend.app.services.spoolbuddy_ssh._run_ssh_command", side_effect=mock_ssh),
  547. patch("backend.app.services.spoolbuddy_ssh.detect_current_branch", return_value="main"),
  548. patch(
  549. "backend.app.services.spoolbuddy_ssh.asyncssh.import_known_hosts",
  550. side_effect=capture_import,
  551. ),
  552. patch("backend.app.core.database.async_session", return_value=mock_ctx),
  553. patch("backend.app.api.routes.spoolbuddy.ws_manager", mock_ws),
  554. ):
  555. mock_settings.base_dir = tmp_path
  556. await perform_ssh_update("sb-test", "10.0.0.1")
  557. assert captured_args, "import_known_hosts was never called"
  558. for arg in captured_args:
  559. assert isinstance(arg, str), f"asyncssh.import_known_hosts must receive str, got {type(arg).__name__}: {arg!r}"