mesh/merge-gate head c9a711c259ed merged into trunk 827b89dbf109 — pass: builds betterbird, sent nowhere; no bus step; 2 wait(s) for a person; every machin…
mesh/repo-check head c9a711c259ed merged into trunk 827b89dbf109 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
writeAccountLists checked only for a running Betterbird and wrote without a sync; it now waits for the profile's lock too and writes atomically. readLayer read any answer that was not JSON as an empty layer, and a proposal replaces the layer whole, so a changed wording would have dropped accounts; it now refuses. The README says the rollout order that leaves no node on Betterbird's defaults.
264 lines
11 KiB
Go
264 lines
11 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
|
||
// garbled answers the layer in words that are not its JSON.
|
||
garbled 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)
|
||
if d.garbled {
|
||
return answer(true, "Settings of betterbird on "+node+":\n accounts = {…}\n")
|
||
}
|
||
layer := d.layers[node]
|
||
if layer == nil {
|
||
where := node
|
||
if where == "" {
|
||
where = "the whole mesh"
|
||
}
|
||
return answer(true, "betterbird on "+where+": no layer — the module's definition says\nbetterbird on "+where+", 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)
|
||
}
|
||
}
|
||
|
||
// Review 121: a layer the controller answers in any other words than its JSON or "no layer" refuses the proposal,
|
||
// because a proposal replaces the layer whole and would drop accounts.
|
||
func TestALayerThatCannotBeReadRefusesTheProposal(t *testing.T) {
|
||
f := newFake(t)
|
||
f.profileWithAccounts()
|
||
f.profileWithConfiguration()
|
||
f.declareAccounts(adoptable)
|
||
links(t, nil)
|
||
f2 := newFake(t)
|
||
f2.profileWithAccounts()
|
||
f2.declareAccounts(adoptable)
|
||
mesh := &derivingMesh{t: t, shanks: f2, garbled: true}
|
||
oldNode, oldAsk := nodeName, asker
|
||
t.Cleanup(func() { nodeName, asker = oldNode, oldAsk })
|
||
nodeName = func() string { return "g14" }
|
||
asker = mesh.ask
|
||
if _, err := f.Derive(nil, false, true, nil, ""); err == nil || !strings.Contains(err.Error(), "cannot be read as the layer") ||
|
||
len(mesh.proposed) != 0 {
|
||
t.Fatalf("the derive refuses: %v %v", err, mesh.proposed)
|
||
}
|
||
if _, err := f.ProfileAccounts(true); err == nil || !strings.Contains(err.Error(), "cannot be read as the layer") ||
|
||
len(mesh.proposed) != 0 {
|
||
t.Fatalf("betterbird_accounts refuses: %v %v", err, mesh.proposed)
|
||
}
|
||
if _, err := readLayer(""); err == nil {
|
||
t.Fatal("an unreadable layer is an error, never an empty layer")
|
||
}
|
||
mesh.garbled = false
|
||
if got, err := readLayer("shanks"); err != nil || len(got) != 0 {
|
||
t.Fatalf("no layer, in the controller's words, is an empty layer: %v %v", got, err)
|
||
}
|
||
}
|