Skip to content

Commit 316542c

Browse files
authored
fix(tests): let pytest-asyncio own the suite, drop anyio markers (#6)
Test (redis) has been failing on main since the toolchain drifted, with "RuntimeError: Event loop is closed" raised from the module-scoped `client` fixture teardown. Every test passed; only the finalizer errored. Two async plugins were driving the same tests. The suite carried @pytest.mark.anyio while pyproject sets asyncio_mode = "auto", so anyio parametrized the marked tests ("[asyncio]") and ran them in loops it managed itself, while the module-scoped fixtures stayed on pytest-asyncio's loop. The Redis connection pool therefore accumulated connections opened on per-test loops that were closed by the time the module fixture tore down, and disconnecting them touched dead transports. SQLite never showed it because aiosqlite is loop-agnostic. CI instrumentation confirmed both the cause and the fix: before, the fixture setup reported no current event loop; after, setup, teardown and finalization all report the same loop. asyncio_mode = "auto" already handles every async test, so the 47 markers were redundant. Removing them leaves a single owner for the loop lifecycle. The invariant is documented in pyproject.toml so it does not regress.
1 parent 3c1353b commit 316542c

5 files changed

Lines changed: 8 additions & 52 deletions

File tree

pyproject.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ asyncio_mode = "auto"
7777
# subsequent Redis call would die with "Future attached to a different
7878
# loop". Module-scoped loops keep the fixture and its client coherent.
7979
# SQLite via aiosqlite is loop-agnostic, so this is a no-op for that backend.
80+
#
81+
# For the same reason, pytest-asyncio must be the ONLY async plugin driving
82+
# these tests: do not add @pytest.mark.anyio. anyio's plugin parametrizes marked
83+
# tests ("[asyncio]") and runs them in loops it manages itself, while the
84+
# module-scoped fixtures stay on pytest-asyncio's loop. The Redis pool then ends
85+
# up holding connections opened on loops that are already closed, and the module
86+
# teardown dies with "RuntimeError: Event loop is closed" — reproducible only in
87+
# CI. asyncio_mode = "auto" already handles every async test without a marker.
8088
asyncio_default_fixture_loop_scope = "module"
8189
asyncio_default_test_loop_scope = "module"
8290
testpaths = ["tests"]

tests/test_admin.py

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@
55
import os
66
import tempfile
77

8-
import pytest
9-
108
os.environ.setdefault("STORAGE_BACKEND", "sqlite")
119
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as _tmp_db:
1210
os.environ["SQLITE_PATH"] = _tmp_db.name
@@ -66,7 +64,6 @@ async def _load_all():
6664
await storage.close()
6765

6866

69-
@pytest.mark.anyio
7067
async def test_export_import_roundtrip():
7168
await _wipe()
7269
originals = [
@@ -94,7 +91,6 @@ async def test_export_import_roundtrip():
9491
assert restored == expected
9592

9693

97-
@pytest.mark.anyio
9894
async def test_import_skips_existing_without_overwrite():
9995
await _wipe()
10096
original = _make_paste("dup", language="go", view_count=5)
@@ -122,7 +118,6 @@ async def test_import_skips_existing_without_overwrite():
122118
await storage.close()
123119

124120

125-
@pytest.mark.anyio
126121
async def test_import_overwrite_replaces_existing():
127122
await _wipe()
128123
original = _make_paste("ow", language="go", view_count=5)
@@ -148,7 +143,6 @@ async def test_import_overwrite_replaces_existing():
148143
await storage.close()
149144

150145

151-
@pytest.mark.anyio
152146
async def test_import_ignores_blank_lines():
153147
await _wipe()
154148
p = _make_paste("blank")
@@ -160,7 +154,6 @@ async def test_import_ignores_blank_lines():
160154
assert skipped == 0
161155

162156

163-
@pytest.mark.anyio
164157
async def test_import_tolerates_unknown_fields():
165158
"""A JSONL export from a future version that adds a column must still
166159
import cleanly on an older server — unknown fields are dropped with a

tests/test_api.py

Lines changed: 0 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import os
55
import tempfile
66

7-
import pytest
87
import pytest_asyncio
98
from httpx import ASGITransport, AsyncClient
109

@@ -40,7 +39,6 @@ def _fake_paste(**kwargs):
4039
}
4140

4241

43-
@pytest.mark.anyio
4442
async def test_healthz(client):
4543
from app import __version__
4644

@@ -53,7 +51,6 @@ async def test_healthz(client):
5351
assert body["version"] == __version__
5452

5553

56-
@pytest.mark.anyio
5754
async def test_healthz_stays_200_when_storage_is_down(client, monkeypatch):
5855
"""Liveness MUST NOT depend on the storage backend — a Redis blip should
5956
not cascade into a container restart loop."""
@@ -66,14 +63,12 @@ async def boom():
6663
assert r.status_code == 200
6764

6865

69-
@pytest.mark.anyio
7066
async def test_readyz_200_when_storage_up(client):
7167
r = await client.get("/readyz")
7268
assert r.status_code == 200
7369
assert r.json()["status"] == "ok"
7470

7571

76-
@pytest.mark.anyio
7772
async def test_readyz_503_when_storage_down(client, monkeypatch):
7873
"""Readiness must flip to 503 so the ingress drains traffic until the
7974
backend recovers."""
@@ -87,7 +82,6 @@ async def boom():
8782
assert r.json()["status"] == "error"
8883

8984

90-
@pytest.mark.anyio
9185
async def test_csp_uses_nonce_not_unsafe_inline(client):
9286
"""script-src must be nonce-based, not 'unsafe-inline'. A weakening
9387
of this header would silently re-enable XSS via any injection point."""
@@ -100,7 +94,6 @@ async def test_csp_uses_nonce_not_unsafe_inline(client):
10094
assert "'unsafe-inline'" not in script_src
10195

10296

103-
@pytest.mark.anyio
10497
async def test_csp_nonce_is_per_request(client):
10598
"""Nonces must be unique per response — reuse would let an attacker
10699
who saw one nonce reuse it on later injections."""
@@ -116,7 +109,6 @@ async def test_csp_nonce_is_per_request(client):
116109
assert f'nonce="{n1}"' in r1.text
117110

118111

119-
@pytest.mark.anyio
120112
async def test_homepage_og_image_is_absolute_banner(client):
121113
"""Link unfurls need an absolute og:image. A relative URL (or the portrait
122114
logo) is what made iMessage render a huge blown-up icon."""
@@ -125,7 +117,6 @@ async def test_homepage_og_image_is_absolute_banner(client):
125117
assert 'name="twitter:card" content="summary_large_image"' in html
126118

127119

128-
@pytest.mark.anyio
129120
async def test_paste_page_omits_og_image(client):
130121
"""Paste pages carry no og:image on purpose, so unfurls fall back to the
131122
compact card instead of stretching an image into the hero slot."""
@@ -135,7 +126,6 @@ async def test_paste_page_omits_og_image(client):
135126
assert 'name="twitter:card" content="summary"' in html
136127

137128

138-
@pytest.mark.anyio
139129
async def test_paste_page_emits_sri_for_third_party_libs(client):
140130
"""Third-party scripts on the paste page carry SRI hashes so a tampered
141131
file in /static/ (or a compromised CDN if we ever front it) fails to load
@@ -146,7 +136,6 @@ async def test_paste_page_emits_sri_for_third_party_libs(client):
146136
assert 'crossorigin="anonymous"' in html
147137

148138

149-
@pytest.mark.anyio
150139
async def test_compressed_flag_round_trips(client):
151140
"""compressed is a server-transparent metadata flag — it must persist
152141
through save/load exactly as the client sent it, with no coercion."""
@@ -157,7 +146,6 @@ async def test_compressed_flag_round_trips(client):
157146
assert g.json()["compressed"] is True
158147

159148

160-
@pytest.mark.anyio
161149
async def test_kdf_field_round_trips(client):
162150
"""The kdf hint must persist exactly as sent so the viewer can run the
163151
matching KDF — derive PBKDF2 from a password meant for Argon2id and
@@ -173,22 +161,19 @@ async def test_kdf_field_round_trips(client):
173161
assert g["has_password"] is True
174162

175163

176-
@pytest.mark.anyio
177164
async def test_kdf_defaults_to_pbkdf2(client):
178165
r = await client.post("/api/v1/pastes", json=_fake_paste())
179166
g = (await client.get(f"/api/v1/pastes/{r.json()['id']}")).json()
180167
assert g["kdf"] == "pbkdf2-sha256"
181168

182169

183-
@pytest.mark.anyio
184170
async def test_kdf_rejects_unknown_value(client):
185171
"""An unknown KDF must be rejected at the validator — silently accepting
186172
it would let a malformed paste reach the storage layer."""
187173
r = await client.post("/api/v1/pastes", json=_fake_paste(kdf="scrypt"))
188174
assert r.status_code == 422
189175

190176

191-
@pytest.mark.anyio
192177
async def test_compressed_flag_defaults_to_false(client):
193178
"""Pastes created without the flag must come back with compressed=False —
194179
backwards compatibility for clients and existing pastes."""
@@ -198,7 +183,6 @@ async def test_compressed_flag_defaults_to_false(client):
198183
assert g.json()["compressed"] is False
199184

200185

201-
@pytest.mark.anyio
202186
async def test_homepage_ships_argon2_lib_with_sri(client):
203187
"""The homepage must serve the Argon2id WASM bundle so the KDF picker
204188
can switch to argon2id without a second round-trip. SRI is mandatory
@@ -213,7 +197,6 @@ async def test_homepage_ships_argon2_lib_with_sri(client):
213197
assert m, "Argon2 WASM lib should be loaded on the homepage with SRI"
214198

215199

216-
@pytest.mark.anyio
217200
async def test_password_paste_includes_argon2_lib(client):
218201
"""A password-protected paste page must ship the Argon2 lib unconditionally
219202
— the viewer doesn't know yet which KDF the paste used."""
@@ -223,7 +206,6 @@ async def test_password_paste_includes_argon2_lib(client):
223206
assert "hash-wasm-argon2" in html
224207

225208

226-
@pytest.mark.anyio
227209
async def test_non_password_paste_omits_argon2_lib(client):
228210
"""Non-password pastes never run a KDF in the browser, so the 29 KB lib
229211
must not be shipped to them."""
@@ -232,7 +214,6 @@ async def test_non_password_paste_omits_argon2_lib(client):
232214
assert "hash-wasm-argon2" not in html
233215

234216

235-
@pytest.mark.anyio
236217
async def test_csp_allows_wasm_unsafe_eval(client):
237218
"""script-src must include 'wasm-unsafe-eval' so hash-wasm can instantiate
238219
its Argon2id WebAssembly module. Without it the lib silently fails to load
@@ -243,7 +224,6 @@ async def test_csp_allows_wasm_unsafe_eval(client):
243224
assert "'unsafe-eval'" not in csp.replace("'wasm-unsafe-eval'", "")
244225

245226

246-
@pytest.mark.anyio
247227
async def test_paste_page_ships_qr_button_modal_and_lib(client):
248228
"""QR code is rendered client-side so the URL fragment (encryption key)
249229
never reaches the server. The button + modal + lib must be in the shell;
@@ -278,7 +258,6 @@ class _Req:
278258
)
279259

280260

281-
@pytest.mark.anyio
282261
async def test_create_and_get_paste(client):
283262
r = await client.post("/api/v1/pastes", json=_fake_paste())
284263
assert r.status_code == 201
@@ -291,7 +270,6 @@ async def test_create_and_get_paste(client):
291270
assert r2.json()["id"] == data["id"]
292271

293272

294-
@pytest.mark.anyio
295273
async def test_burn_after_read(client):
296274
r = await client.post("/api/v1/pastes", json=_fake_paste(burn=True))
297275
paste_id = r.json()["id"]
@@ -300,7 +278,6 @@ async def test_burn_after_read(client):
300278
assert (await client.get(f"/api/v1/pastes/{paste_id}")).status_code == 404
301279

302280

303-
@pytest.mark.anyio
304281
async def test_max_views(client):
305282
r = await client.post("/api/v1/pastes", json=_fake_paste(max_views=2))
306283
paste_id = r.json()["id"]
@@ -310,7 +287,6 @@ async def test_max_views(client):
310287
assert (await client.get(f"/api/v1/pastes/{paste_id}")).status_code == 404
311288

312289

313-
@pytest.mark.anyio
314290
async def test_delete_with_valid_token(client):
315291
r = await client.post("/api/v1/pastes", json=_fake_paste())
316292
data = r.json()
@@ -322,7 +298,6 @@ async def test_delete_with_valid_token(client):
322298
assert (await client.get(f"/api/v1/pastes/{data['id']}")).status_code == 404
323299

324300

325-
@pytest.mark.anyio
326301
async def test_delete_with_invalid_token(client):
327302
r = await client.post("/api/v1/pastes", json=_fake_paste())
328303
r2 = await client.delete(
@@ -332,7 +307,6 @@ async def test_delete_with_invalid_token(client):
332307
assert r2.status_code == 403
333308

334309

335-
@pytest.mark.anyio
336310
async def test_update_paste_replaces_ciphertext(client):
337311
"""The PUT endpoint lets the owner change the ciphertext while keeping
338312
the same id and metadata — used by the in-place edit flow."""
@@ -356,7 +330,6 @@ async def test_update_paste_replaces_ciphertext(client):
356330
assert j["has_password"] == original.json()["has_password"]
357331

358332

359-
@pytest.mark.anyio
360333
async def test_update_paste_invalid_token_returns_403(client):
361334
r = await client.post("/api/v1/pastes", json=_fake_paste())
362335
pid = r.json()["id"]
@@ -369,7 +342,6 @@ async def test_update_paste_invalid_token_returns_403(client):
369342
assert u.status_code == 403
370343

371344

372-
@pytest.mark.anyio
373345
async def test_update_unknown_paste_returns_403(client):
374346
"""Same enumeration-resistance policy as DELETE: missing-vs-wrong-token
375347
must be indistinguishable to a caller."""
@@ -382,7 +354,6 @@ async def test_update_unknown_paste_returns_403(client):
382354
assert u.status_code == 403
383355

384356

385-
@pytest.mark.anyio
386357
async def test_update_can_flip_compressed_flag(client):
387358
"""A re-encrypt path that turns compression on (or off) must be able to
388359
persist the new flag — otherwise the viewer would gunzip wrong bytes."""
@@ -401,7 +372,6 @@ async def test_update_can_flip_compressed_flag(client):
401372
assert (await client.get(f"/api/v1/pastes/{data['id']}")).json()["compressed"] is True
402373

403374

404-
@pytest.mark.anyio
405375
async def test_delete_of_unknown_paste_also_returns_403(client):
406376
"""The API must not distinguish 'paste exists with wrong token' from
407377
'paste does not exist' — otherwise an attacker can enumerate IDs by
@@ -413,7 +383,6 @@ async def test_delete_of_unknown_paste_also_returns_403(client):
413383
assert r.status_code == 403
414384

415385

416-
@pytest.mark.anyio
417386
async def test_form_delete_of_unknown_paste_also_returns_403(client):
418387
"""Same policy for the HTML form endpoint — the form is the legitimate
419388
owner path, but we still avoid leaking existence to anyone who can POST."""
@@ -424,13 +393,11 @@ async def test_form_delete_of_unknown_paste_also_returns_403(client):
424393
assert r.status_code == 403
425394

426395

427-
@pytest.mark.anyio
428396
async def test_ssrf_webhook_blocked(client):
429397
r = await client.post("/api/v1/pastes", json=_fake_paste(webhook_url="http://192.168.1.1/hook"))
430398
assert r.status_code == 400
431399

432400

433-
@pytest.mark.anyio
434401
async def test_content_too_large(client):
435402
payload = {
436403
"content": "A" * (1024 * 1024),
@@ -442,14 +409,12 @@ async def test_content_too_large(client):
442409
assert r.status_code in (400, 413)
443410

444411

445-
@pytest.mark.anyio
446412
async def test_security_txt(client):
447413
r = await client.get("/.well-known/security.txt")
448414
assert r.status_code == 200
449415
assert "Contact:" in r.text
450416

451417

452-
@pytest.mark.anyio
453418
async def test_robots_txt_points_at_sitemap(client):
454419
r = await client.get("/robots.txt")
455420
assert r.status_code == 200
@@ -458,7 +423,6 @@ async def test_robots_txt_points_at_sitemap(client):
458423
assert "/sitemap.xml" in r.text
459424

460425

461-
@pytest.mark.anyio
462426
async def test_sitemap_lists_only_the_landing_page(client):
463427
r = await client.get("/sitemap.xml")
464428
assert r.status_code == 200
@@ -467,7 +431,6 @@ async def test_sitemap_lists_only_the_landing_page(client):
467431
assert r.text.count("<loc>") == 1
468432

469433

470-
@pytest.mark.anyio
471434
async def test_paste_page_is_noindex(client):
472435
created = await client.post("/api/v1/pastes", json=_fake_paste())
473436
assert created.status_code == 201
@@ -478,7 +441,6 @@ async def test_paste_page_is_noindex(client):
478441
assert 'rel="canonical"' not in r.text
479442

480443

481-
@pytest.mark.anyio
482444
async def test_landing_page_has_description_and_canonical(client):
483445
r = await client.get("/")
484446
assert r.status_code == 200
@@ -487,7 +449,6 @@ async def test_landing_page_has_description_and_canonical(client):
487449
assert "noindex" not in r.text
488450

489451

490-
@pytest.mark.anyio
491452
async def test_id_collision_retry(client, monkeypatch):
492453
"""When the random ID generator collides, create_paste should retry
493454
up to 8 times before giving up — it must never overwrite an existing paste."""
@@ -518,7 +479,6 @@ def fake_token_urlsafe(nbytes):
518479
assert calls["n"] == 2 # proves we actually retried past the collisions
519480

520481

521-
@pytest.mark.anyio
522482
async def test_security_headers_present(client):
523483
r = await client.get("/")
524484
assert r.headers.get("x-content-type-options") == "nosniff"

0 commit comments

Comments
 (0)