aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-hub/tests/test_availability_between_members.py
blob: 2be7c037b448bb04e508dfd412f594f39e44edf9 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
"""
Availability: what one participant can do to the others.

The three security reviews asked who can *read* what, who can impersonate whom,
and what a hostile node can forge. None of them asked what a legitimate but
misconfigured or careless member costs everyone else — and that is the question
a group platform lives or dies on, because every member is invited by someone
who trusted them and none of them is an attacker.

Written after 2026-09-11, where a member's unconfigured node was registered by
the hub as a host for a group it could not serve, and — being answered first —
made that group unopenable for everyone in it. Nothing was compromised and
nothing was forged. The group was simply gone.

Each test below is two accounts, because that is the shape the single-node
suite could not express: `_make_user` twice, a group owned by one, the other
holding whatever the defect needs. A one-member test proves a one-member
property, and every finding here needed a second person to exist at all.
"""

import base64
import time

import pytest
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey

from meshbay_common.crypto import pk_to_b64


async def _make_user(client, username: str) -> dict:
    sk_ed, sk_x = Ed25519PrivateKey.generate(), X25519PrivateKey.generate()
    pk_ed, pk_x = pk_to_b64(sk_ed.public_key()), pk_to_b64(sk_x.public_key())
    r = await client.post("/v1/users/register", json={
        "username": username,
        "email": f"{username}@example.test",
        "auth_key": base64.b64encode(b"k" * 32).decode(),
        "pk_user_ed25519": pk_ed,
        "pk_user_x25519": pk_x,
    })
    assert r.status_code == 201, r.text
    user_id = r.json()["user_id"]
    r = await client.post("/v1/users/login", json={
        "username": username,
        "auth_key": base64.b64encode(b"k" * 32).decode(),
    })
    assert r.status_code == 200, r.text
    return {"user_id": user_id, "username": username,
            "token": r.json()["access_token"], "pk_ed": pk_ed, "sk_ed": sk_ed}


async def _announce_node(client, user: dict) -> str:
    ts = int(time.time())
    msg = f"meshbay:node_announce:{user['user_id']}:{user['pk_ed']}:{ts}".encode()
    r = await client.post("/v1/nodes/announce", json={
        "pk_node": user["pk_ed"], "endpoint_hint": "test", "timestamp": ts,
        "signature": base64.b64encode(user["sk_ed"].sign(msg)).decode(),
    }, headers={"Authorization": f"Bearer {user['token']}"})
    assert r.status_code == 201, r.text
    return r.json()["node_id"]


async def _make_group(client, owner: dict, name: str) -> str:
    r = await client.post(
        "/v1/groups",
        json={"name": name, "visibility": "private", "join_policy": "invite"},
        headers={"Authorization": f"Bearer {owner['token']}"})
    assert r.status_code == 201, r.text
    return r.json()["group_id"]


async def _add_member(client, owner: dict, group_id: str, member: dict) -> None:
    r = await client.post(
        f"/v1/groups/{group_id}/members/{member['username']}",
        headers={"Authorization": f"Bearer {owner['token']}"})
    assert r.status_code == 201, r.text


# ── A member's node must not speak for a group it does not host ──────────────

