syntropy 0.30.0 → 0.31.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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +17 -0
  3. data/bin/syntropy +8 -86
  4. data/cmd/_banner.rb +16 -0
  5. data/cmd/help.rb +12 -0
  6. data/cmd/serve.rb +95 -0
  7. data/cmd/test.rb +40 -0
  8. data/examples/{counter.rb → basic/counter.rb} +1 -1
  9. data/examples/{templates.rb → basic/templates.rb} +1 -1
  10. data/examples/mcp-oauth/.ruby-version +1 -0
  11. data/examples/mcp-oauth/Gemfile +8 -0
  12. data/examples/mcp-oauth/README.md +128 -0
  13. data/examples/mcp-oauth/app/.well-known/oauth-authorization-server.rb +18 -0
  14. data/examples/mcp-oauth/app/.well-known/oauth-protected-resource.rb +10 -0
  15. data/examples/mcp-oauth/app/_lib/auth_store.rb +23 -0
  16. data/examples/mcp-oauth/app/index.md +1 -0
  17. data/examples/mcp-oauth/app/mcp.rb +38 -0
  18. data/examples/mcp-oauth/app/oauth/authorize.rb +26 -0
  19. data/examples/mcp-oauth/app/oauth/consent.rb +86 -0
  20. data/examples/mcp-oauth/app/oauth/register.rb +15 -0
  21. data/examples/mcp-oauth/app/oauth/token.rb +79 -0
  22. data/examples/mcp-oauth/app/signin.rb +85 -0
  23. data/examples/mcp-oauth/test/helper.rb +9 -0
  24. data/examples/mcp-oauth/test/test_app.rb +27 -0
  25. data/examples/mcp-oauth/test/test_oauth.rb +628 -0
  26. data/lib/syntropy/app.rb +15 -4
  27. data/lib/syntropy/applets/builtin/default_error_handler.rb +3 -3
  28. data/lib/syntropy/applets/builtin/req.rb +1 -1
  29. data/lib/syntropy/dev_mode.rb +1 -1
  30. data/lib/syntropy/errors.rb +6 -0
  31. data/lib/syntropy/http/client.rb +43 -0
  32. data/lib/syntropy/http/client_connection.rb +36 -0
  33. data/lib/syntropy/http/io_extensions.rb +148 -0
  34. data/lib/syntropy/http/server.rb +5 -5
  35. data/lib/syntropy/http/{connection.rb → server_connection.rb} +9 -38
  36. data/lib/syntropy/http.rb +3 -1
  37. data/lib/syntropy/logger.rb +5 -1
  38. data/lib/syntropy/papercraft_extensions.rb +1 -1
  39. data/lib/syntropy/request/mock_adapter.rb +2 -0
  40. data/lib/syntropy/request/request_info.rb +20 -1
  41. data/lib/syntropy/request/response.rb +2 -2
  42. data/lib/syntropy/request/validation.rb +10 -3
  43. data/lib/syntropy/routing_tree.rb +2 -1
  44. data/lib/syntropy/test.rb +65 -0
  45. data/lib/syntropy/version.rb +1 -1
  46. data/lib/syntropy.rb +1 -21
  47. data/syntropy.gemspec +1 -2
  48. data/test/app/.well-known/foo.rb +3 -0
  49. data/test/helper.rb +1 -25
  50. data/test/test_app.rb +53 -68
  51. data/test/test_caching.rb +1 -1
  52. data/test/test_http_client.rb +52 -0
  53. data/test/test_http_client_connection.rb +43 -0
  54. data/test/{test_connection.rb → test_http_server_connection.rb} +29 -29
  55. data/test/test_json_api.rb +4 -4
  56. data/test/{test_request_extensions.rb → test_request.rb} +150 -18
  57. data/test/test_routing_tree.rb +15 -3
  58. metadata +41 -29
  59. data/test/test_request_info.rb +0 -90
  60. /data/examples/{bad.rb → basic/bad.rb} +0 -0
  61. /data/examples/{card.rb → basic/card.rb} +0 -0
  62. /data/examples/{counter.js → basic/counter.js} +0 -0
  63. /data/examples/{counter_api.rb → basic/counter_api.rb} +0 -0
  64. /data/examples/{favicon.ico → basic/favicon.ico} +0 -0
  65. /data/examples/{index.md → basic/index.md} +0 -0
