syntropy 0.30.0 → 0.32.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 (96) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +2 -0
  3. data/CHANGELOG.md +30 -0
  4. data/TODO.md +46 -1
  5. data/bin/syntropy +8 -86
  6. data/cmd/_banner.rb +16 -0
  7. data/cmd/console.rb +77 -0
  8. data/cmd/help.rb +12 -0
  9. data/cmd/serve.rb +95 -0
  10. data/cmd/test.rb +40 -0
  11. data/examples/{counter.rb → basic/counter.rb} +1 -1
  12. data/examples/{templates.rb → basic/templates.rb} +1 -1
  13. data/examples/blog/app/_layout/default.rb +11 -0
  14. data/examples/blog/app/_lib/post_store.rb +47 -0
  15. data/examples/blog/app/_schema/2026-01-01-initial.rb +9 -0
  16. data/examples/blog/app/_setup.rb +4 -0
  17. data/examples/blog/app/index.rb +7 -0
  18. data/examples/blog/app/posts/[id]/edit.rb +33 -0
  19. data/examples/blog/app/posts/[id]/index.rb +58 -0
  20. data/examples/blog/app/posts/index.rb +38 -0
  21. data/examples/blog/app/posts/new.rb +29 -0
  22. data/examples/mcp-oauth/.ruby-version +1 -0
  23. data/examples/mcp-oauth/Gemfile +8 -0
  24. data/examples/mcp-oauth/README.md +128 -0
  25. data/examples/mcp-oauth/app/.well-known/oauth-authorization-server.rb +18 -0
  26. data/examples/mcp-oauth/app/.well-known/oauth-protected-resource.rb +10 -0
  27. data/examples/mcp-oauth/app/_lib/auth_store.rb +23 -0
  28. data/examples/mcp-oauth/app/index.md +1 -0
  29. data/examples/mcp-oauth/app/mcp.rb +85 -0
  30. data/examples/mcp-oauth/app/oauth/authorize.rb +18 -0
  31. data/examples/mcp-oauth/app/oauth/consent.rb +86 -0
  32. data/examples/mcp-oauth/app/oauth/register.rb +14 -0
  33. data/examples/mcp-oauth/app/oauth/token.rb +79 -0
  34. data/examples/mcp-oauth/app/signin.rb +85 -0
  35. data/examples/mcp-oauth/test/helper.rb +9 -0
  36. data/examples/mcp-oauth/test/test_app.rb +27 -0
  37. data/examples/mcp-oauth/test/test_oauth.rb +628 -0
  38. data/lib/syntropy/app.rb +34 -9
  39. data/lib/syntropy/applets/builtin/default_error_handler.rb +3 -3
  40. data/lib/syntropy/applets/builtin/req.rb +1 -1
  41. data/lib/syntropy/db/connection_pool.rb +71 -0
  42. data/lib/syntropy/db/schema.rb +92 -0
  43. data/lib/syntropy/db/store.rb +31 -0
  44. data/lib/syntropy/dev_mode.rb +1 -1
  45. data/lib/syntropy/errors.rb +6 -0
  46. data/lib/syntropy/http/client.rb +43 -0
  47. data/lib/syntropy/http/client_connection.rb +36 -0
  48. data/lib/syntropy/http/io_extensions.rb +176 -0
  49. data/lib/syntropy/http/server.rb +5 -5
  50. data/lib/syntropy/http/{connection.rb → server_connection.rb} +15 -91
  51. data/lib/syntropy/http.rb +3 -1
  52. data/lib/syntropy/logger.rb +5 -1
  53. data/lib/syntropy/{module.rb → module_loader.rb} +47 -8
  54. data/lib/syntropy/papercraft_extensions.rb +1 -1
  55. data/lib/syntropy/request/mock_adapter.rb +2 -0
  56. data/lib/syntropy/request/request_info.rb +22 -4
  57. data/lib/syntropy/request/response.rb +2 -2
  58. data/lib/syntropy/request/validation.rb +11 -5
  59. data/lib/syntropy/routing_tree.rb +2 -1
  60. data/lib/syntropy/test.rb +77 -0
  61. data/lib/syntropy/version.rb +1 -1
  62. data/lib/syntropy.rb +5 -23
  63. data/syntropy.gemspec +3 -3
  64. data/test/app/.well-known/foo.rb +3 -0
  65. data/test/app/_hook.rb +1 -1
  66. data/test/app/by_method.rb +9 -0
  67. data/test/app_setup/_setup.rb +7 -0
  68. data/test/app_setup/index.rb +1 -0
  69. data/test/app_with_schema/_schema/2026-01-02-foo.rb +12 -0
  70. data/test/app_with_schema/_schema/2026-05-30-bar.rb +7 -0
  71. data/test/helper.rb +1 -25
  72. data/test/schema/2026-01-02-foo.rb +12 -0
  73. data/test/schema/2026-05-30-bar.rb +7 -0
  74. data/test/test_app.rb +110 -70
  75. data/test/test_caching.rb +1 -1
  76. data/test/{test_connection_pool.rb → test_db_connection_pool.rb} +7 -2
  77. data/test/test_db_schema.rb +96 -0
  78. data/test/test_db_store.rb +24 -0
  79. data/test/test_http_client.rb +52 -0
  80. data/test/test_http_client_connection.rb +43 -0
  81. data/test/test_http_protocol.rb +250 -0
  82. data/test/{test_connection.rb → test_http_server_connection.rb} +39 -48
  83. data/test/test_json_api.rb +5 -5
  84. data/test/{test_module.rb → test_module_loader.rb} +31 -0
  85. data/test/{test_request_extensions.rb → test_request.rb} +153 -18
  86. data/test/test_routing_tree.rb +15 -3
  87. data/test/test_server.rb +9 -13
  88. metadata +84 -36
  89. data/lib/syntropy/connection_pool.rb +0 -61
  90. data/test/test_request_info.rb +0 -90
  91. /data/examples/{bad.rb → basic/bad.rb} +0 -0
  92. /data/examples/{card.rb → basic/card.rb} +0 -0
  93. /data/examples/{counter.js → basic/counter.js} +0 -0
  94. /data/examples/{counter_api.rb → basic/counter_api.rb} +0 -0
  95. /data/examples/{favicon.ico → basic/favicon.ico} +0 -0
  96. /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