@pytest.mark.asyncio
async def test_a_members_node_cannot_notify_a_group_it_does_not_host(client):
    """
    `chat_notify` carried a `group_id` the hub believed, so any connected node
    could write a notification to every member of any group on the hub, with a
    display string of its own choosing. The node's account needed no relation
    to the group whatsoever — this is the same defect as the group claim, one
    message further along the same socket.
    """
    from meshbay_hub.api import revocation as rev

    owner = await _make_user(client, "av_owner1")
    outsider = await _make_user(client, "av_outsider1")
    owner_node = await _announce_node(client, owner)
    outsider_node = await _announce_node(client, outsider)
    group_id = await _make_group(client, owner, "not-yours")

    rev._node_groups[owner_node] = [group_id]
    rev._node_groups[outsider_node] = []          # hosts nothing, as in the incident
    try:
        await rev._handle_chat_notify(
            group_id, "Someone you do not know", outsider["user_id"],
            node_id=outsider_node)

        r = await client.get("/v1/notifications",
                             headers={"Authorization": f"Bearer {owner['token']}"})
        assert r.status_code == 200, r.text
        assert r.json()["notifications"] == [], (
            "an unrelated node wrote into this account's notifications")

        # And the node that does host it is still able to.
        await rev._handle_chat_notify(
            group_id, "A real member", outsider["user_id"], node_id=owner_node)
        r = await client.get("/v1/notifications",
                             headers={"Authorization": f"Bearer {owner['token']}"})
        assert len(r.json()["notifications"]) == 1
    finally:
        rev._node_groups.pop(owner_node, None)
        rev._node_groups.pop(outsider_node, None)


@pytest.mark.asyncio
async def test_a_member_whose_node_hosts_nothing_still_cannot_notify(client):
    """
    The incident's own shape, and the one a check on *membership* would have
    missed: the second account is a real member of the group, invited by its
    owner. Their node is simply not the one holding the files. Being entitled
    to be in a group is not being entitled to speak for it.
    """
    from meshbay_hub.api import revocation as rev

    owner = await _make_user(client, "av_owner2")
    member = await _make_user(client, "av_member2")
    owner_node = await _announce_node(client, owner)
    member_node = await _announce_node(client, member)
    group_id = await _make_group(client, owner, "shared-room")
    await _add_member(client, owner, group_id, member)

    rev._node_groups[owner_node] = [group_id]
    rev._node_groups[member_node] = []
    try:
        await rev._handle_chat_notify(
            group_id, "Sounds like a member", member["user_id"],
            node_id=member_node)
        r = await client.get("/v1/notifications",
                             headers={"Authorization": f"Bearer {owner['token']}"})
        assert r.json()["notifications"] == [], (
            "a member's empty node wrote into the group owner's notifications")
    finally:
        rev._node_groups.pop(owner_node, None)
        rev._node_groups.pop(member_node, None)


@pytest.mark.asyncio
async def test_one_node_cannot_spend_the_hubs_database_on_notifications():
    """
    Each chat_notify is a query and a write per member of the group, spawned
    without backpressure. The budget is what stops one group's node from
    costing every other group on the instance.
    """
    from meshbay_hub.api.revocation import (
        NOTIFY_BURST, _notify_budget, _notify_window)

    node = "budget-node"
    _notify_window.pop(node, None)
    try:
        assert all(_notify_budget(node) for _ in range(NOTIFY_BURST))
        assert not _notify_budget(node), "the burst was not bounded"

        # A second node is unaffected — the budget is per node, not global.
        assert _notify_budget("another-node")
    finally:
        _notify_window.pop(node, None)
        _notify_window.pop("another-node", None)


@pytest.mark.asyncio
async def test_the_notify_budget_is_not_refilled_by_reconnecting(client):
    """
    The obvious place to clear this is the socket's `finally`, beside the other
    two registries — and that would make reconnecting the way around it. The
    same node token is valid for an hour.
    """
    from meshbay_hub.api import revocation as rev

    user = await _make_user(client, "av_reconnect")
    node_id = await _announce_node(client, user)
    rev._notify_window.pop(node_id, None)
    try:
        for _ in range(rev.NOTIFY_BURST):
            rev._notify_budget(node_id)
        assert not rev._notify_budget(node_id)

        # The disconnect path itself, not a re-enactment of it: `forget_node`
        # is what the socket's `finally` calls, so adding a line there is
        # caught here.
        rev.forget_node(node_id)
        assert node_id not in rev._connected_nodes
        assert node_id not in rev._node_groups

        assert not rev._notify_budget(node_id), (
            "disconnecting refilled the budget, so reconnecting defeats it")
    finally:
        rev._notify_window.pop(node_id, None)


