The grants part writes its list as an object of its own bucket, sealed to the bus node's key, and names it in user-list.sealed; the node-engine of the node holding mesh-broker is granted that one key and an object by name, the grants part its bucket (the controller's copy carries the same grants, byte for byte, golden regenerated there). A state may name its readers (announce, NamedReaders): mesh-vault.bus-accounts is read by mesh-grants alone.
406 lines
14 KiB
Go
406 lines
14 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) {
|
|
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) {
|
|
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 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)
|
|
}
|
|
})
|
|
// 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")
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
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 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")
|
|
}
|
|
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 out != in || out.Phase != "removed" {
|
|
t.Errorf("round trip: %+v", out)
|
|
}
|
|
}
|