tep 0.11.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 (193) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/Makefile +134 -0
  4. data/README.md +247 -0
  5. data/SINATRA_COMPAT.md +376 -0
  6. data/bin/tep +2156 -0
  7. data/examples/agentic_chat/README.md +103 -0
  8. data/examples/agentic_chat/app.rb +310 -0
  9. data/examples/api_gateway/README.md +49 -0
  10. data/examples/api_gateway/app.rb +66 -0
  11. data/examples/blog/app.rb +367 -0
  12. data/examples/blog/views/index.erb +36 -0
  13. data/examples/blog/views/login.erb +28 -0
  14. data/examples/blog/views/new_post.erb +25 -0
  15. data/examples/blog/views/show.erb +16 -0
  16. data/examples/chat/app.rb +278 -0
  17. data/examples/chat/assets/logo.svg +13 -0
  18. data/examples/chat/assets/style.css +209 -0
  19. data/examples/chat/views/index.erb +142 -0
  20. data/examples/chatbot/README.md +111 -0
  21. data/examples/chatbot/app.rb +1024 -0
  22. data/examples/chatbot/assets/chat.js +249 -0
  23. data/examples/chatbot/assets/compare.js +93 -0
  24. data/examples/chatbot/assets/markdown.js +84 -0
  25. data/examples/chatbot/assets/style.css +215 -0
  26. data/examples/chatbot/schema.sql +25 -0
  27. data/examples/chatbot/views/compare.erb +43 -0
  28. data/examples/chatbot/views/index.erb +42 -0
  29. data/examples/chatbot/views/login.erb +22 -0
  30. data/examples/chatbot/views/setup.erb +23 -0
  31. data/examples/counter/README.md +68 -0
  32. data/examples/counter/app.rb +85 -0
  33. data/examples/experiments/AGENTS.md +91 -0
  34. data/examples/experiments/README.md +99 -0
  35. data/examples/experiments/app.rb +225 -0
  36. data/examples/geohash/Gemfile +11 -0
  37. data/examples/geohash/Gemfile.lock +17 -0
  38. data/examples/geohash/README.md +58 -0
  39. data/examples/geohash/app.rb +33 -0
  40. data/examples/hello.rb +120 -0
  41. data/examples/llm_gateway/README.md +73 -0
  42. data/examples/llm_gateway/app.rb +91 -0
  43. data/examples/maidenhead/Gemfile +7 -0
  44. data/examples/maidenhead/Gemfile.lock +17 -0
  45. data/examples/maidenhead/README.md +47 -0
  46. data/examples/maidenhead/app.rb +46 -0
  47. data/examples/pg_hello.rb +76 -0
  48. data/examples/qdrant/Gemfile +11 -0
  49. data/examples/qdrant/Gemfile.lock +29 -0
  50. data/examples/qdrant/README.md +54 -0
  51. data/examples/sinatra_style.rb +32 -0
  52. data/examples/websocket_echo.rb +37 -0
  53. data/lib/tep/agent_delegation.rb +35 -0
  54. data/lib/tep/app.rb +291 -0
  55. data/lib/tep/assets.rb +52 -0
  56. data/lib/tep/auth.rb +78 -0
  57. data/lib/tep/auth_bearer_token.rb +126 -0
  58. data/lib/tep/auth_oauth2.rb +189 -0
  59. data/lib/tep/auth_oauth2_client.rb +29 -0
  60. data/lib/tep/auth_oauth2_code.rb +40 -0
  61. data/lib/tep/auth_session_cookie.rb +132 -0
  62. data/lib/tep/broadcast.rb +265 -0
  63. data/lib/tep/broadcast_subscription.rb +42 -0
  64. data/lib/tep/cache.rb +49 -0
  65. data/lib/tep/events.rb +257 -0
  66. data/lib/tep/filter.rb +21 -0
  67. data/lib/tep/handler.rb +35 -0
  68. data/lib/tep/http.rb +599 -0
  69. data/lib/tep/identity.rb +67 -0
  70. data/lib/tep/job.rb +186 -0
  71. data/lib/tep/json.rb +572 -0
  72. data/lib/tep/jwt.rb +126 -0
  73. data/lib/tep/live_view.rb +219 -0
  74. data/lib/tep/llm.rb +505 -0
  75. data/lib/tep/logger.rb +85 -0
  76. data/lib/tep/mcp.rb +203 -0
  77. data/lib/tep/multipart.rb +98 -0
  78. data/lib/tep/net.rb +155 -0
  79. data/lib/tep/openai_server.rb +725 -0
  80. data/lib/tep/parallel.rb +168 -0
  81. data/lib/tep/parser.rb +81 -0
  82. data/lib/tep/password.rb +102 -0
  83. data/lib/tep/pg.rb +1128 -0
  84. data/lib/tep/presence.rb +589 -0
  85. data/lib/tep/presence_entry.rb +52 -0
  86. data/lib/tep/proxy.rb +801 -0
  87. data/lib/tep/request.rb +194 -0
  88. data/lib/tep/response.rb +134 -0
  89. data/lib/tep/router.rb +137 -0
  90. data/lib/tep/scheduler.rb +342 -0
  91. data/lib/tep/security.rb +140 -0
  92. data/lib/tep/server.rb +276 -0
  93. data/lib/tep/server_scheduled.rb +375 -0
  94. data/lib/tep/session.rb +98 -0
  95. data/lib/tep/shell.rb +62 -0
  96. data/lib/tep/sphttp.c +858 -0
  97. data/lib/tep/sqlite.rb +215 -0
  98. data/lib/tep/streamer.rb +31 -0
  99. data/lib/tep/tep_pg.c +769 -0
  100. data/lib/tep/tep_sqlite.c +320 -0
  101. data/lib/tep/url.rb +161 -0
  102. data/lib/tep/version.rb +3 -0
  103. data/lib/tep/websocket/connection.rb +171 -0
  104. data/lib/tep/websocket/driver.rb +169 -0
  105. data/lib/tep/websocket/frame.rb +238 -0
  106. data/lib/tep/websocket/handshake.rb +159 -0
  107. data/lib/tep/websocket.rb +68 -0
  108. data/lib/tep.rb +981 -0
  109. data/public/hello.txt +1 -0
  110. data/public/style.css +4 -0
  111. data/spinel-ext.json +33 -0
  112. data/test/helper.rb +248 -0
  113. data/test/real_world/01_simple.rb +5 -0
  114. data/test/real_world/02_lifecycle.rb +20 -0
  115. data/test/real_world/03_chat.rb +75 -0
  116. data/test/real_world/04_health_api.rb +25 -0
  117. data/test/real_world/05_todo_api.rb +57 -0
  118. data/test/real_world/06_basic_auth.rb +25 -0
  119. data/test/real_world/07_bbc_rest_api.rb +228 -0
  120. data/test/real_world/07_sklise_things.rb +109 -0
  121. data/test/real_world/08_jwd83_helloworld.rb +56 -0
  122. data/test/run_all.rb +7 -0
  123. data/test/run_parallel.rb +89 -0
  124. data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
  125. data/test/test_api_gateway.rb +76 -0
  126. data/test/test_auth.rb +223 -0
  127. data/test/test_auth_oauth2.rb +208 -0
  128. data/test/test_auth_session_cookie.rb +198 -0
  129. data/test/test_broadcast.rb +197 -0
  130. data/test/test_broadcast_pg.rb +135 -0
  131. data/test/test_cache.rb +98 -0
  132. data/test/test_cache_static.rb +48 -0
  133. data/test/test_cookies.rb +52 -0
  134. data/test/test_erb.rb +53 -0
  135. data/test/test_erb_ivars.rb +58 -0
  136. data/test/test_events.rb +114 -0
  137. data/test/test_filters.rb +41 -0
  138. data/test/test_geohash_example.rb +89 -0
  139. data/test/test_http.rb +137 -0
  140. data/test/test_http_pool.rb +122 -0
  141. data/test/test_http_pool_send.rb +57 -0
  142. data/test/test_identity.rb +165 -0
  143. data/test/test_inbound_tls.rb +101 -0
  144. data/test/test_inbound_tls_scheduled.rb +101 -0
  145. data/test/test_job.rb +108 -0
  146. data/test/test_json.rb +168 -0
  147. data/test/test_jwt.rb +143 -0
  148. data/test/test_live_view.rb +324 -0
  149. data/test/test_llm.rb +250 -0
  150. data/test/test_llm_gateway.rb +95 -0
  151. data/test/test_logger.rb +101 -0
  152. data/test/test_maidenhead_example.rb +86 -0
  153. data/test/test_mcp.rb +264 -0
  154. data/test/test_misc_v02.rb +54 -0
  155. data/test/test_modular.rb +43 -0
  156. data/test/test_multi_filters.rb +40 -0
  157. data/test/test_mustache.rb +57 -0
  158. data/test/test_openai_server.rb +598 -0
  159. data/test/test_optional_segments.rb +45 -0
  160. data/test/test_parallel.rb +102 -0
  161. data/test/test_params.rb +99 -0
  162. data/test/test_pass.rb +42 -0
  163. data/test/test_password.rb +101 -0
  164. data/test/test_pg.rb +673 -0
  165. data/test/test_presence.rb +374 -0
  166. data/test/test_presence_pg.rb +309 -0
  167. data/test/test_proxy.rb +556 -0
  168. data/test/test_proxy_dsl.rb +119 -0
  169. data/test/test_proxy_streaming.rb +146 -0
  170. data/test/test_real_world.rb +397 -0
  171. data/test/test_regex_routes.rb +52 -0
  172. data/test/test_request_methods.rb +102 -0
  173. data/test/test_response.rb +123 -0
  174. data/test/test_routing.rb +109 -0
  175. data/test/test_scheduler.rb +153 -0
  176. data/test/test_security.rb +72 -0
  177. data/test/test_server_scheduled.rb +56 -0
  178. data/test/test_sessions.rb +59 -0
  179. data/test/test_shell.rb +54 -0
  180. data/test/test_sqlite.rb +148 -0
  181. data/test/test_sqlite_cached.rb +171 -0
  182. data/test/test_static.rb +57 -0
  183. data/test/test_streaming.rb +96 -0
  184. data/test/test_unsupported.rb +32 -0
  185. data/test/test_websocket.rb +152 -0
  186. data/test/test_websocket_echo.rb +138 -0
  187. data/test/views/greet.erb +5 -0
  188. data/test/views/hello.erb +5 -0
  189. data/test/views/list.erb +5 -0
  190. data/test/views/m_ivars.mustache +3 -0
  191. data/test/views/m_simple.mustache +4 -0
  192. data/test/views/mixed.erb +3 -0
  193. metadata +264 -0