# ── A member must not aim other people's traffic ─────────────────────────────

@pytest.mark.asyncio
async def test_a_swarm_source_cannot_name_someone_elses_address(client):
    """
    `endpoint` was free text documented as "ip:port", so an account could
    publish a third party's address as a source for any content. Nothing dials
    a swarm source today, which is the only reason this was not already the
    reflection primitive that `notify_incoming` was fixed for (H6). A port is
    all a reader needs: where the node is comes from the node record, which is
    stamped with the address its announce arrived from.
    """
    user = await _make_user(client, "av_swarm1")
    headers = {"Authorization": f"Bearer {user['token']}"}

    for bad in ("192.0.2.7:4433", "evil.example:53", "webrtc:0", "webrtc:70000",
                "webrtc:4433 ", "http://example.test"):
        r = await client.post("/v1/swarm/register", headers=headers,
                              json={"content_hash": "ab" * 32, "endpoint": bad})
        assert r.status_code == 422, f"{bad!r} was accepted: {r.text}"

    r = await client.post("/v1/swarm/register", headers=headers,
                          json={"content_hash": "ab" * 32, "endpoint": "webrtc:19010"})
    assert r.status_code == 201, r.text


@pytest.mark.asyncio
async def test_one_account_cannot_fill_the_swarm_table(client, monkeypatch):
    """Rows are keyed (hash, account) with no cap — an invented hash each time."""
    import meshbay_hub.api.groups as groups_api
    monkeypatch.setattr(groups_api, "MAX_SWARM_HASHES_PER_ACCOUNT", 3)

    user = await _make_user(client, "av_swarm2")
    headers = {"Authorization": f"Bearer {user['token']}"}
    for i in range(3):
        r = await client.post("/v1/swarm/register", headers=headers,
                              json={"content_hash": f"{i:064x}",
                                    "endpoint": "webrtc:19010"})
        assert r.status_code == 201, r.text

    r = await client.post("/v1/swarm/register", headers=headers,
                          json={"content_hash": f"{99:064x}", "endpoint": "webrtc:19010"})
    assert r.status_code == 429, r.text

    # Refreshing one already held is not a new claim and must still work.
    r = await client.post("/v1/swarm/register", headers=headers,
                          json={"content_hash": f"{0:064x}", "endpoint": "webrtc:19011"})
    assert r.status_code == 201, r.text


# ── A member's node must not answer for another's ────────────────────────────

def test_a_node_cannot_answer_an_offer_it_was_never_sent():
    """
    `handle_webrtc_answer` resolved any pending `peer_id` from any node's
    socket. The answer is the SDP the browser then connects to, so the check is
    what keeps one node from standing in for the node a client asked for. That
    it had not happened rested on a uuid4 being unguessable.
    """
    import asyncio

    from meshbay_hub.api.signaling import (
        _answer_owner, _webrtc_answers, handle_webrtc_answer)

    loop = asyncio.new_event_loop()
    try:
        future = loop.create_future()
        _webrtc_answers["peer-1"] = future
        _answer_owner["peer-1"] = "the-node-asked-for"

        handle_webrtc_answer({"peer_id": "peer-1", "sdp": "v=0 impostor"},
                             "a-different-node")
        assert not future.done(), "another node resolved this offer"

        handle_webrtc_answer({"peer_id": "peer-1", "sdp": "v=0 genuine"},
                             "the-node-asked-for")
        assert future.done() and future.result()["sdp"] == "v=0 genuine"
    finally:
        _webrtc_answers.pop("peer-1", None)
        _answer_owner.pop("peer-1", None)
        loop.close()


# ── One account must not be able to mail another at will ─────────────────────

