package main import ( "encoding/json" "os" "path/filepath" "reflect" "strings" "testing" ) // Betterbird's whole configuration (novox/hq ADR 0329, issue 586): identities, calendars, address books, filters // and tags declared mesh-wide, adopted or created as accounts are, filters owned whole per declared account, what // Betterbird rewrites written only while it is closed, derived from the nodes' profiles one layer at a time, and // shown with the operator's own values but never a credential. // profileWithConfiguration adds to profileWithAccounts' profile: account1's directory and signature, CalDAV // calendars (one address twice), an ICS and an Owl calendar, a local and a CardDAV address book, two tags, and // account1's filter file holding one filter of the person's. func (f *fake) profileWithConfiguration() { f.t.Helper() prefs := f.read(profileDir+"/prefs.js") + `user_pref("mail.server.server1.directory-rel", "[ProfD]ImapMail/imap.example.org");` + "\n" + `user_pref("mail.server.server2.directory-rel", "[ProfD]Mail/Local Folders");` + "\n" + `user_pref("mail.identity.id1.htmlSigText", "Regards");` + "\n" + `user_pref("calendar.registry.c1.type", "caldav");` + "\n" + `user_pref("calendar.registry.c1.uri", "https://dav.example.org/cal/home/");` + "\n" + `user_pref("calendar.registry.c1.name", "Home");` + "\n" + `user_pref("calendar.registry.c1.color", "#3366ff");` + "\n" + `user_pref("calendar.registry.c2.type", "caldav");` + "\n" + `user_pref("calendar.registry.c2.uri", "https://dav.example.org/cal/shared/");` + "\n" + `user_pref("calendar.registry.c2.name", "Shared A");` + "\n" + `user_pref("calendar.registry.c3.type", "caldav");` + "\n" + `user_pref("calendar.registry.c3.uri", "https://dav.example.org/cal/shared/");` + "\n" + `user_pref("calendar.registry.c3.name", "Shared B");` + "\n" + `user_pref("calendar.registry.c4.type", "ics");` + "\n" + `user_pref("calendar.registry.c4.uri", "https://feeds.example.org/secret-token-abc.ics");` + "\n" + `user_pref("calendar.registry.c4.name", "Holidays");` + "\n" + `user_pref("calendar.registry.c5.type", "owl");` + "\n" + `user_pref("calendar.registry.c5.uri", "owl://work@corp.example.com/calendar");` + "\n" + `user_pref("calendar.registry.c5.name", "Work");` + "\n" + `user_pref("calendar.list.sortOrder", "c1 c2 c3 c4 c5");` + "\n" + `user_pref("ldap_2.servers.pab.description", "Personal Address Book");` + "\n" + `user_pref("ldap_2.servers.pab.dirType", 101);` + "\n" + `user_pref("ldap_2.servers.ab1.description", "Contacts");` + "\n" + `user_pref("ldap_2.servers.ab1.dirType", 102);` + "\n" + `user_pref("ldap_2.servers.ab1.carddav.url", "https://dav.example.org/card/me/");` + "\n" + `user_pref("ldap_2.servers.ab1.carddav.username", "me");` + "\n" + `user_pref("mailnews.tags.$label1.tag", "Important");` + "\n" + `user_pref("mailnews.tags.$label1.color", "#FF0000");` + "\n" + `user_pref("mailnews.tags.personal.tag", "Personal");` + "\n" + `user_pref("mailnews.tags.personal.color", "#00AA00");` + "\n" f.write(profileDir+"/prefs.js", prefs) f.write(profileDir+"/ImapMail/imap.example.org/"+filterFileName, "version=\"9\"\nlogging=\"no\"\nname=\"Old\"\n"+ "enabled=\"yes\"\ntype=\"17\"\naction=\"Mark read\"\ncondition=\"AND (from,contains,news)\"\n") f.write(profileDir+"/Mail/Local Folders/"+filterFileName, "version=\"9\"\nlogging=\"no\"\n") f.write(profileDir+"/calendar-data/cache.sqlite", "events") } const declaredConfiguration = `{ "identities": { "mail": {"account": "mail", "signature": ["-- ", "Other"], "signature-above-quote": true}, "mailalias": {"account": "mail", "address": "alias@mail.example.org", "name": "Alias", "reply-to": "other@mail.example.org"} }, "calendars": { "home": {"name": "Home", "url": "https://dav.example.org/cal/home", "color": "#3366CC", "user": "other", "invitations": "mail"}, "work": {"name": "Team", "url": "https://dav.example.org/cal/team/"}, "shared": {"name": "Shared", "url": "https://dav.example.org/cal/shared/"} }, "address-books": { "contacts": {"name": "Contacts", "url": "https://dav.example.org/card/me/", "user": "other"}, "team": {"name": "Team", "url": "https://dav.example.org/card/team/"} }, "filters": {} }` const declaredTags = `{"tags": {"$label1": {"name": "Important", "color": "#ff0000", "order": 1}, "todo": {"name": "To do", "color": "#0000ff", "order": 2}}}` func (f *fake) declareConfiguration(body, tags string) { f.write(testHome+"/"+configurationFile, body) f.write(testHome+"/"+tagsFile, tags) } func entriesBy(u UserJS) map[string]resolvedEntry { out := map[string]resolvedEntry{} for _, e := range u.Configuration { out[e.Kind+"/"+e.Name] = e } return out } // §2, §4, §6: the settings' shapes; a credential, an address with a password, a key on a tag and the four new // prefixes in prefs are refused, said without the value. func TestTheSettingsRefuseACredentialAPasswordedAddressATagsKeyAndTheirPrefixesInPrefs(t *testing.T) { f := newFake(t) f.declareConfiguration(declaredConfiguration, declaredTags) c, err := f.readConfiguration() if err != nil || len(c.Identities) != 2 || len(c.Calendars) != 3 || len(c.AddressBooks) != 2 || len(c.Tags) != 2 { t.Fatalf("the declared shapes are taken: %+v %v", c, err) } if c.Calendars["home"].Color != "#3366cc" || c.Tags["todo"].Color != "#0000FF" { t.Errorf("colours are said as Betterbird keeps them: %+v %+v", c.Calendars["home"], c.Tags["todo"]) } for _, bad := range []struct{ conf, tags, says string }{ {`{"calendars": {"home": {"name": "H", "url": "https://dav.example.org/c/", "password": "hunter2"}}}`, `{}`, "shaped like a credential"}, {`{"calendars": {"home": {"name": "H", "url": "https://me:hunter2@dav.example.org/c/"}}}`, `{}`, "carries a password"}, {`{"address-books": {"b": {"name": "B", "url": "https://me:hunter2@dav.example.org/b/"}}}`, `{}`, "carries a password"}, {`{}`, `{"tags": {"todo": {"name": "To do", "key": "x"}}}`, "does not hold the shape"}, {`{"tags": {"todo": {"name": "To do"}}}`, `{}`, "holds no tags"}, {`{"identities": {"x": {"account": "mail", "name": "Me"}}}`, `{}`, "default identity"}, {`{"filters": {"mail": [{"name": "F", "enabled": true, "when": ["manual"], "match": "all", "actions": [{"action": "Delete"}]}]}}`, `{}`, "needs a condition"}, {`{"filters": {"mail": [{"name": "F", "enabled": true, "when": ["manual"], "match": "every-message", "actions": [{"action": "Move to folder", "folder": "imap://x@y/INBOX"}]}]}}`, `{}`, "path within the account"}, } { f.declareConfiguration(bad.conf, bad.tags) _, err := f.readConfiguration() if err == nil || !strings.Contains(err.Error(), bad.says) || strings.Contains(err.Error(), "hunter2") { t.Errorf("%s %s: %v", bad.conf, bad.tags, err) } } // A key is an identity's, a calendar's or an address book's. f.declareConfiguration(`{"identities": {"x": {"account": "mail", "address": "x@mail.example.org", "key": "id9"}}, "calendars": {"c": {"name": "C", "url": "https://dav.example.org/c/", "key": "c9"}}, "address-books": {"b": {"name": "B", "url": "https://dav.example.org/b/", "key": "ab9"}}}`, `{}`) if _, err := f.readConfiguration(); err != nil { t.Fatal(err) } // prefs refuses the four prefixes the new settings own, and the account's and a credential's as before. for _, name := range []string{"calendar.registry.c1.color", "calendar.list.sortOrder", "ldap_2.servers.ab1.description", "mailnews.tags.$label1.color", "mail.identity.id1.htmlSigText", "signon.rememberSignons"} { body, _ := json.Marshal(map[string]any{"prefs": map[string]any{name: "x"}}) if _, err := parseDeclaredPrefs(body); err == nil || !strings.Contains(err.Error(), name) { t.Errorf("%s: %v", name, err) } } got, err := parseDeclaredPrefs([]byte(`{"prefs": {"mail.uifontsize": 14, "mail.chat.enabled": false, "calendar.alarms.show": null, "intl.date_time.pattern_override.date_short": "yyyy-MM-dd"}}`)) if err != nil || !reflect.DeepEqual(got, map[string]string{"mail.uifontsize": "14", "mail.chat.enabled": "false", "intl.date_time.pattern_override.date_short": `"yyyy-MM-dd"`}) { t.Fatalf("one entry per preference, null taking one out: %v %v", got, err) } if _, err := parseDeclaredPrefs([]byte(`{"prefs": {"mail.uifontsize": 14.5}}`)); err == nil { t.Fatal("a number Betterbird does not keep is refused") } } // §7, §9: each kind is adopted, created or refused as accounts are; the declared fields go into the user.js, and // what Betterbird rewrites waits for it to close; leaving the setting releases or removes, never events. func TestEachKindIsAdoptedOrCreatedAndWhatBetterbirdRewritesWaitsForItToClose(t *testing.T) { f := newFake(t) f.profileWithAccounts() f.profileWithConfiguration() f.noPackage() links(t, nil) f.declare("// none") f.declareAccounts(adoptable) f.declareConfiguration(declaredConfiguration, declaredTags) before := f.read(profileDir + "/prefs.js") // Betterbird runs: the user.js is placed, prefs.js and the filter files wait (§9). u, err := f.userJS(true) if err != nil { t.Fatal(err) } by := entriesBy(u) for want, how := range map[string][2]string{ "identities/mail": {adopted, "id1"}, "identities/mailalias": {created, "ident_mesh_mailalias"}, "calendars/home": {adopted, "c1"}, "calendars/work": {created, "mesh_work"}, "calendars/shared": {refused, ""}, "address-books/contacts": {adopted, "ab1"}, "address-books/team": {created, "mesh_team"}, "tags/$label1": {adopted, "$label1"}, "tags/todo": {created, "todo"}, } { if e := by[want]; e.How != how[0] || e.Key != how[1] { t.Errorf("%s: %+v, want %v", want, e, how) } } if why := by["calendars/shared"].Why; !strings.Contains(why, "2 entries") || !strings.Contains(why, "c2, c3") || !strings.Contains(why, "key in this node's layer") { t.Errorf("an ambiguous match is refused with the keys and the remedy: %s", why) } placed := f.read(profileDir + "/user.js") for _, want := range []string{ `user_pref("mail.identity.id1.htmlSigText", "-- \nOther");`, `user_pref("mail.identity.id1.reply_on_top", 1);`, `user_pref("mail.identity.id1.sig_bottom", false);`, `user_pref("mail.identity.ident_mesh_mailalias.useremail", "alias@mail.example.org");`, `user_pref("mail.identity.ident_mesh_mailalias.smtpServer", "smtp1");`, `user_pref("calendar.registry.c1.color", "#3366cc");`, `user_pref("calendar.registry.c1.imip.identity.key", "id1");`, `user_pref("calendar.registry.mesh_work.type", "caldav");`, `user_pref("ldap_2.servers.ab1.carddav.username", "other");`, `user_pref("ldap_2.servers.mesh_team.dirType", 102);`, `user_pref("mailnews.tags.todo.color", "#0000FF");`, `user_pref("mailnews.tags.todo.ordinal", "1002");`, } { if !strings.Contains(placed, want) { t.Errorf("the user.js lacks %s", want) } } for _, never := range []string{"calendar.registry.c2.", "calendar.registry.c3.", "calendar.registry.c4.", "calendar.registry.c5.", "ldap_2.servers.pab.", "mailnews.tags.personal.", "calendar.list.sortOrder"} { if strings.Contains(placed, never) { t.Errorf("the user.js writes %s, which nothing declares or which is refused", never) } } if !strings.HasPrefix(u.Closed, "not written") || !strings.Contains(u.Closed, "running") || u.Waiting[kindIdentities] != 1 || u.Waiting[kindCalendars] != 1 { t.Fatalf("while Betterbird runs prefs.js is not written, and the waiting changes are counted: %q %v", u.Closed, u.Waiting) } if f.read(profileDir+"/prefs.js") != before { t.Fatal("prefs.js changed while Betterbird runs") } ch, _ := f.Check() waits := false for _, x := range ch.Findings { waits = waits || (strings.Contains(x.What, "change(s) to calendars are not written yet") && strings.Contains(x.Do, "betterbird_restart")) } if !waits { t.Fatalf("the check counts the waiting changes per kind: %+v", ch.Findings) } // A key in the node's layer settles the ambiguous calendar; one the profile does not hold is refused. f.declareConfiguration(strings.Replace(declaredConfiguration, `"url": "https://dav.example.org/cal/shared/"}`, `"url": "https://dav.example.org/cal/shared/", "key": "c3"}`, 1), declaredTags) if u, _ = f.userJS(false); entriesBy(u)["calendars/shared"].Key != "c3" || entriesBy(u)["calendars/shared"].How != adopted { t.Fatalf("the named key is adopted: %+v", entriesBy(u)["calendars/shared"]) } f.declareConfiguration(strings.Replace(declaredConfiguration, `"url": "https://dav.example.org/cal/shared/"}`, `"url": "https://dav.example.org/cal/shared/", "key": "c9"}`, 1), declaredTags) if u, _ = f.userJS(false); entriesBy(u)["calendars/shared"].How != refused || !strings.Contains(entriesBy(u)["calendars/shared"].Why, "c9") { t.Fatalf("a key the profile does not hold is refused: %+v", entriesBy(u)["calendars/shared"]) } f.declareConfiguration(strings.Replace(declaredConfiguration, `"url": "https://dav.example.org/cal/shared/"}`, `"url": "https://dav.example.org/cal/shared/", "key": "c1"}`, 1), declaredTags) if u, _ = f.userJS(false); entriesBy(u)["calendars/shared"].How != refused { t.Fatalf("a key whose entry does not match is refused: %+v", entriesBy(u)["calendars/shared"]) } f.declareConfiguration(declaredConfiguration, declaredTags) // Closed: the lists gain the module's own keys at their end, every other line as it was. stopBetterbird(t, f) f.write(profileDir+"/lock", "") if err := f.Prepare(); err == nil || !strings.Contains(err.Error(), "locked") { t.Fatalf("the profile's lock holds the writes back: %v", err) } os.Remove(filepath.Join(f.Root, profileDir, "lock")) if err := f.Prepare(); err != nil && !strings.Contains(err.Error(), "calendars shared is refused") { t.Fatal(err) } after := f.read(profileDir + "/prefs.js") for _, want := range []string{`user_pref("mail.account.account1.identities", "id1,ident_mesh_mailalias");`, `user_pref("calendar.list.sortOrder", "c1 c2 c3 c4 c5 mesh_work");`} { if !strings.Contains(after, want) { t.Errorf("prefs.js lacks %s:\n%s", want, after) } } bl, al := strings.Split(before, "\n"), strings.Split(after, "\n") for i := range bl { if bl[i] != al[i] && !strings.Contains(bl[i], ".identities") && !strings.Contains(bl[i], "sortOrder") { t.Errorf("line %d changed: %q to %q", i+1, bl[i], al[i]) } } // Leaving: an adopted entry is released (its lines go, the profile keeps it), a created one's preferences are // removed; the calendar's events stay. f.write(profileDir+"/prefs.js", f.read(profileDir+"/prefs.js")+ `user_pref("calendar.registry.mesh_work.calendarSyncState", "x");`+"\n"+`user_pref("mailnews.tags.todo.tag", "To do");`+"\n") f.declareConfiguration(`{"identities": {"mail": {"account": "mail"}}}`, `{"tags": {}}`) if err := f.Prepare(); err != nil { t.Fatal(err) } final := f.read(profileDir + "/prefs.js") for _, gone := range []string{"mesh_work", "ident_mesh_mailalias", "mailnews.tags.todo."} { if strings.Contains(final, gone) { t.Errorf("what the module created and the setting left is removed: %s is still there", gone) } } for _, kept := range []string{`calendar.registry.c1.uri`, `ldap_2.servers.ab1.carddav.url`, `mailnews.tags.$label1.tag`, `calendar.registry.c4.uri`} { if !strings.Contains(final, kept) { t.Errorf("released or undeclared, %s stays the person's", kept) } } if f.read(profileDir+"/calendar-data/cache.sqlite") != "events" { t.Error("a calendar's events are never touched") } if placed := f.read(profileDir + "/user.js"); strings.Contains(placed, "calendar.registry.c1.") || strings.Contains(placed, "ab1") { t.Errorf("a released entry's lines leave the user.js:\n%s", placed) } } const declaredFilters = `{"filters": {"mail": [ {"name": "Receipts", "enabled": true, "when": ["new-mail", "manual"], "match": "all", "conditions": [{"field": "subject", "op": "contains", "value": "receipt"}], "actions": [{"action": "Move to folder", "folder": "INBOX/Receipts"}, {"action": "Forward", "value": "archive@mail.example.org"}]}, {"name": "Everything", "enabled": false, "when": ["manual"], "match": "every-message", "actions": [{"action": "Mark read"}]} ]}}` const renderedFilters = "version=\"9\"\nlogging=\"no\"\n" + "name=\"Receipts\"\nenabled=\"yes\"\ntype=\"17\"\naction=\"Move to folder\"\n" + "actionValue=\"imap://other2@imap.example.org/INBOX/Receipts\"\naction=\"Forward\"\nactionValue=\"archive@mail.example.org\"\n" + "condition=\"AND (subject,contains,receipt)\"\n" + "name=\"Everything\"\nenabled=\"no\"\ntype=\"16\"\naction=\"Mark read\"\ncondition=\"ALL\"\n" // §8: an account named in filters has its msgFilterRules.dat written whole from the list, the replaced file kept; // an account never named keeps its file byte for byte; one taken out keeps the file it last had. func TestFiltersAreOwnedWholePerDeclaredAccount(t *testing.T) { f := newFake(t) f.profileWithAccounts() f.profileWithConfiguration() f.noPackage() links(t, nil) f.declare("// none") f.declareAccounts(adoptable) f.declareConfiguration(declaredFilters, `{"tags": {}}`) filters := profileDir + "/ImapMail/imap.example.org/" + filterFileName local := profileDir + "/Mail/Local Folders/" + filterFileName old, oldLocal := f.read(filters), f.read(local) u, err := f.userJS(true) if err != nil { t.Fatal(err) } if u.Waiting[kindFilters] != 1 || f.read(filters) != old { t.Fatalf("while Betterbird runs the filter file waits: %v", u.Waiting) } stopBetterbird(t, f) if err := f.Prepare(); err != nil { t.Fatal(err) } if got := f.read(filters); got != renderedFilters { t.Fatalf("the file is the declared list, its folder this node's address:\n%s", got) } keptPath := testHome + "/.config/betterbird/kept/60016jsp.default-default/mail." + filterFileName if f.read(keptPath) != old { t.Fatal("the replaced file is kept in the module's own directory") } if f.read(local) != oldLocal { t.Fatal("an account never named keeps its file byte for byte") } if parsed := parseFilters(renderedFilters, "imap://other2@imap.example.org"); len(parsed.NotDeclarable) != 0 || mustJSON(parsed.Filters) != mustJSON(mustConfig(t, declaredFilters).Filters["mail"]) { t.Fatalf("the file reads back as the declared list: %+v", parsed) } // A filter the person adds in Betterbird is drift first, and gone at the next apply; the kept file stays the first. f.write(filters, renderedFilters+"name=\"Mine\"\nenabled=\"yes\"\ntype=\"16\"\naction=\"Delete\"\ncondition=\"ALL\"\n") d, _ := f.Drift(kindFilters) if len(d.Kinds) != 1 || len(d.Kinds[0].Declared) != 1 || d.Kinds[0].Declared[0].State != "differs" || !strings.Contains(mustJSON(d.Kinds[0].Declared[0].Differs), "Mine") { t.Fatalf("drift shows the added filter: %+v", d) } if err := f.Prepare(); err != nil { t.Fatal(err) } if f.read(filters) != renderedFilters || f.read(keptPath) != old { t.Fatal("the added filter is gone, and the kept file is still the one first replaced") } // Taken out of filters: the module stops writing, the last file stays the person's, the kept copy is named. f.declareConfiguration(`{"filters": {}}`, `{"tags": {}}`) f.write(filters, renderedFilters+"name=\"Later\"\nenabled=\"yes\"\ntype=\"16\"\naction=\"Delete\"\ncondition=\"ALL\"\n") lastHad := f.read(filters) if err := f.Prepare(); err != nil { t.Fatal(err) } if f.read(filters) != lastHad || f.read(keptPath) != old { t.Fatal("an account taken out keeps its file byte for byte, and the kept copy stays") } l, _ := f.List(kindFilters) if l.Kept["mail"] != "~/.config/betterbird/kept/60016jsp.default-default/mail."+filterFileName { t.Fatalf("betterbird_filters says where the kept copy is: %+v", l.Kept) } d, _ = f.Drift(kindFilters) if len(d.Kinds[0].Declared) != 0 || len(d.Kinds[0].Undeclared) != 0 { t.Fatalf("drift no longer counts that account's filters: %+v", d.Kinds[0]) } } func mustConfig(t *testing.T, body string) Configuration { t.Helper() var c Configuration if err := decodeInto([]byte(body), configurationFile, &c); err != nil { t.Fatal(err) } return c } // §11: per kind and entry, the field that differs with both values; an undeclared entry is listed; once the module's // user.js is placed, what it declares holds. func TestDriftNamesEachDifferingFieldAndTheUndeclaredEntries(t *testing.T) { f := newFake(t) f.profileWithAccounts() f.profileWithConfiguration() f.noPackage() links(t, nil) f.declare("// none") f.declareAccounts(adoptable) f.declareConfiguration(strings.Replace(declaredConfiguration, `"work": {"name": "Team", "url": "https://dav.example.org/cal/team/"},`, "", 1), declaredTags) d, err := f.Drift("") if err != nil { t.Fatal(err) } by := map[string]KindDrift{} for _, k := range d.Kinds { by[k.Kind] = k } field := func(kind, name, f string) *fieldDiff { for _, e := range by[kind].Declared { if e.Name == name { for _, x := range e.Differs { if x.Field == f { return &x } } } } return nil } if x := field(kindIdentities, "mail", "signature"); x == nil || mustJSON(x.Profile) != `["Regards"]` { t.Errorf("the signature differs, with both values: %+v", by[kindIdentities]) } if x := field(kindCalendars, "home", "color"); x == nil || x.Declared != "#3366cc" || x.Profile != "#3366ff" { t.Errorf("the colour differs: %+v", by[kindCalendars]) } if x := field(kindAddressBooks, "contacts", "user"); x == nil || x.Profile != "me" { t.Errorf("the user differs: %+v", by[kindAddressBooks]) } states := map[string]string{} for _, k := range d.Kinds { for _, e := range k.Declared { states[k.Kind+"/"+e.Name] = e.State } } if states["tags/todo"] != "lacks" || states["calendars/shared"] != refused || states["tags/$label1"] != "holds" || states["address-books/team"] != "lacks" { t.Errorf("lacks, refused and holds: %v", states) } undeclared := mustJSON(d) for _, want := range []string{`"key":"personal"`, `"key":"c2"`, `"key":"c3"`} { if !strings.Contains(undeclared, want) { t.Errorf("the undeclared entries are listed: lacks %s", want) } } if _, err := f.userJS(true); err != nil { t.Fatal(err) } d, _ = f.Drift("") for _, k := range d.Kinds { for _, e := range k.Declared { if e.State == "differs" { t.Errorf("once the user.js is placed, %s %s holds: %+v", k.Kind, e.Name, e) } } } } // §1, §2, §13: the listing tools answer the operator's own values, Owl's marked, an ICS calendar by its name only, // and never a credential: through the tools, a password in an address and a field named for one are withheld. func TestTheListingsShowTheValuesOwlsMarkedAnICSByNameAndNoCredential(t *testing.T) { f := newFake(t) f.profileWithAccounts() f.profileWithConfiguration() f.write(profileDir+"/prefs.js", f.read(profileDir+"/prefs.js")+ `user_pref("calendar.registry.c6.type", "caldav");`+"\n"+ `user_pref("calendar.registry.c6.uri", "https://other:hunter2@dav.example.org/cal/x/");`+"\n"+ `user_pref("calendar.registry.c6.name", "Old");`+"\n") f.write(profileDir+"/logins.json", `{"logins":[{"hostname":"https://dav.example.org","encryptedPassword":"hunter3"}]}`) links(t, nil) f.declare("// none") f.declareAccounts(adoptable) f.declareConfiguration(declaredConfiguration, declaredTags) old := machine machine = f.Machine t.Cleanup(func() { machine = old }) answers := map[string]string{} for _, tool := range tools() { if strings.HasPrefix(tool.Name, "betterbird_") && contains([]string{"betterbird_identities", "betterbird_calendars", "betterbird_address_books", "betterbird_filters", "betterbird_tags", "betterbird_accounts", "betterbird_drift"}, tool.Name) { out, err := tool.Run(map[string]any{}) if err != nil { t.Fatalf("%s: %v", tool.Name, err) } answers[tool.Name] = mustJSON(out) } } cals := answers["betterbird_calendars"] for _, shown := range []string{"https://dav.example.org/cal/home/", `"name":"Work","url":"owl://work@corp.example.com/calendar"`, `"owl":true`, `"name":"Holidays"`} { if !strings.Contains(cals, shown) { t.Errorf("betterbird_calendars shows %s: %s", shown, cals) } } if strings.Contains(cals, "secret-token") { t.Error("an ICS calendar is listed by its name only") } for name, a := range answers { if strings.Contains(a, "hunter2") || strings.Contains(a, "hunter3") { t.Errorf("%s carries a credential: %s", name, a) } } if !strings.Contains(cals, "https://other:‹withheld›@dav.example.org/cal/x/") { t.Errorf("an address's password is shown as ‹withheld›: %s", cals) } for _, shown := range []string{`"signature":["Regards"]`, "other@mail.example.org"} { if !strings.Contains(answers["betterbird_identities"], shown) { t.Errorf("betterbird_identities shows %s", shown) } } if got := mustJSON(credentialChecked(map[string]any{"name": "x", "password": "p", "nested": []any{map[string]any{"oauthToken": "t"}}})); got != `{"name":"x","nested":[{"oauthToken":"‹withheld›"}],"password":"‹withheld›"}` { t.Errorf("a field named for a credential is withheld: %s", got) } // The start's words reach the journal: a refusal names kinds, names and keys, never an address. stopBetterbird(t, f) err := f.Prepare() if err == nil || !strings.Contains(err.Error(), "calendars shared is refused") || strings.Contains(err.Error(), "example.org") { t.Fatalf("%v", err) } }