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
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.
317 lines
13 KiB
Go
317 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// betterbird's shape (novox/hq issue 345, ADR 0208, ADR 0316): the X display on its own machine; its directory
|
|
// in the home with the mergeable files the settings fill, each naming the keys it takes (ADR 0326): prefs, accounts
|
|
// and configuration (identities, calendars, address-books, filters) trusted, tags not (ADR 0329 §5); the community package declared absent before anything is placed in /opt/betterbird; Betterbird from
|
|
// its pinned upstream release, Owl pinned by its vendor's digest, the policy and the dictionaries' default
|
|
// unpacked into the installation; the tools' program for the start's preparation, the start and the launcher;
|
|
// every new build waiting for the operator's push; and the Go bundle serving exactly the listed tools.
|
|
|
|
type manifest struct {
|
|
Module string `json:"module"`
|
|
Version string `json:"version"`
|
|
Upgrade map[string]string `json:"upgrade"`
|
|
Requires []string `json:"requires"`
|
|
Invokes []string `json:"invokes"`
|
|
Settings map[string]any `json:"settings"`
|
|
Tools []string `json:"tools"`
|
|
Resources []map[string]any `json:"resources"`
|
|
Build struct {
|
|
Artifacts []map[string]any `json:"artifacts"`
|
|
} `json:"build"`
|
|
}
|
|
|
|
func readManifest(t *testing.T) (manifest, string) {
|
|
t.Helper()
|
|
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
|
dec.DisallowUnknownFields()
|
|
var m manifest
|
|
if err := dec.Decode(&m); err != nil {
|
|
t.Fatalf("module.json: %v", err)
|
|
}
|
|
return m, string(raw)
|
|
}
|
|
|
|
func resource(t *testing.T, m manifest, id string) map[string]any {
|
|
t.Helper()
|
|
for _, r := range m.Resources {
|
|
if r["id"] == id {
|
|
return r
|
|
}
|
|
}
|
|
t.Fatalf("module.json has no resource %s", id)
|
|
return nil
|
|
}
|
|
|
|
func artifact(t *testing.T, m manifest, name string) map[string]any {
|
|
t.Helper()
|
|
for _, a := range m.Build.Artifacts {
|
|
if a["name"] == name {
|
|
return a
|
|
}
|
|
}
|
|
t.Fatalf("module.json builds no artifact %s", name)
|
|
return nil
|
|
}
|
|
|
|
func TestItPlacesBetterbirdWhereTheCommunityPackageDidAndOnlyAfterItIsGone(t *testing.T) {
|
|
m, raw := readManifest(t)
|
|
if m.Module != "betterbird" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) {
|
|
t.Fatalf("%+v", m)
|
|
}
|
|
// Every new build waits for a person's push: the switch removes a package and replaces what a running
|
|
// Betterbird has open (ADR 0316 §4).
|
|
if m.Upgrade["policy"] != "record" || strings.TrimSpace(m.Upgrade["why"]) == "" {
|
|
t.Fatalf("the module is marked for a person's approval, with why: %v", m.Upgrade)
|
|
}
|
|
var ids []string
|
|
for _, r := range m.Resources {
|
|
ids = append(ids, r["id"].(string))
|
|
}
|
|
want := "configuration-dir,prefs,accounts,configuration,tags,community-package,installation,owl,policies,spelling,preparation,start,launcher"
|
|
if strings.Join(ids, ",") != want {
|
|
t.Fatalf("the resources, in the order the node-engine applies them: %v, want %s", ids, want)
|
|
}
|
|
pkg := resource(t, m, "community-package")
|
|
if pkg["type"] != "package" || pkg["package"] != packageFor || pkg["absent"] != true {
|
|
t.Fatalf("the community package is declared absent (ADR 0180): %v", pkg)
|
|
}
|
|
places := map[string]string{"installation": installDir, "owl": filepath.Dir(owlFile),
|
|
"policies": filepath.Dir(policyFile), "spelling": installDir + "/defaults/pref", "preparation": filepath.Dir(preparation)}
|
|
arts := map[string]string{"installation": "archive", "owl": "owl", "policies": "distribution", "spelling": "defaults", "preparation": "tools"}
|
|
for id, path := range places {
|
|
r := resource(t, m, id)
|
|
if r["type"] != "archive" || r["path"] != path || r["artifact"] != arts[id] || r["owner"] != nil {
|
|
t.Errorf("%s is the %s archive unpacked at %s, root's: %v", id, arts[id], path, r)
|
|
}
|
|
}
|
|
for _, never := range []string{".thunderbird", "prefs.js", "logins.json", "key4.db"} {
|
|
if regexp.MustCompile(regexp.QuoteMeta(never) + `\b`).MatchString(raw) {
|
|
t.Errorf("module.json names %q: the profile is Betterbird's", never)
|
|
}
|
|
}
|
|
s := strings.ToLower(raw)
|
|
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http://", "mediahuis"} {
|
|
if strings.Contains(s, never) {
|
|
t.Errorf("module.json names %q", never)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ADR 0329 §4–§6, ADR 0326, issue 586: every setting reaches the machine as a mergeable file of the account's that
|
|
// names the keys it takes; four kinds and accounts and prefs are trusted, tags is not; no setting has a default in the
|
|
// definition, since each is the operator's own value; and each file's default declares nothing.
|
|
func TestTheSettingsAreMergeableFilesThatNameTheirKeysAndTagsAloneIsUntrusted(t *testing.T) {
|
|
m, _ := readManifest(t)
|
|
if len(m.Settings) != 0 {
|
|
t.Fatalf("no setting has a default in the definition: the operator's setup is the settings' mesh-wide layer (issue 586, ADR 0262): %v", m.Settings)
|
|
}
|
|
files := map[string]struct {
|
|
path, mode string
|
|
takes []any
|
|
trusted bool
|
|
}{
|
|
"prefs": {prefsFile, "0644", []any{"prefs"}, true},
|
|
"accounts": {accountsFile, "0600", []any{"accounts"}, true},
|
|
"configuration": {configurationFile, "0600", []any{"identities", "calendars", "address-books", "filters"}, true},
|
|
"tags": {tagsFile, "0644", []any{"tags"}, false},
|
|
}
|
|
merging := 0
|
|
for _, r := range m.Resources {
|
|
if r["merge"] != nil {
|
|
merging++
|
|
}
|
|
}
|
|
if merging != len(files) {
|
|
t.Fatalf("%d mergeable files, want %d", merging, len(files))
|
|
}
|
|
for id, want := range files {
|
|
r := resource(t, m, id)
|
|
if r["path"] != "${machine:account-home}/"+want.path || r["merge"] != "json" || r["trusted"] != want.trusted ||
|
|
r["mode"] != want.mode || r["owner"] != "${machine:account}" || !reflect.DeepEqual(r["takes-settings"], want.takes) {
|
|
t.Errorf("%s: %v", id, r)
|
|
}
|
|
var content map[string]any
|
|
if err := json.Unmarshal([]byte(r["content"].(string)), &content); err != nil || len(content) != len(want.takes) {
|
|
t.Errorf("%s's default holds each key it takes, empty: %v %v", id, content, err)
|
|
}
|
|
}
|
|
got, err := parseAccounts([]byte(resource(t, m, "accounts")["content"].(string)))
|
|
if err != nil || len(got) != 0 {
|
|
t.Fatalf("its default declares no account: %v %v", got, err)
|
|
}
|
|
prefs, err := parseDeclaredPrefs([]byte(resource(t, m, "prefs")["content"].(string)))
|
|
if err != nil || len(prefs) != 0 {
|
|
t.Fatalf("its default declares no preference: %v %v", prefs, err)
|
|
}
|
|
var c Configuration
|
|
if err := decodeInto([]byte(resource(t, m, "configuration")["content"].(string)), configurationFile, &c); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := decodeInto([]byte(resource(t, m, "tags")["content"].(string)), tagsFile, &c); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestBothPinsAreAURLAVersionAndASha256AndOwlIsNeverInTheRepository(t *testing.T) {
|
|
m, _ := readManifest(t)
|
|
sha := regexp.MustCompile(`^[0-9a-f]{64}$`)
|
|
for _, name := range []string{"archive", "owl"} {
|
|
a := artifact(t, m, name)
|
|
u, _ := a["url"].(string)
|
|
if a["kind"] != "archive" || !strings.HasPrefix(u, "https://") || !sha.MatchString(fmt.Sprint(a["sha256"])) ||
|
|
strings.TrimSpace(fmt.Sprint(a["version"])) == "" || a["from"] != nil {
|
|
t.Errorf("%s is pinned by URL, version and sha256, and copied from nowhere in the repository: %v", name, a)
|
|
}
|
|
}
|
|
if a := artifact(t, m, "archive"); a["within"] != "betterbird" {
|
|
t.Errorf("the release unpacks into betterbird/, whose content is the installation: %v", a)
|
|
}
|
|
if a := artifact(t, m, "owl"); a["file"] != filepath.Base(owlFile) {
|
|
t.Errorf("Owl's xpi is kept whole as %s: %v", filepath.Base(owlFile), a)
|
|
}
|
|
// Owl's licence forbids copying it to third parties: no xpi anywhere in the module's tree.
|
|
filepath.Walk(filepath.Join("..", ".."), func(p string, info os.FileInfo, err error) error {
|
|
if err == nil && strings.HasSuffix(strings.ToLower(p), ".xpi") {
|
|
t.Errorf("%s is in the repository", p)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// owlSupports is the range of Betterbird versions each pinned Owl declares in its manifest.json
|
|
// (browser_specific_settings.gecko strict_min_version, strict_max_version), read from the vendor's xpi
|
|
// when its pin was taken. Moving Owl's pin means reading the new xpi's range into here.
|
|
var owlSupports = map[string][2]string{
|
|
"1.5.6.1": {"115.0", "156.*"},
|
|
}
|
|
|
|
// owlAccepts says why a Betterbird version is outside the range an Owl version declares, or nil.
|
|
func owlAccepts(owl, betterbird string) error {
|
|
r, known := owlSupports[owl]
|
|
if !known {
|
|
return fmt.Errorf("Owl %s's supported range is not recorded in owlSupports: read it from the xpi's manifest.json", owl)
|
|
}
|
|
major := func(v string) int {
|
|
head, _, _ := strings.Cut(v, ".")
|
|
n, err := strconv.Atoi(head)
|
|
if err != nil {
|
|
return -1
|
|
}
|
|
return n
|
|
}
|
|
b, lo, hi := major(betterbird), major(r[0]), major(r[1])
|
|
if b < 0 || b < lo || b > hi {
|
|
return fmt.Errorf("Betterbird %s is outside the range Owl %s supports (%s to %s): a Betterbird Owl does not support "+
|
|
"waits for Owl's pin to move (novox/hq ADR 0316 §8)", betterbird, owl, r[0], r[1])
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func TestABetterbirdOwlDoesNotSupportIsRefused(t *testing.T) {
|
|
m, _ := readManifest(t)
|
|
owl, bb := fmt.Sprint(artifact(t, m, "owl")["version"]), fmt.Sprint(artifact(t, m, "archive")["version"])
|
|
if err := owlAccepts(owl, bb); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, outside := range []string{"157.0esr-bb1", "102.15.1esr-bb4"} {
|
|
if err := owlAccepts(owl, outside); err == nil {
|
|
t.Errorf("Betterbird %s was accepted with Owl %s", outside, owl)
|
|
}
|
|
}
|
|
if err := owlAccepts("9.9", bb); err == nil {
|
|
t.Error("an Owl whose range nobody read was accepted")
|
|
}
|
|
}
|
|
|
|
func TestThePolicyTurnsTheUpdaterOffAndInstallsTheMeshsOwlEverywhere(t *testing.T) {
|
|
m, _ := readManifest(t)
|
|
if a := artifact(t, m, "distribution"); a["kind"] != "archive" || a["from"] != "install/distribution" {
|
|
t.Fatalf("%v", a)
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join("..", "..", "install", "distribution", "policies.json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if why := policyProblem(raw); why != "" {
|
|
t.Fatalf("the module's policies.json: %s", why)
|
|
}
|
|
entries, _ := os.ReadDir(filepath.Join("..", "..", "install", "distribution"))
|
|
if len(entries) != 1 {
|
|
t.Fatalf("the distribution directory holds the policy alone: %v", entries)
|
|
}
|
|
if a := artifact(t, m, "defaults"); a["kind"] != "archive" || a["from"] != "install/defaults-pref" {
|
|
t.Fatalf("%v", a)
|
|
}
|
|
prefs, err := os.ReadFile(filepath.Join("..", "..", "install", "defaults-pref", "vendor-prefs.js"))
|
|
if err != nil || !strings.Contains(string(prefs), `pref("spellchecker.dictionary_path", "/usr/share/hunspell");`) {
|
|
t.Fatalf("the dictionaries are named as a default, in place of a link: %s %v", prefs, err)
|
|
}
|
|
}
|
|
|
|
func TestTheStartPreparesThenStartsBetterbirdAndTheLauncherUsesIt(t *testing.T) {
|
|
m, _ := readManifest(t)
|
|
start := resource(t, m, "start")
|
|
content := start["content"].(string)
|
|
if start["path"] != "/usr/local/bin/betterbird" || start["mode"] != "0755" ||
|
|
!strings.Contains(content, "\n"+preparation+" prepare ||") || !strings.HasSuffix(content, "\nexec "+startBin+" \"$@\"\n") {
|
|
t.Fatalf("the start runs the preparation, says when it did not complete, and starts Betterbird either way: %v", start)
|
|
}
|
|
launcher := resource(t, m, "launcher")
|
|
lc := launcher["content"].(string)
|
|
if launcher["path"] != "/usr/local/share/applications/eu.betterbird.Betterbird.desktop" ||
|
|
!strings.Contains(lc, "\nExec=/usr/local/bin/betterbird %u\n") || !strings.Contains(lc, "\nIcon="+installDir+"/") ||
|
|
!strings.Contains(lc, "\nStartupWMClass=eu.betterbird.Betterbird\n") {
|
|
t.Fatalf("the launcher starts the module's start and names the icon in the installation: %v", launcher)
|
|
}
|
|
if b := artifact(t, m, "tools"); filepath.Base(preparation) != b["binary"] {
|
|
t.Fatalf("the preparation runs the tools' binary: %v", b)
|
|
}
|
|
}
|
|
|
|
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
|
m, _ := readManifest(t)
|
|
served := map[string]bool{}
|
|
for _, tool := range tools() {
|
|
served[tool.Name] = true
|
|
if !strings.HasPrefix(tool.Name, "betterbird_") || strings.TrimSpace(tool.Description) == "" {
|
|
t.Errorf("%s: prefixed betterbird_ and described", tool.Name)
|
|
}
|
|
}
|
|
for _, name := range m.Tools {
|
|
if !served[name] {
|
|
t.Errorf("module.json lists %s, which the bundle does not serve", name)
|
|
}
|
|
delete(served, name)
|
|
}
|
|
for name := range served {
|
|
t.Errorf("the bundle serves %s, which module.json does not list", name)
|
|
}
|
|
b := artifact(t, m, "tools")
|
|
if b["kind"] != "bundle" || b["language"] != "go" || b["from"] != "cmd/betterbird-tools" || b["binary"] != "betterbird-tools" {
|
|
t.Errorf("the Go tools bundle: %v", b)
|
|
}
|
|
// The tools propose through the controller's settings verb (ADR 0317 §9), and betterbird_derive asks the controller
|
|
// which nodes hold the module and each of them for its profile (ADR 0329 §10).
|
|
if !reflect.DeepEqual(m.Invokes, []string{"seat:mesh-controller.settings", "seat:mesh-controller.nodes",
|
|
"seat:mesh-controller.node", "betterbird.betterbird_derive"}) {
|
|
t.Errorf("the module invokes the controller's settings, nodes and node verbs and its own derive: %v", m.Invokes)
|
|
}
|
|
}
|