@pytest.mark.asyncio
async def test_an_invite_email_says_what_the_hub_knows_not_what_it_is_told(
        client, monkeypatch):
    """
    `group_name` went from the request body into the subject line of an email
    the hub sends under its own domain, and the invitee need not be a member of
    anything — as they cannot be, being invited. So any account, having made a
    group, could mail any other account arbitrary text. The name now comes from
    the group row, and the code has to look like one.
    """
    sent: list = []
    import meshbay_hub.mail as mail_mod
    monkeypatch.setattr(mail_mod, "send_invite_notification",
                        lambda *a, **kw: sent.append(a))

    owner = await _make_user(client, "av_inviter")
    await _make_user(client, "av_invitee")
    group_id = await _make_group(client, owner, "real-group-name")
    headers = {"Authorization": f"Bearer {owner['token']}"}

    r = await client.post(f"/v1/groups/{group_id}/invite-notify", headers=headers,
                          json={"username": "av_invitee", "code": "not a code",
                                "group_name": "Your account is suspended"})
    assert r.status_code == 422, r.text
    assert not sent

    r = await client.post(f"/v1/groups/{group_id}/invite-notify", headers=headers,
                          json={"username": "av_invitee", "code": "AB12-CD34",
                                "group_name": "Your account is suspended"})
    assert r.status_code == 200, r.text
    assert sent, "the legitimate path stopped working"
    assert sent[0][3] == "real-group-name", (
        "the sender chose the subject line of a message the hub signs")


# ── A relay is not authenticated by the key it publishes ─────────────────────

@pytest.mark.asyncio
async def test_a_relay_must_prove_it_holds_the_approved_key(client, monkeypatch):
    """
    `relay_register` had no `Depends` and verified nothing: it compared
    `pk_relay` against the approved value, which is a **public** key. Anyone
    who could read it could rewrite where the hub tells nodes to send relayed
    traffic — an unauthenticated write to state other people's machines act
    on. The module docstring said the relay "signs keepalive JWTs"; `jwt` was
    imported and never used.
    """
    from meshbay_hub.api import relay as relay_mod

    # The registry ships closed (`relay.RELAYS_ENABLED`); the proof it demands
    # is still what will be wanted the day it opens.
    monkeypatch.setattr(relay_mod, "RELAYS_ENABLED", True)
    sk = Ed25519PrivateKey.generate()
    pk = pk_to_b64(sk.public_key())
    relay_mod._relays["r1"] = {"pk": pk, "active": False}
    try:
        # The public key alone, which used to be enough.
        r = await client.post("/v1/relays/register", json={
            "relay_id": "r1", "endpoint": "198.51.100.9:9999",
            "pk_relay": pk, "capacity": 100})
        assert r.status_code == 400, r.text
        assert relay_mod._relays["r1"].get("endpoint") is None

        # A signature over someone else's endpoint does not carry either: the
        # endpoint is inside the signed message.
        ts = int(time.time())
        sig = sk.sign(f"meshbay:relay_register:r1:10.0.0.1:4433:{ts}".encode())
        r = await client.post("/v1/relays/register", json={
            "relay_id": "r1", "endpoint": "198.51.100.9:9999", "pk_relay": pk,
            "timestamp": ts, "signature": base64.b64encode(sig).decode()})
        assert r.status_code == 401, r.text

        endpoint = "203.0.113.4:4433"
        sig = sk.sign(f"meshbay:relay_register:r1:{endpoint}:{ts}".encode())
        r = await client.post("/v1/relays/register", json={
            "relay_id": "r1", "endpoint": endpoint, "pk_relay": pk,
            "timestamp": ts, "signature": base64.b64encode(sig).decode()})
        assert r.status_code == 201, r.text
        assert relay_mod._relays["r1"]["endpoint"] == endpoint
    finally:
        relay_mod._relays.pop("r1", None)


