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
|
"""
The hub can send mail from its own domain, to anywhere, with no authentication
to its local Postfix. That is an open relay unless something bounds it, and
what bounds it has to be one place, or the next `send_*` helper added will not
have it.
Three API paths reach that ability and two of them mail an address the caller
types:
POST /v1/users/register an address nobody has verified
PATCH /v1/users/me an address nobody has verified, signed in
POST /v1/users/password/reset only the address already on file
POST /v1/groups/{id}/invite-notify only a registered member's address
The widest was the register *resend* branch: no token, no captcha, and the
username and address are the caller's own from a moment ago, so registering a
victim's address once bought the right to mail them at the endpoint's rate
limit indefinitely.
The bound that matters is per **recipient**, because that is what a person
being mail-bombed experiences, and no combination of accounts, addresses or
endpoints moves it. Per-account and per-IP limits sit above it and bound a
caller, which is a different and weaker thing.
"""
import base64
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
from meshbay_hub import mail as mail_mod
from meshbay_hub.api import users as users_mod
# Captured at import, before conftest's autouse fixture replaces them for every
# other test in the suite: one stubs `_send` out entirely and the other skips
# the registration mail. This file is about the code they stub.
_REAL_SEND = mail_mod._send
_REAL_CREATE_AND_SEND = users_mod._create_and_send_verification
@pytest.fixture(autouse=True)
def _real_mail_path(monkeypatch):
"""Undo conftest's stubs, for the tests that go through the API.
The direct tests below call `_REAL_SEND` rather than `mail_mod._send`:
depending on a fixture to *un*-patch a module global is a coupling that
passes alone and fails in a full run, which is how this file first behaved.
"""
monkeypatch.setattr(mail_mod, "_send", _REAL_SEND)
monkeypatch.setattr(users_mod, "_create_and_send_verification",
_REAL_CREATE_AND_SEND)
assert mail_mod._send is _REAL_SEND
@pytest.fixture(autouse=True)
def _configured_limits():
"""What `hub.toml` says, which is what a missing settings row falls back to.
Set at startup by the lifespan; pinned here so a test that never builds an
app still measures against the shipped defaults rather than against zero.
"""
from meshbay_hub import hub_settings
from meshbay_hub.config import MailConfig
hub_settings.set_mail_defaults(MailConfig())
@pytest.fixture
def wire(monkeypatch):
"""Everything that actually reaches Postfix, captured at the socket."""
sent = []
class _FakeSMTP:
def __init__(self, *a, **kw):
pass
def __enter__(self):
return self
def __exit__(self, *a):
return False
def send_message(self, msg):
sent.append((msg["To"], msg["Subject"]))
monkeypatch.setattr(mail_mod.smtplib, "SMTP", _FakeSMTP)
return sent
def _msg(to: str):
from email.message import EmailMessage
m = EmailMessage()
m["From"] = "noreply@test"
m["To"] = to
m["Subject"] = "test"
m.set_content("test")
return m
async def _charge(db, purpose: str, address: str) -> bool:
"""One send against the allowance. True if it was granted."""
try:
await mail_mod.reserve(db, purpose, address)
except mail_mod.MailRefused:
await db.rollback()
return False
await db.commit()
return True
# ── The gate itself ──────────────────────────────────────────────────────────
def test_a_send_must_name_a_purpose(wire):
"""Keyword-required, so a helper that forgets is a TypeError rather than
an unrestricted send."""
with pytest.raises(TypeError):
_REAL_SEND(_msg("someone@example.test"))
def test_send_refuses_a_purpose_this_hub_does_not_have(wire):
"""The structural half of the gate, and the half that needs no state. It
is in `_send` so that nothing which puts a message on the wire can name a
reason this hub does not send mail for; the counting half needs a database
session and lives in `reserve`."""
assert not _REAL_SEND(_msg("b@example.test"), purpose="marketing")
assert not _REAL_SEND(_msg("c@example.test"), purpose="")
assert _REAL_SEND(_msg("a@example.test"), purpose="registration")
assert [to for to, _ in wire] == ["a@example.test"]
@pytest.mark.asyncio
async def test_reserve_refuses_a_purpose_this_hub_does_not_have(db_session):
with pytest.raises(mail_mod.MailRefused):
await mail_mod.reserve(db_session, "marketing", "a@example.test")
@pytest.mark.asyncio
async def test_one_recipient_has_a_cooldown_and_a_daily_allowance(db_session):
"""Across every purpose — the recipient is what is protected, not the
sender."""
from meshbay_hub.config import MailConfig
from meshbay_hub.db.models import MailQuota
victim = "victim@example.test"
assert await _charge(db_session, "registration", victim)
assert not await _charge(db_session, "password_reset", victim), (
"a second purpose walked around the cooldown")
cap = MailConfig().destination_daily_cap
cool = "cool:invitation:" + mail_mod.destination_key(victim)[len("dest:"):]
async def past_the_cooldown():
# Step past it without waiting it out. The daily count is deliberately
# left alone.
row = await db_session.get(MailQuota, cool)
if row:
row.last_sent = None
await db_session.commit()
for _ in range(cap - 1):
await past_the_cooldown()
assert await _charge(db_session, "invite", victim)
await past_the_cooldown()
assert not await _charge(db_session, "invite", victim), (
f"the {cap}-a-day allowance for one recipient was not enforced")
@pytest.mark.asyncio
async def test_an_invitation_does_not_stop_the_code_its_invitee_asks_for(db_session):
"""
The regression: an invitation link arrives, the invitee registers a minute
later, and the verification code was refused — silently — by the cooldown
the invitation had just armed. Nobody invited by link could sign up without
waiting two minutes and then asking again, with nothing on screen saying so.
The two are one conversation, so they do not share a cooldown; each family
still keeps its own, and the daily cap still counts both.
"""
async def charge(purpose, addr):
try:
await mail_mod.reserve(db_session, purpose, addr, sender="owner-1")
except mail_mod.MailRefused:
await db_session.rollback()
return False
await db_session.commit()
return True
for invitation in ("invite", "invite_link"):
addr = f"newcomer-{invitation}@example.test"
assert await charge(invitation, addr)
assert await charge("registration", addr), (
f"a verification code was refused after an {invitation} mail")
assert not await charge("password_reset", addr), (
"within the account family the cooldown must still hold")
assert not await charge("invite", addr), (
"a second invitation in the same burst must still wait")
@pytest.mark.asyncio
async def test_the_same_recipient_is_one_recipient_however_it_is_written(db_session):
"""Case and surrounding space are not a new person."""
assert await _charge(db_session, "registration", "Victim@Example.Test")
assert not await _charge(db_session, "registration", " victim@example.test ")
@pytest.mark.asyncio
async def test_the_instance_has_a_ceiling_no_account_can_buy_past(db_session):
"""Registration is open, so "per account" is a bound an attacker buys more
of. This one is not."""
from meshbay_hub.config import MailConfig
cfg = MailConfig()
general = cfg.hourly_budget - cfg.hourly_reserved_for_recovery
for i in range(general):
assert await _charge(db_session, "registration", f"u{i}@example.test")
assert not await _charge(db_session, "registration", "one-more@example.test")
@pytest.mark.asyncio
async def test_a_flood_of_sign_ups_cannot_lock_out_a_passphrase_reset(db_session):
"""The reserved share: someone waiting on a reset code does not wait
because strangers were registering all hour."""
from meshbay_hub.config import MailConfig
cfg = MailConfig()
general = cfg.hourly_budget - cfg.hourly_reserved_for_recovery
for i in range(general):
assert await _charge(db_session, "registration", f"flood{i}@example.test")
assert not await _charge(db_session, "registration", "another@example.test")
assert await _charge(db_session, "password_reset", "waiting@example.test"), (
"a sign-up flood spent the share kept for recovery")
assert await _charge(db_session, "invite", "invited@example.test")
@pytest.mark.asyncio
async def test_the_allowance_is_written_down_and_survives_a_restart(db_session):
"""It was two module dicts, so every deploy handed out a fresh budget —
and the hub is deployed often."""
from meshbay_hub.db.models import MailQuota
assert await _charge(db_session, "registration", "kept@example.test")
# A restart loses everything that was not written down. These were.
assert await db_session.get(MailQuota, "hour") is not None
assert await db_session.get(
MailQuota, mail_mod.destination_key("kept@example.test")) is not None
assert not await _charge(db_session, "registration", "kept@example.test")
@pytest.mark.asyncio
async def test_a_bound_an_admin_changed_is_the_one_that_applies(db_session):
"""These are settings, not constants: the hour a budget runs out is not
when anyone wants to edit a file and restart."""
from meshbay_hub import hub_settings
await hub_settings.set_raw(db_session, "mail.destination_cooldown_seconds", "0")
await hub_settings.set_raw(db_session, "mail.destination_daily_cap", "2")
await db_session.commit()
assert await _charge(db_session, "registration", "tuned@example.test")
assert await _charge(db_session, "registration", "tuned@example.test")
assert not await _charge(db_session, "registration", "tuned@example.test")
def test_a_refusal_never_names_the_address():
"""This line goes to the journal, which is not where addresses live.
Listens on the module's own logger rather than through `caplog`: the hub
configures logging when an app is built, so whether pytest's capture sees
anything depends on which tests ran first. In the full suite it saw
nothing, and the assertion that had to hold — no address in the line —
was never reached.
"""
import logging
records: list[str] = []
class _Capture(logging.Handler):
def emit(self, record):
records.append(record.getMessage())
# And the line as emitted. Level and `logging.disable` are both forced:
# attaching a handler is not enough when an earlier test has raised the
# logger's level, which is why the first version of this saw nothing in
# the full suite and everything on its own.
handler = _Capture()
previous_level, previously_disabled = mail_mod.log.level, mail_mod.log.disabled
mail_mod.log.addHandler(handler)
mail_mod.log.setLevel(logging.WARNING)
# `.disabled` as well as the level and the manager-wide switch. Something
# earlier in a full run leaves this logger disabled — pytest's own capture
# plugin toggles loggers between tests — and a disabled logger drops the
# record before any handler sees it. Alone, none of this is needed; the
# first two versions of this test passed alone and failed in the suite for
# two different reasons, which is the whole argument for not reading a
# global's state in a test without pinning it first.
mail_mod.log.disabled = False
logging.disable(logging.NOTSET)
try:
_REAL_SEND(_msg("private@example.test"), purpose="marketing")
finally:
mail_mod.log.removeHandler(handler)
mail_mod.log.setLevel(previous_level)
mail_mod.log.disabled = previously_disabled
assert records, "a refusal must leave a trace"
assert not any("private@example.test" in line for line in records)
assert any("marketing" in line for line in records), (
"the trace has to say what was refused to be worth writing")
# ── The doors, driven through the API ────────────────────────────────────────
async def _register(client, username: str, email: str, captcha=None):
sk_ed, sk_x = Ed25519PrivateKey.generate(), X25519PrivateKey.generate()
return await client.post("/v1/users/register", json={
"username": username,
"email": email,
"auth_key": base64.b64encode(b"k" * 32).decode(),
"pk_user_ed25519": pk_to_b64(sk_ed.public_key()),
"pk_user_x25519": pk_to_b64(sk_x.public_key()),
})
@pytest.mark.asyncio
async def test_the_register_resend_branch_cannot_be_hammered(client, wire):
"""
No token and no captcha reach this branch. Registering a victim's address
once used to buy the right to mail them at the endpoint's rate limit for
as long as the account stayed pending.
"""
victim = "bombing-target@example.test"
r = await _register(client, "relay_a_test", victim)
assert r.status_code == 201, r.text
assert len(wire) == 1
for _ in range(5):
r = await _register(client, "relay_a_test", victim)
assert r.status_code == 201, r.text
assert len(wire) == 1, f"{len(wire)} mails to one address from one sign-up"
async def _signed_in(client, db_session, username: str, email: str) -> dict:
"""Register, activate, sign in.
Activated by hand because this file restores the real verification mail,
which conftest stubs out for the rest of the suite — so the account is
genuinely `pending` here, exactly as it would be in production, and a
pending account cannot log in.
"""
from meshbay_hub.db.models import User
from sqlalchemy import select, update
r = await _register(client, username, email)
assert r.status_code == 201, r.text
await db_session.execute(
update(User).where(User.username == username).values(status="active"))
await db_session.commit()
assert (await db_session.execute(
select(User.status).where(User.username == username))).scalar() == "active"
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 {"Authorization": f"Bearer {r.json()['access_token']}"}
@pytest.mark.asyncio
async def test_an_account_may_point_the_hub_at_one_stranger_then_wait(
client, db_session, wire):
"""
`PATCH /v1/users/me` is the only path where a signed-in account chooses the
recipient. A short delay alone still allows one stranger per interval,
indefinitely, so the delay is long — and a second, different address
inside it is refused.
"""
headers = await _signed_in(client, db_session, "relay_b_test",
"relay_b@example.test")
before = len(wire)
r = await client.patch("/v1/users/me", headers=headers,
json={"email": "a-stranger@example.test"})
assert r.status_code == 200, r.text
r = await client.patch("/v1/users/me", headers=headers,
json={"email": "another-stranger@example.test"})
assert r.status_code == 429, r.text
assert len(wire) - before == 1, "the hub mailed a second stranger on demand"
@pytest.mark.asyncio
async def test_the_address_already_pending_may_be_asked_for_again(
client, db_session, wire):
"""
The exemption, and why it is not a hole: re-asking for a code for the
address already pending reaches no new recipient, and that recipient is
bounded by its own daily allowance regardless. Without it, a typo would
lock the account out of correcting it for the whole window.
"""
from meshbay_hub import hub_settings
from meshbay_hub.db.models import MailQuota
headers = await _signed_in(client, db_session, "relay_d_test",
"relay_d@example.test")
typo = "jean@gmial.test"
r = await client.patch("/v1/users/me", headers=headers, json={"email": typo})
assert r.status_code == 200, r.text
# The recipient's own cooldown is not what is under test here.
await hub_settings.set_raw(db_session, "mail.destination_cooldown_seconds", "0")
await db_session.commit()
row = await db_session.get(MailQuota, mail_mod.destination_key(typo))
row.last_sent = None
await db_session.commit()
before = len(wire)
r = await client.patch("/v1/users/me", headers=headers, json={"email": typo})
assert r.status_code == 200, (
"a typo locked the account out of correcting it: " + r.text)
assert len(wire) - before == 1
@pytest.mark.asyncio
async def test_the_delay_survives_the_verification_row_being_deleted(
client, db_session, wire):
"""
The handler deletes this account's unverified rows before writing a new
one, so a window counted from that table would count one, always. Which is
what the first version of this counted. It comes off the IP log.
"""
headers = await _signed_in(client, db_session, "relay_c_test",
"relay_c@example.test")
r = await client.patch("/v1/users/me", headers=headers,
json={"email": "c-first@example.test"})
assert r.status_code == 200, r.text
from meshbay_hub.db.models import EmailVerification
from sqlalchemy import delete
await db_session.execute(delete(EmailVerification))
await db_session.commit()
r = await client.patch("/v1/users/me", headers=headers,
json={"email": "c-second@example.test"})
assert r.status_code == 429, (
"the delay was counted from a table the handler empties")
# ── The operator's controls ──────────────────────────────────────────────────
async def _admin(client, db_session, username: str) -> dict:
from meshbay_hub.db.models import User
from sqlalchemy import update
headers = await _signed_in(client, db_session, username,
f"{username}@example.test")
await db_session.execute(
update(User).where(User.username == username).values(role="admin"))
await db_session.commit()
return headers
@pytest.mark.asyncio
async def test_the_panel_shows_the_bounds_their_defaults_and_the_range(
client, db_session):
"""The panel draws the fields from this, so a value it cannot store is one
the operator is told about rather than one that is silently clamped."""
headers = await _admin(client, db_session, "mailadmin1")
r = await client.get("/v1/admin/settings", headers=headers)
assert r.status_code == 200, r.text
body = r.json()
from meshbay_hub import hub_settings
assert set(body["mail"]) == set(hub_settings.MAIL_KEYS)
assert set(body["mail_defaults"]) == set(hub_settings.MAIL_KEYS)
assert body["mail_bounds"]["hourly_budget"][0] >= 1, (
"a budget of zero would stop the hub sending anything at all")
@pytest.mark.asyncio
async def test_an_admin_changes_a_bound_and_it_takes_effect(client, db_session):
headers = await _admin(client, db_session, "mailadmin2")
r = await client.patch("/v1/admin/settings", headers=headers,
json={"mail": {"destination_daily_cap": 3}})
assert r.status_code == 200, r.text
assert r.json()["mail"]["destination_daily_cap"] == 3
from meshbay_hub import hub_settings
assert (await hub_settings.mail_limits(db_session))[
"destination_daily_cap"] == 3
@pytest.mark.asyncio
async def test_a_value_outside_the_range_is_clamped_not_stored(client, db_session):
"""A number typed into a web form is not a reason to send without a bound."""
headers = await _admin(client, db_session, "mailadmin3")
r = await client.patch("/v1/admin/settings", headers=headers,
json={"mail": {"hourly_budget": 0,
"destination_daily_cap": 10 ** 9}})
assert r.status_code == 200, r.text
from meshbay_hub import hub_settings
low, _ = hub_settings.MAIL_BOUNDS["hourly_budget"]
_, high = hub_settings.MAIL_BOUNDS["destination_daily_cap"]
assert r.json()["mail"]["hourly_budget"] == low
assert r.json()["mail"]["destination_daily_cap"] == high
@pytest.mark.asyncio
async def test_an_unknown_mail_setting_is_refused(client, db_session):
headers = await _admin(client, db_session, "mailadmin4")
r = await client.patch("/v1/admin/settings", headers=headers,
json={"mail": {"send_to_everyone": 1}})
assert r.status_code == 422, r.text
@pytest.mark.asyncio
async def test_a_moderator_may_read_the_bounds_but_not_change_them(
client, db_session):
from meshbay_hub.db.models import User
from sqlalchemy import update
headers = await _signed_in(client, db_session, "mailmod_test",
"mailmod@example.test")
await db_session.execute(
update(User).where(User.username == "mailmod_test").values(role="moderator"))
await db_session.commit()
assert (await client.get("/v1/admin/settings", headers=headers)).status_code == 200
r = await client.patch("/v1/admin/settings", headers=headers,
json={"mail": {"hourly_budget": 10}})
assert r.status_code == 403, r.text
@pytest.mark.asyncio
async def test_the_operator_can_see_whether_the_hub_is_still_sending(
client, db_session, wire):
"""There was no way to see this at all: a refusal was a line in the
journal, so an instance that had stopped sending sign-up codes looked,
from the panel, exactly like one with no sign-ups."""
headers = await _admin(client, db_session, "mailadmin5")
r = await client.get("/v1/admin/mail", headers=headers)
assert r.status_code == 200, r.text
before = r.json()
# A delta, not an absolute: creating the admin above signed somebody up,
# which sent a verification code and spent one of the hour's allowance.
assert before["general_remaining"] < before["recovery_remaining"], (
"the reserved share must be visible as the difference between the two")
assert await _charge(db_session, "registration", "seen@example.test")
after = (await client.get("/v1/admin/mail", headers=headers)).json()
assert after["hourly_used"] == before["hourly_used"] + 1
assert after["general_remaining"] == before["general_remaining"] - 1
assert after["recipients_tracked"] > before["recipients_tracked"]
def test_the_hub_refuses_to_start_with_more_than_one_worker():
"""The node registry and the signaling futures are per-process dicts, so a
second worker makes a node intermittently unreachable for half its
members — a symptom that describes something else entirely."""
from meshbay_hub.daemon import single_worker_or_exit
single_worker_or_exit(1)
for workers in (0, 2, 8):
with pytest.raises(SystemExit) as exit_:
single_worker_or_exit(workers)
assert exit_.value.code == 2
# ── The operator is told, not left to notice ─────────────────────────────────
@pytest.mark.asyncio
async def test_spending_the_sign_up_share_notifies_the_administrators(
client, db_session):
"""
A refusal is otherwise a line in the journal. A hub that has stopped
sending sign-up codes looks, from every screen anyone opens, exactly like
one nobody is signing up to.
"""
from meshbay_hub.config import MailConfig
headers = await _admin(client, db_session, "mailwatcher")
await client.delete("/v1/notifications", headers=headers)
cfg = MailConfig()
general = cfg.hourly_budget - cfg.hourly_reserved_for_recovery
for i in range(general):
await _charge(db_session, "registration", f"ceiling{i}@example.test")
assert not await _charge(db_session, "registration", "over@example.test")
r = await client.get("/v1/notifications", headers=headers)
assert r.status_code == 200, r.text
kinds = [n["kind"] for n in r.json()["notifications"]]
assert "mail_budget_general" in kinds, (
"the sign-up ceiling fell and nobody was told")
assert "mail_budget_all" not in kinds, (
"recovery still has its share; saying otherwise would be alarming and "
"wrong")
@pytest.mark.asyncio
async def test_the_administrators_are_told_once_an_hour_not_once_a_refusal(
client, db_session):
"""A flood is what spends the budget, so a notification per refusal would
bury the one that matters under the thing that caused it."""
from meshbay_hub.config import MailConfig
headers = await _admin(client, db_session, "mailwatcher2")
await client.delete("/v1/notifications", headers=headers)
cfg = MailConfig()
general = cfg.hourly_budget - cfg.hourly_reserved_for_recovery
for i in range(general):
await _charge(db_session, "registration", f"burst{i}@example.test")
for i in range(5):
assert not await _charge(db_session, "registration", f"over{i}@example.test")
r = await client.get("/v1/notifications", headers=headers)
general_alerts = [n for n in r.json()["notifications"]
if n["kind"] == "mail_budget_general"]
assert len(general_alerts) == 1, f"{len(general_alerts)} alerts for one hour"
@pytest.mark.asyncio
async def test_the_panel_says_which_ceiling_has_fallen(client, db_session):
"""Two states, and the difference matters to whoever is reading: one means
newcomers are turned away, the other means somebody locked out of their
account cannot get back in."""
from meshbay_hub.config import MailConfig
headers = await _admin(client, db_session, "mailwatcher3")
cfg = MailConfig()
for i in range(cfg.hourly_budget - cfg.hourly_reserved_for_recovery):
await _charge(db_session, "registration", f"state{i}@example.test")
body = (await client.get("/v1/admin/mail", headers=headers)).json()
assert body["general_exhausted"] and not body["all_exhausted"]
for i in range(cfg.hourly_reserved_for_recovery):
await _charge(db_session, "password_reset", f"rec{i}@example.test")
body = (await client.get("/v1/admin/mail", headers=headers)).json()
assert body["all_exhausted"]
|