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.
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
@@ -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 tiny static web server for local previews."
12
- spec.description = "wsv serves a local directory over HTTP from a zero-config CLI."
13
- spec.homepage = "https://rubygems.org/gems/wsv"
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
- "bin/wsv",
29
+ "exe/wsv",
29
30
  "lib/**/*.rb",
30
31
  "test/**/*.rb",
31
32
  "wsv.gemspec"
32
33
  ]
33
- spec.bindir = "bin"
34
+ spec.bindir = "exe"
34
35
  spec.executables = ["wsv"]
35
36
  spec.require_paths = ["lib"]
36
37
  end