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.
222 lines
9.5 KiB
Go
222 lines
9.5 KiB
Go
package main
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"reflect"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// ADR 0329 §10, §12: derived from every desktop node's profile, laid out per layer, and proposed one layer at a
|
||
// time, carrying the layer as it stands.
|
||
|
||
// derivingMesh is two desktop nodes, g14 (this one, f) and shanks (f2), and a server that holds no Betterbird, as the
|
||
// controller answers them; layers are the controller's layers, open the proposals that wait.
|
||
type derivingMesh struct {
|
||
t *testing.T
|
||
shanks *fake
|
||
layers map[string]map[string]any
|
||
open bool
|
||
proposed []map[string]any
|
||
asked []string
|
||
asleep bool
|
||
}
|
||
|
||
func (d *derivingMesh) ask(key string, body any) (json.RawMessage, error) {
|
||
d.asked = append(d.asked, key)
|
||
args, _ := body.(map[string]any)
|
||
answer := func(ok bool, text string) (json.RawMessage, error) {
|
||
b, _ := json.Marshal(map[string]any{"ok": ok, "output": text})
|
||
return b, nil
|
||
}
|
||
switch {
|
||
case key == "seat:mesh-controller.nodes":
|
||
return answer(true, `[{"name": "g14"}, {"name": "novox"}, {"name": "shanks"}]`)
|
||
case key == "seat:mesh-controller.node":
|
||
if args["node"] == "novox" {
|
||
return answer(true, "novox\n assigned: postgres, nats\n")
|
||
}
|
||
return answer(true, fmt.Sprintf("%s\n last heard from here\n assigned: adwaita, betterbird, dunst\n", args["node"]))
|
||
case key == "betterbird.betterbird_derive@shanks":
|
||
if d.asleep {
|
||
return nil, errors.New("nats: no responders available for request")
|
||
}
|
||
here, err := d.shanks.deriveHere(stringsOf(args["kinds"]))
|
||
if err != nil {
|
||
d.t.Fatal(err)
|
||
}
|
||
text, _ := json.Marshal(credentialChecked(DeriveAnswer{Here: &here}))
|
||
return json.Marshal(map[string]any{"content": []any{map[string]any{"type": "text", "text": string(text)}}})
|
||
case key == "seat:mesh-controller.settings" && args["proposals"] == "true":
|
||
if d.open {
|
||
return answer(true, "sab12 set betterbird on the whole mesh (accounts, identities)\n by g14.node-tools at 2026-10-13 01:00; fingerprint ab12\n waiting for the operator until 2026-10-14 01:00\n"+
|
||
"scd34 set mounts on shanks (sources)\n by g14.node-tools at 2026-10-11 00:05; fingerprint dd0b\n chosen: done\n")
|
||
}
|
||
return answer(true, "scd34 set mounts on shanks (sources)\n by g14.node-tools; fingerprint dd0b\n chosen: done\n")
|
||
case key == "seat:mesh-controller.settings" && args["propose"] == "true":
|
||
d.proposed = append(d.proposed, args)
|
||
return answer(true, "proposal sab12: Set the settings of betterbird?\n fingerprint ab12")
|
||
case key == "seat:mesh-controller.settings":
|
||
node, _ := args["node"].(string)
|
||
layer := d.layers[node]
|
||
if layer == nil {
|
||
return answer(true, "betterbird on "+node+": no layer — the module's definition says\nbetterbird on "+node+", every value")
|
||
}
|
||
b, _ := json.MarshalIndent(layer, "", " ")
|
||
return answer(true, string(b)+"\n approved by the operator\nbetterbird on the whole mesh, every value and where it comes from:\n")
|
||
}
|
||
d.t.Fatalf("asked %s %v", key, body)
|
||
return nil, nil
|
||
}
|
||
|
||
func stringsOf(v any) []string {
|
||
switch x := v.(type) {
|
||
case []string:
|
||
return x
|
||
case []any:
|
||
var out []string
|
||
for _, s := range x {
|
||
out = append(out, s.(string))
|
||
}
|
||
return out
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func TestDeriveLaysEachEntryOutPerLayerAndProposesOneLayerAtATime(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.declareAccounts(adoptable)
|
||
links(t, nil)
|
||
f2 := newFake(t)
|
||
f2.profileWithAccounts()
|
||
f2.profileWithConfiguration()
|
||
// shanks holds the same accounts and calendars, another signature, another tag colour and a calendar of its own.
|
||
f2.write(profileDir+"/prefs.js", strings.NewReplacer(`"Regards"`, `"Cheers"`, `"#FF0000"`, `"#CC0000"`).Replace(f2.read(profileDir+"/prefs.js"))+
|
||
`user_pref("calendar.registry.s1.type", "caldav");`+"\n"+
|
||
`user_pref("calendar.registry.s1.uri", "https://dav.example.org/cal/standup/");`+"\n"+
|
||
`user_pref("calendar.registry.s1.name", "Standup");`+"\n")
|
||
f2.declareAccounts(adoptable)
|
||
mesh := &derivingMesh{t: t, shanks: f2, layers: map[string]map[string]any{"": {"accounts": map[string]any{"mail": "as approved"}}}}
|
||
oldNode, oldAsk := nodeName, asker
|
||
t.Cleanup(func() { nodeName, asker = oldNode, oldAsk })
|
||
nodeName = func() string { return "g14" }
|
||
asker = mesh.ask
|
||
|
||
a, err := f.Derive(nil, false, false, nil, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !reflect.DeepEqual(a.From, []string{"g14", "shanks"}) || len(a.NotHeard) != 0 {
|
||
t.Fatalf("derived from the nodes holding Betterbird: %v %v", a.From, a.NotHeard)
|
||
}
|
||
whole := mustJSON(a)
|
||
mw := a.Layers[meshWide]
|
||
ids, _ := mw[kindIdentities].(map[string]any)
|
||
if mail, _ := ids["mail"].(map[string]any); mail == nil || mustJSON(mail["signature"]) != `["Regards"]` || mail["address"] != nil {
|
||
t.Errorf("an identity on both nodes is mesh-wide with this node's value, the default without its address: %v", ids)
|
||
}
|
||
cals, _ := mw[kindCalendars].(map[string]any)
|
||
if cals["home"] == nil || cals["standup"] != nil {
|
||
t.Errorf("a calendar both hold is mesh-wide, one only shanks holds is not: %v", cals)
|
||
}
|
||
if sh := mustJSON(a.Layers["shanks"]); !strings.Contains(sh, "https://dav.example.org/cal/standup/") {
|
||
t.Errorf("an entry on one node only goes into that node's layer: %s", sh)
|
||
}
|
||
tags, _ := mw[kindTags].(map[string]any)
|
||
if l1, _ := tags["$label1"].(map[string]any); l1 == nil || l1["color"] != "#FF0000" {
|
||
t.Errorf("the tag is mesh-wide with this node's colour: %v", tags)
|
||
}
|
||
diffs := map[string]difference{}
|
||
for _, d := range a.Differences {
|
||
diffs[d.Kind+"/"+d.Entry+"/"+d.Field] = d
|
||
}
|
||
if d := diffs["identities/mail/signature"]; d.Node != "shanks" || mustJSON(d.There) != `["Cheers"]` {
|
||
t.Errorf("the answer lists the field shanks holds differently: %+v", a.Differences)
|
||
}
|
||
if d := diffs["tags/$label1/color"]; d.There != "#CC0000" || d.MeshWide != "#FF0000" {
|
||
t.Errorf("%+v", a.Differences)
|
||
}
|
||
for _, never := range []string{"secret-token", "hunter2", "owl://", `"Work"`} {
|
||
if strings.Contains(mustJSON(a.Layers), never) {
|
||
t.Errorf("no ICS address, credential or Owl calendar is derived: %s in %s", never, mustJSON(a.Layers))
|
||
}
|
||
}
|
||
if !strings.Contains(whole, "Holidays") || !strings.Contains(whole, "‹withheld›") || strings.Contains(mustJSON(credentialChecked(a)), "hunter2") {
|
||
t.Errorf("what is left out is named, an ICS calendar by its name and a password as ‹withheld›: %v", a.NotDerived)
|
||
}
|
||
if len(mesh.proposed) != 0 {
|
||
t.Fatal("without propose nothing is proposed")
|
||
}
|
||
|
||
// Propose: the mesh-wide layer first, as it stands with the derived kinds, and the operator's setup of prefs.
|
||
prefs := map[string]any{}
|
||
for n, v := range shippedDefaultsWant {
|
||
prefs[n], _ = prefValue(v)
|
||
}
|
||
a, err = f.Derive(nil, false, true, prefs, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(mesh.proposed) != 1 || mesh.proposed[0]["node"] != nil || mesh.proposed[0]["replace"] != "true" || a.Next != meshWide {
|
||
t.Fatalf("the mesh-wide layer is proposed first: %v %s", mesh.proposed, a.Next)
|
||
}
|
||
var values map[string]any
|
||
_ = json.Unmarshal([]byte(mesh.proposed[0]["values"].(string)), &values)
|
||
if values["accounts"] == nil || values[kindIdentities] == nil || values[kindTags] == nil || values["prefs"] == nil {
|
||
t.Fatalf("the layer as it stands, with every derived kind and prefs: %v", values)
|
||
}
|
||
body, _ := json.Marshal(map[string]any{"prefs": values["prefs"]})
|
||
if got, err := parseDeclaredPrefs(body); err != nil || !reflect.DeepEqual(got, shippedDefaultsWant) {
|
||
t.Fatalf("the operator's 21 preferences, one entry each: %v %v", got, err)
|
||
}
|
||
if !strings.Contains(a.Proposed, "sab12") {
|
||
t.Fatalf("%s", a.Proposed)
|
||
}
|
||
|
||
// While it waits nothing more is proposed.
|
||
mesh.open = true
|
||
if _, err := f.Derive(nil, false, true, nil, ""); err == nil || !strings.Contains(err.Error(), "sab12 (the whole mesh)") || len(mesh.proposed) != 1 {
|
||
t.Fatalf("one open proposal of the module's at a time: %v", err)
|
||
}
|
||
// Approved: the mesh-wide layer holds it, so the next layer is shanks's own.
|
||
mesh.open = false
|
||
mesh.layers[""] = values
|
||
a, err = f.Derive(nil, false, true, prefs, "")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(mesh.proposed) != 2 || mesh.proposed[1]["node"] != "shanks" || a.Next != "shanks" ||
|
||
!strings.Contains(mesh.proposed[1]["values"].(string), "standup") {
|
||
t.Fatalf("then the node's own layer: %v", mesh.proposed)
|
||
}
|
||
var shanks map[string]any
|
||
if err := json.Unmarshal([]byte(mesh.proposed[1]["values"].(string)), &shanks); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
mesh.layers["shanks"] = shanks
|
||
if a, err = f.Derive(nil, false, true, prefs, ""); err != nil || len(mesh.proposed) != 2 || !strings.HasPrefix(a.Next, "none") {
|
||
t.Fatalf("nothing left to propose: %v %s", err, a.Next)
|
||
}
|
||
|
||
// A desktop node that does not answer: derived without it, never proposed.
|
||
mesh.asleep = true
|
||
a, err = f.Derive(nil, false, true, nil, meshWide)
|
||
if err == nil || len(a.NotHeard) != 1 || !strings.Contains(err.Error(), "did not answer") || len(mesh.proposed) != 2 {
|
||
t.Fatalf("a layer is never proposed without a desktop node: %v %v", err, a.NotHeard)
|
||
}
|
||
|
||
// A key to adopt is a node's: the mesh-wide layer's proposal refuses one.
|
||
if err := proposable(map[string]any{kindCalendars: map[string]any{"c": map[string]any{"name": "C",
|
||
"url": "https://dav.example.org/c/", "key": "c3"}}}, true); err == nil || !strings.Contains(err.Error(), "node's own layer") {
|
||
t.Fatalf("%v", err)
|
||
}
|
||
}
|