wsv 0.9.0 → 0.10.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 +42 -0
- data/README.md +65 -11
- data/lib/wsv/app.rb +39 -4
- data/lib/wsv/cli.rb +49 -3
- data/lib/wsv/cors.rb +30 -0
- data/lib/wsv/path_resolver.rb +6 -0
- data/lib/wsv/request/parser.rb +2 -2
- data/lib/wsv/request/too_large.rb +18 -0
- data/lib/wsv/request.rb +1 -9
- data/lib/wsv/response/file_builder.rb +3 -2
- data/lib/wsv/response.rb +9 -0
- data/lib/wsv/server/banner.rb +56 -0
- data/lib/wsv/server/browser_launcher.rb +63 -0
- data/lib/wsv/server/deadline_reader.rb +23 -0
- data/lib/wsv/server.rb +62 -36
- data/lib/wsv/status.rb +1 -0
- data/lib/wsv/tls_context/resolver.rb +71 -0
- data/lib/wsv/tls_context/self_signed_cert.rb +38 -0
- data/lib/wsv/tls_context.rb +29 -0
- data/lib/wsv/version.rb +1 -1
- data/lib/wsv.rb +2 -0
- data/test/app_test.rb +194 -0
- data/test/browser_launcher_test.rb +57 -0
- data/test/cli_test.rb +64 -0
- data/test/response_test.rb +8 -0
- data/test/server_test.rb +57 -2
- data/test/tls_context_test.rb +169 -0
- metadata +12 -2
data/lib/wsv/server.rb
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "openssl"
|
|
3
4
|
require "socket"
|
|
4
5
|
require_relative "app"
|
|
5
6
|
require_relative "request"
|
|
6
7
|
require_relative "response"
|
|
8
|
+
require_relative "server/banner"
|
|
9
|
+
require_relative "server/browser_launcher"
|
|
10
|
+
require_relative "server/deadline_reader"
|
|
7
11
|
|
|
8
12
|
module Wsv
|
|
9
13
|
class Server
|
|
@@ -20,7 +24,11 @@ module Wsv
|
|
|
20
24
|
out: $stdout,
|
|
21
25
|
err: $stderr,
|
|
22
26
|
read_timeout: DEFAULT_READ_TIMEOUT,
|
|
23
|
-
max_connections: DEFAULT_MAX_CONNECTIONS
|
|
27
|
+
max_connections: DEFAULT_MAX_CONNECTIONS,
|
|
28
|
+
tls: nil,
|
|
29
|
+
spa: false,
|
|
30
|
+
open: false,
|
|
31
|
+
cors: false
|
|
24
32
|
)
|
|
25
33
|
@host = host
|
|
26
34
|
@port = port
|
|
@@ -29,7 +37,10 @@ module Wsv
|
|
|
29
37
|
@err = err
|
|
30
38
|
@read_timeout = read_timeout
|
|
31
39
|
@max_connections = max_connections
|
|
32
|
-
@
|
|
40
|
+
@tls = tls
|
|
41
|
+
@ssl_context = tls&.to_ssl_context
|
|
42
|
+
@open = open
|
|
43
|
+
@app = App.new(@root, spa: spa, cors: cors)
|
|
33
44
|
@running = false
|
|
34
45
|
@mutex = Mutex.new
|
|
35
46
|
@active = 0
|
|
@@ -40,6 +51,7 @@ module Wsv
|
|
|
40
51
|
@running = true
|
|
41
52
|
log_startup
|
|
42
53
|
trap_signals
|
|
54
|
+
open_in_browser if @open
|
|
43
55
|
accept_loop
|
|
44
56
|
ensure
|
|
45
57
|
close
|
|
@@ -66,6 +78,8 @@ module Wsv
|
|
|
66
78
|
rescue IO::TimeoutError
|
|
67
79
|
write_response(client, Response.text(408))
|
|
68
80
|
rescue StandardError => e
|
|
81
|
+
# Treat unmapped failures as connection-scoped and close with 400 rather
|
|
82
|
+
# than letting one bad request path bring down the server.
|
|
69
83
|
@err.puts "wsv: #{e.class}: #{e.message}"
|
|
70
84
|
write_response(client, Response.text(400))
|
|
71
85
|
ensure
|
|
@@ -104,6 +118,9 @@ module Wsv
|
|
|
104
118
|
chunk = client.read_nonblock(8192, exception: false)
|
|
105
119
|
case chunk
|
|
106
120
|
when nil, :wait_writable
|
|
121
|
+
# nil = EOF. :wait_writable can come back from SSLSocket during a
|
|
122
|
+
# renegotiation (read needs an underlying write). Either way,
|
|
123
|
+
# there's nothing more we can usefully drain right now.
|
|
107
124
|
return
|
|
108
125
|
when :wait_readable
|
|
109
126
|
remaining = deadline - Time.now
|
|
@@ -113,21 +130,6 @@ module Wsv
|
|
|
113
130
|
end
|
|
114
131
|
end
|
|
115
132
|
|
|
116
|
-
class DeadlineReader
|
|
117
|
-
def initialize(io, deadline)
|
|
118
|
-
@io = io
|
|
119
|
-
@deadline = deadline
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def gets(limit)
|
|
123
|
-
remaining = @deadline - Time.now
|
|
124
|
-
raise IO::TimeoutError if remaining <= 0
|
|
125
|
-
|
|
126
|
-
@io.timeout = remaining
|
|
127
|
-
@io.gets(limit)
|
|
128
|
-
end
|
|
129
|
-
end
|
|
130
|
-
|
|
131
133
|
def accept_loop
|
|
132
134
|
while @running
|
|
133
135
|
client = nil
|
|
@@ -162,24 +164,58 @@ module Wsv
|
|
|
162
164
|
true
|
|
163
165
|
end
|
|
164
166
|
|
|
165
|
-
return
|
|
167
|
+
return spawn_rejection(client) unless accepted
|
|
166
168
|
|
|
167
169
|
begin
|
|
168
170
|
Thread.new do
|
|
169
171
|
Thread.current.report_on_exception = false
|
|
170
|
-
handle(client)
|
|
172
|
+
handle(maybe_wrap_tls(client))
|
|
171
173
|
ensure
|
|
172
174
|
@mutex.synchronize { @active -= 1 }
|
|
173
175
|
end
|
|
174
176
|
rescue ThreadError => e
|
|
175
177
|
@err.puts "wsv: thread error: #{e.message}"
|
|
176
178
|
@mutex.synchronize { @active -= 1 }
|
|
179
|
+
spawn_rejection(client)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Reject in a separate thread so a slow client cannot block accept_loop
|
|
184
|
+
# via graceful_close's drain_recv (up to DRAIN_TIMEOUT seconds).
|
|
185
|
+
def spawn_rejection(client)
|
|
186
|
+
Thread.new do
|
|
187
|
+
Thread.current.report_on_exception = false
|
|
177
188
|
reject(client)
|
|
178
189
|
end
|
|
190
|
+
rescue ThreadError
|
|
191
|
+
reject(client)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def maybe_wrap_tls(client)
|
|
195
|
+
return client unless @ssl_context
|
|
196
|
+
|
|
197
|
+
client.timeout = @read_timeout
|
|
198
|
+
ssl = OpenSSL::SSL::SSLSocket.new(client, @ssl_context)
|
|
199
|
+
ssl.sync_close = true
|
|
200
|
+
ssl.accept
|
|
201
|
+
ssl
|
|
202
|
+
rescue StandardError
|
|
203
|
+
# If wrapping or the handshake failed, `handle` is never called and
|
|
204
|
+
# its ensure does not get a chance to close the underlying socket.
|
|
205
|
+
# Close it here so we do not leak a TCPSocket per failed handshake.
|
|
206
|
+
begin
|
|
207
|
+
client.close
|
|
208
|
+
rescue StandardError
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
raise
|
|
179
212
|
end
|
|
180
213
|
|
|
181
214
|
def reject(client)
|
|
182
|
-
|
|
215
|
+
# In TLS mode `client` is the raw TCPSocket before any handshake.
|
|
216
|
+
# Writing a plaintext 503 over it would corrupt the TLS handshake
|
|
217
|
+
# the client is about to start, so just close in that case.
|
|
218
|
+
write_response(client, Response.text(503)) unless @ssl_context
|
|
183
219
|
ensure
|
|
184
220
|
graceful_close(client)
|
|
185
221
|
end
|
|
@@ -196,28 +232,18 @@ module Wsv
|
|
|
196
232
|
end
|
|
197
233
|
end
|
|
198
234
|
rescue ArgumentError
|
|
235
|
+
# Signal.trap raises ArgumentError when called from a context that
|
|
236
|
+
# cannot install signal handlers (e.g. embedded in a non-main thread,
|
|
237
|
+
# which is how tests start the server). Skip silently in that case.
|
|
199
238
|
nil
|
|
200
239
|
end
|
|
201
240
|
|
|
202
241
|
def log_startup
|
|
203
|
-
@out
|
|
204
|
-
@out.puts "Bind: #{url_for(host)}"
|
|
205
|
-
@out.puts "Local: #{url_for('127.0.0.1')}" unless localhost?(host)
|
|
206
|
-
@out.puts "Stop: Ctrl-C"
|
|
207
|
-
warn_public_bind unless localhost?(host)
|
|
208
|
-
end
|
|
209
|
-
|
|
210
|
-
def warn_public_bind
|
|
211
|
-
@err.puts "WARNING: binding to #{host} exposes #{root} on your network."
|
|
212
|
-
@err.puts " Pass --host 127.0.0.1 (or omit --host) for local-only access."
|
|
213
|
-
end
|
|
214
|
-
|
|
215
|
-
def url_for(display_host)
|
|
216
|
-
"http://#{display_host}:#{port}/"
|
|
242
|
+
Banner.new(host: host, port: port, root: root, out: @out, err: @err, tls: @tls).emit
|
|
217
243
|
end
|
|
218
244
|
|
|
219
|
-
def
|
|
220
|
-
|
|
245
|
+
def open_in_browser
|
|
246
|
+
BrowserLauncher.new(host: host, port: port, tls: @tls, err: @err).launch
|
|
221
247
|
end
|
|
222
248
|
end
|
|
223
249
|
end
|
data/lib/wsv/status.rb
CHANGED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
|
|
5
|
+
module Wsv
|
|
6
|
+
class TlsContext
|
|
7
|
+
class Resolver
|
|
8
|
+
XDG_DIR = "wsv"
|
|
9
|
+
CERT_FILE = "cert.pem"
|
|
10
|
+
KEY_FILE = "key.pem"
|
|
11
|
+
|
|
12
|
+
def self.resolve(cert_path: nil, key_path: nil)
|
|
13
|
+
new(cert_path: cert_path, key_path: key_path).resolve
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(cert_path: nil, key_path: nil)
|
|
17
|
+
@cert_path = cert_path
|
|
18
|
+
@key_path = key_path
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def resolve
|
|
22
|
+
return from_files(@cert_path, @key_path) if @cert_path && @key_path
|
|
23
|
+
|
|
24
|
+
raise ArgumentError, "--cert and --key must be provided together" if @cert_path || @key_path
|
|
25
|
+
|
|
26
|
+
xdg = xdg_pair
|
|
27
|
+
return from_files(*xdg) if xdg
|
|
28
|
+
|
|
29
|
+
ephemeral
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def from_files(cert_path, key_path)
|
|
35
|
+
cert = OpenSSL::X509::Certificate.new(File.read(cert_path))
|
|
36
|
+
key = OpenSSL::PKey.read(File.read(key_path))
|
|
37
|
+
raise ArgumentError, "key file at #{key_path} does not contain a private key" unless key.private?
|
|
38
|
+
unless cert.check_private_key(key)
|
|
39
|
+
raise ArgumentError,
|
|
40
|
+
"TLS certificate at #{cert_path} does not match the key at #{key_path}"
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
TlsContext.new(cert: cert, key: key)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def ephemeral
|
|
47
|
+
key = OpenSSL::PKey::RSA.new(2048)
|
|
48
|
+
cert = SelfSignedCert.build(key)
|
|
49
|
+
TlsContext.new(cert: cert, key: key, ephemeral: true)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def xdg_pair
|
|
53
|
+
cert = File.join(xdg_base, XDG_DIR, CERT_FILE)
|
|
54
|
+
key = File.join(xdg_base, XDG_DIR, KEY_FILE)
|
|
55
|
+
cert_exists = File.exist?(cert)
|
|
56
|
+
key_exists = File.exist?(key)
|
|
57
|
+
return [cert, key] if cert_exists && key_exists
|
|
58
|
+
|
|
59
|
+
if cert_exists ^ key_exists
|
|
60
|
+
raise ArgumentError, "found only one of #{cert} / #{key} -- both must exist or neither"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def xdg_base
|
|
67
|
+
ENV["XDG_CONFIG_HOME"] || File.join(Dir.home, ".config")
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
|
|
6
|
+
module Wsv
|
|
7
|
+
class TlsContext
|
|
8
|
+
class SelfSignedCert
|
|
9
|
+
SUBJECT = "/CN=localhost"
|
|
10
|
+
SAN = "DNS:localhost,IP:127.0.0.1,IP:::1"
|
|
11
|
+
VALIDITY_SECONDS = 365 * 24 * 60 * 60
|
|
12
|
+
|
|
13
|
+
def self.build(key)
|
|
14
|
+
new(key).build
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(key)
|
|
18
|
+
@key = key
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def build
|
|
22
|
+
cert = OpenSSL::X509::Certificate.new
|
|
23
|
+
cert.version = 2
|
|
24
|
+
cert.serial = SecureRandom.random_number((2**63) - 1) + 1
|
|
25
|
+
cert.subject = OpenSSL::X509::Name.parse(SUBJECT)
|
|
26
|
+
cert.issuer = cert.subject
|
|
27
|
+
cert.public_key = @key.public_key
|
|
28
|
+
cert.not_before = Time.now - 60
|
|
29
|
+
cert.not_after = Time.now + VALIDITY_SECONDS
|
|
30
|
+
ef = OpenSSL::X509::ExtensionFactory.new(cert, cert)
|
|
31
|
+
cert.add_extension(ef.create_extension("subjectAltName", SAN))
|
|
32
|
+
cert.add_extension(ef.create_extension("basicConstraints", "CA:FALSE", true))
|
|
33
|
+
cert.sign(@key, OpenSSL::Digest.new("SHA256"))
|
|
34
|
+
cert
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "openssl"
|
|
4
|
+
require_relative "tls_context/self_signed_cert"
|
|
5
|
+
require_relative "tls_context/resolver"
|
|
6
|
+
|
|
7
|
+
module Wsv
|
|
8
|
+
class TlsContext
|
|
9
|
+
attr_reader :cert, :key
|
|
10
|
+
|
|
11
|
+
def initialize(cert:, key:, ephemeral: false)
|
|
12
|
+
@cert = cert
|
|
13
|
+
@key = key
|
|
14
|
+
@ephemeral = ephemeral
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def ephemeral?
|
|
18
|
+
@ephemeral
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_ssl_context
|
|
22
|
+
ctx = OpenSSL::SSL::SSLContext.new
|
|
23
|
+
ctx.cert = @cert
|
|
24
|
+
ctx.key = @key
|
|
25
|
+
ctx.min_version = OpenSSL::SSL::TLS1_2_VERSION
|
|
26
|
+
ctx
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/wsv/version.rb
CHANGED
data/lib/wsv.rb
CHANGED
|
@@ -6,7 +6,9 @@ require_relative "wsv/mime_types"
|
|
|
6
6
|
require_relative "wsv/path_resolver"
|
|
7
7
|
require_relative "wsv/request"
|
|
8
8
|
require_relative "wsv/response"
|
|
9
|
+
require_relative "wsv/cors"
|
|
9
10
|
require_relative "wsv/app"
|
|
11
|
+
require_relative "wsv/tls_context"
|
|
10
12
|
require_relative "wsv/server"
|
|
11
13
|
require_relative "wsv/cli"
|
|
12
14
|
|
data/test/app_test.rb
CHANGED
|
@@ -186,6 +186,124 @@ class AppTest < Minitest::Test
|
|
|
186
186
|
assert_equal "3", response.headers["Content-Length"]
|
|
187
187
|
end
|
|
188
188
|
|
|
189
|
+
def test_spa_fallback_serves_index_for_missing_path
|
|
190
|
+
File.write(File.join(@dir, "index.html"), "<h1>SPA</h1>")
|
|
191
|
+
spa_app = Wsv::App.new(File.realpath(@dir), spa: true)
|
|
192
|
+
|
|
193
|
+
response = spa_app.call(req("GET", "/users/123"))
|
|
194
|
+
|
|
195
|
+
assert_equal 200, response.status
|
|
196
|
+
assert_equal "<h1>SPA</h1>", response.body
|
|
197
|
+
assert_equal "text/html; charset=utf-8", response.headers["Content-Type"]
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def test_spa_fallback_head_serves_index_headers_without_body
|
|
201
|
+
File.write(File.join(@dir, "index.html"), "<h1>SPA</h1>")
|
|
202
|
+
spa_app = Wsv::App.new(File.realpath(@dir), spa: true)
|
|
203
|
+
|
|
204
|
+
response = spa_app.call(req("HEAD", "/users/123"))
|
|
205
|
+
|
|
206
|
+
assert_equal 200, response.status
|
|
207
|
+
assert_equal "", response.body
|
|
208
|
+
assert_equal "text/html; charset=utf-8", response.headers["Content-Type"]
|
|
209
|
+
assert_equal "12", response.headers["Content-Length"]
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def test_spa_disabled_returns_404_for_missing_path
|
|
213
|
+
File.write(File.join(@dir, "index.html"), "<h1>SPA</h1>")
|
|
214
|
+
|
|
215
|
+
response = @app.call(req("GET", "/users/123"))
|
|
216
|
+
|
|
217
|
+
assert_equal 404, response.status
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def test_spa_keeps_403_for_dotfile
|
|
221
|
+
File.write(File.join(@dir, "index.html"), "<h1>SPA</h1>")
|
|
222
|
+
File.write(File.join(@dir, ".env"), "secret")
|
|
223
|
+
spa_app = Wsv::App.new(File.realpath(@dir), spa: true)
|
|
224
|
+
|
|
225
|
+
response = spa_app.call(req("GET", "/.env"))
|
|
226
|
+
|
|
227
|
+
assert_equal 403, response.status
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def test_spa_keeps_403_for_path_traversal
|
|
231
|
+
File.write(File.join(@dir, "index.html"), "<h1>SPA</h1>")
|
|
232
|
+
spa_app = Wsv::App.new(File.realpath(@dir), spa: true)
|
|
233
|
+
|
|
234
|
+
response = spa_app.call(req("GET", "/../etc/passwd"))
|
|
235
|
+
|
|
236
|
+
assert_equal 403, response.status
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def test_spa_returns_404_when_no_index_html
|
|
240
|
+
spa_app = Wsv::App.new(File.realpath(@dir), spa: true)
|
|
241
|
+
|
|
242
|
+
response = spa_app.call(req("GET", "/users/123"))
|
|
243
|
+
|
|
244
|
+
assert_equal 404, response.status
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def test_spa_serves_real_file_when_path_matches
|
|
248
|
+
File.write(File.join(@dir, "index.html"), "<h1>SPA</h1>")
|
|
249
|
+
File.write(File.join(@dir, "robots.txt"), "User-agent: *")
|
|
250
|
+
spa_app = Wsv::App.new(File.realpath(@dir), spa: true)
|
|
251
|
+
|
|
252
|
+
response = spa_app.call(req("GET", "/robots.txt"))
|
|
253
|
+
|
|
254
|
+
assert_equal 200, response.status
|
|
255
|
+
assert_equal "User-agent: *", response.body
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def test_custom_404_page_when_404_html_exists
|
|
259
|
+
File.write(File.join(@dir, "404.html"), "<h1>not found</h1>")
|
|
260
|
+
|
|
261
|
+
response = @app.call(req("GET", "/missing.txt"))
|
|
262
|
+
|
|
263
|
+
assert_equal 404, response.status
|
|
264
|
+
assert_equal "<h1>not found</h1>", response.body
|
|
265
|
+
assert_equal "text/html; charset=utf-8", response.headers["Content-Type"]
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def test_no_custom_404_falls_back_to_plain_text
|
|
269
|
+
response = @app.call(req("GET", "/missing.txt"))
|
|
270
|
+
|
|
271
|
+
assert_equal 404, response.status
|
|
272
|
+
assert_includes response.body, "404 Not Found"
|
|
273
|
+
assert_equal "text/plain; charset=utf-8", response.headers["Content-Type"]
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def test_custom_404_does_not_apply_to_403
|
|
277
|
+
File.write(File.join(@dir, "404.html"), "<h1>not found</h1>")
|
|
278
|
+
File.write(File.join(@dir, ".env"), "secret")
|
|
279
|
+
|
|
280
|
+
response = @app.call(req("GET", "/.env"))
|
|
281
|
+
|
|
282
|
+
assert_equal 403, response.status
|
|
283
|
+
refute_includes response.body, "<h1>not found</h1>"
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def test_spa_mode_takes_precedence_over_custom_404
|
|
287
|
+
File.write(File.join(@dir, "index.html"), "<h1>SPA</h1>")
|
|
288
|
+
File.write(File.join(@dir, "404.html"), "<h1>not found</h1>")
|
|
289
|
+
spa_app = Wsv::App.new(File.realpath(@dir), spa: true)
|
|
290
|
+
|
|
291
|
+
response = spa_app.call(req("GET", "/users/123"))
|
|
292
|
+
|
|
293
|
+
assert_equal 200, response.status
|
|
294
|
+
assert_equal "<h1>SPA</h1>", response.body
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def test_custom_404_works_with_head
|
|
298
|
+
File.write(File.join(@dir, "404.html"), "<h1>not found</h1>")
|
|
299
|
+
|
|
300
|
+
response = @app.call(req("HEAD", "/missing.txt"))
|
|
301
|
+
|
|
302
|
+
assert_equal 404, response.status
|
|
303
|
+
assert_equal "", response.body
|
|
304
|
+
assert_equal "text/html; charset=utf-8", response.headers["Content-Type"]
|
|
305
|
+
end
|
|
306
|
+
|
|
189
307
|
def test_serves_single_byte_range
|
|
190
308
|
File.write(File.join(@dir, "data.bin"), "abcdefghij")
|
|
191
309
|
|
|
@@ -219,6 +337,82 @@ class AppTest < Minitest::Test
|
|
|
219
337
|
assert_equal "bytes", response.headers["Accept-Ranges"]
|
|
220
338
|
end
|
|
221
339
|
|
|
340
|
+
def test_cors_disabled_omits_acao_header
|
|
341
|
+
File.write(File.join(@dir, "x.txt"), "hi")
|
|
342
|
+
|
|
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
|
|
349
|
+
File.write(File.join(@dir, "x.txt"), "hi")
|
|
350
|
+
cors_app = Wsv::App.new(File.realpath(@dir), cors: true)
|
|
351
|
+
|
|
352
|
+
response = cors_app.call(req("GET", "/x.txt"))
|
|
353
|
+
|
|
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"]
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def test_cors_preflight_returns_204_with_methods_and_max_age
|
|
370
|
+
cors_app = Wsv::App.new(File.realpath(@dir), cors: true)
|
|
371
|
+
|
|
372
|
+
response = cors_app.call(req("OPTIONS", "/x.txt"))
|
|
373
|
+
|
|
374
|
+
assert_equal 204, response.status
|
|
375
|
+
assert_equal "*", response.headers["Access-Control-Allow-Origin"]
|
|
376
|
+
assert_equal "GET, HEAD, OPTIONS", response.headers["Access-Control-Allow-Methods"]
|
|
377
|
+
assert_equal "86400", response.headers["Access-Control-Max-Age"]
|
|
378
|
+
assert_equal "Origin", response.headers["Vary"]
|
|
379
|
+
assert_equal "", response.body
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def test_cors_preflight_echoes_requested_headers
|
|
383
|
+
cors_app = Wsv::App.new(File.realpath(@dir), cors: true)
|
|
384
|
+
|
|
385
|
+
response = cors_app.call(req("OPTIONS", "/x.txt", "access-control-request-headers" => "X-Custom, Authorization"))
|
|
386
|
+
|
|
387
|
+
assert_equal 204, response.status
|
|
388
|
+
assert_equal "X-Custom, Authorization", response.headers["Access-Control-Allow-Headers"]
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def test_cors_preflight_omits_allow_headers_when_not_requested
|
|
392
|
+
cors_app = Wsv::App.new(File.realpath(@dir), cors: true)
|
|
393
|
+
|
|
394
|
+
response = cors_app.call(req("OPTIONS", "/x.txt"))
|
|
395
|
+
|
|
396
|
+
refute response.headers.key?("Access-Control-Allow-Headers")
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def test_options_without_cors_returns_405
|
|
400
|
+
response = @app.call(req("OPTIONS", "/x.txt"))
|
|
401
|
+
|
|
402
|
+
assert_equal 405, response.status
|
|
403
|
+
assert_equal "GET, HEAD", response.headers["Allow"]
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def test_cors_405_advertises_options_in_allow
|
|
407
|
+
cors_app = Wsv::App.new(File.realpath(@dir), cors: true)
|
|
408
|
+
|
|
409
|
+
response = cors_app.call(req("POST", "/x.txt"))
|
|
410
|
+
|
|
411
|
+
assert_equal 405, response.status
|
|
412
|
+
assert_equal "GET, HEAD, OPTIONS", response.headers["Allow"]
|
|
413
|
+
assert_equal "*", response.headers["Access-Control-Allow-Origin"]
|
|
414
|
+
end
|
|
415
|
+
|
|
222
416
|
def test_multipart_range_falls_through_to_200
|
|
223
417
|
File.write(File.join(@dir, "data.bin"), "abcdefghij")
|
|
224
418
|
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class BrowserLauncherTest < Minitest::Test
|
|
6
|
+
def test_loopback_host_used_directly
|
|
7
|
+
launcher = build(host: "127.0.0.1")
|
|
8
|
+
|
|
9
|
+
assert_equal "http://127.0.0.1:8000/", launcher.send(:url)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def test_wildcard_v4_translated_to_loopback
|
|
13
|
+
launcher = build(host: "0.0.0.0")
|
|
14
|
+
|
|
15
|
+
assert_equal "http://127.0.0.1:8000/", launcher.send(:url)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def test_wildcard_v6_translated_to_loopback
|
|
19
|
+
launcher = build(host: "::")
|
|
20
|
+
|
|
21
|
+
assert_equal "http://[::1]:8000/", launcher.send(:url)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def test_ipv6_literal_host_is_bracketed
|
|
25
|
+
launcher = build(host: "::1")
|
|
26
|
+
|
|
27
|
+
assert_equal "http://[::1]:8000/", launcher.send(:url)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def test_specific_host_passes_through
|
|
31
|
+
launcher = build(host: "192.168.1.5")
|
|
32
|
+
|
|
33
|
+
assert_equal "http://192.168.1.5:8000/", launcher.send(:url)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def test_https_scheme_when_tls_present
|
|
37
|
+
launcher = build(host: "127.0.0.1", tls: Object.new)
|
|
38
|
+
|
|
39
|
+
assert_equal "https://127.0.0.1:8000/", launcher.send(:url)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def test_logs_when_platform_unsupported
|
|
43
|
+
err = StringIO.new
|
|
44
|
+
launcher = build(host: "127.0.0.1", err: err)
|
|
45
|
+
launcher.define_singleton_method(:platform_command) { nil }
|
|
46
|
+
|
|
47
|
+
launcher.launch
|
|
48
|
+
|
|
49
|
+
assert_includes err.string, "not supported on this platform"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def build(host:, tls: nil, err: StringIO.new)
|
|
55
|
+
Wsv::Server::BrowserLauncher.new(host: host, port: 8000, tls: tls, err: err)
|
|
56
|
+
end
|
|
57
|
+
end
|
data/test/cli_test.rb
CHANGED
|
@@ -42,6 +42,8 @@ class CLITest < Minitest::Test
|
|
|
42
42
|
assert_equal 0, code
|
|
43
43
|
assert_includes out.string, "Usage: wsv"
|
|
44
44
|
assert_includes out.string, "--host HOST"
|
|
45
|
+
assert_includes out.string, "Examples:"
|
|
46
|
+
assert_includes out.string, "wsv _site"
|
|
45
47
|
end
|
|
46
48
|
|
|
47
49
|
def test_version
|
|
@@ -68,4 +70,66 @@ class CLITest < Minitest::Test
|
|
|
68
70
|
assert_equal 1, code
|
|
69
71
|
assert_includes err.string, "directory does not exist"
|
|
70
72
|
end
|
|
73
|
+
|
|
74
|
+
def test_tls_flag_parses
|
|
75
|
+
Dir.mktmpdir do |dir|
|
|
76
|
+
options = Wsv::CLI.new([]).parse_options(["--tls", dir])
|
|
77
|
+
|
|
78
|
+
assert options[:tls]
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def test_cert_without_key_errors
|
|
83
|
+
err = StringIO.new
|
|
84
|
+
Dir.mktmpdir do |dir|
|
|
85
|
+
code = Wsv::CLI.new(["--cert", "/nonexistent/cert.pem", dir], err: err).run
|
|
86
|
+
|
|
87
|
+
assert_equal 1, code
|
|
88
|
+
assert_includes err.string, "must be provided together"
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def test_cert_and_key_parses
|
|
93
|
+
Dir.mktmpdir do |dir|
|
|
94
|
+
options = Wsv::CLI.new([]).parse_options(["--cert", "a.pem", "--key", "b.pem", dir])
|
|
95
|
+
|
|
96
|
+
assert_equal "a.pem", options[:cert]
|
|
97
|
+
assert_equal "b.pem", options[:key]
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def test_spa_flag_parses
|
|
102
|
+
Dir.mktmpdir do |dir|
|
|
103
|
+
options = Wsv::CLI.new([]).parse_options(["--spa", dir])
|
|
104
|
+
|
|
105
|
+
assert options[:spa]
|
|
106
|
+
assert_equal dir, options[:directory]
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def test_cors_flag_parses
|
|
111
|
+
Dir.mktmpdir do |dir|
|
|
112
|
+
options = Wsv::CLI.new([]).parse_options(["--cors", dir])
|
|
113
|
+
|
|
114
|
+
assert options[:cors]
|
|
115
|
+
assert_equal dir, options[:directory]
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def test_cors_default_off
|
|
120
|
+
Dir.mktmpdir do |dir|
|
|
121
|
+
options = Wsv::CLI.new([]).parse_options([dir])
|
|
122
|
+
|
|
123
|
+
refute options[:cors]
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def test_open_flag_parses
|
|
128
|
+
Dir.mktmpdir do |dir|
|
|
129
|
+
options = Wsv::CLI.new([]).parse_options(["--open", dir])
|
|
130
|
+
|
|
131
|
+
assert options[:open]
|
|
132
|
+
assert_equal dir, options[:directory]
|
|
133
|
+
end
|
|
134
|
+
end
|
|
71
135
|
end
|