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
-15
@@ -17,10 +17,12 @@ from email.message import EmailMessage
|
||||
from email.utils import formatdate
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import sysconfig
|
||||
from app.config import settings
|
||||
from app.db import SessionLocal
|
||||
from app.models import User
|
||||
from app.notifications import create_notification
|
||||
from app.ratelimit import RateLimiter
|
||||
|
||||
@@ -39,8 +41,6 @@ def _alert_admins(reason: str, subject: str, recipients: list[str]) -> None:
|
||||
if not _alert_throttle.allow(reason):
|
||||
return
|
||||
try:
|
||||
from app.models import User # local: app.models imports settle before this ever runs
|
||||
|
||||
with SessionLocal() as db:
|
||||
admins = (
|
||||
db.execute(
|
||||
@@ -54,6 +54,9 @@ def _alert_admins(reason: str, subject: str, recipients: list[str]) -> None:
|
||||
.all()
|
||||
)
|
||||
if not admins:
|
||||
# Nothing was recorded, so don't let this cost the next hour of alerts on the very
|
||||
# path whose job is to stop drops going unseen. Same on the failure branch below.
|
||||
_alert_throttle.reset()
|
||||
return
|
||||
for uid in admins:
|
||||
create_notification(
|
||||
@@ -68,24 +71,33 @@ def _alert_admins(reason: str, subject: str, recipients: list[str]) -> None:
|
||||
)
|
||||
db.commit()
|
||||
except Exception:
|
||||
_alert_throttle.reset()
|
||||
log.exception("Could not record the undelivered-email notice (%r)", subject)
|
||||
|
||||
|
||||
def _smtp() -> dict:
|
||||
"""Effective SMTP config (admin DB override over env defaults). Opens its own short
|
||||
session because email is often sent from a BackgroundTask, outside a request's db."""
|
||||
with SessionLocal() as db:
|
||||
return {
|
||||
"host": sysconfig.get_str(db, "smtp_host"),
|
||||
"port": sysconfig.get_int(db, "smtp_port"),
|
||||
"user": sysconfig.get_str(db, "smtp_user"),
|
||||
"password": sysconfig.get_str(db, "smtp_password"),
|
||||
"from": sysconfig.get_str(db, "smtp_from"),
|
||||
}
|
||||
def _read_smtp(db: Session) -> dict:
|
||||
return {
|
||||
"host": sysconfig.get_str(db, "smtp_host"),
|
||||
"port": sysconfig.get_int(db, "smtp_port"),
|
||||
"user": sysconfig.get_str(db, "smtp_user"),
|
||||
"password": sysconfig.get_str(db, "smtp_password"),
|
||||
"from": sysconfig.get_str(db, "smtp_from"),
|
||||
}
|
||||
|
||||
|
||||
def email_enabled() -> bool:
|
||||
c = _smtp()
|
||||
def _smtp(db: Session | None = None) -> dict:
|
||||
"""Effective SMTP config (admin DB override over env defaults). Opens its own short session when
|
||||
the caller has none, because email is usually sent from a BackgroundTask outside a request's db.
|
||||
A caller that IS inside a request should pass its session: reading the password decrypts a Fernet
|
||||
value, and /api/me polls this for every admin tab."""
|
||||
if db is not None:
|
||||
return _read_smtp(db)
|
||||
with SessionLocal() as own:
|
||||
return _read_smtp(own)
|
||||
|
||||
|
||||
def email_enabled(db: Session | None = None) -> bool:
|
||||
c = _smtp(db)
|
||||
return bool(c["host"] and c["user"] and c["password"])
|
||||
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ from app.auth import (
|
||||
operator_contact,
|
||||
purge_user,
|
||||
)
|
||||
from app.config import settings
|
||||
from app.db import get_db
|
||||
from app.demo import reset_demo_state
|
||||
from app.models import (
|
||||
@@ -175,12 +176,19 @@ def delete_invite(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Remove a whitelist/request entry outright — the counterpart to add_invite, for an address that
|
||||
never became an account (an approval added by mistake, or one that is no longer wanted). Denying
|
||||
a request keeps the row so the person cannot immediately re-ask; deleting it forgets them, and a
|
||||
fresh request from that address starts over as pending.
|
||||
never became an account (an approval added by mistake, or one that is no longer wanted). Deny
|
||||
records a decision and keeps the row for the audit trail; delete forgets the address. Neither
|
||||
stops the person asking again: `upsert_pending_invite` reopens a denied row, and a deleted one
|
||||
simply comes back as a fresh pending request.
|
||||
|
||||
Refused once an account exists: `is_allowed` reads this row, so removing it would silently revoke
|
||||
a live user's access. Delete the USER instead — purge_user removes the invite along with it."""
|
||||
Refused once an account exists for the address, whatever its state: this row is the account's
|
||||
approval gate, and orphaning the account from it would leave a person who can neither sign in nor
|
||||
be approved. Delete the USER instead — purge_user removes the invite along with it.
|
||||
|
||||
Reports `still_allowed_by_env`, because deleting the row is NOT always a revoke: `is_allowed`
|
||||
falls back to the env ALLOWED_EMAILS / ADMIN_EMAILS bootstrap lists once no invite exists, so for
|
||||
an address on those the deletion leaves access intact (and an ADMIN_EMAILS address is still
|
||||
promoted to admin on its next Google sign-in). The admin has to hear that, not guess it."""
|
||||
inv = db.get(Invite, invite_id)
|
||||
if inv is None:
|
||||
raise HTTPException(status_code=404, detail="No such invite")
|
||||
@@ -189,6 +197,8 @@ def delete_invite(
|
||||
status_code=409,
|
||||
detail="That address already has an account — delete the account instead.",
|
||||
)
|
||||
email = (inv.email or "").lower()
|
||||
still_allowed_by_env = email in settings.allowed_email_set or email in settings.admin_email_set
|
||||
audit.record(
|
||||
db,
|
||||
admin.id,
|
||||
@@ -199,7 +209,7 @@ def delete_invite(
|
||||
)
|
||||
db.delete(inv)
|
||||
db.commit()
|
||||
return {"deleted": invite_id}
|
||||
return {"deleted": invite_id, "still_allowed_by_env": still_allowed_by_env}
|
||||
|
||||
|
||||
# --- Users & roles --------------------------------------------------------------------
|
||||
|
||||
@@ -108,7 +108,7 @@ def get_me(
|
||||
db.scalar(select(func.count()).select_from(Invite).where(Invite.status == "pending"))
|
||||
or 0
|
||||
)
|
||||
email_configured = email_mod.email_enabled()
|
||||
email_configured = email_mod.email_enabled(db) # this request's session; no second connect
|
||||
return {
|
||||
"authenticated": True,
|
||||
"id": user.id,
|
||||
|
||||
@@ -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