@@ -0,0 +1,628 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'helper'
4
+ require 'base64'
5
+ require 'digest'
6
+
7
+ class OAuthBaseTest < Minitest::Test
8
+ APP_ROOT = File.expand_path(File.join(__dir__, '../app'))
9
+ HTTP = Syntropy::HTTP
10
+
11
+ def setup
12
+ @machine = UM.new
13
+ @app = Syntropy::App.new(
14
+ root_dir: APP_ROOT,
15
+ mount_path: '/',
16
+ machine: @machine
17
+ )
18
+ @store = @app.module_loader.load('_lib/auth_store')
19
+ @test_harness = Syntropy::TestHarness.new(@app)
20
+ end
21
+
22
+ def teardown
23
+ @machine = nil
24
+ @app = nil
25
+ @test_harness = nil
26
+ end
27
+ end
28
+
29
+ class AuthStoreTest < OAuthBaseTest
30
+ def test_auth_store
31
+ assert_nil @store.fetch('foo')
32
+
33
+ o = { a: 1, b: 2 }
34
+ key = @store.store(o)
35
+ assert_kind_of String, key
36
+ assert_equal o, @store.fetch(key)
37
+
38
+ assert_equal o, @store.fetch_and_remove(key)
39
+ assert_nil @store.fetch(key)
40
+ end
41
+ end
42
+
43
+ class OAuthPhase1DiscoveryTest < OAuthBaseTest
44
+ def test_mcp_no_bearer_token
45
+ req = @test_harness.request(
46
+ {
47
+ ':method' => 'POST',
48
+ ':path' => '/mcp',
49
+ 'content-type' => 'application/json'
50
+ },
51
+ JSON.dump({
52
+ method: 'initialize',
53
+ jsonrpc: '2.0',
54
+ params: {}
55
+ })
56
+ )
57
+ assert_equal HTTP::UNAUTHORIZED, req.response_status
58
+
59
+ www_auth = req.response_headers['WWW-Authenticate']
60
+ assert_match /realm="mcp"/, www_auth
61
+ assert_match /#{'resource_metadata="http://localhost:1234/.well-known/oauth-protected-resource"'}/, www_auth
62
+ end
63
+
64
+ def test_mcp_invalid_bearer_token
65
+ req = @test_harness.request(
66
+ {
67
+ ':method' => 'POST',
68
+ ':path' => '/mcp',
69
+ 'content-type' => 'application/json',
70
+ 'authorization' => 'Bearer foobar'
71
+ },
72
+ JSON.dump({
73
+ method: 'initialize',
74
+ jsonrpc: '2.0',
75
+ params: {}
76
+ })
77
+ )
78
+ assert_equal HTTP::UNAUTHORIZED, req.response_status
79
+
80
+ www_auth = req.response_headers['WWW-Authenticate']
81
+ assert_match /realm="mcp"/, www_auth
82
+ assert_match /#{'resource_metadata="http://localhost:1234/.well-known/oauth-protected-resource"'}/, www_auth
83
+ end
84
+
85
+ def test_oauth_protected_resource_metadatas
86
+ req = @test_harness.request(
87
+ ':method' => 'GET',
88
+ ':path' => '/.well-known/oauth-protected-resource'
89
+ )
90
+ assert_equal HTTP::OK, req.response_status
91
+ json = req.response_json
92
+ assert_equal ["http://localhost:1234/"], json['authorization_servers']
93
+ assert_equal ["mcp:read", "mcp:write"], json['scopes_supported']
94
+ end
95
+
96
+ def test_oauth_authorization_server_metadata
97
+ req = @test_harness.request(
98
+ ':method' => 'GET',
99
+ ':path' => '/.well-known/oauth-authorization-server'
100
+ )
101
+ assert_equal HTTP::OK, req.response_status
102
+ json = req.response_json
103
+ assert_equal "http://localhost:1234/", json['issuer']
104
+ assert_equal "http://localhost:1234/oauth/register", json['registration_endpoint']
105
+ assert_equal "http://localhost:1234/oauth/authorize", json['authorization_endpoint']
106
+ assert_equal "http://localhost:1234/oauth/token", json['token_endpoint']
107
+ assert_equal ["mcp:read", "mcp:write"], json['scopes_supported']
108
+ assert_equal ["code"], json['response_types_supported']
109
+ end
110
+ end
111
+
112
+ class OAuthPhase2ClientRegistrationTest < OAuthBaseTest
113
+ def test_oauth_register_endpoint
114
+ client_info = {
115
+ "client_name" => "My AI Agent",
116
+ "redirect_uris" => ["http://localhost:8400/callback"],
117
+ "grant_types" => ["authorization_code", "refresh_token"]
118
+ }
119
+
120
+ req = @test_harness.request(
121
+ {
122
+ ':method' => 'POST',
123
+ ':path' => '/oauth/register',
124
+ 'content-type' => 'application/json',
125
+ 'authorization' => 'Bearer foobar'
126
+ },
127
+ JSON.dump(client_info)
128
+ )
129
+
130
+ assert_equal HTTP::CREATED, req.response_status
131
+ json = req.response_json
132
+
133
+ client_id = json['client_id']
134
+ assert_kind_of String, client_id
135
+ assert_equal client_info, @store.fetch(client_id)
136
+
137
+ assert_equal client_info['client_name'], json['client_name']
138
+ assert_equal client_info['redirect_uris'], json['redirect_uris']
139
+ assert_equal client_info['grant_types'], json['grant_types']
140
+ end
141
+ end
142
+
143
+ class OAuthPhase3AuthorizationTest < OAuthBaseTest
144
+ def test_oauth_authorization_endpoint
145
+ # register client
146
+ client_info = {
147
+ "client_name" => "My AI Agent",
148
+ "redirect_uris" => ["http://localhost:8400/callback"],
149
+ "grant_types" => ["authorization_code", "refresh_token"]
150
+ }
151
+ req = @test_harness.request(
152
+ {
153
+ ':method' => 'POST',
154
+ ':path' => '/oauth/register',
155
+ 'content-type' => 'application/json',
156
+ 'authorization' => 'Bearer foobar'
157
+ },
158
+ JSON.dump(client_info)
159
+ )
160
+ assert_equal HTTP::CREATED, req.response_status
161
+ json = req.response_json
162
+ client_id = json['client_id']
163
+
164
+ params = {
165
+ 'response_type' => 'code',
166
+ 'client_id' => client_id,
167
+ 'redirect_uri' => 'http://localhost:4321/callback',
168
+ 'code_challenge' => SecureRandom.hex(16),
169
+ 'code_challenge_method' => 'S256',
170
+ 'state' => 'my_state'
171
+ }
172
+ req = @test_harness.request(
173
+ ':method' => 'GET',
174
+ ':path' => "/oauth/authorize?#{URI.encode_www_form(params)}"
175
+ )
176
+ assert_equal HTTP::FOUND, req.response_status
177
+ assert_equal '/signin', req.response_headers['Location']
178
+
179
+ set_cookie = req.response_headers['Set-Cookie']
180
+ refute_nil set_cookie
181
+ m = set_cookie.match(/oauth_signin_id=([^\s]+)$/)
182
+ signin_id = m[1]
183
+
184
+ assert_equal params, @store.fetch(signin_id)
185
+ end
186
+
187
+ def test_oauth_signin_with_oauth_signin_id
188
+ client_info = {
189
+ "client_name" => "My AI Agent",
190
+ "redirect_uris" => ["http://localhost:8400/callback"],
191
+ "grant_types" => ["authorization_code", "refresh_token"]
192
+ }
193
+ client_id = @store.store(client_info)
194
+
195
+ auth_params = {
196
+ response_type: 'code',
197
+ client_id: client_id,
198
+ redirect_uri: 'http://localhost:4321/callback',
199
+ code_challenge: SecureRandom.hex(16),
200
+ code_challenge_method: 'S256',
201
+ state: 'my_state'
202
+ }
203
+ oauth_signin_id = @store.store(auth_params)
204
+
205
+ req = @test_harness.request(
206
+ {
207
+ ':method' => 'POST',
208
+ ':path' => '/signin',
209
+ 'cookie' => "oauth_signin_id=#{oauth_signin_id}",
210
+ 'content-type' => 'application/x-www-form-urlencoded'
211
+ },
212
+ URI.encode_www_form(
213
+ username: 'foobar',
214
+ password: 'foobar'
215
+ )
216
+ )
217
+
218
+ auth_info = @store.fetch(oauth_signin_id)
219
+ sid = auth_info['sid']
220
+ refute_nil sid
221
+ session_info = @store.fetch(sid)
222
+ assert_kind_of Hash, session_info
223
+ assert_equal 'foobar', session_info[:username]
224
+
225
+ assert_equal HTTP::SEE_OTHER, req.response_status
226
+ assert_equal '/oauth/consent', req.response_headers['Location']
227
+ end
228
+
229
+ def test_signin_endpoint_get
230
+ req = @test_harness.request(
231
+ {
232
+ ':method' => 'GET',
233
+ ':path' => '/signin',
234
+ }
235
+ )
236
+ assert_equal HTTP::OK, req.response_status
237
+ assert_equal 'text/html', req.response_content_type
238
+ end
239
+
240
+ def test_signin_endpoint_post_bad_creds
241
+ req = @test_harness.request(
242
+ {
243
+ ':method' => 'POST',
244
+ ':path' => '/signin',
245
+ 'content-type' => 'application/x-www-form-urlencoded'
246
+ },
247
+ URI.encode_www_form(
248
+ username: 'foobar',
249
+ password: 'bad'
250
+ )
251
+ )
252
+ assert_equal HTTP::UNAUTHORIZED, req.response_status
253
+ assert_equal 'text/html', req.response_content_type
254
+ assert_nil req.response_headers['Set-Cookie']
255
+ end
256
+
257
+ def test_signin_endpoint_post_good_creds
258
+ req = @test_harness.request(
259
+ {
260
+ ':method' => 'POST',
261
+ ':path' => '/signin',
262
+ 'content-type' => 'application/x-www-form-urlencoded'
263
+ },
264
+ URI.encode_www_form(
265
+ username: 'foobar',
266
+ password: 'foobar'
267
+ )
268
+ )
269
+ assert_equal HTTP::SEE_OTHER, req.response_status
270
+ assert_equal '/', req.response_headers['Location']
271
+ sid = req.response_cookie('sid')
272
+ refute_nil sid
273
+
274
+ info = @store.fetch(sid)
275
+ assert_kind_of Hash, info
276
+ assert_equal 'foobar', info[:username]
277
+ end
278
+
279
+ def test_oauth_consent_endpoint_get_no_oauth_signin_id
280
+ req = @test_harness.request(
281
+ {
282
+ ':method' => 'GET',
283
+ ':path' => '/oauth/consent',
284
+ }
285
+ )
286
+ assert_equal HTTP::BAD_REQUEST, req.response_status
287
+ end
288
+
289
+ def test_oauth_consent_endpoint_get_invalid_oauth_signin_id
290
+ req = @test_harness.request(
291
+ {
292
+ ':method' => 'GET',
293
+ ':path' => '/oauth/consent',
294
+ 'cookie' => 'outh_signin_id=foo'
295
+ }
296
+ )
297
+ assert_equal HTTP::BAD_REQUEST, req.response_status
298
+ end
299
+
300
+ def test_oauth_consent_endpoint_get_valid_oauth_signin_id
301
+ client_info = {
302
+ "client_name" => "My AI Agent",
303
+ "redirect_uris" => ["http://localhost:8400/callback"],
304
+ "grant_types" => ["authorization_code", "refresh_token"]
305
+ }
306
+ client_id = @store.store(client_info)
307
+
308
+ session_info = {
309
+ username: 'foobar'
310
+ }
311
+ sid = @store.store(session_info)
312
+
313
+ auth_params = {
314
+ 'response_type' => 'code',
315
+ 'client_id' => client_id,
316
+ 'redirect_uri' => 'http://localhost:4321/callback',
317
+ 'code_challenge' => SecureRandom.hex(16),
318
+ 'code_challenge_method' => 'S256',
319
+ 'state' => 'my_state',
320
+ 'sid' => sid
321
+ }
322
+ oauth_signin_id = @store.store(auth_params)
323
+
324
+ req = @test_harness.request(
325
+ {
326
+ ':method' => 'GET',
327
+ ':path' => '/oauth/consent',
328
+ 'cookie' => "oauth_signin_id=#{oauth_signin_id}"
329
+ }
330
+ )
331
+ assert_equal HTTP::OK, req.response_status
332
+ end
333
+
334
+ def test_oauth_consent_endpoint_post_deny
335
+ client_info = {
336
+ "client_name" => "My AI Agent",
337
+ "redirect_uris" => ["http://localhost:8400/callback"],
338
+ "grant_types" => ["authorization_code", "refresh_token"]
339
+ }
340
+ client_id = @store.store(client_info)
341
+
342
+ session_info = {
343
+ username: 'foobar'
344
+ }
345
+ sid = @store.store(session_info)
346
+
347
+ auth_params = {
348
+ 'response_type' => 'code',
349
+ 'client_id' => client_id,
350
+ 'redirect_uri' => 'http://localhost:4321/callback',
351
+ 'code_challenge' => SecureRandom.hex(16),
352
+ 'code_challenge_method' => 'S256',
353
+ 'state' => 'my_state',
354
+ 'sid' => sid
355
+ }
356
+ oauth_signin_id = @store.store(auth_params)
357
+
358
+ req = @test_harness.request(
359
+ {
360
+ ':method' => 'POST',
361
+ ':path' => '/oauth/consent',
362
+ 'cookie' => "oauth_signin_id=#{oauth_signin_id}",
363
+ 'content-type' => 'application/x-www-form-urlencoded'
364
+ },
365
+ URI.encode_www_form(
366
+ decision: 'deny',
367
+ )
368
+ )
369
+ assert_equal HTTP::FOUND, req.response_status
370
+
371
+ deny_uri = "http://localhost:4321/callback?error=access_denied&state=my_state"
372
+ assert_equal deny_uri, req.response_headers['Location']
373
+ end
374
+
375
+ def test_oauth_consent_endpoint_post_allow
376
+ client_info = {
377
+ "client_name" => "My AI Agent",
378
+ "redirect_uris" => ["http://localhost:8400/callback"],
379
+ "grant_types" => ["authorization_code", "refresh_token"]
380
+ }
381
+ client_id = @store.store(client_info)
382
+
383
+ session_info = {
384
+ username: 'foobar'
385
+ }
386
+ sid = @store.store(session_info)
387
+
388
+ auth_params = {
389
+ 'response_type' => 'code',
390
+ 'client_id' => client_id,
391
+ 'redirect_uri' => 'http://localhost:4321/callback',
392
+ 'code_challenge' => SecureRandom.hex(16),
393
+ 'code_challenge_method' => 'S256',
394
+ 'state' => 'my_state',
395
+ 'sid' => sid
396
+ }
397
+ oauth_signin_id = @store.store(auth_params)
398
+
399
+ req = @test_harness.request(
400
+ {
401
+ ':method' => 'POST',
402
+ ':path' => '/oauth/consent',
403
+ 'cookie' => "oauth_signin_id=#{oauth_signin_id}",
404
+ 'content-type' => 'application/x-www-form-urlencoded'
405
+ },
406
+ URI.encode_www_form(
407
+ decision: 'allow',
408
+ )
409
+ )
410
+ assert_equal HTTP::FOUND, req.response_status
411
+
412
+ u = URI.parse(req.response_headers['Location'])
413
+ q = URI.decode_www_form(u.query).to_h
414
+ u.query = nil
415
+ assert_equal 'http://localhost:4321/callback', u.to_s
416
+ assert_equal 'my_state', q['state']
417
+ assert_kind_of String, q['code']
418
+
419
+ code_info = @store.fetch(q['code'])
420
+ assert_equal auth_params, code_info
421
+ end
422
+ end
423
+
424
+ class OAuthPhase4AuthorizationTest < OAuthBaseTest
425
+ def setup
426
+ super
427
+ @client_info = {
428
+ "client_name" => "My AI Agent",
429
+ "redirect_uris" => ["http://localhost:8400/callback"],
430
+ "grant_types" => ["authorization_code", "refresh_token"]
431
+ }
432
+ @client_id = @store.store(@client_info)
433
+
434
+ @session_info = {
435
+ username: 'foobar'
436
+ }
437
+ @sid = @store.store(@session_info)
438
+
439
+ @code_verifier = SecureRandom.hex(16)
440
+ @code_challenge = Base64.urlsafe_encode64(Digest::SHA256.digest(@code_verifier), padding: false)
441
+
442
+ @auth_params = {
443
+ 'response_type' => 'code',
444
+ 'client_id' => @client_id,
445
+ 'redirect_uri' => 'http://localhost:4321/callback',
446
+ 'code_challenge' => @code_challenge,
447
+ 'code_challenge_method' => 'S256',
448
+ 'state' => 'my_state',
449
+ 'sid' => @sid
450
+ }
451
+ @auth_code = @store.store(@auth_params)
452
+ end
453
+
454
+ def test_oauth_token_exchange
455
+ req = @test_harness.request(
456
+ {
457
+ ':method' => 'POST',
458
+ ':path' => '/oauth/token',
459
+ 'content-type' => 'application/x-www-form-urlencoded'
460
+ },
461
+ URI.encode_www_form(
462
+ grant_type: 'authorization_code',
463
+ code: @auth_code,
464
+ redirect_uri: 'http://localhost:4321/callback',
465
+ client_id: @client_id,
466
+ code_verifier: @code_verifier
467
+ )
468
+ )
469
+
470
+ assert_equal HTTP::OK, req.response_status
471
+ assert_equal 'application/json', req.response_content_type
472
+ json = req.response_json
473
+
474
+ at = json['access_token']
475
+ assert_kind_of String, at
476
+ token_info = @store.fetch(at)
477
+ refute_nil token_info
478
+ assert_equal @session_info[:username], token_info[:username]
479
+ assert_equal 'oauth', token_info[:type]
480
+
481
+ assert_equal 'Bearer', json['token_type']
482
+ assert_kind_of Integer, json['expires_in']
483
+ end
484
+
485
+ def test_oauth_token_exchange_missing_params
486
+ req = @test_harness.request(
487
+ {
488
+ ':method' => 'POST',
489
+ ':path' => '/oauth/token',
490
+ 'content-type' => 'application/x-www-form-urlencoded'
491
+ },
492
+ URI.encode_www_form({}
493
+ # grant_type: 'authorization_code',
494
+ # code: auth_code,
495
+ # redirect_uri: 'http://localhost:4321/callback',
496
+ # client_id: client_id,
497
+ # code_verifier: code_verifier
498
+ )
499
+ )
500
+
501
+ assert_equal HTTP::BAD_REQUEST, req.response_status
502
+ assert_equal 'application/json', req.response_content_type
503
+ json = req.response_json
504
+
505
+ error = json['error']
506
+ assert_equal 'invalid_request', error
507
+ end
508
+
509
+ def test_oauth_token_exchange_invalid_grant_type
510
+ req = @test_harness.request(
511
+ {
512
+ ':method' => 'POST',
513
+ ':path' => '/oauth/token',
514
+ 'content-type' => 'application/x-www-form-urlencoded'
515
+ },
516
+ URI.encode_www_form(
517
+ grant_type: 'foo',
518
+ code: @auth_code,
519
+ redirect_uri: 'http://localhost:4321/callback',
520
+ client_id: @client_id,
521
+ code_verifier: @code_verifier
522
+ )
523
+ )
524
+
525
+ assert_equal HTTP::BAD_REQUEST, req.response_status
526
+ assert_equal 'application/json', req.response_content_type
527
+ json = req.response_json
528
+
529
+ error = json['error']
530
+ assert_equal 'unsupported_grant_type', error
531
+ end
532
+
533
+ def test_oauth_token_exchange_invalid_code
534
+ req = @test_harness.request(
535
+ {
536
+ ':method' => 'POST',
537
+ ':path' => '/oauth/token',
538
+ 'content-type' => 'application/x-www-form-urlencoded'
539
+ },
540
+ URI.encode_www_form(
541
+ grant_type: 'authorization_code',
542
+ code: @auth_code + '!',
543
+ redirect_uri: 'http://localhost:4321/callback',
544
+ client_id: @client_id,
545
+ code_verifier: @code_verifier
546
+ )
547
+ )
548
+
549
+ assert_equal HTTP::BAD_REQUEST, req.response_status
550
+ assert_equal 'application/json', req.response_content_type
551
+ json = req.response_json
552
+
553
+ error = json['error']
554
+ assert_equal 'invalid_request', error
555
+ end
556
+
557
+ def test_oauth_token_exchange_invalid_redirect_uri
558
+ req = @test_harness.request(
559
+ {
560
+ ':method' => 'POST',
561
+ ':path' => '/oauth/token',
562
+ 'content-type' => 'application/x-www-form-urlencoded'
563
+ },
564
+ URI.encode_www_form(
565
+ grant_type: 'authorization_code',
566
+ code: @auth_code,
567
+ redirect_uri: 'http://localhost:4321/foo',
568
+ client_id: @client_id,
569
+ code_verifier: @code_verifier
570
+ )
571
+ )
572
+
573
+ assert_equal HTTP::BAD_REQUEST, req.response_status
574
+ assert_equal 'application/json', req.response_content_type
575
+ json = req.response_json
576
+
577
+ error = json['error']
578
+ assert_equal 'invalid_request', error
579
+ end
580
+
581
+ def test_oauth_token_exchange_invalid_client_id
582
+ req = @test_harness.request(
583
+ {
584
+ ':method' => 'POST',
585
+ ':path' => '/oauth/token',
586
+ 'content-type' => 'application/x-www-form-urlencoded'
587
+ },
588
+ URI.encode_www_form(
589
+ grant_type: 'authorization_code',
590
+ code: @auth_code + '!',
591
+ redirect_uri: 'http://localhost:4321/callback',
592
+ client_id: @client_id + 'foo',
593
+ code_verifier: @code_verifier
594
+ )
595
+ )
596
+
597
+ assert_equal HTTP::BAD_REQUEST, req.response_status
598
+ assert_equal 'application/json', req.response_content_type
599
+ json = req.response_json
600
+
601
+ error = json['error']
602
+ assert_equal 'invalid_request', error
603
+ end
604
+
605
+ def test_oauth_token_exchange_invalid_code_verifier
606
+ req = @test_harness.request(
607
+ {
608
+ ':method' => 'POST',
609
+ ':path' => '/oauth/token',
610
+ 'content-type' => 'application/x-www-form-urlencoded'
611
+ },
612
+ URI.encode_www_form(
613
+ grant_type: 'authorization_code',
614
+ code: @auth_code + '!',
615
+ redirect_uri: 'http://localhost:4321/callback',
616
+ client_id: @client_id,
617
+ code_verifier: @code_verifier + 'abc'
618
+ )
619
+ )
620
+
621
+ assert_equal HTTP::BAD_REQUEST, req.response_status
622
+ assert_equal 'application/json', req.response_content_type
623
+ json = req.response_json
624
+
625
+ error = json['error']
626
+ assert_equal 'invalid_request', error
627
+ end
628
+ end
data/lib/syntropy/app.rb CHANGED
@@ -35,6 +35,7 @@ module Syntropy
35
35
  end
