aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_availability_between_members.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-12 10:08:36 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-12 16:36:54 +0200
commit02f061ee2c1824734bf63c91d39b47848926f59c (patch)
tree8c3261860876b73fa2eec82a4b648ded2873261b /packages/meshbay-hub/tests/test_availability_between_members.py
parent7c3a1d6fd765ef0421d0f3d85e512e10ea2f6f87 (diff)
downloadmeshbay-02f061ee2c1824734bf63c91d39b47848926f59c.tar.gz
fix(hub): no mail from the event loop, and a ceiling on every path that sends it
`users.py` and `admin.py` under the availability lens. `admin.py` needed nothing — its moderator/admin line is drawn explicitly, self-modification is refused, and every list it serves is bounded. `users.py` had four findings and one of them is the worst of this whole pass. AV9 `mail._send` is `smtplib` with a ten-second timeout, called straight from four async handlers. That wait is not one request's, it is the instance's: nothing else served, no node socket read, no WebRTC offer relayed, until the MTA answers. Reachable by any signed-in user at request rate through the endpoint below. It has no symptom a test catches — everything simply works slowly, for everyone, whenever the mail server is having a bad day. AV10 `PATCH /v1/users/me` is the third path that makes the hub send mail and the only one with neither a rate limit nor a captcha, while `register` and `password/reset-request` have both. The address is any string the caller types and the duplicate check only rejects one already held by an account here, so every address *not* registered on this hub was a valid target: a relay for verification codes with the hub's own reputation attached. A rate limit counting by IP bounds a caller and not an inbox, so the floor under it is a cooldown per account — the same for a reset request, whose cost also lands in a mailbox that is not the asker's. AV11 `default_tab:` accepted any suffix on a `{key:path}` route with an unbounded Text value and no cap on rows: one account could write without limit into a table shared with everyone. The suffix is a group id, which is what the SPA writes, so it is checked as one. A key over 64 characters was also a 500 rather than a 400 — the column is String(64), which PostgreSQL enforces and SQLite does not, so it would have appeared in production and in no test. AV12 `/v1/notifications` and `/v1/groups` had no upper bound on `limit` and no floor under `offset`, while every list in `admin.py` carries `le=200`. The group directory takes no authentication at all. Two shapes recur and are now named in §13.5b: a limit written on one of several equivalent paths, and a bound that counts the wrong thing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4YmK41VsEURWFdop4EEeT
Diffstat (limited to 'packages/meshbay-hub/tests/test_availability_between_members.py')
-rw-r--r--packages/meshbay-hub/tests/test_availability_between_members.py132
1 files changed, 132 insertions, 0 deletions
diff --git a/packages/meshbay-hub/tests/test_availability_between_members.py b/packages/meshbay-hub/tests/test_availability_between_members.py
index f2c9a4f..282169d 100644
--- a/packages/meshbay-hub/tests/test_availability_between_members.py
+++ b/packages/meshbay-hub/tests/test_availability_between_members.py
@@ -386,3 +386,135 @@ async def test_a_captured_relay_registration_is_not_replayable(client):
assert r.status_code == 401, r.text
finally:
relay_mod._relays.pop("r2", None)
+
+
+# ── Mail: three paths out of the hub, one of them unmetered ──────────────────
+
+@pytest.mark.asyncio
+async def test_changing_your_address_cannot_mail_strangers_at_will(
+ client, monkeypatch):
+ """
+ `PATCH /v1/users/me` is the third path that makes the hub send mail, and
+ it was the one with no rate limit and no captcha — while `register` and
+ `password/reset-request` have both. The address is any string the caller
+ types, and the duplicate check only rejects one already held by an account
+ here, so every address *not* registered on this hub was a valid target.
+ """
+ sent: list = []
+ import meshbay_hub.mail as mail_mod
+ monkeypatch.setattr(mail_mod, "send_email_change_code",
+ lambda *a, **kw: sent.append(a))
+
+ user = await _make_user(client, "av_mailer")
+ headers = {"Authorization": f"Bearer {user['token']}"}
+
+ r = await client.patch("/v1/users/me", headers=headers,
+ json={"email": "a-stranger@example.test"})
+ assert r.status_code == 200, r.text
+ assert len(sent) == 1
+
+ r = await client.patch("/v1/users/me", headers=headers,
+ json={"email": "another-stranger@example.test"})
+ assert r.status_code == 429, r.text
+ assert len(sent) == 1, "the hub mailed a second stranger on demand"
+
+
+@pytest.mark.asyncio
+async def test_a_reset_mail_lands_once_per_account_per_window(client, monkeypatch):
+ """Knowing the username/email pair is the hard part, and this endpoint is
+ careful about it. Once someone does, the cost of repeating lands in a
+ mailbox that is not theirs — and the rate limit above counts by IP."""
+ sent: list = []
+ import meshbay_hub.mail as mail_mod
+ monkeypatch.setattr(mail_mod, "send_password_reset_code",
+ lambda *a, **kw: sent.append(a))
+
+ user = await _make_user(client, "av_resettee")
+ body = {"username": user["username"], "email": "av_resettee@example.test"}
+
+ for _ in range(3):
+ r = await client.post("/v1/users/password/reset-request", json=body)
+ assert r.status_code == 200, r.text
+ assert len(sent) == 1, f"{len(sent)} reset mails for one account in one window"
+
+
+def test_no_mail_is_sent_from_the_event_loop():
+ """
+ `smtplib` is synchronous and waits up to ten seconds. Called straight from
+ an async handler — which is what all four call sites did — that wait is not
+ one request's, it is the whole hub's: nothing else is served, no node
+ socket is read, no offer relayed, until the MTA answers.
+
+ Read from the source because the failure has no symptom a test can catch:
+ everything works, slowly, for everyone, whenever the mail server is having
+ a bad day.
+ """
+ import pathlib
+ import re as _re
+
+ root = pathlib.Path(__file__).resolve().parents[1] / "src" / "meshbay_hub"
+ # A direct *call* — `mail.send_x(`. A bare `mail.send_x` with no paren is
+ # the function being handed to send_off_loop, which is the point. The
+ # first version of this matched those continuation lines and so failed on
+ # the fixed code: look for the call, not for the name.
+ direct_call = _re.compile(r"\bmail\.send_(?!off_loop)\w+\s*\(")
+ offenders = []
+ for path in root.rglob("*.py"):
+ if path.name == "mail.py":
+ continue
+ for n, line in enumerate(path.read_text().splitlines(), 1):
+ if direct_call.search(line):
+ offenders.append(f"{path.name}:{n}: {line.strip()}")
+ assert not offenders, (
+ "these call a blocking SMTP send directly; use mail.send_off_loop:\n"
+ + "\n".join(offenders))
+
+
+# ── One account's rows are not the whole table ───────────────────────────────
+
+@pytest.mark.asyncio
+async def test_the_preference_namespace_is_not_open(client):
+ """
+ `default_tab:` accepted any suffix, on a `{key:path}` route, with an
+ unbounded Text value: one account could write unbounded rows into a table
+ shared with everyone. The suffix is a group id — that is what the SPA
+ writes — so it is checked as one.
+ """
+ user = await _make_user(client, "av_prefs")
+ headers = {"Authorization": f"Bearer {user['token']}"}
+ gid = await _make_group(client, user, "prefs-group")
+
+ r = await client.put(f"/v1/users/me/preferences/default_tab:{gid}",
+ headers=headers, json={"value": "files"})
+ assert r.status_code == 200, r.text
+
+ for bad in ("default_tab:" + "x" * 300, "default_tab:not-a-uuid",
+ "default_tab:", "default_tab:../../etc"):
+ r = await client.put(f"/v1/users/me/preferences/{bad}",
+ headers=headers, json={"value": "files"})
+ assert r.status_code == 400, f"{bad!r} was accepted: {r.text}"
+
+ r = await client.put(f"/v1/users/me/preferences/default_tab:{gid}",
+ headers=headers, json={"value": "f" * 5000})
+ assert r.status_code == 422, r.text
+
+
+@pytest.mark.asyncio
+async def test_a_list_cannot_be_asked_for_the_whole_table(client):
+ """Every list in admin.py carries `le=200`. These two did not — and the
+ public group directory takes no authentication at all."""
+ user = await _make_user(client, "av_lister")
+ headers = {"Authorization": f"Bearer {user['token']}"}
+
+ r = await client.get("/v1/notifications?limit=1000000", headers=headers)
+ assert r.status_code == 422, r.text
+ r = await client.get("/v1/notifications?limit=-1", headers=headers)
+ assert r.status_code == 422, r.text
+
+ r = await client.get("/v1/groups?limit=1000000")
+ assert r.status_code == 422, r.text
+ r = await client.get("/v1/groups?offset=-5")
+ assert r.status_code == 422, r.text
+
+ r = await client.get("/v1/notifications?limit=20", headers=headers)
+ assert r.status_code == 200, r.text