test: cover ServerRemoteAddrApp, ServerTest
This commit is contained in:
1 parent
a2cf52256a
commit
d62920fa24
2 files changed
+201
No files matched your search
@@ -0,0 +1,183 @@
|
|||||||
|
require 'minitest/autorun'
|
||||||
|
require 'prism'
|
||||||
|
require 'net/http'
|
||||||
|
require 'socket'
|
||||||
|
|
||||||
|
class ServerRemoteAddrApp < Prism::Application
|
||||||
|
get '/whoami' do
|
||||||
|
json(remote_addr: request.env['REMOTE_ADDR'])
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class ServerTest < Minitest::Test
|
||||||
|
def setup
|
||||||
|
@server = Prism::Server.new(ServerRemoteAddrApp, port: 0).start
|
||||||
|
@port = @server.port
|
||||||
|
end
|
||||||
|
|
||||||
|
def teardown
|
||||||
|
@server.stop
|
||||||
|
end
|
||||||
|
|
||||||
|
# Regression: the hand-rolled Rack env built by Server#read_request never
|
||||||
|
# set REMOTE_ADDR, so anything keying off the caller's IP over a real
|
||||||
|
# socket - Middleware::RateLimiting's IP-based throttles chief among
|
||||||
|
# them - silently never matched, even though the middleware itself was
|
||||||
|
# correctly implemented and covered by tests that inject REMOTE_ADDR by
|
||||||
|
# hand.
|
||||||
|
def test_remote_addr_is_populated_from_the_real_socket
|
||||||
|
response = Net::HTTP.get_response(URI("http://127.0.0.1:#{@port}/whoami"))
|
||||||
|
assert_equal '200', response.code
|
||||||
|
assert_equal '127.0.0.1', JSON.parse(response.body)['remote_addr']
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# A controller action returning a Prism::StreamingBody (see
|
||||||
|
# Prism::Controller#stream) - real end-to-end proof that
|
||||||
|
# Server#write_chunked_response speaks correct HTTP/1.1 chunked
|
||||||
|
# Transfer-Encoding over an actual TCPSocket, not just against a directly
|
||||||
|
# invoked middleware/App#call(env) tuple the way test_controller_streaming.rb
|
||||||
|
# and test_response.rb both already cover.
|
||||||
|
class ServerStreamingApp < Prism::Application
|
||||||
|
get '/export' do
|
||||||
|
headers('Content-Type' => 'text/csv')
|
||||||
|
stream do |out|
|
||||||
|
out.write("id,title\n")
|
||||||
|
out.write("1,one\n")
|
||||||
|
out.write("2,two\n")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
get '/plain' do
|
||||||
|
json(ok: true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
class ServerStreamingTest < Minitest::Test
|
||||||
|
def setup
|
||||||
|
@server = Prism::Server.new(ServerStreamingApp, port: 0).start
|
||||||
|
@port = @server.port
|
||||||
|
end
|
||||||
|
|
||||||
|
def teardown
|
||||||
|
@server.stop
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_net_http_client_receives_the_full_reassembled_body
|
||||||
|
response = Net::HTTP.get_response(URI("http://127.0.0.1:#{@port}/export"))
|
||||||
|
assert_equal '200', response.code
|
||||||
|
assert_equal "id,title\n1,one\n2,two\n", response.body
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_net_http_reports_chunked_transfer_encoding_not_content_length
|
||||||
|
response = Net::HTTP.get_response(URI("http://127.0.0.1:#{@port}/export"))
|
||||||
|
assert_equal 'chunked', response['Transfer-Encoding']
|
||||||
|
assert_nil response['Content-Length']
|
||||||
|
end
|
||||||
|
|
||||||
|
# Reads the raw bytes off the socket ourselves (bypassing Net::HTTP's own
|
||||||
|
# chunked-decoding) to prove the wire format itself - not just that some
|
||||||
|
# HTTP client happens to be able to reassemble it - matches RFC 7230
|
||||||
|
# SS4.1: one `<hex-size>\r\n<data>\r\n` frame per chunk written by the
|
||||||
|
# action, in the order it wrote them, followed by the `0\r\n\r\n`
|
||||||
|
# terminating chunk.
|
||||||
|
def test_raw_wire_bytes_use_correct_chunk_framing
|
||||||
|
socket = TCPSocket.new('127.0.0.1', @port)
|
||||||
|
socket.write("GET /export HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
|
||||||
|
raw = socket.read
|
||||||
|
socket.close
|
||||||
|
|
||||||
|
head, chunked = raw.split("\r\n\r\n", 2)
|
||||||
|
assert_includes head, 'Transfer-Encoding: chunked'
|
||||||
|
refute_includes head, 'Content-Length'
|
||||||
|
|
||||||
|
expected = "9\r\nid,title\n\r\n" \
|
||||||
|
"6\r\n1,one\n\r\n" \
|
||||||
|
"6\r\n2,two\n\r\n" \
|
||||||
|
"0\r\n\r\n"
|
||||||
|
assert_equal expected, chunked
|
||||||
|
end
|
||||||
|
|
||||||
|
# A streamed response must not break the keep-alive connection for
|
||||||
|
# whatever request comes after it on the same socket - the chunked body
|
||||||
|
# is self-delimiting (the 0-length terminating chunk), so Server#serve's
|
||||||
|
# normal loop can keep reading the next request right after it exactly
|
||||||
|
# like it would after an ordinary Content-Length body.
|
||||||
|
def test_keep_alive_connection_survives_a_streamed_response_followed_by_a_plain_one
|
||||||
|
Net::HTTP.start('127.0.0.1', @port) do |http|
|
||||||
|
first = http.request(Net::HTTP::Get.new('/export'))
|
||||||
|
second = http.request(Net::HTTP::Get.new('/plain'))
|
||||||
|
|
||||||
|
assert_equal '200', first.code
|
||||||
|
assert_equal "id,title\n1,one\n2,two\n", first.body
|
||||||
|
assert_equal '200', second.code
|
||||||
|
assert_equal({ 'ok' => true }, JSON.parse(second.body))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# A streaming action whose producer block pauses (blocked on a Queue the
|
||||||
|
# test controls) between two chunks, so a test can yank the client socket
|
||||||
|
# out from under it mid-stream at a precise, non-racy point instead of
|
||||||
|
# guessing with a sleep.
|
||||||
|
class ServerAbandonedStreamApp < Prism::Application
|
||||||
|
class << self
|
||||||
|
attr_accessor :gate
|
||||||
|
end
|
||||||
|
|
||||||
|
get '/slow-export' do
|
||||||
|
stream do |out|
|
||||||
|
out.write("first\n")
|
||||||
|
self.class.gate.pop
|
||||||
|
out.write("second\n")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
get '/plain' do
|
||||||
|
json(ok: true)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Regression coverage for the "not a dedicated test" gap left open by the
|
||||||
|
# streaming session (see prism-postep.md, sesja 91, "NIE zweryfikowane"):
|
||||||
|
# write_chunked_response's `socket.write` runs against a socket the client
|
||||||
|
# may have already closed, and Server#serve wraps the WHOLE request loop in
|
||||||
|
# `rescue Errno::EPIPE, Errno::ECONNRESET, IOError` to survive that - but
|
||||||
|
# nothing had ever actually exercised a real abandoned connection mid-chunk.
|
||||||
|
class ServerAbandonedStreamTest < Minitest::Test
|
||||||
|
def setup
|
||||||
|
ServerAbandonedStreamApp.gate = Queue.new
|
||||||
|
@server = Prism::Server.new(ServerAbandonedStreamApp, port: 0).start
|
||||||
|
@port = @server.port
|
||||||
|
end
|
||||||
|
|
||||||
|
def teardown
|
||||||
|
@server.stop
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_client_disconnecting_mid_stream_does_not_crash_the_server_or_wedge_future_requests
|
||||||
|
socket = TCPSocket.new('127.0.0.1', @port)
|
||||||
|
socket.write("GET /slow-export HTTP/1.1\r\nHost: 127.0.0.1\r\nConnection: close\r\n\r\n")
|
||||||
|
|
||||||
|
# Wait for the first chunk to actually land on the wire before pulling
|
||||||
|
# the rug - otherwise we might close before write_chunked_response's
|
||||||
|
# loop has even started, proving nothing about a write to a dead socket.
|
||||||
|
ready = IO.select([socket], nil, nil, 2)
|
||||||
|
refute_nil ready, 'server never sent the first chunk within 2s'
|
||||||
|
socket.recv(4096)
|
||||||
|
socket.close
|
||||||
|
sleep 0.05 # let the kernel actually deliver the FIN/RST before we continue the producer
|
||||||
|
|
||||||
|
ServerAbandonedStreamApp.gate.push(:go)
|
||||||
|
sleep 0.1 # give the (separate) serve() thread a moment to hit the dead write and rescue it
|
||||||
|
|
||||||
|
# The real assertion: whatever happened to that abandoned write, the
|
||||||
|
# server process itself is still alive and still accepting brand-new
|
||||||
|
# connections on a fresh socket - Server#serve's rescue clause did its
|
||||||
|
# job instead of an unhandled exception silently killing the accept
|
||||||
|
# loop or leaving the process in a broken state.
|
||||||
|
response = Net::HTTP.get_response(URI("http://127.0.0.1:#{@port}/plain"))
|
||||||
|
assert_equal '200', response.code
|
||||||
|
assert_equal({ 'ok' => true }, JSON.parse(response.body))
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -30,4 +30,22 @@ class SessionTest < Minitest::Test
|
|||||||
assert_equal({}, Prism::Session.decode(nil, 's'))
|
assert_equal({}, Prism::Session.decode(nil, 's'))
|
||||||
assert_equal({}, Prism::Session.decode('', 's'))
|
assert_equal({}, Prism::Session.decode('', 's'))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_previous_secrets_still_decode_a_cookie_signed_under_a_retired_secret
|
||||||
|
cookie = Prism::Session.encode({ 'user' => 'foksiu' }, 'old-secret')
|
||||||
|
decoded = Prism::Session.decode(cookie, 'new-secret', previous_secrets: ['old-secret'])
|
||||||
|
assert_equal 'foksiu', decoded['user']
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_previous_secrets_do_not_accept_a_cookie_signed_under_neither_secret
|
||||||
|
cookie = Prism::Session.encode({ 'user' => 'foksiu' }, 'unrelated-secret')
|
||||||
|
decoded = Prism::Session.decode(cookie, 'new-secret', previous_secrets: ['old-secret'])
|
||||||
|
assert_equal({}, decoded)
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_encode_always_signs_under_the_current_secret_not_a_previous_one
|
||||||
|
cookie = Prism::Session.encode({ 'user' => 'foksiu' }, 'new-secret')
|
||||||
|
assert_equal({}, Prism::Session.decode(cookie, 'old-secret'))
|
||||||
|
assert_equal 'foksiu', Prism::Session.decode(cookie, 'new-secret')['user']
|
||||||
|
end
|
||||||
end
|
end
|
||||||
Reference in new issue
Block a user