fix(auth): a Google connect names its tab's account too, and a refused tab says why

- /auth/google/connect is a navigation and carries no headers: the tab names the account it
  was built for in `expect`, checked by the same comparison as the request header
  (built_for_another_account); a mismatch lands on /?account=changed
- the link marker names the OAuth state of its own flow: an older flow coming back no longer
  spends the marker of the connect still open (which then returned as a plain sign-in)
- a 401 account_changed reloads the tab with a one-time flag, and the reloaded tab says it
  now shows the account signed in
- a refused switch to a suspended or not-yet-approved account says so in the signed-in
  app's words, naming the address a request was recorded for
- the prefs merge locks FOR NO KEY UPDATE, so a transaction that only added a row referring
  to the account does not hold the save up; a test pins the lock
- giving up a lost page closes a channel page open over it
- e2e README names the chromium-only specs
This commit is contained in:
2026-10-21 02:05:33 +02:00
parent 94418a087e
commit ef99f7b5c7
19 changed files with 487 additions and 121 deletions
+31 -1
View File
@@ -6,7 +6,8 @@ the request first read it — two merges over the same earlier read keep only th
"""
from fastapi import Depends, Request
from sqlalchemy import update
from sqlalchemy import event, update
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session
from app.auth import current_user
@@ -42,3 +43,32 @@ def test_a_save_keeps_a_key_another_save_stored_after_this_one_read_the_account(
assert r.status_code == 200
db.expire_all()
assert db.get(User, user.id).preferences == {"seenVersion": "1.2.3", "startOnHome": False}
def test_the_merge_reads_the_account_under_a_lock_that_orders_merges_and_nothing_else(
client, make_user
):
# The re-read above is half of it: two merges that both read before either commits would still
# keep only the last, unless the read takes a lock the other merge waits for. FOR NO KEY UPDATE
# is that lock, and the weakest one: a plain FOR UPDATE would also wait behind any transaction
# that only added a row referring to the account (a playlist pull, then minutes of YouTube).
make_user(email="prefs-lock@example.com", password=_PW)
assert (
client.post(
"/auth/password-login", json={"email": "prefs-lock@example.com", "password": _PW}
)
).status_code == 200
sent: list[str] = []
def record(_conn, _cursor, statement, *_rest):
sent.append(statement)
event.listen(Engine, "before_cursor_execute", record)
try:
assert client.put("/api/me/preferences", json={"startOnHome": False}).status_code == 200
finally:
event.remove(Engine, "before_cursor_execute", record)
locks = [s for s in sent if "FROM users" in s and " FOR " in s]
assert locks, sent
assert all(s.rstrip().endswith("FOR NO KEY UPDATE") for s in locks), locks