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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +57 -0
- data/README.md +9 -3
- data/lib/wsv/app.rb +12 -56
- data/lib/wsv/cli.rb +24 -4
- data/lib/wsv/cors.rb +7 -5
- data/lib/wsv/range_request.rb +91 -0
- data/lib/wsv/response/sse_body.rb +35 -0
- data/lib/wsv/response/sse_builder.rb +42 -0
- data/lib/wsv/response.rb +12 -0
- data/lib/wsv/server/access_log.rb +48 -0
- data/lib/wsv/server/connection.rb +59 -21
- data/lib/wsv/server.rb +22 -5
- data/lib/wsv/version.rb +1 -1
- data/lib/wsv.rb +1 -0
- data/test/access_log_test.rb +94 -0
- data/test/app_test.rb +23 -29
- data/test/banner_test.rb +74 -0
- data/test/cli_test.rb +72 -1
- data/test/cors_test.rb +76 -0
- data/test/custom_app_test.rb +128 -0
- data/test/path_resolver_test.rb +8 -0
- data/test/range_request_test.rb +124 -0
- data/test/server_test.rb +92 -70
- data/test/sse_test.rb +88 -0
- data/test/test_helper.rb +8 -0
- metadata +12 -2
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
|
-
@
|
|
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
|
|
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
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
|
-
|
|
341
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
355
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
data/test/banner_test.rb
ADDED
|
@@ -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(["
|
|
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
|
data/test/path_resolver_test.rb
CHANGED
|
@@ -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")
|