tep 0.11.6 → 0.11.7
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 +4 -4
- data/README.md +1 -1
- data/bin/tep +49 -11
- data/examples/blog/app.rb +1 -0
- data/examples/experiments/app.rb +3 -1
- data/lib/tep/app.rb +1 -0
- data/lib/tep/auth_bearer_token.rb +1 -0
- data/lib/tep/auth_session_cookie.rb +6 -0
- data/lib/tep/cache.rb +3 -0
- data/lib/tep/http.rb +7 -5
- data/lib/tep/openai_server.rb +3 -0
- data/lib/tep/parser.rb +4 -1
- data/lib/tep/pg.rb +16 -0
- data/lib/tep/request.rb +16 -6
- data/lib/tep/server.rb +5 -1
- data/lib/tep/server_scheduled.rb +8 -5
- data/lib/tep/session.rb +9 -1
- data/lib/tep/version.rb +1 -1
- data/lib/tep/websocket/handshake.rb +7 -1
- data/lib/tep.rb +22 -0
- data/test/differential/10_semantics.rb +56 -0
- data/test/differential/11_matrix.rb +63 -0
- data/test/differential/Gemfile +14 -0
- data/test/differential/runner.rb +250 -0
- data/test/test_broadcast_pg.rb +6 -2
- data/test/test_live_view.rb +3 -1
- data/test/test_mcp.rb +3 -1
- data/test/test_pg.rb +21 -2
- data/test/test_real_world.rb +8 -3
- data/test/test_request_methods.rb +7 -1
- data/test/test_unsupported.rb +47 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f69c2e25a18f0ad96cc6a765e9655cc130c35ecc90fe006715409868c8e69e72
|
|
4
|
+
data.tar.gz: bfe36187e4d9565b03c58b37ac1e0243f96dbe3b888752983acc6d6f87595fc7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 65c8b19acd15c4548a98a3db081e5f0c9d5b73068b8e99744e9064a35f1a4cdc920aa0a447061d708cd889155c5034efc6303532abee38869b193b5a1d10241d
|
|
7
|
+
data.tar.gz: 6307c6fefde2da3b7a3e67480d1c59ab051527c62796d03971fe25e1596c0fbee7271ea9d9d531b458ec310b99758e9d81b075d1f4c139d9500c25afc677a71a
|
data/README.md
CHANGED
|
@@ -22,7 +22,7 @@ SQLite, opt-in PostgreSQL, WebSockets, Broadcast + Presence + LiveView,
|
|
|
22
22
|
an MCP tool catalog, TLS, a pooled HTTP client, and an OpenAI-compatible
|
|
23
23
|
LLM client + server.
|
|
24
24
|
|
|
25
|
-
> **Current release:** [v0.11.
|
|
25
|
+
> **Current release:** [v0.11.7](https://github.com/OriPekelman/tep/releases/tag/v0.11.7)
|
|
26
26
|
> on [RubyGems](https://rubygems.org/gems/tep) — `gem install tep`.
|
|
27
27
|
> Pre-alpha; API still in motion.
|
|
28
28
|
|
data/bin/tep
CHANGED
|
@@ -86,7 +86,14 @@ def fatal(msg)
|
|
|
86
86
|
end
|
|
87
87
|
|
|
88
88
|
# Build the translated Ruby source from a Sinatra-style file.
|
|
89
|
-
|
|
89
|
+
#
|
|
90
|
+
# Strict by default (mirror condition 3, docs/mirrors/sinatra.md):
|
|
91
|
+
# any construct the translator would drop -- Rack `use`, `helpers`,
|
|
92
|
+
# unknown `set` keys, on_stop, malformed DSL blocks, missing views --
|
|
93
|
+
# is a fatal error, because building anyway produces a binary that
|
|
94
|
+
# silently diverges from what the same source does under real
|
|
95
|
+
# sinatra. `--lax` (or TEP_LAX=1) restores warn-and-continue.
|
|
96
|
+
def translate(input_path, lax: ENV["TEP_LAX"] == "1")
|
|
90
97
|
raw_source = File.read(input_path)
|
|
91
98
|
|
|
92
99
|
# Split off `__END__` inline templates. Sinatra's convention is
|
|
@@ -909,7 +916,19 @@ def translate(input_path)
|
|
|
909
916
|
RB
|
|
910
917
|
out << ""
|
|
911
918
|
|
|
912
|
-
warnings.
|
|
919
|
+
if warnings.any?
|
|
920
|
+
if lax
|
|
921
|
+
warnings.each { |w| warn "tep: #{w}" }
|
|
922
|
+
warn "tep: --lax: built with #{warnings.length} dropped construct(s); " \
|
|
923
|
+
"behaviour diverges from real sinatra where they applied"
|
|
924
|
+
else
|
|
925
|
+
warnings.each { |w| warn "tep: error: #{w}" }
|
|
926
|
+
fatal "#{warnings.length} unsupported construct(s) -- refusing to build " \
|
|
927
|
+
"a binary that silently diverges (docs/mirrors/sinatra.md, " \
|
|
928
|
+
"condition 3). Re-run with --lax (or TEP_LAX=1) to drop them " \
|
|
929
|
+
"with a warning instead."
|
|
930
|
+
end
|
|
931
|
+
end
|
|
913
932
|
|
|
914
933
|
out.join("\n")
|
|
915
934
|
end
|
|
@@ -1596,8 +1615,12 @@ def rewrite_block(src, force_string: true)
|
|
|
1596
1615
|
# `headers["X"] = "y"` -> `res.headers[...]` when not already qualified
|
|
1597
1616
|
s = s.gsub(/(?<![\w.])headers\[/, "res.headers[")
|
|
1598
1617
|
|
|
1599
|
-
# `cookies["k"]` -> `
|
|
1600
|
-
|
|
1618
|
+
# `cookies["k"] = v` (write) -> direct hash write; `cookies["k"]`
|
|
1619
|
+
# (read) -> Tep.cookie(req, "k"), which nil-guards the miss: the
|
|
1620
|
+
# documented cookies[] contract is ""-on-miss (test_cookies pins
|
|
1621
|
+
# it), but the underlying hash reads nil for a missing key.
|
|
1622
|
+
s = s.gsub(/(?<![\w.])cookies\[([^\]]*)\]\s*=(?![=~])/, 'req.cookies[\1] =')
|
|
1623
|
+
s = s.gsub(/(?<![\w.])cookies\[([^\]]*)\]/, 'Tep.cookie(req, \1)')
|
|
1601
1624
|
|
|
1602
1625
|
# `erb :name` -> `tep_view_name(Tep.str_hash, req.ivars)`
|
|
1603
1626
|
# `erb :name, locals: {a: "b", c: "d"}` -> build a String=>String
|
|
@@ -1697,6 +1720,12 @@ def rewrite_block(src, force_string: true)
|
|
|
1697
1720
|
# to string keys.
|
|
1698
1721
|
s = s.gsub(/(req\.params)\[:(\w+)\]/, '\1["\2"]')
|
|
1699
1722
|
|
|
1723
|
+
# Read form -> Tep.param(req, k), which nil-guards the miss: tep's
|
|
1724
|
+
# documented params[] contract is ""-on-miss (test_params pins it;
|
|
1725
|
+
# ledgered as a sinatra divergence -- sinatra reads nil). Writes
|
|
1726
|
+
# (assignment) stay direct hash writes.
|
|
1727
|
+
s = s.gsub(/req\.params\[([^\]]*)\](?!\s*=[^=~])/, 'Tep.param(req, \1)')
|
|
1728
|
+
|
|
1700
1729
|
# `"#{params['x']}"` interpolation works fine after the params rewrite.
|
|
1701
1730
|
|
|
1702
1731
|
# String-coerce the last expression of the block body. Spinel's
|
|
@@ -2176,23 +2205,25 @@ def cmd_build(args)
|
|
|
2176
2205
|
input = nil
|
|
2177
2206
|
out_path = nil
|
|
2178
2207
|
c_only = false
|
|
2208
|
+
lax = ENV["TEP_LAX"] == "1"
|
|
2179
2209
|
i = 0
|
|
2180
2210
|
while i < args.length
|
|
2181
2211
|
a = args[i]
|
|
2182
2212
|
case a
|
|
2183
2213
|
when "-o" then out_path = args[i + 1]; i += 2
|
|
2184
2214
|
when "-c" then c_only = true; i += 1
|
|
2215
|
+
when "--lax" then lax = true; i += 1
|
|
2185
2216
|
when /^-/ then fatal "unknown option: #{a}"
|
|
2186
2217
|
else
|
|
2187
2218
|
input ||= a
|
|
2188
2219
|
i += 1
|
|
2189
2220
|
end
|
|
2190
2221
|
end
|
|
2191
|
-
fatal "usage: tep build app.rb [-o out] [-c]" unless input
|
|
2222
|
+
fatal "usage: tep build app.rb [-o out] [-c] [--lax]" unless input
|
|
2192
2223
|
|
|
2193
2224
|
base = File.basename(input, ".rb")
|
|
2194
2225
|
out_path ||= File.join(File.dirname(input), base)
|
|
2195
|
-
translated = translate(input)
|
|
2226
|
+
translated = translate(input, lax: lax)
|
|
2196
2227
|
|
|
2197
2228
|
# Spinel's require_relative resolves against the source file's
|
|
2198
2229
|
# directory and chokes on absolute paths. Drop the generated file
|
|
@@ -2230,8 +2261,10 @@ def cmd_run(args)
|
|
|
2230
2261
|
end
|
|
2231
2262
|
|
|
2232
2263
|
def cmd_translate(args)
|
|
2233
|
-
|
|
2234
|
-
|
|
2264
|
+
lax = ENV["TEP_LAX"] == "1"
|
|
2265
|
+
lax = true if args.delete("--lax")
|
|
2266
|
+
input = args.first or fatal "usage: tep translate app.rb [--lax]"
|
|
2267
|
+
puts translate(input, lax: lax)
|
|
2235
2268
|
end
|
|
2236
2269
|
|
|
2237
2270
|
case ARGV.shift
|
|
@@ -2243,9 +2276,14 @@ when nil, "-h", "--help"
|
|
|
2243
2276
|
tep -- Sinatra-flavoured framework that compiles to a native binary.
|
|
2244
2277
|
|
|
2245
2278
|
Usage:
|
|
2246
|
-
tep build app.rb [-o out] [-c]
|
|
2247
|
-
tep run app.rb [-p PORT] [-w N]
|
|
2248
|
-
tep translate app.rb
|
|
2279
|
+
tep build app.rb [-o out] [-c] [--lax] translate + spinel-compile
|
|
2280
|
+
tep run app.rb [-p PORT] [-w N] build then exec
|
|
2281
|
+
tep translate app.rb [--lax] print the translated Ruby
|
|
2282
|
+
|
|
2283
|
+
Strict by default: source constructs tep would have to drop
|
|
2284
|
+
(Rack `use`, `helpers do`, unknown `set` keys, ...) fail the
|
|
2285
|
+
build rather than silently diverging from real sinatra.
|
|
2286
|
+
`--lax` (or TEP_LAX=1) downgrades them to warnings.
|
|
2249
2287
|
|
|
2250
2288
|
Source compatibility: top-level `get '/path' do ... end`,
|
|
2251
2289
|
`post`, `put`, `patch`, `delete`, `before`, `after`, `not_found`,
|
data/examples/blog/app.rb
CHANGED
|
@@ -332,6 +332,7 @@ end
|
|
|
332
332
|
post '/api/posts' do
|
|
333
333
|
res.headers["Content-Type"] = "application/json"
|
|
334
334
|
auth = req.req_headers["authorization"]
|
|
335
|
+
auth = "" if auth.nil? # missing header reads as nil (tep#235)
|
|
335
336
|
bearer = ""
|
|
336
337
|
if auth.length > 7 && auth[0, 7] == "Bearer "
|
|
337
338
|
bearer = auth[7, auth.length - 7]
|
data/examples/experiments/app.rb
CHANGED
|
@@ -218,7 +218,9 @@ end
|
|
|
218
218
|
# baked in. Here, accept an X-Demo-Cap-Run header as the
|
|
219
219
|
# capability source so humans can drive the demo with curl.
|
|
220
220
|
before do
|
|
221
|
-
|
|
221
|
+
cap = req.req_headers["x-demo-cap-run"]
|
|
222
|
+
cap = "" if cap.nil? # missing header reads as nil (tep#235)
|
|
223
|
+
if cap.length > 0
|
|
222
224
|
req.identity = Tep::Identity.new(
|
|
223
225
|
"user:demo", nil, [:run_experiments])
|
|
224
226
|
end
|
data/lib/tep/app.rb
CHANGED
|
@@ -84,12 +84,16 @@ module Tep
|
|
|
84
84
|
# if the session has no identity (no prior #set call, or after
|
|
85
85
|
# #clear) or the stored identity is expired.
|
|
86
86
|
def self.try(req)
|
|
87
|
+
# Session#get on a missing key reads as nil (sinatra-parity Hash
|
|
88
|
+
# semantics; same class as tep#235) -- guard before String calls.
|
|
87
89
|
sub = req.session.get("identity_sub")
|
|
90
|
+
sub = "" if sub.nil?
|
|
88
91
|
if sub.length == 0
|
|
89
92
|
return nil
|
|
90
93
|
end
|
|
91
94
|
|
|
92
95
|
exp_str = req.session.get("identity_exp")
|
|
96
|
+
exp_str = "" if exp_str.nil?
|
|
93
97
|
if exp_str.length > 0
|
|
94
98
|
exp = exp_str.to_i
|
|
95
99
|
if exp > 0 && Time.now.to_i >= exp
|
|
@@ -98,9 +102,11 @@ module Tep
|
|
|
98
102
|
end
|
|
99
103
|
|
|
100
104
|
caps_str = req.session.get("identity_caps")
|
|
105
|
+
caps_str = "" if caps_str.nil?
|
|
101
106
|
caps = Tep::AuthBearerToken.parse_caps(caps_str)
|
|
102
107
|
|
|
103
108
|
delegate_str = req.session.get("identity_delegate")
|
|
109
|
+
delegate_str = "" if delegate_str.nil?
|
|
104
110
|
delegation = Tep::AuthBearerToken.parse_delegate(delegate_str)
|
|
105
111
|
|
|
106
112
|
Tep::Identity.new(sub, delegation, caps)
|
data/lib/tep/cache.rb
CHANGED
|
@@ -18,8 +18,10 @@ module Tep
|
|
|
18
18
|
# tag is matched as a substring so a comma-separated list of tags
|
|
19
19
|
# in If-None-Match works.
|
|
20
20
|
etag = res.headers["ETag"]
|
|
21
|
+
etag = "" if etag.nil?
|
|
21
22
|
if etag.length > 0
|
|
22
23
|
inm = req.headers["if-none-match"]
|
|
24
|
+
inm = "" if inm.nil?
|
|
23
25
|
if inm.length > 0
|
|
24
26
|
if inm == "*"
|
|
25
27
|
return true
|
|
@@ -35,6 +37,7 @@ module Tep
|
|
|
35
37
|
lm = res.lastmod_epoch
|
|
36
38
|
if lm > 0
|
|
37
39
|
ims = req.headers["if-modified-since"]
|
|
40
|
+
ims = "" if ims.nil?
|
|
38
41
|
if ims.length > 0
|
|
39
42
|
ims_epoch = Sock.sphttp_parse_http_date(ims)
|
|
40
43
|
if ims_epoch >= 0 && lm <= ims_epoch
|
data/lib/tep/http.rb
CHANGED
|
@@ -9,10 +9,12 @@
|
|
|
9
9
|
# plumbing (sphttp_connect, sphttp_set_nonblock, sphttp_recv_*); the
|
|
10
10
|
# missing piece is the HTTP/1.0 client on top.
|
|
11
11
|
#
|
|
12
|
-
# Scope
|
|
13
|
-
#
|
|
14
|
-
# * **
|
|
15
|
-
#
|
|
12
|
+
# Scope
|
|
13
|
+
# -----
|
|
14
|
+
# * **http + https** -- outbound TLS landed in #150
|
|
15
|
+
# (Sock.sphttp_connect_tls; non-blocking handshake under the
|
|
16
|
+
# scheduled server). Talk to internal services, the local Ollama
|
|
17
|
+
# API, vLLM, your own tep-backed sidecars, or external https APIs.
|
|
16
18
|
# * **HTTP/1.0 + Connection: close** -- one socket per request,
|
|
17
19
|
# read until EOF. No keep-alive, no pipelining.
|
|
18
20
|
# * **No chunked-transfer reading** -- assumes Content-Length or
|
|
@@ -24,7 +26,7 @@
|
|
|
24
26
|
#
|
|
25
27
|
# These limits cover the dashboard's needs (talking to local
|
|
26
28
|
# inference backends) and the bulk of "hit an internal API"
|
|
27
|
-
# workloads.
|
|
29
|
+
# workloads. Keep-alive + chunked reading land as a v2 surface.
|
|
28
30
|
#
|
|
29
31
|
# API shape
|
|
30
32
|
# ---------
|
data/lib/tep/openai_server.rb
CHANGED
|
@@ -141,7 +141,10 @@ module Tep
|
|
|
141
141
|
def self.serve!(events_jsonl = "")
|
|
142
142
|
events = Tep::Events.new(events_jsonl)
|
|
143
143
|
Tep::APP.set_openai_events(events)
|
|
144
|
+
# ENV[] reads nil for an unset variable (CRuby semantics) --
|
|
145
|
+
# guard before String calls (same class as tep#235).
|
|
144
146
|
host = ENV["HOSTNAME"]
|
|
147
|
+
host = "" if host.nil?
|
|
145
148
|
if host.length == 0
|
|
146
149
|
host = "tep"
|
|
147
150
|
end
|
data/lib/tep/parser.rb
CHANGED
|
@@ -55,8 +55,11 @@ module Tep
|
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
# Parse Cookie header into req.cookies. Format: "k=v; k2=v2; ...".
|
|
58
|
-
# Whitespace around `;` is allowed and stripped.
|
|
58
|
+
# Whitespace around `;` is allowed and stripped. A request with no
|
|
59
|
+
# Cookie header reads as nil (don't call methods on it: spinel's
|
|
60
|
+
# unresolved-call gate raises NoMethodError, same as CRuby).
|
|
59
61
|
cookie_blob = req.req_headers["cookie"]
|
|
62
|
+
cookie_blob = "" if cookie_blob.nil?
|
|
60
63
|
if cookie_blob.length > 0
|
|
61
64
|
cookie_blob.split(";").each do |pair|
|
|
62
65
|
eq = Tep.str_find(pair, "=", 0)
|
data/lib/tep/pg.rb
CHANGED
|
@@ -190,6 +190,16 @@ module PG
|
|
|
190
190
|
@last_sqlstate = ""
|
|
191
191
|
@last_error_message = ""
|
|
192
192
|
@last_result_rh = -1
|
|
193
|
+
# Never-connected sentinel: PG::Connection.new("") is the
|
|
194
|
+
# type-seeding shape (see the _tep_seed_pg_* block). Exec on a
|
|
195
|
+
# seed conn returns the empty-Result sentinel instead of
|
|
196
|
+
# raising -- a REAL failed connect (non-empty conninfo) still
|
|
197
|
+
# raises PG::UnableToSend on first exec, per the documented
|
|
198
|
+
# non-raising-connect / raising-exec contract.
|
|
199
|
+
@seed_conn = false
|
|
200
|
+
if opts.is_a?(String) && opts.length == 0
|
|
201
|
+
@seed_conn = true
|
|
202
|
+
end
|
|
193
203
|
if opts.is_a?(String)
|
|
194
204
|
if Tep::Scheduler.scheduled_context?
|
|
195
205
|
h = Connection.async_connect(opts)
|
|
@@ -382,6 +392,9 @@ module PG
|
|
|
382
392
|
Pg.tep_pg_set_nonblocking(@pgh, 1)
|
|
383
393
|
ok = Pg.tep_pg_send_query(@pgh, sql)
|
|
384
394
|
if ok != 1
|
|
395
|
+
if @seed_conn
|
|
396
|
+
return PG::Result.new(-1)
|
|
397
|
+
end
|
|
385
398
|
Connection.raise_send_failure(self)
|
|
386
399
|
end
|
|
387
400
|
Connection.drain_send(@pgh)
|
|
@@ -421,6 +434,9 @@ module PG
|
|
|
421
434
|
Pg.tep_pg_set_nonblocking(@pgh, 1)
|
|
422
435
|
ok = Pg.tep_pg_send_query_params(@pgh, sql)
|
|
423
436
|
if ok != 1
|
|
437
|
+
if @seed_conn
|
|
438
|
+
return PG::Result.new(-1)
|
|
439
|
+
end
|
|
424
440
|
Connection.raise_send_failure(self)
|
|
425
441
|
end
|
|
426
442
|
Connection.drain_send(@pgh)
|
data/lib/tep/request.rb
CHANGED
|
@@ -51,11 +51,16 @@ module Tep
|
|
|
51
51
|
def headers; @req_headers; end
|
|
52
52
|
def body; @raw_body; end
|
|
53
53
|
|
|
54
|
-
#
|
|
55
|
-
#
|
|
56
|
-
#
|
|
54
|
+
# A missing header reads as nil (CRuby Hash semantics; spinel's
|
|
55
|
+
# unresolved-call gate raises NoMethodError on nil receivers, same
|
|
56
|
+
# as CRuby would). Guard before calling String methods; an empty
|
|
57
|
+
# header and no header both mean "use HTTP/1.1 default behaviour".
|
|
58
|
+
# NOTE: guard with `x = "" if x.nil?`, not `x || ""` -- the ||-form
|
|
59
|
+
# mis-compiles at spinel pin f6d5eef (blanks the hit value).
|
|
57
60
|
def keep_alive?
|
|
58
|
-
lc = @req_headers["connection"]
|
|
61
|
+
lc = @req_headers["connection"]
|
|
62
|
+
lc = "" if lc.nil?
|
|
63
|
+
lc = lc.downcase
|
|
59
64
|
if lc == "close"
|
|
60
65
|
return false
|
|
61
66
|
end
|
|
@@ -70,7 +75,9 @@ module Tep
|
|
|
70
75
|
end
|
|
71
76
|
|
|
72
77
|
def form?
|
|
73
|
-
@req_headers["content-type"]
|
|
78
|
+
ct = @req_headers["content-type"]
|
|
79
|
+
ct = "" if ct.nil?
|
|
80
|
+
ct.downcase.start_with?("application/x-www-form-urlencoded")
|
|
74
81
|
end
|
|
75
82
|
|
|
76
83
|
# True when the request body is a multipart/form-data submission
|
|
@@ -78,7 +85,9 @@ module Tep
|
|
|
78
85
|
# or carrying file inputs). Tep::Multipart.parse handles the
|
|
79
86
|
# text fields; file-upload parts are skipped in v1.
|
|
80
87
|
def multipart?
|
|
81
|
-
@req_headers["content-type"]
|
|
88
|
+
ct = @req_headers["content-type"]
|
|
89
|
+
ct = "" if ct.nil?
|
|
90
|
+
ct.downcase.start_with?("multipart/form-data")
|
|
82
91
|
end
|
|
83
92
|
|
|
84
93
|
# ---- Rack::Request-style accessors (reads only, no .ip yet) ----
|
|
@@ -99,6 +108,7 @@ module Tep
|
|
|
99
108
|
# proxy sets.
|
|
100
109
|
def scheme
|
|
101
110
|
proto = @req_headers["x-forwarded-proto"]
|
|
111
|
+
proto = "" if proto.nil?
|
|
102
112
|
if proto.length > 0
|
|
103
113
|
return proto.downcase
|
|
104
114
|
end
|
data/lib/tep/server.rb
CHANGED
|
@@ -231,7 +231,11 @@ module Tep
|
|
|
231
231
|
res.set_body("")
|
|
232
232
|
end
|
|
233
233
|
|
|
234
|
-
|
|
234
|
+
# Default Content-Type even on empty bodies (redirects!) --
|
|
235
|
+
# sinatra/Rack parity, caught by the differential oracle
|
|
236
|
+
# (test/differential/runner.rb). 204/304 are the exception:
|
|
237
|
+
# Rack strips entity headers there, so we don't default one in.
|
|
238
|
+
if !res.headers.key?("Content-Type") && res.status != 204 && res.status != 304
|
|
235
239
|
res.headers["Content-Type"] = "text/html; charset=utf-8"
|
|
236
240
|
end
|
|
237
241
|
res.headers["Content-Length"] = res.body.length.to_s
|
data/lib/tep/server_scheduled.rb
CHANGED
|
@@ -327,11 +327,14 @@ module Tep
|
|
|
327
327
|
res.file_path = ""
|
|
328
328
|
end
|
|
329
329
|
|
|
330
|
-
# Default Content-Type for inline-body responses
|
|
331
|
-
#
|
|
332
|
-
#
|
|
333
|
-
#
|
|
334
|
-
|
|
330
|
+
# Default Content-Type for inline-body responses -- including
|
|
331
|
+
# empty ones (redirects), for sinatra/Rack parity (differential
|
|
332
|
+
# oracle finding; matches Tep::Server). 204/304 excepted: Rack
|
|
333
|
+
# strips entity headers there. Without a Content-Type, the
|
|
334
|
+
# Security::Headers nosniff default leaves the browser refusing
|
|
335
|
+
# to interpret an erb response as HTML.
|
|
336
|
+
if res.file_path.length == 0 && !res.headers.key?("Content-Type") &&
|
|
337
|
+
res.status != 204 && res.status != 304
|
|
335
338
|
res.headers["Content-Type"] = "text/html; charset=utf-8"
|
|
336
339
|
end
|
|
337
340
|
reason = Tep.reason(res.status)
|
data/lib/tep/session.rb
CHANGED
|
@@ -24,7 +24,15 @@ module Tep
|
|
|
24
24
|
# exposes only named methods; the translator rewrites
|
|
25
25
|
# `session[k] = v` to `session.set(k, v)` and `session[k]` to
|
|
26
26
|
# `session.get(k)` for source compatibility with Sinatra.
|
|
27
|
-
|
|
27
|
+
# A missing key reads as "" -- the documented `session[k]` DSL
|
|
28
|
+
# contract (test_sessions pins it; a deliberate divergence from
|
|
29
|
+
# sinatra's nil-on-miss, ledgered in docs/mirrors/sinatra.md).
|
|
30
|
+
# The raw @data hash reads nil for a miss.
|
|
31
|
+
def get(k)
|
|
32
|
+
v = @data[k]
|
|
33
|
+
v = "" if v.nil?
|
|
34
|
+
v
|
|
35
|
+
end
|
|
28
36
|
def set(k, v); @data[k] = v; @dirty = true; end
|
|
29
37
|
def has?(k); @data.key?(k); end
|
|
30
38
|
def length; @data.length; end
|
data/lib/tep/version.rb
CHANGED
|
@@ -26,13 +26,16 @@ module Tep
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
# Upgrade + Connection headers (downcased per Tep::Request).
|
|
29
|
+
# A missing header reads as nil; treat it as absent ("").
|
|
29
30
|
upgrade = req.headers["upgrade"]
|
|
31
|
+
upgrade = "" if upgrade.nil?
|
|
30
32
|
if Handshake.icontains(upgrade, "websocket") == false
|
|
31
33
|
out.valid = false
|
|
32
34
|
out.reason = "missing/invalid Upgrade"
|
|
33
35
|
return out
|
|
34
36
|
end
|
|
35
37
|
conn = req.headers["connection"]
|
|
38
|
+
conn = "" if conn.nil?
|
|
36
39
|
if Handshake.icontains(conn, "upgrade") == false
|
|
37
40
|
out.valid = false
|
|
38
41
|
out.reason = "missing/invalid Connection"
|
|
@@ -49,6 +52,7 @@ module Tep
|
|
|
49
52
|
|
|
50
53
|
# Sec-WebSocket-Key: 24-char base64 (16-byte nonce).
|
|
51
54
|
key = req.headers["sec-websocket-key"]
|
|
55
|
+
key = "" if key.nil?
|
|
52
56
|
if key.length == 0
|
|
53
57
|
out.valid = false
|
|
54
58
|
out.reason = "missing Sec-WebSocket-Key"
|
|
@@ -60,7 +64,9 @@ module Tep
|
|
|
60
64
|
|
|
61
65
|
# Parse Sec-WebSocket-Protocol (comma-separated). Handler
|
|
62
66
|
# gets the offered list; can opt-in via Driver.accept_protocol.
|
|
63
|
-
|
|
67
|
+
proto_blob = req.headers["sec-websocket-protocol"]
|
|
68
|
+
proto_blob = "" if proto_blob.nil?
|
|
69
|
+
out.protocols = Handshake.split_csv(proto_blob)
|
|
64
70
|
out
|
|
65
71
|
end
|
|
66
72
|
|
data/lib/tep.rb
CHANGED
|
@@ -129,6 +129,28 @@ module Tep
|
|
|
129
129
|
h
|
|
130
130
|
end
|
|
131
131
|
|
|
132
|
+
# Read a request cookie. A missing cookie reads as "" -- that is the
|
|
133
|
+
# documented `cookies["k"]` DSL contract (test_cookies pins it) --
|
|
134
|
+
# while the underlying hash reads nil for a missing key. bin/tep
|
|
135
|
+
# rewrites `cookies[k]` reads to this call.
|
|
136
|
+
def self.cookie(req, k)
|
|
137
|
+
v = req.cookies[k]
|
|
138
|
+
v = "" if v.nil?
|
|
139
|
+
v
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Read a request param. A missing param reads as "" -- tep's
|
|
143
|
+
# documented `params[k]` DSL contract (test_params pins it; a
|
|
144
|
+
# deliberate divergence from sinatra's nil, ledgered in
|
|
145
|
+
# docs/mirrors/sinatra.md). bin/tep rewrites `params[k]` reads to
|
|
146
|
+
# this call; direct `req.params[...]` access keeps raw Hash
|
|
147
|
+
# semantics (nil on miss).
|
|
148
|
+
def self.param(req, k)
|
|
149
|
+
v = req.params[k]
|
|
150
|
+
v = "" if v.nil?
|
|
151
|
+
v
|
|
152
|
+
end
|
|
153
|
+
|
|
132
154
|
# str_find -- naive substring search returning the int position of
|
|
133
155
|
# `needle` in `s` starting from `start`, or -1 if not found.
|
|
134
156
|
#
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Differential fixture: core routing/response semantics that both real
|
|
2
|
+
# sinatra and tep claim. This file must stay valid under BOTH dialects
|
|
3
|
+
# (docs/mirrors/sinatra.md condition 2) -- no tep extensions
|
|
4
|
+
# (request.headers, cookies[], set_cookie) and no sinatra-contrib.
|
|
5
|
+
require 'sinatra'
|
|
6
|
+
|
|
7
|
+
get '/hi/:name' do
|
|
8
|
+
'hi ' + params[:name]
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
get '/two/:a/:b' do
|
|
12
|
+
params[:a] + '-' + params[:b]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
get '/q' do
|
|
16
|
+
q = params[:q]
|
|
17
|
+
q = '' if q.nil?
|
|
18
|
+
'q=' + q
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
post '/form' do
|
|
22
|
+
t = params[:text]
|
|
23
|
+
t = '' if t.nil?
|
|
24
|
+
'text=' + t
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
get '/redir' do
|
|
28
|
+
redirect '/hi/there'
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
get '/redir301' do
|
|
32
|
+
redirect '/hi/there', 301
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
get '/teapot' do
|
|
36
|
+
status 418
|
|
37
|
+
'short and stout'
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
get '/halted' do
|
|
41
|
+
halt 401, 'no entry'
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
get '/hdr' do
|
|
45
|
+
headers['x-custom'] = 'yes'
|
|
46
|
+
'ok'
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
get '/ct' do
|
|
50
|
+
content_type 'text/plain'
|
|
51
|
+
'plain body'
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
not_found do
|
|
55
|
+
'nope: ' + request.path
|
|
56
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Differential fixture: broader Phase A matrix coverage. Valid under
|
|
2
|
+
# BOTH real sinatra and tep (docs/mirrors/sinatra.md condition 2).
|
|
3
|
+
# Divergent read-APIs (splat value, regex captures, cookies DSL) are
|
|
4
|
+
# deliberately NOT read here -- routes only exercise surface both
|
|
5
|
+
# dialects share; the known divergences are pinned by the runner's
|
|
6
|
+
# ledgered-divergence assertions instead.
|
|
7
|
+
require 'sinatra'
|
|
8
|
+
|
|
9
|
+
put '/verb' do
|
|
10
|
+
'put-ok'
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
patch '/verb' do
|
|
14
|
+
'patch-ok'
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Splat: single-segment matches in both dialects. The VALUE is not
|
|
18
|
+
# read (sinatra: params['splat'] array; tep: no exposed value) and
|
|
19
|
+
# multi-segment matching diverges -- see runner ledger L5.
|
|
20
|
+
get '/files/*' do
|
|
21
|
+
'file-route'
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Regex route, no capture reads (sinatra: params['captures']; tep:
|
|
25
|
+
# params["1"] -- runner ledger L6). Unanchored on purpose: sinatra's
|
|
26
|
+
# mustermann raises at boot on ^/$ anchors (it anchors implicitly),
|
|
27
|
+
# while tep accepts them -- anchored regex routes are tep-only (L6).
|
|
28
|
+
get %r{/rx/\d+} do
|
|
29
|
+
'rx-route'
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# after-filter mutating a response header; both dialects claim it.
|
|
33
|
+
after do
|
|
34
|
+
headers['x-after'] = 'ran'
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
get '/afterhdr' do
|
|
38
|
+
'body'
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Query-string URL decoding: %xx and `+` as space.
|
|
42
|
+
get '/dec' do
|
|
43
|
+
v = params[:v]
|
|
44
|
+
v = '' if v.nil?
|
|
45
|
+
'v=[' + v + ']'
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Multipart/form-data: text fields merge into params in both dialects
|
|
49
|
+
# (file parts are tep-ledgered as skipped; not exercised here).
|
|
50
|
+
post '/mp' do
|
|
51
|
+
f = params[:field]
|
|
52
|
+
f = '' if f.nil?
|
|
53
|
+
'field=[' + f + ']'
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Bare halt (status only, empty body).
|
|
57
|
+
get '/gone' do
|
|
58
|
+
halt 410
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
not_found do
|
|
62
|
+
'nf: ' + request.path
|
|
63
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Gems for the differential oracle ONLY (test/differential/runner.rb):
|
|
2
|
+
# real sinatra as the behavioural reference. Deliberately a separate
|
|
3
|
+
# Gemfile so sinatra never becomes a dependency of tep itself -- the
|
|
4
|
+
# main Gemfile stays rake/minitest/base64/rbs.
|
|
5
|
+
#
|
|
6
|
+
# BUNDLE_GEMFILE=test/differential/Gemfile bundle install
|
|
7
|
+
# ruby test/differential/runner.rb
|
|
8
|
+
|
|
9
|
+
source "https://rubygems.org"
|
|
10
|
+
|
|
11
|
+
gem "sinatra"
|
|
12
|
+
gem "rackup"
|
|
13
|
+
gem "puma"
|
|
14
|
+
gem "minitest"
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# Differential oracle: real sinatra vs tep (mirror condition 2,
|
|
2
|
+
# docs/mirrors/sinatra.md). Boots each fixture app twice -- once under
|
|
3
|
+
# CRuby + the real sinatra gem, once as a tep-compiled binary -- fires
|
|
4
|
+
# the same requests at both, and diffs status / body / declared
|
|
5
|
+
# headers. Any divergence not recorded in LEDGER is a failure: new
|
|
6
|
+
# divergences must be either fixed or explicitly ledgered with a
|
|
7
|
+
# reason (which then belongs in docs/mirrors/sinatra.md too).
|
|
8
|
+
#
|
|
9
|
+
# Not part of `rake test` (FileList only globs test/test_*.rb): the
|
|
10
|
+
# sinatra gem must never become a tep dependency. Run explicitly:
|
|
11
|
+
#
|
|
12
|
+
# gem install sinatra rackup puma # or bundle with ./Gemfile
|
|
13
|
+
# ruby test/differential/runner.rb
|
|
14
|
+
#
|
|
15
|
+
# CI runs this as its own job (`differential` in ci.yml).
|
|
16
|
+
|
|
17
|
+
begin
|
|
18
|
+
gem "sinatra"
|
|
19
|
+
rescue Gem::LoadError
|
|
20
|
+
abort "differential runner needs the sinatra gem (gem install sinatra rackup puma)"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
require "minitest/autorun"
|
|
24
|
+
require "net/http"
|
|
25
|
+
require "socket"
|
|
26
|
+
require "tmpdir"
|
|
27
|
+
require "fileutils"
|
|
28
|
+
require "shellwords"
|
|
29
|
+
require "rbconfig"
|
|
30
|
+
|
|
31
|
+
module Differential
|
|
32
|
+
TEP_BIN = File.expand_path("../../bin/tep", __dir__)
|
|
33
|
+
ROOT = File.expand_path("../..", __dir__)
|
|
34
|
+
PORT_BASE = 5300 + ($$ % 100)
|
|
35
|
+
|
|
36
|
+
@port = PORT_BASE
|
|
37
|
+
def self.next_port
|
|
38
|
+
@port += 1
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# ---- the exclusion ledger, harness-side ----
|
|
42
|
+
# Divergences we accept, keyed by response facet. Every entry needs a
|
|
43
|
+
# reason and should be mirrored in docs/mirrors/sinatra.md. The
|
|
44
|
+
# runner *normalizes* these away before diffing; anything left is a
|
|
45
|
+
# real, unledgered divergence and fails the run.
|
|
46
|
+
#
|
|
47
|
+
# L1 content-type charset: sinatra appends ";charset=utf-8" to text
|
|
48
|
+
# types; tep emits the bare type.
|
|
49
|
+
# L2 protection headers: sinatra ships rack-protection by default
|
|
50
|
+
# (x-xss-protection / x-content-type-options / x-frame-options);
|
|
51
|
+
# tep's Tep::Security::Headers is opt-in.
|
|
52
|
+
# L3 server/date/connection/content-length: transport furniture --
|
|
53
|
+
# body equality already covers length.
|
|
54
|
+
# L4 redirect Location absolutization: sinatra expands "/path" to an
|
|
55
|
+
# absolute http://host:port/path; tep echoes the path as given.
|
|
56
|
+
# Compare path component only.
|
|
57
|
+
# L5 splat scope: sinatra's `*` spans path segments; tep's matches a
|
|
58
|
+
# single trailing segment. Pinned per-request via `diverge:`.
|
|
59
|
+
# L6 divergent read APIs, not exercised by fixtures: regex captures
|
|
60
|
+
# (sinatra params['captures'] / block args vs tep params["1".."9"]),
|
|
61
|
+
# splat value (params['splat'] array vs none), request.headers
|
|
62
|
+
# (tep extension), bare set_cookie/cookies[] (tep mirrors
|
|
63
|
+
# contrib-style helpers), and ^/$-anchored regex routes (tep
|
|
64
|
+
# accepts; sinatra's mustermann raises at boot -- anchor-free
|
|
65
|
+
# regexes are the portable form). Fixtures avoid these; the
|
|
66
|
+
# ledger in docs/mirrors/sinatra.md records them.
|
|
67
|
+
IGNORED_HEADERS = %w[
|
|
68
|
+
server date connection content-length x-xss-protection
|
|
69
|
+
x-content-type-options x-frame-options keep-alive
|
|
70
|
+
].freeze
|
|
71
|
+
|
|
72
|
+
def self.normalize_content_type(v)
|
|
73
|
+
v.to_s.split(";").first.to_s.strip.downcase # L1
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def self.normalize_location(v)
|
|
77
|
+
return "" if v.nil?
|
|
78
|
+
v.sub(%r{\Ahttps?://[^/]+}, "") # L4
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
Server = Struct.new(:pid, :port, :log) do
|
|
82
|
+
def stop
|
|
83
|
+
return unless pid
|
|
84
|
+
Process.kill("TERM", -pid) rescue Process.kill("TERM", pid) rescue nil
|
|
85
|
+
Process.wait(pid) rescue nil
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.wait_for_port(port, pid, timeout: 15.0)
|
|
90
|
+
deadline = Time.now + timeout
|
|
91
|
+
while Time.now < deadline
|
|
92
|
+
begin
|
|
93
|
+
TCPSocket.new("127.0.0.1", port).close
|
|
94
|
+
return true
|
|
95
|
+
rescue Errno::ECONNREFUSED
|
|
96
|
+
raise "server died before binding :#{port}" if Process.wait(pid, Process::WNOHANG) rescue nil
|
|
97
|
+
sleep 0.05
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
raise "server on :#{port} didn't come up"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def self.boot_sinatra(app_path)
|
|
104
|
+
port = next_port
|
|
105
|
+
log = File.join(Dir.mktmpdir("diff-sin"), "sinatra.log")
|
|
106
|
+
pid = Process.spawn(
|
|
107
|
+
RbConfig.ruby, app_path, "-p", port.to_s, "-o", "127.0.0.1", "-e", "production",
|
|
108
|
+
out: log, err: [:child, :out], pgroup: true
|
|
109
|
+
)
|
|
110
|
+
wait_for_port(port, pid)
|
|
111
|
+
Server.new(pid, port, log)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def self.boot_tep(app_path)
|
|
115
|
+
tmp = Dir.mktmpdir("diff-tep")
|
|
116
|
+
bin = File.join(tmp, "app")
|
|
117
|
+
out = `#{Shellwords.escape(TEP_BIN)} build #{Shellwords.escape(app_path)} -o #{Shellwords.escape(bin)} 2>&1`
|
|
118
|
+
raise "tep build failed for #{app_path}:\n#{out}" unless $?.success?
|
|
119
|
+
port = next_port
|
|
120
|
+
log = File.join(tmp, "tep.log")
|
|
121
|
+
pid = Process.spawn(bin, "-p", port.to_s, "-q",
|
|
122
|
+
out: log, err: [:child, :out], pgroup: true)
|
|
123
|
+
wait_for_port(port, pid)
|
|
124
|
+
Server.new(pid, port, log)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def self.request(port, verb, path, body: nil, headers: {})
|
|
128
|
+
http = Net::HTTP.new("127.0.0.1", port)
|
|
129
|
+
http.open_timeout = 5
|
|
130
|
+
http.read_timeout = 5
|
|
131
|
+
klass = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post,
|
|
132
|
+
"PUT" => Net::HTTP::Put, "PATCH" => Net::HTTP::Patch,
|
|
133
|
+
"DELETE" => Net::HTTP::Delete }.fetch(verb)
|
|
134
|
+
req = klass.new(path)
|
|
135
|
+
headers.each { |k, v| req[k] = v }
|
|
136
|
+
if body
|
|
137
|
+
req.body = body
|
|
138
|
+
req["Content-Type"] ||= "application/x-www-form-urlencoded"
|
|
139
|
+
end
|
|
140
|
+
http.request(req)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# One test class per fixture; each boots both servers once and replays
|
|
145
|
+
# the same request script against each.
|
|
146
|
+
class DifferentialCase < Minitest::Test
|
|
147
|
+
FIXTURES = {
|
|
148
|
+
File.expand_path("../real_world/01_simple.rb", __dir__) => [
|
|
149
|
+
["GET", "/"],
|
|
150
|
+
],
|
|
151
|
+
File.expand_path("../real_world/04_health_api.rb", __dir__) => [
|
|
152
|
+
["GET", "/healthz"],
|
|
153
|
+
["GET", "/version"],
|
|
154
|
+
["GET", "/"],
|
|
155
|
+
["GET", "/missing"], # custom not_found on both
|
|
156
|
+
],
|
|
157
|
+
File.expand_path("../real_world/05_todo_api.rb", __dir__) => [
|
|
158
|
+
["GET", "/todos"],
|
|
159
|
+
["POST", "/todos", { body: "text=buy-milk" }],
|
|
160
|
+
["POST", "/todos", { body: "text=ship-tep" }],
|
|
161
|
+
["GET", "/todos"],
|
|
162
|
+
["DELETE", "/todos/1"],
|
|
163
|
+
["DELETE", "/todos/9999"],
|
|
164
|
+
["GET", "/todos"],
|
|
165
|
+
],
|
|
166
|
+
File.expand_path("11_matrix.rb", __dir__) => [
|
|
167
|
+
["PUT", "/verb"],
|
|
168
|
+
["PATCH", "/verb"],
|
|
169
|
+
["GET", "/files/one"],
|
|
170
|
+
# L5: sinatra's splat spans segments (/files/a/b matches); tep's
|
|
171
|
+
# splat is last-segment-only (404s). Pinned divergence.
|
|
172
|
+
["GET", "/files/a/b", { diverge: { "status" => "L5 splat last-segment-only" } }],
|
|
173
|
+
["GET", "/rx/123"],
|
|
174
|
+
["GET", "/rx/abc"], # regex miss -> not_found on both
|
|
175
|
+
["GET", "/afterhdr", { expect_headers: ["x-after"] }],
|
|
176
|
+
["GET", "/dec?v=a%20b+c"], # %xx and + decoding
|
|
177
|
+
["POST", "/mp", {
|
|
178
|
+
body: "--XDIFFB\r\nContent-Disposition: form-data; name=\"field\"\r\n\r\nzap-mp\r\n--XDIFFB--\r\n",
|
|
179
|
+
headers: { "Content-Type" => "multipart/form-data; boundary=XDIFFB" },
|
|
180
|
+
}],
|
|
181
|
+
["GET", "/gone"], # bare halt 410
|
|
182
|
+
["GET", "/nowhere"],
|
|
183
|
+
],
|
|
184
|
+
File.expand_path("10_semantics.rb", __dir__) => [
|
|
185
|
+
["GET", "/hi/tep"],
|
|
186
|
+
["GET", "/two/a/b"],
|
|
187
|
+
["GET", "/q?q=hello"],
|
|
188
|
+
["GET", "/q"], # missing param -> ""
|
|
189
|
+
["POST", "/form", { body: "text=zap" }],
|
|
190
|
+
["GET", "/redir"],
|
|
191
|
+
["GET", "/redir301"],
|
|
192
|
+
["GET", "/teapot"],
|
|
193
|
+
["GET", "/halted"],
|
|
194
|
+
["GET", "/hdr", { expect_headers: ["x-custom"] }],
|
|
195
|
+
["GET", "/ct"],
|
|
196
|
+
["GET", "/hi%20there"], # url-decoded 404 path via not_found
|
|
197
|
+
],
|
|
198
|
+
}.freeze
|
|
199
|
+
|
|
200
|
+
def compare(app, sin, tep, verb, path, opts)
|
|
201
|
+
body = opts[:body]
|
|
202
|
+
headers = opts[:headers] || {}
|
|
203
|
+
r_sin = Differential.request(sin.port, verb, path, body: body, headers: headers)
|
|
204
|
+
r_tep = Differential.request(tep.port, verb, path, body: body, headers: headers)
|
|
205
|
+
ctx = "#{File.basename(app)} #{verb} #{path}"
|
|
206
|
+
|
|
207
|
+
# Ledgered per-request divergences (opts[:diverge] = {facet =>
|
|
208
|
+
# "Ln reason"}): the divergence is EXPECTED and pinned -- if the
|
|
209
|
+
# two servers ever agree again, the assertion flips, telling us to
|
|
210
|
+
# clean the ledger entry (and docs/mirrors/sinatra.md). A status
|
|
211
|
+
# divergence short-circuits the body/content-type diff: different
|
|
212
|
+
# routes answered, comparing their bodies is meaningless.
|
|
213
|
+
diverge = opts[:diverge] || {}
|
|
214
|
+
if diverge.key?("status")
|
|
215
|
+
refute_equal r_sin.code, r_tep.code,
|
|
216
|
+
"#{ctx}: ledgered status divergence (#{diverge["status"]}) healed -- remove from ledger"
|
|
217
|
+
return
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
assert_equal r_sin.code, r_tep.code, "#{ctx}: status diverged"
|
|
221
|
+
assert_equal r_sin.body, r_tep.body, "#{ctx}: body diverged"
|
|
222
|
+
assert_equal Differential.normalize_content_type(r_sin["content-type"]),
|
|
223
|
+
Differential.normalize_content_type(r_tep["content-type"]),
|
|
224
|
+
"#{ctx}: content-type diverged (post-normalization)"
|
|
225
|
+
if %w[301 302 303 307 308].include?(r_sin.code)
|
|
226
|
+
assert_equal Differential.normalize_location(r_sin["location"]),
|
|
227
|
+
Differential.normalize_location(r_tep["location"]),
|
|
228
|
+
"#{ctx}: redirect Location diverged (path component)"
|
|
229
|
+
end
|
|
230
|
+
(opts[:expect_headers] || []).each do |h|
|
|
231
|
+
assert_equal r_sin[h], r_tep[h], "#{ctx}: declared header #{h} diverged"
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
FIXTURES.each do |app, script|
|
|
236
|
+
name = File.basename(app, ".rb")
|
|
237
|
+
define_method("test_differential_#{name}") do
|
|
238
|
+
sin = Differential.boot_sinatra(app)
|
|
239
|
+
tep = Differential.boot_tep(app)
|
|
240
|
+
begin
|
|
241
|
+
script.each do |verb, path, opts|
|
|
242
|
+
compare(app, sin, tep, verb, path, opts || {})
|
|
243
|
+
end
|
|
244
|
+
ensure
|
|
245
|
+
sin.stop
|
|
246
|
+
tep.stop
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
data/test/test_broadcast_pg.rb
CHANGED
|
@@ -111,8 +111,12 @@ class TestBroadcastPg < TepTest
|
|
|
111
111
|
end
|
|
112
112
|
|
|
113
113
|
def test_poll_returns_zero_on_timeout
|
|
114
|
-
#
|
|
115
|
-
#
|
|
114
|
+
# Test order is randomized and the LISTEN channel is shared:
|
|
115
|
+
# another test's publish can leave an unconsumed NOTIFY queued.
|
|
116
|
+
# Drain first, then assert a clean timeout.
|
|
117
|
+
5.times do
|
|
118
|
+
break if get("/poll?timeout=50").body == "0"
|
|
119
|
+
end
|
|
116
120
|
res = get("/poll?timeout=100")
|
|
117
121
|
assert_equal "0", res.body
|
|
118
122
|
end
|
data/test/test_live_view.rb
CHANGED
|
@@ -18,8 +18,10 @@ class TestLiveView < TepTest
|
|
|
18
18
|
end
|
|
19
19
|
def mount(req)
|
|
20
20
|
# Pull a seed value from the request's params if present;
|
|
21
|
-
# otherwise leave at 0.
|
|
21
|
+
# otherwise leave at 0. A missing param reads as nil
|
|
22
|
+
# (sinatra-parity) -- guard before String calls.
|
|
22
23
|
seed = req.params["seed"]
|
|
24
|
+
seed = "" if seed.nil?
|
|
23
25
|
if seed.length > 0
|
|
24
26
|
@count = seed.to_i
|
|
25
27
|
end
|
data/test/test_mcp.rb
CHANGED
|
@@ -12,7 +12,9 @@ class TestMCP < TepTest
|
|
|
12
12
|
# paths -- we just override req.identity with a synthetic one
|
|
13
13
|
# so req.identity.may?(:admin) returns true on demand.
|
|
14
14
|
before do
|
|
15
|
-
|
|
15
|
+
cap = req.req_headers["x-test-cap-admin"]
|
|
16
|
+
cap = "" if cap.nil?
|
|
17
|
+
if cap.length > 0
|
|
16
18
|
req.identity = Tep::Identity.new(
|
|
17
19
|
"user:42", nil, [:admin])
|
|
18
20
|
end
|
data/test/test_pg.rb
CHANGED
|
@@ -268,10 +268,22 @@ class TestPg < TepTest
|
|
|
268
268
|
r.clear
|
|
269
269
|
out = "raised=no"
|
|
270
270
|
rescue PG::UndefinedTable => e
|
|
271
|
+
# Hierarchy proof via a nested rescue arm instead of
|
|
272
|
+
# `e.is_a?(PG::Error)`: is_a? on a rescued local is
|
|
273
|
+
# unsupported at the current spinel pin (typed-rescue local
|
|
274
|
+
# widens; tracked in the SPINEL_PIN notes). Re-raising e and
|
|
275
|
+
# catching it with the PARENT class proves the subclass
|
|
276
|
+
# relation just as strictly.
|
|
277
|
+
hier = "no"
|
|
278
|
+
begin
|
|
279
|
+
raise e
|
|
280
|
+
rescue PG::Error
|
|
281
|
+
hier = "yes"
|
|
282
|
+
end
|
|
271
283
|
out = "raised=UndefinedTable" +
|
|
272
284
|
" sqlstate=" + c.last_sqlstate +
|
|
273
285
|
" match42P01=" + (c.last_sqlstate == "42P01" ? "yes" : "no") +
|
|
274
|
-
" is_pg_error=" +
|
|
286
|
+
" is_pg_error=" + hier
|
|
275
287
|
end
|
|
276
288
|
c.close
|
|
277
289
|
out
|
|
@@ -295,9 +307,16 @@ class TestPg < TepTest
|
|
|
295
307
|
r2.clear
|
|
296
308
|
out = "first_ok=yes second_raised=no"
|
|
297
309
|
rescue PG::UniqueViolation => e
|
|
310
|
+
# Same nested-rescue hierarchy proof as /missing_table.
|
|
311
|
+
hier = "no"
|
|
312
|
+
begin
|
|
313
|
+
raise e
|
|
314
|
+
rescue PG::Error
|
|
315
|
+
hier = "yes"
|
|
316
|
+
end
|
|
298
317
|
out = "first_ok=yes second_raised=UniqueViolation" +
|
|
299
318
|
" sqlstate=" + c.last_sqlstate +
|
|
300
|
-
" is_pg_error=" +
|
|
319
|
+
" is_pg_error=" + hier
|
|
301
320
|
end
|
|
302
321
|
r3 = c.exec_params("DELETE FROM " + TBL + " WHERE id = $1", ["99001"])
|
|
303
322
|
r3.clear
|
data/test/test_real_world.rb
CHANGED
|
@@ -30,10 +30,14 @@ class TestRealWorld < TepTest
|
|
|
30
30
|
PORT_BASE + 100 + @@port_counter
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
# lax: true builds with --lax for fidelity copies of upstream apps
|
|
34
|
+
# that use out-of-contract DSL on purpose (02_lifecycle's `on_stop`)
|
|
35
|
+
# -- the strict default (mirror condition 3) would rightly refuse.
|
|
36
|
+
def with_app(example_filename, lax: false)
|
|
34
37
|
src = File.join(EXAMPLES_DIR, example_filename)
|
|
35
38
|
bin = Dir.mktmpdir + "/app"
|
|
36
|
-
|
|
39
|
+
lax_flag = lax ? " --lax" : ""
|
|
40
|
+
out = `#{Shellwords.escape(File.expand_path("../bin/tep", __dir__))} build#{lax_flag} #{Shellwords.escape(src)} -o #{Shellwords.escape(bin)} 2>&1`
|
|
37
41
|
raise "build failed:\n#{out}" unless $?.success?
|
|
38
42
|
port = TestRealWorld.next_port
|
|
39
43
|
pid = Process.spawn(bin, "-p", port.to_s, "-q",
|
|
@@ -73,7 +77,8 @@ class TestRealWorld < TepTest
|
|
|
73
77
|
# ---- 02: lifecycle ----
|
|
74
78
|
|
|
75
79
|
def test_02_lifecycle_root_renders
|
|
76
|
-
|
|
80
|
+
# upstream example uses `on_stop` (no tep shutdown path) -> --lax
|
|
81
|
+
with_app("02_lifecycle.rb", lax: true) do
|
|
77
82
|
res = get("/")
|
|
78
83
|
assert_equal "200", res.code
|
|
79
84
|
assert_match(/lifecycle events/, res.body)
|
|
@@ -24,7 +24,13 @@ class TestRequestMethods < TepTest
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
get '/accept-and-ct' do
|
|
27
|
-
|
|
27
|
+
# Absent headers read as nil through the Rack-parity accessors
|
|
28
|
+
# (sinatra too) -- guard before concatenation.
|
|
29
|
+
a = request.accept
|
|
30
|
+
a = "" if a.nil?
|
|
31
|
+
ct = request.content_type
|
|
32
|
+
ct = "" if ct.nil?
|
|
33
|
+
"accept=" + a + " ct=" + ct
|
|
28
34
|
end
|
|
29
35
|
RB
|
|
30
36
|
|
data/test/test_unsupported.rb
CHANGED
|
@@ -29,4 +29,51 @@ class TestUnsupported < TepTest
|
|
|
29
29
|
|
|
30
30
|
# send_file, configure, pass, multiple filters, optional segments
|
|
31
31
|
# have all moved into supported and have their own test files.
|
|
32
|
+
|
|
33
|
+
# ---- strict-by-default translator (mirror condition 3) ----
|
|
34
|
+
# Out-of-contract constructs must FAIL the build, not silently
|
|
35
|
+
# drop (docs/mirrors/sinatra.md). These assert at translate level,
|
|
36
|
+
# so no spinel compile is needed. `--lax` restores warn-and-continue.
|
|
37
|
+
|
|
38
|
+
def translate_out(source, flags: "")
|
|
39
|
+
tmp = Dir.mktmpdir("tep-strict")
|
|
40
|
+
src = File.join(tmp, "app.rb")
|
|
41
|
+
File.write(src, "require 'sinatra'\n" + source + "\nget('/') { 'ok' }\n")
|
|
42
|
+
out = `#{TepHarness::TEP_BIN} translate #{flags} #{src} 2>&1 >/dev/null`
|
|
43
|
+
[$?.success?, out]
|
|
44
|
+
ensure
|
|
45
|
+
FileUtils.remove_entry(tmp) if tmp
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def assert_strict_refusal(source, message_fragment)
|
|
49
|
+
ok, out = translate_out(source)
|
|
50
|
+
refute ok, "expected strict translate to fail, but it succeeded"
|
|
51
|
+
assert_match(/error: .*#{Regexp.escape(message_fragment)}/, out)
|
|
52
|
+
assert_match(/--lax/, out, "refusal should mention the --lax escape hatch")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def test_strict_rejects_rack_use
|
|
56
|
+
assert_strict_refusal(%(use Rack::Session::Cookie, secret: "x"),
|
|
57
|
+
"unsupported `use`")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def test_strict_rejects_helpers_block
|
|
61
|
+
assert_strict_refusal(%(helpers do\n def shout(s); s.upcase; end\nend),
|
|
62
|
+
"unsupported `helpers do ... end`")
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def test_strict_rejects_unknown_set_key
|
|
66
|
+
assert_strict_refusal(%(set :sessions, true), "unsupported `set :sessions`")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def test_strict_rejects_on_stop
|
|
70
|
+
assert_strict_refusal(%(on_stop do\n puts "bye"\nend), "on_stop")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def test_lax_downgrades_to_warning
|
|
74
|
+
ok, out = translate_out("use Rack::Head", flags: "--lax")
|
|
75
|
+
assert ok, "expected --lax translate to succeed, got:\n#{out}"
|
|
76
|
+
assert_match(/unsupported `use`/, out)
|
|
77
|
+
refute_match(/error:/, out)
|
|
78
|
+
end
|
|
32
79
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: tep
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.11.
|
|
4
|
+
version: 0.11.7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Ori Pekelman
|
|
@@ -162,6 +162,10 @@ files:
|
|
|
162
162
|
- public/hello.txt
|
|
163
163
|
- public/style.css
|
|
164
164
|
- spinel-ext.json
|
|
165
|
+
- test/differential/10_semantics.rb
|
|
166
|
+
- test/differential/11_matrix.rb
|
|
167
|
+
- test/differential/Gemfile
|
|
168
|
+
- test/differential/runner.rb
|
|
165
169
|
- test/helper.rb
|
|
166
170
|
- test/real_world/01_simple.rb
|
|
167
171
|
- test/real_world/02_lifecycle.rb
|
|
@@ -273,7 +277,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
273
277
|
- !ruby/object:Gem::Version
|
|
274
278
|
version: '0'
|
|
275
279
|
requirements: []
|
|
276
|
-
rubygems_version:
|
|
280
|
+
rubygems_version: 3.6.9
|
|
277
281
|
specification_version: 4
|
|
278
282
|
summary: A Sinatra-flavoured web framework that compiles to a native binary via Spinel
|
|
279
283
|
test_files: []
|