Skip to content

Commit 466f616

Browse files
claudeadewale
authored andcommitted
Add behavioral tests for the POST run-flow and ASGI bridge
Test-only additions covering paths the follow-up commit left at the source-substring level: - RunExampleFlowTests drives the real run_example POST handler through the verification-required (asserts the data-turnstile-required attribute and that code does NOT run), missing-site-key, oversize-413, off-mode, and verify-then-set-clearance branches. The Dynamic Worker run and Turnstile siteverify call are stubbed so only handler control flow is exercised. - DynamicWorkerCodeTests asserts build_dynamic_worker_code embeds code via repr (no closing-quote breakout) and defines the entrypoint. - Bridge process_request: stub js/pyodide.ffi/workers and drive a trivial ASGI app through the non-streaming path (echoes body, status, latin-1 headers), app-exception propagation, and the new max_body_bytes 413 cap (asserts the app never runs on an oversize body). https://claude.ai/code/session_018EpcfENTg7herPR712vZeE
1 parent 0eba588 commit 466f616

2 files changed

Lines changed: 235 additions & 0 deletions

File tree

‎tests/test_main_turnstile.py‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88
from __future__ import annotations
99

10+
import asyncio
1011
import sys
1112
import time
1213
import types
@@ -192,5 +193,118 @@ def test_layout_options_bypass_cache(self):
192193
self.assertTrue(main.should_cache_get_url("https://example.dev/examples/values"))
193194

194195

196+
def post_request(body: bytes, *, env=None, headers: dict[str, str] | None = None) -> Request:
197+
raw_headers = [(b"content-type", b"application/x-www-form-urlencoded")]
198+
for key, value in (headers or {}).items():
199+
raw_headers.append((key.lower().encode(), value.encode()))
200+
201+
async def receive():
202+
return {"type": "http.request", "body": body, "more_body": False}
203+
204+
return Request(
205+
{
206+
"type": "http",
207+
"method": "POST",
208+
"path": "/examples/values",
209+
"query_string": b"",
210+
"headers": raw_headers,
211+
"env": env,
212+
},
213+
receive,
214+
)
215+
216+
217+
class RunExampleFlowTests(unittest.TestCase):
218+
"""Behavioral tests of the POST /examples/{slug} handler branches:
219+
verification-required, missing-site-key, too-large, and
220+
verify-then-set-clearance. Exercises the real run_example handler with
221+
a constructed Request; the Dynamic Worker run and Turnstile siteverify
222+
HTTP call are stubbed so only the handler's control flow is under test.
223+
"""
224+
225+
def _run(self, request):
226+
return asyncio.run(main.run_example("values", request))
227+
228+
def setUp(self):
229+
self._saved = {name: getattr(main, name) for name in ("_run_example", "_verify_turnstile")}
230+
231+
def tearDown(self):
232+
for name, value in self._saved.items():
233+
setattr(main, name, value)
234+
235+
def _stub_run(self, output="OUT"):
236+
async def _fake_run_example(request, slug, code):
237+
self._ran_code = code
238+
return output
239+
main._run_example = _fake_run_example
240+
241+
def test_verification_required_path_does_not_run_code(self):
242+
self._ran_code = None
243+
self._stub_run()
244+
env = session_env(TURNSTILE_SITE_KEY="site-key")
245+
response = self._run(post_request(b"code=print(1)", env=env))
246+
body = response.body.decode()
247+
# The retriable-challenge marker is rendered as a real attribute
248+
# (the bare string also lives in the page's static JS selector).
249+
self.assertIn('data-turnstile-required="true"', body)
250+
self.assertIn("Verification required", body)
251+
self.assertIsNone(self._ran_code, "code must not run before verification")
252+
253+
def test_missing_site_key_message(self):
254+
self._stub_run()
255+
env = session_env() # secret set, but no TURNSTILE_SITE_KEY
256+
response = self._run(post_request(b"code=print(1)", env=env))
257+
body = response.body.decode()
258+
self.assertIn("TURNSTILE_SITE_KEY is not configured", body)
259+
self.assertNotIn('data-turnstile-required="true"', body)
260+
261+
def test_oversize_body_rejected_with_413(self):
262+
self._stub_run()
263+
big = b"code=" + b"x" * (main.MAX_SUBMITTED_BODY_BYTES + 1)
264+
response = self._run(post_request(big, env=session_env(TURNSTILE_CHALLENGE_MODE="off")))
265+
self.assertEqual(response.status_code, 413)
266+
self.assertIn("too large", response.body.decode().lower())
267+
268+
def test_off_mode_runs_without_challenge(self):
269+
self._ran_code = None
270+
self._stub_run(output="ran-output")
271+
response = self._run(
272+
post_request(b"code=print(42)", env=session_env(TURNSTILE_CHALLENGE_MODE="off"))
273+
)
274+
self.assertEqual(self._ran_code, "print(42)")
275+
self.assertIn("ran-output", response.body.decode())
276+
277+
def test_verified_token_sets_clearance_cookie(self):
278+
self._ran_code = None
279+
self._stub_run(output="verified-run")
280+
281+
async def _fake_verify(request, token):
282+
return True, "ok", "success"
283+
284+
main._verify_turnstile = _fake_verify
285+
env = session_env(TURNSTILE_SITE_KEY="site-key")
286+
response = self._run(post_request(b"code=print(1)&cf-turnstile-response=tok", env=env))
287+
self.assertEqual(self._ran_code, "print(1)")
288+
set_cookie = response.headers.get("set-cookie", "")
289+
self.assertIn(f"{main.TURNSTILE_CLEARANCE_COOKIE}=", set_cookie)
290+
291+
292+
class DynamicWorkerCodeTests(unittest.TestCase):
293+
"""Behavioral coverage of build_dynamic_worker_code: the generated
294+
module embeds the submitted code as a string literal (so a
295+
closing-quote injection cannot break out) and defines the entrypoint.
296+
"""
297+
298+
def test_generated_module_embeds_code_and_entrypoint(self):
299+
from app import build_dynamic_worker_code
300+
301+
module = build_dynamic_worker_code("print('hi <b>')")
302+
self.assertIn(repr("print('hi <b>')"), module)
303+
self.assertIn("class Default(WorkerEntrypoint)", module)
304+
self.assertIn("async def fetch", module)
305+
tricky = build_dynamic_worker_code("x = '''end'''")
306+
self.assertIn(repr("x = '''end'''"), tricky)
307+
308+
195309
if __name__ == "__main__":
196310
unittest.main()