36
36
 
37
37
  attr_reader :module_loader, :routing_tree, :root_dir, :mount_path, :env
38
+ attr_accessor :test_mode
38
39
 
39
40
  def initialize(**env)
40
41
  @machine = env[:machine]
@@ -321,7 +322,7 @@ module Syntropy
321
322
  }
322
323
  body {
323
324
  markdown md
324
- auto_refresh_watch! if @env[:dev_mode]
325
+ auto_refresh! if @env[:dev_mode]
325
326
  }
326
327
  }
327
328
  }
@@ -450,18 +451,28 @@ module Syntropy
450
451
  end
451
452
 
452
453
  RAW_DEFAULT_ERROR_HANDLER = ->(req, err) {
454
+ status = Syntropy::Error.http_status(err)
455
+
453
456
  msg = err.message
454
457
  msg = nil if msg.empty? || (req.method == 'head')
455
- req.respond(msg, ':status' => Syntropy::Error.http_status(err)) rescue nil
458
+ req.respond(msg, ':status' => status) rescue nil
456
459
  }
457
460
 
458
- def default_error_handler
461
+ TEST_MODE_DEFAULT_ERROR_HANDLER = ->(req, err) {
462
+ status = Syntropy::Error.http_status(err)
463
+ raise if status == HTTP::INTERNAL_SERVER_ERROR
459
464
 
465
+ msg = err.message
466
+ msg = nil if msg.empty? || (req.method == 'head')
467
+ req.respond(msg, ':status' => status) rescue nil
468
+ }
469
+
470
+ def default_error_handler
460
471
  @default_error_handler ||= begin
