Files
mesh-catalog/modules/betterbird/cmd/betterbird-tools/betterbird_test.go
T
jochen 1835f0efad
mesh/merge-gate head 1835f0efad0f merged into trunk 39e7a06e6293 — pass: builds betterbird, sent nowhere; no bus step; 2 wait(s) for a person; every machin…
mesh/repo-check head 1835f0efad0f merged into trunk 39e7a06e6293 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery superseded: a newer head of the same pull request
betterbird: own Betterbird's whole configuration as settings, shown without a credential (hq issue 586, ADR 0329)
The module owned only the accounts' server fields, so identities, signatures,
calendars, address books, filters and tags could differ between desktop nodes
unseen, and the operator's own preferences were shipped in the catalogue.

prefs becomes one entry per preference (a node's layer overrides one key) and the
defaults file keeps no value of the operator's; identities, calendars,
address-books, filters and tags become settings, adopted like accounts, filters
owned whole per account, and what Betterbird rewrites written only while it is
closed. New tools list each kind, derive the layers from every desktop node and
propose one at a time, and show drift; every answer passes a credential check.
2026-10-13 02:03:04 +02:00

277 lines
11 KiB
Go

package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
const profileDir = testHome + "/.thunderbird/60016jsp.default-default"
// links stands in for the kernel's fd links: path → target.
func links(t *testing.T, m map[string]string) {
t.Helper()
old := readLink
readLink = func(p string) (string, error) {
for k, v := range m {
if strings.HasSuffix(p, k) {
return v, nil
}
}
return "", os.ErrNotExist
}
t.Cleanup(func() { readLink = old })
}
// betterbird is a running Betterbird with one child, its profile and its prefs.
func (f *fake) betterbird() {
f.proc(4000, 1000, "betterbird-bin", []string{"betterbird"}, "session-4.scope", "DISPLAY=:1")
f.proc(4001, 1000, "betterbird-bin", []string{"/opt/betterbird/betterbird-bin", "-contentproc", "-isForBrowser"}, "session-4.scope")
f.write("/proc/4000/fd/40", "")
f.write("/proc/4000/fd/41", "")
f.write("/proc/4000/fd/1", "")
f.write(testHome+"/.thunderbird/profiles.ini", "[Profile0]\nName=default-default\nIsRelative=1\nPath=60016jsp.default-default\n\n"+
"[Profile1]\nName=default\nIsRelative=1\nPath=au2i98w4.default\nDefault=1\n\n[InstallF72D9E66FA3C07ED]\nDefault=60016jsp.default-default\nLocked=1\n")
f.write(profileDir+"/prefs.js", "// Mozilla User Preferences\n"+
`user_pref("browser.display.use_system_colors", true);`+"\n"+
`user_pref("extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org");`+"\n"+
`user_pref("calendar.alarms.showmissed", true);`+"\n"+
`user_pref("mail.identity.id1.useremail", "someone@example.org");`+"\n"+
`user_pref("extensions.owl.token", "abc");`+"\n"+
`user_pref("signon.rememberSignons", true);`+"\n")
f.write(profileDir+"/extensions.json", `{"addons":[{"id":"owl@beonex.com","active":true,"location":"app-profile"},{"id":"x@mozilla.org","active":true,"location":"app-builtin"}]}`)
}
// declare writes the setting prefs as the node-engine does (prefs.json, novox/hq issue 586), from user_pref
// statements: each one entry of the map.
func (f *fake) declare(prefs string) {
f.t.Helper()
values := map[string]any{}
for name, lit := range statementsOf([]byte(prefs)) {
v, ok := prefValue(lit)
if !ok {
f.t.Fatalf("%s: %s is not a value the setting holds", name, lit)
}
values[name] = v
}
body, _ := json.Marshal(map[string]any{"prefs": values})
f.write(testHome+"/"+prefsFile, string(body))
}
func TestTheProfileIsTheOneTheRunningBetterbirdHoldsElseTheInstallationsDefault(t *testing.T) {
f := newFake(t)
f.betterbird()
links(t, map[string]string{"/proc/4000/fd/40": profileDir + "/prefs.js", "/proc/4000/fd/41": profileDir + "/places.sqlite"})
p, err := f.profile()
if err != nil || p.Dir != "~/.thunderbird/60016jsp.default-default" || !strings.Contains(p.FoundBy, "pid 4000, 2 files") {
t.Fatalf("%+v %v", p, err)
}
links(t, map[string]string{})
p, err = f.profile()
if err != nil || p.Dir != "~/.thunderbird/60016jsp.default-default" || !strings.Contains(p.FoundBy, "InstallF72D9E66FA3C07ED") {
t.Fatalf("the installation's default, not Profile1's Default=1: %+v %v", p, err)
}
}
func TestPrefsAnswerValuesButNeverACredentialOrAnAccount(t *testing.T) {
f := newFake(t)
f.betterbird()
links(t, nil)
f.write(profileDir+"/user.js", mark+")\n"+`user_pref("browser.display.use_system_colors", false);`+"\n")
got, err := f.Prefs([]string{"browser.display.", "mail.identity.*", "extensions.owl.token", "signon.rememberSignons", "gfx.webrender.software"})
if err != nil {
t.Fatal(err)
}
ps := got["prefs"].([]Pref)
by := map[string]Pref{}
for _, p := range ps {
by[p.Name] = p
}
if p := by["browser.display.use_system_colors"]; p.Value != "false" || !strings.HasPrefix(p.From, "user.js") {
t.Fatalf("user.js wins: %+v", p)
}
for _, n := range []string{"mail.identity.id1.useremail", "extensions.owl.token", "signon.rememberSignons"} {
if p := by[n]; p.Value != "" || p.Withheld == "" {
t.Errorf("%s: %+v", n, p)
}
}
if p := by["gfx.webrender.software"]; p.Value != "" || !strings.Contains(p.From, "default") {
t.Fatalf("%+v", p)
}
if _, err := f.Prefs(nil); err == nil {
t.Fatal("names are required")
}
}
func TestTheModulesUserJSIsPlacedOnlyWhereNoOperatorsFileIs(t *testing.T) {
f := newFake(t)
f.betterbird()
links(t, nil)
if _, err := f.userJS(true); err == nil || !strings.Contains(err.Error(), "not pushed") {
t.Fatalf("nothing declared: %v", err)
}
f.declare(`user_pref("calendar.alarms.showmissed", true); user_pref("ui.key.x", "a;b");`)
u, err := f.userJS(true)
if err != nil || !u.Applied || u.Statements != 2 {
t.Fatalf("%+v %v", u, err)
}
placed, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "user.js"))
declared, _, _ := f.declaredText()
if string(placed) != declared || !strings.Contains(declared, `user_pref("ui.key.x", "a;b");`) {
t.Fatalf("placed whole, as declared:\n%s", placed)
}
if !strings.HasPrefix(u.Effective, "yes") {
t.Fatalf("the fake Betterbird started after the file's time: %q", u.Effective)
}
later := time.Unix(1_900_000_000, 0)
os.Chtimes(filepath.Join(f.Root, profileDir, "user.js"), later, later)
if u, _ := f.userJS(false); !strings.HasPrefix(u.Effective, "not yet") {
t.Fatalf("placed after Betterbird started: %q", u.Effective)
}
f.write(profileDir+"/user.js", `user_pref("a.b", 1);`+"\n")
if _, err := f.userJS(true); err == nil || !strings.Contains(err.Error(), "not the module's") {
t.Fatalf("an operator's user.js is never overwritten: %v", err)
}
if b, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "user.js")); string(b) != `user_pref("a.b", 1);`+"\n" {
t.Fatal("and it is left as it was")
}
}
func TestTheUserJSHoldsOnlyPreferencesAndNoCredential(t *testing.T) {
for text, ok := range map[string]bool{
mark + ")\n// none\n": true,
mark + ")\n" + `user_pref("a.b", true); user_pref("c", -3); user_pref("d", "x\"y;z");`: true,
mark + ")\n" + `pref("a.b", true);`: false,
mark + ")\n" + `user_pref("a.b", true); Services.foo();`: false,
mark + ")\n" + `user_pref("mail.smtpserver.s1.password", "x");`: false,
`user_pref("a.b", true);`: false,
} {
if err := validUserJS(text); (err == nil) != ok {
t.Errorf("%q: %v", text, err)
}
}
}
func TestStatusSaysWhatRunsTheProfileTheAddOnsAndTheDisplayPrefs(t *testing.T) {
f := newFake(t)
f.betterbird()
links(t, map[string]string{"/proc/4000/fd/1": "pipe:[36802]"})
f.write("/opt/betterbird/application.ini", "[App]\nVendor=Betterbird Project\nName=Betterbird\nVersion=153.1.0\nBuildID=20260820183427\n")
f.answer = func(name string, args []string) Output {
if name == "pacman" && args[0] == "-Q" {
return Output{Stdout: "betterbird-bin 153.1.0esr-1\n"}
}
if name == "pacman" && args[0] == "-Qmq" {
return Output{Stdout: "betterbird-bin\n"}
}
return Output{}
}
s, err := f.Status()
if err != nil {
t.Fatal(err)
}
if len(s.Running) != 1 || s.Running[0].PID != 4000 || s.Children != 1 || s.Installed != "153.1.0esr-1" || !s.Foreign ||
s.Installation.Version != "153.1.0" || s.Profile == nil || len(s.AddOns) != 1 || s.AddOns[0].ID != "owl@beonex.com" {
t.Fatalf("%+v", s)
}
if !s.Output[0].Dead {
t.Fatalf("the session's dead pipe: %+v", s.Output)
}
found := false
for _, p := range s.Display {
if p.Name == "extensions.activeThemeID" && p.Value == `"thunderbird-compact-dark@mozilla.org"` {
found = true
}
}
if !found {
t.Fatalf("%+v", s.Display)
}
}
func TestCheckNamesWhatIsMissingAndWhatToDo(t *testing.T) {
f := newFake(t)
f.betterbird()
links(t, map[string]string{"/proc/4000/fd/1": "pipe:[1]", "/proc/4000/fd/2": "/dev/null"})
f.answer = func(name string, args []string) Output { return Output{Stdout: "betterbird-bin 153.1.0esr-1\n"} }
a, err := f.Check()
if err != nil || a.OK {
t.Fatalf("%+v %v", a, err)
}
all := ""
for _, x := range a.Findings {
all += x.What + " | " + x.Do + "\n"
}
if !strings.Contains(all, "not on this machine") || !strings.Contains(all, "pipe nobody reads") {
t.Fatal(all)
}
}
func TestRestartEndsItPlacesTheUserJSAndStartsItUnderTheServiceManager(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.betterbird()
links(t, nil)
f.declare("// none")
f.onStart = func(argv []string) {
f.proc(5000, 1000, "betterbird-bin", []string{"betterbird"}, "mesh-betterbird.service")
}
a, err := f.Restart(false)
if err != nil {
t.Fatal(err)
}
if len(a.Ended) != 1 || a.Ended[0] != 4000 || len(a.Running) != 1 || a.Running[0].PID != 5000 || !strings.HasPrefix(a.UserJS, "placed") {
t.Fatalf("%+v", a)
}
if !f.called("systemd-run --user --collect --quiet --unit=mesh-betterbird") {
t.Fatalf("%v", f.calls)
}
}
func TestTheReminderTestTouchesNothingOfTheOperatorsProfile(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.betterbird()
links(t, nil)
f.declare(`user_pref("calendar.alarms.show", true);`)
before, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "prefs.js"))
a, err := f.ReminderTest(40*time.Second, []string{"extensions.activeThemeID", "mail.identity.id1.useremail", "nope.nope"}, false)
if err != nil {
t.Fatal(err)
}
if strings.Join(a.Carried, ",") != "extensions.activeThemeID" || len(a.Withheld) != 2 {
t.Fatalf("%+v", a)
}
dir := filepath.Join(f.Root, "/run/user/1000", testUnit)
user, _ := os.ReadFile(filepath.Join(dir, "profile", "user.js"))
cal, _ := os.ReadFile(filepath.Join(dir, "test.ics"))
if !strings.Contains(string(user), `"calendar.alarms.show", true`) || !strings.Contains(string(user), `"extensions.activeThemeID", "thunderbird-compact-dark@mozilla.org"`) ||
!strings.Contains(string(user), "file:///run/user/1000/"+testUnit+"/test.ics") || strings.Contains(string(user), "someone@") {
t.Fatalf("%s", user)
}
// Betterbird's update check stays off, so no About window opens on the operator's screen, and the
// test's own preferences come last, so nothing carried or declared overrides them.
if !strings.Contains(string(user), `"betterbird.update.checkIntervalDays", 0`) ||
strings.Index(string(user), "extensions.activeThemeID") > strings.Index(string(user), "calendar.registry.meshtest.uri") ||
strings.Index(string(user), `"calendar.alarms.show"`) > strings.Index(string(user), "calendar.registry.meshtest.uri") {
t.Fatalf("%s", user)
}
// The event starts 10 minutes after the reminder, which is due 40 s from now.
if !strings.Contains(string(cal), "DTSTART:20270115T081040Z") || !strings.Contains(string(cal), "TRIGGER:-PT10M") ||
a.Due != "2027-01-15T08:00:40Z" {
t.Fatalf("%s %s", cal, a.Due)
}
if !f.called("systemd-run --user --collect --quiet --unit=" + testUnit + " -p RuntimeMaxSec=600 -p ExecStopPost=/usr/bin/rm -rf /run/user/1000/" + testUnit + " --setenv=MOZ_NO_REMOTE=1") {
t.Fatalf("%v", f.calls)
}
after, _ := os.ReadFile(filepath.Join(f.Root, profileDir, "prefs.js"))
if string(before) != string(after) || exists(filepath.Join(f.Root, profileDir, "user.js")) {
t.Fatal("the operator's profile is untouched")
}
if _, err := f.ReminderTest(0, nil, true); err != nil || exists(dir) {
t.Fatalf("stop removes the test profile: %v", err)
}
}