tep 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/Makefile +134 -0
  4. data/README.md +247 -0
  5. data/SINATRA_COMPAT.md +376 -0
  6. data/bin/tep +2156 -0
  7. data/examples/agentic_chat/README.md +103 -0
  8. data/examples/agentic_chat/app.rb +310 -0
  9. data/examples/api_gateway/README.md +49 -0
  10. data/examples/api_gateway/app.rb +66 -0
  11. data/examples/blog/app.rb +367 -0
  12. data/examples/blog/views/index.erb +36 -0
  13. data/examples/blog/views/login.erb +28 -0
  14. data/examples/blog/views/new_post.erb +25 -0
  15. data/examples/blog/views/show.erb +16 -0
  16. data/examples/chat/app.rb +278 -0
  17. data/examples/chat/assets/logo.svg +13 -0
  18. data/examples/chat/assets/style.css +209 -0
  19. data/examples/chat/views/index.erb +142 -0
  20. data/examples/chatbot/README.md +111 -0
  21. data/examples/chatbot/app.rb +1024 -0
  22. data/examples/chatbot/assets/chat.js +249 -0
  23. data/examples/chatbot/assets/compare.js +93 -0
  24. data/examples/chatbot/assets/markdown.js +84 -0
  25. data/examples/chatbot/assets/style.css +215 -0
  26. data/examples/chatbot/schema.sql +25 -0
  27. data/examples/chatbot/views/compare.erb +43 -0
  28. data/examples/chatbot/views/index.erb +42 -0
  29. data/examples/chatbot/views/login.erb +22 -0
  30. data/examples/chatbot/views/setup.erb +23 -0
  31. data/examples/counter/README.md +68 -0
  32. data/examples/counter/app.rb +85 -0
  33. data/examples/experiments/AGENTS.md +91 -0
  34. data/examples/experiments/README.md +99 -0
  35. data/examples/experiments/app.rb +225 -0
  36. data/examples/geohash/Gemfile +11 -0
  37. data/examples/geohash/Gemfile.lock +17 -0
  38. data/examples/geohash/README.md +58 -0
  39. data/examples/geohash/app.rb +33 -0
  40. data/examples/hello.rb +120 -0
  41. data/examples/llm_gateway/README.md +73 -0
  42. data/examples/llm_gateway/app.rb +91 -0
  43. data/examples/maidenhead/Gemfile +7 -0
  44. data/examples/maidenhead/Gemfile.lock +17 -0
  45. data/examples/maidenhead/README.md +47 -0
  46. data/examples/maidenhead/app.rb +46 -0
  47. data/examples/pg_hello.rb +76 -0
  48. data/examples/qdrant/Gemfile +11 -0
  49. data/examples/qdrant/Gemfile.lock +29 -0
  50. data/examples/qdrant/README.md +54 -0
  51. data/examples/sinatra_style.rb +32 -0
  52. data/examples/websocket_echo.rb +37 -0
  53. data/lib/tep/agent_delegation.rb +35 -0
  54. data/lib/tep/app.rb +291 -0
  55. data/lib/tep/assets.rb +52 -0
  56. data/lib/tep/auth.rb +78 -0
  57. data/lib/tep/auth_bearer_token.rb +126 -0
  58. data/lib/tep/auth_oauth2.rb +189 -0
  59. data/lib/tep/auth_oauth2_client.rb +29 -0
  60. data/lib/tep/auth_oauth2_code.rb +40 -0
  61. data/lib/tep/auth_session_cookie.rb +132 -0
  62. data/lib/tep/broadcast.rb +265 -0
  63. data/lib/tep/broadcast_subscription.rb +42 -0
  64. data/lib/tep/cache.rb +49 -0
  65. data/lib/tep/events.rb +257 -0
  66. data/lib/tep/filter.rb +21 -0
  67. data/lib/tep/handler.rb +35 -0
  68. data/lib/tep/http.rb +599 -0
  69. data/lib/tep/identity.rb +67 -0
  70. data/lib/tep/job.rb +186 -0
  71. data/lib/tep/json.rb +572 -0
  72. data/lib/tep/jwt.rb +126 -0
  73. data/lib/tep/live_view.rb +219 -0
  74. data/lib/tep/llm.rb +505 -0
  75. data/lib/tep/logger.rb +85 -0
  76. data/lib/tep/mcp.rb +203 -0
  77. data/lib/tep/multipart.rb +98 -0
  78. data/lib/tep/net.rb +155 -0
  79. data/lib/tep/openai_server.rb +725 -0
  80. data/lib/tep/parallel.rb +168 -0
  81. data/lib/tep/parser.rb +81 -0
  82. data/lib/tep/password.rb +102 -0
  83. data/lib/tep/pg.rb +1128 -0
  84. data/lib/tep/presence.rb +589 -0
  85. data/lib/tep/presence_entry.rb +52 -0
  86. data/lib/tep/proxy.rb +801 -0
  87. data/lib/tep/request.rb +194 -0
  88. data/lib/tep/response.rb +134 -0
  89. data/lib/tep/router.rb +137 -0
  90. data/lib/tep/scheduler.rb +342 -0
  91. data/lib/tep/security.rb +140 -0
  92. data/lib/tep/server.rb +276 -0
  93. data/lib/tep/server_scheduled.rb +375 -0
  94. data/lib/tep/session.rb +98 -0
  95. data/lib/tep/shell.rb +62 -0
  96. data/lib/tep/sphttp.c +858 -0
  97. data/lib/tep/sqlite.rb +215 -0
  98. data/lib/tep/streamer.rb +31 -0
  99. data/lib/tep/tep_pg.c +769 -0
  100. data/lib/tep/tep_sqlite.c +320 -0
  101. data/lib/tep/url.rb +161 -0
  102. data/lib/tep/version.rb +3 -0
  103. data/lib/tep/websocket/connection.rb +171 -0
  104. data/lib/tep/websocket/driver.rb +169 -0
  105. data/lib/tep/websocket/frame.rb +238 -0
  106. data/lib/tep/websocket/handshake.rb +159 -0
  107. data/lib/tep/websocket.rb +68 -0
  108. data/lib/tep.rb +981 -0
  109. data/public/hello.txt +1 -0
  110. data/public/style.css +4 -0
  111. data/spinel-ext.json +33 -0
  112. data/test/helper.rb +248 -0
  113. data/test/real_world/01_simple.rb +5 -0
  114. data/test/real_world/02_lifecycle.rb +20 -0
  115. data/test/real_world/03_chat.rb +75 -0
  116. data/test/real_world/04_health_api.rb +25 -0
  117. data/test/real_world/05_todo_api.rb +57 -0
  118. data/test/real_world/06_basic_auth.rb +25 -0
  119. data/test/real_world/07_bbc_rest_api.rb +228 -0
  120. data/test/real_world/07_sklise_things.rb +109 -0
  121. data/test/real_world/08_jwd83_helloworld.rb +56 -0
  122. data/test/run_all.rb +7 -0
  123. data/test/run_parallel.rb +89 -0
  124. data/test/spinel_scheduled_burst_segv_repro.rb +33 -0
  125. data/test/test_api_gateway.rb +76 -0
  126. data/test/test_auth.rb +223 -0
  127. data/test/test_auth_oauth2.rb +208 -0
  128. data/test/test_auth_session_cookie.rb +198 -0
  129. data/test/test_broadcast.rb +197 -0
  130. data/test/test_broadcast_pg.rb +135 -0
  131. data/test/test_cache.rb +98 -0
  132. data/test/test_cache_static.rb +48 -0
  133. data/test/test_cookies.rb +52 -0
  134. data/test/test_erb.rb +53 -0
  135. data/test/test_erb_ivars.rb +58 -0
  136. data/test/test_events.rb +114 -0
  137. data/test/test_filters.rb +41 -0
  138. data/test/test_geohash_example.rb +89 -0
  139. data/test/test_http.rb +137 -0
  140. data/test/test_http_pool.rb +122 -0
  141. data/test/test_http_pool_send.rb +57 -0
  142. data/test/test_identity.rb +165 -0
  143. data/test/test_inbound_tls.rb +101 -0
  144. data/test/test_inbound_tls_scheduled.rb +101 -0
  145. data/test/test_job.rb +108 -0
  146. data/test/test_json.rb +168 -0
  147. data/test/test_jwt.rb +143 -0
  148. data/test/test_live_view.rb +324 -0
  149. data/test/test_llm.rb +250 -0
  150. data/test/test_llm_gateway.rb +95 -0
  151. data/test/test_logger.rb +101 -0
  152. data/test/test_maidenhead_example.rb +86 -0
  153. data/test/test_mcp.rb +264 -0
  154. data/test/test_misc_v02.rb +54 -0
  155. data/test/test_modular.rb +43 -0
  156. data/test/test_multi_filters.rb +40 -0
  157. data/test/test_mustache.rb +57 -0
  158. data/test/test_openai_server.rb +598 -0
  159. data/test/test_optional_segments.rb +45 -0
  160. data/test/test_parallel.rb +102 -0
  161. data/test/test_params.rb +99 -0
  162. data/test/test_pass.rb +42 -0
  163. data/test/test_password.rb +101 -0
  164. data/test/test_pg.rb +673 -0
  165. data/test/test_presence.rb +374 -0
  166. data/test/test_presence_pg.rb +309 -0
  167. data/test/test_proxy.rb +556 -0
  168. data/test/test_proxy_dsl.rb +119 -0
  169. data/test/test_proxy_streaming.rb +146 -0
  170. data/test/test_real_world.rb +397 -0
  171. data/test/test_regex_routes.rb +52 -0
  172. data/test/test_request_methods.rb +102 -0
  173. data/test/test_response.rb +123 -0
  174. data/test/test_routing.rb +109 -0
  175. data/test/test_scheduler.rb +153 -0
  176. data/test/test_security.rb +72 -0
  177. data/test/test_server_scheduled.rb +56 -0
  178. data/test/test_sessions.rb +59 -0
  179. data/test/test_shell.rb +54 -0
  180. data/test/test_sqlite.rb +148 -0
  181. data/test/test_sqlite_cached.rb +171 -0
  182. data/test/test_static.rb +57 -0
  183. data/test/test_streaming.rb +96 -0
  184. data/test/test_unsupported.rb +32 -0
  185. data/test/test_websocket.rb +152 -0
  186. data/test/test_websocket_echo.rb +138 -0
  187. data/test/views/greet.erb +5 -0
  188. data/test/views/hello.erb +5 -0
  189. data/test/views/list.erb +5 -0
  190. data/test/views/m_ivars.mustache +3 -0
  191. data/test/views/m_simple.mustache +4 -0
  192. data/test/views/mixed.erb +3 -0
  193. metadata +264 -0
