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
@@ -0,0 +1,111 @@
// @vitest-environment jsdom
//
// "Sign in as someone else" with a Google account the server will not sign in (not approved, or
// suspended) comes back to the account still signed in here, with the flags the sign-in page reads;
// a tab the server refused because another account signed in under it reloads with a flag of its
// own. Each says so in the signed-in app's words, not the sign-in page's.
import { afterEach, describe, expect, it, vi } from "vitest";
import {
ACCOUNT_CHANGED_FLAG,
accountChangedUrl,
accountSwitchNotice,
clearEntryUrl,
reloadAfterRefusal,
} from "./entryUrl";
const notice = (search: string, hash = "") =>
accountSwitchNotice(new URLSearchParams(search), new URLSearchParams(hash));
afterEach(() => {
window.history.replaceState(null, "", "/");
vi.unstubAllGlobals();
});
describe("a switch of account, told to the account signed in", () => {
it("names the address a request was recorded for", () => {
expect(notice("", "access=requested&email=b%2Btag%40example.com")).toEqual({
level: "info",
key: "header.account.switchRequested",
values: { email: "b+tag@example.com" },
});
});
it("names the address the request lives under when that is another spelling", () => {
expect(notice("", "access=requested&email=bob%40x.com&linked=b.ob%40x.com")?.values).toEqual({
email: "b.ob@x.com",
});
});
it("says the account is not approved, and nothing changed here", () => {
expect(notice("", "access=denied")).toEqual({
level: "warning",
key: "header.account.switchNotApproved",
});
});
it("says that account is suspended, not this one", () => {
expect(notice("?login=suspended")).toEqual({
level: "warning",
key: "header.account.switchSuspended",
});
});
it("says the tab reloaded as the account signed in now", () => {
expect(notice(`?page=stats&${ACCOUNT_CHANGED_FLAG}`)).toEqual({
level: "info",
key: "header.account.tabSwitched",
});
});
it("says nothing on an ordinary load", () => {
expect(notice("?page=stats", "")).toBeNull();
});
});
describe("the reload of a tab another account signed in under", () => {
it("stays on its page and carries the flag", () => {
window.history.replaceState(null, "", "/?page=stats&chan=UC1#x=1");
const url = new URL(accountChangedUrl(), "http://h");
expect(url.pathname).toBe("/");
expect(Object.fromEntries(url.searchParams)).toEqual({
page: "stats",
chan: "UC1",
account: "changed",
});
expect(url.hash).toBe("#x=1");
});
it("drops the flag once read, so a later reload does not say it again", () => {
window.history.replaceState(null, "", `/?page=stats&${ACCOUNT_CHANGED_FLAG}`);
clearEntryUrl();
expect(window.location.search).toBe("?page=stats");
});
});
describe("a tab whose session the server refused", () => {
const stubLocation = () => {
const loc = {
pathname: "/",
search: "?page=stats",
hash: "",
replace: vi.fn(),
reload: vi.fn(),
};
vi.stubGlobal("location", loc);
return loc;
};
it("reloads as the account signed in now, saying why, when it showed another", () => {
const loc = stubLocation();
reloadAfterRefusal({ detailData: { code: "refused", reason: "account_changed" } });
expect(loc.replace).toHaveBeenCalledWith(`/?page=stats&${ACCOUNT_CHANGED_FLAG}`);
expect(loc.reload).not.toHaveBeenCalled();
});
it("reloads plainly when the session ended", () => {
const loc = stubLocation();
reloadAfterRefusal({ detailData: { code: "refused", reason: "not_authenticated" } });
expect(loc.reload).toHaveBeenCalled();
expect(loc.replace).not.toHaveBeenCalled();
});
});