Files
cc2-spoolman-bridge/tests/test_ssh.py
T
Jonas MenzelandClaude Opus 5.5 a9041eb538
CI / test (push) Successful in 1m6s
CI / image (push) Successful in 43s
Multiple printers, printer/slot in Spoolman, kept in sync
- One worker per printer (name, IP, CC2 Control URL, own pinned host key);
  a spool sits in at most one slot of one printer, moves between printers
- Spoolman: location "<printer> Slot N" plus extra fields "drucker"/"slot"
  (created by the bridge), cleared and "Lager" on removal
- Full comparison every minute: the printer wins over manual edits of loaded
  spools; stale printer locations go to storage; own locations untouched
- Automatic migration of v1 settings, state and host key
- Status hint when a tag is still in a tray but unmapped (e.g. after
  "Take out" in CC2 Control)
- SSH test: connection drops during exec give a clean error, not a 500
- Mock environment with several printers; tests for all of the above

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GcEk5tkfFSYD6NTF66NkC
2026-10-25 03:53:01 +01:00

176 lines
6.0 KiB
Python

"""SSH reader against a local paramiko server that behaves like the restricted printer key."""
import io
import json
import os
import socket
import stat
import threading
import paramiko
import pytest
from app.sshsource import CanvasRecordReader, HostKeyMismatch, HostKeyUnknown, KeyStore, SSHError
from .fakes import record
RECORD = record(10001, "", 1000, 10008)
class Server(paramiko.ServerInterface):
reject_exec = False
def __init__(self, allowed: paramiko.PKey):
self.allowed = allowed
self.commands = []
def check_channel_request(self, kind, chanid):
return paramiko.OPEN_SUCCEEDED if kind == "session" else paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED
def get_allowed_auths(self, username):
return "publickey"
def check_auth_publickey(self, username, key):
ok = username == "root" and key.get_base64() == self.allowed.get_base64()
return paramiko.AUTH_SUCCESSFUL if ok else paramiko.AUTH_FAILED
def check_channel_exec_request(self, channel, command):
self.commands.append(command)
if Server.reject_exec:
return False
def reply(): # forced command: always the canvas record
channel.sendall(json.dumps(RECORD).encode())
channel.send_exit_status(0)
channel.close()
threading.Timer(0.05, reply).start() # after the exec request is acknowledged
return True
@pytest.fixture
def printer():
host_key = paramiko.Ed25519Key.from_private_key(io.StringIO(_new_key_text()))
client_key_text = _new_key_text()
client_key = paramiko.Ed25519Key.from_private_key(io.StringIO(client_key_text))
sock = socket.socket()
sock.bind(("127.0.0.1", 0))
sock.listen(5)
servers = []
stop = threading.Event()
def accept():
while not stop.is_set():
try:
conn, _ = sock.accept()
except OSError:
return
t = paramiko.Transport(conn)
t.add_server_key(host_key)
srv = Server(client_key)
servers.append((t, srv))
try:
t.start_server(server=srv)
except Exception:
pass
threading.Thread(target=accept, daemon=True).start()
yield {"port": sock.getsockname()[1], "host_key": host_key, "client_key_text": client_key_text,
"servers": servers}
stop.set()
sock.close()
for t, _ in servers:
t.close()
def _new_key_text():
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
return Ed25519PrivateKey.generate().private_bytes(
serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH, serialization.NoEncryption()).decode()
def test_key_store_permissions_and_no_secret_in_info(tmp_path, printer):
keys = KeyStore(tmp_path)
keys.save_key(printer["client_key_text"])
mode = stat.S_IMODE(os.stat(keys.key_path).st_mode)
assert mode == 0o600
info = keys.key_info()
assert info["fingerprint"].startswith("SHA256:") and info["type"] == "ssh-ed25519"
assert "PRIVATE" not in json.dumps(info)
def test_rejects_garbage_key(tmp_path):
with pytest.raises(SSHError):
KeyStore(tmp_path).save_key("not a key")
def test_first_contact_requires_confirmation(tmp_path, printer):
keys = KeyStore(tmp_path)
keys.save_key(printer["client_key_text"])
reader = CanvasRecordReader(keys, "p1", "127.0.0.1", printer["port"])
with pytest.raises(HostKeyUnknown):
reader.read()
result = reader.test()
assert result["host_key_pinned"] is False
assert result["record"]["channels0"]["weight"] == "10001"
keys.pin_host_key("p1", "127.0.0.1", printer["host_key"])
rec = reader.read()
assert rec["channels3"]["weight"] == "10008"
# transport is reused between reads
reader.read()
assert len(printer["servers"]) == 3 # refused read, test(), then one transport for both reads
reader.close()
def test_changed_host_key_is_rejected(tmp_path, printer):
keys = KeyStore(tmp_path)
keys.save_key(printer["client_key_text"])
other = paramiko.Ed25519Key.from_private_key(io.StringIO(_new_key_text()))
keys.pin_host_key("p1", "127.0.0.1", other)
reader = CanvasRecordReader(keys, "p1", "127.0.0.1", printer["port"])
with pytest.raises(HostKeyMismatch):
reader.read()
with pytest.raises(HostKeyMismatch):
reader.test()
def test_wrong_client_key_fails_cleanly(tmp_path, printer):
keys = KeyStore(tmp_path)
keys.save_key(_new_key_text())
keys.pin_host_key("p1", "127.0.0.1", printer["host_key"])
with pytest.raises(SSHError, match="abgelehnt|fehlgeschlagen"):
CanvasRecordReader(keys, "p1", "127.0.0.1", printer["port"]).read()
def test_reconnects_after_drop(tmp_path, printer):
keys = KeyStore(tmp_path)
keys.save_key(printer["client_key_text"])
keys.pin_host_key("p1", "127.0.0.1", printer["host_key"])
reader = CanvasRecordReader(keys, "p1", "127.0.0.1", printer["port"])
reader.read()
for t, _ in printer["servers"]:
t.close()
assert reader.read()["channels0"]["weight"] == "10001"
def test_unreachable_printer(tmp_path, printer):
keys = KeyStore(tmp_path)
keys.save_key(printer["client_key_text"])
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
s.close()
with pytest.raises(SSHError, match="nicht erreichbar"):
CanvasRecordReader(keys, "p1", "127.0.0.1", port, timeout=2).read()
def test_exec_failure_is_a_clean_error(tmp_path, printer, monkeypatch):
monkeypatch.setattr(Server, "reject_exec", True)
keys = KeyStore(tmp_path)
keys.save_key(printer["client_key_text"])
reader = CanvasRecordReader(keys, "p1", "127.0.0.1", printer["port"])
with pytest.raises(SSHError, match="Lesefehler"):
reader.test()
keys.pin_host_key("p1", "127.0.0.1", printer["host_key"])
with pytest.raises(SSHError, match="Lesefehler"):
reader.read()