Files
mesh-sdk/go/delivery/delivery_test.go
T
jschoubben dd9d9d5b5f
mesh/delivery closed: a newer head of the same pull request
mesh/check checking dd9d9d5b5f01 on dae8e288d2e1 | novox/mesh-sdk#98 in group feat/1126-nodes-compose
Compare haves with SameHave in the round-trip tests, now that a have carries a list (issue 1126)
2026-10-26 20:20:12 +01:00

494 lines
18 KiB
Go

package delivery
import (
"bytes"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/seal"
)
// 1131/AC1: the have key forms and parses back; bad names are refused, naming them.
func TestHaveKey(t *testing.T) {
t.Run("presence", testPresenceKey)
t.Run("pulse", testPulseKey)
t.Run("pulse ttl", testPulseTTL)
if HaveBucket != "node-engine_have" {
t.Fatalf("HaveBucket = %q", HaveBucket)
}
key, err := HaveKey("ace", "plex")
if err != nil || key != "ace.plex" {
t.Fatalf("HaveKey = %q, %v", key, err)
}
node, module, isNode, err := ParseHaveKey(key)
if err != nil || node != "ace" || module != "plex" || isNode {
t.Fatalf("ParseHaveKey = %q %q %v %v", node, module, isNode, err)
}
if got, err := HaveNodeKey("ace"); err != nil || got != "ace.node" {
t.Fatalf("HaveNodeKey = %q, %v", got, err)
}
if _, err := HaveNodeKey("a b"); err == nil {
t.Error("HaveNodeKey with a space accepted")
}
node, module, isNode, err = ParseHaveKey("ace.node")
if err != nil || node != "ace" || module != "" || !isNode {
t.Fatalf("ParseHaveKey node = %q %q %v %v", node, module, isNode, err)
}
for _, c := range []struct{ node, module, named string }{
{"ace", "node", "node"},
{"ace", "", "module"},
{"", "plex", "node"},
{"a.b", "plex", "a.b"},
{"ace", "p*", "p*"},
{"ace", "p>", "p>"},
{"ace", "p x", "p x"},
{"ace", "p\tx", "module"},
} {
_, err := HaveKey(c.node, c.module)
if err == nil || !strings.Contains(err.Error(), c.named) {
t.Errorf("HaveKey(%q, %q) = %v, want an error naming %q", c.node, c.module, err, c.named)
}
}
for _, bad := range []string{"", "ace", "ace.", ".plex", "ace.plex.x", "ace.p x"} {
if _, _, _, err := ParseHaveKey(bad); err == nil {
t.Errorf("ParseHaveKey(%q) accepted", bad)
}
}
}
// 1131/AC2: the presence keys form and parse back; a node named part is refused.
func testPresenceKey(t *testing.T) {
if PresenceBucket != "mesh-delivery-watchman_presence" {
t.Fatalf("PresenceBucket = %q", PresenceBucket)
}
key, err := PresenceNodeKey("ace")
if err != nil || key != "ace" {
t.Fatalf("PresenceNodeKey = %q, %v", key, err)
}
node, part, instance, err := ParsePresenceKey(key)
if err != nil || node != "ace" || part != "" || instance != "" {
t.Fatalf("ParsePresenceKey node = %q %q %q %v", node, part, instance, err)
}
key, err = PresencePartKey("view", "novox")
if err != nil || key != "part.view.novox" {
t.Fatalf("PresencePartKey = %q, %v", key, err)
}
node, part, instance, err = ParsePresenceKey(key)
if err != nil || node != "" || part != "view" || instance != "novox" {
t.Fatalf("ParsePresenceKey part = %q %q %q %v", node, part, instance, err)
}
if _, err := PresenceNodeKey("part"); err == nil || !strings.Contains(err.Error(), "part") {
t.Errorf("PresenceNodeKey(part) = %v", err)
}
if _, err := PresenceNodeKey("a.b"); err == nil {
t.Error("PresenceNodeKey(a.b) accepted")
}
if _, err := PresencePartKey("", "x"); err == nil {
t.Error("PresencePartKey with empty part accepted")
}
if _, err := PresencePartKey("view", "x y"); err == nil {
t.Error("PresencePartKey with a space accepted")
}
for _, bad := range []string{"", "part", "part.view", "part.view.a.b", "a.b", "part..x"} {
if _, _, _, err := ParsePresenceKey(bad); err == nil {
t.Errorf("ParsePresenceKey(%q) accepted", bad)
}
}
}
// 1131/AC3: the pulse key splits at the first dot, the key holding dots.
func testPulseKey(t *testing.T) {
if PulseBucket != "mesh-delivery-watchman_pulse" {
t.Fatalf("PulseBucket = %q", PulseBucket)
}
key, err := PulseKey("node-engine_have", "ace.plex")
if err != nil || key != "node-engine_have.ace.plex" {
t.Fatalf("PulseKey = %q, %v", key, err)
}
bucket, rest, err := ParsePulseKey(key)
if err != nil || bucket != "node-engine_have" || rest != "ace.plex" {
t.Fatalf("ParsePulseKey = %q %q %v", bucket, rest, err)
}
for _, c := range [][2]string{{"", "k"}, {"a.b", "k"}, {"b", ""}, {"b", "k x"}, {"b", ".k"}, {"b", "k."}, {"b", "k*"}} {
if _, err := PulseKey(c[0], c[1]); err == nil {
t.Errorf("PulseKey(%q, %q) accepted", c[0], c[1])
}
}
for _, bad := range []string{"", "nodot", ".k", "b."} {
if _, _, err := ParsePulseKey(bad); err == nil {
t.Errorf("ParsePulseKey(%q) accepted", bad)
}
}
}
// 1131/AC4: each value encodes, decodes equal, and carries "v":1.
func TestRoundTrip(t *testing.T) {
t.Run("phase and health refused", testPhaseHealthRefused)
hasV1 := func(t *testing.T, b []byte) {
t.Helper()
var m map[string]any
if err := json.Unmarshal(b, &m); err != nil {
t.Fatal(err)
}
if m["v"] != float64(1) {
t.Fatalf("v = %v in %s", m["v"], b)
}
if len(b) > 500 {
t.Fatalf("value is %d bytes", len(b))
}
}
t.Run("have", func(t *testing.T) {
in := Have{Build: "b1", FP: "f1", Phase: PhaseRunning, Health: HealthOK, Detail: "up"}
b, err := EncodeHave(in)
if err != nil {
t.Fatal(err)
}
hasV1(t, b)
out, err := DecodeHave(b)
if err != nil {
t.Fatal(err)
}
in.V = Version
if !SameHave(out, in) {
t.Fatalf("got %+v want %+v", out, in)
}
})
t.Run("have with the user list", func(t *testing.T) {
at := time.Date(2026, 10, 25, 21, 40, 0, 0, time.UTC)
in := Have{FP: "f1", Phase: PhaseRunning, Health: HealthOK, UserList: &UserListWritten{Source: "novox.nats.f1",
Revision: 7, Wanted: "sha256:ab", Digest: "sha256:ab", Users: 93, At: at}}
b, err := EncodeHave(in)
if err != nil {
t.Fatal(err)
}
hasV1(t, b)
out, err := DecodeHave(b)
if err != nil {
t.Fatal(err)
}
in.V = Version
if !reflect.DeepEqual(out, in) || !out.UserList.At.Equal(at) {
t.Fatalf("got %+v want %+v", out, in)
}
// And with the hashes handed to the vault (ADR 0547 clause 9): sealed to its key, opened by it alone, every
// listed user with a bcrypt hash once, and refused to another key.
hash := "$2a$11$" + strings.Repeat("a", 53)
list := []byte("{ user: \"controller\", password: \"" + hash + "\", permissions: {\n" +
"{ user: \"node.ace\", password: \"" + hash + "\", permissions: {\n" +
"{ user: \"controller\", password: \"" + hash + "\" }\n{ user: \"nkey\", password: \"plain\" }\n")
accounts := HandedAccountsOf(list)
if len(accounts) != 2 || accounts[0].User != "controller" || accounts[1].User != "node.ace" || accounts[1].Hash != hash {
t.Fatalf("handed accounts: %+v", accounts)
}
vault, err := seal.GenerateKeyPair()
if err != nil {
t.Fatal(err)
}
other, _ := seal.GenerateKeyPair()
handed, err := HandAccounts(accounts, vault.PublicKey, "sha256:ab", at)
if err != nil {
t.Fatal(err)
}
in.UserList.Handed = &handed
b, err = EncodeHave(in)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), "node.ace") || strings.Contains(string(b), hash) {
t.Fatalf("a user or a hash is in the clear: %s", b)
}
out, err = DecodeHave(b)
if err != nil || !SameHave(out, in) {
t.Fatalf("handed round trip: %v %+v", err, out.UserList)
}
opened, err := OpenHanded(*out.UserList.Handed, vault.PublicKey, vault.PrivateKey)
if err != nil || !reflect.DeepEqual(opened, accounts) {
t.Fatalf("opened %+v, %v", opened, err)
}
if _, err := OpenHanded(*out.UserList.Handed, other.PublicKey, other.PrivateKey); err == nil {
t.Fatal("a handing opened for another key")
}
if _, err := HandAccounts(accounts, "", "sha256:ab", at); err == nil {
t.Fatal("handed with no vault key")
}
})
// The grants part's list sealed to the bus node (novox/hq ADR 0547 clause 7): the key's value round-trips and refuses
// what the bus node could not follow, and the object opens with the node's key alone, to the digest the key names.
t.Run("the grants part's sealed user list", func(t *testing.T) {
in := Have{FP: "f1", Phase: PhaseRunning, Health: HealthOK, UserList: &UserListWritten{Source: GrantsUserListSource,
Revision: 3, Wanted: "sha256:ab", Digest: "sha256:ab", Users: 2, Follows: FollowGrants, Key: "PEM"}}
b, err := EncodeHave(in)
if err != nil {
t.Fatal(err)
}
if out, err := DecodeHave(b); err != nil || out.UserList.Follows != FollowGrants || out.UserList.Key != "PEM" {
t.Fatalf("got %+v, %v", out.UserList, err)
}
list := []byte("accounts { MESH { users = [ { user: \"controller\" } ] } }\n")
digest := UserListDigest(list)
node, err := seal.GenerateKeyPair()
if err != nil {
t.Fatal(err)
}
other, _ := seal.GenerateKeyPair()
g := GrantsUserList{Follow: FollowGrants, Digest: digest, Object: UserListObjectName(digest),
SealedTo: SealingKeyID(node.PublicKey), Node: "novox", Revision: 1, Users: 1}
raw, err := EncodeGrantsUserList(g)
if err != nil {
t.Fatal(err)
}
back, err := DecodeGrantsUserList(raw)
if err != nil || back.Digest != digest || back.LeftOut == nil || back.Follow != FollowGrants {
t.Fatalf("decoded %+v, %v", back, err)
}
for _, bad := range []GrantsUserList{{Follow: "both", Digest: digest, Object: g.Object},
{Follow: FollowGrants, Digest: digest, Object: "other"}, {Follow: FollowGrants, Digest: "md5:x", Object: "md5:x"}} {
if _, err := EncodeGrantsUserList(bad); err == nil {
t.Errorf("%+v was written", bad)
}
}
if _, err := EncodeGrantsUserList(GrantsUserList{Follow: FollowController}); err != nil {
t.Errorf("a switch with no list yet refused: %v", err)
}
object, err := SealUserList(list, node.PublicKey)
if err != nil {
t.Fatal(err)
}
if bytes.Contains(object, []byte("controller")) {
t.Fatal("the object carries the list in the clear")
}
opener := func(priv string) func(seal.Box) ([]byte, error) {
return func(b seal.Box) ([]byte, error) { s, err := seal.Open(b, priv); return []byte(s), err }
}
if got, err := OpenUserList(object, digest, opener(node.PrivateKey)); err != nil || !bytes.Equal(got, list) {
t.Fatalf("opened %q, %v", got, err)
}
if _, err := OpenUserList(object, digest, opener(other.PrivateKey)); err == nil {
t.Error("another key opened the list")
}
if _, err := OpenUserList(object, "sha256:00", opener(node.PrivateKey)); err == nil {
t.Error("a list of another digest was taken")
}
if _, err := SealUserList(list, "not a key"); err == nil {
t.Error("a list was sealed to no key")
}
})
t.Run("have node", func(t *testing.T) {
in := HaveNode{Windows: json.RawMessage(`{"a":1}`), Busy: json.RawMessage(`true`)}
b, err := EncodeHaveNode(in)
if err != nil {
t.Fatal(err)
}
hasV1(t, b)
out, err := DecodeHaveNode(b)
if err != nil {
t.Fatal(err)
}
if !SameHaveNode(out, HaveNode{V: Version, Windows: in.Windows, Busy: in.Busy}) {
t.Fatalf("got %+v", out)
}
})
t.Run("presence", func(t *testing.T) {
b, err := EncodePresence(Presence{Online: true})
if err != nil {
t.Fatal(err)
}
hasV1(t, b)
out, err := DecodePresence(b)
if err != nil || out != (Presence{V: Version, Online: true}) {
t.Fatalf("got %+v %v", out, err)
}
})
t.Run("pulse", func(t *testing.T) {
in := Pulse{Bucket: "node-engine_have", Key: "ace.plex", Revision: 7, Owes: "ace"}
b, err := EncodePulse(in)
if err != nil {
t.Fatal(err)
}
hasV1(t, b)
out, err := DecodePulse(b)
in.V = Version
if err != nil || out != in {
t.Fatalf("got %+v %v", out, err)
}
})
}
// 1131/AC5: a missing or unknown version is refused as ErrUnknownVersion, naming both versions.
func TestUnknownVersion(t *testing.T) {
decoders := map[string]func([]byte) error{
"have": func(b []byte) error { _, err := DecodeHave(b); return err },
"have node": func(b []byte) error { _, err := DecodeHaveNode(b); return err },
"presence": func(b []byte) error { _, err := DecodePresence(b); return err },
"pulse": func(b []byte) error { _, err := DecodePulse(b); return err },
}
for name, dec := range decoders {
t.Run(name, func(t *testing.T) {
err := dec([]byte(`{"v":2,"phase":"running","health":"ok"}`))
if !errors.Is(err, ErrUnknownVersion) || !strings.Contains(err.Error(), "2") || !strings.Contains(err.Error(), "1") {
t.Errorf("v 2: %v", err)
}
err = dec([]byte(`{"phase":"running","health":"ok"}`))
if !errors.Is(err, ErrUnknownVersion) {
t.Errorf("missing v: %v", err)
}
if err := dec([]byte(`not json`)); err == nil || errors.Is(err, ErrUnknownVersion) {
t.Errorf("garbage: %v", err)
}
})
}
}
// 1131/AC6: a phase or health outside the lists is refused on encode and decode.
func testPhaseHealthRefused(t *testing.T) {
for _, p := range []Phase{PhaseBackingUp, PhaseApplying, PhaseChecking, PhaseRunning, PhaseFailed} {
for _, h := range []Health{HealthOK, HealthFailed, HealthWaits, HealthUnknown} {
if _, err := EncodeHave(Have{Phase: p, Health: h}); err != nil {
t.Errorf("%s/%s refused: %v", p, h, err)
}
}
}
if _, err := EncodeHave(Have{Phase: "sleeping", Health: HealthOK}); err == nil || !strings.Contains(err.Error(), "sleeping") {
t.Errorf("bad phase encoded: %v", err)
}
if _, err := EncodeHave(Have{Phase: PhaseRunning, Health: "fine"}); err == nil || !strings.Contains(err.Error(), "fine") {
t.Errorf("bad health encoded: %v", err)
}
if _, err := EncodeHave(Have{Phase: PhaseRunning}); err == nil {
t.Error("empty health encoded")
}
if _, err := DecodeHave([]byte(`{"v":1,"phase":"sleeping","health":"ok"}`)); err == nil {
t.Error("bad phase decoded")
}
if _, err := DecodeHave([]byte(`{"v":1,"phase":"running","health":"fine"}`)); err == nil {
t.Error("bad health decoded")
}
// ADR 0547 §3 (issue 1126): a have waits for a yes with its judgement and the composition waiting, and no other have
// names either.
waits := Have{Phase: PhaseWaitingYes, Health: HealthOK, Judgement: JudgedRecreates, Waits: "sha256:" + strings.Repeat("a", 64)}
if raw, err := EncodeHave(waits); err != nil {
t.Errorf("a have waiting for a yes: %v", err)
} else if back, err := DecodeHave(raw); err != nil || back.Judgement != JudgedRecreates || back.Waits != waits.Waits {
t.Errorf("a have waiting for a yes came back %+v, %v", back, err)
}
for name, h := range map[string]Have{
"neither judged": {Phase: PhaseWaitingYes, Health: HealthOK, Judgement: JudgedNeither, Waits: waits.Waits},
"no waiting digest": {Phase: PhaseWaitingYes, Health: HealthOK, Judgement: JudgedMovesData},
"running judged": {Phase: PhaseRunning, Health: HealthOK, Judgement: JudgedRecreates},
} {
if _, err := EncodeHave(h); err == nil {
t.Errorf("%s: encoded", name)
}
}
}
// 1131/AC7: the pulse's time to live is 120 seconds minus the move's age; at the bound the move is stalled.
func testPulseTTL(t *testing.T) {
for _, c := range []struct {
age time.Duration
ttl time.Duration
stalled bool
}{
{0, 120 * time.Second, false},
{30 * time.Second, 90 * time.Second, false},
{119 * time.Second, time.Second, false},
{120 * time.Second, 0, true},
{200 * time.Second, 0, true},
} {
ttl, stalled := PulseTTL(c.age)
if ttl != c.ttl || stalled != c.stalled {
t.Errorf("PulseTTL(%v) = %v, %v; want %v, %v", c.age, ttl, stalled, c.ttl, c.stalled)
}
}
if PulseMoveBound != 120*time.Second || HaveHistory != 20 || PresenceHistory != 20 {
t.Error("constants")
}
}
// 1131/AC8: equal values say same, different ones do not.
func TestSame(t *testing.T) {
t.Run("have removed", testHaveRemoved)
a := Have{V: 1, Build: "b", Phase: PhaseRunning, Health: HealthOK}
b := a
if !SameHave(a, b) {
t.Error("equal haves differ")
}
b.Detail = "x"
if SameHave(a, b) {
t.Error("different haves same")
}
// The sealed values a self-composing node runs are part of its have (issue 1126).
sa, sb := a, a
sa.Sealed, sb.Sealed = []string{`{"box":1}`}, []string{`{"box":1}`}
if !SameHave(sa, sb) || SameHave(sa, a) {
t.Error("equal sealed values differ, or one said and one not are the same")
}
if raw, err := EncodeHave(sa); err != nil {
t.Error(err)
} else if back, err := DecodeHave(raw); err != nil || !SameHave(back, sa) {
t.Errorf("sealed values do not travel: %+v, %v", back, err)
}
// The user list is compared by what it says, never by where it is held (ADR 0547 §8).
at := time.Date(2026, 10, 25, 21, 0, 0, 0, time.UTC)
ua, ub := a, a
ua.UserList = &UserListWritten{Source: "k", Revision: 1, Wanted: "sha256:a", Digest: "sha256:a", Users: 2, At: at}
ub.UserList = &UserListWritten{Source: "k", Revision: 1, Wanted: "sha256:a", Digest: "sha256:a", Users: 2, At: at.In(time.Local)}
if !SameHave(ua, ub) || SameHave(ua, a) {
t.Error("equal user lists differ, or one said and one not are the same")
}
ub.UserList.Revision = 2
if SameHave(ua, ub) {
t.Error("different user lists same")
}
// A handing is compared by what it says: one said and one not differ, as do two to other keys.
ub.UserList.Revision = 1
ua.UserList.Handed = &AccountsHanded{From: "sha256:a", To: "sha256:k", Users: 2, At: at}
if SameHave(ua, ub) {
t.Error("a handing said and one not are the same")
}
ub.UserList.Handed = &AccountsHanded{From: "sha256:a", To: "sha256:k", Users: 2, At: at.In(time.Local)}
if !SameHave(ua, ub) {
t.Error("equal handings differ")
}
ub.UserList.Handed.To = "sha256:j"
if SameHave(ua, ub) {
t.Error("handings to other keys same")
}
n := HaveNode{V: 1, Busy: json.RawMessage(`true`)}
if !SameHaveNode(n, HaveNode{V: 1, Busy: json.RawMessage(`true`)}) || SameHaveNode(n, HaveNode{V: 1}) {
t.Error("have node")
}
if !SamePresence(Presence{V: 1, Online: true}, Presence{V: 1, Online: true}) || SamePresence(Presence{V: 1}, Presence{V: 1, Online: true}) {
t.Error("presence")
}
p := Pulse{V: 1, Bucket: "b", Key: "k", Revision: 1, Owes: "ace"}
q := p
if !SamePulse(p, q) {
t.Error("equal pulses differ")
}
q.Revision = 2
if SamePulse(p, q) {
t.Error("different pulses same")
}
}
func testHaveRemoved(t *testing.T) { // 1370/AC3
in := Have{Build: "b1", Phase: PhaseRemoved, Health: HealthUnknown, Detail: "the declaration no longer holds it"}
b, err := EncodeHave(in)
if err != nil {
t.Fatal(err)
}
out, err := DecodeHave(b)
if err != nil {
t.Fatal(err)
}
in.V = Version
if !SameHave(out, in) || out.Phase != "removed" {
t.Errorf("round trip: %+v", out)
}
}