Sign-out never cleared local storage. It stopped push, flushed settings and removed the subscription -- that last one reasoned explicitly that a browser left holding someone's mail becomes somebody else's next -- and then left the settings cache and the recently-addressed list on disk. That list is other people's addresses, and nothing ever removed it. Clearing it on sign-out is now unconditional, because lending a laptop is the same exposure as a public machine, only quieter. The keep-list is short and deliberate: lastUser, which only a trusted device writes; the trust flag; and the random push device id. Everything else goes, so a key added later is forgotten by default rather than by nobody having thought about it. "Keep me signed in on this device" defaulted to true, which assumed the answer most costly to get wrong -- someone on a library machine got a thirty-day cookie unless they noticed a ticked box. It now asks whose computer this is, defaults to not yours, and says what each answer does. Untrusted means a session cookie, nothing written locally, no push subscription, and a five minute idle sign-out. The idle timer is there because the alternative does not work: custom beforeunload text was removed from browsers years ago, and no event fires at all for walking away from a signed-in screen, which is the case that matters. A timer needs nobody's cooperation. Reads are gated as well as writes, since a machine trusted once still has the residue; an untrusted sign-in purges it outright. The wire keeps calling this `remember` -- it is persisted in SESSION_FILE, and renaming it would invalidate every session file on upgrade for a change of vocabulary. Verified in a browser against the mock, not only in tests: untrusted sign-in leaves localStorage empty through a full session including folder expansion; trusted writes settings, recent and lastUser as before; sign-out clears recent and settings while keeping lastUser; an untrusted sign-in afterwards clears even that.
171 lines
6.9 KiB
TypeScript
171 lines
6.9 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import { DEFAULT_SETTINGS, DEVICE_KEYS, acceptRemote, isDarkTheme, syncedPart, toggleTarget, useSettings, type Theme } from "@/store/settings";
|
|
import { loadJson, saveJson, setDeviceTrusted } from "@/lib/storage";
|
|
|
|
/**
|
|
* "ihasmail" is a dark theme wearing ihasmail.org's palette. Everything that
|
|
* asks "is this dark?" has to say yes for it — the top-bar toggle picks its
|
|
* icon from the answer, and the message frame decides whether mail sits on a
|
|
* light card or follows the app. A theme that painted dark while reporting
|
|
* light would show a sun icon on a dark screen and light-card mail on it.
|
|
*/
|
|
|
|
describe("which themes paint dark", () => {
|
|
it("counts ihasmail as dark, regardless of the OS", () => {
|
|
expect(isDarkTheme("ihasmail", false)).toBe(true);
|
|
expect(isDarkTheme("ihasmail", true)).toBe(true);
|
|
});
|
|
|
|
it("still resolves the ordinary three the way it always did", () => {
|
|
expect(isDarkTheme("dark", false)).toBe(true);
|
|
expect(isDarkTheme("light", true)).toBe(false);
|
|
expect(isDarkTheme("system", true)).toBe(true);
|
|
expect(isDarkTheme("system", false)).toBe(false);
|
|
});
|
|
|
|
it("treats a missing OS preference as light, not as unknown", () => {
|
|
// matchMedia is absent in some embeddings; the default must not read dark.
|
|
expect(isDarkTheme("system")).toBe(false);
|
|
});
|
|
|
|
it("has an answer for every theme there is", () => {
|
|
// A theme added later without a branch here would silently paint light.
|
|
const all: Theme[] = ["system", "light", "dark", "ihasmail"];
|
|
for (const t of all) expect(typeof isDarkTheme(t, false), t).toBe("boolean");
|
|
});
|
|
});
|
|
|
|
describe("the default theme", () => {
|
|
it("is ihasmail, so a new account looks like ihasmail before anyone chooses", () => {
|
|
expect(DEFAULT_SETTINGS.theme).toBe("ihasmail");
|
|
});
|
|
|
|
/**
|
|
* The guarantee that matters when a default changes: it moves nobody who
|
|
* already has a theme stored — which is everyone using ihasmail today, since
|
|
* the setting is saved whether or not they deliberately picked it.
|
|
*
|
|
* `localStorage` is not available in this environment, and `saveJson`
|
|
* swallows that, so a plain round-trip here would pass for the wrong reason:
|
|
* both sides would be the fallback. Stub it, so what is under test is
|
|
* `loadJson`'s merge rather than the environment.
|
|
*/
|
|
const withStorage = (fn: () => void) => {
|
|
const store = new Map<string, string>();
|
|
Object.defineProperty(globalThis, "localStorage", {
|
|
configurable: true,
|
|
value: {
|
|
getItem: (k: string) => store.get(k) ?? null,
|
|
setItem: (k: string, v: string) => void store.set(k, v),
|
|
removeItem: (k: string) => void store.delete(k),
|
|
},
|
|
});
|
|
// Reads and writes are gated on device trust now, and the gate defaults to
|
|
// closed. These tests are about `loadJson`'s merge, so open it and put it
|
|
// back -- an untrusted device is covered by storage.test.ts instead.
|
|
setDeviceTrusted(true);
|
|
try {
|
|
fn();
|
|
} finally {
|
|
setDeviceTrusted(false);
|
|
Reflect.deleteProperty(globalThis, "localStorage");
|
|
}
|
|
};
|
|
|
|
it("is only a default — a stored theme wins", () => {
|
|
withStorage(() => {
|
|
saveJson("theme-test", { ...DEFAULT_SETTINGS, theme: "light" });
|
|
expect(loadJson("theme-test", DEFAULT_SETTINGS).theme).toBe("light");
|
|
});
|
|
});
|
|
|
|
it("fills in from the default only for keys the stored settings lack", () => {
|
|
withStorage(() => {
|
|
// An older settings blob that predates a key must not lose the new one.
|
|
saveJson("theme-test-partial", { theme: "dark" });
|
|
const loaded = loadJson("theme-test-partial", DEFAULT_SETTINGS);
|
|
expect(loaded.theme).toBe("dark");
|
|
expect(loaded.accent).toBe(DEFAULT_SETTINGS.accent);
|
|
});
|
|
});
|
|
|
|
it("falls back to the default when nothing is stored", () => {
|
|
withStorage(() => {
|
|
expect(loadJson("theme-test-absent", DEFAULT_SETTINGS).theme).toBe("ihasmail");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("the top-bar toggle", () => {
|
|
it("goes to light from anything dark", () => {
|
|
expect(toggleTarget("dark", "ihasmail")).toBe("light");
|
|
expect(toggleTarget("dark", "dark")).toBe("light");
|
|
expect(toggleTarget("dark", "system")).toBe("light");
|
|
});
|
|
|
|
it("comes back to the theme you were actually on", () => {
|
|
// The whole point: two clicks from ihasmail must return to ihasmail, not
|
|
// deposit you on plain dark.
|
|
expect(toggleTarget("light", "ihasmail")).toBe("ihasmail");
|
|
expect(toggleTarget("light", "dark")).toBe("dark");
|
|
});
|
|
|
|
it("can bring back \"match system\", which the toggle used to strand", () => {
|
|
expect(toggleTarget("light", "system")).toBe("system");
|
|
});
|
|
|
|
it("round-trips every dark theme there is", () => {
|
|
for (const t of ["dark", "ihasmail", "system"] as const) {
|
|
expect(toggleTarget(toggleTarget("light", t) === "light" ? "light" : "dark", t), t).toBe("light");
|
|
expect(toggleTarget("light", t), t).toBe(t);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("remembering which dark theme you were on", () => {
|
|
const setTheme = (t: Theme) => {
|
|
useSettings.getState().update({ theme: t });
|
|
return useSettings.getState().settings;
|
|
};
|
|
|
|
it("records a dark theme chosen from Settings, not just from the toggle", () => {
|
|
// update() is the single path every way of choosing a theme goes through,
|
|
// which is why the remembering lives there rather than at the call sites.
|
|
expect(setTheme("dark").lastDarkTheme).toBe("dark");
|
|
expect(setTheme("ihasmail").lastDarkTheme).toBe("ihasmail");
|
|
expect(setTheme("system").lastDarkTheme).toBe("system");
|
|
});
|
|
|
|
it("does not let light overwrite it — that is the theme being toggled away from", () => {
|
|
setTheme("ihasmail");
|
|
expect(setTheme("light").lastDarkTheme).toBe("ihasmail");
|
|
});
|
|
|
|
it("survives a there-and-back through the toggle", () => {
|
|
setTheme("ihasmail");
|
|
const away = setTheme(toggleTarget("dark", useSettings.getState().settings.lastDarkTheme));
|
|
expect(away.theme).toBe("light");
|
|
const back = setTheme(toggleTarget("light", away.lastDarkTheme));
|
|
expect(back.theme).toBe("ihasmail");
|
|
});
|
|
});
|
|
|
|
describe("where the theme settings live", () => {
|
|
it("follows the account, not the browser", () => {
|
|
// Both of these ride in the account's settings.json, so a theme chosen on
|
|
// one machine — and the toggle's way back to it — are the same everywhere.
|
|
// Named explicitly rather than derived from DEVICE_KEYS: the test that
|
|
// does derive it would still pass if one of these were moved there, since
|
|
// its expectation would move too.
|
|
const synced = syncedPart(DEFAULT_SETTINGS);
|
|
expect(synced).toHaveProperty("theme");
|
|
expect(synced).toHaveProperty("lastDarkTheme");
|
|
expect(DEVICE_KEYS.has("theme")).toBe(false);
|
|
expect(DEVICE_KEYS.has("lastDarkTheme")).toBe(false);
|
|
});
|
|
|
|
it("is applied from a settings file another device wrote", () => {
|
|
expect(acceptRemote({ theme: "dark", lastDarkTheme: "dark" })).toEqual({ theme: "dark", lastDarkTheme: "dark" });
|
|
});
|
|
});
|