syntropy 0.28.2 → 0.29.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,649 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative './helper'
4
+ require 'securerandom'
5
+
6
+ class ConnectionTest < Minitest::Test
7
+ def make_socket_pair
8
+ port = SecureRandom.random_number(10000..40000)
9
+ server_fd = @machine.socket(UM::AF_INET, UM::SOCK_STREAM, 0, 0)
10
+ @machine.setsockopt(server_fd, UM::SOL_SOCKET, UM::SO_REUSEADDR, true)
11
+ @machine.bind(server_fd, '127.0.0.1', port)
12
+ @machine.listen(server_fd, UM::SOMAXCONN)
13
+
14
+ client_conn_fd = @machine.socket(UM::AF_INET, UM::SOCK_STREAM, 0, 0)
15
+ @machine.connect(client_conn_fd, '127.0.0.1', port)
16
+
17
+ server_conn_fd = @machine.accept(server_fd)
18
+
19
+ @machine.close(server_fd)
20
+ [client_conn_fd, server_conn_fd]
21
+ end
22
+
23
+ def setup
24
+ @machine = UM.new
25
+ @c_fd, @s_fd = make_socket_pair
26
+ # s = @machine.io(@s_fd, :socket)
27
+
28
+ @reqs = []
29
+ @hook = nil
30
+ @app = ->(req) { @reqs << req; @hook&.call(req) }
31
+ @env = {}
32
+ @adapter = Syntropy::Connection.new(nil, @machine, @s_fd, @env, &@app)
33
+ end
34
+
35
+ def teardown
36
+ @machine.close(@c_fd) rescue nil
37
+ @machine.close(@s_fd) rescue nil
38
+ end
39
+
40
+ def write_http_request(msg, shutdown_wr = true)
41
+ @machine.send(@c_fd, msg, msg.bytesize, UM::MSG_WAITALL)
42
+ @machine.shutdown(@c_fd, UM::SHUT_WR) if shutdown_wr
43
+ end
44
+
45
+ def write_client_side(msg)
46
+ @machine.send(@c_fd, msg, msg.bytesize, UM::MSG_WAITALL)
47
+ end
48
+
49
+ def read_client_side(len = 65536)
50
+ buf = +''
51
+ res = @machine.recv(@c_fd, buf, len, 0)
52
+ res == 0 ? nil : buf
53
+ end
54
+
55
+ def test_http_unsupported_versions
56
+ write_http_request "GET / HTTP/0.9\r\n\r\n"
57
+ @adapter.serve_request
58
+ response = read_client_side
59
+ assert_equal "HTTP/1.1 505\r\nTransfer-Encoding: chunked\r\n\r\n1a\r\nHTTP version not supported\r\n0\r\n\r\n", response
60
+
61
+ setup
62
+
63
+ write_http_request "GET / HTTP/1.0\r\n\r\n"
64
+ @adapter.serve_request
65
+ response = read_client_side
66
+ assert_equal "HTTP/1.1 505\r\nTransfer-Encoding: chunked\r\n\r\n1a\r\nHTTP version not supported\r\n0\r\n\r\n", response
67
+
68
+ setup
69
+
70
+ @hook = ->(req) { req.respond('hi') }
71
+ write_http_request "GET / HTTP/1.1\r\n\r\n"
72
+ @adapter.serve_request
73
+ @machine.close(@s_fd)
74
+ response = read_client_side
75
+ assert_equal "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\n2\r\nhi\r\n0\r\n\r\n", response
76
+ end
77
+
78
+ def test_basic_request_parsing
79
+ write_http_request "GET / HTTP/1.1\r\n\r\n"
80
+
81
+ @adapter.serve_request
82
+ assert_equal 1, @reqs.size
83
+ req = @reqs.shift
84
+ headers = req.headers
85
+ assert_equal({
86
+ ':method' => 'get',
87
+ ':path' => '/',
88
+ ':protocol' => 'http/1.1'
89
+ }, headers)
90
+ end
91
+
92
+ def test_pipelined_requests
93
+ msg = <<~HTTP.crlf_lines
94
+ GET /foo HTTP/1.1
95
+ Server: foo.com
96
+
97
+ SCHMET /bar HTTP/1.1
98
+
99
+
100
+
101
+ HTTP
102
+ write_http_request msg
103
+
104
+ @adapter.run
105
+ assert_equal 2, @reqs.size
106
+ req0 = @reqs.shift
107
+ headers = req0.headers
108
+ assert_equal({
109
+ ':method' => 'get',
110
+ ':path' => '/foo',
111
+ ':protocol' => 'http/1.1',
112
+ 'server' => 'foo.com'
113
+ }, headers)
114
+
115
+ req1 = @reqs.shift
116
+ headers = req1.headers
117
+ assert_equal({
118
+ ':method' => 'schmet',
119
+ ':path' => '/bar',
120
+ ':protocol' => 'http/1.1'
121
+ }, headers)
122
+ end
123
+
124
+ def test_pipelined_requests_with_body
125
+ write_http_request <<~HTTP.crlf_lines
126
+ POST /foo HTTP/1.1
127
+ Server: foo.com
128
+ Content-Length: 3
129
+
130
+ abcSCHMOST /bar HTTP/1.1
131
+ Server: bar.com
132
+ Content-Length: 6
133
+
134
+ defghi
135
+ HTTP
136
+
137
+ @bodies = []
138
+ @hook = ->(req) { @bodies << req.read }
139
+
140
+ @adapter.run
141
+ assert_equal 2, @reqs.size
142
+
143
+ req0 = @reqs.shift
144
+ headers = req0.headers
145
+ assert_equal({
146
+ ':method' => 'post',
147
+ ':path' => '/foo',
148
+ ':protocol' => 'http/1.1',
149
+ 'server' => 'foo.com',
150
+ 'content-length' => '3',
151
+ ':body-done-reading' => true
152
+ }, headers)
153
+ body = @bodies.shift
154
+ assert_equal 'abc', body
155
+
156
+ req1 = @reqs.shift
157
+ headers = req1.headers
158
+ assert_equal({
159
+ ':method' => 'schmost',
160
+ ':path' => '/bar',
161
+ ':protocol' => 'http/1.1',
162
+ 'server' => 'bar.com',
163
+ 'content-length' => '6',
164
+ ':body-done-reading' => true
165
+ }, headers)
166
+ body = @bodies.shift
167
+ assert_equal 'defghi', body
168
+ end
169
+
170
+ def test_pipelined_requests_with_body_chunked
171
+ msg = <<~HTTP.crlf_lines
172
+ POST /foo HTTP/1.1
173
+ Server: foo.com
174
+ Transfer-Encoding: chunked
175
+
176
+ 3
177
+ abc
178
+ 2
179
+ de
180
+ 0
181
+
182
+ SCHMOST /bar HTTP/1.1
183
+ Server: bar.com
184
+ Transfer-Encoding: chunked
185
+
186
+ 1f
187
+ 123456789abcdefghijklmnopqrstuv
188
+ 0
189
+
190
+
191
+
192
+ HTTP
193
+ write_http_request(msg)
194
+
195
+ @bodies = []
196
+ @hook = ->(req) { @bodies << req.read }
197
+
198
+ @adapter.run
199
+ assert_equal 2, @reqs.size
200
+
201
+ req0 = @reqs.shift
202
+ headers = req0.headers
203
+ assert_equal({
204
+ ':method' => 'post',
205
+ ':path' => '/foo',
206
+ ':protocol' => 'http/1.1',
207
+ 'server' => 'foo.com',
208
+ 'transfer-encoding' => 'chunked',
209
+ ':body-done-reading' => true
210
+ }, headers)
211
+ body = @bodies.shift
212
+ assert_equal 'abcde', body
213
+
214
+ req1 = @reqs.shift
215
+ headers = req1.headers
216
+ assert_equal({
217
+ ':method' => 'schmost',
218
+ ':path' => '/bar',
219
+ ':protocol' => 'http/1.1',
220
+ 'server' => 'bar.com',
221
+ 'transfer-encoding' => 'chunked',
222
+ ':body-done-reading' => true
223
+ }, headers)
224
+ body = @bodies.shift
225
+ assert_equal '123456789abcdefghijklmnopqrstuv', body
226
+ end
227
+
228
+ def test_each_chunk
229
+ write_http_request <<~HTTP.crlf_lines
230
+ POST /foo HTTP/1.1
231
+ Server: foo.com
232
+ Transfer-Encoding: chunked
233
+
234
+ 3
235
+ abc
236
+ 2
237
+ de
238
+ 0
239
+
240
+ SCHMOST /bar HTTP/1.1
241
+ Server: bar.com
242
+ Content-Length: 31
243
+
244
+ 123456789abcdefghijklmnopqrstuv
245
+ HTTP
246
+
247
+ chunks = []
248
+ @hook = ->(req) { req.each_chunk { chunks << it } }
249
+
250
+ @adapter.serve_request
251
+ assert_equal 1, @reqs.size
252
+
253
+ req0 = @reqs.shift
254
+ headers = req0.headers
255
+ assert_equal({
256
+ ':method' => 'post',
257
+ ':path' => '/foo',
258
+ ':protocol' => 'http/1.1',
259
+ 'server' => 'foo.com',
260
+ 'transfer-encoding' => 'chunked',
261
+ ':body-done-reading' => true
262
+ }, headers)
263
+ assert_equal ['abc', 'de'], chunks
264
+
265
+ chunks.clear
266
+ @adapter.serve_request
267
+ assert_equal 1, @reqs.size
268
+
269
+ req1 = @reqs.shift
270
+ headers = req1.headers
271
+ assert_equal({
272
+ ':method' => 'schmost',
273
+ ':path' => '/bar',
274
+ ':protocol' => 'http/1.1',
275
+ 'server' => 'bar.com',
276
+ 'content-length' => '31',
277
+ ':body-done-reading' => true
278
+ }, headers)
279
+ assert_equal ['123456789abcdefghijklmnopqrstuv'], chunks
280
+ end
281
+
282
+ def test_204_status_on_empty_response
283
+ @hook = ->(req) {
284
+ req.respond(nil, {})
285
+ }
286
+
287
+ write_http_request "GET / HTTP/1.1\r\n\r\n"
288
+ @adapter.run
289
+ response = read_client_side
290
+
291
+ expected = <<~HTTP.crlf_lines
292
+ HTTP/1.1 204
293
+
294
+
295
+
296
+ HTTP
297
+ assert_equal(expected, response)
298
+
299
+ end
300
+
301
+ def test_that_server_uses_chunked_encoding_in_http_1_1
302
+ @hook = ->(req) {
303
+ req.respond('Hello, world!')
304
+ }
305
+
306
+ # using HTTP 1.0, server should close connection after responding
307
+ write_http_request "GET / HTTP/1.1\r\n\r\n"
308
+ @adapter.run
309
+
310
+ response = read_client_side
311
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\nd\r\nHello, world!\r\n0\r\n\r\n"
312
+ assert_equal(expected, response)
313
+ end
314
+
315
+ def test_that_server_maintains_connection_if_no_connection_close_header
316
+ @hook = ->(req) {
317
+ req.respond('Hi', {})
318
+ }
319
+
320
+ write_http_request "GET / HTTP/1.1\r\nConnection: close\r\n\r\n", false
321
+ res = @adapter.serve_request
322
+ assert_equal false, res
323
+
324
+ response = read_client_side
325
+ assert_equal("HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\n2\r\nHi\r\n0\r\n\r\n", response)
326
+
327
+ write_http_request "GET / HTTP/1.1\r\n\r\n", false
328
+ res = @adapter.serve_request
329
+ assert_equal true, res
330
+
331
+ response = read_client_side
332
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\n2\r\nHi\r\n0\r\n\r\n"
333
+ assert_equal(expected, response)
334
+ end
335
+
336
+ def test_pipelining_client
337
+ @hook = ->(req) {
338
+ if req.headers['foo'] == 'bar'
339
+ req.respond('Hello, foobar!', {})
340
+ else
341
+ req.respond('Hello, world!', {})
342
+ end
343
+
344
+ }
345
+
346
+ write_http_request "GET / HTTP/1.1\r\n\r\nGET / HTTP/1.1\r\nFoo: bar\r\n\r\n"
347
+ @adapter.run
348
+ response = read_client_side
349
+
350
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\nd\r\nHello, world!\r\n0\r\n\r\n" +
351
+ "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\ne\r\nHello, foobar!\r\n0\r\n\r\n"
352
+ assert_equal(expected, response)
353
+ end
354
+
355
+ def test_body_chunks
356
+ chunks = []
357
+ request = nil
358
+
359
+ @hook = ->(req) {
360
+ request = req
361
+ req.send_headers
362
+ req.each_chunk do |c|
363
+ chunks << c
364
+ req << c.upcase
365
+ end
366
+ req.finish
367
+ }
368
+
369
+ msg = "POST / HTTP/1.1\r\nTransfer-Encoding: chunked\r\n\r\n6\r\nfoobar\r\n"
370
+ write_http_request msg, false
371
+ @machine.spin { @adapter.serve_request rescue nil }
372
+ @machine.sleep(0.01)
373
+
374
+ assert request
375
+ assert_equal %w[foobar], chunks
376
+ assert !request.complete?
377
+
378
+ write_http_request "6\r\nbazbud\r\n", false
379
+ @machine.sleep(0.01)
380
+ assert_equal %w[foobar bazbud], chunks
381
+ assert !request.complete?
382
+
383
+ write_http_request "0\r\n\r\n"
384
+ @machine.sleep(0.01)
385
+ assert_equal %w[foobar bazbud], chunks
386
+ assert request.complete?
387
+
388
+ @machine.sleep(0.01)
389
+ response = read_client_side
390
+
391
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\n6\r\nFOOBAR\r\n6\r\nBAZBUD\r\n0\r\n\r\n"
392
+ assert_equal(expected, response)
393
+ end
394
+
395
+ def test_upgrade
396
+ done = nil
397
+
398
+ @hook = ->(req) do
399
+ return if req.upgrade_protocol != 'echo'
400
+
401
+ req.upgrade(:echo) do |stream, fd|
402
+ @machine.sleep(0.01)
403
+ buf = +''
404
+ while true
405
+ buf = stream.read(0)
406
+ break if !buf
407
+
408
+ res = @machine.send(fd, buf, buf.bytesize, 0)
409
+ end
410
+ req.adapter.close
411
+ done = true
412
+ end
413
+ rescue Exception => e
414
+ p e
415
+ p e.backtrace.join("\n")
416
+ end
417
+
418
+ msg = "GET / HTTP/1.1\r\nUpgrade: echo\r\nConnection: upgrade\r\n\r\n"
419
+ write_http_request(msg, false)
420
+ @machine.spin { @adapter.serve_request rescue nil }
421
+ @machine.sleep(0.01)
422
+
423
+ response = read_client_side
424
+ expected = "HTTP/1.1 101\r\nContent-Length: 0\r\nUpgrade: echo\r\nConnection: upgrade\r\n\r\n"
425
+ assert_equal(expected, response)
426
+
427
+ assert !done
428
+
429
+ write_client_side 'foo'
430
+ assert_equal 'foo', read_client_side
431
+
432
+ write_client_side 'bar'
433
+ assert_equal 'bar', read_client_side
434
+
435
+ @machine.close(@c_fd)
436
+ assert !done
437
+
438
+ @machine.sleep(0.01)
439
+ assert done
440
+ end
441
+
442
+ def test_big_download
443
+ chunk_size = 1000
444
+ chunk_count = 1000
445
+ chunk = '*' * chunk_size
446
+
447
+ @hook = ->(req) do
448
+ req.send_headers
449
+ chunk_count.times do |i|
450
+ req << chunk
451
+ @machine.snooze
452
+ end
453
+ req.finish
454
+ @machine.close(@s_fd)
455
+ rescue Exception => e
456
+ p e
457
+ p e.backtrace.join("\n")
458
+ end
459
+
460
+ response = +''
461
+ count = 0
462
+
463
+ write_client_side("GET / HTTP/1.1\r\n\r\n")
464
+ @machine.spin do
465
+ @adapter.serve_request
466
+ rescue => e
467
+ p e
468
+ p e.backtrace
469
+ end
470
+
471
+ while (data = read_client_side(chunk_size))
472
+ response << data
473
+ count += 1
474
+ @machine.snooze
475
+ break if data[-7..-1] == "\r\n0\r\n\r\n"
476
+ end
477
+
478
+ chunks = "#{chunk_size.to_s(16)}\r\n#{'*' * chunk_size}\r\n" * chunk_count
479
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\n#{chunks}0\r\n\r\n"
480
+
481
+ assert_equal expected, response
482
+ assert count >= chunk_count
483
+ end
484
+
485
+ def test_static_file_serving
486
+ fn = "/tmp/syntropy-#{rand(1000)}"
487
+ IO.write(fn, 'foobar')
488
+
489
+ @hook = ->(req) do
490
+ req.respond_with_static_file(fn, nil, nil, nil)
491
+ rescue => e
492
+ p e
493
+ p e.backtrace
494
+ end
495
+
496
+ response = +''
497
+ count = 0
498
+
499
+ write_client_side("GET / HTTP/1.1\r\n\r\n")
500
+ @machine.spin do
501
+ @adapter.serve_request
502
+ rescue => e
503
+ p e
504
+ p e.backtrace
505
+ end
506
+
507
+ while (data = read_client_side(65536))
508
+ response << data
509
+ count += 1
510
+ @machine.snooze
511
+ break if data[-7..-1] == "\r\n0\r\n\r\n"
512
+ end
513
+
514
+ content = IO.read(fn)
515
+ file_size = content.bytesize
516
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\n#{file_size.to_s(16)}\r\n#{content}\r\n0\r\n\r\n"
517
+
518
+ assert_equal expected, response
519
+ end
520
+
521
+ def test_static_file_serving_big
522
+ fn = "/tmp/syntropy-#{rand(1000)}"
523
+ IO.write(fn, 'foobar')
524
+
525
+ @hook = ->(req) do
526
+ req.respond_with_static_file(fn, nil, nil, { max_len: 3 })
527
+ req.adapter.close
528
+ end
529
+
530
+ response = +''
531
+ count = 0
532
+
533
+ write_client_side("GET / HTTP/1.1\r\n\r\n")
534
+ @machine.spin { @adapter.serve_request }
535
+
536
+ while (data = read_client_side(65536))
537
+ response << data
538
+ count += 1
539
+ @machine.snooze
540
+ end
541
+
542
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\n3\r\nfoo\r\n3\r\nbar\r\n0\r\n\r\n"
543
+ assert_equal expected, response
544
+ end
545
+
546
+ def test_connection_server_headers
547
+ @env[:server_headers] = "Server: Syntropy\r\n"
548
+
549
+ @hook = ->(req) do
550
+ req.respond('foo')
551
+ end
552
+
553
+ write_client_side("GET / HTTP/1.1\r\n\r\n")
554
+ @adapter.serve_request
555
+ response = read_client_side(65536)
556
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nServer: Syntropy\r\n\r\n3\r\nfoo\r\n0\r\n\r\n"
557
+ assert_equal expected, response
558
+
559
+ @env[:server_headers] = "Server: TP3\r\n"
560
+
561
+ write_client_side("GET / HTTP/1.1\r\n\r\n")
562
+ @adapter.serve_request
563
+ response = read_client_side(65536)
564
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nServer: TP3\r\n\r\n3\r\nfoo\r\n0\r\n\r\n"
565
+ assert_equal expected, response
566
+ end
567
+
568
+ def test_set_response_headers_1
569
+ @hook = ->(req) {
570
+ req.set_response_headers("Set-Cookie" => 'foo=bar')
571
+ req.respond('foo')
572
+ }
573
+
574
+ write_client_side("GET / HTTP/1.1\r\n\r\n")
575
+ @adapter.serve_request
576
+ response = read_client_side(65536)
577
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nSet-Cookie: foo=bar\r\n\r\n3\r\nfoo\r\n0\r\n\r\n"
578
+ assert_equal expected, response
579
+
580
+ @hook = ->(req) {
581
+ req.set_response_headers("Set-Cookie" => 'foo=bar')
582
+ req.respond('foo', 'Content-Type' => 'text/plain')
583
+ }
584
+
585
+ write_client_side("GET / HTTP/1.1\r\n\r\n")
586
+ @adapter.serve_request
587
+ response = read_client_side(65536)
588
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nSet-Cookie: foo=bar\r\nContent-Type: text/plain\r\n\r\n3\r\nfoo\r\n0\r\n\r\n"
589
+ assert_equal expected, response
590
+ end
591
+
592
+ def test_set_response_headers_2
593
+ @hook = ->(req) {
594
+ req.set_response_headers("Set-Cookie" => 'foo=bar')
595
+ req.set_response_headers("Foo" => 'bar')
596
+ req.respond('foo', 'Content-Type' => 'text/plain')
597
+ }
598
+
599
+ write_client_side("GET / HTTP/1.1\r\n\r\n")
600
+ @adapter.serve_request
601
+ response = read_client_side(65536)
602
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nSet-Cookie: foo=bar\r\nFoo: bar\r\nContent-Type: text/plain\r\n\r\n3\r\nfoo\r\n0\r\n\r\n"
603
+ assert_equal expected, response
604
+ end
605
+
606
+ def test_set_cookie_single
607
+ @hook = ->(req) {
608
+ req.set_cookie('foo=bar; HttpOnly')
609
+ req.respond('foo')
610
+ }
611
+
612
+ write_client_side("GET / HTTP/1.1\r\n\r\n")
613
+ @adapter.serve_request
614
+ response = read_client_side(65536)
615
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nSet-Cookie: foo=bar; HttpOnly\r\n\r\n3\r\nfoo\r\n0\r\n\r\n"
616
+ assert_equal expected, response
617
+
618
+ end
619
+
620
+ def test_set_cookie_multi1
621
+ @hook = ->(req) {
622
+ req.set_cookie('foo=bar; HttpOnly', 'bar=baz')
623
+ req.respond('foo')
624
+ }
625
+
626
+ write_client_side("GET / HTTP/1.1\r\n\r\n")
627
+ @adapter.serve_request
628
+ response = read_client_side(65536)
629
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nSet-Cookie: foo=bar; HttpOnly\r\nSet-Cookie: bar=baz\r\n\r\n3\r\nfoo\r\n0\r\n\r\n"
630
+ assert_equal expected, response
631
+
632
+ end
633
+
634
+ def test_set_cookie_multi2
635
+ @hook = ->(req) {
636
+ req.set_cookie('a=1', 'b=2')
637
+ req.set_cookie('c=3')
638
+ req.set_cookie('d=4', 'e=5')
639
+ req.respond('foo')
640
+ }
641
+
642
+ write_client_side("GET / HTTP/1.1\r\n\r\n")
643
+ @adapter.serve_request
644
+ response = read_client_side(65536)
645
+ expected = "HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\nSet-Cookie: a=1\r\nSet-Cookie: b=2\r\nSet-Cookie: c=3\r\nSet-Cookie: d=4\r\nSet-Cookie: e=5\r\n\r\n3\r\nfoo\r\n0\r\n\r\n"
646
+ assert_equal expected, response
647
+
648
+ end
649
+ end