Files
mesh-catalog/modules/betterbird/cmd/betterbird-tools/configuration_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

519 lines
25 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}