Files
silo-server/web/src/hooks/useTheme.test.ts
T
QuickandClaude Opus 5 4f59356cf0 fix(web): tag local appearance caches with their owning account
The theme, text scale, text weight, high contrast, custom theme variable
and custom CSS caches in localStorage were untagged, so on a shared
browser a second account inherited the first account's appearance: with
no server value of its own, every fallback resolved to whatever the
previous account had stored, and the leftover `silo-theme` key also
suppressed the admin-configured default theme for the new account.

DateTimeFormatProvider already solved this by stamping its cache with the
authenticated user id and refusing another account's values. Extract that
mechanism into `createOwnedCache` in utils/storage.ts (where key
namespacing lives) and put all three groups behind it, so appearance and
custom theme get the same protection instead of a third copy of the rule.

- Each group carries its own owner stamp. A shared stamp would be unsafe:
  the groups are written by hooks nested inside each other, and effects
  run inner-first, so whichever hook stamped first would vouch for the
  other's still-stale values.
- A null owner (auth bootstrapping, or signed out) still trusts the
  cache, which keeps the warm start and the login screen's last look.
- An unstamped cache is not trusted once an account is known, so existing
  users take a one-time appearance reset on first load rather than a
  chance of seeing someone else's settings.
- When a foreign cache is detected the values are dropped and the empty
  cache is handed to the new account, so a later single save cannot
  re-trust the rest of the previous account's state.

Owner is the user id because /settings is user-scoped server side; it
lives in one helper (`appearanceCacheOwner`) so it can be widened if
appearance moves to profile scope. `shouldLoadApiTheme` is gone: it had
become a synonym for `appearanceCacheOwner(...) !== null` with no callers
left.

Part of #376

AI-use disclosure: implemented with Claude Code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:24:33 +00:00

46 lines
1.6 KiB
TypeScript

import { beforeEach, describe, expect, it } from "vitest";
import { appearanceCache, storage } from "@/utils/storage";
import { DEFAULT_THEME } from "@/lib/themes";
import { appearanceCacheOwner, getInitialTheme } from "./themePreferences";
describe("appearanceCacheOwner", () => {
// No owner also means no API settings request: the hooks gate the query on
// having resolved an owner.
it("has no owner until auth bootstrap finishes", () => {
expect(appearanceCacheOwner({ loading: true, user: null })).toBeNull();
expect(appearanceCacheOwner({ loading: true, user: { id: 1 } })).toBeNull();
});
it("has no owner when nobody is signed in", () => {
expect(appearanceCacheOwner({ loading: false, user: null })).toBeNull();
});
it("identifies the cache owner by the authenticated user id", () => {
expect(appearanceCacheOwner({ loading: false, user: { id: 7 } })).toBe("7");
});
});
describe("getInitialTheme", () => {
beforeEach(() => {
Object.values(storage.KEYS).forEach((key) => storage.remove(key));
});
it("warms up from the cache while the owner is unknown", () => {
appearanceCache.set(storage.KEYS.THEME, "cobalt-studio", "1");
expect(getInitialTheme(null)).toBe("cobalt-studio");
});
it("warms up from the cache for the account that stored it", () => {
appearanceCache.set(storage.KEYS.THEME, "cobalt-studio", "1");
expect(getInitialTheme("1")).toBe("cobalt-studio");
});
it("ignores another account's cached theme", () => {
appearanceCache.set(storage.KEYS.THEME, "cobalt-studio", "1");
expect(getInitialTheme("2")).toBe(DEFAULT_THEME);
});
});