Three operator gaps, all found the same way — a person could not sign up and the
app showed nobody anything.
- DELETE /api/admin/invites/{id} with a delete control on both the pending
requests and the previously-decided list. Deny keeps the row so the person
cannot immediately re-ask; delete forgets the address. Refused with 409 while
a User row exists for it, because is_allowed reads that row and removing it
would revoke a live user's access with no other sign — the list ships
has_account so the UI withholds the button instead of offering one that fails.
- Email is fail-soft by design, which had come to mean invisible. _send() now
records an email_undelivered notification for every admin when a message is
skipped or rejected, naming the message that was lost, throttled to one an
hour. /api/me carries email_configured, driving a standing warning on
Configuration → Email / SMTP and above the access-request list.
- The sign-in card's Create account / Forgot password / Didn't get the email
views are history entries (useHistorySubview), so Back returns to the previous
view instead of only moving the address bar.
Also: the per-test rate-limiter reset sweeps every loaded app.* module rather
than a hand-kept list of three — this change added a limiter outside it.
107 lines
4.2 KiB
Python
107 lines
4.2 KiB
Python
"""Deleting a whitelist / access-request entry (admin).
|
|
|
|
Deny keeps the row so a refused person cannot immediately re-ask; delete forgets the address
|
|
outright, for an approval added by mistake or one that is no longer wanted. The one hard rule is
|
|
that an entry backed by an account is NOT deletable here: `is_allowed` reads this row, so removing
|
|
it would silently revoke a live user's access.
|
|
"""
|
|
|
|
from sqlalchemy import select
|
|
|
|
from app.models import Invite, User
|
|
|
|
|
|
def _invites(client):
|
|
r = client.get("/api/admin/invites")
|
|
assert r.status_code == 200
|
|
return {i["email"]: i for i in r.json()}
|
|
|
|
|
|
def test_admin_deletes_a_whitelist_entry_that_never_became_an_account(db, make_user, as_user):
|
|
admin = make_user(email="admin@example.com", role="admin")
|
|
db.add(Invite(email="mistake@example.com", status="approved", decided_by=admin.email))
|
|
db.commit()
|
|
client = as_user(admin)
|
|
listed = _invites(client)["mistake@example.com"]
|
|
assert listed["has_account"] is False # drives the delete affordance in the UI
|
|
|
|
r = client.delete(f"/api/admin/invites/{listed['id']}")
|
|
|
|
assert r.status_code == 200 and r.json() == {"deleted": listed["id"]}
|
|
db.expire_all()
|
|
assert (
|
|
db.execute(select(Invite).where(Invite.email == "mistake@example.com")).scalar_one_or_none()
|
|
is None
|
|
)
|
|
|
|
|
|
def test_deleting_an_entry_that_has_an_account_is_refused(db, make_user, as_user):
|
|
# The guard that matters: this row is what is_allowed() reads, so deleting it would revoke the
|
|
# user's access without touching the account or saying so.
|
|
admin = make_user(email="admin@example.com", role="admin")
|
|
member = make_user(email="member@example.com")
|
|
db.add(Invite(email=member.email, status="approved", decided_by=admin.email))
|
|
db.commit()
|
|
client = as_user(admin)
|
|
listed = _invites(client)[member.email]
|
|
assert listed["has_account"] is True
|
|
|
|
r = client.delete(f"/api/admin/invites/{listed['id']}")
|
|
|
|
assert r.status_code == 409
|
|
db.expire_all()
|
|
assert (
|
|
db.execute(select(Invite).where(Invite.email == member.email)).scalar_one_or_none()
|
|
is not None
|
|
)
|
|
|
|
|
|
def test_deleting_a_pending_request_forgets_it(db, make_user, as_user):
|
|
# A pending row is deletable too — "not now, and don't keep the record" is a different decision
|
|
# from Deny, which keeps the row precisely so the person cannot immediately re-ask.
|
|
admin = make_user(email="admin@example.com", role="admin")
|
|
db.add(Invite(email="asker@example.com", status="pending"))
|
|
db.commit()
|
|
client = as_user(admin)
|
|
|
|
r = client.delete(f"/api/admin/invites/{_invites(client)['asker@example.com']['id']}")
|
|
|
|
assert r.status_code == 200
|
|
assert "asker@example.com" not in _invites(client)
|
|
|
|
|
|
def test_deleting_a_missing_invite_is_404(db, make_user, as_user):
|
|
admin = make_user(email="admin@example.com", role="admin")
|
|
assert as_user(admin).delete("/api/admin/invites/9999").status_code == 404
|
|
|
|
|
|
def test_a_regular_user_cannot_delete_an_invite(db, make_user, as_user):
|
|
make_user(email="admin@example.com", role="admin")
|
|
plain = make_user(email="plain@example.com")
|
|
db.add(Invite(email="mistake@example.com", status="approved"))
|
|
db.commit()
|
|
inv_id = db.execute(select(Invite.id).where(Invite.email == "mistake@example.com")).scalar_one()
|
|
|
|
assert as_user(plain).delete(f"/api/admin/invites/{inv_id}").status_code == 403
|
|
|
|
db.expire_all()
|
|
assert db.get(Invite, inv_id) is not None
|
|
|
|
|
|
def test_deleting_the_user_also_clears_the_way_to_re_invite(db, make_user, as_user):
|
|
# The refusal above tells the admin to delete the account instead; purge_user takes the invite
|
|
# with it, so the address really is forgotten and a fresh request starts over as pending.
|
|
admin = make_user(email="admin@example.com", role="admin")
|
|
member = make_user(email="member@example.com")
|
|
db.add(Invite(email=member.email, status="approved", decided_by=admin.email))
|
|
db.commit()
|
|
client = as_user(admin)
|
|
|
|
assert client.delete(f"/api/admin/users/{member.id}").status_code == 200
|
|
|
|
db.expire_all()
|
|
assert db.execute(select(User).where(User.email == member.email)).scalar_one_or_none() is None
|
|
assert (
|
|
db.execute(select(Invite).where(Invite.email == member.email)).scalar_one_or_none() is None
|
|
)
|