wsv 0.10.1 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/wsv/server.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require "openssl"
4
4
  require "socket"
5
5
  require_relative "app"
6
+ require_relative "cors"
6
7
  require_relative "server/banner"
7
8
  require_relative "server/browser_launcher"
8
9
  require_relative "server/connection"
@@ -26,7 +27,9 @@ module Wsv
26
27
  tls: nil,
27
28
  spa: false,
28
29
  open: false,
29
- cors: false
30
+ cors: false,
31
+ quiet: false,
32
+ app: nil
30
33
  )
31
34
  @host = host
32
35
  @port = port
@@ -37,7 +40,11 @@ module Wsv
37
40
  @tls = tls
38
41
  @ssl_context = tls&.to_ssl_context
39
42
  @open = open
40
- @app = App.new(@root, spa: spa, cors: cors)
43
+ @cors = Cors.new if cors
44
+ @access_log = quiet ? NullAccessLog.new : AccessLog.new(out: out)
45
+ # `app:` lets callers plug in a custom request handler in place of
46
+ # the default file server.
47
+ @app = app || App.new(@root, spa: spa, cors: @cors)
41
48
  @throttle = ConnectionThrottle.new(max: max_connections, err: err)
42
49
  @running = false
43
50
  end
@@ -73,6 +80,15 @@ module Wsv
73
80
  next
74
81
  end
75
82
 
83
+ # Disable Nagle so small writes (SSE frames, headers) are not held
84
+ # in the TCP send buffer waiting for an ACK. Applies before any TLS
85
+ # wrap; NODELAY is a TCP-layer option that persists through SSL.
86
+ begin
87
+ client.setsockopt(:TCP, :NODELAY, 1)
88
+ rescue StandardError
89
+ nil
90
+ end
91
+
76
92
  begin
77
93
  spawn_handler(client)
78
94
  rescue StandardError => e
@@ -88,7 +104,8 @@ module Wsv
88
104
 
89
105
  def spawn_handler(client)
90
106
  accepted = @throttle.try_spawn do
91
- Connection.new(maybe_wrap_tls(client), err: @err).serve(@app, read_timeout: @read_timeout)
107
+ Connection.new(maybe_wrap_tls(client), err: @err, cors: @cors, access_log: @access_log)
108
+ .serve(@app, read_timeout: @read_timeout)
92
109
  end
93
110
  spawn_rejection(client) unless accepted
94
111
  end
@@ -102,10 +119,10 @@ module Wsv
102
119
  reply = !@ssl_context
103
120
  Thread.new do
104
121
  Thread.current.report_on_exception = false
105
- Connection.new(client, err: @err).reject(reply: reply)
122
+ Connection.new(client, err: @err, cors: @cors, access_log: @access_log).reject(reply: reply)
106
123
  end
107
124
  rescue ThreadError
108
- Connection.new(client, err: @err).reject(reply: reply)
125
+ Connection.new(client, err: @err, cors: @cors, access_log: @access_log).reject(reply: reply)
109
126
  end
110
127
 
111
128
  def maybe_wrap_tls(client)
data/lib/wsv/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wsv
4
- VERSION = "0.10.1"
4
+ VERSION = "0.12.0"
5
5
  end
data/lib/wsv.rb CHANGED
@@ -4,6 +4,7 @@ require_relative "wsv/version"
4
4
  require_relative "wsv/status"
5
5
  require_relative "wsv/mime_types"
6
6
  require_relative "wsv/path_resolver"
7
+ require_relative "wsv/range_request"
7
8
  require_relative "wsv/request"
8
9
  require_relative "wsv/response"
