# ABOUTME: Tests for greetd IPC protocol — socket communication with length-prefixed JSON. # ABOUTME: Uses mock sockets to verify message encoding/decoding and greetd request types. import json import struct import socket from unittest.mock import MagicMock, patch import pytest from moongreet.ipc import ( send_message, recv_message, create_session, post_auth_response, start_session, cancel_session, ) class FakeSocket: """A fake socket that records sent data and provides canned receive data.""" def __init__(self, recv_data: bytes = b""): self.sent = bytearray() self._recv_data = recv_data self._recv_offset = 0 def sendall(self, data: bytes) -> None: self.sent.extend(data) def recv(self, n: int, flags: int = 0) -> bytes: chunk = self._recv_data[self._recv_offset : self._recv_offset + n] self._recv_offset += n return chunk @classmethod def with_response(cls, response: dict) -> "FakeSocket": """Create a FakeSocket pre-loaded with a length-prefixed JSON response.""" payload = json.dumps(response).encode("utf-8") data = struct.pack("=I", len(payload)) + payload return cls(recv_data=data) class FragmentingSocket: """A fake socket that delivers data in small chunks to simulate fragmentation.""" def __init__(self, data: bytes, chunk_size: int = 3): self.sent = bytearray() self._data = data self._offset = 0 self._chunk_size = chunk_size def sendall(self, data: bytes) -> None: self.sent.extend(data) def recv(self, n: int, flags: int = 0) -> bytes: available = min(n, self._chunk_size, len(self._data) - self._offset) if available <= 0: return b"" chunk = self._data[self._offset : self._offset + available] self._offset += available return chunk class TestSendMessage: """Tests for encoding and sending length-prefixed JSON messages.""" def test_sends_length_prefixed_json(self) -> None: sock = FakeSocket() msg = {"type": "create_session", "username": "testuser"} send_message(sock, msg) payload = json.dumps(msg).encode("utf-8") expected = struct.pack("=I", len(payload)) + payload assert bytes(sock.sent) == expected def test_sends_empty_dict(self) -> None: sock = FakeSocket() send_message(sock, {}) payload = json.dumps({}).encode("utf-8") expected = struct.pack("=I", len(payload)) + payload assert bytes(sock.sent) == expected def test_sends_nested_message(self) -> None: sock = FakeSocket() msg = {"type": "post_auth_message_response", "response": "secret123"} send_message(sock, msg) # Verify the payload is correctly length-prefixed length_bytes = bytes(sock.sent[:4]) length = struct.unpack("=I", length_bytes)[0] decoded = json.loads(sock.sent[4:]) assert length == len(json.dumps(msg).encode("utf-8")) assert decoded == msg class TestRecvMessage: """Tests for receiving and decoding length-prefixed JSON messages.""" def test_receives_valid_message(self) -> None: response = {"type": "success"} sock = FakeSocket.with_response(response) result = recv_message(sock) assert result == response def test_receives_complex_message(self) -> None: response = { "type": "auth_message", "auth_message_type": "secret", "auth_message": "Password:", } sock = FakeSocket.with_response(response) result = recv_message(sock) assert result == response def test_raises_on_empty_recv(self) -> None: sock = FakeSocket(recv_data=b"") with pytest.raises(ConnectionError): recv_message(sock) def test_receives_fragmented_data(self) -> None: """recv() may return fewer bytes than requested — must loop.""" response = {"type": "success"} payload = json.dumps(response).encode("utf-8") data = struct.pack("=I", len(payload)) + payload sock = FragmentingSocket(data, chunk_size=3) result = recv_message(sock) assert result == response def test_rejects_oversized_payload(self) -> None: """Payloads exceeding the size limit must be rejected.""" header = struct.pack("=I", 10_000_000) sock = FakeSocket(recv_data=header) with pytest.raises(ConnectionError, match="too large"): recv_message(sock) class TestCreateSession: """Tests for the create_session greetd request.""" def test_sends_create_session_request(self) -> None: response = { "type": "auth_message", "auth_message_type": "secret", "auth_message": "Password:", } sock = FakeSocket.with_response(response) result = create_session(sock, "dominik") # Verify sent message length = struct.unpack("=I", bytes(sock.sent[:4]))[0] sent_msg = json.loads(sock.sent[4 : 4 + length]) assert sent_msg == {"type": "create_session", "username": "dominik"} assert result == response class TestPostAuthResponse: """Tests for posting authentication responses (passwords).""" def test_sends_password_response(self) -> None: response = {"type": "success"} sock = FakeSocket.with_response(response) result = post_auth_response(sock, "mypassword") length = struct.unpack("=I", bytes(sock.sent[:4]))[0] sent_msg = json.loads(sock.sent[4 : 4 + length]) assert sent_msg == { "type": "post_auth_message_response", "response": "mypassword", } assert result == response def test_sends_none_response(self) -> None: """For auth types that don't require a response.""" response = {"type": "success"} sock = FakeSocket.with_response(response) result = post_auth_response(sock, None) length = struct.unpack("=I", bytes(sock.sent[:4]))[0] sent_msg = json.loads(sock.sent[4 : 4 + length]) assert sent_msg == { "type": "post_auth_message_response", "response": None, } class TestStartSession: """Tests for starting a session after authentication.""" def test_sends_start_session_request(self) -> None: response = {"type": "success"} sock = FakeSocket.with_response(response) result = start_session(sock, ["Hyprland"]) length = struct.unpack("=I", bytes(sock.sent[:4]))[0] sent_msg = json.loads(sock.sent[4 : 4 + length]) assert sent_msg == {"type": "start_session", "cmd": ["Hyprland"]} assert result == response def test_sends_multi_arg_command(self) -> None: response = {"type": "success"} sock = FakeSocket.with_response(response) result = start_session(sock, ["sway", "--config", "/etc/sway/config"]) length = struct.unpack("=I", bytes(sock.sent[:4]))[0] sent_msg = json.loads(sock.sent[4 : 4 + length]) assert sent_msg == { "type": "start_session", "cmd": ["sway", "--config", "/etc/sway/config"], } class TestCancelSession: """Tests for cancelling an in-progress session.""" def test_sends_cancel_session_request(self) -> None: response = {"type": "success"} sock = FakeSocket.with_response(response) result = cancel_session(sock) length = struct.unpack("=I", bytes(sock.sent[:4]))[0] sent_msg = json.loads(sock.sent[4 : 4 + length]) assert sent_msg == {"type": "cancel_session"} assert result == response