438 lines
18 KiB
Go
438 lines
18 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
meshconditions "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions"
|
|
occ "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/clients/operatorchannelclient"
|
|
oc "git.novox.be/novox/mesh-contracts/operator-and-conversation/go/seats/operatorchannel"
|
|
)
|
|
|
|
// fakeDelivery is the delivery parts as mesh-config asks them: each node's have, whether a node-engine states what it
|
|
// reads, and who reads a module's keys (nil rows and known false: the indexer does not serve the tool yet).
|
|
type fakeDelivery struct {
|
|
mu sync.Mutex
|
|
haves map[string]string // "<node>.<module>" → fingerprint
|
|
notSays map[string]bool // node → its node-engine states nothing
|
|
rows []ReaderRow
|
|
known bool
|
|
}
|
|
|
|
func (d *fakeDelivery) Have(node, module string) (string, bool, error) {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
fp, ok := d.haves[node+"."+module]
|
|
return fp, ok, nil
|
|
}
|
|
|
|
func (d *fakeDelivery) Reads(node string) error {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
if d.notSays[node] {
|
|
return errors.New("the verb manifest-reads is not served")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (d *fakeDelivery) Readers(string) ([]ReaderRow, bool, error) {
|
|
d.mu.Lock()
|
|
defer d.mu.Unlock()
|
|
return d.rows, d.known, nil
|
|
}
|
|
|
|
// fakeVault records the retirements asked of the seat mesh-vault.
|
|
type fakeVault struct{ asked []RetireRequest }
|
|
|
|
func (v *fakeVault) Retire(r RetireRequest) error { v.asked = append(v.asked, r); return nil }
|
|
|
|
// The fingerprints of the first, second and third build of player.
|
|
var (
|
|
fp1 = "src2:" + strings.Repeat("1", 64)
|
|
fp2 = "src2:" + strings.Repeat("2", 64)
|
|
fp3 = "src2:" + strings.Repeat("3", 64)
|
|
)
|
|
|
|
const (
|
|
oldPlayer = `{"module":"player","config":{"volume":{"type":"number","default":5}}}`
|
|
newPlayer = `{"module":"player","config":{"level":{"type":"number","default":5,"renamed-from":"volume"}}}`
|
|
// donePlayer is the manifest once `renamed-from` left it.
|
|
donePlayer = `{"module":"player","config":{"level":{"type":"number","default":5}}}`
|
|
)
|
|
|
|
// aRenamingMesh is a mesh with player on g14 and novox, both running its first build (reading `volume`), a clock the
|
|
// test moves, and the delivery parts faked.
|
|
func aRenamingMesh(t *testing.T) (*Config, *memStore, *fakeDelivery, *time.Time) {
|
|
t.Helper()
|
|
m := newStore()
|
|
m.declare(t, "player", oldPlayer)
|
|
indexerList(t, m, "aaaaaaaaaaaa", theList)
|
|
for _, node := range []string{"g14", "novox"} {
|
|
m.assign(t, node, "player")
|
|
m.want(t, node, "player", fp1, oldPlayer)
|
|
}
|
|
clock := time.Date(2026, 11, 4, 10, 0, 0, 0, time.UTC)
|
|
d := &fakeDelivery{haves: map[string]string{"g14.player": fp1, "novox.player": fp1}, notSays: map[string]bool{}}
|
|
c := &Config{Store: m, Now: func() time.Time { return clock }, Logf: t.Logf, Delivery: d}
|
|
if _, err := c.Reconcile(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return c, m, d, &clock
|
|
}
|
|
|
|
func findingsOf(t *testing.T, m *memStore, kind string) []meshconditions.FindingsByKeyValue {
|
|
t.Helper()
|
|
var out []meshconditions.FindingsByKeyValue
|
|
for _, k := range m.keysOf(FindingsState) {
|
|
e, _ := m.Get(FindingsState, k)
|
|
f, err := meshconditions.DecodeFindingsByKeyValue(e.Value)
|
|
if err != nil {
|
|
t.Fatalf("finding %s: %v", k, err)
|
|
}
|
|
if f.Kind == kind {
|
|
out = append(out, f)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// **A renamed value moves with its trust, both names answer until the node-engines run the new build, and the window
|
|
// closes per node** (design 32, C20): the value set under the old name is under the new one with no operator input,
|
|
// `renamed` is said once, the old name answers the new value on a node until that node's have runs a build that reads the
|
|
// new name (on `delivered`, or for a returning node on `manifest-reads-changed` once its node-engine states what it
|
|
// reads), and the rename goes once every window is closed and `renamed-from` left the manifest.
|
|
func TestARenamedValueMovesAndTheOldNameAnswersUntilEachNodeRunsTheNewBuild(t *testing.T) {
|
|
c, m, d, _ := aRenamingMesh(t)
|
|
set(t, c, Call{Key: "volume", Value: 8.0, Module: "player", Node: "g14"})
|
|
m.declare(t, "player", newPlayer)
|
|
r, err := c.Reconcile()
|
|
if err != nil || len(r.Renamed) != 1 {
|
|
t.Fatalf("reconcile: %+v, %v", r, err)
|
|
}
|
|
if v, ok := storedValue(t, m, "module.g14.player.level"); !ok || string(v.Value) != "8" || v.By == "" {
|
|
t.Errorf("the value is %+v under the new name (set: %v)", v, ok)
|
|
}
|
|
if _, ok := storedValue(t, m, "module.g14.player.volume"); ok {
|
|
t.Error("the old name still holds a value")
|
|
}
|
|
for _, c := range []struct{ node, key, want string }{{"g14", "level", "8"}, {"g14", "volume", "8"}, {"novox", "level", "5"}, {"novox", "volume", "5"}} {
|
|
if r := m.resolved(t, c.node, "player", c.key); string(r.Value) != c.want {
|
|
t.Errorf("%s answers %s on %s as %s, want %s", c.key, "player", c.node, r.Value, c.want)
|
|
}
|
|
}
|
|
if got := m.saidOf(EventRenamed); len(got) != 1 || got[0].(RenamedEvent).From != "volume" || got[0].(RenamedEvent).To != "level" {
|
|
t.Errorf("renamed was said as %+v", got)
|
|
}
|
|
// The history keeps the move; a second pass says and moves nothing more.
|
|
if h, _ := c.History(Call{Module: "player", Node: "g14"}); len(h) == 0 || h[len(h)-1].Action != "resolve" && h[len(h)-1].Action != "rename" {
|
|
t.Errorf("history %+v", h)
|
|
}
|
|
if _, err := c.Reconcile(); err != nil || len(m.saidOf(EventRenamed)) != 1 {
|
|
t.Errorf("a second pass said renamed %d times: %v", len(m.saidOf(EventRenamed)), err)
|
|
}
|
|
rows, _ := c.Renames("", true)
|
|
if len(rows) != 1 || strings.Join(rows[0].Open, ",") != "g14,novox" || strings.Join(rows[0].Moved, ",") != "g14" {
|
|
t.Fatalf("renames %+v", rows)
|
|
}
|
|
|
|
// A delivery while both nodes still run the first build closes nothing.
|
|
if said := c.Windows(""); len(said) != 2 || !strings.Contains(said[0], "still runs a build that reads volume") {
|
|
t.Errorf("windows said %v", said)
|
|
}
|
|
// g14 applies the new build.
|
|
m.want(t, "g14", "player", fp2, newPlayer)
|
|
d.haves["g14.player"] = fp2
|
|
if said := c.Windows(""); len(said) == 0 {
|
|
t.Error("g14 applied the new build and its window did not close")
|
|
}
|
|
if _, err := m.Get(ResolvedState, ResolvedKey("g14", "player", "volume")); err != nil {
|
|
t.Fatal(err)
|
|
} else if e, _ := m.Get(ResolvedState, ResolvedKey("g14", "player", "volume")); e != nil {
|
|
t.Error("g14 still answers the old name after its window closed")
|
|
}
|
|
m.resolved(t, "novox", "player", "volume") // novox still answers it
|
|
// novox applies it, but its node-engine, returning, states nothing yet: the window stays open.
|
|
m.want(t, "novox", "player", fp2, newPlayer)
|
|
d.haves["novox.player"] = fp2
|
|
d.notSays["novox"] = true
|
|
c.Windows("novox")
|
|
if rows, _ := c.Renames("", true); len(rows) != 1 || strings.Join(rows[0].Open, ",") != "novox" {
|
|
t.Errorf("a node-engine that states nothing closed its window: %+v", rows)
|
|
}
|
|
d.notSays["novox"] = false
|
|
c.Windows("novox")
|
|
if rows, _ := c.Renames("", true); len(rows) != 0 {
|
|
t.Errorf("windows still open: %+v", rows)
|
|
}
|
|
// The rename stays while `renamed-from` is in the manifest, and goes when it left.
|
|
if all, _ := c.Renames("", false); len(all) != 1 || all[0].Closed["g14"] != "applied" {
|
|
t.Errorf("renames %+v", all)
|
|
}
|
|
m.declare(t, "player", donePlayer)
|
|
if _, err := c.Reconcile(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if all, _ := c.Renames("", false); len(all) != 0 {
|
|
t.Errorf("the rename stayed after renamed-from left the manifest: %+v", all)
|
|
}
|
|
}
|
|
|
|
// A refused move is a finding, cleared once it moved (C4): both names declared, both names holding different values, a
|
|
// trusted new name over a value set without the operator's Approve. Nothing is moved, nothing is said.
|
|
func TestARefusedRenameIsAFindingAndIsClearedOnceItMoves(t *testing.T) {
|
|
both := `{"module":"player","config":{"volume":{"type":"number","default":5},"level":{"type":"number","default":5,"renamed-from":"volume"}}}`
|
|
trusted := `{"module":"player","config":{"level":{"type":"number","default":5,"trusted":true,"renamed-from":"volume"}}}`
|
|
for _, tc := range []struct {
|
|
name, manifest string
|
|
both bool
|
|
why string
|
|
fixed string
|
|
}{
|
|
{"both names declared", both, false, "declares both names", newPlayer},
|
|
{"both names hold a value", newPlayer, true, "both names hold a value", newPlayer},
|
|
{"a trusted name over an unapproved value", trusted, false, "without the operator's Approve", newPlayer},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
c, m, _, _ := aRenamingMesh(t)
|
|
set(t, c, Call{Key: "volume", Value: 8.0, Module: "player", Node: "g14"})
|
|
if tc.both {
|
|
// The new name is set meanwhile, to another value, in the state a faulty hand wrote it.
|
|
if err := m.Put(ValuesState, "module.g14.player.level", Value{Value: json.RawMessage("9"), By: "x", At: time.Now()}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
m.declare(t, "player", tc.manifest)
|
|
if _, err := c.Reconcile(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fs := findingsOf(t, m, FindingRenameRefused)
|
|
if len(fs) != 1 || !strings.Contains(fs[0].Summary, tc.why) || fs[0].Observer != Module {
|
|
t.Fatalf("findings %+v, want one saying %q", fs, tc.why)
|
|
}
|
|
if _, ok := storedValue(t, m, "module.g14.player.volume"); !ok || len(m.saidOf(EventRenamed)) != 0 {
|
|
t.Errorf("a refused rename moved the value or said renamed")
|
|
}
|
|
// Put right, it moves by hand and the finding goes.
|
|
m.declare(t, "player", tc.fixed)
|
|
if tc.both {
|
|
_ = m.Delete(ValuesState, "module.g14.player.level")
|
|
}
|
|
if _, err := c.RenameAgain("player", "level"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fs := findingsOf(t, m, FindingRenameRefused); len(fs) != 0 {
|
|
t.Errorf("the finding stayed after the move: %+v", fs)
|
|
}
|
|
if v, ok := storedValue(t, m, "module.g14.player.level"); !ok || string(v.Value) != "8" {
|
|
t.Errorf("the value is %+v after the move", v)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A window held open seven days is a finding naming the node, cleared when it closes; dropping a node by hand is a hand
|
|
// act with its why (C4, C5), and the seat's verb `renames` names the nodes holding a window open.
|
|
func TestAWindowHeldSevenDaysIsAFindingAndAHandCloseIsAHandAct(t *testing.T) {
|
|
c, m, d, clock := aRenamingMesh(t)
|
|
m.declare(t, "player", newPlayer)
|
|
if _, err := c.Reconcile(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.want(t, "g14", "player", fp2, newPlayer)
|
|
d.haves["g14.player"] = fp2
|
|
c.Windows("")
|
|
*clock = clock.Add(8 * 24 * time.Hour)
|
|
if _, err := c.Reconcile(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
fs := findingsOf(t, m, FindingWindowHeld)
|
|
if len(fs) != 1 || fs[0].Subject.Machine != "novox" || !strings.Contains(fs[0].Summary, "novox") {
|
|
t.Fatalf("findings %+v, want one naming novox", fs)
|
|
}
|
|
// The seat's verb names the node holding the window open.
|
|
var verb func(map[string]any) (any, error)
|
|
for _, tool := range Tools(c) {
|
|
if tool.Name == Seat+"."+RenamesVerb {
|
|
verb = tool.Run
|
|
}
|
|
}
|
|
got, err := verb(map[string]any{"module": "player"})
|
|
if raw, _ := json.Marshal(got); err != nil || !strings.Contains(string(raw), `"open":["novox"]`) {
|
|
t.Errorf("renames answered %s, %v", raw, err)
|
|
}
|
|
if _, err := c.RenameClose("player", "level", "novox", ""); err == nil {
|
|
t.Error("a window was dropped with no why")
|
|
}
|
|
if _, err := c.RenameClose("player", "level", "g14", "g14 is closed already"); err == nil {
|
|
t.Error("a node holding no open window was dropped")
|
|
}
|
|
m.handActs = nil
|
|
if _, err := c.RenameClose("player", "level", "novox", "novox is retired for good"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(m.handActs) != 1 || !strings.Contains(m.handActs[0], "novox is retired for good") {
|
|
t.Errorf("hand acts %v", m.handActs)
|
|
}
|
|
if fs := findingsOf(t, m, FindingWindowHeld); len(fs) != 0 {
|
|
t.Errorf("the finding stayed after the window closed: %+v", fs)
|
|
}
|
|
if rows, _ := c.Renames("", true); len(rows) != 0 {
|
|
t.Errorf("open renames %+v", rows)
|
|
}
|
|
}
|
|
|
|
// retiringMesh is a mesh where player no longer declares `volume`, a value of it is set on g14, and g14 runs the first
|
|
// build (which reads it).
|
|
func retiringMesh(t *testing.T) (*Config, *memStore, *fakeDelivery, *standIn, *fakeVault, *time.Time) {
|
|
t.Helper()
|
|
c, m, d, clock := aRenamingMesh(t)
|
|
set(t, c, Call{Key: "volume", Value: 8.0, Module: "player", Node: "g14"})
|
|
m.declare(t, "player", donePlayer)
|
|
r := newStandIn()
|
|
v := &fakeVault{}
|
|
c.Asks, c.Vault = r, v
|
|
d.known = true
|
|
d.rows = []ReaderRow{{Module: "player", Key: "volume", Kind: KindValue, Readers: []string{}}}
|
|
return c, m, d, r, v, clock
|
|
}
|
|
|
|
func retiringOf(t *testing.T, c *Config) []RetiringRow {
|
|
t.Helper()
|
|
got, err := c.ConfigRetiring("", "", "")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return got.(map[string]any)["retiring"].([]RetiringRow)
|
|
}
|
|
|
|
// **A value no manifest reads is retired only on the operator's Approve** (C24): while a node runs a build that reads it
|
|
// it is not marked and nobody is asked; once none does, `retiring` is said and the operator is asked once; the value stays
|
|
// until the Approve, then it is removed, `retired` is said for the node it was set on, and the warrant is recorded.
|
|
func TestAValueNoManifestReadsIsRetiredOnlyOnTheOperatorsApprove(t *testing.T) {
|
|
c, m, d, r, _, _ := retiringMesh(t)
|
|
// The indexer does not serve the tool yet: nothing retires on that.
|
|
d.known = false
|
|
if said := c.Retirements("player"); len(said) != 1 || !strings.Contains(said[0], "does not say") || len(retiringOf(t, c)) != 0 {
|
|
t.Errorf("an indexer without the tool: %v", said)
|
|
}
|
|
d.known = true
|
|
// g14 still runs a build that reads volume: draining, no ask, no event.
|
|
c.Retirements("player")
|
|
if rows := retiringOf(t, c); len(rows) != 1 || rows[0].State != RetireDraining || strings.Join(rows[0].Nodes, ",") != "g14,novox" {
|
|
t.Fatalf("retiring %+v, want draining on g14 and novox", rows)
|
|
}
|
|
if len(r.asks) != 0 || len(m.saidOf(EventRetiring)) != 0 {
|
|
t.Fatalf("asked %d times and said retiring %d times while two nodes read the value", len(r.asks), len(m.saidOf(EventRetiring)))
|
|
}
|
|
// Both nodes apply the build that reads it no longer.
|
|
for _, node := range []string{"g14", "novox"} {
|
|
m.want(t, node, "player", fp3, donePlayer)
|
|
d.haves[node+".player"] = fp3
|
|
}
|
|
c.Windows("")
|
|
rows := retiringOf(t, c)
|
|
if len(rows) != 1 || rows[0].State != RetireAsked || len(r.asks) != 1 || len(m.saidOf(EventRetiring)) != 1 {
|
|
t.Fatalf("retiring %+v, %d asks, %d events", rows, len(r.asks), len(m.saidOf(EventRetiring)))
|
|
}
|
|
if _, ok := storedValue(t, m, "module.g14.player.volume"); !ok {
|
|
t.Fatal("the value was removed before the operator answered")
|
|
}
|
|
// A second manifest-accepted asks nobody again.
|
|
c.Retirements("player")
|
|
if len(r.asks) != 1 {
|
|
t.Errorf("asked %d times", len(r.asks))
|
|
}
|
|
r.choose(t, rows[0].Ask, OptionApprove, nil)
|
|
m.handActs = nil
|
|
if said, err := c.Decide(); err != nil || len(said) == 0 {
|
|
t.Fatalf("decide: %v, %v", said, err)
|
|
}
|
|
if _, ok := storedValue(t, m, "module.g14.player.volume"); ok {
|
|
t.Error("the approved value is still set")
|
|
}
|
|
if got := m.saidOf(EventRetired); len(got) != 1 || got[0].(RetiredEvent).Node != "g14" || got[0].(RetiredEvent).Name != "volume" {
|
|
t.Errorf("retired was said as %+v", got)
|
|
}
|
|
if len(retiringOf(t, c)) != 0 || len(m.handActs) == 0 {
|
|
t.Errorf("retiring %+v, hand acts %v", retiringOf(t, c), m.handActs)
|
|
}
|
|
}
|
|
|
|
// A declined retirement is not asked again until the value is read again; a value read again is dropped; an ask unanswered
|
|
// is asked once more after seven days and then written as a finding, cleared by an answer or a cancel; a secret is
|
|
// retired through the seat mesh-vault with the ask that warranted it (C24, C7).
|
|
func TestARetirementDeclinedUnansweredCancelledOrASecret(t *testing.T) {
|
|
c, m, d, r, v, clock := retiringMesh(t)
|
|
for _, node := range []string{"g14", "novox"} {
|
|
m.want(t, node, "player", fp3, donePlayer)
|
|
d.haves[node+".player"] = fp3
|
|
}
|
|
c.Retirements("player")
|
|
ask := retiringOf(t, c)[0].Ask
|
|
|
|
// Unanswered: the ask expires, a week passes, it is asked once more, expires again: a finding.
|
|
r.choose(t, ask, OptionApprove, func(w *oc.Warrant) { w.Outcome = occ.OutcomeExpired })
|
|
c.Decide()
|
|
if rows := retiringOf(t, c); rows[0].State != RetireUnanswered || len(findingsOf(t, m, FindingRetireUnanswered)) != 0 {
|
|
t.Fatalf("after the first expiry: %+v", rows)
|
|
}
|
|
*clock = clock.Add(8 * 24 * time.Hour)
|
|
c.Decide()
|
|
rows := retiringOf(t, c)
|
|
if rows[0].State != RetireAsked || len(r.asks) != 2 || rows[0].Ask == ask {
|
|
t.Fatalf("after a week: %+v, %d asks", rows, len(r.asks))
|
|
}
|
|
r.choose(t, rows[0].Ask, OptionApprove, func(w *oc.Warrant) { w.Outcome = occ.OutcomeExpired })
|
|
c.Decide()
|
|
if fs := findingsOf(t, m, FindingRetireUnanswered); len(fs) != 1 {
|
|
t.Fatalf("findings %+v after two asks unanswered", fs)
|
|
}
|
|
// Asked again by hand, then declined: the finding goes and it is not asked again.
|
|
if _, err := c.ConfigRetiring("ask-again", "player", "volume"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
third := retiringOf(t, c)[0].Ask
|
|
r.choose(t, third, OptionDecline, nil)
|
|
c.Decide()
|
|
if rows := retiringOf(t, c); rows[0].State != RetireDeclined || len(findingsOf(t, m, FindingRetireUnanswered)) != 0 {
|
|
t.Fatalf("after the decline: %+v", rows)
|
|
}
|
|
asked := len(r.asks)
|
|
c.Retirements("player")
|
|
if len(r.asks) != asked {
|
|
t.Error("a declined retirement was asked again")
|
|
}
|
|
// Read again: dropped.
|
|
d.rows = []ReaderRow{{Module: "player", Key: "volume", Kind: KindValue, Readers: []string{"player"}}}
|
|
if said := c.Retirements("player"); len(said) != 1 || !strings.Contains(said[0], "read again") || len(retiringOf(t, c)) != 0 {
|
|
t.Errorf("read again: %v", said)
|
|
}
|
|
|
|
// A secret: its nodes come from the indexer's answer; on the Approve the vault is asked for each, once.
|
|
d.rows = []ReaderRow{{Module: "player", Key: "token", Kind: KindSecret, Readers: []string{}, Nodes: []string{}}}
|
|
c.Retirements("player")
|
|
secret := retiringOf(t, c)[0]
|
|
if secret.Kind != KindSecret || secret.State != RetireAsked {
|
|
t.Fatalf("the secret: %+v", secret)
|
|
}
|
|
r.choose(t, secret.Ask, OptionApprove, nil)
|
|
c.Decide()
|
|
if len(v.asked) != 2 || v.asked[0].Node != "g14" || v.asked[0].Name != "token" || v.asked[0].Ask != secret.Ask {
|
|
t.Errorf("the vault was asked %+v", v.asked)
|
|
}
|
|
if len(m.saidOf(EventRetired)) != 2 || len(retiringOf(t, c)) != 0 {
|
|
t.Errorf("retired %d times, retiring %+v", len(m.saidOf(EventRetired)), retiringOf(t, c))
|
|
}
|
|
// A secret whose readers' nodes the indexer cannot name is not marked.
|
|
d.rows = []ReaderRow{{Module: "player", Key: "other", Kind: KindSecret, Readers: []string{}}}
|
|
if c.Retirements("player"); len(retiringOf(t, c)) != 0 {
|
|
t.Errorf("a secret read on unknown nodes was marked: %+v", retiringOf(t, c))
|
|
}
|
|
_ = fmt.Sprint
|
|
}
|