wsv 0.10.1 → 0.11.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 +21 -0
- data/lib/wsv/app.rb +12 -56
- data/lib/wsv/cors.rb +7 -5
- data/lib/wsv/range_request.rb +91 -0
- data/lib/wsv/server/connection.rb +10 -2
- data/lib/wsv/server.rb +6 -4
- data/lib/wsv/version.rb +1 -1
- data/lib/wsv.rb +1 -0
- data/test/app_test.rb +12 -29
- data/test/banner_test.rb +59 -0
- data/test/cors_test.rb +76 -0
- data/test/range_request_test.rb +103 -0
- data/test/server_test.rb +67 -71
- data/test/test_helper.rb +8 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: adfd569857554409cef36ce05205eb50f833a58a27e654e1da80646afa2ec40e
|
|
4
|
+
data.tar.gz: 8d5c75289dd59dc65412ebff24bf0cf4d9aa9933befb17354f9b2fb3320b5ca3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b4514041977a6c9a599ea9f2a3735f7b4668ab18172b974453293c444c4441221e068f9374cbdd6c5058b99ecb776edbe47bda3bff4fd77731dbeb9b45cd9446
|
|
7
|
+
data.tar.gz: f1d06286b164b0f70dce52d7fd86b9fdf29c64f6cbc0515e3d26558afb4498d23277fc23b66709298afffb593e321a21498cc55dea2ab8b58e31186b48aaa021
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.11.0
|
|
4
|
+
|
|
5
|
+
- Extract `Wsv::RangeRequest` from `App`. RFC 7233 range parsing
|
|
6
|
+
(suffix / open-ended / bounded ranges, unsatisfiability) now lives
|
|
7
|
+
in its own class with a `Result` value object (`#full?` /
|
|
8
|
+
`#partial?` / `#unsatisfiable?` predicates plus `#bounds`),
|
|
9
|
+
matching the `PathResolver::Result` pattern. Behavior unchanged.
|
|
10
|
+
- Make `Server::Connection` the sole place that adds CORS overlay
|
|
11
|
+
headers (`Access-Control-Allow-Origin`, `Vary`) on outgoing
|
|
12
|
+
responses. Previously `App` applied the overlay on its return
|
|
13
|
+
value, but rescue-path responses (408 / 414 / 431 / 503 / unmapped
|
|
14
|
+
400) bypassed `App` and therefore lacked CORS headers. Now every
|
|
15
|
+
response — `App`, parser errors, timeouts, and the 503 rejection —
|
|
16
|
+
gets the overlay uniformly. `Cors#preflight` no longer includes
|
|
17
|
+
ACAO/Vary itself, since Connection adds them.
|
|
18
|
+
- `Wsv::App.new(... cors:)` now expects a `Wsv::Cors` instance (or
|
|
19
|
+
`nil`) instead of a Boolean. `Wsv::Server.new(... cors: true/false)`
|
|
20
|
+
is unchanged. Per the README's stability statement, the Ruby API
|
|
21
|
+
under `lib/wsv/` is implementation-only and may change in any
|
|
22
|
+
release.
|
|
23
|
+
|
|
3
24
|
## 0.10.1
|
|
4
25
|
|
|
5
26
|
- Update gem description and README tagline to position wsv as
|
data/lib/wsv/app.rb
CHANGED
|
@@ -4,24 +4,23 @@ require "time"
|
|
|
4
4
|
require "uri"
|
|
5
5
|
require_relative "cors"
|
|
6
6
|
require_relative "path_resolver"
|
|
7
|
+
require_relative "range_request"
|
|
7
8
|
require_relative "response"
|
|
8
9
|
|
|
9
10
|
module Wsv
|
|
10
11
|
class App
|
|
11
12
|
ALLOWED_METHODS = %w[GET HEAD].freeze
|
|
12
|
-
RANGE_PATTERN = /\Abytes=(\d+)?-(\d+)?\z/
|
|
13
13
|
|
|
14
|
-
def initialize(root, spa: false, cors:
|
|
14
|
+
def initialize(root, spa: false, cors: nil)
|
|
15
15
|
@resolver = PathResolver.new(root)
|
|
16
16
|
@spa = spa
|
|
17
|
-
@cors =
|
|
17
|
+
@cors = cors
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def call(request)
|
|
21
21
|
return @cors.preflight(request) if @cors && request.method == "OPTIONS"
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
@cors ? @cors.overlay(response) : response
|
|
23
|
+
build_response(request)
|
|
25
24
|
end
|
|
26
25
|
|
|
27
26
|
private
|
|
@@ -30,7 +29,7 @@ module Wsv
|
|
|
30
29
|
head = request.head?
|
|
31
30
|
|
|
32
31
|
unless ALLOWED_METHODS.include?(request.method)
|
|
33
|
-
return Response.text(405, headers: { "Allow" =>
|
|
32
|
+
return Response.text(405, headers: { "Allow" => allow_header }, head: head)
|
|
34
33
|
end
|
|
35
34
|
|
|
36
35
|
raw_path, query = request.target.split("?", 2)
|
|
@@ -51,8 +50,8 @@ module Wsv
|
|
|
51
50
|
file_response(result.file, request, head: head)
|
|
52
51
|
end
|
|
53
52
|
|
|
54
|
-
def
|
|
55
|
-
@cors
|
|
53
|
+
def allow_header
|
|
54
|
+
(@cors&.allow_methods || ALLOWED_METHODS).join(", ")
|
|
56
55
|
end
|
|
57
56
|
|
|
58
57
|
def error_response(status, head:)
|
|
@@ -69,15 +68,11 @@ module Wsv
|
|
|
69
68
|
return Response.not_modified if not_modified?(file, request.headers["if-modified-since"])
|
|
70
69
|
|
|
71
70
|
size = File.size(file)
|
|
72
|
-
range =
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
Response.file(file, head: head)
|
|
78
|
-
else
|
|
79
|
-
Response.file(file, head: head, range: range)
|
|
80
|
-
end
|
|
71
|
+
range = RangeRequest.parse(request.headers["range"], size)
|
|
72
|
+
return Response.range_not_satisfiable(size, head: head) if range.unsatisfiable?
|
|
73
|
+
return Response.file(file, head: head) if range.full?
|
|
74
|
+
|
|
75
|
+
Response.file(file, head: head, range: range.bounds)
|
|
81
76
|
end
|
|
82
77
|
|
|
83
78
|
def not_modified?(file, header_value)
|
|
@@ -89,45 +84,6 @@ module Wsv
|
|
|
89
84
|
false
|
|
90
85
|
end
|
|
91
86
|
|
|
92
|
-
def parse_range(header_value, file_size)
|
|
93
|
-
return nil if header_value.nil? || header_value.empty?
|
|
94
|
-
|
|
95
|
-
match = header_value.match(RANGE_PATTERN)
|
|
96
|
-
# Per RFC 7233, an unparseable Range is treated as if absent: fall
|
|
97
|
-
# through as nil so the caller serves a normal 200 instead of 416.
|
|
98
|
-
return nil unless match
|
|
99
|
-
|
|
100
|
-
first, last = match.captures
|
|
101
|
-
if first.nil? && last.nil?
|
|
102
|
-
nil
|
|
103
|
-
elsif first.nil?
|
|
104
|
-
suffix_range(last.to_i, file_size)
|
|
105
|
-
elsif last.nil?
|
|
106
|
-
open_range(first.to_i, file_size)
|
|
107
|
-
else
|
|
108
|
-
bounded_range(first.to_i, last.to_i, file_size)
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def suffix_range(suffix, file_size)
|
|
113
|
-
return :unsatisfiable if suffix.zero? || file_size.zero?
|
|
114
|
-
|
|
115
|
-
[file_size - suffix, 0].max..(file_size - 1)
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def open_range(first, file_size)
|
|
119
|
-
return :unsatisfiable if first >= file_size
|
|
120
|
-
|
|
121
|
-
first..(file_size - 1)
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
def bounded_range(first, last, file_size)
|
|
125
|
-
return :unsatisfiable if first > last || first >= file_size
|
|
126
|
-
|
|
127
|
-
last = file_size - 1 if last >= file_size
|
|
128
|
-
first..last
|
|
129
|
-
end
|
|
130
|
-
|
|
131
87
|
def redirect_location(raw_path, query)
|
|
132
88
|
path = URI(raw_path.to_s).path
|
|
133
89
|
path = "/" if path.empty?
|
data/lib/wsv/cors.rb
CHANGED
|
@@ -5,15 +5,17 @@ require_relative "response"
|
|
|
5
5
|
module Wsv
|
|
6
6
|
class Cors
|
|
7
7
|
ALLOW_ORIGIN = "*"
|
|
8
|
-
ALLOW_METHODS =
|
|
8
|
+
ALLOW_METHODS = %w[GET HEAD OPTIONS].freeze
|
|
9
9
|
MAX_AGE = "86400"
|
|
10
10
|
|
|
11
|
+
def allow_methods
|
|
12
|
+
ALLOW_METHODS
|
|
13
|
+
end
|
|
14
|
+
|
|
11
15
|
def preflight(request)
|
|
12
16
|
headers = {
|
|
13
|
-
"Access-Control-Allow-
|
|
14
|
-
"Access-Control-
|
|
15
|
-
"Access-Control-Max-Age" => MAX_AGE,
|
|
16
|
-
"Vary" => "Origin"
|
|
17
|
+
"Access-Control-Allow-Methods" => ALLOW_METHODS.join(", "),
|
|
18
|
+
"Access-Control-Max-Age" => MAX_AGE
|
|
17
19
|
}
|
|
18
20
|
requested = request.headers["access-control-request-headers"]
|
|
19
21
|
headers["Access-Control-Allow-Headers"] = requested if requested
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Wsv
|
|
4
|
+
# Parses an HTTP `Range` header (RFC 7233) against a known file size.
|
|
5
|
+
class RangeRequest
|
|
6
|
+
PATTERN = /\Abytes=(\d+)?-(\d+)?\z/
|
|
7
|
+
|
|
8
|
+
class Result
|
|
9
|
+
attr_reader :bounds
|
|
10
|
+
|
|
11
|
+
def initialize(kind:, bounds: nil)
|
|
12
|
+
@kind = kind
|
|
13
|
+
@bounds = bounds
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def full?
|
|
17
|
+
@kind == :full
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def partial?
|
|
21
|
+
@kind == :partial
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def unsatisfiable?
|
|
25
|
+
@kind == :unsatisfiable
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.full
|
|
29
|
+
new(kind: :full)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.partial(bounds)
|
|
33
|
+
new(kind: :partial, bounds: bounds)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.unsatisfiable
|
|
37
|
+
new(kind: :unsatisfiable)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.parse(header_value, file_size)
|
|
42
|
+
new(header_value, file_size).parse
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize(header_value, file_size)
|
|
46
|
+
@header_value = header_value
|
|
47
|
+
@file_size = file_size
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def parse
|
|
51
|
+
return Result.full if @header_value.nil? || @header_value.empty?
|
|
52
|
+
|
|
53
|
+
match = @header_value.match(PATTERN)
|
|
54
|
+
# Per RFC 7233, an unparseable Range is treated as if absent: return
|
|
55
|
+
# full so the caller serves a normal 200 instead of 416.
|
|
56
|
+
return Result.full unless match
|
|
57
|
+
|
|
58
|
+
first, last = match.captures
|
|
59
|
+
if first.nil? && last.nil?
|
|
60
|
+
Result.full
|
|
61
|
+
elsif first.nil?
|
|
62
|
+
suffix_range(last.to_i)
|
|
63
|
+
elsif last.nil?
|
|
64
|
+
open_range(first.to_i)
|
|
65
|
+
else
|
|
66
|
+
bounded_range(first.to_i, last.to_i)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def suffix_range(suffix)
|
|
73
|
+
return Result.unsatisfiable if suffix.zero? || @file_size.zero?
|
|
74
|
+
|
|
75
|
+
Result.partial([@file_size - suffix, 0].max..(@file_size - 1))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def open_range(first)
|
|
79
|
+
return Result.unsatisfiable if first >= @file_size
|
|
80
|
+
|
|
81
|
+
Result.partial(first..(@file_size - 1))
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def bounded_range(first, last)
|
|
85
|
+
return Result.unsatisfiable if first > last || first >= @file_size
|
|
86
|
+
|
|
87
|
+
last = @file_size - 1 if last >= @file_size
|
|
88
|
+
Result.partial(first..last)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -13,9 +13,10 @@ module Wsv
|
|
|
13
13
|
class Connection
|
|
14
14
|
DRAIN_TIMEOUT = 5
|
|
15
15
|
|
|
16
|
-
def initialize(client, err:)
|
|
16
|
+
def initialize(client, err:, cors: nil)
|
|
17
17
|
@client = client
|
|
18
18
|
@err = err
|
|
19
|
+
@cors = cors
|
|
19
20
|
end
|
|
20
21
|
|
|
21
22
|
def serve(app, read_timeout:)
|
|
@@ -50,14 +51,21 @@ module Wsv
|
|
|
50
51
|
|
|
51
52
|
private
|
|
52
53
|
|
|
54
|
+
# Connection is the sole place that adds ACAO / Vary headers, so every
|
|
55
|
+
# response (App, parser errors, timeouts, the 503 rejection) gets them
|
|
56
|
+
# uniformly when CORS is enabled.
|
|
53
57
|
def write(response)
|
|
54
58
|
return if @client.closed?
|
|
55
59
|
|
|
56
|
-
response.write_to(@client)
|
|
60
|
+
finalize(response).write_to(@client)
|
|
57
61
|
rescue Errno::EPIPE, Errno::ECONNRESET, IOError
|
|
58
62
|
nil
|
|
59
63
|
end
|
|
60
64
|
|
|
65
|
+
def finalize(response)
|
|
66
|
+
@cors ? @cors.overlay(response) : response
|
|
67
|
+
end
|
|
68
|
+
|
|
61
69
|
def graceful_close
|
|
62
70
|
return if @client.closed?
|
|
63
71
|
|
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"
|
|
@@ -37,7 +38,8 @@ module Wsv
|
|
|
37
38
|
@tls = tls
|
|
38
39
|
@ssl_context = tls&.to_ssl_context
|
|
39
40
|
@open = open
|
|
40
|
-
@
|
|
41
|
+
@cors = Cors.new if cors
|
|
42
|
+
@app = App.new(@root, spa: spa, cors: @cors)
|
|
41
43
|
@throttle = ConnectionThrottle.new(max: max_connections, err: err)
|
|
42
44
|
@running = false
|
|
43
45
|
end
|
|
@@ -88,7 +90,7 @@ module Wsv
|
|
|
88
90
|
|
|
89
91
|
def spawn_handler(client)
|
|
90
92
|
accepted = @throttle.try_spawn do
|
|
91
|
-
Connection.new(maybe_wrap_tls(client), err: @err).serve(@app, read_timeout: @read_timeout)
|
|
93
|
+
Connection.new(maybe_wrap_tls(client), err: @err, cors: @cors).serve(@app, read_timeout: @read_timeout)
|
|
92
94
|
end
|
|
93
95
|
spawn_rejection(client) unless accepted
|
|
94
96
|
end
|
|
@@ -102,10 +104,10 @@ module Wsv
|
|
|
102
104
|
reply = !@ssl_context
|
|
103
105
|
Thread.new do
|
|
104
106
|
Thread.current.report_on_exception = false
|
|
105
|
-
Connection.new(client, err: @err).reject(reply: reply)
|
|
107
|
+
Connection.new(client, err: @err, cors: @cors).reject(reply: reply)
|
|
106
108
|
end
|
|
107
109
|
rescue ThreadError
|
|
108
|
-
Connection.new(client, err: @err).reject(reply: reply)
|
|
110
|
+
Connection.new(client, err: @err, cors: @cors).reject(reply: reply)
|
|
109
111
|
end
|
|
110
112
|
|
|
111
113
|
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"
|
data/test/app_test.rb
CHANGED
|
@@ -337,50 +337,34 @@ class AppTest < Minitest::Test
|
|
|
337
337
|
assert_equal "bytes", response.headers["Accept-Ranges"]
|
|
338
338
|
end
|
|
339
339
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
340
|
+
# CORS overlay (Access-Control-Allow-Origin / Vary) is applied by
|
|
341
|
+
# Server::Connection, not by App. App only short-circuits OPTIONS preflight
|
|
342
|
+
# and adds OPTIONS to the Allow header. End-to-end CORS behavior is covered
|
|
343
|
+
# by server_test.rb.
|
|
344
344
|
|
|
345
|
-
|
|
346
|
-
end
|
|
347
|
-
|
|
348
|
-
def test_cors_adds_acao_to_successful_response
|
|
345
|
+
def test_app_does_not_add_cors_headers_directly
|
|
349
346
|
File.write(File.join(@dir, "x.txt"), "hi")
|
|
350
|
-
cors_app = Wsv::App.new(File.realpath(@dir), cors:
|
|
347
|
+
cors_app = Wsv::App.new(File.realpath(@dir), cors: Wsv::Cors.new)
|
|
351
348
|
|
|
352
349
|
response = cors_app.call(req("GET", "/x.txt"))
|
|
353
350
|
|
|
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"]
|
|
351
|
+
refute response.headers.key?("Access-Control-Allow-Origin")
|
|
352
|
+
refute response.headers.key?("Vary")
|
|
367
353
|
end
|
|
368
354
|
|
|
369
355
|
def test_cors_preflight_returns_204_with_methods_and_max_age
|
|
370
|
-
cors_app = Wsv::App.new(File.realpath(@dir), cors:
|
|
356
|
+
cors_app = Wsv::App.new(File.realpath(@dir), cors: Wsv::Cors.new)
|
|
371
357
|
|
|
372
358
|
response = cors_app.call(req("OPTIONS", "/x.txt"))
|
|
373
359
|
|
|
374
360
|
assert_equal 204, response.status
|
|
375
|
-
assert_equal "*", response.headers["Access-Control-Allow-Origin"]
|
|
376
361
|
assert_equal "GET, HEAD, OPTIONS", response.headers["Access-Control-Allow-Methods"]
|
|
377
362
|
assert_equal "86400", response.headers["Access-Control-Max-Age"]
|
|
378
|
-
assert_equal "Origin", response.headers["Vary"]
|
|
379
363
|
assert_equal "", response.body
|
|
380
364
|
end
|
|
381
365
|
|
|
382
366
|
def test_cors_preflight_echoes_requested_headers
|
|
383
|
-
cors_app = Wsv::App.new(File.realpath(@dir), cors:
|
|
367
|
+
cors_app = Wsv::App.new(File.realpath(@dir), cors: Wsv::Cors.new)
|
|
384
368
|
|
|
385
369
|
response = cors_app.call(req("OPTIONS", "/x.txt", "access-control-request-headers" => "X-Custom, Authorization"))
|
|
386
370
|
|
|
@@ -389,7 +373,7 @@ class AppTest < Minitest::Test
|
|
|
389
373
|
end
|
|
390
374
|
|
|
391
375
|
def test_cors_preflight_omits_allow_headers_when_not_requested
|
|
392
|
-
cors_app = Wsv::App.new(File.realpath(@dir), cors:
|
|
376
|
+
cors_app = Wsv::App.new(File.realpath(@dir), cors: Wsv::Cors.new)
|
|
393
377
|
|
|
394
378
|
response = cors_app.call(req("OPTIONS", "/x.txt"))
|
|
395
379
|
|
|
@@ -404,13 +388,12 @@ class AppTest < Minitest::Test
|
|
|
404
388
|
end
|
|
405
389
|
|
|
406
390
|
def test_cors_405_advertises_options_in_allow
|
|
407
|
-
cors_app = Wsv::App.new(File.realpath(@dir), cors:
|
|
391
|
+
cors_app = Wsv::App.new(File.realpath(@dir), cors: Wsv::Cors.new)
|
|
408
392
|
|
|
409
393
|
response = cors_app.call(req("POST", "/x.txt"))
|
|
410
394
|
|
|
411
395
|
assert_equal 405, response.status
|
|
412
396
|
assert_equal "GET, HEAD, OPTIONS", response.headers["Allow"]
|
|
413
|
-
assert_equal "*", response.headers["Access-Control-Allow-Origin"]
|
|
414
397
|
end
|
|
415
398
|
|
|
416
399
|
def test_multipart_range_falls_through_to_200
|
data/test/banner_test.rb
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
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_brackets_ipv6_address_in_url
|
|
26
|
+
out = StringIO.new
|
|
27
|
+
build(host: "::1", port: 8000, out: out).emit
|
|
28
|
+
|
|
29
|
+
assert_includes out.string, "http://[::1]:8000/"
|
|
30
|
+
refute_includes out.string, "http://::1:8000/"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def test_percent_encodes_ipv6_zone_identifier
|
|
34
|
+
out = StringIO.new
|
|
35
|
+
build(host: "fe80::1%eth0", port: 8000, out: out).emit
|
|
36
|
+
|
|
37
|
+
assert_includes out.string, "http://[fe80::1%25eth0]:8000/"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def test_logs_https_scheme_when_tls_enabled
|
|
41
|
+
out = StringIO.new
|
|
42
|
+
build(host: "127.0.0.1", port: 8000, out: out, tls: ephemeral_tls).emit
|
|
43
|
+
|
|
44
|
+
assert_includes out.string, "https://"
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def test_warns_about_self_signed_cert
|
|
48
|
+
err = StringIO.new
|
|
49
|
+
build(host: "127.0.0.1", err: err, tls: ephemeral_tls).emit
|
|
50
|
+
|
|
51
|
+
assert_includes err.string, "self-signed"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
private
|
|
55
|
+
|
|
56
|
+
def build(host:, port: 0, root: ROOT, out: StringIO.new, err: StringIO.new, tls: nil)
|
|
57
|
+
Wsv::Server::Banner.new(host: host, port: port, root: root, out: out, err: err, tls: tls)
|
|
58
|
+
end
|
|
59
|
+
end
|
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,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class RangeRequestTest < Minitest::Test
|
|
6
|
+
def test_nil_header_is_full
|
|
7
|
+
result = Wsv::RangeRequest.parse(nil, 100)
|
|
8
|
+
|
|
9
|
+
assert_predicate result, :full?
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def test_empty_header_is_full
|
|
13
|
+
result = Wsv::RangeRequest.parse("", 100)
|
|
14
|
+
|
|
15
|
+
assert_predicate result, :full?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def test_unparseable_syntax_is_full
|
|
19
|
+
# Per RFC 7233 an unparseable Range is treated as if absent.
|
|
20
|
+
result = Wsv::RangeRequest.parse("garbage", 100)
|
|
21
|
+
|
|
22
|
+
assert_predicate result, :full?
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def test_empty_range_is_full
|
|
26
|
+
# `bytes=-` matches the regex but yields no bounds; treat as absent.
|
|
27
|
+
result = Wsv::RangeRequest.parse("bytes=-", 100)
|
|
28
|
+
|
|
29
|
+
assert_predicate result, :full?
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def test_bounded_range
|
|
33
|
+
result = Wsv::RangeRequest.parse("bytes=2-5", 100)
|
|
34
|
+
|
|
35
|
+
assert_predicate result, :partial?
|
|
36
|
+
assert_equal 2..5, result.bounds
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def test_open_range
|
|
40
|
+
result = Wsv::RangeRequest.parse("bytes=5-", 10)
|
|
41
|
+
|
|
42
|
+
assert_predicate result, :partial?
|
|
43
|
+
assert_equal 5..9, result.bounds
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def test_suffix_range
|
|
47
|
+
result = Wsv::RangeRequest.parse("bytes=-3", 10)
|
|
48
|
+
|
|
49
|
+
assert_predicate result, :partial?
|
|
50
|
+
assert_equal 7..9, result.bounds
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def test_suffix_larger_than_file_clamps_to_zero
|
|
54
|
+
result = Wsv::RangeRequest.parse("bytes=-99", 10)
|
|
55
|
+
|
|
56
|
+
assert_predicate result, :partial?
|
|
57
|
+
assert_equal 0..9, result.bounds
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def test_bounded_last_past_file_clamps_to_end
|
|
61
|
+
result = Wsv::RangeRequest.parse("bytes=5-99", 10)
|
|
62
|
+
|
|
63
|
+
assert_predicate result, :partial?
|
|
64
|
+
assert_equal 5..9, result.bounds
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def test_zero_byte_suffix_is_unsatisfiable
|
|
68
|
+
result = Wsv::RangeRequest.parse("bytes=-0", 10)
|
|
69
|
+
|
|
70
|
+
assert_predicate result, :unsatisfiable?
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def test_suffix_against_empty_file_is_unsatisfiable
|
|
74
|
+
result = Wsv::RangeRequest.parse("bytes=-3", 0)
|
|
75
|
+
|
|
76
|
+
assert_predicate result, :unsatisfiable?
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def test_open_range_past_file_is_unsatisfiable
|
|
80
|
+
result = Wsv::RangeRequest.parse("bytes=10-", 5)
|
|
81
|
+
|
|
82
|
+
assert_predicate result, :unsatisfiable?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def test_bounded_first_past_file_is_unsatisfiable
|
|
86
|
+
result = Wsv::RangeRequest.parse("bytes=10-20", 5)
|
|
87
|
+
|
|
88
|
+
assert_predicate result, :unsatisfiable?
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def test_inverted_bounded_range_is_unsatisfiable
|
|
92
|
+
result = Wsv::RangeRequest.parse("bytes=5-3", 100)
|
|
93
|
+
|
|
94
|
+
assert_predicate result, :unsatisfiable?
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def test_multipart_range_is_full
|
|
98
|
+
# `bytes=0-2,5-7` doesn't match the single-range regex; treat as absent.
|
|
99
|
+
result = Wsv::RangeRequest.parse("bytes=0-2,5-7", 100)
|
|
100
|
+
|
|
101
|
+
assert_predicate result, :full?
|
|
102
|
+
end
|
|
103
|
+
end
|
data/test/server_test.rb
CHANGED
|
@@ -5,6 +5,8 @@ require "socket"
|
|
|
5
5
|
require_relative "test_helper"
|
|
6
6
|
|
|
7
7
|
class ServerTest < Minitest::Test
|
|
8
|
+
include TlsTestHelpers
|
|
9
|
+
|
|
8
10
|
def setup
|
|
9
11
|
@dir = Dir.mktmpdir
|
|
10
12
|
@server = nil
|
|
@@ -164,55 +166,74 @@ class ServerTest < Minitest::Test
|
|
|
164
166
|
socket&.close
|
|
165
167
|
end
|
|
166
168
|
|
|
167
|
-
def
|
|
168
|
-
|
|
169
|
-
start_server(read_timeout: 5)
|
|
170
|
-
|
|
171
|
-
slow_socket = TCPSocket.open("127.0.0.1", @server.port)
|
|
169
|
+
def test_408_carries_cors_header_when_cors_enabled
|
|
170
|
+
start_server(read_timeout: 0.1, cors: true)
|
|
172
171
|
|
|
173
|
-
|
|
174
|
-
response =
|
|
175
|
-
elapsed = Time.now - started
|
|
172
|
+
socket = TCPSocket.open("127.0.0.1", @server.port)
|
|
173
|
+
response = socket.read
|
|
176
174
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
assert_operator elapsed, :<, 1.0, "request should not be serialized behind slow client"
|
|
175
|
+
assert_includes response, "HTTP/1.1 408"
|
|
176
|
+
assert_includes response, "Access-Control-Allow-Origin: *"
|
|
180
177
|
ensure
|
|
181
|
-
|
|
178
|
+
socket&.close
|
|
182
179
|
end
|
|
183
180
|
|
|
184
|
-
def
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
181
|
+
def test_414_carries_cors_header_when_cors_enabled
|
|
182
|
+
start_server(cors: true)
|
|
183
|
+
|
|
184
|
+
long_path = "/" + ("a" * 9000)
|
|
185
|
+
socket = TCPSocket.open("127.0.0.1", @server.port)
|
|
186
|
+
socket.write("GET #{long_path} HTTP/1.1\r\nHost: localhost\r\n\r\n")
|
|
187
|
+
response = socket.read
|
|
188
188
|
|
|
189
|
-
assert_includes
|
|
190
|
-
assert_includes
|
|
189
|
+
assert_includes response, "HTTP/1.1 414"
|
|
190
|
+
assert_includes response, "Access-Control-Allow-Origin: *"
|
|
191
|
+
ensure
|
|
192
|
+
socket&.close
|
|
191
193
|
end
|
|
192
194
|
|
|
193
|
-
def
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
195
|
+
def test_200_carries_cors_header_when_cors_enabled
|
|
196
|
+
File.write(File.join(@dir, "x.txt"), "hi")
|
|
197
|
+
start_server(cors: true)
|
|
198
|
+
|
|
199
|
+
response = get("/x.txt")
|
|
197
200
|
|
|
198
|
-
|
|
201
|
+
assert_equal "200", response.code
|
|
202
|
+
assert_equal "*", response["access-control-allow-origin"]
|
|
203
|
+
assert_equal "Origin", response["vary"]
|
|
199
204
|
end
|
|
200
205
|
|
|
201
|
-
def
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
server.
|
|
206
|
+
def test_options_preflight_carries_cors_headers_when_cors_enabled
|
|
207
|
+
start_server(cors: true)
|
|
208
|
+
|
|
209
|
+
socket = TCPSocket.open("127.0.0.1", @server.port)
|
|
210
|
+
socket.write("OPTIONS /x.txt HTTP/1.1\r\nHost: localhost\r\n" \
|
|
211
|
+
"Access-Control-Request-Method: GET\r\n\r\n")
|
|
212
|
+
response = socket.read
|
|
205
213
|
|
|
206
|
-
assert_includes
|
|
207
|
-
|
|
214
|
+
assert_includes response, "HTTP/1.1 204"
|
|
215
|
+
assert_includes response, "Access-Control-Allow-Origin: *"
|
|
216
|
+
assert_includes response, "Access-Control-Allow-Methods: GET, HEAD, OPTIONS"
|
|
217
|
+
assert_includes response, "Vary: Origin"
|
|
218
|
+
ensure
|
|
219
|
+
socket&.close
|
|
208
220
|
end
|
|
209
221
|
|
|
210
|
-
def
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
222
|
+
def test_slow_client_does_not_block_other_clients
|
|
223
|
+
File.write(File.join(@dir, "x.txt"), "ok")
|
|
224
|
+
start_server(read_timeout: 5)
|
|
225
|
+
|
|
226
|
+
slow_socket = TCPSocket.open("127.0.0.1", @server.port)
|
|
227
|
+
|
|
228
|
+
started = Time.now
|
|
229
|
+
response = get("/x.txt")
|
|
230
|
+
elapsed = Time.now - started
|
|
214
231
|
|
|
215
|
-
|
|
232
|
+
assert_equal "200", response.code
|
|
233
|
+
assert_equal "ok", response.body
|
|
234
|
+
assert_operator elapsed, :<, 1.0, "request should not be serialized behind slow client"
|
|
235
|
+
ensure
|
|
236
|
+
slow_socket&.close
|
|
216
237
|
end
|
|
217
238
|
|
|
218
239
|
def test_accept_loop_survives_transient_accept_error
|
|
@@ -272,7 +293,7 @@ class ServerTest < Minitest::Test
|
|
|
272
293
|
|
|
273
294
|
def test_serves_over_tls
|
|
274
295
|
File.write(File.join(@dir, "x.txt"), "secret")
|
|
275
|
-
start_server(tls:
|
|
296
|
+
start_server(tls: ephemeral_tls)
|
|
276
297
|
|
|
277
298
|
response = Net::HTTP.start("127.0.0.1", @server.port, use_ssl: true,
|
|
278
299
|
verify_mode: OpenSSL::SSL::VERIFY_NONE) do |http|
|
|
@@ -283,24 +304,6 @@ class ServerTest < Minitest::Test
|
|
|
283
304
|
assert_equal "secret", response.body
|
|
284
305
|
end
|
|
285
306
|
|
|
286
|
-
def test_logs_https_scheme_when_tls_enabled
|
|
287
|
-
out = StringIO.new
|
|
288
|
-
@server = Wsv::Server.new(host: "127.0.0.1", port: 0, root: @dir,
|
|
289
|
-
out: out, err: StringIO.new, tls: build_ephemeral_tls)
|
|
290
|
-
@server.send(:log_startup)
|
|
291
|
-
|
|
292
|
-
assert_includes out.string, "https://"
|
|
293
|
-
end
|
|
294
|
-
|
|
295
|
-
def test_warns_about_self_signed_cert
|
|
296
|
-
err = StringIO.new
|
|
297
|
-
@server = Wsv::Server.new(host: "127.0.0.1", port: 0, root: @dir,
|
|
298
|
-
out: StringIO.new, err: err, tls: build_ephemeral_tls)
|
|
299
|
-
@server.send(:log_startup)
|
|
300
|
-
|
|
301
|
-
assert_includes err.string, "self-signed"
|
|
302
|
-
end
|
|
303
|
-
|
|
304
307
|
def test_unsupported_method
|
|
305
308
|
start_server
|
|
306
309
|
|
|
@@ -312,7 +315,7 @@ class ServerTest < Minitest::Test
|
|
|
312
315
|
|
|
313
316
|
private
|
|
314
317
|
|
|
315
|
-
def start_server(read_timeout: Wsv::Server::DEFAULT_READ_TIMEOUT, tls: nil)
|
|
318
|
+
def start_server(read_timeout: Wsv::Server::DEFAULT_READ_TIMEOUT, tls: nil, cors: false)
|
|
316
319
|
@server = Wsv::Server.new(
|
|
317
320
|
host: "127.0.0.1",
|
|
318
321
|
port: free_port,
|
|
@@ -320,39 +323,32 @@ class ServerTest < Minitest::Test
|
|
|
320
323
|
out: StringIO.new,
|
|
321
324
|
err: StringIO.new,
|
|
322
325
|
read_timeout: read_timeout,
|
|
323
|
-
tls: tls
|
|
326
|
+
tls: tls,
|
|
327
|
+
cors: cors
|
|
324
328
|
)
|
|
325
329
|
@thread = Thread.new { @server.start }
|
|
326
330
|
wait_until_ready
|
|
327
331
|
end
|
|
328
332
|
|
|
333
|
+
# Wrap accept_loop so the very first call to @server.accept raises a
|
|
334
|
+
# transient error. Avoids redefining Server#start, which would silently
|
|
335
|
+
# drift if start grew new steps (open_in_browser, etc.).
|
|
329
336
|
def inject_one_accept_error(server, error_class)
|
|
337
|
+
original_accept_loop = server.method(:accept_loop)
|
|
330
338
|
fired = false
|
|
331
|
-
server.define_singleton_method(:
|
|
332
|
-
|
|
333
|
-
original = @server.method(:accept)
|
|
339
|
+
server.define_singleton_method(:accept_loop) do
|
|
340
|
+
original_accept = @server.method(:accept)
|
|
334
341
|
@server.define_singleton_method(:accept) do
|
|
335
342
|
unless fired
|
|
336
343
|
fired = true
|
|
337
344
|
raise error_class, "injected"
|
|
338
345
|
end
|
|
339
|
-
|
|
346
|
+
original_accept.call
|
|
340
347
|
end
|
|
341
|
-
|
|
342
|
-
log_startup
|
|
343
|
-
trap_signals
|
|
344
|
-
accept_loop
|
|
345
|
-
ensure
|
|
346
|
-
close
|
|
348
|
+
original_accept_loop.call
|
|
347
349
|
end
|
|
348
350
|
end
|
|
349
351
|
|
|
350
|
-
def build_ephemeral_tls
|
|
351
|
-
key = OpenSSL::PKey::RSA.new(2048)
|
|
352
|
-
cert = Wsv::TlsContext::SelfSignedCert.build(key)
|
|
353
|
-
Wsv::TlsContext.new(cert: cert, key: key, ephemeral: true)
|
|
354
|
-
end
|
|
355
|
-
|
|
356
352
|
def free_port
|
|
357
353
|
server = TCPServer.new("127.0.0.1", 0)
|
|
358
354
|
server.addr[1]
|
data/test/test_helper.rb
CHANGED
|
@@ -7,3 +7,11 @@ require "fileutils"
|
|
|
7
7
|
require "stringio"
|
|
8
8
|
require "tmpdir"
|
|
9
9
|
require "wsv"
|
|
10
|
+
|
|
11
|
+
module TlsTestHelpers
|
|
12
|
+
def ephemeral_tls
|
|
13
|
+
key = OpenSSL::PKey::RSA.new(2048)
|
|
14
|
+
cert = Wsv::TlsContext::SelfSignedCert.build(key)
|
|
15
|
+
Wsv::TlsContext.new(cert: cert, key: key, ephemeral: true)
|
|
16
|
+
end
|
|
17
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: wsv
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.11.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- takahashim
|
|
@@ -29,6 +29,7 @@ files:
|
|
|
29
29
|
- lib/wsv/cors.rb
|
|
30
30
|
- lib/wsv/mime_types.rb
|
|
31
31
|
- lib/wsv/path_resolver.rb
|
|
32
|
+
- lib/wsv/range_request.rb
|
|
32
33
|
- lib/wsv/request.rb
|
|
33
34
|
- lib/wsv/request/parser.rb
|
|
34
35
|
- lib/wsv/request/too_large.rb
|
|
@@ -50,9 +51,12 @@ files:
|
|
|
50
51
|
- lib/wsv/tls_context/self_signed_cert.rb
|
|
51
52
|
- lib/wsv/version.rb
|
|
52
53
|
- test/app_test.rb
|
|
54
|
+
- test/banner_test.rb
|
|
53
55
|
- test/browser_launcher_test.rb
|
|
54
56
|
- test/cli_test.rb
|
|
57
|
+
- test/cors_test.rb
|
|
55
58
|
- test/path_resolver_test.rb
|
|
59
|
+
- test/range_request_test.rb
|
|
56
60
|
- test/request_test.rb
|
|
57
61
|
- test/response_test.rb
|
|
58
62
|
- test/server_test.rb
|