325 lines
13 KiB
Go
325 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"`
|
|
Config map[string]any `json:"config"`
|
|
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, ADR 0348: every value reaches the machine as a mergeable file of the
|
|
// account's that names the keys it takes, and every one is trusted, tags too since issue 751 (the operator's
|
|
// decision); each is an object whose default is the file's own, empty, never required, since a node without the
|
|
// operator's setup still runs Betterbird; and each file's default declares nothing.
|
|
func TestTheValuesAreTrustedMergeableFilesThatNameTheirKeys(t *testing.T) {
|
|
m, _ := readManifest(t)
|
|
if _, said := m.Config["tags"]; !said || len(m.Config) != 7 {
|
|
t.Fatalf("the seven values are declared under config: %v", m.Config)
|
|
}
|
|
for key, raw := range m.Config {
|
|
d, _ := raw.(map[string]any)
|
|
def, _ := d["default"].(map[string]any)
|
|
if d["type"] != "object" || d["trusted"] != true || d["required"] != nil || def == nil || len(def) != 0 {
|
|
t.Errorf("%s is a trusted object whose default is empty and which is not required: %v", key, d)
|
|
}
|
|
}
|
|
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"}, true},
|
|
}
|
|
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-config"], want.takes) {
|
|
t.Errorf("%s: %v", id, r)
|
|
}
|
|
var content map[string]any
|
|
if err := json.Unmarshal([]byte(templateText(t, r)), &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(templateText(t, resource(t, m, "accounts"))))
|
|
if err != nil || len(got) != 0 {
|
|
t.Fatalf("its default declares no account: %v %v", got, err)
|
|
}
|
|
prefs, err := parseDeclaredPrefs([]byte(templateText(t, resource(t, m, "prefs"))))
|
|
if err != nil || len(prefs) != 0 {
|
|
t.Fatalf("its default declares no preference: %v %v", prefs, err)
|
|
}
|
|
var c Configuration
|
|
if err := decodeInto([]byte(templateText(t, resource(t, m, "configuration"))), configurationFile, &c); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := decodeInto([]byte(templateText(t, resource(t, m, "tags"))), 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 := templateText(t, start)
|
|
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 := templateText(t, launcher)
|
|
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)
|
|
}
|
|
}
|