@@ -0,0 +1,165 @@
1
+ require_relative "helper"
2
+
3
+ # Tep::Identity + Tep::AgentDelegation: the principal+delegate
4
+ # identity types Auth issues and Broadcast/Presence/LiveView consume.
5
+ # See docs/BATTERIES-DESIGN.md for the broader Auth design.
6
+ class TestIdentity < TepTest
7
+ app_source <<~RB
8
+ require 'sinatra'
9
+
10
+ HUMAN_CAPS = [:read, :write]
11
+ AGENT_CAPS = [:read]
12
+
13
+ HUMAN = Tep::Identity.new("user:42", nil, HUMAN_CAPS)
14
+
15
+ BOT_DELEGATION = Tep::AgentDelegation.new(
16
+ "summarizer-bot", 1000, 2000, :token)
17
+ AGENT = Tep::Identity.new("user:42", BOT_DELEGATION, AGENT_CAPS)
18
+
19
+ # Plain-text helper so every route returns the answer directly.
20
+ before do
21
+ res.headers["Content-Type"] = "text/plain"
22
+ end
23
+
24
+ get '/human/subject' do
25
+ HUMAN.subject
26
+ end
27
+
28
+ get '/human/is_human' do
29
+ HUMAN.human? ? "yes" : "no"
30
+ end
31
+
32
+ get '/human/is_agent' do
33
+ HUMAN.agent? ? "yes" : "no"
34
+ end
35
+
36
+ get '/human/may_read' do
37
+ HUMAN.may?(:read) ? "yes" : "no"
38
+ end
39
+
40
+ get '/human/may_post_summary' do
41
+ HUMAN.may?(:post_summary) ? "yes" : "no"
42
+ end
43
+
44
+ get '/agent/subject' do
45
+ AGENT.subject
46
+ end
47
+
48
+ get '/agent/is_human' do
49
+ AGENT.human? ? "yes" : "no"
50
+ end
51
+
52
+ get '/agent/is_agent' do
53
+ AGENT.agent? ? "yes" : "no"
54
+ end
55
+
56
+ get '/agent/may_read' do
57
+ AGENT.may?(:read) ? "yes" : "no"
58
+ end
59
+
60
+ get '/agent/may_write' do
61
+ AGENT.may?(:write) ? "yes" : "no"
62
+ end
63
+
64
+ get '/agent/agent_id' do
65
+ AGENT.acting_via.agent_id
66
+ end
67
+
68
+ get '/agent/origin' do
69
+ AGENT.acting_via.origin.to_s
70
+ end
71
+
72
+ get '/agent/expired_before' do
73
+ AGENT.acting_via.expired?(1500) ? "yes" : "no"
74
+ end
75
+
76
+ get '/agent/expired_after' do
77
+ AGENT.acting_via.expired?(2500) ? "yes" : "no"
78
+ end
79
+
80
+ get '/anonymous/subject' do
81
+ Tep::Identity.anonymous.subject
82
+ end
83
+
84
+ get '/anonymous/may_read' do
85
+ Tep::Identity.anonymous.may?(:read) ? "yes" : "no"
86
+ end
87
+
88
+ get '/anonymous/is_human' do
89
+ Tep::Identity.anonymous.human? ? "yes" : "no"
90
+ end
91
+ RB
92
+
93
+ def test_human_subject_format
94
+ assert_equal "user:user:42", get("/human/subject").body
95
+ end
96
+
97
+ def test_human_is_human
98
+ assert_equal "yes", get("/human/is_human").body
99
+ end
100
+
101
+ def test_human_is_not_agent
102
+ assert_equal "no", get("/human/is_agent").body
103
+ end
104
+
105
+ def test_human_has_granted_cap
106
+ assert_equal "yes", get("/human/may_read").body
107
+ end
108
+
109
+ def test_human_lacks_ungranted_cap
110
+ assert_equal "no", get("/human/may_post_summary").body
111
+ end
112
+
113
+ def test_agent_subject_format
114
+ assert_equal "agent:summarizer-bot/user:42", get("/agent/subject").body
115
+ end
116
+
117
+ def test_agent_is_not_human
118
+ assert_equal "no", get("/agent/is_human").body
119
+ end
120
+
121
+ def test_agent_is_agent
122
+ assert_equal "yes", get("/agent/is_agent").body
123
+ end
124
+
125
+ def test_agent_has_granted_cap
126
+ assert_equal "yes", get("/agent/may_read").body
127
+ end
128
+
129
+ def test_agent_lacks_principal_cap
130
+ # Principal HUMAN has :write; AGENT was granted only :read.
131
+ # Cap subset not superset.
132
+ assert_equal "no", get("/agent/may_write").body
133
+ end
134
+
135
+ def test_agent_delegation_exposes_agent_id
136
+ assert_equal "summarizer-bot", get("/agent/agent_id").body
137
+ end
138
+
139
+ def test_agent_delegation_exposes_origin
140
+ assert_equal "token", get("/agent/origin").body
141
+ end
142
+
143
+ def test_delegation_not_expired_before_window
144
+ assert_equal "no", get("/agent/expired_before").body
145
+ end
146
+
147
+ def test_delegation_expired_after_window
148
+ assert_equal "yes", get("/agent/expired_after").body
149
+ end
150
+
151
+ def test_anonymous_subject_is_empty_principal
152
+ assert_equal "user:", get("/anonymous/subject").body
153
+ end
154
+
155
+ def test_anonymous_has_no_capabilities
156
+ assert_equal "no", get("/anonymous/may_read").body
157
+ end
158
+
159
+ def test_anonymous_is_human
160
+ # Without a delegation, anonymous is technically "human" per the
161
+ # acting_via shape. Apps gating routes by anonymous-vs-not check
162
+ # principal_id == "" or use a wrapping helper.
163
+ assert_equal "yes", get("/anonymous/is_human").body
164
+ end
165
+ end
@@ -0,0 +1,101 @@
1
+ require_relative "helper"
2
+ require "openssl"
3
+ require "socket"
4
+ require "timeout"
5
+
6
+ # Inbound server TLS (#148 phase 2): Tep::Server terminates HTTPS when
7
+ # Tep.tls_cert / Tep.tls_key are set. Boots a tep binary with a
8
+ # self-signed cert and drives it with a TLS client.
9
+ class TestInboundTls < TepTest
10
+ # Per-process cert paths (unique under the parallel runner). Baked
11
+ # into app_source at class load; generated before the binary boots.
12
+ CERT = "/tmp/tep_tls_test_#{Process.pid}.crt"
13
+ KEY = "/tmp/tep_tls_test_#{Process.pid}.key"
14
+
15
+ app_source <<~RB
16
+ require 'sinatra'
17
+ Tep.tls_cert = "#{CERT}"
18
+ Tep.tls_key = "#{KEY}"
19
+
20
+ get '/hello' do
21
+ "tls-ok"
22
+ end
23
+ RB
24
+
25
+ def self.gen_cert
26
+ return if File.exist?(CERT) && File.exist?(KEY)
27
+ key = OpenSSL::PKey::RSA.new(2048)
28
+ cert = OpenSSL::X509::Certificate.new
29
+ cert.version = 2
30
+ cert.serial = 1
31
+ cert.subject = OpenSSL::X509::Name.parse("/CN=localhost")
32
+ cert.issuer = cert.subject
33
+ cert.public_key = key.public_key
34
+ cert.not_before = Time.now - 60
35
+ cert.not_after = Time.now + 3600
36
+ cert.sign(key, OpenSSL::Digest::SHA256.new)
37
+ File.write(CERT, cert.to_pem)
38
+ File.write(KEY, key.to_pem)
39
+ end
40
+
41
+ def setup
42
+ self.class.gen_cert # must exist before the spawned binary boots
43
+ super
44
+ end
45
+
46
+ def tls_get(path)
47
+ # Timeout-wrapped so a server-side handshake/read hang fails the
48
+ # test fast instead of wedging forever.
49
+ Timeout.timeout(15) do
50
+ sock = TCPSocket.new("127.0.0.1", @port)
51
+ ctx = OpenSSL::SSL::SSLContext.new
52
+ ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE # self-signed
53
+ ssl = OpenSSL::SSL::SSLSocket.new(sock, ctx)
54
+ ssl.connect
55
+ ssl.write("GET #{path} HTTP/1.0\r\nHost: localhost\r\nConnection: close\r\n\r\n")
56
+ out = ssl.read.to_s
57
+ ssl.close rescue nil
58
+ sock.close rescue nil
59
+ out
60
+ end
61
+ end
62
+
63
+ def test_serves_a_request_over_tls
64
+ resp = tls_get("/hello")
65
+ assert_match(/200/, resp)
66
+ assert_match(/tls-ok/, resp)
67
+ end
68
+
69
+ def test_presents_the_configured_certificate
70
+ cn = Timeout.timeout(15) do
71
+ sock = TCPSocket.new("127.0.0.1", @port)
72
+ ctx = OpenSSL::SSL::SSLContext.new
73
+ ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE
74
+ ssl = OpenSSL::SSL::SSLSocket.new(sock, ctx)
75
+ ssl.connect
76
+ subj = ssl.peer_cert.subject.to_s
77
+ ssl.close rescue nil
78
+ sock.close rescue nil
79
+ subj
80
+ end
81
+ assert_match(/CN=localhost/, cn)
82
+ end
83
+
84
+ def test_plaintext_request_to_tls_port_is_dropped
85
+ # A plain-HTTP request to the TLS port: SSL_accept fails on the
86
+ # non-TLS bytes, the server drops the connection -> no HTTP reply.
87
+ sock = TCPSocket.new("127.0.0.1", @port)
88
+ sock.write("GET /hello HTTP/1.0\r\nConnection: close\r\n\r\n")
89
+ data = ""
90
+ begin
91
+ data = sock.read_nonblock(64)
92
+ rescue IO::WaitReadable
93
+ IO.select([sock], nil, nil, 1.0)
94
+ data = (sock.read_nonblock(64) rescue "")
95
+ rescue
96
+ data = ""
97
+ end
98
+ sock.close rescue nil
99
+ refute_match(/HTTP\//, data.to_s) # no plaintext HTTP response
100
+ end
101
+ end
@@ -0,0 +1,101 @@
1
+ require_relative "helper"
2
+ require "openssl"
3
+ require "socket"
4
+ require "timeout"
5
+
6
+ # Scheduled-server inbound TLS (#148 phase 2, scheduled variant):
7
+ # Tep::Server::Scheduled terminates HTTPS with a NON-BLOCKING SSL_accept
8
+ # (sphttp_tls_accept_start + handshake_step parked on the scheduler).
9
+ # Mirrors test_inbound_tls.rb but with `set :scheduler, :scheduled`, so
10
+ # it exercises the cooperative handshake + want-aware recv path.
11
+ class TestInboundTlsScheduled < TepTest
12
+ CERT = "/tmp/tep_tls_sched_test_#{Process.pid}.crt"
13
+ KEY = "/tmp/tep_tls_sched_test_#{Process.pid}.key"
14
+
15
+ app_source <<~RB
16
+ require 'sinatra'
17
+ set :scheduler, :scheduled
18
+ Tep.tls_cert = "#{CERT}"
19
+ Tep.tls_key = "#{KEY}"
20
+
21
+ get '/hello' do
22
+ "tls-sched-ok"
23
+ end
24
+
25
+ post '/echo' do
26
+ "echo:" + request.body.read
27
+ end
28
+ RB
29
+
30
+ def self.gen_cert
31
+ return if File.exist?(CERT) && File.exist?(KEY)
32
+ key = OpenSSL::PKey::RSA.new(2048)
33
+ cert = OpenSSL::X509::Certificate.new
34
+ cert.version = 2
35
+ cert.serial = 1
36
+ cert.subject = OpenSSL::X509::Name.parse("/CN=localhost")
37
+ cert.issuer = cert.subject
38
+ cert.public_key = key.public_key
39
+ cert.not_before = Time.now - 60
40
+ cert.not_after = Time.now + 3600
41
+ cert.sign(key, OpenSSL::Digest::SHA256.new)
42
+ File.write(CERT, cert.to_pem)
43
+ File.write(KEY, key.to_pem)
44
+ end
45
+
46
+ def setup
47
+ self.class.gen_cert # must exist before the spawned binary boots
48
+ super
49
+ end
50
+
51
+ def tls_socket
52
+ sock = TCPSocket.new("127.0.0.1", @port)
53
+ ctx = OpenSSL::SSL::SSLContext.new
54
+ ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE # self-signed
55
+ ssl = OpenSSL::SSL::SSLSocket.new(sock, ctx)
56
+ ssl.connect
57
+ [ssl, sock]
58
+ end
59
+
60
+ def tls_get(path)
61
+ Timeout.timeout(15) do
62
+ ssl, sock = tls_socket
63
+ ssl.write("GET #{path} HTTP/1.0\r\nHost: localhost\r\nConnection: close\r\n\r\n")
64
+ out = ssl.read.to_s
65
+ ssl.close rescue nil
66
+ sock.close rescue nil
67
+ out
68
+ end
69
+ end
70
+
71
+ def test_serves_a_request_over_tls_under_scheduler
72
+ resp = tls_get("/hello")
73
+ assert_match(/200/, resp)
74
+ assert_match(/tls-sched-ok/, resp)
75
+ end
76
+
77
+ def test_post_body_over_tls_under_scheduler
78
+ # Drives the want-aware body drain (consume_body_via_scheduler) over
79
+ # TLS -- a partial SSL record must not truncate the body.
80
+ resp = Timeout.timeout(15) do
81
+ ssl, sock = tls_socket
82
+ body = "hello-tls-body"
83
+ ssl.write("POST /echo HTTP/1.0\r\nHost: localhost\r\n" \
84
+ "Content-Type: text/plain\r\nContent-Length: #{body.bytesize}\r\n" \
85
+ "Connection: close\r\n\r\n#{body}")
86
+ out = ssl.read.to_s
87
+ ssl.close rescue nil
88
+ sock.close rescue nil
89
+ out
90
+ end
91
+ assert_match(/200/, resp)
92
+ assert_match(/echo:hello-tls-body/, resp)
93
+ end
94
+
95
+ def test_two_sequential_tls_connections
96
+ # A second connection must hand-shake cleanly after the first closed
97
+ # (server CTX reused across connections / fibers).
98
+ assert_match(/tls-sched-ok/, tls_get("/hello"))
99
+ assert_match(/tls-sched-ok/, tls_get("/hello"))
100
+ end
101
+ end
data/test/test_job.rb ADDED
@@ -0,0 +1,108 @@
1
+ require_relative "helper"
2
+
3
+ # Tep::Job -- SQLite-backed sidekiq-shaped queue. The app declares
4
+ # job classes, enqueues from one handler, then drains via fetch_next
5
+ # from another. Dispatch is user-side (spinel can't carry cls_id
6
+ # through PtrArray<Tep::Job>), so the worker handler has an explicit
7
+ # `if name == "..."` ladder.
8
+ class TestJob < TepTest
9
+ app_source <<~RB
10
+ require 'sinatra'
11
+
12
+ DB_PATH = "/tmp/tep_job_test.db"
13
+
14
+ on_start do
15
+ Tep::Shell.run("rm -f " + DB_PATH)
16
+ Tep::Job.init_schema(DB_PATH)
17
+ end
18
+
19
+ class UpcaseJob < Tep::Job
20
+ def perform(arg)
21
+ arg.upcase
22
+ end
23
+ end
24
+
25
+ class ReverseJob < Tep::Job
26
+ def perform(arg)
27
+ out = ""
28
+ i = arg.length - 1
29
+ while i >= 0
30
+ out = out + arg[i]
31
+ i -= 1
32
+ end
33
+ out
34
+ end
35
+ end
36
+
37
+ get '/enqueue/:name/:arg' do
38
+ id = Tep::Job.enqueue(params[:name], params[:arg], DB_PATH)
39
+ "id=" + id.to_s
40
+ end
41
+
42
+ get '/process' do
43
+ claim = Tep::Job.fetch_next(DB_PATH)
44
+ if claim.length == 0
45
+ "ran=0"
46
+ else
47
+ parts = claim.split("|", 3)
48
+ row_id = parts[0].to_i
49
+ name = parts[1]
50
+ arg = parts[2]
51
+ result = ""
52
+ if name == "UpcaseJob"
53
+ result = UpcaseJob.new.perform(arg)
54
+ elsif name == "ReverseJob"
55
+ result = ReverseJob.new.perform(arg)
56
+ end
57
+ Tep::Job.mark_done(DB_PATH, row_id, result)
58
+ "ran=1"
59
+ end
60
+ end
61
+
62
+ get '/result/:id' do
63
+ db = Tep::SQLite.new
64
+ db.open(DB_PATH)
65
+ st = db.first_str("SELECT status FROM tep_jobs WHERE id = ?", params[:id])
66
+ body = db.first_str("SELECT result FROM tep_jobs WHERE id = ?", params[:id])
67
+ db.close
68
+ st + "/" + body
69
+ end
70
+ RB
71
+
72
+ def test_upcase_job_round_trip
73
+ enq = get("/enqueue/UpcaseJob/hello")
74
+ assert_match(/id=\d+/, enq.body)
75
+ id = enq.body.split("=")[1]
76
+
77
+ pr = get("/process")
78
+ assert_equal "ran=1", pr.body
79
+
80
+ rr = get("/result/#{id}")
81
+ assert_equal "done/HELLO", rr.body
82
+ end
83
+
84
+ def test_reverse_job_round_trip
85
+ enq = get("/enqueue/ReverseJob/tepworks")
86
+ id = enq.body.split("=")[1]
87
+ get("/process")
88
+ rr = get("/result/#{id}")
89
+ assert_equal "done/skrowpet", rr.body
90
+ end
91
+
92
+ def test_process_returns_zero_on_empty_queue
93
+ 20.times { get("/process") }
94
+ res = get("/process")
95
+ assert_equal "ran=0", res.body
96
+ end
97
+
98
+ def test_fifo_order
99
+ a = get("/enqueue/UpcaseJob/aaa").body.split("=")[1]
100
+ b = get("/enqueue/UpcaseJob/bbb").body.split("=")[1]
101
+ get("/process")
102
+ get("/process")
103
+ ra = get("/result/#{a}").body
104
+ rb = get("/result/#{b}").body
105
+ assert_equal "done/AAA", ra
106
+ assert_equal "done/BBB", rb
107
+ end
108
+ end
data/test/test_json.rb ADDED
@@ -0,0 +1,168 @@
1
+ require_relative "helper"
2
+
3
+ # Tep::Json -- pure-Ruby JSON encode primitives + flat-key decode.
4
+ class TestJson < TepTest
5
+ app_source <<~RB
6
+ require 'sinatra'
7
+
8
+ # ---- encode side ----
9
+ get '/escape' do
10
+ res.headers["Content-Type"] = "application/json"
11
+ Tep::Json.quote("a\\"b\\nc")
12
+ end
13
+
14
+ get '/object' do
15
+ res.headers["Content-Type"] = "application/json"
16
+ "{" + Tep::Json.encode_pair_str("name", "alice") + "," +
17
+ Tep::Json.encode_pair_int("age", 30) + "}"
18
+ end
19
+
20
+ get '/array' do
21
+ res.headers["Content-Type"] = "application/json"
22
+ Tep::Json.from_str_array(["a", "b", "c"])
23
+ end
24
+
25
+ get '/int_array' do
26
+ res.headers["Content-Type"] = "application/json"
27
+ Tep::Json.from_int_array([1, 2, 3])
28
+ end
29
+
30
+ get '/echo_html' do
31
+ res.headers["Content-Type"] = "application/json"
32
+ "{" + Tep::Json.encode_pair_str("payload", "<script>alert(1)</script>") + "}"
33
+ end
34
+
35
+ # ---- decode side ----
36
+ post '/parse_str' do
37
+ res.headers["Content-Type"] = "text/plain"
38
+ Tep::Json.get_str(req.raw_body, "name")
39
+ end
40
+
41
+ post '/parse_int' do
42
+ res.headers["Content-Type"] = "text/plain"
43
+ Tep::Json.get_int(req.raw_body, "n").to_s
44
+ end
45
+
46
+ post '/has_key' do
47
+ res.headers["Content-Type"] = "text/plain"
48
+ Tep::Json.has_key?(req.raw_body, "x") ? "yes" : "no"
49
+ end
50
+
51
+ post '/skip_nested' do
52
+ # Read a top-level key past a nested object (skip_value should
53
+ # walk the nested object correctly).
54
+ res.headers["Content-Type"] = "text/plain"
55
+ Tep::Json.get_str(req.raw_body, "after")
56
+ end
57
+
58
+ post '/parse_float' do
59
+ res.headers["Content-Type"] = "text/plain"
60
+ Tep::Json.get_float(req.raw_body, "x").to_s
61
+ end
62
+ RB
63
+
64
+ def test_quote_escapes
65
+ res = get("/escape")
66
+ # The route quoted the string `a"b\nc`; the escape should turn
67
+ # the quote and newline into \" and \n. The HTTP body is JSON,
68
+ # so the client sees: "a\"b\nc"
69
+ assert_match(/"a\\"b\\nc"/, res.body)
70
+ end
71
+
72
+ def test_encode_pair_str_and_int
73
+ res = get("/object")
74
+ assert_equal '{"name":"alice","age":30}', res.body.strip
75
+ end
76
+
77
+ def test_from_str_array
78
+ res = get("/array")
79
+ assert_equal '["a","b","c"]', res.body.strip
80
+ end
81
+
82
+ def test_from_int_array
83
+ res = get("/int_array")
84
+ assert_equal "[1,2,3]", res.body.strip
85
+ end
86
+
87
+ def test_html_chars_are_escaped_in_strings
88
+ # JSON escape includes backslash + quote; tag chars (< > /) pass
89
+ # through as-is (legal JSON, the client does its own HTML escape
90
+ # if it embeds the value).
91
+ res = get("/echo_html")
92
+ assert_match(/"payload":"<script>alert\(1\)<\\\/script>"|"payload":"<script>alert\(1\)<\/script>"/, res.body)
93
+ end
94
+
95
+ def test_get_str
96
+ res = post("/parse_str", '{"name":"alice","age":30}')
97
+ assert_equal "alice", res.body.strip
98
+ end
99
+
100
+ def test_get_str_missing_returns_empty
101
+ res = post("/parse_str", '{"other":"value"}')
102
+ assert_equal "", res.body.strip
103
+ end
104
+
105
+ def test_get_int
106
+ res = post("/parse_int", '{"n":42}')
107
+ assert_equal "42", res.body.strip
108
+ end
109
+
110
+ def test_get_int_negative
111
+ res = post("/parse_int", '{"n":-7}')
112
+ assert_equal "-7", res.body.strip
113
+ end
114
+
115
+ def test_has_key
116
+ res = post("/has_key", '{"x":1}')
117
+ assert_equal "yes", res.body.strip
118
+ res = post("/has_key", '{"y":1}')
119
+ assert_equal "no", res.body.strip
120
+ end
121
+
122
+ def test_skips_nested_objects
123
+ body = '{"first":{"a":1,"b":{"c":2}},"after":"target"}'
124
+ res = post("/skip_nested", body)
125
+ assert_equal "target", res.body.strip
126
+ end
127
+
128
+ def test_skips_strings_with_braces
129
+ # The skip-string walker should ignore { / } inside string values.
130
+ body = '{"first":"has{}braces","after":"target"}'
131
+ res = post("/skip_nested", body)
132
+ assert_equal "target", res.body.strip
133
+ end
134
+
135
+ def test_handles_escaped_quote_in_string
136
+ # \" inside a value-string must not terminate the string and
137
+ # corrupt the walk.
138
+ body = '{"first":"has \\"quote\\" inside","after":"target"}'
139
+ res = post("/skip_nested", body)
140
+ assert_equal "target", res.body.strip
141
+ end
142
+
143
+ def test_get_float_decimal
144
+ res = post("/parse_float", '{"x":3.14}')
145
+ assert_equal "3.14", res.body.strip
146
+ end
147
+
148
+ def test_get_float_negative
149
+ res = post("/parse_float", '{"x":-0.5}')
150
+ assert_equal "-0.5", res.body.strip
151
+ end
152
+
153
+ def test_get_float_integer_literal
154
+ # JSON integer 42 read as float -> 42.0
155
+ res = post("/parse_float", '{"x":42}')
156
+ assert_equal "42.0", res.body.strip
157
+ end
158
+
159
+ def test_get_float_exponent
160
+ res = post("/parse_float", '{"x":1.5e2}')
161
+ assert_equal "150.0", res.body.strip
162
+ end
163
+
164
+ def test_get_float_missing_key_returns_zero
165
+ res = post("/parse_float", '{"other":42}')
166
+ assert_equal "0.0", res.body.strip
167
+ end
168
+ end