Files
ihasmail-inbuxa/web/src/store/__tests__/settings-policy.test.ts
T
jcoffey-dev c31a653a04 Apply installation policy changes once each, per account
The last third of #207, and the only part that remembers anything.

An admin turns a setting on for people who are already here -- which a default
cannot do, since a default only seeds an account that has none -- and readers
may still turn it back off afterwards, which enforcement does not allow. The
difference between the two is entirely in the remembering.

Each change carries its own version, and an account stores the ones it has had
in its own settings file. Ids rather than a high-water mark, so a change dated
earlier than one already applied is not silently skipped -- the reporter's
analogy is a schema migration, and this is that shape.

Per account rather than per device, because ihasmail's settings are not
browser-local: they live in a file in the reader's own JMAP Files, with the
browser holding a cache. Signing in on a phone does not apply everything a
second time.

A change reaches somebody who had already decided otherwise. That is intended
and confirmed on the issue: the point is to reach everybody who is already
here. It is applied once, and their next decision sticks.

One `update` for however many are pending, since each would otherwise push a
settings file of its own. Enforced values still win, being applied after. A
change whose settings this build does not have at all is dropped rather than
recorded, or it would never run on the ihasmail that does have them.

The reader is told. A setting moving under somebody without a word is the part
of this worth being uneasy about, so the count is toasted with a way into
Settings.

README gains the Docker half the user asked for: a mounted policy file, the
same thing as environment variables for a deployment with no volume, a compose
fragment, and the fact that a policy is read once at startup so editing it
means a restart.

Closes #207.
2026-09-02 11:00:48 -07:00

