wsv 0.8.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.
@@ -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.8.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
@@ -60,6 +60,16 @@ class AppTest < Minitest::Test
60
60
  assert_equal "/docs/?q=1", response.headers["Location"]
61
61
  end
62
62
 
63
+ def test_redirect_normalizes_absolute_form_target_to_origin_form
64
+ FileUtils.mkdir_p(File.join(@dir, "docs"))
65
+ File.write(File.join(@dir, "docs", "index.html"), "x")
66
+
67
+ response = @app.call(req("GET", "http://example.test/docs"))
68
+
69
+ assert_equal 301, response.status
70
+ assert_equal "/docs/", response.headers["Location"]
71
+ end
72
+
63
73
  def test_head_omits_body_but_keeps_content_length
64
74
  File.write(File.join(@dir, "x.txt"), "hi")
65
75
 
@@ -69,9 +79,353 @@ class AppTest < Minitest::Test
69
79
  assert_equal "2", response.headers["Content-Length"]
70
80
  end
71
81
 
82
+ def test_advertises_accept_ranges_on_200
83
+ File.write(File.join(@dir, "x.txt"), "hi")
84
+
85
+ response = @app.call(req("GET", "/x.txt"))
86
+
87
+ assert_equal "bytes", response.headers["Accept-Ranges"]
88
+ end
89
+
90
+ def test_returns_304_when_if_modified_since_matches
91
+ path = File.join(@dir, "x.txt")
92
+ File.write(path, "hi")
93
+
94
+ response = @app.call(req("GET", "/x.txt", "if-modified-since" => File.mtime(path).httpdate))
95
+
96
+ assert_equal 304, response.status
97
+ assert_equal "", response.body
98
+ refute response.headers.key?("Content-Length")
99
+ end
100
+
101
+ def test_returns_200_when_if_modified_since_is_older
102
+ path = File.join(@dir, "x.txt")
103
+ File.write(path, "hi")
104
+ older = (File.mtime(path) - 3600).httpdate
105
+
106
+ response = @app.call(req("GET", "/x.txt", "if-modified-since" => older))
107
+
108
+ assert_equal 200, response.status
109
+ assert_equal "hi", response.body
110
+ end
111
+
112
+ def test_invalid_if_modified_since_is_ignored
113
+ File.write(File.join(@dir, "x.txt"), "hi")
114
+
115
+ response = @app.call(req("GET", "/x.txt", "if-modified-since" => "not a date"))
116
+
117
+ assert_equal 200, response.status
118
+ end
119
+
120
+ def test_serves_byte_range
121
+ File.write(File.join(@dir, "data.bin"), "abcdefghij")
122
+
123
+ response = @app.call(req("GET", "/data.bin", "range" => "bytes=2-5"))
124
+
125
+ assert_equal 206, response.status
126
+ assert_equal "cdef", response.body
127
+ assert_equal "4", response.headers["Content-Length"]
128
+ assert_equal "bytes 2-5/10", response.headers["Content-Range"]
129
+ end
130
+
131
+ def test_serves_open_ended_range
132
+ File.write(File.join(@dir, "data.bin"), "abcdefghij")
133
+
134
+ response = @app.call(req("GET", "/data.bin", "range" => "bytes=7-"))
135
+
136
+ assert_equal 206, response.status
137
+ assert_equal "hij", response.body
138
+ assert_equal "bytes 7-9/10", response.headers["Content-Range"]
139
+ end
140
+
141
+ def test_serves_suffix_range
142
+ File.write(File.join(@dir, "data.bin"), "abcdefghij")
143
+
144
+ response = @app.call(req("GET", "/data.bin", "range" => "bytes=-3"))
145
+
146
+ assert_equal 206, response.status
147
+ assert_equal "hij", response.body
148
+ assert_equal "bytes 7-9/10", response.headers["Content-Range"]
149
+ end
150
+
151
+ def test_clamps_range_end_to_file_size
152
+ File.write(File.join(@dir, "data.bin"), "abcdefghij")
153
+
154
+ response = @app.call(req("GET", "/data.bin", "range" => "bytes=5-99"))
155
+
156
+ assert_equal 206, response.status
157
+ assert_equal "fghij", response.body
158
+ assert_equal "bytes 5-9/10", response.headers["Content-Range"]
159
+ end
160
+
161
+ def test_unsatisfiable_range_returns_416
162
+ File.write(File.join(@dir, "data.bin"), "abc")
163
+
164
+ response = @app.call(req("GET", "/data.bin", "range" => "bytes=10-20"))
165
+
166
+ assert_equal 416, response.status
167
+ assert_equal "bytes */3", response.headers["Content-Range"]
168
+ end
169
+
170
+ def test_invalid_range_syntax_serves_full_content
171
+ File.write(File.join(@dir, "data.bin"), "abc")
172
+
173
+ response = @app.call(req("GET", "/data.bin", "range" => "bytes=garbage"))
174
+
175
+ assert_equal 200, response.status
176
+ assert_equal "abc", response.body
177
+ end
178
+
179
+ def test_head_with_range_omits_body_but_keeps_headers
180
+ File.write(File.join(@dir, "data.bin"), "abcdefghij")
181
+
182
+ response = @app.call(req("HEAD", "/data.bin", "range" => "bytes=0-2"))
183
+
184
+ assert_equal 206, response.status
185
+ assert_equal "", response.body
186
+ assert_equal "3", response.headers["Content-Length"]
187
+ end
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
+
307
+ def test_serves_single_byte_range
308
+ File.write(File.join(@dir, "data.bin"), "abcdefghij")
309
+
310
+ response = @app.call(req("GET", "/data.bin", "range" => "bytes=0-0"))
311
+
312
+ assert_equal 206, response.status
313
+ assert_equal "a", response.body
314
+ assert_equal "1", response.headers["Content-Length"]
315
+ assert_equal "bytes 0-0/10", response.headers["Content-Range"]
316
+ end
317
+
318
+ def test_inverted_range_returns_416
319
+ File.write(File.join(@dir, "data.bin"), "abcdefghij")
320
+
321
+ response = @app.call(req("GET", "/data.bin", "range" => "bytes=5-3"))
322
+
323
+ assert_equal 416, response.status
324
+ assert_equal "bytes */10", response.headers["Content-Range"]
325
+ end
326
+
327
+ def test_206_preserves_caching_headers
328
+ path = File.join(@dir, "data.bin")
329
+ File.write(path, "abcdefghij")
330
+
331
+ response = @app.call(req("GET", "/data.bin", "range" => "bytes=2-5"))
332
+
333
+ assert_equal 206, response.status
334
+ assert_equal Wsv::MimeTypes.for_file("data.bin"), response.headers["Content-Type"]
335
+ assert_equal File.mtime(path).httpdate, response.headers["Last-Modified"]
336
+ assert_equal "no-cache", response.headers["Cache-Control"]
337
+ assert_equal "bytes", response.headers["Accept-Ranges"]
338
+ end
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
+
416
+ def test_multipart_range_falls_through_to_200
417
+ File.write(File.join(@dir, "data.bin"), "abcdefghij")
418
+
419
+ response = @app.call(req("GET", "/data.bin", "range" => "bytes=0-2,5-7"))
420
+
421
+ assert_equal 200, response.status
422
+ assert_equal "abcdefghij", response.body
423
+ refute response.headers.key?("Content-Range")
424
+ end
425
+
72
426
  private
73
427
 
74
- def req(method, target)
75
- Wsv::Request.new(method: method, target: target, version: "HTTP/1.1", headers: {})
428
+ def req(method, target, headers = {})
429
+ Wsv::Request.new(method: method, target: target, version: "HTTP/1.1", headers: headers)
76
430
  end
77
431
  end
@@ -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