Gmail delivers x.y@, xy@ and xy+tag@ to one inbox; 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 (ids 13, 14). The +tag case fails every time: a Google account cannot contain +, so OAuth can only ever answer with the base address. Mail still goes to the address AS TYPED; only identity matching uses the canonical form. utils.canonical_email is the single definition, and a model validator derives users.email_canonical / invites.email_canonical on every write, so no call site can forget it. Scope is narrow on purpose: dots are significant on most providers and the Gmail rule does not hold for Workspace custom domains. Swept every sibling of the same principle, not just the reported one: is_allowed, upsert_pending_invite, the Google-callback adoption, _register_account (so no new duplicate pair can be created), _activate_user_by_email, admin _accounts_for (an invite backing a live user under another spelling used to look deletable) and add_invite, plus purge_user, which now erases every canonically-equal invite instead of leaving one behind that still grants access. Migration 0079 backfills both columns and indexes them NON-uniquely: prod already carries canonical duplicates (approved invite pairs since 2026-06-16), and merging accounts is a judgement call about people's data, not something a migration does on its own. A test pins the backfill SQL against the Python helper.
159 lines
6.9 KiB
Python
159 lines
6.9 KiB
Python
"""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
|