218 lines
9.5 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DEFAULT_SETTINGS, useSettings } from "@/store/settings";
import { isEnforced, policyDefaults, policyEnforced, resetSettingsPolicyForTest } from "@/lib/settingsPolicy";
/*
* Settings an installation decides, from #207.
*
* A school turning on "warn about outside senders" for three thousand pupils
* cannot ask three thousand pupils. Two powers, and the difference between them
* is the whole point: defaults are a starting point the reader may change,
* enforced settings are not.
*/
vi.mock("@/lib/settingsSync", () => ({
queueSettingsPush: vi.fn(),
pendingSettingsKeys: () => new Set<string>(),
}));
beforeEach(() => {
resetSettingsPolicyForTest();
useSettings.setState({ settings: { ...DEFAULT_SETTINGS } });
});
afterEach(() => {
resetSettingsPolicyForTest();
vi.restoreAllMocks();
});
describe("what the installation has decided", () => {
it("keeps to the settings this build actually has", () => {
/*
* A policy written against a newer ihasmail, or with a typo in it, must not
* introduce a key nothing reads: it would be carried around and pushed to
* the reader's settings file for ever. Same rule an imported settings file
* already gets.
*/
resetSettingsPolicyForTest({
defaults: { conversationMode: false, notARealSetting: true } as never,
enforced: { alsoNotReal: 1 } as never,
});
expect(policyDefaults()).toEqual({ conversationMode: false });
expect(policyEnforced()).toEqual({});
});
it("says which settings belong to the administrator", () => {
// The setting the issue was actually about: the outside-sender banner.
resetSettingsPolicyForTest({ defaults: {}, enforced: { externalSenderBanner: true } as never });
expect(isEnforced("externalSenderBanner")).toBe(true);
expect(isEnforced("conversationMode")).toBe(false);
});
});
describe("defaults, for an account that has none of its own", () => {
it("seeds them", () => {
resetSettingsPolicyForTest({ defaults: { conversationMode: false } as never, enforced: {} });
useSettings.getState().seedFromPolicy();
expect(useSettings.getState().settings.conversationMode).toBe(false);
});
it("leaves everything it does not name alone", () => {
resetSettingsPolicyForTest({ defaults: { conversationMode: false } as never, enforced: {} });
useSettings.getState().seedFromPolicy();
expect(useSettings.getState().settings.showAvatars).toBe(DEFAULT_SETTINGS.showAvatars);
});
it("can still be changed afterwards, being a starting point and not a rule", () => {
resetSettingsPolicyForTest({ defaults: { conversationMode: false } as never, enforced: {} });
useSettings.getState().seedFromPolicy();
useSettings.getState().update({ conversationMode: true });
expect(useSettings.getState().settings.conversationMode).toBe(true);
});
it("does nothing at all when the installation has set none", () => {
const before = useSettings.getState().settings;
useSettings.getState().seedFromPolicy();
expect(useSettings.getState().settings).toBe(before);
});
});
describe("enforced settings, which the reader may not change", () => {
beforeEach(() => {
resetSettingsPolicyForTest({ defaults: {}, enforced: { conversationMode: true } as never });
});
it("survives an update that tries to change it", () => {
useSettings.getState().update({ conversationMode: false });
expect(useSettings.getState().settings.conversationMode).toBe(true);
});
it("does not stop the rest of that same update", () => {
// The one key is refused; the others are the reader's business.
useSettings.getState().update({ conversationMode: false, showAvatars: false });
expect(useSettings.getState().settings.conversationMode).toBe(true);
expect(useSettings.getState().settings.showAvatars).toBe(false);
});
it("survives a settings file arriving from another device", () => {
// An older sign-in wrote past the policy before it existed. Hydrating must
// not put that back.
useSettings.getState().hydrate({ conversationMode: false, showAvatars: false });
expect(useSettings.getState().settings.conversationMode).toBe(true);
expect(useSettings.getState().settings.showAvatars).toBe(false);
});
it("survives a reset", () => {
// Resetting must not be the way around a policy.
useSettings.getState().reset();
expect(useSettings.getState().settings.conversationMode).toBe(true);
});
it("survives an imported settings file", () => {
useSettings.getState().importJson(JSON.stringify({ conversationMode: false }));
expect(useSettings.getState().settings.conversationMode).toBe(true);
});
});
describe("reset, where the installation has chosen defaults", () => {
it("goes back to the installation's answer rather than to ihasmail's", () => {
resetSettingsPolicyForTest({ defaults: { conversationMode: false } as never, enforced: {} });
useSettings.getState().update({ conversationMode: true });
useSettings.getState().reset();
expect(useSettings.getState().settings.conversationMode).toBe(false);
});
});
/*
* The third power: applied once each, to everybody, and changeable afterwards.
*
* The difference from `enforced` is entirely in the remembering. Both reach an
* account that already exists; only this one lets the reader have the last
* word, and only because the version is stored.
*/
describe("changes an installation wants applied once", () => {
const change = (version: string, settings: Record<string, unknown>) => ({ version, settings } as never);
it("applies one the account has not had", () => {
resetSettingsPolicyForTest({ changes: [change("20260902", { conversationMode: false })] });
const applied = useSettings.getState().applyPolicyChanges();
expect(applied.map((c) => c.version)).toEqual(["20260902"]);
expect(useSettings.getState().settings.conversationMode).toBe(false);
});
it("remembers it, so the next sign-in does not do it again", () => {
resetSettingsPolicyForTest({ changes: [change("20260902", { conversationMode: false })] });
useSettings.getState().applyPolicyChanges();
// The reader decides otherwise, which is the whole difference from enforcing.
useSettings.getState().update({ conversationMode: true });
expect(useSettings.getState().applyPolicyChanges()).toEqual([]);
expect(useSettings.getState().settings.conversationMode).toBe(true);
});
it("reaches an account that had already chosen otherwise", () => {
/*
* Confirmed as intended on #207: the point is to reach everybody who is
* already here, so somebody who turned it off last week does get it turned
* back on -- once.
*/
useSettings.getState().update({ conversationMode: false });
resetSettingsPolicyForTest({ changes: [change("20260902", { conversationMode: true })] });
useSettings.getState().applyPolicyChanges();
expect(useSettings.getState().settings.conversationMode).toBe(true);
});
it("applies only the ones that are new, keeping what it has seen", () => {
resetSettingsPolicyForTest({ changes: [change("A", { conversationMode: false })] });
useSettings.getState().applyPolicyChanges();
resetSettingsPolicyForTest({
changes: [change("A", { conversationMode: false }), change("B", { showAvatars: false })],
});
const applied = useSettings.getState().applyPolicyChanges();
expect(applied.map((c) => c.version)).toEqual(["B"]);
expect(useSettings.getState().settings.appliedPolicyChanges).toEqual(["A", "B"]);
});
it("does not skip a change dated earlier than one already applied", () => {
// Ids, not a high-water mark. An admin backfilling a change must not find
// it silently ignored because a later one went first.
resetSettingsPolicyForTest({ changes: [change("20260902", { conversationMode: false })] });
useSettings.getState().applyPolicyChanges();
resetSettingsPolicyForTest({
changes: [change("20260101", { showAvatars: false }), change("20260902", { conversationMode: false })],
});
expect(useSettings.getState().applyPolicyChanges().map((c) => c.version)).toEqual(["20260101"]);
expect(useSettings.getState().settings.showAvatars).toBe(false);
});
it("goes out as one write however many changes are pending", () => {
resetSettingsPolicyForTest({
changes: [change("A", { conversationMode: false }), change("B", { showAvatars: false })],
});
const applied = useSettings.getState().applyPolicyChanges();
expect(applied).toHaveLength(2);
expect(useSettings.getState().settings.conversationMode).toBe(false);
expect(useSettings.getState().settings.showAvatars).toBe(false);
});
it("does nothing, and says so, when there are none", () => {
expect(useSettings.getState().applyPolicyChanges()).toEqual([]);
});
it("cannot undo an enforced setting, which outranks it", () => {
resetSettingsPolicyForTest({
enforced: { conversationMode: true } as never,
changes: [change("A", { conversationMode: false })],
});
useSettings.getState().applyPolicyChanges();
expect(useSettings.getState().settings.conversationMode).toBe(true);
});
it("drops a change whose settings this build does not have, rather than recording it", () => {
// Recording it as applied would mean it never runs on the ihasmail that
// does have the setting.
resetSettingsPolicyForTest({ changes: [change("A", { notARealSetting: true })] });
expect(useSettings.getState().applyPolicyChanges()).toEqual([]);
expect(useSettings.getState().settings.appliedPolicyChanges).toEqual([]);
});
});