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
+6 -5
View File
@@ -223,10 +223,11 @@ function reportFailure(err: HttpError, quiet: boolean): void {
if (RETRIABLE_GATEWAY.has(err.status)) {
markConnectivityLost();
} else if (err.status === 401) {
// Session ended server-side (e.g. the account was suspended or deleted mid-visit). Let the app
// shell decide what to do (drop to the login page if we were signed in). Not a modal — but
// Session ended server-side (e.g. the account was suspended or deleted mid-visit), or this tab
// shows another account than the one signed in now. Let the app shell decide what to do (drop to
// the login page, or reload as the account signed in, if we were signed in). Not a modal — but
// handled all the same, so not the caller's to toast either.
onUnauthorized?.();
onUnauthorized?.(err);
reported.add(err);
} else if (quiet) {
/* caller handles the error inline — no global modal */
@@ -256,8 +257,8 @@ export interface ReqConfig {
// page. The handler itself guards against firing when we were never signed in, so it's safe to
// call for every 401. (The bootstrap /api/me probe no longer 401s — it returns 200 with a null
// user when logged out — but other protected endpoints still do.)
let onUnauthorized: (() => void) | null = null;
export function setUnauthorizedHandler(fn: (() => void) | null): void {
let onUnauthorized: ((err: HttpError) => void) | null = null;
export function setUnauthorizedHandler(fn: ((err: HttpError) => void) | null): void {
onUnauthorized = fn;
}
@@ -7,7 +7,10 @@ import { describe, expect, it } from "vitest";
// one without a word.
const read = (path: string) => readFileSync(new URL(path, import.meta.url), "utf-8");
const AUTH = read("../../../../backend/app/auth.py");
const ROUTES = read("../../../../backend/app/routes/auth.py");
const CORE = read("./core.ts");
const CONNECT = read("../googleConnect.ts");
const ENTRY = read("../entryUrl.ts");
describe("the account a tab was built for", () => {
it("is the header the server checks", () => {
@@ -16,4 +19,17 @@ describe("the account a tab was built for", () => {
expect(backend).toBeTruthy();
expect(backend).toBe(frontend?.toLowerCase());
});
it("rides a Google connect as the query parameter the route reads", () => {
// A navigation carries no headers; a rename on one side would leave the connect unchecked.
expect(CONNECT).toMatch(/params\.set\("expect", account\)/);
expect(ROUTES).toMatch(/^ {4}expect: str \| None = None,$/m);
});
it("lands a refused navigation where the reloaded tab says why", () => {
const landing = /^ACCOUNT_CHANGED_LANDING = "\/\?([^"]+)"$/m.exec(ROUTES)?.[1];
const flag = /^export const ACCOUNT_CHANGED_FLAG = "([^"]+)";$/m.exec(ENTRY)?.[1];
expect(landing).toBeTruthy();
expect(landing).toBe(flag);
});
});