test: cover WebSocketConnectionTest and teardown

This commit is contained in:
dualfroz committed 2026-09-14 02:33:45 +02:00
1 parent 44b93174b4
commit 14b93c93bf
1 file changed
+167
+167
View File
@@ -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