Files
siftlode/backend/tests/test_admin_invites.py
T
peter 44f626f732 feat(admin): delete a whitelist entry, and stop hiding a broken mail setup
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.
2026-09-01 23:09:25 +02:00

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
)