9
10
  require_relative "wsv/cors"
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class AccessLogTest < Minitest::Test
6
+ def test_records_clf_line_for_serviced_request
7
+ out = StringIO.new
8
+ log = Wsv::Server::AccessLog.new(out: out)
9
+
10
+ log.record(
11
+ remote_addr: "127.0.0.1",
12
+ request: build_request(method: "GET", target: "/index.html", version: "HTTP/1.1"),
13
+ status: 200,
14
+ bytes: 1234
15
+ )
16
+
17
+ assert_match(%r{\A127\.0\.0\.1 - - \[\d{2}/\w{3}/\d{4}:\d{2}:\d{2}:\d{2} [+-]\d{4}\] }, out.string)
18
+ assert_includes out.string, %("GET /index.html HTTP/1.1" 200 1234)
19
+ end
20
+
21
+ def test_uses_dash_for_zero_bytes
22
+ out = StringIO.new
23
+ Wsv::Server::AccessLog.new(out: out).record(
24
+ remote_addr: "127.0.0.1",
25
+ request: build_request(method: "HEAD", target: "/", version: "HTTP/1.1"),
26
+ status: 200,
27
+ bytes: 0
28
+ )
29
+
30
+ assert_match(/200 -\z/, out.string.chomp)
31
+ end
32
+
33
+ def test_uses_dash_when_remote_addr_unknown
34
+ out = StringIO.new
35
+ Wsv::Server::AccessLog.new(out: out).record(
36
+ remote_addr: nil,
37
+ request: build_request(method: "GET", target: "/", version: "HTTP/1.1"),
38
+ status: 200,
39
+ bytes: 1
40
+ )
41
+
42
+ assert out.string.start_with?("- - - ")
43
+ end
44
+
45
+ def test_uses_dash_when_request_unparsed
46
+ out = StringIO.new
47
+ Wsv::Server::AccessLog.new(out: out).record(
48
+ remote_addr: "127.0.0.1",
49
+ request: nil,
50
+ status: 408,
51
+ bytes: 11
52
+ )
53
+
54
+ assert_includes out.string, %("-" 408 11)
55
+ end
56
+
57
+ def test_sanitizes_control_characters_in_request_line
58
+ out = StringIO.new
59
+ Wsv::Server::AccessLog.new(out: out).record(
60
+ remote_addr: "127.0.0.1",
61
+ request: build_request(method: "GET", target: "/x\r\ninjected:1", version: "HTTP/1.1"),
62
+ status: 400,
63
+ bytes: 0
64
+ )
65
+
66
+ refute_includes out.string, "\r"
67
+ assert_equal 1, out.string.count("\n")
68
+ assert_includes out.string, '\\x0d\\x0a'
69
+ end
70
+
71
+ def test_sanitizes_quotes_in_request_target
72
+ out = StringIO.new
73
+ Wsv::Server::AccessLog.new(out: out).record(
74
+ remote_addr: "127.0.0.1",
75
+ request: build_request(method: "GET", target: %(/x"y), version: "HTTP/1.1"),
76
+ status: 200,
77
+ bytes: 1
78
+ )
79
+
80
+ assert_includes out.string, '\\x22'
81
+ end
82
+
83
+ def test_null_access_log_is_silent
84
+ log = Wsv::Server::NullAccessLog.new
85
+
86
+ assert_nil log.record(remote_addr: "127.0.0.1", request: nil, status: 200, bytes: 0)
87
+ end
88
+
89
+ private
90
+
91
+ def build_request(method:, target:, version:)
92
+ Wsv::Request.new(method: method, target: target, version: version, headers: {})
93
+ end
94
+ end
data/test/app_test.rb CHANGED
@@ -109,6 +109,17 @@ class AppTest < Minitest::Test
109
109
  assert_equal "hi", response.body
110
110
  end
111
111
 
112
+ def test_304_takes_precedence_over_range
113
+ path = File.join(@dir, "x.txt")
114
+ File.write(path, "hi")
115
+
116
+ response = @app.call(req("GET", "/x.txt",
117
+ "if-modified-since" => File.mtime(path).httpdate,
118
+ "range" => "bytes=0-0"))
119
+
120
+ assert_equal 304, response.status
121
+ end
122
+
112
123
  def test_invalid_if_modified_since_is_ignored
113
124
  File.write(File.join(@dir, "x.txt"), "hi")
114
125
 
@@ -337,50 +348,34 @@ class AppTest < Minitest::Test
337
348
  assert_equal "bytes", response.headers["Accept-Ranges"]
338
349
  end
339
350
 
340
- def test_cors_disabled_omits_acao_header
341
- File.write(File.join(@dir, "x.txt"), "hi")
351
+ # CORS overlay (Access-Control-Allow-Origin / Vary) is applied by
352
+ # Server::Connection, not by App. App only short-circuits OPTIONS preflight
353
+ # and adds OPTIONS to the Allow header. End-to-end CORS behavior is covered
354
+ # by server_test.rb.
342
355
 
343
- response = @app.call(req("GET", "/x.txt"))
344
-
345
- refute response.headers.key?("Access-Control-Allow-Origin")
346
- end
347
-
348
- def test_cors_adds_acao_to_successful_response
356
+ def test_app_does_not_add_cors_headers_directly
349
357
  File.write(File.join(@dir, "x.txt"), "hi")
350
- cors_app = Wsv::App.new(File.realpath(@dir), cors: true)
358
+ cors_app = Wsv::App.new(File.realpath(@dir), cors: Wsv::Cors.new)
351
359
 
352
360
  response = cors_app.call(req("GET", "/x.txt"))
353
361
 
354
- assert_equal 200, response.status
355
- assert_equal "hi", response.body
356
- assert_equal "*", response.headers["Access-Control-Allow-Origin"]
357
- assert_equal "Origin", response.headers["Vary"]
358
- end
359
-
360
- def test_cors_adds_acao_to_error_response
361
- cors_app = Wsv::App.new(File.realpath(@dir), cors: true)
362
-
363
- response = cors_app.call(req("GET", "/missing.txt"))
364
-
365
- assert_equal 404, response.status
366
- assert_equal "*", response.headers["Access-Control-Allow-Origin"]
362
+ refute response.headers.key?("Access-Control-Allow-Origin")
363
+ refute response.headers.key?("Vary")
367
364
  end
368
365
 
369
366
  def test_cors_preflight_returns_204_with_methods_and_max_age
370
- cors_app = Wsv::App.new(File.realpath(@dir), cors: true)
367
+ cors_app = Wsv::App.new(File.realpath(@dir), cors: Wsv::Cors.new)
371
368
 
372
369
  response = cors_app.call(req("OPTIONS", "/x.txt"))
373
370
 
374
371
  assert_equal 204, response.status
375
- assert_equal "*", response.headers["Access-Control-Allow-Origin"]
376
372
  assert_equal "GET, HEAD, OPTIONS", response.headers["Access-Control-Allow-Methods"]
377
373
  assert_equal "86400", response.headers["Access-Control-Max-Age"]
378
- assert_equal "Origin", response.headers["Vary"]
379
374
  assert_equal "", response.body
380
375
  end
381
376
 
382
377
  def test_cors_preflight_echoes_requested_headers
383
- cors_app = Wsv::App.new(File.realpath(@dir), cors: true)
378
+ cors_app = Wsv::App.new(File.realpath(@dir), cors: Wsv::Cors.new)
384
379
 
385
380
  response = cors_app.call(req("OPTIONS", "/x.txt", "access-control-request-headers" => "X-Custom, Authorization"))
386
381
 
@@ -389,7 +384,7 @@ class AppTest < Minitest::Test
389
384
  end
390
385
 
391
386
  def test_cors_preflight_omits_allow_headers_when_not_requested
392
- cors_app = Wsv::App.new(File.realpath(@dir), cors: true)
387
+ cors_app = Wsv::App.new(File.realpath(@dir), cors: Wsv::Cors.new)
393
388
 
394
389
  response = cors_app.call(req("OPTIONS", "/x.txt"))
395
390
 
@@ -404,13 +399,12 @@ class AppTest < Minitest::Test
404
399
  end
405
400
 
406
401
  def test_cors_405_advertises_options_in_allow
407
- cors_app = Wsv::App.new(File.realpath(@dir), cors: true)
402
+ cors_app = Wsv::App.new(File.realpath(@dir), cors: Wsv::Cors.new)
408
403
 
409
404
  response = cors_app.call(req("POST", "/x.txt"))
410
405
 
411
406
  assert_equal 405, response.status
412
407
  assert_equal "GET, HEAD, OPTIONS", response.headers["Allow"]
413
- assert_equal "*", response.headers["Access-Control-Allow-Origin"]
414
408
  end
415
409
 
416
410
  def test_multipart_range_falls_through_to_200
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class BannerTest < Minitest::Test
6
+ include TlsTestHelpers
7
+
8
+ ROOT = Dir.tmpdir
9
+
10
+ def test_warns_when_binding_to_non_loopback
11
+ err = StringIO.new
12
+ build(host: "0.0.0.0", err: err).emit
13
+
14
+ assert_includes err.string, "WARNING"
15
+ assert_includes err.string, "0.0.0.0"
16
+ end
17
+
18
+ def test_no_warning_for_loopback_bind
19
+ err = StringIO.new
20
+ build(host: "127.0.0.1", err: err).emit
21
+
22
+ refute_includes err.string, "WARNING"
23
+ end
24
+
25
+ def test_warns_when_binding_to_ipv6_wildcard
26
+ err = StringIO.new
27
+ build(host: "::", err: err).emit
28
+
29
+ assert_includes err.string, "WARNING"
30
+ assert_includes err.string, "::"
31
+ end
32
+
33
+ def test_no_warning_for_ipv6_loopback
34
+ err = StringIO.new
35
+ build(host: "::1", err: err).emit
36
+
37
+ refute_includes err.string, "WARNING"
38
+ end
39
+
40
+ def test_brackets_ipv6_address_in_url
41
+ out = StringIO.new
42
+ build(host: "::1", port: 8000, out: out).emit
43
+
44
+ assert_includes out.string, "http://[::1]:8000/"
45
+ refute_includes out.string, "http://::1:8000/"
46
+ end
47
+
48
+ def test_percent_encodes_ipv6_zone_identifier
49
+ out = StringIO.new
50
+ build(host: "fe80::1%eth0", port: 8000, out: out).emit
51
+
52
+ assert_includes out.string, "http://[fe80::1%25eth0]:8000/"
53
+ end
54
+
55
+ def test_logs_https_scheme_when_tls_enabled
56
+ out = StringIO.new
57
+ build(host: "127.0.0.1", port: 8000, out: out, tls: ephemeral_tls).emit
58
+
59
+ assert_includes out.string, "https://"
60
+ end
61
+
62
+ def test_warns_about_self_signed_cert
63
+ err = StringIO.new
64
+ build(host: "127.0.0.1", err: err, tls: ephemeral_tls).emit
65
+
66
+ assert_includes err.string, "self-signed"
67
+ end
68
+
69
+ private
70
+
71
+ def build(host:, port: 0, root: ROOT, out: StringIO.new, err: StringIO.new, tls: nil)
72
+ Wsv::Server::Banner.new(host: host, port: port, root: root, out: out, err: err, tls: tls)
73
+ end
74
+ end
data/test/cli_test.rb CHANGED
@@ -17,7 +17,7 @@ class CLITest < Minitest::Test
17
17
 
18
18
  def test_host_port_and_directory
19
19
  Dir.mktmpdir do |dir|
20
- options = Wsv::CLI.new([]).parse_options(["-h", "127.0.0.1", "-p", "3000", dir])
20
+ options = Wsv::CLI.new([]).parse_options(["--host", "127.0.0.1", "-p", "3000", dir])
21
21
 
22
22
  assert_equal "127.0.0.1", options[:host]
23
23
  assert_equal 3000, options[:port]
@@ -25,6 +25,14 @@ class CLITest < Minitest::Test
25
25
  end
26
26
  end
27
27
 
28
+ def test_short_help_flag
29
+ out = StringIO.new
30
+ code = Wsv::CLI.new(["-h"], out: out).run
31
+
32
+ assert_equal 0, code
33
+ assert_includes out.string, "Usage: wsv"
34
+ end
35
+
28
36
  def test_long_host_port_and_directory
29
37
  Dir.mktmpdir do |dir|
30
38
  options = Wsv::CLI.new([]).parse_options(["--host", "localhost", "--port", "4567", dir])
@@ -35,6 +43,48 @@ class CLITest < Minitest::Test
35
43
  end
36
44
  end
37
45
 
46
+ def test_bare_ipv6_host
47
+ options = Wsv::CLI.new([]).parse_options(["--host", "::1"])
48
+
49
+ assert_equal "::1", options[:host]
50
+ end
51
+
52
+ def test_bracketed_ipv6_host_is_unwrapped
53
+ options = Wsv::CLI.new([]).parse_options(["--host", "[::1]"])
54
+
55
+ assert_equal "::1", options[:host]
56
+ end
57
+
58
+ def test_bracketed_ipv6_with_zone_id
59
+ options = Wsv::CLI.new([]).parse_options(["--host", "[fe80::1%en0]"])
60
+
61
+ assert_equal "fe80::1%en0", options[:host]
62
+ end
63
+
64
+ def test_host_with_port_suffix_rejected
65
+ err = StringIO.new
66
+ code = Wsv::CLI.new(["--host", "[::1]:8000"], err: err).run
67
+
68
+ assert_equal 1, code
69
+ assert_includes err.string, "must not include a port"
70
+ end
71
+
72
+ def test_host_unbalanced_brackets_rejected
73
+ err = StringIO.new
74
+ code = Wsv::CLI.new(["--host", "[::1"], err: err).run
75
+
76
+ assert_equal 1, code
77
+ assert_includes err.string, "unbalanced brackets"
78
+ end
79
+
80
+ def test_host_empty_brackets_rejected
81
+ err = StringIO.new
82
+ code = Wsv::CLI.new(["--host", "[]"], err: err).run
83
+
84
+ assert_equal 1, code
85
+ assert_includes err.string, "bracket value is empty"
86
+ end
87
+
38
88
  def test_help
39
89
  out = StringIO.new
40
90
  code = Wsv::CLI.new(["--help"], out: out).run
@@ -62,6 +112,14 @@ class CLITest < Minitest::Test
62
112
  assert_includes err.string, "port must be between 1 and 65535"
63
113
  end
64
114
 
115
+ def test_port_above_max_rejected
116
+ err = StringIO.new
117
+ code = Wsv::CLI.new(["-p", "65536"], err: err).run
118
+
119
+ assert_equal 1, code
120
+ assert_includes err.string, "port must be between 1 and 65535"
121
+ end
122
+
65
123
  def test_missing_directory
66
124
  err = StringIO.new
67
125
  missing = File.join(Dir.tmpdir, "wsv-missing-#{Time.now.to_i}-#{$$}")
@@ -89,6 +147,19 @@ class CLITest < Minitest::Test
89
147
  end
90
148
  end
91
149
 
150
+ def test_missing_cert_file_errors_at_runtime
151
+ err = StringIO.new
152
+ Dir.mktmpdir do |dir|
153
+ missing_cert = File.join(dir, "missing-cert.pem")
154
+ missing_key = File.join(dir, "missing-key.pem")
155
+ code = Wsv::CLI.new(["--cert", missing_cert, "--key", missing_key, dir], err: err).run
156
+
157
+ assert_equal 1, code
158
+ assert_includes err.string, "wsv:"
159
+ assert_includes err.string, "missing-cert.pem"
160
+ end
161
+ end
162
+
92
163
  def test_cert_and_key_parses
93
164
  Dir.mktmpdir do |dir|
94
165
  options = Wsv::CLI.new([]).parse_options(["--cert", "a.pem", "--key", "b.pem", dir])
data/test/cors_test.rb ADDED
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "test_helper"
4
+
5
+ class CorsTest < Minitest::Test
6
+ def setup
7
+ @cors = Wsv::Cors.new
8
+ end
9
+
10
+ def test_allow_methods_returns_array
11
+ assert_equal %w[GET HEAD OPTIONS], @cors.allow_methods
12
+ end
13
+
14
+ # `Cors#preflight` returns the preflight-specific headers only;
15
+ # Server::Connection adds ACAO / Vary on top, uniformly.
16
+
17
+ def test_preflight_returns_204
18
+ response = @cors.preflight(req)
19
+
20
+ assert_equal 204, response.status
21
+ end
22
+
23
+ def test_preflight_includes_allow_methods_and_max_age
24
+ response = @cors.preflight(req)
25
+
26
+ assert_equal "GET, HEAD, OPTIONS", response.headers["Access-Control-Allow-Methods"]
27
+ assert_equal "86400", response.headers["Access-Control-Max-Age"]
28
+ end
29
+
30
+ def test_preflight_omits_acao_and_vary
31
+ response = @cors.preflight(req)
32
+
33
+ refute response.headers.key?("Access-Control-Allow-Origin")
34
+ refute response.headers.key?("Vary")
35
+ end
36
+
37
+ def test_preflight_echoes_requested_headers
38
+ response = @cors.preflight(req("access-control-request-headers" => "X-Custom, Authorization"))
39
+
40
+ assert_equal "X-Custom, Authorization", response.headers["Access-Control-Allow-Headers"]
41
+ end
42
+
43
+ def test_preflight_omits_allow_headers_when_not_requested
44
+ response = @cors.preflight(req)
45
+
46
+ refute response.headers.key?("Access-Control-Allow-Headers")
47
+ end
48
+
49
+ def test_overlay_adds_acao_and_vary
50
+ base = Wsv::Response.text(200)
51
+ overlaid = @cors.overlay(base)
52
+
53
+ assert_equal "*", overlaid.headers["Access-Control-Allow-Origin"]
54
+ assert_equal "Origin", overlaid.headers["Vary"]
55
+ end
56
+
57
+ def test_overlay_preserves_existing_headers
58
+ base = Wsv::Response.text(404)
59
+ overlaid = @cors.overlay(base)
60
+
61
+ assert_equal "text/plain; charset=utf-8", overlaid.headers["Content-Type"]
62
+ end
63
+
64
+ def test_overlay_returns_a_new_response_keeping_status
65
+ base = Wsv::Response.text(404)
66
+ overlaid = @cors.overlay(base)
67
+
68
+ assert_equal 404, overlaid.status
69
+ end
70
+
71
+ private
72
+
73
+ def req(headers = {})
74
+ Wsv::Request.new(method: "OPTIONS", target: "/", version: "HTTP/1.1", headers: headers)
75
+ end
76
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "socket"
5
+ require_relative "test_helper"
6
+
7
+ # Covers two related extension points:
8
+ #
9
+ # * `Wsv::Server.new(app:)` — DI a custom request handler
10
+ # * `Wsv::Response.sse { |io| ... }` — long-lived Server-Sent Events responses
11
+ class CustomAppTest < Minitest::Test
12
+ def setup
13
+ @dir = Dir.mktmpdir
14
+ @server = nil
15
+ @thread = nil
16
+ end
17
+
18
+ def teardown
19
+ @server&.stop
20
+ @thread&.join(2)
21
+ FileUtils.remove_entry(@dir)
22
+ end
23
+
24
+ def test_custom_app_handles_requests_in_place_of_default
25
+ app = Class.new do
26
+ def call(_request)
27
+ Wsv::Response.new(
28
+ status: 200,
29
+ headers: { "Content-Type" => "text/plain", "Content-Length" => "8" },
30
+ body: "from-app"
31
+ )
32
+ end
33
+ end.new
34
+
35
+ start_server(app: app)
36
+ response = get("/anything")
37
+
38
+ assert_equal "200", response.code
39
+ assert_equal "from-app", response.body
40
+ end
41
+
42
+ def test_sse_response_delivers_chunks_in_order
43
+ chunks = ["data: one\n\n", "data: two\n\n", "data: three\n\n"]
44
+ app = Class.new do
45
+ define_method(:call) do |_request|
46
+ Wsv::Response.sse do |io|
47
+ chunks.each do |c|
48
+ io.write(c)
49
+ io.flush
50
+ end
51
+ end
52
+ end
53
+ end.new
54
+
55
+ start_server(app: app)
56
+ body = read_full_body("/events")
57
+
58
+ chunks.each { |c| assert_includes body, c }
59
+ assert_operator body.index(chunks[0]), :<, body.index(chunks[1])
60
+ assert_operator body.index(chunks[1]), :<, body.index(chunks[2])
61
+ end
62
+
63
+ def test_sse_response_uses_event_stream_content_type
64
+ app = Class.new do
65
+ def call(_request)
66
+ Wsv::Response.sse { |io| io.write("data: hi\n\n") }
67
+ end
68
+ end.new
69
+
70
+ start_server(app: app)
71
+ response = get("/stream")
72
+
73
+ assert_equal "200", response.code
74
+ assert_match %r{text/event-stream}, response["content-type"]
75
+ refute response["content-length"], "sse must not advertise Content-Length"
76
+ end
77
+
78
+ private
79
+
80
+ def start_server(app:)
81
+ @server = Wsv::Server.new(
82
+ host: "127.0.0.1",
83
+ port: free_port,
84
+ root: @dir,
85
+ out: StringIO.new,
86
+ err: StringIO.new,
87
+ app: app
88
+ )
89
+ @thread = Thread.new { @server.start }
90
+ wait_until_ready
91
+ end
92
+
93
+ def free_port
94
+ s = TCPServer.new("127.0.0.1", 0)
95
+ s.addr[1]
96
+ ensure
97
+ s&.close
98
+ end
99
+
100
+ def wait_until_ready
101
+ deadline = Time.now + 2
102
+ loop do
103
+ TCPSocket.open("127.0.0.1", @server.port).close
104
+ break
105
+ rescue Errno::ECONNREFUSED
106
+ raise if Time.now >= deadline
107
+
108
+ sleep 0.01
109
+ end
110
+ end
111
+
112
+ def get(path)
113
+ Net::HTTP.get_response(URI("http://127.0.0.1:#{@server.port}#{path}"))
114
+ end
115
+
116
+ # Read until the server closes the connection (streaming responses
117
+ # omit Content-Length so Net::HTTP would still read-to-EOF, but for
118
+ # clarity we use a raw socket here).
119
+ def read_full_body(path)
120
+ socket = TCPSocket.open("127.0.0.1", @server.port)
121
+ socket.write("GET #{path} HTTP/1.1\r\nHost: localhost\r\n\r\n")
122
+ raw = socket.read
123
+ header_end = raw.index("\r\n\r\n")
124
+ raw[(header_end + 4)..]
125
+ ensure
126
+ socket&.close
127
+ end
128
+ end
@@ -13,6 +13,14 @@ class PathResolverTest < Minitest::Test
13
13
  FileUtils.remove_entry(@dir)
14
14
  end
15
15
 
16
+ def test_empty_path_returns_redirect
17
+ # `""` has no trailing slash → resolver returns redirect the same
18
+ # way it would for `/somedir` when `somedir` is a directory.
19
+ result = @resolver.resolve("")
20
+
21
+ assert_predicate result, :redirect?
22
+ end
23
+
16
24
  def test_resolves_existing_file
17
25
  path = File.join(@dir, "hello.txt")
18
26
  File.write(path, "hi")