‎tests/test_worker_asgi_bridge_scope.py‎

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncio
12
import importlib
23
import pathlib
34
import sys
@@ -23,6 +24,7 @@ class Request:
2324

2425
workers.Request = Request
2526
workers.Context = object
27+
workers.wait_until = lambda task: None
2628
sys.modules["workers"] = workers
2729
self.Request = Request
2830

@@ -38,8 +40,48 @@ def new(cls, value):
3840
search=f"?{parsed.query}" if parsed.query else "",
3941
)
4042

43+
# Minimal JS-runtime fakes used by process_request's non-streaming path.
44+
class Response:
45+
def __init__(self, body, headers, status):
46+
self.body = body
47+
self.headers = headers
48+
self.status = status
49+
50+
@classmethod
51+
def new(cls, body, *, headers=None, status=200):
52+
return cls(body, headers, status)
53+
54+
class Object:
55+
@staticmethod
56+
def fromEntries(pairs):
57+
return dict(pairs)
58+
4159
js.URL = URL
60+
js.Response = Response
61+
js.Object = Object
62+
js.TransformStream = object # only referenced on the streaming path
4263
sys.modules["js"] = js
64+
65+
ffi = types.ModuleType("pyodide.ffi")
66+
67+
class _Proxy:
68+
def __init__(self, obj):
69+
self._obj = obj
70+
71+
def getBuffer(self):
72+
return SimpleNamespace(data=self._obj)
73+
74+
def destroy(self):
75+
pass
76+
77+
ffi.create_proxy = _Proxy
78+
self.saved_pyodide = sys.modules.get("pyodide")
79+
self.saved_pyodide_ffi = sys.modules.get("pyodide.ffi")
80+
pyodide = sys.modules.get("pyodide") or types.ModuleType("pyodide")
81+
pyodide.ffi = ffi
82+
sys.modules["pyodide"] = pyodide
83+
sys.modules["pyodide.ffi"] = ffi
84+
4385
self.bridge = importlib.import_module("src.worker_asgi_bridge")
4486