461
472
  if @builtin_applet
462
473
  @builtin_applet.module_loader.load('/default_error_handler')
463
474
  else
464
- RAW_DEFAULT_ERROR_HANDLER
475
+ @test_mode ? TEST_MODE_DEFAULT_ERROR_HANDLER : RAW_DEFAULT_ERROR_HANDLER
465
476
  end
466
477
  end
467
478
  end
@@ -23,7 +23,7 @@ ErrorPage = ->(error:, status:, backtrace:) {
23
23
  }
24
24
  end
25
25
  }
26
- auto_refresh_watch!
26
+ auto_refresh!
27
27
  }
28
28
  }
29
29
  }
@@ -32,7 +32,7 @@ def transform_backtrace(backtrace)
32
32
  backtrace.map do
33
33
  if (m = it.match(/^(.+:\d+):/))
34
34
  location = m[1]
35
- { entry: it, url: "vscode://file/#{location}" }
35
+ { entry: it, url: "zed://file/#{location}" }
36
36
  else
37
37
  { entry: it, url: nil }
38
38
  end
@@ -43,7 +43,7 @@ def error_response_html(req, error)
43
43
  status = Syntropy::Error.http_status(error)
44
44
  backtrace = transform_backtrace(error.backtrace)
45
45
  html = Papercraft.html(ErrorPage, error:, status:, backtrace:)
46
- req.html_response(html, ':status' => status)
46
+ req.respond_html(html, ':status' => status)
47
47
  end
48
48
 
49
49
  def error_response_raw(req, error)