Files
mesh-sdk/go/delivery/noderecord_test.go
T
jschoubben 85ba0a9a70
mesh/delivery merged: source.main moved to novox/mesh-sdk@5a38a1221659
mesh/delivery-group group feat/l3-final merged: every member is merged
mesh/check pass 85ba0a9a70d8 on 52e564845e44 | unit 3760 run, 4992 kept, 56 skipped | integration 3841 run, 5103 kept, 59 skipped | over bound: unit, …
A node record states the reader capabilities its engine reads, a module value the ones its manifest needs, and a want of rule 9 or 10 reads (ADR 0614 §2, §4, issue 774)
NodeRecord.ManifestReads (`manifest-reads`) and Module.Needs (`needs`) are additive at Version 1, read leniently by
every running part. checkRule takes 1 to 10 so a later release may number the verb wait and the capability hold;
nothing writes 9 or 10 in this one. kept_until_delivered.go stays: this release still builds beside mesh-host 798d73e,
and its head comment names the next mesh-sdk release as the one that removes it.

Claude-Session: https://claude.ai/code/session_01JG73KG7AfSdh6FP8VrBqCn
2026-11-03 23:18:22 +01:00

216 lines
10 KiB
Go

package delivery
import (
"encoding/base64"
"reflect"
"regexp"
"strconv"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// secretNamed is facts.secretKey's pattern: a field so named would read as a secret, and the runtime's credential-field
// check (ADR 0201 §13) refuses such a field in a state every node reads.
var secretNamed = regexp.MustCompile(`(?i)(secret|passw|token|api[-_]?key|private[-_]?key|credential|bearer|cookie|salt|signing)`)
// Issue 1126 (ADR 0555), and issue 774 for the enrolment's keys (ADR 0587 §2): a node's record reads back, rides in its have key, refuses a node that is not a name and an empty capability, and
// neither it nor the placement file names a field like a secret.
func TestNodeRecord(t *testing.T) {
hub := NodeRecord{Node: "novox", TunnelKey: "k", Capabilities: []string{"docker"}, SealingKey: "age1x", ServingKey: "pub",
Certificate: "cert", OutwardLinks: []string{"eth0"}, Networks: []catalogue.AttachedNetwork{{Link: "eth0", Network: "192.0.2.0/24"}},
NetworksSaid: true, Contract: 3, Trusts: []string{"controller key sha256:aa", "vault key sha256:bb"},
ManifestReads: []string{"seat:mesh-catalog", "word:tool-runner"}}
raw, err := EncodeNodeRecord(hub)
if err != nil {
t.Fatal(err)
}
back, err := DecodeNodeRecord(raw)
if err != nil || !SameNodeRecord(back, hub) || back.V != Version {
t.Fatalf("round trip: %+v, %v", back, err)
}
// It rides in the node's own have key: a have that differs only in its record is another value.
with, without := HaveNode{V: Version, Record: &hub}, HaveNode{V: Version}
if raw, err := EncodeHaveNode(with); err != nil {
t.Fatal(err)
} else if got, err := DecodeHaveNode(raw); err != nil || got.Record == nil || !SameHaveNode(got, with) || SameHaveNode(got, without) {
t.Fatalf("the have's record: %+v, %v", got, err)
}
changed := hub
changed.Contract = 4
if SameHaveNode(with, HaveNode{V: Version, Record: &changed}) {
t.Error("a changed record reads as the same have")
}
for name, r := range map[string]NodeRecord{
"empty capability": {Node: "ace", Capabilities: []string{""}},
"empty reader capability": {Node: "ace", ManifestReads: []string{""}},
"node that is not a name": {Node: "a.b"},
} {
if _, err := EncodeNodeRecord(r); err == nil {
t.Errorf("%s: taken", name)
}
}
// Every JSON field name of the record and the types it carries, walked: none reads as a secret.
var walk func(reflect.Type, string)
walk = func(ty reflect.Type, at string) {
for ty.Kind() == reflect.Pointer || ty.Kind() == reflect.Slice {
ty = ty.Elem()
}
if ty.Kind() != reflect.Struct {
return
}
for i := 0; i < ty.NumField(); i++ {
f := ty.Field(i)
name, _, _ := strings.Cut(f.Tag.Get("json"), ",")
if secretNamed.MatchString(name) || secretNamed.MatchString(f.Name) {
t.Errorf("%s.%s (%q) is named like a secret", at, f.Name, name)
}
walk(f.Type, at+"."+f.Name)
}
}
walk(reflect.TypeOf(NodeRecord{}), "NodeRecord")
// 774/S35/AC1: an enrolment key and an address key read back equal, and neither names a field like a secret: the
// grants part reads them (ADR 0587 §2).
issued := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC)
// The tunnel key given at issue, at every stage.
wg := base64.StdEncoding.EncodeToString(make([]byte, 32))
for _, e := range []Enrolment{
{Node: "lab", Address: "10.0.0.7", Why: "a new machine", Caller: "mesh-enrolment", Ask: "a1", Stage: EnrolmentLive,
OverlayKey: wg, Issued: issued, Expires: issued.Add(DefaultTokenLife)},
{Node: "lab", Address: "10.0.0.7", Why: "a new machine", Caller: "mesh-enrolment", Ask: "a1", Stage: EnrolmentRedeeming,
OverlayKey: wg, Issued: issued, Expires: issued.Add(MostTokenLife), KeysDigest: KeysDigest("pub", "overlay", "sealing", "serving")},
} {
raw, err := EncodeEnrolment(e)
if err != nil {
t.Fatal(err)
}
back, err := DecodeEnrolment(raw)
e.V = Version
if err != nil || !reflect.DeepEqual(back, e) {
t.Errorf("enrolment round trip: %+v, %v", back, err)
}
}
// ADR 0589 §6: reserved before the ask with neither ask nor issue time, and from the ask with both.
if raw, err := EncodeEnrolmentAddress("lab", "", time.Time{}); err != nil {
t.Fatal(err)
} else if back, err := DecodeEnrolmentAddress(raw); err != nil || back.Node != "lab" || back.Ask != "" || strings.Contains(string(raw), "issued") {
t.Errorf("address round trip: %+v, %v (%s)", back, err, raw)
}
if raw, err := EncodeEnrolmentAddress("lab", "a1", issued); err != nil {
t.Fatal(err)
} else if back, err := DecodeEnrolmentAddress(raw); err != nil || back.Ask != "a1" || !back.Issued.Equal(issued) {
t.Errorf("address at the ask round trip: %+v, %v", back, err)
}
for name, raw := range map[string]string{
"no node": `{"v":1}`,
"an ask with no issue time": `{"v":1,"node":"lab","ask":"a1"}`,
"an issue time with no ask": `{"v":1,"node":"lab","issued":"2026-11-02T01:00:00Z"}`,
} {
if _, err := DecodeEnrolmentAddress([]byte(raw)); err == nil {
t.Errorf("an address with %s: taken", name)
}
}
if id := EnrolmentIssuedID("lab", issued); id != "enrolment-issued.lab."+strconv.FormatInt(issued.UnixNano(), 10) {
t.Errorf("the issued event's id: %q", id)
}
for name, e := range map[string]Enrolment{
"keys before redeem": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentLive,
OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour), KeysDigest: KeysDigest("a", "b", "c", "d")},
"no keys at redeem": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentRedeeming,
OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour)},
"no address": {Node: "lab", Why: "w", Caller: "c", Stage: EnrolmentAsked, OverlayKey: wg, Issued: issued, Expires: issued.Add(time.Hour)},
"no tunnel key": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentAsked, Issued: issued,
Expires: issued.Add(time.Hour)},
"a tunnel key not of 32 bytes": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: EnrolmentAsked,
OverlayKey: base64.StdEncoding.EncodeToString(make([]byte, 31)), Issued: issued, Expires: issued.Add(time.Hour)},
"other stage": {Node: "lab", Address: "10.0.0.7", Why: "w", Caller: "c", Stage: "spent", OverlayKey: wg, Issued: issued,
Expires: issued.Add(time.Hour)},
} {
if _, err := EncodeEnrolment(e); err == nil {
t.Errorf("enrolment with %s: taken", name)
}
}
if key, ok := EnrolmentKeyAddress(EnrolmentAddressKey("10.0.0.7")); !ok || key != "10.0.0.7" {
t.Errorf("address key: %q %v", key, ok)
}
if node, ok := EnrolmentKeyNode(EnrolmentNodeKey("lab")); !ok || node != "lab" {
t.Errorf("node key: %q %v", node, ok)
}
for _, life := range []string{"", "1h", "24h"} {
if _, err := TokenLife(life); err != nil {
t.Errorf("a life of %q: %v", life, err)
}
}
for _, life := range []string{"25h", "-1h", "a day"} {
if _, err := TokenLife(life); err == nil {
t.Errorf("a life of %q: taken", life)
}
}
// 774/S38/AC1: a node's key of node-logins reads back equal, an empty rotating reads as none, and a stage without a
// rotation, a rotation to anything but the other login, or a login not the node's is refused (ADR 0583 §1).
for _, l := range []NodeLogin{
{Node: "g14", Login: "node.g14", Since: issued},
{Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: RotationReady, Since: issued, ListRevision: 41},
{Node: "g14", Login: "node.g14--b", Rotating: "node.g14", Stage: RotationBegun, Since: issued},
} {
raw, err := EncodeNodeLogin(l)
if err != nil {
t.Fatal(err)
}
back, err := DecodeNodeLogin(raw)
l.V = Version
if err != nil || !reflect.DeepEqual(back, l) {
t.Errorf("node login round trip: %+v, %v", back, err)
}
}
// The hand acts that warranted its rotations, as the vault records them.
if raw, err := EncodeNodeLogin(NodeLogin{Node: "g14", Login: "node.g14--b", Acts: []string{"act-1"}}); err != nil ||
!strings.Contains(string(raw), `"acts":["act-1"]`) {
t.Errorf("a login with its acts: %s, %v", raw, err)
}
if l, err := DecodeNodeLogin([]byte(`{"v":1,"node":"g14","login":"node.g14","rotating":"","since":"2026-11-02T01:00:00Z"}`)); err != nil || l.Rotates() {
t.Errorf("an empty rotating: %+v, %v", l, err)
}
for name, l := range map[string]NodeLogin{
"a stage, no rotation": {Node: "g14", Login: "node.g14", Stage: RotationBegun},
"another node's login": {Node: "g14", Login: "node.ace"},
"a rotation to itself": {Node: "g14", Login: "node.g14", Rotating: "node.g14", Stage: RotationBegun},
"a rotation, no stage": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b"},
"a rotation, done stage": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: "done"},
"an empty act": {Node: "g14", Login: "node.g14", Acts: []string{""}},
} {
if _, err := EncodeNodeLogin(l); err == nil {
t.Errorf("node login with %s: taken", name)
}
}
if node, ok := LoginNode("node.g14--b"); !ok || node != "g14" || OtherLogin("g14", "node.g14--b") != "node.g14" {
t.Errorf("the second login's node: %q %v", node, ok)
}
walk(reflect.TypeOf(NodeLogin{}), "NodeLogin")
walk(reflect.TypeOf(Enrolment{}), "Enrolment")
walk(reflect.TypeOf(EnrolmentAddress{}), "EnrolmentAddress")
walk(reflect.TypeOf(EnrolmentExpiry{}), "EnrolmentExpiry")
// enrolment-expired names the issued event by its message id (§2), never a stream sequence.
if raw, err := EncodeEnrolmentExpiry(EnrolmentExpiry{Node: "lab", IssuedID: "enrolment-issued.lab.1"}); err != nil {
t.Errorf("an expiry: %v", err)
} else if x, err := DecodeEnrolmentExpiry(raw); err != nil || x.IssuedID != "enrolment-issued.lab.1" ||
!strings.Contains(string(raw), `"issued-event":"enrolment-issued.lab.1"`) {
t.Errorf("an expiry reads back as %+v, %v (%s)", x, err, raw)
}
for name, raw := range map[string]string{
"no issued event": `{"v":1,"node":"lab"}`,
"a sequence for it": `{"v":1,"node":"lab","issued-event":7}`,
"a node not a name": `{"v":1,"node":"Lab Pi","issued-event":"e"}`,
} {
if _, err := DecodeEnrolmentExpiry([]byte(raw)); err == nil {
t.Errorf("an expiry with %s: taken", name)
}
}
// And the placement file, which holds what a person decides of a node (K2): accounts by name, never a credential.
walk(reflect.TypeOf(Placement{}), "Placement")
}