Round 1 on the feature diff found 15; the substantive ones: - The delete control VANISHED on any invite whose address had registered, which is the ordinary sign-up shape (register writes the User and the pending invite together). The guard stays broad — that row is the account's approval gate, and orphaning it strands someone who can then neither sign in nor be approved — but the control is now a disabled button carrying the reason, on both lists, so an admin never sees a row silently missing a button its neighbours have. - Deleting an entry is not always a revoke: is_allowed falls back to the env ALLOWED_EMAILS / ADMIN_EMAILS lists once no row exists, and an ADMIN_EMAILS address is force-promoted on its next Google sign-in. The route now reports still_allowed_by_env and the UI says so instead of claiming access was removed. - "Deny keeps the row so they can't immediately re-ask" was simply false — upsert_pending_invite reopens a denied row. Corrected everywhere it was stated. - The Configuration warning reads me.email_configured, which save() did not invalidate, so it kept warning about fields the admin had just filled in. - The read-only variant was an aria-hidden span explained only by a Tooltip that the hints toggle can switch off; now a disabled button with title + aria-label. - The delete error path threw away the 409's reason (the stale-list case) and left the stale row on screen. - The throttle window was spent even when no notice was written, silencing the next hour on the path whose job is to stop silent drops. - email_enabled() opened a second session and decrypted the SMTP password on every /api/me poll; it now takes the caller's session. - welcomeMode.dom.test.ts never rendered the card, so reverting the history wiring to useState left it green. A render test now drives the real links and fails on that revert (verified). Plus /api/me flag tests, the registration-shaped pending case, the env-allowlist case, and a release note that matches what the code does.
152 lines
6.2 KiB
Python
152 lines
6.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_a_pending_request_from_a_registration_is_not_deletable(db, make_user, as_user):
|
|
# The shape the ordinary sign-up produces: _register_account writes the User (inactive) AND the
|
|
# pending Invite together. The invite is that account's approval gate, so deleting it would
|
|
# strand the person — they could neither sign in nor be approved, and re-registering is a silent
|
|
# no-op. Managed from Users & roles instead, which is what the UI and the 409 both say.
|
|
admin = make_user(email="admin@example.com", role="admin")
|
|
make_user(email="signup@example.com", is_active=False, email_verified=False)
|
|
db.add(Invite(email="signup@example.com", status="pending"))
|
|
db.commit()
|
|
client = as_user(admin)
|
|
listed = _invites(client)["signup@example.com"]
|
|
assert listed["has_account"] is True # the UI shows a disabled control, not a missing one
|
|
|
|
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 == "signup@example.com")).scalar_one_or_none()
|
|
is not None
|
|
)
|
|
|
|
|
|
def test_deleting_an_env_allow_listed_address_reports_that_it_kept_access(
|
|
db, make_user, as_user, monkeypatch
|
|
):
|
|
# Removing the row is NOT always a revoke: is_allowed falls back to the env bootstrap lists once
|
|
# no invite exists. The admin has to be told, or they think they removed access and did not.
|
|
from app.config import settings
|
|
|
|
monkeypatch.setattr(
|
|
type(settings), "allowed_email_set", property(lambda self: {"ops@example.com"})
|
|
)
|
|
admin = make_user(email="admin@example.com", role="admin")
|
|
db.add(Invite(email="ops@example.com", status="approved", decided_by=admin.email))
|
|
db.commit()
|
|
client = as_user(admin)
|
|
|
|
r = client.delete(f"/api/admin/invites/{_invites(client)['ops@example.com']['id']}")
|
|
|
|
assert r.status_code == 200
|
|
assert r.json()["still_allowed_by_env"] is True
|
|
|
|
|
|
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 r.json()["still_allowed_by_env"] is False # nothing in the env allow-lists
|
|
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
|
|
)
|