@pytest.mark.asyncio
async def test_every_relay_route_is_closed_as_the_hub_ships(client):
    """Nothing in the tree uses the registry, and two of its routes take no account.

    A dependency on the router, so a route added later is closed too. The flag
    is read as shipped, not set here — a test that closes the gate itself
    would keep passing the day somebody opens it.
    """
    admin = await _make_user(client, "relayadmin")
    from meshbay_hub.api.deps import set_admin_usernames
    set_admin_usernames(["relayadmin"])
    auth = {"Authorization": f"Bearer {admin['token']}"}

    for method, path in (("get", "/v1/relays"),
                         ("post", "/v1/relays/register"),
                         ("post", "/v1/relays/approve")):
        kwargs = {"headers": auth} if method == "get" else {"json": {}, "headers": auth}
        r = await getattr(client, method)(path, **kwargs)
        assert r.status_code == 503, (path, r.status_code, r.text)


@pytest.mark.asyncio
async def test_a_stranger_who_locks_your_name_does_not_sign_you_out(client, db_session):
    """AV26. The sign-in lockout is keyed by username, and usernames are public.

    So anyone can spend your attempts, and the design has to make that cost as
    little as possible: a lockout refuses *passphrase* sign-in and nothing else.
    The session you already have keeps working and keeps renewing, and a reset
    code sent to your own address ends the lockout at once.
    """
    victim_key = base64.b64encode(b"k" * 32).decode()
    r = await client.post("/v1/users/register", json={
        "username": "victim26", "email": "victim26@example.test", "auth_key": victim_key})
    assert r.status_code == 201, r.text
    session = (await client.post("/v1/users/login", json={
        "username": "victim26", "auth_key": victim_key})).json()

    # The stranger needs no account at all — only the name.
    for _ in range(4):
        r = await client.post("/v1/users/login", json={
            "username": "victim26", "auth_key": "guess" + "x" * 39})
        assert r.status_code == 401
    r = await client.post("/v1/users/login", json={
        "username": "victim26", "auth_key": victim_key})
    assert r.status_code == 429, r.text

    # Still signed in, and still able to stay signed in.
    auth = {"Authorization": f"Bearer {session['access_token']}"}
    assert (await client.get("/v1/users/me", headers=auth)).status_code == 200
    r = await client.post("/v1/users/token/refresh",
                          json={"refresh_token": session["refresh_token"]})
    assert r.status_code == 200, r.text

    # The way out that needs nobody's help: a code to the address on file.
    from meshbay_hub.db.models import EmailVerification, User
    from sqlalchemy import select
    r = await client.post("/v1/users/password/reset-request", json={
        "username": "victim26", "email": "victim26@example.test"})
    assert r.status_code == 200, r.text
    uid = (await db_session.execute(
        select(User.id).where(User.username == "victim26"))).scalar_one()
    code = (await db_session.execute(select(EmailVerification.code).where(
        EmailVerification.user_id == uid,
        EmailVerification.purpose == "password_reset"))).scalar_one()
    new_key = base64.b64encode(b"n" * 32).decode()
    r = await client.post("/v1/users/password/reset", json={
        "username": "victim26", "code": code, "new_auth_key": new_key})
    assert r.status_code == 200, r.text
    r = await client.post("/v1/users/login", json={
        "username": "victim26", "auth_key": new_key})
    assert r.status_code == 200, r.text


@pytest.mark.asyncio
async def test_a_captured_relay_registration_is_not_replayable(client, monkeypatch):
    """Same reason /v1/nodes/announce bounds its timestamp."""
    from meshbay_hub.api import relay as relay_mod

    monkeypatch.setattr(relay_mod, "RELAYS_ENABLED", True)
    sk = Ed25519PrivateKey.generate()
    pk = pk_to_b64(sk.public_key())
    relay_mod._relays["r2"] = {"pk": pk, "active": False}
    try:
        ts = int(time.time()) - 3600
        sig = sk.sign(f"meshbay:relay_register:r2:203.0.113.5:4433:{ts}".encode())
        r = await client.post("/v1/relays/register", json={
            "relay_id": "r2", "endpoint": "203.0.113.5:4433", "pk_relay": pk,
            "timestamp": ts, "signature": base64.b64encode(sig).decode()})
        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


