Files
siftlode/frontend/src/lib/urlState.dom.test.ts
T
peter 3ac3d382e7 fix(auth): stop the address-bar mirror eating emailed auth tokens, and unlock revoked accounts
Two independent bugs made an email+password registration unrecoverable in prod.

The verification link did nothing. urlWithParams/clearParams rebuilt the URL as
pathname+query and dropped the fragment — but the one-time auth tokens ride the
fragment (#verify=/#reset=, SB3), and the providers that mirror nav params
(useUrlMirror in FeedFilters/Playlists/Plex/Channels) sit ABOVE the lazily-loaded
<App> in main.tsx. Their mount effect replaceState'd the token away before the auth
card's chunk had loaded, so the card read an empty hash: the link opened a bare
sign-in page, no request, no banner. Both helpers now carry the fragment over.

The account was then locked out for good. SEC-10 nulls password_hash when an
unverified account is attested (here: admin approval landing before the user could
verify — which the bug above made impossible). That is indistinguishable from a
Google-only account, and reset eligibility keyed off password_hash alone, so no
reset link was ever issued: no password to sign in with, and no way to get one.
users.password_revoked_at records the revocation, reset eligibility honours it, and
setting a password again (reset-confirm or set-password) clears it. An account that
never had a password stays ineligible on purpose — its owner adds one from Settings.

/auth/verify now also answers password_reset_required so the card can say why the
registered password stopped working, instead of leaving the user at the uniform
"Invalid email or password". Telling the holder of a valid verify token leaks
nothing: they have already proved they own the mailbox.

The 0078 migration backfills the stamp for rows carrying the exact lockout signature
(local, non-demo, no password, unverified), so existing victims recover unaided.
2026-09-02 01:28:45 +02:00

174 lines
6.8 KiB
TypeScript

// @vitest-environment jsdom
//
// The address-bar helpers that back the URL model: urlWithParams (read-modify-write on the live query so
// each provider owns its own key without clobbering the others), clearParams (strip only transient
// auth/one-time keys, never the nav/filter ones), and filterParamUpdates (feed filters → set/delete
// delta, or clear-all). The round-trip through filtersToParams/paramsToFilters is exercised too.
import { describe, it, expect, beforeEach } from "vitest";
import {
urlWithParams,
clearParams,
filterParamUpdates,
filtersToParams,
paramsToFilters,
} from "./urlState";
import { DEFAULT_TAG_MODES, type FeedFilters } from "./api";
function setLocation(search: string) {
window.history.replaceState(null, "", `/app${search}`);
}
function queryOf(url: string): URLSearchParams {
return new URLSearchParams(url.includes("?") ? url.slice(url.indexOf("?") + 1) : "");
}
const DEFAULTS: FeedFilters = {
tags: [],
tagModes: DEFAULT_TAG_MODES,
channelIds: [],
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
describe("urlWithParams", () => {
beforeEach(() => setLocation(""));
it("sets a key while preserving the pathname and existing params", () => {
setLocation("?page=plex");
const url = urlWithParams({ v: "show:1" });
expect(url.startsWith("/app?")).toBe(true);
const q = queryOf(url);
expect(q.get("v")).toBe("show:1");
expect(q.get("page")).toBe("plex"); // untouched
});
it("deletes a key on a null value and leaves the others", () => {
setLocation("?page=plex&v=show:1&q=react");
const q = queryOf(urlWithParams({ v: null }));
expect(q.has("v")).toBe(false);
expect(q.get("page")).toBe("plex");
expect(q.get("q")).toBe("react");
});
it("applies a mix of sets and deletes in one call", () => {
setLocation("?page=plex&v=show:1&chan=UC1");
const q = queryOf(urlWithParams({ page: null, chan: null, v: null, pl: null }));
expect([...q.keys()]).toEqual([]); // page switch resets the view-specific keys
});
it("returns a bare path when the query empties out", () => {
setLocation("?page=channels");
expect(urlWithParams({ page: null })).toBe("/app");
});
it("carries the fragment over — the one-time auth tokens ride it", () => {
// Regression (prod, 2026-09-02): the providers that mirror nav params mount ABOVE the lazy <App>,
// so this URL was replaceState'd before the auth card's chunk loaded. Dropping the hash ate the
// #verify= token and the verification link opened a bare sign-in page.
window.history.replaceState(null, "", "/app?page=plex#verify=tok123");
expect(urlWithParams({ v: "show:1" })).toBe("/app?page=plex&v=show%3A1#verify=tok123");
});
it("carries the fragment over even when the query empties out", () => {
window.history.replaceState(null, "", "/app?page=channels#reset=tok456");
expect(urlWithParams({ page: null })).toBe("/app#reset=tok456");
});
});
describe("clearParams", () => {
beforeEach(() => setLocation(""));
it("removes only the named transient keys, keeping nav/filter params", () => {
setLocation("?link=ok&page=plex&v=show:1&q=react");
clearParams("link", "oauth", "admin", "access", "verify", "reset");
const q = new URLSearchParams(window.location.search);
expect(q.has("link")).toBe(false);
expect(q.get("page")).toBe("plex");
expect(q.get("v")).toBe("show:1");
expect(q.get("q")).toBe("react");
});
it("is a no-op (no history write) when none of the keys are present", () => {
setLocation("?page=plex");
const before = window.location.search;
clearParams("link", "oauth");
expect(window.location.search).toBe(before);
});
});
describe("filterParamUpdates", () => {
it("maps non-default filters to values and defaults to null (delete)", () => {
const f: FeedFilters = { ...DEFAULTS, q: "react", tags: [3, 7], show: "watched" };
const upd = filterParamUpdates(f);
expect(upd.q).toBe("react");
expect(upd.tags).toBe("3,7");
expect(upd.show).toBe("watched");
expect(upd.sort).toBeNull(); // default "newest" is not emitted → cleared
expect(upd.scope).toBeNull();
});
it("clears ALL filter keys when passed null (leaving the feed)", () => {
const upd = filterParamUpdates(null);
for (const v of Object.values(upd)) expect(v).toBeNull();
// includes every recognized filter key
expect(Object.keys(upd)).toEqual(
expect.arrayContaining(["q", "tags", "show", "sort", "scope"]),
);
});
it("round-trips through the URL (filters → params → filters)", () => {
const f: FeedFilters = {
...DEFAULTS,
q: "hi",
tags: [1, 2],
scope: "all",
includeShorts: true,
};
const back = paramsToFilters(filtersToParams(f), DEFAULTS);
expect(back.q).toBe("hi");
expect(back.tags).toEqual([1, 2]);
expect(back.scope).toBe("all");
expect(back.includeShorts).toBe(true);
});
it("round-trips per-category tag modes (only 'and' is emitted)", () => {
const f: FeedFilters = {
...DEFAULTS,
tags: [1, 2],
tagModes: { language: "and", topic: "and", other: "or" },
};
const params = filtersToParams(f);
expect(params.get("tagmode_language")).toBe("and");
expect(params.get("tagmode_topic")).toBe("and");
expect(params.has("tagmode_other")).toBe(false); // 'or' is the default, not emitted
const back = paramsToFilters(params, DEFAULTS);
expect(back.tagModes).toEqual({ language: "and", topic: "and", other: "or" });
});
it("migrates a legacy global ?tagmode=and into the topic slot only", () => {
const back = paramsToFilters(new URLSearchParams("tagmode=and"), DEFAULTS);
expect(back.tagModes).toEqual({ language: "or", topic: "and", other: "or" });
});
it("round-trips the year filter; emitted only when set", () => {
expect(filtersToParams({ ...DEFAULTS, year: 2024 }).get("year")).toBe("2024");
expect(filtersToParams({ ...DEFAULTS, year: undefined }).has("year")).toBe(false);
expect(paramsToFilters(new URLSearchParams("year=2024"), DEFAULTS).year).toBe(2024);
// Junk / absent → undefined (off), never a NaN year.
expect(paramsToFilters(new URLSearchParams("year=nope"), DEFAULTS).year).toBeUndefined();
expect(paramsToFilters(new URLSearchParams(""), DEFAULTS).year).toBeUndefined();
});
it("round-trips the 'No tag' (untagged) flag; emitted only when on", () => {
expect(filtersToParams({ ...DEFAULTS, untagged: true }).get("notag")).toBe("1");
expect(filtersToParams({ ...DEFAULTS, untagged: false }).has("notag")).toBe(false);
expect(paramsToFilters(new URLSearchParams("notag=1"), DEFAULTS).untagged).toBe(true);
// Absent → the base value is left untouched (undefined here, i.e. off).
expect(paramsToFilters(new URLSearchParams(""), DEFAULTS).untagged).toBeUndefined();
});
});