@@ -0,0 +1,123 @@
1
+ require_relative "helper"
2
+
3
+ # Status codes, custom headers, content-type, redirect, halt.
4
+ class TestResponse < TepTest
5
+ app_source <<~RB
6
+ get '/ok' do
7
+ "ok"
8
+ end
9
+
10
+ get '/created' do
11
+ status 201
12
+ "made"
13
+ end
14
+
15
+ get '/no-content' do
16
+ status 204
17
+ ""
18
+ end
19
+
20
+ get '/server-err' do
21
+ status 500
22
+ "boom"
23
+ end
24
+
25
+ get '/plain' do
26
+ content_type 'text/plain; charset=utf-8'
27
+ "plain text"
28
+ end
29
+
30
+ get '/json' do
31
+ content_type 'application/json'
32
+ '{"ok":true}'
33
+ end
34
+
35
+ get '/custom-header' do
36
+ headers["X-Tep-Test"] = "yep"
37
+ "with header"
38
+ end
39
+
40
+ get '/redirect' do
41
+ redirect '/ok'
42
+ end
43
+
44
+ get '/redirect-301' do
45
+ redirect '/ok', 301
46
+ end
47
+
48
+ get '/halt-401' do
49
+ halt 401, "denied"
50
+ end
51
+
52
+ get '/halt-bare' do
53
+ halt 418
54
+ end
55
+ RB
56
+
57
+ def test_default_200
58
+ res = get("/ok")
59
+ assert_equal "200", res.code
60
+ assert_equal "OK", res.message
61
+ end
62
+
63
+ def test_status_201
64
+ assert_equal "201", get("/created").code
65
+ end
66
+
67
+ def test_status_204
68
+ assert_equal "204", get("/no-content").code
69
+ end
70
+
71
+ def test_status_500
72
+ assert_equal "500", get("/server-err").code
73
+ end
74
+
75
+ def test_default_content_type_html
76
+ res = get("/ok")
77
+ assert_match(/text\/html/, res["content-type"])
78
+ end
79
+
80
+ def test_explicit_content_type_plain
81
+ res = get("/plain")
82
+ assert_equal "text/plain; charset=utf-8", res["content-type"]
83
+ end
84
+
85
+ def test_explicit_content_type_json
86
+ res = get("/json")
87
+ assert_equal "application/json", res["content-type"]
88
+ assert_equal '{"ok":true}', res.body
89
+ end
90
+
91
+ def test_custom_header
92
+ res = get("/custom-header")
93
+ assert_equal "yep", res["x-tep-test"]
94
+ end
95
+
96
+ def test_redirect_default_302
97
+ res = get("/redirect")
98
+ assert_equal "302", res.code
99
+ assert_equal "/ok", res["location"]
100
+ end
101
+
102
+ def test_redirect_explicit_301
103
+ res = get("/redirect-301")
104
+ assert_equal "301", res.code
105
+ assert_equal "/ok", res["location"]
106
+ end
107
+
108
+ def test_halt_with_body
109
+ res = get("/halt-401")
110
+ assert_equal "401", res.code
111
+ assert_equal "denied", res.body
112
+ end
113
+
114
+ def test_halt_status_only
115
+ res = get("/halt-bare")
116
+ assert_equal "418", res.code
117
+ end
118
+
119
+ def test_content_length
120
+ res = get("/ok")
121
+ assert_equal "2", res["content-length"]
122
+ end
123
+ end
@@ -0,0 +1,109 @@
1
+ require_relative "helper"
2
+
3
+ # Routing checklist: verbs, path params, query, splat, 404, method
4
+ # mismatch. Mirrors the surface in Sinatra's routing_test.rb that
5
+ # matches our v0.1 scope.
6
+ class TestRouting < TepTest
7
+ app_source <<~RB
8
+ get '/' do
9
+ "root"
10
+ end
11
+
12
+ get '/hi/:name' do
13
+ "hi, " + params[:name]
14
+ end
15
+
16
+ get '/users/:id/posts/:post_id' do
17
+ "user " + params[:id] + " post " + params[:post_id]
18
+ end
19
+
20
+ get '/search' do
21
+ params[:q] + "/" + params[:page]
22
+ end
23
+
24
+ get '/files/*' do
25
+ "splat"
26
+ end
27
+
28
+ post '/echo' do
29
+ params[:msg]
30
+ end
31
+
32
+ put '/widgets/:id' do
33
+ "put " + params[:id]
34
+ end
35
+
36
+ patch '/widgets/:id' do
37
+ "patch " + params[:id]
38
+ end
39
+
40
+ delete '/widgets/:id' do
41
+ "delete " + params[:id]
42
+ end
43
+ RB
44
+
45
+ def test_root
46
+ res = get("/")
47
+ assert_equal "200", res.code
48
+ assert_equal "root", res.body
49
+ end
50
+
51
+ def test_path_param
52
+ res = get("/hi/world")
53
+ assert_equal "200", res.code
54
+ assert_equal "hi, world", res.body
55
+ end
56
+
57
+ def test_two_path_params
58
+ res = get("/users/42/posts/7")
59
+ assert_equal "200", res.code
60
+ assert_equal "user 42 post 7", res.body
61
+ end
62
+
63
+ def test_query_string
64
+ res = get("/search?q=ruby&page=2")
65
+ assert_equal "200", res.code
66
+ assert_equal "ruby/2", res.body
67
+ end
68
+
69
+ def test_splat
70
+ res = get("/files/anything")
71
+ assert_equal "200", res.code
72
+ assert_equal "splat", res.body
73
+ end
74
+
75
+ def test_post_form
76
+ res = post("/echo", "msg=hello",
77
+ "Content-Type" => "application/x-www-form-urlencoded")
78
+ assert_equal "200", res.code
79
+ assert_equal "hello", res.body
80
+ end
81
+
82
+ def test_put
83
+ res = put("/widgets/1")
84
+ assert_equal "200", res.code
85
+ assert_equal "put 1", res.body
86
+ end
87
+
88
+ def test_patch
89
+ res = patch("/widgets/1")
90
+ assert_equal "200", res.code
91
+ assert_equal "patch 1", res.body
92
+ end
93
+
94
+ def test_delete
95
+ res = delete("/widgets/1")
96
+ assert_equal "200", res.code
97
+ assert_equal "delete 1", res.body
98
+ end
99
+
100
+ def test_404_unknown_path
101
+ res = get("/nowhere")
102
+ assert_equal "404", res.code
103
+ end
104
+
105
+ def test_404_method_mismatch
106
+ res = post("/", "")
107
+ assert_equal "404", res.code
108
+ end
109
+ end
@@ -0,0 +1,153 @@
1
+ require_relative "helper"
2
+
3
+ # Tep::Scheduler -- cooperative fiber scheduler. End-to-end tests
4
+ # covering both parking modes:
5
+ #
6
+ # * Time-driven (run_until_empty / Fiber.yield)
7
+ # * I/O-driven (io_wait + poll(2)) -- a fiber parks on a listening
8
+ # socket, the handler issues an outbound connect against itself
9
+ # (sphttp_connect) to make the listener readable, the scheduler's
10
+ # next tick picks up the readiness and resumes the fiber.
11
+ class TestScheduler < TepTest
12
+ # The prefork handler in /cooperate SIGSEGVs on the
13
+ # Tep::Scheduler.clear + spawn_fiber + run_until_empty path.
14
+ # Verified still failing on current spinel master (post the
15
+ # #641 wave). Minimal repro pending -- the prefork-vs-cooperative
16
+ # crossover here doesn't reproduce from a 6-class fiber probe.
17
+ # Class stays skipped so the suite's signal stays useful.
18
+ def setup
19
+ skip "Tep::Scheduler primitives SIGSEGV in prefork handlers; " \
20
+ "minimal repro pending"
21
+ super
22
+ end
23
+
24
+ app_source <<~RB
25
+ require 'sinatra'
26
+
27
+ # Fiber body must be a method call on `self` (no closure over
28
+ # locals -- see spinel's test/fiber_yield_across_method_call.rb).
29
+ # So Worker stashes the Fiber in an ivar at construction time
30
+ # with an implicit-self body `run`, and the handler reads it
31
+ # back via `fiber`. Each tick appends `name + remaining` to
32
+ # `@trail` -- per-instance ivar, no class variables (mixing
33
+ # @@cvar mutation across a Fiber yield boundary tickled a
34
+ # spinel-side crash on Linux that didn't reproduce on macOS).
35
+ class Worker
36
+ attr_accessor :name, :remaining, :trail, :fiber
37
+ def initialize(n, count)
38
+ @name = n
39
+ @remaining = count
40
+ @trail = ""
41
+ @fiber = Fiber.new { run }
42
+ end
43
+ def run
44
+ while @remaining > 0
45
+ if @trail.length > 0
46
+ @trail = @trail + ","
47
+ end
48
+ @trail = @trail + @name + @remaining.to_s
49
+ @remaining -= 1
50
+ Fiber.yield
51
+ end
52
+ end
53
+ end
54
+
55
+ get '/cooperate' do
56
+ Tep::Scheduler.clear
57
+ w1 = Worker.new("A", 3)
58
+ Tep::Scheduler.spawn_fiber(w1.fiber)
59
+ n = Tep::Scheduler.run_until_empty
60
+ "loops=" + n.to_s + " trail=" + w1.trail
61
+ end
62
+
63
+ get '/alive' do
64
+ Tep::Scheduler.clear
65
+ before = Tep::Scheduler.alive_count.to_s
66
+ Tep::Scheduler.spawn_fiber(Tep.seed_fiber)
67
+ after = Tep::Scheduler.alive_count.to_s
68
+ before + "->" + after
69
+ end
70
+
71
+ # Park a fiber on a listening socket via io_wait. From outside the
72
+ # fiber the handler kicks an outbound TCP connect against the same
73
+ # listener -- now `lfd` is read-ready (pending accept), the next
74
+ # tick's poll(2) round sees POLLIN, and resumes the fiber with
75
+ # the ready bits.
76
+ class IoWorker
77
+ attr_accessor :result, :lfd, :timeout, :fiber
78
+ def initialize(lfd, timeout)
79
+ @lfd = lfd
80
+ @timeout = timeout
81
+ @result = -1
82
+ @fiber = Fiber.new { run }
83
+ end
84
+ def run
85
+ @result = Tep::Scheduler.io_wait(@lfd, Tep::Scheduler::READ, @timeout)
86
+ end
87
+ end
88
+
89
+ get '/io_wait_ready' do
90
+ Tep::Scheduler.clear
91
+ lfd = Sock.sphttp_listen(15999, 0)
92
+ w = IoWorker.new(lfd, 3)
93
+ Tep::Scheduler.spawn_fiber(w.fiber)
94
+ cfd = Sock.sphttp_connect("127.0.0.1", 15999)
95
+ Tep::Scheduler.run_for(3)
96
+ Sock.sphttp_close(cfd)
97
+ Sock.sphttp_close(lfd)
98
+ "result=" + w.result.to_s + " cfd=" + (cfd > 0 ? "ok" : "fail")
99
+ end
100
+
101
+ get '/io_wait_timeout' do
102
+ Tep::Scheduler.clear
103
+ lfd = Sock.sphttp_listen(15998, 0)
104
+ t0 = Time.now.to_i
105
+ w = IoWorker.new(lfd, 1)
106
+ Tep::Scheduler.spawn_fiber(w.fiber)
107
+ Tep::Scheduler.run_for(2)
108
+ elapsed = Time.now.to_i - t0
109
+ Sock.sphttp_close(lfd)
110
+ "result=" + w.result.to_s + " elapsed=" + elapsed.to_s
111
+ end
112
+ RB
113
+
114
+ def test_one_fiber_drains_via_run_until_empty
115
+ res = get("/cooperate")
116
+ assert_equal "200", res.code
117
+ body = res.body
118
+ # 3 iterations of `while @remaining > 0` produce 3 yields. Each
119
+ # yield gives back control after run_until_empty's tick. After
120
+ # the third yield the next resume re-enters the while header,
121
+ # the loop exits, and the fiber body returns -- that's a 4th
122
+ # successful resume before alive? flips to false. So loops=4.
123
+ assert_match(/loops=4/, body)
124
+ assert_match(/trail=A3,A2,A1/, body)
125
+ end
126
+
127
+ def test_alive_count_changes_as_fibers_spawn
128
+ res = get("/alive")
129
+ assert_equal "200", res.code
130
+ assert_equal "0->1", res.body.strip
131
+ end
132
+
133
+ def test_io_wait_resumes_when_socket_becomes_readable
134
+ res = get("/io_wait_ready")
135
+ assert_equal "200", res.code
136
+ # READ bit is 1; the connect made the listener accept-ready, so
137
+ # the fiber should resume with result=1. cfd should be a real fd
138
+ # (> 0); if connect failed we'd see cfd=fail.
139
+ assert_match(/result=1/, res.body)
140
+ assert_match(/cfd=ok/, res.body)
141
+ end
142
+
143
+ def test_io_wait_returns_zero_on_timeout
144
+ res = get("/io_wait_timeout")
145
+ assert_equal "200", res.code
146
+ # No connect happens, so the listener never becomes readable.
147
+ # After ~1s the timeout fires and io_wait returns 0.
148
+ assert_match(/result=0/, res.body)
149
+ # 1s timeout, run_for cap 2s, allow either 1 or 2 elapsed seconds
150
+ # (poll wake-up + clock granularity).
151
+ assert_match(/elapsed=[12]/, res.body)
152
+ end
153
+ end
@@ -0,0 +1,72 @@
1
+ require_relative "helper"
2
+
3
+ # Tep::Security::Cors + Tep::Security::Headers -- before / after
4
+ # filter classes that wire into Tep.before / Tep.after.
5
+ class TestSecurity < TepTest
6
+ app_source <<~RB
7
+ require 'sinatra'
8
+
9
+ CORS = Tep::Security::Cors.new
10
+ CORS.set_origin("https://app.example.com")
11
+ CORS.set_allowed_verbs("GET,POST,DELETE,OPTIONS")
12
+ CORS.set_allowed_headers("Content-Type,X-Custom")
13
+ CORS.set_max_age(7200)
14
+ Tep.before CORS
15
+
16
+ HEADERS = Tep::Security::Headers.new
17
+ HEADERS.set_hsts(31536000) # 1 year
18
+ Tep.after HEADERS
19
+
20
+ get '/' do
21
+ "ok"
22
+ end
23
+
24
+ # Route that pre-sets X-Frame-Options before the Headers
25
+ # after-filter runs -- verifies the filter's
26
+ # `unless res.headers.key?` guard preserves handler-set values.
27
+ get '/custom_frame' do
28
+ res.headers["X-Frame-Options"] = "DENY"
29
+ "ok"
30
+ end
31
+ RB
32
+
33
+ def test_cors_origin_on_get
34
+ res = get("/")
35
+ assert_equal "200", res.code
36
+ assert_equal "https://app.example.com", res["Access-Control-Allow-Origin"]
37
+ assert_equal "Origin", res["Vary"]
38
+ end
39
+
40
+ def test_cors_preflight_returns_204
41
+ res = req(:options, "/", nil, {})
42
+ assert_equal "204", res.code
43
+ assert_equal "GET,POST,DELETE,OPTIONS", res["Access-Control-Allow-Methods"]
44
+ assert_equal "Content-Type,X-Custom", res["Access-Control-Allow-Headers"]
45
+ assert_equal "7200", res["Access-Control-Max-Age"]
46
+ assert_equal "https://app.example.com", res["Access-Control-Allow-Origin"]
47
+ end
48
+
49
+ def test_default_secure_headers_on_response
50
+ res = get("/")
51
+ assert_equal "nosniff", res["X-Content-Type-Options"]
52
+ assert_equal "SAMEORIGIN", res["X-Frame-Options"]
53
+ assert_equal "strict-origin-when-cross-origin", res["Referrer-Policy"]
54
+ assert_equal "0", res["X-XSS-Protection"]
55
+ end
56
+
57
+ def test_hsts_when_configured
58
+ res = get("/")
59
+ assert_match(/max-age=31536000/, res["Strict-Transport-Security"])
60
+ assert_match(/includeSubDomains/, res["Strict-Transport-Security"])
61
+ end
62
+
63
+ def test_handler_set_header_survives_after_filter
64
+ # The Headers after-filter only sets each header when not already
65
+ # present (`unless res.headers.key?`). /custom_frame pre-sets
66
+ # X-Frame-Options to DENY; the filter must not overwrite it back
67
+ # to SAMEORIGIN.
68
+ res = get("/custom_frame")
69
+ assert_equal "200", res.code
70
+ assert_equal "DENY", res["X-Frame-Options"]
71
+ end
72
+ end
@@ -0,0 +1,56 @@
1
+ # Smoke test for Tep::Server::Scheduled (fiber-per-connection server).
2
+ # Validates the basic HTTP request/response loop works under the
3
+ # scheduler. Concurrent-connection / slow-client cases are out of
4
+ # scope for this initial test -- they'll get their own coverage when
5
+ # the WebSocket battery lands and exercises that surface for real.
6
+ require_relative "helper"
7
+
8
+ class TestServerScheduled < TepTest
9
+ app_source <<~RB
10
+ require "sinatra"
11
+
12
+ set :scheduler, :scheduled
13
+
14
+ get "/ping" do
15
+ "pong"
16
+ end
17
+
18
+ get "/echo/:word" do
19
+ params[:word]
20
+ end
21
+
22
+ post "/upper" do
23
+ request.body.upcase
24
+ end
25
+ RB
26
+
27
+ def test_basic_get_through_scheduler
28
+ res = get("/ping")
29
+ assert_equal "200", res.code
30
+ assert_equal "pong", res.body
31
+ end
32
+
33
+ def test_path_capture_through_scheduler
34
+ res = get("/echo/hello")
35
+ assert_equal "200", res.code
36
+ assert_equal "hello", res.body
37
+ end
38
+
39
+ def test_post_body_through_scheduler
40
+ res = post("/upper", "hello world")
41
+ assert_equal "200", res.code
42
+ assert_equal "HELLO WORLD", res.body
43
+ end
44
+
45
+ def test_two_sequential_requests
46
+ # Same connection, two HTTP/1.1 keep-alive requests. The
47
+ # scheduler's per-connection-fiber must handle the second
48
+ # iteration of its keep-alive loop correctly.
49
+ r1 = get("/ping")
50
+ r2 = get("/echo/two")
51
+ assert_equal "200", r1.code
52
+ assert_equal "200", r2.code
53
+ assert_equal "pong", r1.body
54
+ assert_equal "two", r2.body
55
+ end
56
+ end
@@ -0,0 +1,59 @@
1
+ require_relative "helper"
2
+
3
+ class TestSessions < TepTest
4
+ app_source <<~RB
5
+ Tep.session_secret = "test-secret-1234567890abcdef"
6
+
7
+ get '/login' do
8
+ session["user"] = "alice"
9
+ session["plan"] = "pro"
10
+ "logged in"
11
+ end
12
+
13
+ get '/whoami' do
14
+ "user=" + session["user"] + " plan=" + session["plan"]
15
+ end
16
+
17
+ get '/no-session-write' do
18
+ "ok"
19
+ end
20
+ RB
21
+
22
+ def test_login_sets_signed_cookie
23
+ res = get("/login")
24
+ assert_equal "200", res.code
25
+ assert_match(/^tep\.session=/, res["set-cookie"])
26
+ assert_match(/HttpOnly/, res["set-cookie"])
27
+ assert_match(/SameSite=Lax/, res["set-cookie"])
28
+ end
29
+
30
+ def test_session_round_trip
31
+ login = get("/login")
32
+ cookie_line = login["set-cookie"]
33
+ # Extract the Cookie name=value before the first ';'.
34
+ cookie_pair = cookie_line.split(";").first
35
+ res = get("/whoami", "Cookie" => cookie_pair)
36
+ assert_equal "200", res.code
37
+ assert_equal "user=alice plan=pro", res.body
38
+ end
39
+
40
+ def test_no_write_no_set_cookie
41
+ res = get("/no-session-write")
42
+ assert_equal "200", res.code
43
+ assert_nil res["set-cookie"]
44
+ end
45
+
46
+ def test_tampered_cookie_rejected
47
+ # Take a real session cookie, corrupt the signature, and expect
48
+ # whoami to see no session data.
49
+ login = get("/login")
50
+ line = login["set-cookie"]
51
+ pair = line.split(";").first
52
+ name, val = pair.split("=", 2)
53
+ # Flip a bit in the signature (last char): should fail HMAC verify.
54
+ tampered = val[0...-1] + (val[-1] == "0" ? "1" : "0")
55
+ res = get("/whoami", "Cookie" => "#{name}=#{tampered}")
56
+ # Session is rejected -> empty values for both keys.
57
+ assert_equal "user= plan=", res.body
58
+ end
59
+ end
@@ -0,0 +1,54 @@
1
+ require_relative "helper"
2
+
3
+ # Tep::Shell -- popen + read. Lives behind FFI helpers
4
+ # (sphttp_shell_capture, sphttp_file_read); this test boots a tiny
5
+ # tep app that exercises both from inside a handler.
6
+ class TestShell < TepTest
7
+ app_source <<~RB
8
+ require 'sinatra'
9
+
10
+ get '/echo' do
11
+ Tep::Shell.run("printf hello").strip
12
+ end
13
+
14
+ get '/run_limited' do
15
+ # Cap at 3 bytes; full output is "hello" -- we should see "hel".
16
+ Tep::Shell.run_limited("printf hello", 3)
17
+ end
18
+
19
+ get '/file' do
20
+ # /etc/hosts ships on Linux + macOS + BSDs; /etc/hostname is
21
+ # Linux-only (macOS keeps the hostname in scutil instead).
22
+ Tep::Shell.read("/etc/hosts").length.to_s
23
+ end
24
+
25
+ get '/missing' do
26
+ Tep::Shell.read("/does/not/exist/at/all").length.to_s
27
+ end
28
+ RB
29
+
30
+ def test_run_captures_stdout
31
+ res = get("/echo")
32
+ assert_equal "200", res.code
33
+ assert_equal "hello", res.body.strip
34
+ end
35
+
36
+ def test_run_respects_byte_cap
37
+ res = get("/run_limited")
38
+ assert_equal "200", res.code
39
+ assert_equal "hel", res.body
40
+ end
41
+
42
+ def test_read_returns_bytes_for_real_file
43
+ res = get("/file")
44
+ assert_equal "200", res.code
45
+ # /etc/hosts is always non-empty on any sane Unix.
46
+ assert_operator res.body.strip.to_i, :>=, 1
47
+ end
48
+
49
+ def test_read_returns_empty_on_missing_path
50
+ res = get("/missing")
51
+ assert_equal "200", res.code
52
+ assert_equal "0", res.body.strip
53
+ end
54
+ end