From 14b93c93bf99b7bf3c1ca41390ab48805994447e Mon Sep 17 00:00:00 2001 From: DualFroz Date: Mon, 14 Sep 2026 02:33:45 +0200 Subject: [PATCH] test: cover WebSocketConnectionTest and teardown --- test/test_websocket_connection.rb | 167 ++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 test/test_websocket_connection.rb diff --git a/test/test_websocket_connection.rb b/test/test_websocket_connection.rb new file mode 100644 index 0000000..1918754 --- /dev/null +++ b/test/test_websocket_connection.rb @@ -0,0 +1,167 @@ +require 'minitest/autorun' +require 'prism' +require 'socket' + +# Exercises Connection over a real connected socket pair (one end plays the +# framework's Connection, the other plays a raw client sending hand-built +# frames) rather than mocking the socket, since the interesting behavior +# here - auto ping/pong, fragment reassembly, the close handshake - is all +# about how bytes actually flow across the wire. +class WebSocketConnectionTest < Minitest::Test + Frame = Prism::WebSocket::Frame + + def setup + @server_socket, @client_socket = Socket.pair(:UNIX, :STREAM, 0) + @connection = Prism::WebSocket::Connection.new(@server_socket) + end + + def teardown + @server_socket.close unless @server_socket.closed? + @client_socket.close unless @client_socket.closed? + end + + def test_reads_a_single_unfragmented_text_message + @client_socket.write(Frame.encode(Frame::TEXT, 'hello', mask: true)) + assert_equal 'hello', @connection.read_message + end + + def test_reassembles_a_fragmented_message_across_continuation_frames + @client_socket.write(Frame.encode(Frame::TEXT, 'foo', fin: false, mask: true)) + @client_socket.write(Frame.encode(Frame::CONTINUATION, 'bar', fin: false, mask: true)) + @client_socket.write(Frame.encode(Frame::CONTINUATION, 'baz', mask: true)) + assert_equal 'foobarbaz', @connection.read_message + end + + def test_automatically_replies_to_a_ping_with_a_pong_and_keeps_reading + @client_socket.write(Frame.encode(Frame::PING, 'are you there', mask: true)) + @client_socket.write(Frame.encode(Frame::TEXT, 'after the ping', mask: true)) + + assert_equal 'after the ping', @connection.read_message + + reply = Frame.decode(@client_socket) + assert_equal Frame::PONG, reply.opcode + assert_equal 'are you there', reply.payload + end + + def test_close_frame_echoes_close_and_returns_nil + @client_socket.write(Frame.encode(Frame::CLOSE, '', mask: true)) + assert_nil @connection.read_message + assert @connection.closed? + + reply = Frame.decode(@client_socket) + assert_equal Frame::CLOSE, reply.opcode + end + + def test_write_text_is_visible_on_the_other_end_unmasked + @connection.write_text('pushed from the server') + frame = Frame.decode(@client_socket) + assert_equal Frame::TEXT, frame.opcode + assert_equal 'pushed from the server', frame.payload + end + + def test_continuation_without_a_preceding_data_frame_raises + @client_socket.write(Frame.encode(Frame::CONTINUATION, 'orphan', mask: true)) + assert_raises(Prism::WebSocket::ProtocolError) { @connection.read_message } + assert @connection.closed? + end + + def test_read_message_returns_nil_once_closed + @connection.close + assert_nil @connection.read_message + end + + # RFC 6455 5.1: the server MUST close the connection upon receiving a + # frame that is not masked - a Connection is always the server's view + # of one client, so this applies to every opcode, not just data frames. + def test_raises_on_an_unmasked_data_frame_from_the_client + @client_socket.write(Frame.encode(Frame::TEXT, 'no mask', mask: false)) + assert_raises(Prism::WebSocket::ProtocolError) { @connection.read_message } + assert @connection.closed? + end + + def test_raises_on_an_unmasked_control_frame_from_the_client + @client_socket.write(Frame.encode(Frame::PING, 'no mask', mask: false)) + assert_raises(Prism::WebSocket::ProtocolError) { @connection.read_message } + assert @connection.closed? + end + + def test_raises_when_a_single_frame_exceeds_the_configured_max_message_size + capped = Prism::WebSocket::Connection.new(@server_socket, max_message_size: 10) + @client_socket.write(Frame.encode(Frame::TEXT, 'x' * 20, mask: true)) + assert_raises(Prism::WebSocket::ProtocolError) { capped.read_message } + assert capped.closed? + end + + # Each individual fragment fits under the cap, but the reassembled + # message does not - proves the check runs on the accumulated buffer, + # not only on Frame.decode's per-frame length. + def test_raises_when_fragments_reassemble_past_the_configured_max_message_size + capped = Prism::WebSocket::Connection.new(@server_socket, max_message_size: 10) + @client_socket.write(Frame.encode(Frame::TEXT, 'x' * 8, fin: false, mask: true)) + @client_socket.write(Frame.encode(Frame::CONTINUATION, 'x' * 8, mask: true)) + assert_raises(Prism::WebSocket::ProtocolError) { capped.read_message } + assert capped.closed? + end + + # RFC 6455 7.4.1: 1002 is the generic "protocol error" close status. + def test_protocol_violation_sends_a_1002_close_frame_to_the_client_before_raising + @client_socket.write(Frame.encode(Frame::TEXT, 'no mask', mask: false)) + error = assert_raises(Prism::WebSocket::ProtocolError) { @connection.read_message } + assert_equal 1002, error.code + + close_frame = Frame.decode(@client_socket) + assert_equal Frame::CLOSE, close_frame.opcode + assert_equal 1002, close_frame.payload.byteslice(0, 2).unpack1('n') + end + + # RFC 6455 7.4.1: 1009 is the "message too big" close status - distinct + # from the generic 1002 raised by the other ProtocolError cases above. + def test_oversized_message_sends_a_1009_close_frame_to_the_client_before_raising + capped = Prism::WebSocket::Connection.new(@server_socket, max_message_size: 10) + @client_socket.write(Frame.encode(Frame::TEXT, 'x' * 20, mask: true)) + error = assert_raises(Prism::WebSocket::ProtocolError) { capped.read_message } + assert_equal 1009, error.code + + close_frame = Frame.decode(@client_socket) + assert_equal Frame::CLOSE, close_frame.opcode + assert_equal 1009, close_frame.payload.byteslice(0, 2).unpack1('n') + end + + # RFC 6455 8.1: a TEXT frame payload that is not valid UTF-8 must fail + # the connection - "\xC3\x28" is a textbook invalid 2-byte sequence + # (0xC3 wants a continuation byte in 0x80..0xBF next, 0x28 is not one). + def test_raises_1007_on_invalid_utf8_text_payload + invalid = String.new("\xC3\x28", encoding: Encoding::BINARY) + @client_socket.write(Frame.encode(Frame::TEXT, invalid, mask: true)) + error = assert_raises(Prism::WebSocket::ProtocolError) { @connection.read_message } + assert_equal 1007, error.code + assert @connection.closed? + end + + # RFC 6455 7.4.1: 1007 is the "invalid payload data" close status - + # distinct from 1002/1009 raised by the other ProtocolError cases above. + def test_invalid_utf8_sends_a_1007_close_frame_to_the_client_before_raising + invalid = String.new("\xC3\x28", encoding: Encoding::BINARY) + @client_socket.write(Frame.encode(Frame::TEXT, invalid, mask: true)) + assert_raises(Prism::WebSocket::ProtocolError) { @connection.read_message } + + close_frame = Frame.decode(@client_socket) + assert_equal Frame::CLOSE, close_frame.opcode + assert_equal 1007, close_frame.payload.byteslice(0, 2).unpack1('n') + end + + # A legally split multi-byte character (the 2-byte UTF-8 encoding of + # "e" with an acute accent, split after its first byte across a + # CONTINUATION boundary) must NOT be rejected - proves validation runs + # on the fully reassembled message, not per fragment. + def test_valid_utf8_split_across_a_continuation_boundary_is_accepted + first_byte = String.new("\xC3", encoding: Encoding::BINARY) + second_byte = String.new("\xA9", encoding: Encoding::BINARY) + @client_socket.write(Frame.encode(Frame::TEXT, first_byte, fin: false, mask: true)) + @client_socket.write(Frame.encode(Frame::CONTINUATION, second_byte, mask: true)) + + message = @connection.read_message + assert_equal "\u00e9", message + assert message.valid_encoding? + end +end