tep 0.11.5 → 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 +34 -30
- data/bin/tep +59 -13
- data/examples/blog/app.rb +1 -0
- data/examples/experiments/app.rb +3 -1
- data/examples/pg_hello.rb +9 -8
- 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 +29 -28
- data/lib/tep/proxy.rb +2 -2
- 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/shell.rb +17 -6
- 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
data/lib/tep/shell.rb
CHANGED
|
@@ -36,18 +36,29 @@ module Tep
|
|
|
36
36
|
|
|
37
37
|
# Read a file's contents. Useful for /proc/loadavg, /proc/meminfo,
|
|
38
38
|
# /sys/class/thermal/.../temp, and similar small-text endpoints.
|
|
39
|
-
# Returns "" on open failure
|
|
40
|
-
#
|
|
41
|
-
#
|
|
39
|
+
# Returns "" on open failure. Spinel's File.read now raises a
|
|
40
|
+
# CRuby-correct Errno on a missing/unreadable path (it used to
|
|
41
|
+
# silently return ""); we rescue here to preserve this helper's
|
|
42
|
+
# never-raise contract -- a handler reading /proc should get "" for
|
|
43
|
+
# an absent file, not a 502'd worker.
|
|
42
44
|
def self.read(path)
|
|
43
|
-
|
|
45
|
+
begin
|
|
46
|
+
File.read(path)
|
|
47
|
+
rescue => e
|
|
48
|
+
""
|
|
49
|
+
end
|
|
44
50
|
end
|
|
45
51
|
|
|
46
52
|
# Bounded read: slice after the fact. The cap is mostly a
|
|
47
53
|
# defensive cue -- callers that need it should be reading
|
|
48
|
-
# bounded /proc files anyway.
|
|
54
|
+
# bounded /proc files anyway. Same never-raise contract as #read.
|
|
49
55
|
def self.read_limited(path, max_bytes)
|
|
50
|
-
out =
|
|
56
|
+
out = ""
|
|
57
|
+
begin
|
|
58
|
+
out = File.read(path)
|
|
59
|
+
rescue => e
|
|
60
|
+
out = ""
|
|
61
|
+
end
|
|
51
62
|
out.length > max_bytes ? out[0, max_bytes] : out
|
|
52
63
|
end
|
|
53
64
|
|
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
|