fix: address the feature review — accurate copy, reachable controls, real coverage
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.
This commit is contained in:
@@ -27,7 +27,7 @@ def test_admin_deletes_a_whitelist_entry_that_never_became_an_account(db, make_u
|
||||
|
||||
r = client.delete(f"/api/admin/invites/{listed['id']}")
|
||||
|
||||
assert r.status_code == 200 and r.json() == {"deleted": 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()
|
||||
@@ -56,6 +56,50 @@ def test_deleting_an_entry_that_has_an_account_is_refused(db, make_user, as_user
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
@@ -67,6 +111,7 @@ def test_deleting_a_pending_request_forgets_it(db, make_user, as_user):
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""/api/me carries whether this instance can actually send email.
|
||||
|
||||
It drives the operator warnings on the Configuration and Access-requests screens. Admin-only,
|
||||
because it is an operator concern and a regular user has nothing to do with it.
|
||||
"""
|
||||
|
||||
from app import email as email_mod
|
||||
|
||||
_PW = "correct horse battery"
|
||||
|
||||
|
||||
def _signed_in(client, make_user, **kw):
|
||||
"""/api/me resolves its user through optional_current_user, which the as_user override does not
|
||||
cover — so sign in for real and read the bootstrap payload the SPA actually gets."""
|
||||
user = make_user(password=_PW, **kw)
|
||||
assert (
|
||||
client.post("/auth/password-login", json={"email": user.email, "password": _PW}).status_code
|
||||
== 200
|
||||
)
|
||||
r = client.get("/api/me")
|
||||
assert r.status_code == 200 and r.json()["authenticated"] is True
|
||||
return r.json()
|
||||
|
||||
|
||||
def test_an_admin_is_told_when_email_is_not_configured(client, make_user, monkeypatch):
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: False)
|
||||
|
||||
me = _signed_in(client, make_user, email="admin@example.com", role="admin")
|
||||
|
||||
assert me["email_configured"] is False
|
||||
|
||||
|
||||
def test_an_admin_sees_nothing_wrong_when_email_works(client, make_user, monkeypatch):
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: True)
|
||||
|
||||
me = _signed_in(client, make_user, email="admin@example.com", role="admin")
|
||||
|
||||
assert me["email_configured"] is True
|
||||
|
||||
|
||||
def test_a_regular_user_is_never_warned_about_the_mail_setup(client, make_user, monkeypatch):
|
||||
# Not their problem, and the flag must not become a way to probe the instance's configuration.
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: False)
|
||||
|
||||
me = _signed_in(client, make_user, email="plain@example.com")
|
||||
|
||||
assert me["email_configured"] is True
|
||||
|
||||
|
||||
def test_the_flag_reuses_the_request_session(client, make_user, monkeypatch):
|
||||
# /api/me is polled once a minute per open tab; reading the SMTP settings decrypts a Fernet
|
||||
# value, so it must not also check out a second pooled connection each time.
|
||||
seen: list[object] = []
|
||||
monkeypatch.setattr(email_mod, "email_enabled", lambda db=None: seen.append(db) or True)
|
||||
|
||||
_signed_in(client, make_user, email="admin@example.com", role="admin")
|
||||
|
||||
assert seen and seen[0] is not None
|
||||
Reference in New Issue
Block a user