package main import ( "archive/zip" "encoding/json" "os" "path/filepath" "reflect" "strings" "testing" ) // The configuration the module ships (novox/hq issue 345): the policy's locks, the module's defaults file, and the // tools that show the layers, flag drift, clear it and propose a node layer. // shippedDefaultsWant is the DEFAULT layer the operator approved on 2026-10-12: the laptop's setup, with // reminders missed while asleep shown at the next start and chat off. var shippedDefaultsWant = map[string]string{ "mail.uidensity": "0", "mail.uifontsize": "14", "mail.dark-reader.enabled": "false", "mail.pane_config.dynamic": "2", "mail.pane_config.multiline_all": "true", "mail.threadpane.listview": "0", "mailnews.default_view_flags": "1", "mail.warn_on_shift_delete": "false", "mail.biff.show_alert": "false", "mail.biff.play_sound": "false", "mail.biff.show_tray_icon": "false", "mail.biff.use_system_alert": "true", "calendar.view.daystarthour": "9", "calendar.view.dayendhour": "20", "calendar.item.promptDelete": "false", "calendar.timezone.useSystemTimezone": "true", "calendar.alarms.show": "true", "calendar.alarms.showmissed": "true", "mail.chat.enabled": "false", "mailnews.message_display.disable_remote_image": "true", "privacy.donottrackheader.enabled": "true", } var shippedKeepsWant = []string{"extensions.activeThemeID", "browser.display.use_system_colors", "layout.css.always_underline_links", "mail.spam.manualMark", "mail.compose.show_attachment_pane"} func repoFile(t *testing.T, rel string) []byte { t.Helper() raw, err := os.ReadFile(filepath.Join("..", "..", rel)) if err != nil { t.Fatal(err) } return raw } // withDefaults places the module's defaults file as the push does. func (f *fake) withDefaults() { f.t.Helper() f.write(meshDefaults, string(repoFile(f.t, "install/defaults-pref/mesh-defaults.js"))) } // omni places a packed application holding the given files. func (f *fake) omni(files map[string]string) { f.t.Helper() full := filepath.Join(f.Root, omniJa) if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { f.t.Fatal(err) } out, err := os.Create(full) if err != nil { f.t.Fatal(err) } w := zip.NewWriter(out) for name, body := range files { e, _ := w.Create(name) e.Write([]byte(body)) } w.Close() out.Close() } // betterbirdsOwn is a small omni.ja: two files that contradict each other, as all-thunderbird.js and // mailnews.js do in the pinned release. func (f *fake) betterbirdsOwn() { f.omni(map[string]string{ "greprefs.js": `pref("gfx.webrender.software", false);` + "\n", "defaults/pref/all-thunderbird.js": `pref("offline.autoDetect", true);` + "\n" + `pref("mail.threadpane.listview", 1);` + "\n" + `pref("mail.uidensity", 1);` + "\n" + `pref("mail.shell.checkDefaultClient", true);` + "\n", "defaults/pref/mailnews.js": `pref("offline.autoDetect", false);` + "\n" + `pref("mail.biff.show_alert", true); // a comment after it` + "\n" + `pref("mail.spam.manualMark", false);` + "\n" + `pref("mailnews.start_page.enabled", true);` + "\n", "defaults/pref/calendar.js": `pref("calendar.alarms.showmissed", false);` + "\n", "chrome/other.js": `pref("not.a.default", 1);` + "\n", }) } func stopBetterbird(t *testing.T, f *fake) { t.Helper() for _, pid := range []string{"4000", "4001"} { if err := os.RemoveAll(filepath.Join(f.Root, "proc", pid)); err != nil { t.Fatal(err) } } } func TestTheModuleShipsTheApprovedLocksAndDefaults(t *testing.T) { m, _ := readManifest(t) if a := artifact(t, m, "defaults"); a["from"] != "install/defaults-pref" { t.Fatalf("the defaults file ships through the defaults artifact: %v", a) } if r := resource(t, m, "spelling"); r["path"] != prefDir || r["artifact"] != "defaults" { t.Fatalf("placed in Betterbird's own defaults directory: %v", r) } raw := repoFile(t, "install/distribution/policies.json") if why := policyProblem(raw); why != "" { t.Fatal(why) } var p struct { Policies map[string]json.RawMessage `json:"policies"` } if err := json.Unmarshal(raw, &p); err != nil { t.Fatal(err) } for k, want := range map[string]string{"DisableAppUpdate": "true", "DisableTelemetry": "true", "ExtensionUpdate": "false"} { if string(p.Policies[k]) != want { t.Errorf("%s is %s, not %s", k, p.Policies[k], want) } } prefs := policyPrefsOf(raw) for _, n := range []string{"mail.shell.checkDefaultClient", "mail.inappnotifications.enabled", "mailnews.start_page.enabled"} { if prefs[n] != (policyPref{Value: "false", Locked: true}) { t.Errorf("%s is locked off: %+v", n, prefs[n]) } } // Betterbird's Preferences policy accepts only these prefixes (Policies.sys.mjs); privacy. and toolkit. are not. allowed := []string{"accessibility.", "app.update.", "browser.", "calendar.", "chat.", "datareporting.policy.", "dom.", "extensions.", "general.autoScroll", "general.smoothScroll", "geo.", "gfx.", "intl.", "layers.", "layout.", "mail.", "mailnews.", "media.", "network.", "pdfjs.", "places.", "print.", "signon.", "spellchecker.", "ui.", "widget."} for n := range prefs { ok := false for _, a := range allowed { ok = ok || strings.HasPrefix(n, a) } if !ok { t.Errorf("the policy cannot lock %s: its prefix is not one Betterbird accepts", n) } } text := string(repoFile(t, "install/defaults-pref/mesh-defaults.js")) sh := parseShipped([]byte(text)) if !reflect.DeepEqual(sh.values, shippedDefaultsWant) { t.Errorf("the defaults are the approved ones:\n got %v\nwant %v", sh.values, shippedDefaultsWant) } var keeps []string for _, n := range sh.order { if sh.keeps[n] { keeps = append(keeps, n) } } if !reflect.DeepEqual(keeps, shippedKeepsWant) { t.Errorf("the preferences kept at Betterbird's default: %v", keeps) } lines := strings.Split(text, "\n") for i, l := range lines { if defaultLine.MatchString(l) && (i == 0 || !strings.HasPrefix(lines[i-1], "// ")) { t.Errorf("line %d has no comment saying why: %s", i+1, l) } if t2 := strings.TrimSpace(l); t2 != "" && !strings.HasPrefix(t2, "//") && !defaultLine.MatchString(l) { t.Errorf("line %d is neither a comment nor a pref(…) line: %s", i+1, l) } } for n := range sh.values { if withheld(n) != "" || passwordSecret.MatchString(n) { t.Errorf("%s is an account's or a credential's preference, never a default of the module", n) } if _, locked := prefs[n]; locked { t.Errorf("%s is both locked and a default", n) } } } // T2: Betterbird's own defaults are values read from omni.ja, in Gecko's order, and the module's beside them. func TestPrefsSayTheDefaultAndTheFileThatGivesIt(t *testing.T) { f := newFake(t) f.betterbird() f.placedByTheMesh() f.withDefaults() f.betterbirdsOwn() links(t, nil) got, err := f.Prefs([]string{"offline.autoDetect", "mail.threadpane.listview", "mail.uidensity", "gfx.webrender.software", "mailnews.start_page.enabled", "layout.css.always_underline_links", "not.a.default"}) if err != nil { t.Fatal(err) } by := map[string]Pref{} for _, p := range got["prefs"].([]Pref) { by[p.Name] = p } if p := by["offline.autoDetect"]; p.Value != "true" || p.Default != "true" || !strings.Contains(p.DefaultFrom, "all-thunderbird.js") { t.Errorf("of two files, the one first by name holds, as Gecko reads them: %+v", p) } if p := by["mail.threadpane.listview"]; p.Value != "0" || !strings.Contains(p.From, "the module's default") { t.Errorf("the module's default is over Betterbird's: %+v", p) } if p := by["gfx.webrender.software"]; p.Value != "false" || !strings.Contains(p.From, "greprefs.js") { t.Errorf("%+v", p) } if p := by["mailnews.start_page.enabled"]; p.Value != "false" || p.From != "locked by the module's policy" || p.Default != "true" { t.Errorf("a locked preference says its lock and Betterbird's default under it: %+v", p) } if p := by["layout.css.always_underline_links"]; p.Value != "" || !strings.Contains(p.From, "built-in") { t.Errorf("a preference in no file is said as such: %+v", p) } if p := by["not.a.default"]; p.Default != "" { t.Errorf("only defaults/pref and greprefs.js are defaults: %+v", p) } } // T4: of mailnews.* only what names an account, a server or the operator's tags is withheld. func TestMailnewsIsWithheldOnlyWhereItNamesAnAccount(t *testing.T) { for name, held := range map[string]bool{ "mailnews.start_page.enabled": false, "mailnews.default_view_flags": false, "mailnews.message_display.disable_remote_image": false, "mailnews.tags.$label1.tag": true, "mailnews.smtp.loglevel": true, "mailnews.reply_to_self_check_all_ident": true, "mailnews.auto_config.fetchFromISP.sendEmailAddress": true, "mailnews.oauth.useSchemeRedirect": true, "mail.server.server1.hostname": true, } { if got := withheld(name) != ""; got != held { t.Errorf("%s: withheld %v, want %v", name, got, held) } } } // T1: every managed preference through the layers, and drift where the profile's own value beats the module's // default without a node layer saying so. func TestTheConfigShowsTheLayersAndTheDrift(t *testing.T) { f := newFake(t) f.betterbird() f.placedByTheMesh() f.withDefaults() f.betterbirdsOwn() links(t, nil) f.declare(`user_pref("mail.uifontsize", 16);`) f.write(profileDir+"/prefs.js", "// Mozilla User Preferences\n"+ `user_pref("extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org");`+"\n"+ `user_pref("mail.uifontsize", 16);`+"\n"+ `user_pref("mail.uidensity", 0);`+"\n"+ `user_pref("mail.biff.show_alert", true);`+"\n"+ `user_pref("mail.spam.manualMark", false);`+"\n"+ `user_pref("mailnews.start_page.enabled", true);`+"\n") c, err := f.Config(nil) if err != nil { t.Fatal(err) } by := map[string]ConfigPref{} for _, p := range c.Prefs { by[p.Name] = p } if len(c.Prefs) != 3+len(shippedDefaultsWant)+len(shippedKeepsWant) || !strings.HasPrefix(c.Defaults, "placed") { t.Fatalf("every lock, default and kept default: %d, %s", len(c.Prefs), c.Defaults) } if !reflect.DeepEqual(c.Drifting, []string{"extensions.activeThemeID", "mail.biff.show_alert"}) { t.Fatalf("drift: %v", c.Drifting) } if p := by["mail.biff.show_alert"]; p.Layer != "default" || p.ModuleDefault != "false" || p.BetterbirdDefault != "true" || p.Profile != "true" || p.Effective != "true" || p.From != "the profile's prefs.js" || !strings.Contains(p.Drift, "beats the module's false") { t.Errorf("%+v", p) } if p := by["extensions.activeThemeID"]; p.Layer != "Betterbird's default" || !strings.Contains(p.Drift, `"default-theme@mozilla.org"`) || !strings.Contains(p.Remedy, "add-on manager") || p.Profile != `"thunderbird-compact-dark@mozilla.org"` { t.Errorf("another theme is drift whose remedy is the add-on manager, its id answered whole: %+v", p) } if p := by["mail.spam.manualMark"]; p.Drift != "" || p.BetterbirdDefault != "false" { t.Errorf("a saved value equal to Betterbird's default is not drift: %+v", p) } if p := by["mail.uifontsize"]; p.Drift != "" || p.NodeLayer != "16" { t.Errorf("a node layer that says so is not drift: %+v", p) } if p := by["mailnews.start_page.enabled"]; p.Layer != "locked" || p.Effective != "false" || p.Drift != "" { t.Errorf("a lock wins over the profile: %+v", p) } if p := by["calendar.alarms.showmissed"]; p.Effective != "true" || p.From != "the module's default" { t.Errorf("%+v", p) } // T5: the check says the drift, with both remedies. ch, err := f.Check() if err != nil { t.Fatal(err) } found := false for _, x := range ch.Findings { found = found || (strings.Contains(x.What, "1 managed preference(s) drift") && strings.Contains(x.What, "mail.biff.show_alert") && strings.Contains(x.Do, "betterbird_config_apply") && strings.Contains(x.Do, "keep")) } theme := false for _, x := range ch.Findings { theme = theme || (strings.HasPrefix(x.What, "extensions.activeThemeID drifts") && strings.Contains(x.Do, "add-on manager") && !strings.Contains(x.Do, "betterbird_config_apply")) } if !found || !theme { t.Fatalf("the check reports the drift: %+v", ch.Findings) } os.Remove(filepath.Join(f.Root, meshDefaults)) ch, _ = f.Check() found = false for _, x := range ch.Findings { found = found || strings.Contains(x.What, "the module's defaults are not placed") } if !found { t.Fatalf("defaults missing on a node of the mesh's release is a finding: %+v", ch.Findings) } } // T3: the drifting values are cleared from prefs.js only while Betterbird is not running, and nothing else. func TestConfigApplyClearsTheDriftOnlyWhileBetterbirdIsQuit(t *testing.T) { f := newFake(t) f.betterbird() f.placedByTheMesh() f.withDefaults() f.betterbirdsOwn() links(t, nil) f.declare("// none") prefs := "// Mozilla User Preferences\n" + `user_pref("extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org");` + "\n" + `user_pref("mail.identity.id1.useremail", "someone@example.org");` + "\n" + `user_pref("mail.biff.show_alert", true);` + "\n" + `user_pref("mail.uidensity", 0);` + "\n" f.write(profileDir+"/prefs.js", prefs) dry, err := f.ConfigApply(false) if err != nil || dry.Applied || len(dry.Clears) != 1 || len(dry.NotCleared) != 1 { t.Fatalf("a dry run lists and changes nothing: %+v %v", dry, err) } if f.read(profileDir+"/prefs.js") != prefs { t.Fatal("a dry run wrote prefs.js") } if _, err := f.ConfigApply(true); err == nil || !strings.Contains(err.Error(), "running") { t.Fatalf("refused while Betterbird runs: %v", err) } if f.read(profileDir+"/prefs.js") != prefs { t.Fatal("prefs.js changed while Betterbird runs") } stopBetterbird(t, f) f.write(profileDir+"/lock", "") if _, err := f.ConfigApply(true); err == nil || !strings.Contains(err.Error(), "locked") { t.Fatalf("refused while the profile's own lock is there: %v", err) } if err := os.Remove(filepath.Join(f.Root, profileDir, "lock")); err != nil { t.Fatal(err) } a, err := f.ConfigApply(true) if err != nil || !a.Applied { t.Fatalf("%+v %v", a, err) } want := "// Mozilla User Preferences\n" + `user_pref("extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org");` + "\n" + `user_pref("mail.identity.id1.useremail", "someone@example.org");` + "\n" + `user_pref("mail.uidensity", 0);` + "\n" if got := f.read(profileDir + "/prefs.js"); got != want { t.Fatalf("only the drifting lines go:\n%s", got) } said := map[string]Cleared{} for _, c := range a.Clears { said[c.Name] = c } if c := said["mail.biff.show_alert"]; c.Was != "true" || c.Now != "false" { t.Errorf("it says what it cleared and what holds now: %+v", c) } if _, cleared := said["extensions.activeThemeID"]; cleared || len(a.NotCleared) != 1 || !strings.Contains(a.NotCleared[0], "add-on manager") { t.Errorf("the theme is never cleared, and said with its remedy: %+v", a) } raw, _ := json.Marshal(a) if strings.Contains(string(raw), "example.org") { t.Fatalf("no account value leaves: %s", raw) } if c, _ := f.Config(nil); !reflect.DeepEqual(c.Drifting, []string{"extensions.activeThemeID"}) { t.Fatalf("once applied, only the theme drifts, until the add-on manager changes it: %v", c.Drifting) } os.Remove(filepath.Join(f.Root, meshDefaults)) if _, err := f.ConfigApply(true); err == nil || !strings.Contains(err.Error(), "not placed") { t.Fatalf("without the module's defaults there is nothing to come back to: %v", err) } } // T6: keeping a value on one node is a node layer proposed through the controller, never a write to user.js. func TestKeepProposesANodeLayerThroughTheController(t *testing.T) { f := newFake(t) f.betterbird() f.placedByTheMesh() f.withDefaults() links(t, nil) f.declare(`user_pref("calendar.alarms.playsound", false); user_pref("mail.uifontsize", 12);`) f.write(profileDir+"/prefs.js", `user_pref("mail.uifontsize", 16);`+"\n"+`user_pref("mail.biff.show_alert", true);`+"\n") oldNode, oldAsk := nodeName, asker t.Cleanup(func() { nodeName, asker = oldNode, oldAsk }) nodeName = func() string { return "shanks" } var verb string var args map[string]any asker = func(v string, a any) (json.RawMessage, error) { verb, args = v, a.(map[string]any) return json.RawMessage(`{"ok": true, "output": "proposed: p-1"}`), nil } before := f.read(testHome + "/.config/betterbird/user.js") c, err := f.Config([]string{"mail.uifontsize", "mail.biff.show_alert"}) if err != nil { t.Fatal(err) } if verb != "seat:mesh-controller.settings" || args["node"] != "shanks" || args["propose"] != "true" || args["module"] != "betterbird" { t.Fatalf("%s %v", verb, args) } var values map[string]string if err := json.Unmarshal([]byte(args["values"].(string)), &values); err != nil { t.Fatal(err) } want := `user_pref("calendar.alarms.playsound", false); user_pref("mail.uifontsize", 16); user_pref("mail.biff.show_alert", true);` if values["prefs"] != want { t.Fatalf("the layer keeps what the setting says and the kept values:\n got %s\nwant %s", values["prefs"], want) } if c.Proposed != "proposed: p-1" || f.read(testHome+"/.config/betterbird/user.js") != before { t.Fatalf("proposed, and user.js untouched: %+v", c) } for _, bad := range [][]string{{"mailnews.start_page.enabled"}, {"some.other.pref"}, {"mail.uidensity"}} { verb = "" if _, err := f.Config(bad); err == nil || verb != "" { t.Errorf("%v: a lock, an unmanaged name or a value the profile did not save is refused: %v", bad, err) } } nodeName = func() string { return "" } if _, err := f.Config([]string{"mail.uifontsize"}); err == nil || !strings.Contains(err.Error(), "MESH_NODE") { t.Fatalf("%v", err) } } // The review of pull request 253: whether Betterbird asks to be the default client is decided from the policy's // own Preferences entry, never from what else the policy lacks. func TestTheDefaultClientAskIsDecidedFromThePreferencesEntry(t *testing.T) { f := newFake(t) f.betterbird() f.placedByTheMesh() links(t, nil) f.write(policyFile, `{"policies": {"ExtensionSettings": {}}}`) if why := f.defaultClientAsk(filepath.Join(f.Root, profileDir)); why == "" { t.Fatal("a policy lacking the updater and the lock still asks") } f.write(policyFile, `{"policies": {"Preferences": {"mail.shell.checkDefaultClient": {"Value": false, "Status": "locked"}}}}`) if why := f.defaultClientAsk(filepath.Join(f.Root, profileDir)); why != "" { t.Fatalf("the lock holds whatever else the policy lacks: %s", why) } } // The review of pull request 256: the theme is the add-on database's, and a saved default is not drift. func TestTheThemeIsTheAddOnDatabasesAndASavedDefaultIsNotDrift(t *testing.T) { f := newFake(t) f.betterbird() f.placedByTheMesh() f.withDefaults() links(t, nil) f.write(profileDir+"/prefs.js", `user_pref("browser.display.use_system_colors", false);`+"\n"+ `user_pref("layout.css.always_underline_links", false);`+"\n") f.write(profileDir+"/extensions.json", `{"addons":[{"id":"thunderbird-compact-dark@mozilla.org","type":"theme","active":true,"location":"app-builtin"},`+ `{"id":"default-theme@mozilla.org","type":"theme","active":false,"location":"app-builtin"}]}`) c, err := f.Config(nil) if err != nil { t.Fatal(err) } if !reflect.DeepEqual(c.Drifting, []string{"extensions.activeThemeID"}) { t.Fatalf("the active theme from the add-on database, with no preference saved, is drift; a saved default is not: %v", c.Drifting) } f.write(profileDir+"/prefs.js", `user_pref("extensions.activeThemeID", "default-theme@mozilla.org");`+"\n") f.write(profileDir+"/extensions.json", `{"addons":[{"id":"default-theme@mozilla.org","type":"theme","active":true,"location":"app-builtin"}]}`) if c, _ := f.Config(nil); len(c.Drifting) != 0 { t.Fatalf("a profile back on the default theme does not drift: %v", c.Drifting) } if _, err := f.Config([]string{"extensions.activeThemeID"}); err == nil || !strings.Contains(err.Error(), "add-on manager") { t.Fatalf("a node layer cannot choose the theme: %v", err) } } // The review of pull request 256: a credential's or an account's name in the defaults file is never managed. func TestAnAccountsNameInTheDefaultsIsNeverManaged(t *testing.T) { f := newFake(t) f.betterbird() f.placedByTheMesh() links(t, nil) f.write(meshDefaults, string(repoFile(t, "install/defaults-pref/mesh-defaults.js"))+ `pref("mail.identity.id1.useremail", "x");`+"\n"+"// keeps Betterbird's default: mail.server.server1.hostname\n"+ `pref("mail.smtpservers", "");`+"\n") prefs := `user_pref("mail.identity.id1.useremail", "someone@example.org");` + "\n" + `user_pref("mail.server.server1.hostname", "imap.example.org");` + "\n" + `user_pref("mail.smtpservers", "smtp1");` + "\n" f.write(profileDir+"/prefs.js", prefs) c, err := f.Config(nil) if err != nil { t.Fatal(err) } if len(c.Refused) != 3 || len(c.Drifting) != 0 { t.Fatalf("refused, never managed: %+v", c) } raw, _ := json.Marshal(c) if strings.Contains(string(raw), "example.org") { t.Fatalf("no account value is answered: %s", raw) } stopBetterbird(t, f) if _, err := f.ConfigApply(true); err != nil { t.Fatal(err) } if f.read(profileDir+"/prefs.js") != prefs { t.Fatal("prefs.js keeps every account's value") } ch, _ := f.Check() found := false for _, x := range ch.Findings { found = found || (strings.Contains(x.What, "3 preference(s) the tools never manage") && strings.Contains(x.Do, "mesh-defaults.js")) } if !found { t.Fatalf("the check says so: %+v", ch.Findings) } }