wsv 0.9.0 → 0.10.1
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 +61 -0
- data/README.md +79 -19
- 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 +6 -10
- data/lib/wsv/response.rb +9 -0
- data/lib/wsv/server/banner.rb +53 -0
- data/lib/wsv/server/browser_launcher.rb +57 -0
- data/lib/wsv/server/connection.rb +96 -0
- data/lib/wsv/server/connection_throttle.rb +50 -0
- data/lib/wsv/server/deadline_reader.rb +23 -0
- data/lib/wsv/server/url_host.rb +16 -0
- data/lib/wsv/server.rb +56 -122
- 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
- data/wsv.gemspec +7 -6
- metadata +23 -9
- /data/{bin → exe}/wsv +0 -0
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
|
data/test/response_test.rb
CHANGED
|
@@ -46,6 +46,14 @@ class ResponseTest < Minitest::Test
|
|
|
46
46
|
assert_includes io.string, "HTTP/1.1 404 Not Found"
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
+
def test_write_to_emits_nosniff_header
|
|
50
|
+
response = Wsv::Response.text(200)
|
|
51
|
+
io = StringIO.new
|
|
52
|
+
response.write_to(io)
|
|
53
|
+
|
|
54
|
+
assert_includes io.string, "X-Content-Type-Options: nosniff"
|
|
55
|
+
end
|
|
56
|
+
|
|
49
57
|
def test_file_response_streams_via_io_copy_stream
|
|
50
58
|
Dir.mktmpdir do |dir|
|
|
51
59
|
path = File.join(dir, "data.bin")
|
data/test/server_test.rb
CHANGED
|
@@ -198,6 +198,23 @@ class ServerTest < Minitest::Test
|
|
|
198
198
|
refute_includes err.string, "WARNING"
|
|
199
199
|
end
|
|
200
200
|
|
|
201
|
+
def test_banner_brackets_ipv6_address_in_url
|
|
202
|
+
out = StringIO.new
|
|
203
|
+
server = Wsv::Server.new(host: "::1", port: 8000, root: @dir, out: out, err: StringIO.new)
|
|
204
|
+
server.send(:log_startup)
|
|
205
|
+
|
|
206
|
+
assert_includes out.string, "http://[::1]:8000/"
|
|
207
|
+
refute_includes out.string, "http://::1:8000/"
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def test_banner_percent_encodes_ipv6_zone_identifier
|
|
211
|
+
out = StringIO.new
|
|
212
|
+
server = Wsv::Server.new(host: "fe80::1%eth0", port: 8000, root: @dir, out: out, err: StringIO.new)
|
|
213
|
+
server.send(:log_startup)
|
|
214
|
+
|
|
215
|
+
assert_includes out.string, "http://[fe80::1%25eth0]:8000/"
|
|
216
|
+
end
|
|
217
|
+
|
|
201
218
|
def test_accept_loop_survives_transient_accept_error
|
|
202
219
|
File.write(File.join(@dir, "x.txt"), "ok")
|
|
203
220
|
err = StringIO.new
|
|
@@ -253,6 +270,37 @@ class ServerTest < Minitest::Test
|
|
|
253
270
|
assert_equal "304", response.code
|
|
254
271
|
end
|
|
255
272
|
|
|
273
|
+
def test_serves_over_tls
|
|
274
|
+
File.write(File.join(@dir, "x.txt"), "secret")
|
|
275
|
+
start_server(tls: build_ephemeral_tls)
|
|
276
|
+
|
|
277
|
+
response = Net::HTTP.start("127.0.0.1", @server.port, use_ssl: true,
|
|
278
|
+
verify_mode: OpenSSL::SSL::VERIFY_NONE) do |http|
|
|
279
|
+
http.get("/x.txt")
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
assert_equal "200", response.code
|
|
283
|
+
assert_equal "secret", response.body
|
|
284
|
+
end
|
|
285
|
+
|
|
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
|
+
|
|
256
304
|
def test_unsupported_method
|
|
257
305
|
start_server
|
|
258
306
|
|
|
@@ -264,14 +312,15 @@ class ServerTest < Minitest::Test
|
|
|
264
312
|
|
|
265
313
|
private
|
|
266
314
|
|
|
267
|
-
def start_server(read_timeout: Wsv::Server::DEFAULT_READ_TIMEOUT)
|
|
315
|
+
def start_server(read_timeout: Wsv::Server::DEFAULT_READ_TIMEOUT, tls: nil)
|
|
268
316
|
@server = Wsv::Server.new(
|
|
269
317
|
host: "127.0.0.1",
|
|
270
318
|
port: free_port,
|
|
271
319
|
root: @dir,
|
|
272
320
|
out: StringIO.new,
|
|
273
321
|
err: StringIO.new,
|
|
274
|
-
read_timeout: read_timeout
|
|
322
|
+
read_timeout: read_timeout,
|
|
323
|
+
tls: tls
|
|
275
324
|
)
|
|
276
325
|
@thread = Thread.new { @server.start }
|
|
277
326
|
wait_until_ready
|
|
@@ -298,6 +347,12 @@ class ServerTest < Minitest::Test
|
|
|
298
347
|
end
|
|
299
348
|
end
|
|
300
349
|
|
|
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
|
+
|
|
301
356
|
def free_port
|
|
302
357
|
server = TCPServer.new("127.0.0.1", 0)
|
|
303
358
|
server.addr[1]
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "test_helper"
|
|
4
|
+
|
|
5
|
+
class TlsContextTest < Minitest::Test
|
|
6
|
+
def test_to_ssl_context_returns_configured_ssl_context
|
|
7
|
+
key = OpenSSL::PKey::RSA.new(2048)
|
|
8
|
+
cert = Wsv::TlsContext::SelfSignedCert.build(key)
|
|
9
|
+
tls = Wsv::TlsContext.new(cert: cert, key: key)
|
|
10
|
+
|
|
11
|
+
ssl = tls.to_ssl_context
|
|
12
|
+
|
|
13
|
+
assert_kind_of OpenSSL::SSL::SSLContext, ssl
|
|
14
|
+
assert_equal cert, ssl.cert
|
|
15
|
+
assert_equal key, ssl.key
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def test_ephemeral_predicate
|
|
19
|
+
key = OpenSSL::PKey::RSA.new(2048)
|
|
20
|
+
cert = Wsv::TlsContext::SelfSignedCert.build(key)
|
|
21
|
+
|
|
22
|
+
refute_predicate Wsv::TlsContext.new(cert: cert, key: key), :ephemeral?
|
|
23
|
+
assert_predicate Wsv::TlsContext.new(cert: cert, key: key, ephemeral: true), :ephemeral?
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
class SelfSignedCertTest < Minitest::Test
|
|
28
|
+
def test_subject_is_localhost
|
|
29
|
+
cert = build
|
|
30
|
+
|
|
31
|
+
assert_equal "/CN=localhost", cert.subject.to_s
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def test_san_includes_localhost_and_loopback
|
|
35
|
+
san = build.extensions.find { |ext| ext.oid == "subjectAltName" }.value
|
|
36
|
+
|
|
37
|
+
assert_includes san, "DNS:localhost"
|
|
38
|
+
assert_includes san, "IP Address:127.0.0.1"
|
|
39
|
+
assert_includes san, "IP Address:0:0:0:0:0:0:0:1"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def test_signed_with_provided_key
|
|
43
|
+
key = OpenSSL::PKey::RSA.new(2048)
|
|
44
|
+
cert = Wsv::TlsContext::SelfSignedCert.build(key)
|
|
45
|
+
|
|
46
|
+
assert cert.verify(key.public_key)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def build
|
|
52
|
+
Wsv::TlsContext::SelfSignedCert.build(OpenSSL::PKey::RSA.new(2048))
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
class TlsContextResolverTest < Minitest::Test
|
|
57
|
+
def test_resolves_explicit_paths
|
|
58
|
+
Dir.mktmpdir do |dir|
|
|
59
|
+
cert_path, key_path = write_self_signed(dir)
|
|
60
|
+
|
|
61
|
+
tls = Wsv::TlsContext::Resolver.resolve(cert_path: cert_path, key_path: key_path)
|
|
62
|
+
|
|
63
|
+
refute_predicate tls, :ephemeral?
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def test_requires_both_cert_and_key
|
|
68
|
+
assert_raises(ArgumentError) do
|
|
69
|
+
Wsv::TlsContext::Resolver.resolve(cert_path: "/some/cert.pem", key_path: nil)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
assert_raises(ArgumentError) do
|
|
73
|
+
Wsv::TlsContext::Resolver.resolve(cert_path: nil, key_path: "/some/key.pem")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def test_uses_xdg_when_present
|
|
78
|
+
Dir.mktmpdir do |xdg|
|
|
79
|
+
wsv_dir = File.join(xdg, "wsv")
|
|
80
|
+
FileUtils.mkdir_p(wsv_dir)
|
|
81
|
+
write_self_signed(wsv_dir, cert_name: "cert.pem", key_name: "key.pem")
|
|
82
|
+
|
|
83
|
+
with_env("XDG_CONFIG_HOME" => xdg) do
|
|
84
|
+
tls = Wsv::TlsContext::Resolver.resolve
|
|
85
|
+
|
|
86
|
+
refute_predicate tls, :ephemeral?
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def test_falls_back_to_ephemeral_when_no_xdg
|
|
92
|
+
Dir.mktmpdir do |xdg|
|
|
93
|
+
with_env("XDG_CONFIG_HOME" => xdg) do
|
|
94
|
+
tls = Wsv::TlsContext::Resolver.resolve
|
|
95
|
+
|
|
96
|
+
assert_predicate tls, :ephemeral?
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def test_errors_when_only_one_xdg_file_present
|
|
102
|
+
Dir.mktmpdir do |xdg|
|
|
103
|
+
wsv_dir = File.join(xdg, "wsv")
|
|
104
|
+
FileUtils.mkdir_p(wsv_dir)
|
|
105
|
+
File.write(File.join(wsv_dir, "cert.pem"), "stub")
|
|
106
|
+
|
|
107
|
+
with_env("XDG_CONFIG_HOME" => xdg) do
|
|
108
|
+
assert_raises(ArgumentError) { Wsv::TlsContext::Resolver.resolve }
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def test_raises_openssl_error_for_malformed_cert
|
|
114
|
+
Dir.mktmpdir do |dir|
|
|
115
|
+
_cert_path, key_path = write_self_signed(dir)
|
|
116
|
+
bad_cert = File.join(dir, "bad-cert.pem")
|
|
117
|
+
File.write(bad_cert, "this is not a PEM\n")
|
|
118
|
+
|
|
119
|
+
assert_raises(OpenSSL::X509::CertificateError) do
|
|
120
|
+
Wsv::TlsContext::Resolver.resolve(cert_path: bad_cert, key_path: key_path)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def test_raises_openssl_error_for_malformed_key
|
|
126
|
+
Dir.mktmpdir do |dir|
|
|
127
|
+
cert_path, _key_path = write_self_signed(dir)
|
|
128
|
+
bad_key = File.join(dir, "bad-key.pem")
|
|
129
|
+
File.write(bad_key, "this is not a PEM\n")
|
|
130
|
+
|
|
131
|
+
assert_raises(OpenSSL::PKey::PKeyError) do
|
|
132
|
+
Wsv::TlsContext::Resolver.resolve(cert_path: cert_path, key_path: bad_key)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def test_raises_argument_error_when_cert_and_key_do_not_match
|
|
138
|
+
Dir.mktmpdir do |dir|
|
|
139
|
+
cert_path, = write_self_signed(dir, cert_name: "a.pem", key_name: "a.key")
|
|
140
|
+
_, foreign_key = write_self_signed(dir, cert_name: "b.pem", key_name: "b.key")
|
|
141
|
+
|
|
142
|
+
err = assert_raises(ArgumentError) do
|
|
143
|
+
Wsv::TlsContext::Resolver.resolve(cert_path: cert_path, key_path: foreign_key)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
assert_includes err.message, "does not match"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
private
|
|
151
|
+
|
|
152
|
+
def write_self_signed(dir, cert_name: "test-cert.pem", key_name: "test-key.pem")
|
|
153
|
+
key = OpenSSL::PKey::RSA.new(2048)
|
|
154
|
+
cert = Wsv::TlsContext::SelfSignedCert.build(key)
|
|
155
|
+
cert_path = File.join(dir, cert_name)
|
|
156
|
+
key_path = File.join(dir, key_name)
|
|
157
|
+
File.write(cert_path, cert.to_pem)
|
|
158
|
+
File.write(key_path, key.to_pem)
|
|
159
|
+
[cert_path, key_path]
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def with_env(overrides)
|
|
163
|
+
original = ENV.to_h
|
|
164
|
+
overrides.each { |k, v| ENV[k] = v }
|
|
165
|
+
yield
|
|
166
|
+
ensure
|
|
167
|
+
ENV.replace(original)
|
|
168
|
+
end
|
|
169
|
+
end
|
data/wsv.gemspec
CHANGED
|
@@ -8,15 +8,16 @@ Gem::Specification.new do |spec|
|
|
|
8
8
|
spec.authors = ["takahashim"]
|
|
9
9
|
spec.email = ["takahashimm@gmail.com"]
|
|
10
10
|
|
|
11
|
-
spec.summary = "A
|
|
12
|
-
spec.description = "wsv
|
|
13
|
-
|
|
11
|
+
spec.summary = "A zero-dependency static preview server for Ruby projects."
|
|
12
|
+
spec.description = "wsv is a Ruby CLI that previews a directory over HTTP/HTTPS. " \
|
|
13
|
+
"Stdlib-only, no runtime dependencies. Defensive by design: " \
|
|
14
|
+
"blocks dotfiles, binds to loopback, ships with TLS and CORS."
|
|
15
|
+
spec.homepage = "https://github.com/takahashim/wsv"
|
|
14
16
|
spec.license = "MIT"
|
|
15
17
|
spec.required_ruby_version = ">= 3.2"
|
|
16
18
|
|
|
17
19
|
spec.metadata = {
|
|
18
20
|
"homepage_uri" => spec.homepage,
|
|
19
|
-
"source_code_uri" => "https://github.com/takahashim/wsv",
|
|
20
21
|
"changelog_uri" => "https://github.com/takahashim/wsv/blob/main/CHANGELOG.md",
|
|
21
22
|
"rubygems_mfa_required" => "true"
|
|
22
23
|
}
|
|
@@ -25,12 +26,12 @@ Gem::Specification.new do |spec|
|
|
|
25
26
|
"CHANGELOG.md",
|
|
26
27
|
"LICENSE.txt",
|
|
27
28
|
"README.md",
|
|
28
|
-
"
|
|
29
|
+
"exe/wsv",
|
|
29
30
|
"lib/**/*.rb",
|
|
30
31
|
"test/**/*.rb",
|
|
31
32
|
"wsv.gemspec"
|
|
32
33
|
]
|
|
33
|
-
spec.bindir = "
|
|
34
|
+
spec.bindir = "exe"
|
|
34
35
|
spec.executables = ["wsv"]
|
|
35
36
|
spec.require_paths = ["lib"]
|
|
36
37
|
end
|