# ── A node that hosts nothing is not a free target ──────────────────────────

@pytest.mark.asyncio
async def test_a_node_hosting_nothing_is_not_brokered_to_a_stranger(client):
    """
    Signaling read its membership check as `if node_group_ids:` — so when the
    set was empty, the membership check, the group-status check and the
    public-group gate were all skipped and the offer was relayed.

    Since AV1, an empty claim is the *normal* registration of a node that hosts
    nothing: the unconfigured node left running, which is the machine in this
    register's founding incident. Each offer makes it allocate an
    `RTCPeerConnection` and gather ICE, which is finding H6 restored in exactly
    the case AV1 made common — and the stranger paying nothing for it.
    """
    from meshbay_hub.api import revocation as rev

    owner = await _make_user(client, "av_sig_owner")
    stranger = await _make_user(client, "av_sig_stranger")
    node_id = await _announce_node(client, owner)

    class _FakeWS:
        def __init__(self):
            self.sent = []

        async def send_text(self, text):
            self.sent.append(text)

    ws = _FakeWS()
    rev._connected_nodes[node_id] = ws
    rev._node_groups[node_id] = []          # hosts nothing, as in the incident
    try:
        r = await client.post(f"/v1/nodes/{node_id}/webrtc/offer",
                              json={"sdp": "v=0\r\noffer", "ice_candidates": []},
                              headers={"Authorization": f"Bearer {stranger['token']}"})
        assert r.status_code == 403, r.text
        assert not ws.sent, (
            "the node was made to negotiate for someone with no group on it")
    finally:
        rev._connected_nodes.pop(node_id, None)
        rev._node_groups.pop(node_id, None)


@pytest.mark.asyncio
async def test_a_member_still_reaches_the_node_they_share_a_group_with(client):
    """The other half, so the test above is about the claim and not about
    refusing everyone."""
    from meshbay_hub.api import revocation as rev

    owner = await _make_user(client, "av_sig_owner2")
    member = await _make_user(client, "av_sig_member2")
    group_id = await _make_group(client, owner, "shared-one")
    await _add_member(client, owner, group_id, member)
    node_id = await _announce_node(client, owner)

    answered = []

    class _AnsweringWS:
        async def send_text(self, text):
            import json as _json
            from meshbay_hub.api.signaling import handle_webrtc_answer
            msg = _json.loads(text)
            answered.append(msg)
            handle_webrtc_answer({"peer_id": msg["peer_id"], "sdp": "v=0\r\nanswer",
                                  "ice_candidates": []}, node_id)

    rev._connected_nodes[node_id] = _AnsweringWS()
    rev._node_groups[node_id] = [group_id]
    try:
        r = await client.post(f"/v1/nodes/{node_id}/webrtc/offer",
                              json={"sdp": "v=0\r\noffer", "ice_candidates": []},
                              headers={"Authorization": f"Bearer {member['token']}"})
        assert r.status_code == 200, r.text
        assert answered
    finally:
        rev._connected_nodes.pop(node_id, None)
        rev._node_groups.pop(node_id, None)


# ── A private group's hosts are not public knowledge ────────────────────────

