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,146 @@
|
|
|
1
|
+
require_relative "helper"
|
|
2
|
+
|
|
3
|
+
# Tep::Proxy streaming (chunk 6.2, #81). Self-calling scheduled-server
|
|
4
|
+
# shape like test_http.rb / test_proxy.rb: the app boots an SSE
|
|
5
|
+
# upstream producer (/sse_upstream) and proxy routes that forward to
|
|
6
|
+
# 127.0.0.1:<own-port> with stream_request? => true, exercising
|
|
7
|
+
# on_stream_chunk per event + on_stream_end once at the end.
|
|
8
|
+
#
|
|
9
|
+
# Requires the scheduled server (proxy streaming parks on io_wait,
|
|
10
|
+
# same constraint as WebSocket). workers=1 so the upstream-producer
|
|
11
|
+
# fiber and the proxy-consumer fiber cooperate on one worker.
|
|
12
|
+
class TestProxyStreaming < TepTest
|
|
13
|
+
app_source <<~RB
|
|
14
|
+
require 'sinatra'
|
|
15
|
+
|
|
16
|
+
set :scheduler, :scheduled
|
|
17
|
+
set :workers, 1
|
|
18
|
+
|
|
19
|
+
# ---- upstream: emits three SSE events then closes ----
|
|
20
|
+
class SseTicks < Tep::Streamer
|
|
21
|
+
def pump(out)
|
|
22
|
+
out.write("data: a\\n\\n")
|
|
23
|
+
out.write("data: b\\n\\n")
|
|
24
|
+
out.write("data: c\\n\\n")
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
get '/sse_upstream' do
|
|
29
|
+
res.headers["Content-Type"] = "text/event-stream"
|
|
30
|
+
stream SseTicks.new
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# ---- proxy subclasses (streaming hooks) ----
|
|
34
|
+
|
|
35
|
+
# Plain pass-through.
|
|
36
|
+
class PassProxy < Tep::Proxy
|
|
37
|
+
def rewrite_path(path)
|
|
38
|
+
"/sse_upstream"
|
|
39
|
+
end
|
|
40
|
+
def stream_request?(req)
|
|
41
|
+
true
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Finalizer: emit a closing event carrying the framework's
|
|
46
|
+
# chunk_count, proving on_stream_end fires once with live stats.
|
|
47
|
+
class FinalizeProxy < Tep::Proxy
|
|
48
|
+
def rewrite_path(path)
|
|
49
|
+
"/sse_upstream"
|
|
50
|
+
end
|
|
51
|
+
def stream_request?(req)
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
def on_stream_end(req, out, stats)
|
|
55
|
+
out.write("data: count=" + stats.chunk_count.to_s + "\\n\\n")
|
|
56
|
+
0
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Transform: prefix every event, proving on_stream_chunk can
|
|
61
|
+
# rewrite bytes on the way through.
|
|
62
|
+
class PrefixProxy < Tep::Proxy
|
|
63
|
+
def rewrite_path(path)
|
|
64
|
+
"/sse_upstream"
|
|
65
|
+
end
|
|
66
|
+
def stream_request?(req)
|
|
67
|
+
true
|
|
68
|
+
end
|
|
69
|
+
def on_stream_chunk(chunk, out, stats)
|
|
70
|
+
out.write("x-" + chunk.chunk_text)
|
|
71
|
+
0
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Drop: forward only events whose payload contains "b".
|
|
76
|
+
class DropProxy < Tep::Proxy
|
|
77
|
+
def rewrite_path(path)
|
|
78
|
+
"/sse_upstream"
|
|
79
|
+
end
|
|
80
|
+
def stream_request?(req)
|
|
81
|
+
true
|
|
82
|
+
end
|
|
83
|
+
def on_stream_chunk(chunk, out, stats)
|
|
84
|
+
if chunk.chunk_text.include?("b")
|
|
85
|
+
out.write(chunk.chunk_text)
|
|
86
|
+
end
|
|
87
|
+
0
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# ---- mount routes (build proxy with runtime port) ----
|
|
92
|
+
|
|
93
|
+
get '/p/pass/:port' do
|
|
94
|
+
PassProxy.new("http://127.0.0.1:" + params[:port]).handle(req, res)
|
|
95
|
+
res.body
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
get '/p/finalize/:port' do
|
|
99
|
+
FinalizeProxy.new("http://127.0.0.1:" + params[:port]).handle(req, res)
|
|
100
|
+
res.body
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
get '/p/prefix/:port' do
|
|
104
|
+
PrefixProxy.new("http://127.0.0.1:" + params[:port]).handle(req, res)
|
|
105
|
+
res.body
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
get '/p/drop/:port' do
|
|
109
|
+
DropProxy.new("http://127.0.0.1:" + params[:port]).handle(req, res)
|
|
110
|
+
res.body
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Control: the upstream itself, fetched directly.
|
|
114
|
+
get '/direct/:port' do
|
|
115
|
+
r = Tep::Http.get("http://127.0.0.1:" + params[:port] + "/sse_upstream")
|
|
116
|
+
"status=" + r.status.to_s
|
|
117
|
+
end
|
|
118
|
+
RB
|
|
119
|
+
|
|
120
|
+
def test_streams_all_events_through
|
|
121
|
+
res = get("/p/pass/#{@port}")
|
|
122
|
+
assert_equal "200", res.code
|
|
123
|
+
assert_equal "chunked", res["transfer-encoding"]
|
|
124
|
+
assert_equal "data: a\n\ndata: b\n\ndata: c\n\n", res.body
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def test_on_stream_end_fires_once_with_stats
|
|
128
|
+
res = get("/p/finalize/#{@port}")
|
|
129
|
+
assert_equal "200", res.code
|
|
130
|
+
# Three upstream events, then exactly one finalizer event carrying
|
|
131
|
+
# the chunk count.
|
|
132
|
+
assert_equal "data: a\n\ndata: b\n\ndata: c\n\ndata: count=3\n\n", res.body
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def test_on_stream_chunk_can_transform
|
|
136
|
+
res = get("/p/prefix/#{@port}")
|
|
137
|
+
assert_equal "200", res.code
|
|
138
|
+
assert_equal "x-data: a\n\nx-data: b\n\nx-data: c\n\n", res.body
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def test_on_stream_chunk_can_drop
|
|
142
|
+
res = get("/p/drop/#{@port}")
|
|
143
|
+
assert_equal "200", res.code
|
|
144
|
+
assert_equal "data: b\n\n", res.body
|
|
145
|
+
end
|
|
146
|
+
end
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
require_relative "helper"
|
|
2
|
+
require "shellwords"
|
|
3
|
+
|
|
4
|
+
# HTTP-level smoke tests for the real-world examples we claim to
|
|
5
|
+
# support. Each test compiles the example to a temporary binary,
|
|
6
|
+
# starts it on a fresh port, probes it with curl-equivalent
|
|
7
|
+
# requests, and kills it.
|
|
8
|
+
#
|
|
9
|
+
# Failure mode this catches: an example that *compiles* but
|
|
10
|
+
# doesn't actually serve correctly (the SINATRA_COMPAT.md matrix
|
|
11
|
+
# previously called out only build vs. serve via spot-checks; this
|
|
12
|
+
# is the automated version).
|
|
13
|
+
#
|
|
14
|
+
# These run alongside the curated tests but are stamped at the
|
|
15
|
+
# real_world/ source paths so they don't conflict with anything
|
|
16
|
+
# else.
|
|
17
|
+
class TestRealWorld < TepTest
|
|
18
|
+
# Override TepTest's class-level boot. Each test in here brings
|
|
19
|
+
# up its own example binary.
|
|
20
|
+
def self.boot!; end
|
|
21
|
+
def setup; end
|
|
22
|
+
def teardown; end
|
|
23
|
+
|
|
24
|
+
EXAMPLES_DIR = File.expand_path("real_world", __dir__)
|
|
25
|
+
PORT_BASE = 4900 + ($$ % 100)
|
|
26
|
+
|
|
27
|
+
@@port_counter = 0
|
|
28
|
+
def self.next_port
|
|
29
|
+
@@port_counter += 1
|
|
30
|
+
PORT_BASE + 100 + @@port_counter
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def with_app(example_filename)
|
|
34
|
+
src = File.join(EXAMPLES_DIR, example_filename)
|
|
35
|
+
bin = Dir.mktmpdir + "/app"
|
|
36
|
+
out = `#{Shellwords.escape(File.expand_path("../bin/tep", __dir__))} build #{Shellwords.escape(src)} -o #{Shellwords.escape(bin)} 2>&1`
|
|
37
|
+
raise "build failed:\n#{out}" unless $?.success?
|
|
38
|
+
port = TestRealWorld.next_port
|
|
39
|
+
pid = Process.spawn(bin, "-p", port.to_s, "-q",
|
|
40
|
+
pgroup: true, out: "/dev/null", err: "/dev/null")
|
|
41
|
+
wait_for_port(port)
|
|
42
|
+
@port = port
|
|
43
|
+
begin
|
|
44
|
+
yield port
|
|
45
|
+
ensure
|
|
46
|
+
TepHarness.reap(pid)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def wait_for_port(port, timeout: 3.0)
|
|
51
|
+
deadline = Time.now + timeout
|
|
52
|
+
while Time.now < deadline
|
|
53
|
+
begin
|
|
54
|
+
TCPSocket.new("127.0.0.1", port).close
|
|
55
|
+
return
|
|
56
|
+
rescue Errno::ECONNREFUSED
|
|
57
|
+
sleep 0.05
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
raise "server on :#{port} didn't come up"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# ---- 01: simple ----
|
|
64
|
+
|
|
65
|
+
def test_01_simple_root_returns_text
|
|
66
|
+
with_app("01_simple.rb") do
|
|
67
|
+
res = get("/")
|
|
68
|
+
assert_equal "200", res.code
|
|
69
|
+
assert_match(/this is a simple app/, res.body)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# ---- 02: lifecycle ----
|
|
74
|
+
|
|
75
|
+
def test_02_lifecycle_root_renders
|
|
76
|
+
with_app("02_lifecycle.rb") do
|
|
77
|
+
res = get("/")
|
|
78
|
+
assert_equal "200", res.code
|
|
79
|
+
assert_match(/lifecycle events/, res.body)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# ---- 04: health api ----
|
|
84
|
+
|
|
85
|
+
def test_04_health_endpoints
|
|
86
|
+
with_app("04_health_api.rb") do
|
|
87
|
+
assert_match(/"status":"ok"/, get("/healthz").body)
|
|
88
|
+
assert_match(/"version":"1\.4\.2"/, get("/version").body)
|
|
89
|
+
assert_match(/"endpoints"/, get("/").body)
|
|
90
|
+
# not_found block returns a JSON 404
|
|
91
|
+
res = get("/missing")
|
|
92
|
+
assert_equal "404", res.code
|
|
93
|
+
assert_match(/"error":"not found"/, res.body)
|
|
94
|
+
assert_match(/"path":"\/missing"/, res.body)
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# ---- 05: todo api ----
|
|
99
|
+
|
|
100
|
+
def test_05_todo_crud_round_trip
|
|
101
|
+
with_app("05_todo_api.rb") do
|
|
102
|
+
# Empty list at boot.
|
|
103
|
+
assert_equal "[]", get("/todos").body.strip
|
|
104
|
+
|
|
105
|
+
# Create two.
|
|
106
|
+
r1 = post("/todos", "text=buy-milk")
|
|
107
|
+
assert_match(/"id":1,"text":"buy-milk"/, r1.body)
|
|
108
|
+
r2 = post("/todos", "text=ship-tep")
|
|
109
|
+
assert_match(/"id":2,"text":"ship-tep"/, r2.body)
|
|
110
|
+
|
|
111
|
+
# List has both.
|
|
112
|
+
list = get("/todos").body
|
|
113
|
+
assert_match(/"id":1/, list)
|
|
114
|
+
assert_match(/"id":2/, list)
|
|
115
|
+
|
|
116
|
+
# Delete the first.
|
|
117
|
+
d = delete("/todos/1")
|
|
118
|
+
assert_match(/"deleted":1/, d.body)
|
|
119
|
+
|
|
120
|
+
# 404 on missing id.
|
|
121
|
+
d404 = delete("/todos/9999")
|
|
122
|
+
assert_equal "404", d404.code
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# ---- 06: basic auth ----
|
|
127
|
+
|
|
128
|
+
def test_06_basic_auth_blocks_admin_without_token
|
|
129
|
+
with_app("06_basic_auth.rb") do
|
|
130
|
+
assert_equal "200", get("/").code
|
|
131
|
+
|
|
132
|
+
r_no = get("/admin/dashboard")
|
|
133
|
+
assert_equal "401", r_no.code
|
|
134
|
+
|
|
135
|
+
r_bad = get("/admin/dashboard", {"x-token" => "wrong"})
|
|
136
|
+
assert_equal "401", r_bad.code
|
|
137
|
+
|
|
138
|
+
r_ok = get("/admin/dashboard", {"x-token" => "sekret-42"})
|
|
139
|
+
assert_equal "200", r_ok.code
|
|
140
|
+
assert_match(/admin: ok/, r_ok.body)
|
|
141
|
+
|
|
142
|
+
assert_equal "200", get("/admin/users", {"x-token" => "sekret-42"}).code
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# ---- showcase: examples/blog ----
|
|
147
|
+
# Exercises sqlite + json + jwt + password + sessions + erb-with-
|
|
148
|
+
# ivars + logger + security in one app. The blog ships an admin
|
|
149
|
+
# user (alice / hunter2) on first boot.
|
|
150
|
+
|
|
151
|
+
def with_blog
|
|
152
|
+
src = File.expand_path("../examples/blog/app.rb", __dir__)
|
|
153
|
+
bin = Dir.mktmpdir + "/blog"
|
|
154
|
+
db = File.join(File.dirname(bin), "blog.db")
|
|
155
|
+
File.unlink(db) if File.exist?(db)
|
|
156
|
+
out = `TEP_BLOG_DB=#{Shellwords.escape(db)} #{Shellwords.escape(File.expand_path("../bin/tep", __dir__))} build #{Shellwords.escape(src)} -o #{Shellwords.escape(bin)} 2>&1`
|
|
157
|
+
raise "blog build failed:\n#{out}" unless $?.success?
|
|
158
|
+
port = TestRealWorld.next_port
|
|
159
|
+
pid = Process.spawn({"TEP_BLOG_DB" => db}, bin, "-p", port.to_s, "-q",
|
|
160
|
+
pgroup: true, out: "/dev/null", err: "/dev/null")
|
|
161
|
+
wait_for_port(port)
|
|
162
|
+
@port = port
|
|
163
|
+
begin
|
|
164
|
+
yield port
|
|
165
|
+
ensure
|
|
166
|
+
TepHarness.reap(pid)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def test_blog_homepage_lists_posts
|
|
171
|
+
with_blog do
|
|
172
|
+
res = get("/")
|
|
173
|
+
assert_equal "200", res.code
|
|
174
|
+
assert_match(/tep blog/, res.body)
|
|
175
|
+
# First boot seeds an intro post so the homepage isn't empty.
|
|
176
|
+
assert_match(/Welcome to tep \+ spinel/, res.body)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def test_blog_homepage_renders_seed_post
|
|
181
|
+
with_blog do
|
|
182
|
+
res = get("/post/1")
|
|
183
|
+
assert_equal "200", res.code
|
|
184
|
+
assert_match(/<h1>Welcome to tep \+ spinel<\/h1>/, res.body)
|
|
185
|
+
assert_match(/by alice/, res.body)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def test_blog_api_token_and_post_round_trip
|
|
190
|
+
with_blog do
|
|
191
|
+
# Issue a JWT for the seeded admin.
|
|
192
|
+
tok_res = post("/api/token", '{"user":"alice","password":"hunter2"}')
|
|
193
|
+
assert_equal "200", tok_res.code
|
|
194
|
+
token = tok_res.body[/"token":"([^"]+)"/, 1]
|
|
195
|
+
refute_nil token, "token field present in /api/token response"
|
|
196
|
+
|
|
197
|
+
# Create a post via the JSON API. With the seed post present
|
|
198
|
+
# this is row 2; assertion is shape-only.
|
|
199
|
+
hdr = {"Authorization" => "Bearer #{token}"}
|
|
200
|
+
r_create = post("/api/posts", '{"title":"hello","body":"first post"}', hdr)
|
|
201
|
+
assert_equal "201", r_create.code
|
|
202
|
+
created_id = r_create.body[/"id":(\d+)/, 1].to_i
|
|
203
|
+
assert created_id >= 1
|
|
204
|
+
|
|
205
|
+
# Read it back via the public list.
|
|
206
|
+
r_list = get("/api/posts")
|
|
207
|
+
assert_match(/"title":"hello"/, r_list.body)
|
|
208
|
+
assert_match(/"author":"alice"/, r_list.body)
|
|
209
|
+
|
|
210
|
+
# Web view renders the post.
|
|
211
|
+
r_show = get("/post/#{created_id}")
|
|
212
|
+
assert_equal "200", r_show.code
|
|
213
|
+
assert_match(/<h1>hello<\/h1>/, r_show.body)
|
|
214
|
+
assert_match(/by alice/, r_show.body)
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def test_blog_api_token_rejects_bad_password
|
|
219
|
+
with_blog do
|
|
220
|
+
res = post("/api/token", '{"user":"alice","password":"wrong"}')
|
|
221
|
+
assert_equal "401", res.code
|
|
222
|
+
assert_match(/invalid credentials/, res.body)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def test_blog_api_posts_requires_jwt
|
|
227
|
+
with_blog do
|
|
228
|
+
# No Authorization header.
|
|
229
|
+
r1 = post("/api/posts", '{"title":"x","body":"y"}')
|
|
230
|
+
assert_equal "401", r1.code
|
|
231
|
+
|
|
232
|
+
# Tampered token.
|
|
233
|
+
r2 = post("/api/posts", '{"title":"x","body":"y"}',
|
|
234
|
+
{"Authorization" => "Bearer not.a.token"})
|
|
235
|
+
assert_equal "401", r2.code
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
def test_blog_web_login_protects_admin
|
|
240
|
+
with_blog do
|
|
241
|
+
# Without session: 401 on admin.
|
|
242
|
+
r_admin = get("/admin/new")
|
|
243
|
+
assert_equal "401", r_admin.code
|
|
244
|
+
|
|
245
|
+
# With a successful login + cookie jar...
|
|
246
|
+
uri = URI("http://127.0.0.1:#{@port}/login")
|
|
247
|
+
net = Net::HTTP.new(uri.host, uri.port)
|
|
248
|
+
r_login = net.post(uri.path, "user=alice&password=hunter2")
|
|
249
|
+
assert_equal "302", r_login.code
|
|
250
|
+
cookie = r_login["Set-Cookie"]
|
|
251
|
+
refute_nil cookie
|
|
252
|
+
|
|
253
|
+
r_admin2 = get("/admin/new", {"Cookie" => cookie.split(";").first})
|
|
254
|
+
assert_equal "200", r_admin2.code
|
|
255
|
+
assert_match(/posting as.*alice/, r_admin2.body)
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# ---- showcase: examples/chat ----
|
|
260
|
+
# Live chat with SSE streaming + presence. Each open SSE
|
|
261
|
+
# connection occupies one tep worker, so we boot with -w 4.
|
|
262
|
+
|
|
263
|
+
def with_chat
|
|
264
|
+
src = File.expand_path("../examples/chat/app.rb", __dir__)
|
|
265
|
+
bin = Dir.mktmpdir + "/chat"
|
|
266
|
+
db = File.join(File.dirname(bin), "chat.db")
|
|
267
|
+
File.unlink(db) if File.exist?(db)
|
|
268
|
+
out = `TEP_CHAT_DB=#{Shellwords.escape(db)} #{Shellwords.escape(File.expand_path("../bin/tep", __dir__))} build #{Shellwords.escape(src)} -o #{Shellwords.escape(bin)} 2>&1`
|
|
269
|
+
raise "chat build failed:\n#{out}" unless $?.success?
|
|
270
|
+
port = TestRealWorld.next_port
|
|
271
|
+
# Single worker, fresh process group so we can SIGTERM the
|
|
272
|
+
# whole tree on teardown. Prefork (-w >1) leaks orphan workers
|
|
273
|
+
# under macOS SO_REUSEPORT semantics that confuse subsequent
|
|
274
|
+
# boots in the same test run.
|
|
275
|
+
pid = Process.spawn({"TEP_CHAT_DB" => db}, bin, "-p", port.to_s, "-w", "1", "-q",
|
|
276
|
+
pgroup: true, out: "/dev/null", err: "/dev/null")
|
|
277
|
+
wait_for_port(port)
|
|
278
|
+
@port = port
|
|
279
|
+
begin
|
|
280
|
+
yield port
|
|
281
|
+
ensure
|
|
282
|
+
TepHarness.reap(pid)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def test_chat_homepage_renders
|
|
287
|
+
with_chat do
|
|
288
|
+
res = get("/")
|
|
289
|
+
assert_equal "200", res.code
|
|
290
|
+
assert_match(/tep chat/, res.body)
|
|
291
|
+
assert_match(/EventSource/, res.body)
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def test_chat_send_then_recent
|
|
296
|
+
with_chat do
|
|
297
|
+
r1 = post("/chat/send", "author=alice&body=hello")
|
|
298
|
+
assert_equal "200", r1.code
|
|
299
|
+
assert_match(/"id":1/, r1.body)
|
|
300
|
+
|
|
301
|
+
r2 = post("/chat/send", "author=bob&body=hi+alice")
|
|
302
|
+
assert_match(/"id":2/, r2.body)
|
|
303
|
+
|
|
304
|
+
list = get("/chat/recent")
|
|
305
|
+
assert_match(/"author":"alice","body":"hello"/, list.body)
|
|
306
|
+
assert_match(/"author":"bob","body":"hi alice"/, list.body)
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def test_chat_send_validates_required_fields
|
|
311
|
+
with_chat do
|
|
312
|
+
assert_equal "400", post("/chat/send", "body=missing-author").code
|
|
313
|
+
assert_equal "400", post("/chat/send", "author=alice").code
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def test_chat_who_reflects_heartbeats
|
|
318
|
+
with_chat do
|
|
319
|
+
# No-one before any heartbeat.
|
|
320
|
+
assert_equal "[]", get("/chat/who").body.strip
|
|
321
|
+
|
|
322
|
+
post("/chat/heartbeat", "user=alice")
|
|
323
|
+
post("/chat/heartbeat", "user=bob")
|
|
324
|
+
who = get("/chat/who").body
|
|
325
|
+
assert_match(/"user":"alice"/, who)
|
|
326
|
+
assert_match(/"user":"bob"/, who)
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def test_chat_recent_since_param
|
|
331
|
+
with_chat do
|
|
332
|
+
post("/chat/send", "author=a&body=one")
|
|
333
|
+
post("/chat/send", "author=b&body=two")
|
|
334
|
+
post("/chat/send", "author=c&body=three")
|
|
335
|
+
|
|
336
|
+
# since=1 should drop msg #1 and return #2 + #3.
|
|
337
|
+
list = get("/chat/recent?since=1").body
|
|
338
|
+
refute_match(/"body":"one"/, list)
|
|
339
|
+
assert_match(/"body":"two"/, list)
|
|
340
|
+
assert_match(/"body":"three"/, list)
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
def test_chat_serves_bundled_assets
|
|
345
|
+
with_chat do
|
|
346
|
+
css = get("/style.css")
|
|
347
|
+
assert_equal "200", css.code
|
|
348
|
+
assert_match(/text\/css/, css["content-type"])
|
|
349
|
+
assert_match(/--accent/, css.body) # spot-check our content
|
|
350
|
+
assert_match(/max-age=3600/, css["cache-control"])
|
|
351
|
+
|
|
352
|
+
svg = get("/logo.svg")
|
|
353
|
+
assert_equal "200", svg.code
|
|
354
|
+
assert_match(/image\/svg\+xml/, svg["content-type"])
|
|
355
|
+
assert_match(/<svg/, svg.body)
|
|
356
|
+
|
|
357
|
+
# An asset that isn't bundled falls through to 404 via the
|
|
358
|
+
# normal not-found path, not the asset layer.
|
|
359
|
+
assert_equal "404", get("/missing.css").code
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def test_chat_stream_emits_backlog_and_keepalive
|
|
364
|
+
with_chat do
|
|
365
|
+
# Seed messages BEFORE the stream opens.
|
|
366
|
+
post("/chat/send", "author=a&body=pre1")
|
|
367
|
+
post("/chat/send", "author=b&body=pre2")
|
|
368
|
+
|
|
369
|
+
# Pull bytes directly from the SSE socket so we don't have to
|
|
370
|
+
# wait STREAM_MAX (30s) for Net::HTTP to call it done.
|
|
371
|
+
sock = TCPSocket.new("127.0.0.1", @port)
|
|
372
|
+
sock.write("GET /chat/stream?since=0 HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")
|
|
373
|
+
events = String.new
|
|
374
|
+
deadline = Time.now + 4
|
|
375
|
+
while Time.now < deadline
|
|
376
|
+
IO.select([sock], nil, nil, 0.5) or next
|
|
377
|
+
chunk = sock.read_nonblock(4096) rescue nil
|
|
378
|
+
break if chunk.nil? || chunk.empty?
|
|
379
|
+
events << chunk
|
|
380
|
+
# Stop early once we've seen everything we expect.
|
|
381
|
+
break if events.include?("pre1") && events.include?("pre2") && events.include?(": tick")
|
|
382
|
+
end
|
|
383
|
+
sock.close
|
|
384
|
+
|
|
385
|
+
# Live "send while streaming" is a separate concurrency
|
|
386
|
+
# property that depends on prefork SO_REUSEPORT actually
|
|
387
|
+
# load-balancing -- which it doesn't reliably on macOS. The
|
|
388
|
+
# streaming pipeline itself (backlog + keepalive frames) is
|
|
389
|
+
# what we cover here.
|
|
390
|
+
assert_match(/"body":"pre1"/, events)
|
|
391
|
+
assert_match(/"body":"pre2"/, events)
|
|
392
|
+
assert_match(/: tick/, events)
|
|
393
|
+
assert_match(/Transfer-Encoding: chunked/i, events)
|
|
394
|
+
assert_match(/Content-Type: text\/event-stream/i, events)
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require_relative "helper"
|
|
2
|
+
|
|
3
|
+
class TestRegexRoutes < TepTest
|
|
4
|
+
app_source <<~'RB'
|
|
5
|
+
require 'sinatra'
|
|
6
|
+
|
|
7
|
+
get %r{^/posts/(\d+)$} do
|
|
8
|
+
"post id=" + params["1"]
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
get %r{^/users/([a-z]+)/posts/(\d+)$} do
|
|
12
|
+
"user=" + params["1"] + " post=" + params["2"]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
get '/literal/path' do
|
|
16
|
+
"literal wins"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Regex that overlaps with the literal -- literal must take
|
|
20
|
+
# precedence (we register it after, but match() tries all literal
|
|
21
|
+
# routes before any regex one).
|
|
22
|
+
get %r{^/literal/.+$} do
|
|
23
|
+
"regex fallback"
|
|
24
|
+
end
|
|
25
|
+
RB
|
|
26
|
+
|
|
27
|
+
def test_single_capture
|
|
28
|
+
res = get("/posts/42")
|
|
29
|
+
assert_equal "200", res.code
|
|
30
|
+
assert_equal "post id=42", res.body
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def test_two_captures
|
|
34
|
+
res = get("/users/alice/posts/7")
|
|
35
|
+
assert_equal "user=alice post=7", res.body
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def test_no_match_falls_through_to_404
|
|
39
|
+
res = get("/posts/abc")
|
|
40
|
+
assert_equal "404", res.code
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def test_literal_route_beats_regex
|
|
44
|
+
res = get("/literal/path")
|
|
45
|
+
assert_equal "literal wins", res.body
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def test_regex_falls_through_when_literal_misses
|
|
49
|
+
res = get("/literal/elsewhere")
|
|
50
|
+
assert_equal "regex fallback", res.body
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
require_relative "helper"
|
|
2
|
+
|
|
3
|
+
# Rack::Request-style convenience methods. tep doesn't terminate TLS,
|
|
4
|
+
# so .scheme / .ssl? read X-Forwarded-Proto (set by any reasonable
|
|
5
|
+
# reverse proxy).
|
|
6
|
+
class TestRequestMethods < TepTest
|
|
7
|
+
app_source <<~RB
|
|
8
|
+
require 'sinatra'
|
|
9
|
+
|
|
10
|
+
get '/host' do
|
|
11
|
+
request.host
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
get '/ua' do
|
|
15
|
+
request.user_agent
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
get '/ref' do
|
|
19
|
+
request.referer + " :: " + request.referrer
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
get '/scheme' do
|
|
23
|
+
request.scheme + " ssl?=" + request.ssl?.to_s
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
get '/accept-and-ct' do
|
|
27
|
+
"accept=" + request.accept + " ct=" + request.content_type
|
|
28
|
+
end
|
|
29
|
+
RB
|
|
30
|
+
|
|
31
|
+
def test_host
|
|
32
|
+
res = get("/host", "Host" => "example.com:8080")
|
|
33
|
+
assert_equal "example.com:8080", res.body
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def test_user_agent
|
|
37
|
+
res = get("/ua", "User-Agent" => "tep-test/1.0")
|
|
38
|
+
assert_equal "tep-test/1.0", res.body
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def test_referer_and_referrer
|
|
42
|
+
res = get("/ref", "Referer" => "https://prev.example/x")
|
|
43
|
+
assert_equal "https://prev.example/x :: https://prev.example/x", res.body
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def test_scheme_default_http
|
|
47
|
+
res = get("/scheme")
|
|
48
|
+
assert_equal "http ssl?=false", res.body
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def test_scheme_x_forwarded_proto
|
|
52
|
+
res = get("/scheme", "X-Forwarded-Proto" => "https")
|
|
53
|
+
assert_equal "https ssl?=true", res.body
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def test_accept_and_content_type
|
|
57
|
+
res = post("/accept-and-ct", "x=1",
|
|
58
|
+
"Accept" => "application/json",
|
|
59
|
+
"Content-Type" => "application/x-www-form-urlencoded")
|
|
60
|
+
assert_equal "404", res.code # POST not declared; just confirming the route table behaves
|
|
61
|
+
# Re-fetch the GET form for headers we actually want to inspect.
|
|
62
|
+
res2 = get("/accept-and-ct", "Accept" => "application/json")
|
|
63
|
+
assert_match(/accept=application\/json/, res2.body)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# `request.body.read` -- Sinatra apps commonly treat request.body as
|
|
68
|
+
# IO and call .read on it; tep's req.body is already a String, so the
|
|
69
|
+
# bin/tep translator rewrites `request.body.read` -> `req.body` so the
|
|
70
|
+
# Sinatra-style handler compiles + serves unchanged.
|
|
71
|
+
class TestRequestBodyRead < TepTest
|
|
72
|
+
app_source <<~RB
|
|
73
|
+
require 'sinatra'
|
|
74
|
+
|
|
75
|
+
post '/echo' do
|
|
76
|
+
content_type 'text/plain'
|
|
77
|
+
request.body.read
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
post '/echo-twice' do
|
|
81
|
+
# Hit .read twice -- a Sinatra IO would return "" on the second
|
|
82
|
+
# call (cursor at EOF); for tep's no-op .read it just returns
|
|
83
|
+
# the same String again. The expected behaviour here is "tep
|
|
84
|
+
# gives you the body each time"; apps that rely on the IO
|
|
85
|
+
# cursor semantics need to rewrite to a single .read + store.
|
|
86
|
+
content_type 'text/plain'
|
|
87
|
+
request.body.read + "|" + request.body.read
|
|
88
|
+
end
|
|
89
|
+
RB
|
|
90
|
+
|
|
91
|
+
def test_request_body_read_returns_raw_body
|
|
92
|
+
res = post("/echo", "hello world")
|
|
93
|
+
assert_equal "200", res.code
|
|
94
|
+
assert_equal "hello world", res.body
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def test_request_body_read_idempotent
|
|
98
|
+
res = post("/echo-twice", "abc")
|
|
99
|
+
assert_equal "200", res.code
|
|
100
|
+
assert_equal "abc|abc", res.body
|
|
101
|
+
end
|
|
102
|
+
end
|