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.
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 (spinel's File.read swallows fopen
40
- # errors and returns the empty string -- matches the prior
41
- # sphttp_file_read behaviour).
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
- File.read(path)
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 = File.read(path)
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
@@ -1,3 +1,3 @@
1
1
  module Tep
2
- VERSION = "0.11.5"
2
+ VERSION = "0.11.7"
3
3
  end
@@ -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
- out.protocols = Handshake.split_csv(req.headers["sec-websocket-protocol"])
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
@@ -111,8 +111,12 @@ class TestBroadcastPg < TepTest
111
111
  end
112
112
 
113
113
  def test_poll_returns_zero_on_timeout
114
- # With no preceding publish + no other publisher on the channel,
115
- # poll should time out cleanly.
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
@@ -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
- if req.req_headers["x-test-cap-admin"].length > 0
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=" + (e.is_a?(PG::Error) ? "yes" : "no")
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=" + (e.is_a?(PG::Error) ? "yes" : "no")
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
@@ -30,10 +30,14 @@ class TestRealWorld < TepTest
30
30
  PORT_BASE + 100 + @@port_counter
31
31
  end
32
32
 
33
- def with_app(example_filename)
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
- out = `#{Shellwords.escape(File.expand_path("../bin/tep", __dir__))} build #{Shellwords.escape(src)} -o #{Shellwords.escape(bin)} 2>&1`
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
- with_app("02_lifecycle.rb") do
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
- "accept=" + request.accept + " ct=" + request.content_type
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
 
@@ -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