4587
def tearDown(self):
@@ -54,6 +96,85 @@ def tearDown(self):
5496
sys.modules.pop("js", None)
5597
else:
5698
sys.modules["js"] = self.saved_js
99+
for name, saved in (("pyodide", self.saved_pyodide), ("pyodide.ffi", self.saved_pyodide_ffi)):
100+
if saved is None:
101+
sys.modules.pop(name, None)
102+
else:
103+
sys.modules[name] = saved
104+
105+
def _fake_request(self, *, method="POST", url, headers, body_chunks):
106+
async def body_iter():
107+
for chunk in body_chunks:
108+
yield SimpleNamespace(to_bytes=lambda c=chunk: c)
109+
110+
class FakeRequest(self.Request):
111+
pass
112+
113+
req = FakeRequest()
114+
req.method = method
115+
req.url = url
116+
req.headers = headers
117+
req.body = body_iter() if body_chunks else None
118+
return req
119+
120+
def test_process_request_non_streaming_echoes_body_and_status(self):
121+
async def app(scope, receive, send):
122+
body = b""
123+
while True:
124+
message = await receive()
125+
body += message.get("body", b"")
126+
if not message.get("more_body"):
127+
break
128+
await send(
129+
{
130+
"type": "http.response.start",
131+
"status": 201,
132+
"headers": [(b"content-type", b"text/plain"), (b"x-trace", b"abc")],
133+
}
134+
)
135+
await send({"type": "http.response.body", "body": b"echo:" + body, "more_body": False})
136+
137+
req = self._fake_request(
138+
url="https://x.dev/examples/values",
139+
headers={"content-type": "text/plain"},
140+
body_chunks=[b"pi", b"ng"],
141+
)
142+
response = asyncio.run(
143+
self.bridge.process_request(app, req, SimpleNamespace(), None, state={})
144+
)
145+
self.assertEqual(response.status, 201)
146+
self.assertEqual(response.body, b"echo:ping")
147+
self.assertEqual(response.headers.get("content-type"), "text/plain")
148+
self.assertEqual(response.headers.get("x-trace"), "abc")
149+
150+
def test_process_request_propagates_app_exception(self):
151+
async def app(scope, receive, send):
152+
raise RuntimeError("boom in app")
153+
154+
req = self._fake_request(
155+
url="https://x.dev/examples/values",
156+
headers={"content-type": "text/plain"},
157+
body_chunks=[],
158+
)
159+
with self.assertRaises(RuntimeError):
160+
asyncio.run(self.bridge.process_request(app, req, SimpleNamespace(), None, state={}))
161+
162+
def test_process_request_rejects_oversize_body_before_running_app(self):
163+
ran = {"app": False}
164+
165+
async def app(scope, receive, send): # pragma: no cover - must not run
166+
ran["app"] = True
167+
168+
req = self._fake_request(
169+
url="https://x.dev/examples/values",
170+
headers={"content-type": "text/plain"},
171+
body_chunks=[b"toolong"],
172+
)
173+
response = asyncio.run(
174+
self.bridge.process_request(app, req, SimpleNamespace(), None, state={}, max_body_bytes=3)
175+
)
176+
self.assertEqual(response.status, 413)
177+
self.assertFalse(ran["app"], "oversize body must be rejected before the app runs")
57178

58179
def test_request_to_scope_accepts_state_without_request_globals_or_extra_scope(self):
59180
class FakeRequest(self.Request):

0 commit comments

Comments
 (0)