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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/Makefile +134 -0
- data/README.md +247 -0
- data/SINATRA_COMPAT.md +376 -0
- data/bin/tep +2156 -0
- data/examples/agentic_chat/README.md +103 -0
- data/examples/agentic_chat/app.rb +310 -0
- data/examples/api_gateway/README.md +49 -0
- data/examples/api_gateway/app.rb +66 -0
- data/examples/blog/app.rb +367 -0
- data/examples/blog/views/index.erb +36 -0
- data/examples/blog/views/login.erb +28 -0
- data/examples/blog/views/new_post.erb +25 -0
- data/examples/blog/views/show.erb +16 -0
- data/examples/chat/app.rb +278 -0
- data/examples/chat/assets/logo.svg +13 -0
- data/examples/chat/assets/style.css +209 -0
- data/examples/chat/views/index.erb +142 -0
- data/examples/chatbot/README.md +111 -0
- data/examples/chatbot/app.rb +1024 -0
- data/examples/chatbot/assets/chat.js +249 -0
- data/examples/chatbot/assets/compare.js +93 -0
- data/examples/chatbot/assets/markdown.js +84 -0
- data/examples/chatbot/assets/style.css +215 -0
- data/examples/chatbot/schema.sql +25 -0
- data/examples/chatbot/views/compare.erb +43 -0
- data/examples/chatbot/views/index.erb +42 -0
- data/examples/chatbot/views/login.erb +22 -0
- data/examples/chatbot/views/setup.erb +23 -0
- data/examples/counter/README.md +68 -0
- data/examples/counter/app.rb +85 -0
- data/examples/experiments/AGENTS.md +91 -0
- data/examples/experiments/README.md +99 -0
- data/examples/experiments/app.rb +225 -0
- data/examples/geohash/Gemfile +11 -0
- data/examples/geohash/Gemfile.lock +17 -0
- data/examples/geohash/README.md +58 -0
- data/examples/geohash/app.rb +33 -0
- data/examples/hello.rb +120 -0
- data/examples/llm_gateway/README.md +73 -0
- data/examples/llm_gateway/app.rb +91 -0
- data/examples/maidenhead/Gemfile +7 -0
- data/examples/maidenhead/Gemfile.lock +17 -0
- data/examples/maidenhead/README.md +47 -0
- data/examples/maidenhead/app.rb +46 -0
- data/examples/pg_hello.rb +76 -0
- data/examples/qdrant/Gemfile +11 -0
- data/examples/qdrant/Gemfile.lock +29 -0
- data/examples/qdrant/README.md +54 -0
- data/examples/sinatra_style.rb +32 -0
- data/examples/websocket_echo.rb +37 -0
- data/lib/tep/agent_delegation.rb +35 -0
- data/lib/tep/app.rb +291 -0
- data/lib/tep/assets.rb +52 -0
- data/lib/tep/auth.rb +78 -0
- data/lib/tep/auth_bearer_token.rb +126 -0
- data/lib/tep/auth_oauth2.rb +189 -0
- data/lib/tep/auth_oauth2_client.rb +29 -0
- data/lib/tep/auth_oauth2_code.rb +40 -0
- data/lib/tep/auth_session_cookie.rb +132 -0
- data/lib/tep/broadcast.rb +265 -0
- data/lib/tep/broadcast_subscription.rb +42 -0
- data/lib/tep/cache.rb +49 -0
- data/lib/tep/events.rb +257 -0
- data/lib/tep/filter.rb +21 -0
- data/lib/tep/handler.rb +35 -0
- data/lib/tep/http.rb +599 -0
- data/lib/tep/identity.rb +67 -0
- data/lib/tep/job.rb +186 -0
- data/lib/tep/json.rb +572 -0
- data/lib/tep/jwt.rb +126 -0
- data/lib/tep/live_view.rb +219 -0
- data/lib/tep/llm.rb +505 -0
- data/lib/tep/logger.rb +85 -0
- data/lib/tep/mcp.rb +203 -0
- data/lib/tep/multipart.rb +98 -0
- data/lib/tep/net.rb +155 -0
- data/lib/tep/openai_server.rb +725 -0
- data/lib/tep/parallel.rb +168 -0
- data/lib/tep/parser.rb +81 -0
- data/lib/tep/password.rb +102 -0
- data/lib/tep/pg.rb +1128 -0
- data/lib/tep/presence.rb +589 -0
- data/lib/tep/presence_entry.rb +52 -0
- data/lib/tep/proxy.rb +801 -0
- data/lib/tep/request.rb +194 -0
- data/lib/tep/response.rb +134 -0
- data/lib/tep/router.rb +137 -0
- data/lib/tep/scheduler.rb +342 -0
- data/lib/tep/security.rb +140 -0
- data/lib/tep/server.rb +276 -0
- data/lib/tep/server_scheduled.rb +375 -0
- data/lib/tep/session.rb +98 -0
- data/lib/tep/shell.rb +62 -0
- data/lib/tep/sphttp.c +858 -0
- data/lib/tep/sqlite.rb +215 -0
- data/lib/tep/streamer.rb +31 -0
- data/lib/tep/tep_pg.c +769 -0
- data/lib/tep/tep_sqlite.c +320 -0
- data/lib/tep/url.rb +161 -0
- data/lib/tep/version.rb +3 -0
- data/lib/tep/websocket/connection.rb +171 -0
- data/lib/tep/websocket/driver.rb +169 -0
- data/lib/tep/websocket/frame.rb +238 -0
- data/lib/tep/websocket/handshake.rb +159 -0
- data/lib/tep/websocket.rb +68 -0
- data/lib/tep.rb +981 -0
- data/public/hello.txt +1 -0
- data/public/style.css +4 -0
- data/spinel-ext.json +33 -0
- data/test/helper.rb +248 -0
- data/test/real_world/01_simple.rb +5 -0
- data/test/real_world/02_lifecycle.rb +20 -0
- data/test/real_world/03_chat.rb +75 -0
- data/test/real_world/04_health_api.rb +25 -0
- data/test/real_world/05_todo_api.rb +57 -0
- data/test/real_world/06_basic_auth.rb +25 -0
- data/test/real_world/07_bbc_rest_api.rb +228 -0
- data/test/real_world/07_sklise_things.rb +109 -0
- data/test/real_world/08_jwd83_helloworld.rb +56 -0
- data/test/run_all.rb +7 -0
- data/test/run_parallel.rb +89 -0
- data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
- data/test/test_api_gateway.rb +76 -0
- data/test/test_auth.rb +223 -0
- data/test/test_auth_oauth2.rb +208 -0
- data/test/test_auth_session_cookie.rb +198 -0
- data/test/test_broadcast.rb +197 -0
- data/test/test_broadcast_pg.rb +135 -0
- data/test/test_cache.rb +98 -0
- data/test/test_cache_static.rb +48 -0
- data/test/test_cookies.rb +52 -0
- data/test/test_erb.rb +53 -0
- data/test/test_erb_ivars.rb +58 -0
- data/test/test_events.rb +114 -0
- data/test/test_filters.rb +41 -0
- data/test/test_geohash_example.rb +89 -0
- data/test/test_http.rb +137 -0
- data/test/test_http_pool.rb +122 -0
- data/test/test_http_pool_send.rb +57 -0
- data/test/test_identity.rb +165 -0
- data/test/test_inbound_tls.rb +101 -0
- data/test/test_inbound_tls_scheduled.rb +101 -0
- data/test/test_job.rb +108 -0
- data/test/test_json.rb +168 -0
- data/test/test_jwt.rb +143 -0
- data/test/test_live_view.rb +324 -0
- data/test/test_llm.rb +250 -0
- data/test/test_llm_gateway.rb +95 -0
- data/test/test_logger.rb +101 -0
- data/test/test_maidenhead_example.rb +86 -0
- data/test/test_mcp.rb +264 -0
- data/test/test_misc_v02.rb +54 -0
- data/test/test_modular.rb +43 -0
- data/test/test_multi_filters.rb +40 -0
- data/test/test_mustache.rb +57 -0
- data/test/test_openai_server.rb +598 -0
- data/test/test_optional_segments.rb +45 -0
- data/test/test_parallel.rb +102 -0
- data/test/test_params.rb +99 -0
- data/test/test_pass.rb +42 -0
- data/test/test_password.rb +101 -0
- data/test/test_pg.rb +673 -0
- data/test/test_presence.rb +374 -0
- data/test/test_presence_pg.rb +309 -0
- data/test/test_proxy.rb +556 -0
- data/test/test_proxy_dsl.rb +119 -0
- data/test/test_proxy_streaming.rb +146 -0
- data/test/test_real_world.rb +397 -0
- data/test/test_regex_routes.rb +52 -0
- data/test/test_request_methods.rb +102 -0
- data/test/test_response.rb +123 -0
- data/test/test_routing.rb +109 -0
- data/test/test_scheduler.rb +153 -0
- data/test/test_security.rb +72 -0
- data/test/test_server_scheduled.rb +56 -0
- data/test/test_sessions.rb +59 -0
- data/test/test_shell.rb +54 -0
- data/test/test_sqlite.rb +148 -0
- data/test/test_sqlite_cached.rb +171 -0
- data/test/test_static.rb +57 -0
- data/test/test_streaming.rb +96 -0
- data/test/test_unsupported.rb +32 -0
- data/test/test_websocket.rb +152 -0
- data/test/test_websocket_echo.rb +138 -0
- data/test/views/greet.erb +5 -0
- data/test/views/hello.erb +5 -0
- data/test/views/list.erb +5 -0
- data/test/views/m_ivars.mustache +3 -0
- data/test/views/m_simple.mustache +4 -0
- data/test/views/mixed.erb +3 -0
- 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
|