diff options
Diffstat (limited to 'packages/meshbay-hub/tests/test_availability_between_members.py')
| -rw-r--r-- | packages/meshbay-hub/tests/test_availability_between_members.py | 132 |
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 |