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.
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
- @app = App.new(@root)
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 reject(client) unless accepted
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
- write_response(client, Response.text(503))
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.puts "Serving: #{root}"
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 localhost?(display_host)
220
- ["127.0.0.1", "localhost", "::1"].include?(display_host)
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
@@ -4,6 +4,7 @@ module Wsv
4
4
  module Status
5
5
  REASONS = {
6
6
  200 => "OK",
7
+ 204 => "No Content",
7
8
  206 => "Partial Content",
8
9
  301 => "Moved Permanently",
9
10
  304 => "Not Modified",
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Wsv
4
- VERSION = "0.9.0"
4
+ VERSION = "0.10.0"
5
5
  end
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