diff --git a/backend/alembic/versions/0079_email_canonical.py b/backend/alembic/versions/0079_email_canonical.py new file mode 100644 index 00000000..8b2d9d95 --- /dev/null +++ b/backend/alembic/versions/0079_email_canonical.py @@ -0,0 +1,65 @@ +"""email_canonical on users + invites, for matching one mailbox across Gmail spellings + +Revision ID: 0079_email_canonical +Revises: 0078_password_revoked_at +Create Date: 2026-09-04 + +Gmail delivers `x.y@gmail.com`, `xy@gmail.com` and `xy+tag@gmail.com` to ONE mailbox; the invite gate +only lowercased. On 2026-09-03 that turned an approved user's Google sign-in into a silent second +access request (OAuth returns the account's stored primary, which need not be the spelling they typed) +and left prod with two user rows for one mailbox. + +The backfill below reproduces `app.utils.canonical_email` in SQL. That duplication is deliberate — a +migration must not import application code that will keep changing — but it means the two have to +agree; `test_canonical_email_backfill_matches_the_helper` pins them together. + +Deliberately NOT unique. Prod already carries canonical duplicates: two approved invites for one +mailbox dating back to 2026-06-16, and two active user rows from the 2026-09-03 walkthrough. Merging +accounts is a judgement call about people's data, not something a migration gets to do on its own, so +this migration only records the canonical form and leaves the duplicates in place for a human. +""" + +import sqlalchemy as sa +from alembic import op + +revision: str = "0079_email_canonical" +down_revision: str | None = "0078_password_revoked_at" +branch_labels: str | None = None +depends_on: str | None = None + +# Mirrors app.utils.canonical_email: Gmail (and its googlemail alias) loses dots and any `+tag` from +# the local part and lands on gmail.com; every other domain is only lowercased, because the dot rule +# is not guaranteed elsewhere — and does not hold for Workspace custom domains unless their admin +# enabled it. +_CANON_SQL = """ + CASE + WHEN split_part(lower({col}), '@', 2) IN ('gmail.com', 'googlemail.com') + -- an empty local part after stripping falls through to plain lowercase, as the helper does + AND replace(split_part(split_part(lower({col}), '@', 1), '+', 1), '.', '') <> '' + THEN replace(split_part(split_part(lower({col}), '@', 1), '+', 1), '.', '') || '@gmail.com' + ELSE lower({col}) + END +""" + + +def _backfill(table: str) -> None: + op.execute( + f"UPDATE {table} SET email_canonical = {_CANON_SQL.format(col='email')} " + f"WHERE email IS NOT NULL" + ) + + +def upgrade() -> None: + for table in ("users", "invites"): + op.add_column( + table, + sa.Column("email_canonical", sa.String(length=320), nullable=False, server_default=""), + ) + _backfill(table) + op.create_index(f"ix_{table}_email_canonical", table, ["email_canonical"]) + + +def downgrade() -> None: + for table in ("users", "invites"): + op.drop_index(f"ix_{table}_email_canonical", table_name=table) + op.drop_column(table, "email_canonical") diff --git a/backend/app/auth.py b/backend/app/auth.py index 66ddc602..48152cc1 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -12,6 +12,7 @@ from app.config import settings from app.db import get_db from app.models import Invite, User from app.security import decrypt_optional +from app.utils import canonical_email PASSWORD_MIN_LEN = 10 @@ -65,13 +66,29 @@ def has_write_scope(user: User) -> bool: def is_allowed(db: Session, email: str) -> bool: """May this email sign in? An approved Invite is the source of truth; the env - ALLOWED_EMAILS/ADMIN_EMAILS remain a bootstrap fallback (e.g. a fresh DB).""" + ALLOWED_EMAILS/ADMIN_EMAILS remain a bootstrap fallback (e.g. a fresh DB). + + Matched on the CANONICAL form (utils.canonical_email), not the literal string. Gmail delivers + every dotted / `+tagged` spelling to one mailbox, so approving one spelling always meant approving + that mailbox — matching literally only produced the 2026-09-03 lockout, where an approved user's + Google sign-in became a silent second access request because OAuth returned the undotted primary. + + One canonical form can match SEVERAL invite rows (prod carries approved pairs for one mailbox, + the oldest from 2026-06-16). An approved row among them grants access: the API cannot flip an + approved invite to denied (`_decide` only accepts a pending one), so approved-beside-denied means + the admin approved that mailbox under a different spelling, not that they revoked it. Revoking is + deleting the invite row or suspending the account — both still work, since a deleted row is no + longer here to match and suspension is enforced at sign-in. + """ if not email: return False + canon = canonical_email(email) + statuses = ( + db.execute(select(Invite.status).where(Invite.email_canonical == canon)).scalars().all() + ) + if statuses: + return "approved" in statuses email = email.lower() - inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none() - if inv is not None: - return inv.status == "approved" return email in settings.allowed_email_set or email in settings.admin_email_set @@ -130,7 +147,10 @@ def purge_user(db: Session, user: User, background: BackgroundTasks) -> None: ) # Raw delete so Postgres applies ON DELETE CASCADE / SET NULL on every dependent table. db.execute(delete(User).where(User.id == user_id)) - db.execute(delete(Invite).where(Invite.email == email)) + # Every canonically-equal invite goes, not just the literal spelling: is_allowed now matches on + # the canonical form, so leaving a sibling row behind would keep granting access to a mailbox + # whose account was just erased — and prod really does carry approved pairs for one mailbox. + db.execute(delete(Invite).where(Invite.email_canonical == canonical_email(email))) db.commit() if google_token: background.add_task(revoke_google_token, google_token) diff --git a/backend/app/models.py b/backend/app/models.py index d25911cd..ea24548f 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -17,9 +17,10 @@ from sqlalchemy import ( func, ) from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR -from sqlalchemy.orm import Mapped, mapped_column, relationship +from sqlalchemy.orm import Mapped, mapped_column, relationship, validates from app.db import Base +from app.utils import canonical_email class TimestampMixin: @@ -29,6 +30,21 @@ class TimestampMixin: created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) +class CanonicalEmailMixin: + """Keeps `email_canonical` derived from `email` on every write. + + The invariant — "identity is matched on the canonical form, mail is sent to the typed one" — + lives HERE, in code, instead of in a comment every write site has to remember. That distinction + is what the 2026-09-03 lockout was made of: one gate lowercased, nothing else did, and an + approved user's Google sign-in silently became a second access request. + """ + + @validates("email") + def _derive_email_canonical(self, _key: str, value: str | None) -> str | None: + self.email_canonical = canonical_email(value) + return value + + class UpdatedAtMixin: """Adds an `updated_at` that server-defaults on insert and bumps on every UPDATE.""" @@ -37,7 +53,7 @@ class UpdatedAtMixin: ) -class User(Base, TimestampMixin): +class User(Base, TimestampMixin, CanonicalEmailMixin): __tablename__ = "users" id: Mapped[int] = mapped_column(primary_key=True) @@ -45,6 +61,11 @@ class User(Base, TimestampMixin): # multiple NULLs under a unique index). Set on Google sign-in / in-app linking. google_sub: Mapped[str | None] = mapped_column(String(64), unique=True, index=True) email: Mapped[str] = mapped_column(String(320), unique=True, index=True) + # The same mailbox, in the form used to MATCH identities (utils.canonical_email) — Gmail dots and + # `+tags` folded away. Kept by the validator below so no write site can forget it. Deliberately + # NOT unique: prod already carries two rows for one mailbox (a password registration and the + # Google sign-in that failed to find it), and a migration must not merge accounts on its own. + email_canonical: Mapped[str] = mapped_column(String(320), index=True, server_default="") # argon2id hash for email+password login; NULL for Google-only / demo accounts. password_hash: Mapped[str | None] = mapped_column(String(255)) # When SEC-10 dropped a preset password on Google adoption (see the single caller of @@ -121,7 +142,7 @@ class AuthToken(Base, TimestampMixin): used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) -class Invite(Base): +class Invite(Base, CanonicalEmailMixin): """Access-request / whitelist row. Source of truth for who may sign in; the env ALLOWED_EMAILS/ADMIN_EMAILS act only as a bootstrap fallback (see auth.is_allowed).""" @@ -129,6 +150,9 @@ class Invite(Base): id: Mapped[int] = mapped_column(primary_key=True) email: Mapped[str] = mapped_column(String(320), unique=True, index=True) + # See User.email_canonical. Not unique here either: prod has approved invites for both spellings + # of the same mailbox, dating back to 2026-06-16. + email_canonical: Mapped[str] = mapped_column(String(320), index=True, server_default="") # pending | approved | denied status: Mapped[str] = mapped_column(String(16), default="pending", server_default="pending") note: Mapped[str | None] = mapped_column(Text) diff --git a/backend/app/routes/admin.py b/backend/app/routes/admin.py index 24f724fb..220327ca 100644 --- a/backend/app/routes/admin.py +++ b/backend/app/routes/admin.py @@ -27,20 +27,27 @@ from app.models import ( Invite, User, ) -from app.utils import iso, valid_email +from app.utils import canonical_email, iso, valid_email router = APIRouter(prefix="/api/admin", tags=["admin"]) def _accounts_for(db: Session, emails: Iterable[str]) -> set[str]: - """Which of these addresses already have an account. Batched, so listing the invites costs one - extra query. An invite whose address has an account must not be deletable: `is_allowed` reads - this row, so removing it would silently revoke a live user's access.""" - wanted = {e.lower() for e in emails if e} + """Which of these addresses already have an account, as a set of CANONICAL forms. Batched, so + listing the invites costs one extra query. An invite whose address has an account must not be + deletable: `is_allowed` reads this row, so removing it would silently revoke a live user's access. + + Canonical on both sides, because `is_allowed` matches that way: an invite for `x.y@gmail.com` + whose account registered as `xy@gmail.com` would otherwise look account-less, offer the admin a + delete button, and take away a live user's access — precisely the accident this guard exists to + prevent.""" + wanted = {canonical_email(e) for e in emails if e} if not wanted: return set() - rows = db.execute(select(User.email).where(User.email.in_(wanted))).scalars() - return {e.lower() for e in rows if e} + rows = db.execute( + select(User.email_canonical).where(User.email_canonical.in_(wanted)) + ).scalars() + return {e for e in rows if e} def _env_allowed(email: str) -> bool: @@ -82,7 +89,7 @@ def list_invites( order = {"pending": 0, "approved": 1, "denied": 2} rows.sort(key=lambda i: order.get(i.status, 9)) accounts = _accounts_for(db, (i.email for i in rows)) - return [_serialize(i, has_account=(i.email or "").lower() in accounts) for i in rows] + return [_serialize(i, has_account=canonical_email(i.email) in accounts) for i in rows] def _activate_user_by_email(db: Session, email: str) -> None: @@ -97,7 +104,18 @@ def _activate_user_by_email(db: Session, email: str) -> None: DEFAULT experience, because approve-before-verify is the normal ordering. The revoke stays on the Google-adoption path (auth.revoke_preset_password_if_unverified), where the real owner does attest ownership and the preset password must not survive.""" - user = db.execute(select(User).where(User.email == email.lower())).scalar_one_or_none() + # Canonical, with the literal spelling preferred when both exist: approving + # `npeter83.temu@gmail.com` must activate the account that registered as `npeter83temu@gmail.com`, + # since Gmail delivers both to one mailbox and the admin decided about the mailbox. + email = email.lower() + candidates = ( + db.execute( + select(User).where(User.email_canonical == canonical_email(email)).order_by(User.id) + ) + .scalars() + .all() + ) + user = next((u for u in candidates if u.email == email), None) or next(iter(candidates), None) if user is not None and not user.is_active: user.is_active = True db.commit() @@ -169,7 +187,12 @@ def add_invite( email = body.email.strip().lower() if not valid_email(email): raise HTTPException(status_code=400, detail="Enter a valid email address.") - inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none() + # Reuse a canonically-equal row instead of adding a second one for the same mailbox. Prod carries + # exactly that pair for one person from 2026-06-16 (hartmann.peter1986@ / hartmannpeter1986@), + # which is how an admin ends up approving the same human twice and still seeing them bounce. + canon = canonical_email(email) + rows = db.execute(select(Invite).where(Invite.email_canonical == canon)).scalars().all() + inv = next((r for r in rows if r.email == email), None) or next(iter(rows), None) if inv is None: inv = Invite(email=email) db.add(inv) diff --git a/backend/app/routes/auth.py b/backend/app/routes/auth.py index f60b469c..a5930d6c 100644 --- a/backend/app/routes/auth.py +++ b/backend/app/routes/auth.py @@ -37,7 +37,7 @@ from app.db import SessionLocal, get_db from app.models import AuthToken, DemoWhitelist, Invite, OAuthToken, User from app.ratelimit import RateLimiter from app.security import encrypt, hash_password, hash_token, verify_password -from app.utils import valid_email +from app.utils import canonical_email, valid_email VERIFY_TTL = timedelta(hours=24) RESET_TTL = timedelta(hours=1) @@ -149,11 +149,21 @@ def _client_ip(request: Request) -> str: def upsert_pending_invite(db: Session, email: str) -> Invite | None: """Idempotently record an access request. Returns the Invite if a *new* pending row - was created (so the caller can notify admins), else None for already-known emails.""" + was created (so the caller can notify admins), else None for already-known emails. + + "Already known" is decided on the CANONICAL form: `npeter83.temu@` and `npeter83temu@` are one + Gmail mailbox, and treating them as two is what put a second, silent access request in the admin + queue on 2026-09-03 for an already-approved user. An approved sibling wins — re-requesting must + not reopen a decision that has already been made for that mailbox. The row is still STORED under + the address as typed, because that is where mail has to go. + """ email = (email or "").lower() if not valid_email(email): return None - inv = db.execute(select(Invite).where(Invite.email == email)).scalar_one_or_none() + canon = canonical_email(email) + rows = db.execute(select(Invite).where(Invite.email_canonical == canon)).scalars().all() + # Prefer a decided row over a pending one, so a denied duplicate can't mask an approval. + inv = next((r for r in rows if r.status == "approved"), None) or next(iter(rows), None) if inv is not None: # Let a previously-denied person ask again; don't disturb approved/pending rows. if inv.status == "denied": @@ -292,11 +302,21 @@ async def callback( user = db.query(User).filter(User.google_sub == sub).one_or_none() if user is None: - # No account carries this Google identity yet. If one already exists for this email — + # No account carries this Google identity yet. If one already exists for this MAILBOX — # e.g. an email+password registration — adopt the Google identity onto it instead of - # inserting a duplicate (which would collide on the unique email and 500). Google has - # verified the address, so this safely unifies password + Google sign-in on one account. - existing = db.query(User).filter(User.email == email).one_or_none() + # inserting a duplicate. Google has verified the address, so this safely unifies password + + # Google sign-in on one account. + # + # Matched canonically, and that is not cosmetic: on 2026-09-03 a registration as + # `npeter83.temu@gmail.com` met an OAuth claim of `npeter83temu@gmail.com`, the literal + # lookup found nothing, and prod ended up with TWO active user rows for one Gmail mailbox + # (ids 13 and 14). Exact spelling still wins when both exist, so an account that already + # matches literally is never passed over for a canonical sibling. + canon = canonical_email(email) + candidates = db.query(User).filter(User.email_canonical == canon).order_by(User.id).all() + existing = next((u for u in candidates if u.email == email), None) or next( + iter(candidates), None + ) if existing is not None: if existing.google_sub is not None and existing.google_sub != sub: # A different Google identity is already bound to this email — refuse rather than @@ -615,7 +635,13 @@ def _register_account(email: str, password: str) -> None: notice) for a genuinely new email; no-op for an already-registered one. Runs after the response with its own DB session, so the request's timing never reveals whether the email already exists.""" with SessionLocal() as db: - if db.execute(select(User).where(User.email == email)).scalar_one_or_none() is not None: + # Canonical, so a second spelling of one Gmail mailbox cannot open a SECOND account. Prod + # already carries such a pair (user ids 13 and 14, both for npeter83temu@gmail.com); this is + # the end that stops new ones appearing, while the Google callback copes with the existing. + existing = db.execute( + select(User).where(User.email_canonical == canonical_email(email)) + ).first() + if existing is not None: return # already registered — nothing to do (the uniform "ok" was already returned) # Without working SMTP we can't deliver a verification link, so email ownership can't gate # sign-in. Admin approval stays the real gate (is_active=False); mark the account verified so diff --git a/backend/app/utils.py b/backend/app/utils.py index 8e6a523f..3128e4a8 100644 --- a/backend/app/utils.py +++ b/backend/app/utils.py @@ -22,6 +22,39 @@ def valid_email(value: str | None) -> bool: return bool(value and _EMAIL_RE.match(value)) +# Gmail (and its googlemail.com alias) delivers to one mailbox regardless of dots in the local part +# and of any `+tag`; nobody else is guaranteed to. So the canonical form below strips those for +# Gmail only, and merely lowercases everything else. +_GMAIL_DOMAINS = {"gmail.com", "googlemail.com"} + + +def canonical_email(value: str | None) -> str: + """The form used to decide whether two addresses are the SAME MAILBOX. Never used for sending — + mail always goes to the address as the user typed it. + + Why this exists (observed on prod, 2026-09-03): someone registered as `npeter83.temu@gmail.com` + and was approved, but Google's OAuth `email` claim came back as the account's stored primary, + `npeter83temu@gmail.com`. The invite gate only lowercased, found no approved row for that literal + string, and turned an approved user's sign-in into a silent SECOND access request — plus a second + User row. The `+tag` case is worse and fails every time: a Google account cannot contain `+`, so + OAuth can only ever return the base address, meaning anyone who registers with + `user+siftlode@gmail.com` (a common throwaway habit) is guaranteed to be denied after approval. + + Scope is deliberately narrow. The dot rule does NOT hold for Google Workspace custom domains + unless the admin turned it on, and dots are significant on most providers, so anything outside + gmail.com/googlemail.com is only lowercased. Approving one Gmail spelling always did mean + approving that mailbox, so collapsing them is the honest reading of an invite, not a widening + of it. + """ + email = (value or "").strip().lower() + local, sep, domain = email.partition("@") + if not sep or domain not in _GMAIL_DOMAINS: + return email + local = local.partition("+")[0].replace(".", "") + # Normalise the alias domain too, so googlemail and gmail spellings meet. + return f"{local}@gmail.com" if local else email + + def now_utc() -> datetime: """Current time as a timezone-aware UTC datetime.""" return datetime.now(timezone.utc) diff --git a/backend/tests/test_email_canonical.py b/backend/tests/test_email_canonical.py new file mode 100644 index 00000000..a0cf4fb6 --- /dev/null +++ b/backend/tests/test_email_canonical.py @@ -0,0 +1,158 @@ +"""One mailbox, many spellings — the canonical-email identity rule (E9.5 S3). + +Gmail delivers `x.y@gmail.com`, `xy@gmail.com` and `xy+tag@gmail.com` to ONE inbox. The invite gate +used to compare literal strings, so on 2026-09-03 an approved user's Google sign-in became a silent +SECOND access request: they had registered as `npeter83.temu@gmail.com`, and OAuth returned the +account's stored primary `npeter83temu@gmail.com`. The `+tag` case fails every time — a Google account +cannot contain `+`, so OAuth can only ever answer with the base address. + +The rule is narrow on purpose: dots are significant on most providers, and the Gmail dot rule does not +hold for Google Workspace custom domains unless their admin enabled it. +""" + +import pytest +from sqlalchemy import select, text + +from app.auth import is_allowed +from app.models import Invite, User +from app.utils import canonical_email + +# (input, expected canonical) — the matrix the sprint promised, plus the shapes that must NOT fold. +CASES = [ + # Gmail: dots and +tags are noise… + ("npeter83.temu@gmail.com", "npeter83temu@gmail.com"), + ("npeter83temu@gmail.com", "npeter83temu@gmail.com"), + ("n.p.e.t.e.r@gmail.com", "npeter@gmail.com"), + ("user+siftlode@gmail.com", "user@gmail.com"), + ("u.ser+a+b@gmail.com", "user@gmail.com"), + ("USer.Name@GMail.COM", "username@gmail.com"), + # …and googlemail is the same mailbox as gmail. + ("user.name@googlemail.com", "username@gmail.com"), + # Everyone else: lowercase ONLY. Dots stay significant. + ("first.last@example.com", "first.last@example.com"), + ("First.Last@Example.COM", "first.last@example.com"), + ("first+tag@example.com", "first+tag@example.com"), + # A Workspace domain is not Gmail, however Gmail-ish it looks. + ("first.last@gmail.company.com", "first.last@gmail.company.com"), + # Degenerate input must not explode or invent an address. + ("", ""), + (None, ""), + ("not-an-email", "not-an-email"), + (".@gmail.com", ".@gmail.com"), +] + + +@pytest.mark.parametrize("raw,expected", CASES) +def test_canonical_email(raw, expected): + assert canonical_email(raw) == expected + + +def test_the_migration_sql_agrees_with_the_helper(db): + """The 0079 backfill reimplements canonical_email in SQL (a migration must not import app code + that keeps changing). That duplication is only safe while the two agree, so pin them together — + a drift here means rows backfilled into a form the application will never look up.""" + import importlib.util + from pathlib import Path + + path = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0079_email_canonical.py" + spec = importlib.util.spec_from_file_location("m0079", path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + + sql = module._CANON_SQL.format(col=":addr") + for raw, expected in CASES: + if raw is None: + continue # the column is NOT NULL; the backfill skips NULL emails explicitly + got = db.execute(text(f"SELECT {sql}"), {"addr": raw}).scalar_one() + assert got == expected, f"SQL and helper disagree on {raw!r}: {got!r} != {expected!r}" + + +def test_the_column_is_kept_in_sync_by_writing_the_email(db): + """No write site has to remember: the model validator derives it. If this breaks, every lookup + below silently stops matching.""" + user = User(email="Dotted.Name+tag@GoogleMail.com") + db.add(user) + db.commit() + assert user.email == "Dotted.Name+tag@GoogleMail.com" # stored AS TYPED — mail goes here + assert user.email_canonical == "dottedname@gmail.com" # …matched on this + + user.email = "someone.else@example.com" + db.commit() + assert user.email_canonical == "someone.else@example.com" + + +def test_an_approval_covers_every_spelling_of_the_same_gmail_mailbox(db): + # THE 2026-09-03 lockout, in one assertion: approved under the dotted spelling, signing in with + # the primary Google returns. + db.add(Invite(email="npeter83.temu@gmail.com", status="approved")) + db.commit() + + assert is_allowed(db, "npeter83temu@gmail.com") is True + assert is_allowed(db, "npeter83.temu@gmail.com") is True + assert is_allowed(db, "npeter83.temu+throwaway@gmail.com") is True + + +def test_a_plus_tagged_registration_is_not_locked_out_after_approval(db): + # The case that ALWAYS broke: a Google account cannot contain `+`, so OAuth can only return the + # base address, and a literal match could never find the invite. + db.add(Invite(email="someone+siftlode@gmail.com", status="approved")) + db.commit() + + assert is_allowed(db, "someone@gmail.com") is True + + +def test_dots_stay_significant_outside_gmail(db): + db.add(Invite(email="first.last@example.com", status="approved")) + db.commit() + + assert is_allowed(db, "first.last@example.com") is True + assert is_allowed(db, "firstlast@example.com") is False # a DIFFERENT mailbox elsewhere + + +def test_an_approved_row_wins_over_a_denied_sibling(db): + # Prod carries approved pairs for one mailbox (oldest 2026-06-16). The API cannot flip an approved + # invite to denied, so approved-beside-denied means the admin approved that mailbox under another + # spelling — not that they revoked it. Revoking is deleting the row or suspending the account. + db.add(Invite(email="two.spellings@gmail.com", status="denied")) + db.add(Invite(email="twospellings@gmail.com", status="approved")) + db.commit() + + assert is_allowed(db, "twospellings@gmail.com") is True + + +def test_a_denied_mailbox_stays_denied(db): + db.add(Invite(email="no.thanks@gmail.com", status="denied")) + db.commit() + + assert is_allowed(db, "nothanks@gmail.com") is False + + +def test_a_second_spelling_does_not_open_a_second_access_request(db): + """The other half of the 2026-09-03 report: the admin queue got a duplicate request for a user + who was already approved.""" + from app.routes.auth import upsert_pending_invite + + db.add(Invite(email="already.approved@gmail.com", status="approved")) + db.commit() + + created = upsert_pending_invite(db, "alreadyapproved@gmail.com") + + assert created is None # nothing new to notify an admin about + assert len(db.execute(select(Invite)).scalars().all()) == 1 + + +def test_manually_whitelisting_another_spelling_reuses_the_row(db): + """How prod ended up with hartmann.peter1986@ AND hartmannpeter1986@ both approved on the same + day: the admin typed the other spelling and got a second row instead of a no-op.""" + from app.routes.admin import add_invite, AddInviteIn + + admin = User(email="admin@example.com", role="admin", is_active=True, email_verified=True) + db.add(admin) + db.add(Invite(email="hartmann.peter1986@gmail.com", status="approved")) + db.commit() + + add_invite(AddInviteIn(email="hartmannpeter1986@gmail.com"), admin=admin, db=db) + + rows = db.execute(select(Invite)).scalars().all() + assert len(rows) == 1 + assert rows[0].email == "hartmann.peter1986@gmail.com" # the original spelling is preserved