@pytest.mark.asyncio
async def test_a_private_groups_node_list_is_for_its_members(client):
    """
    `GET /v1/groups/{id}/nodes` checked membership only for a public group with
    public groups switched off. A private group answered any authenticated
    account that knew the id — which an ex-member knows for ever — with the ids
    and public keys of the machines hosting it.

    §7.4 already states the property for the public case: a non-member is handed
    no node to connect to. This is that sentence, for the groups the whole
    design optimises for.
    """
    from meshbay_hub.api import revocation as rev

    owner = await _make_user(client, "av_nodes_owner")
    member = await _make_user(client, "av_nodes_member")
    stranger = await _make_user(client, "av_nodes_stranger")
    group_id = await _make_group(client, owner, "private-hosts")
    await _add_member(client, owner, group_id, member)
    node_id = await _announce_node(client, owner)

    rev._node_groups[node_id] = [group_id]
    rev._connected_nodes[node_id] = object()
    try:
        for who in (owner, member):
            r = await client.get(
                f"/v1/groups/{group_id}/nodes",
                headers={"Authorization": f"Bearer {who['token']}"})
            assert r.status_code == 200, r.text
            assert [n["node_id"] for n in r.json()["nodes"]] == [node_id]

        r = await client.get(
            f"/v1/groups/{group_id}/nodes",
            headers={"Authorization": f"Bearer {stranger['token']}"})
        assert r.status_code == 403, (
            "a stranger who knows the group id learned which machines host it: "
            + r.text)
    finally:
        rev._connected_nodes.pop(node_id, None)
        rev._node_groups.pop(node_id, None)


async def _announce_key(client, user: dict, sk) -> int:
    """Announce a *distinct* node key, and return the status code."""
    from meshbay_common.crypto import pk_to_b64

    pk = pk_to_b64(sk.public_key())
    ts = int(time.time())
    msg = f"meshbay:node_announce:{user['user_id']}:{pk}:{ts}".encode()
    r = await client.post("/v1/nodes/announce", json={
        "pk_node": pk, "endpoint_hint": "test", "timestamp": ts,
        "signature": base64.b64encode(sk.sign(msg)).decode(),
    }, headers={"Authorization": f"Bearer {user['token']}"})
    return r.status_code


async def test_one_account_cannot_announce_unlimited_nodes(client, monkeypatch):
    """
    Each new node key is a row in `nodes` and a row in the IP log, and the IP log
    is kept for a year. Proof of possession (M8) settles *whose* key it is and
    says nothing about how many: an account in a loop wrote a year of storage on
    the operator's disk having paid only for signatures.

    Two accounts, because the ceiling has to be per account. One that is shared
    would let a single member deny every other member the ability to bring a
    machine online, which is the same defect with better manners.
    """
    from meshbay_hub.api import nodes as nodes_api

    monkeypatch.setattr(nodes_api, "MAX_NODES_PER_ACCOUNT", 3)
    alice = await _make_user(client, "av_nodecap_alice")
    bob = await _make_user(client, "av_nodecap_bob")

    keys = [Ed25519PrivateKey.generate() for _ in range(4)]
    for sk in keys[:3]:
        assert await _announce_key(client, alice, sk) == 201

    assert await _announce_key(client, alice, keys[3]) == 409, (
        "an account announced past the ceiling")

    # Bob has announced nothing and must be unaffected.
    assert await _announce_key(client, bob, Ed25519PrivateKey.generate()) == 201, (
        "one account's ceiling was charged to another's"
    )


async def test_a_node_at_the_ceiling_can_still_refresh_its_address(client, monkeypatch):
    """
    The ceiling counts rows, so it must be checked only where a row is added.
    Applied to every announce, it would freeze the address of every node an
    account already runs the moment it reached the limit — and a node that
    cannot re-announce is a node nobody can reach after their ISP renumbers
    them, which is an outage caused by the protection.
    """
    from meshbay_hub.api import nodes as nodes_api

    monkeypatch.setattr(nodes_api, "MAX_NODES_PER_ACCOUNT", 2)
    alice = await _make_user(client, "av_nodecap_refresh")

    keys = [Ed25519PrivateKey.generate() for _ in range(2)]
    for sk in keys:
        assert await _announce_key(client, alice, sk) == 201
    assert await _announce_key(client, alice, Ed25519PrivateKey.generate()) == 409

    for sk in keys:
        assert await _announce_key(client, alice, sk) == 201, (
            "a node already known could not re-announce at the ceiling")