Files
mesh-host/internal/link/handover_test.go
T
jschoubben b8158affa7
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: novox: not composed on novox: these assignments cannot be applied: | b8158affa7af on 27a956448c0a
compose-candidate is answered off its subscription, so its bound counts from the ask
An endpoint's subscription hands its asks over one at a time: two checks asking
one node at once queued, and the second answered at about 100 s, past the
checker's bound and the bus's one-minute reply window. compose-candidate is now
answered in a goroutine of its own, at most four at once, one more refused in
words. The new cases are rows of the existing tests (test budget).

Issue 1126, review round 1.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-03 01:28:45 +01:00

452 lines
21 KiB
Go

package link
import (
"context"
"crypto/ed25519"
"encoding/json"
"errors"
"sort"
"strings"
"sync"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/micro"
)
// The hand-over's ask and answer hold these field names; the controller's side holds the same list
// (mesh-controller internal/link, TestTheHandOverAskAndAnswerKeepTheirFieldNames).
func TestTheHandOverAskAndAnswerKeepTheirFieldNames(t *testing.T) {
body, _ := json.Marshal(HandOverAsk{Node: "laptop", Path: "/srv/notes", By: "jo", Expires: time.Now(), Nonce: "n"})
if got := keysOf(t, body); got != "by expires node nonce path" {
t.Fatalf("the ask's fields are %q", got)
}
body, _ = json.Marshal(SignedHandOver{Ask: []byte("{}"), Signature: []byte("s")})
if got := keysOf(t, body); got != "ask signature" {
t.Fatalf("the signed ask's fields are %q", got)
}
body, _ = json.Marshal(HandOverAnswer{Said: "s", Refused: "r"})
if got := keysOf(t, body); got != "refused said" {
t.Fatalf("the answer's fields are %q", got)
}
if AskHandOverSubject("laptop") != "mesh.node.laptop.ask.hand-over" {
t.Fatalf("the subject is %q", AskHandOverSubject("laptop"))
}
if HandOverContext != "novox-mesh hand-over v1\n" {
t.Fatalf("the signing context is %q; the controller holds the same words", HandOverContext)
}
}
func keysOf(t *testing.T, body []byte) string {
t.Helper()
var m map[string]any
if err := json.Unmarshal(body, &m); err != nil {
t.Fatal(err)
}
var keys []string
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return strings.Join(keys, " ")
}
const aNonce = "00112233445566778899aabbccddeeff"
// signedAsk is an ask as the controller makes it, signed with key over the context and its bytes.
func signedAsk(t *testing.T, key ed25519.PrivateKey, ask HandOverAsk) []byte {
t.Helper()
raw, err := json.Marshal(ask)
if err != nil {
t.Fatal(err)
}
body, err := json.Marshal(SignedHandOver{Ask: raw, Signature: ed25519.Sign(key, append([]byte(HandOverContext), raw...))})
if err != nil {
t.Fatal(err)
}
return body
}
// anAsk is a good ask at now.
func anAsk(now time.Time) HandOverAsk {
return HandOverAsk{Node: "n1", Path: "/srv/notes", By: " jo through mesh-cli on anchor ", Expires: now.Add(HandOverGood),
Nonce: aNonce}
}
// **An ask is believed only signed by the mesh, for this node, unexpired, once** (review of issue 356): a
// responder can reach the subject through a reply, so the subject proves nothing. Each refusal below hands
// the handler nothing; the good ask reaches it with its path and who asked, and the same ask again is refused.
func TestAHandOverAskIsBelievedOnlySignedForThisNodeUnexpiredAndOnce(t *testing.T) {
m, key := aMember(t)
_, stranger, _ := ed25519.GenerateKey(nil)
now := time.Date(2026, 10, 9, 18, 0, 0, 0, time.UTC)
var given []string
handle := func(path, by string) (string, error) {
given = append(given, path+" by "+by)
if path == "/refused" {
return "", errors.New("not a directory this machine uses as found; nothing was handed over")
}
return path + " is handed to the mesh", nil
}
check := HandOverCheck{Signer: m.Signer, Node: m.Node, Now: func() time.Time { return now }, Nonces: &Nonces{}}
with := func(change func(*HandOverAsk)) HandOverAsk {
a := anAsk(now)
change(&a)
return a
}
good, _ := json.Marshal(anAsk(now))
unsignedBody, _ := json.Marshal(SignedHandOver{Ask: good})
// Signed as a declaration is, over the bytes alone: not a hand-over's signature.
asDeclaration, _ := json.Marshal(SignedHandOver{Ask: good, Signature: ed25519.Sign(key, good)})
refusals := map[string][]byte{
"too large": signedAsk(t, key, with(func(a *HandOverAsk) { a.Path = "/" + strings.Repeat("a", MaxHandOverAsk) })),
"not a signed hand-over": []byte(`not json`),
"unsigned": unsignedBody,
"signed by another key": signedAsk(t, stranger, anAsk(now)),
"signed as a declaration": asDeclaration,
"the bare ask": good,
"for another node": signedAsk(t, key, with(func(a *HandOverAsk) { a.Node = "n2" })),
"expired": signedAsk(t, key, with(func(a *HandOverAsk) { a.Expires = now })),
"no expiry": signedAsk(t, key, with(func(a *HandOverAsk) { a.Expires = time.Time{} })),
"good for too long": signedAsk(t, key, with(func(a *HandOverAsk) { a.Expires = now.Add(HandOverGood + HandOverSkew + time.Second) })),
"no nonce": signedAsk(t, key, with(func(a *HandOverAsk) { a.Nonce = "" })),
"a short nonce": signedAsk(t, key, with(func(a *HandOverAsk) { a.Nonce = "0011" })),
"nobody": signedAsk(t, key, with(func(a *HandOverAsk) { a.By = " " })),
"two lines": signedAsk(t, key, with(func(a *HandOverAsk) { a.By = "jo\nroot" })),
"a name too long": signedAsk(t, key, with(func(a *HandOverAsk) { a.By = strings.Repeat("x", MaxHandOverBy+1) })),
"a NUL in the name": signedAsk(t, key, with(func(a *HandOverAsk) { a.By = "jo\x00" })),
"no directory": signedAsk(t, key, with(func(a *HandOverAsk) { a.Path = "" })),
}
for name, body := range refusals {
a := AnswerHandOver(check, handle, body)
if a.Refused == "" || a.Said != "" || !strings.Contains(a.Refused, "nothing was handed over") {
t.Fatalf("%s was answered %+v; want a refusal saying nothing was handed over", name, a)
}
}
if len(given) != 0 {
t.Fatalf("the handler was given %v although every ask was refused", given)
}
if a := AnswerHandOver(HandOverCheck{Node: "n1", Nonces: &Nonces{}}, handle, signedAsk(t, key, anAsk(time.Now()))); a.Refused == "" {
t.Fatal("an engine with no key believed an ask")
}
if a := AnswerHandOver(check, nil, signedAsk(t, key, anAsk(now))); a.Refused == "" {
t.Fatal("an engine with no handler took a hand-over")
}
body := signedAsk(t, key, anAsk(now))
a := AnswerHandOver(check, handle, body)
if a.Said != "/srv/notes is handed to the mesh" || a.Refused != "" {
t.Fatalf("the good ask was answered %+v", a)
}
if again := AnswerHandOver(check, handle, body); !strings.Contains(again.Refused, "taken already") {
t.Fatalf("the same ask again was answered %+v", again)
}
refused := signedAsk(t, key, with(func(a *HandOverAsk) { a.Path = "/refused"; a.Nonce = strings.Repeat("ab", 16) }))
if a := AnswerHandOver(check, handle, refused); !strings.HasPrefix(a.Refused, "not a directory") {
t.Fatalf("the handler's refusal was answered %+v", a)
}
if strings.Join(given, "; ") != "/srv/notes by jo through mesh-cli on anchor; /refused by jo through mesh-cli on anchor" {
t.Fatalf("the handler was given %v", given)
}
// Issue 1126: a link opened on the mesh has no signer (the controller that signed these asks ended), so an ask signed
// by the membership's key, the controller's, is refused and nothing is handed over.
before := len(given)
opened := (&natsLink{node: m.Node}).check()
opened.Now, opened.Nonces = func() time.Time { return now }, &Nonces{}
late := signedAsk(t, key, with(func(a *HandOverAsk) { a.Nonce = strings.Repeat("cd", 16) }))
if a := AnswerHandOver(opened, handle, late); !strings.Contains(a.Refused, "not signed by the mesh") || len(given) != before {
t.Fatalf("a link opened on the mesh answered an ask signed by the controller's key: %+v", a)
}
// A nonce is forgotten once its ask has expired, so the set does not grow for ever.
now = now.Add(HandOverGood + time.Second)
check.Nonces.take("ff", now.Add(time.Second), now)
if len(check.Nonces.seen) != 1 {
t.Fatalf("expired nonces are kept: %v", check.Nonces.seen)
}
}
// **A request with no reply is nobody's question**: nothing is judged, recorded or said. One with a reply is
// answered with the judgement.
func TestAHandOverIsAnsweredOnlyWhenItCarriesAReply(t *testing.T) {
m, key := aMember(t)
now := time.Now()
called := 0
handle := func(string, string) (string, error) { called++; return "handed", nil }
check := HandOverCheck{Signer: m.Signer, Node: m.Node, Nonces: &Nonces{}}
if body, answer := HandOverReply(check, handle, "", signedAsk(t, key, anAsk(now))); answer || body != nil || called != 0 {
t.Fatalf("a request with no reply was answered %q %v, handled %d", body, answer, called)
}
body, answer := HandOverReply(check, handle, "_INBOX.x", signedAsk(t, key, anAsk(now)))
var said HandOverAnswer
if !answer || json.Unmarshal(body, &said) != nil || said.Said != "handed" || called != 1 {
t.Fatalf("answered %q %v, handled %d", body, answer, called)
}
}
// handingLink is a quiet link that hears hand-overs, keeping the handler it was given.
type handingLink struct {
*quietLink
given chan HandOverFunc
}
func (l handingLink) HearHandOvers(h HandOverFunc) { l.given <- h }
// **The link is given the queue's hand-over** when it is served: what the controller asks reaches the engine's
// judgement, and nothing else.
func TestALinkThatHearsHandOversIsGivenTheQueues(t *testing.T) {
m, _ := aMember(t)
l := handingLink{quietLink: newQuietLink(), given: make(chan HandOverFunc, 1)}
q := &Session{Membership: m, Timeout: time.Second}
q.HandOver = func(path, by string) (string, error) { return "the queue's: " + path + " by " + by, nil }
ctx, stop := context.WithCancel(context.Background())
served := make(chan error, 1)
go func() { served <- serve(ctx, l, m, q, nil, time.Second) }()
select {
case h := <-l.given:
if said, err := h("/srv/notes", "jo"); err != nil || said != "the queue's: /srv/notes by jo" {
t.Fatalf("the link was given another handler: %q %v", said, err)
}
case <-time.After(5 * time.Second):
t.Fatal("the link was never given the queue's hand-over")
}
stop()
<-served
// 774 (ADR 0563 §1): a call of the node seat reaches its verb with its caller as the subject proves it, unproven on
// the subject that names none; a verb the seat does not serve is refused, and the answer is a tool's envelope.
t.Run("a call of the node seat reaches its verb with its proven caller", func(t *testing.T) {
h := &HandActs{Node: "n1", State: &MemState{},
RootFree: func() RootVerdict { return RootVerdict{Machine: "n1", Free: true, Why: "judged"} },
JudgeDirectories: func(by string) (string, error) { return "judged for " + by, nil },
Composition: func(module, digest string, diff bool) (any, error) {
return map[string]any{"module": module, "digest": digest, "diff": diff}, nil
},
Compose: func(module string) (any, error) { return map[string]any{"composed": true, "retried": module}, nil },
// ADR 0565 §6, §15: the node's key of the engine's state packages.
Packages: func(write bool) (any, error) {
key := []byte(`{"node":"n1","packages":[{"package":"curl","modules":["backup","web"],"absent":false}]}`)
if !write {
return PackagesAnswer("n1", key, nil), nil
}
v := PackagesAnswer("n1", nil, nil)
wrote := true
v.Wrote = &wrote
return v, nil
}}
for _, row := range []struct{ verb, subject, want string }{
{"judge-directories", "mesh.seat.node-engine.call.judge-directories.n1.shanks~node-tools", `"said":"judged for shanks.node-tools"`},
{"judge-directories", EngineToolSubject("judge-directories", "n1"), `"said":"judged for unproven"`},
{"judge-directories", "mesh.seat.node-engine.call.judge-directories.n2.shanks~node-tools", `"said":"judged for unproven"`},
{"root-free", "mesh.seat.node-engine.call.root-free.n1.ace~messenger", `"free":true`},
// The read verb composition (issue 774) takes its module, digest and diff, as a word or a boolean.
{"composition", EngineToolSubject("composition", "n1") + " " + `{"module":"postgres","digest":"sha256:a","diff":true}`, `"diff":true,"digest":"sha256:a","module":"postgres"`},
{"composition", EngineToolSubject("composition", "n1") + " " + `{"module":"postgres","diff":"true"}`, `"diff":true,"digest":"","module":"postgres"`},
// The recovery verb compose (issue 774) makes a pass now, with a module's apply tried again when named.
{"compose", EngineToolSubject("compose", "n1"), `"composed":true,"retried":""`},
{"compose", EngineToolSubject("compose", "n1") + " " + `{"module":"route-proxy"}`, `"retried":"route-proxy"`},
// The read verb packages (ADR 0565 §6) names its source; with write, what it wrote, and a key the engine has not
// written is said and not an error.
{"packages", EngineToolSubject("packages", "n1"), `"source":"mesh-host.packages","key":{"node":"n1"`},
{"packages", EngineToolSubject("packages", "n1") + " " + `{"write":true}`, `"key":null,"wrote":true`},
{"packages", EngineToolSubject("packages", "n1") + " " + `{"write":"true"}`, `"note":"the engine has written no key for this node yet`},
{"unserved", "mesh.seat.node-engine.call.unserved.n1.x", `"error":"the node seat node-engine serves no verb \"unserved\""`},
} {
subject, body, ok := strings.Cut(row.subject, " ")
if !ok {
body = `{}`
}
if got := string(AnswerEngineCall(h.Call, row.verb, "n1", subject, []byte(body))); !strings.Contains(got, row.want) ||
!strings.Contains(got, `"node":"n1"`) {
t.Errorf("%s on %s: %s", row.verb, row.subject, got)
}
}
acts, _ := h.Acts(0)
if got := acts.(map[string]any)["acts"].([]HandAct); len(got) != 3 || got[0].Caller != "unproven" ||
got[2].Caller != "shanks.node-tools" || got[2].Act != ActJudgeDirectories {
t.Errorf("the judgements are recorded as %+v", got)
}
})
// Issue 1126 (review of 2026-11-03): two asks of compose-candidate at once, handed over one at a time as an
// endpoint's subscription hands them, are both answered while the other runs: neither waits for the other, so each
// bound counts from its own arrival. A verb answered on the subscription answers in order, as before.
t.Run("two asks of compose-candidate at once are answered side by side", func(t *testing.T) {
var mu sync.Mutex
arrived, both := 0, make(chan struct{})
handle := func(c EngineCall) (any, error) {
mu.Lock()
if arrived++; arrived == 2 {
close(both)
}
mu.Unlock()
select {
case <-both:
return "together", nil
case <-time.After(2 * time.Second): // only when the asks are answered one after the other
return "alone", nil
}
}
answered := make(chan string, 2)
answer := EngineAnswer(handle, ActComposeCandidate, "n1", make(chan struct{}, 4))
for range 2 {
answer(fakeRequest{subject: EngineToolSubject(ActComposeCandidate, "n1"), answered: answered})
}
for range 2 {
if got := <-answered; !strings.Contains(got, `"result":"together"`) {
t.Errorf("an ask answered %s; want both answered side by side", got)
}
}
full := make(chan struct{}, 1)
full <- struct{}{}
EngineAnswer(handle, ActComposeCandidate, "n1", full)(fakeRequest{subject: "s", answered: answered})
if got := <-answered; !strings.Contains(got, "asks of compose-candidate at once") {
t.Errorf("an ask past the bound answered %s", got)
}
})
}
// fakeRequest is a seat call as the subscription hands it over, its answer sent to answered.
type fakeRequest struct {
subject string
answered chan string
}
func (r fakeRequest) Respond(b []byte, _ ...micro.RespondOpt) error {
r.answered <- string(b)
return nil
}
func (r fakeRequest) RespondJSON(any, ...micro.RespondOpt) error { return nil }
func (r fakeRequest) Error(string, string, []byte, ...micro.RespondOpt) error {
return nil
}
func (r fakeRequest) Data() []byte { return []byte(`{}`) }
func (r fakeRequest) Headers() micro.Headers { return nil }
func (r fakeRequest) Subject() string { return r.subject }
func (r fakeRequest) Reply() string { return "_INBOX.x" }
// **Against a real server**: a signed request on the node's own subject is answered; one on another node's is
// not heard; a message with no reply is answered by nobody.
func TestNatsAHandOverIsAskedOfTheNode(t *testing.T) {
conn, js := aBus(t)
m, key := aMember(t)
l := &natsLink{conn: conn, js: js, node: m.Node, signer: m.Signer}
handled := make(chan string, 4)
l.HearHandOvers(func(path, by string) (string, error) { handled <- path; return "handed " + path, nil })
defer l.Close()
if err := conn.Flush(); err != nil {
t.Fatal(err)
}
ask := anAsk(time.Now())
ask.Nonce = strings.Repeat("cd", 16)
reply, err := conn.Request(AskHandOverSubject(m.Node), signedAsk(t, key, ask), 5*time.Second)
if err != nil {
t.Fatal(err)
}
var said HandOverAnswer
if err := json.Unmarshal(reply.Data, &said); err != nil || said.Said != "handed /srv/notes" {
t.Fatalf("answered %s, %v", reply.Data, err)
}
ask.Nonce = strings.Repeat("ef", 16)
if err := conn.Publish(AskHandOverSubject(m.Node), signedAsk(t, key, ask)); err != nil {
t.Fatal(err)
}
if _, err := conn.Request(AskHandOverSubject("another"), signedAsk(t, key, ask), 300*time.Millisecond); err == nil {
t.Fatal("another node's ask was answered here")
}
if err := conn.Flush(); err != nil {
t.Fatal(err)
}
time.Sleep(100 * time.Millisecond)
if len(handled) != 1 {
t.Fatalf("handled %d asks; want only the one with a reply", len(handled))
}
// 774 (ADR 0563 §2, §3): the engine's acts are its node's keys of its declared state on the bus, each written by
// compare-and-set: a second create and a stale revision lose, and another node's keys are not this node's.
t.Run("the acts are kept in the bus's own storage under this node's keys", func(t *testing.T) {
_ = js.DeleteKeyValue(ActsBucket)
if _, err := js.CreateKeyValue(&nats.KeyValueConfig{Bucket: ActsBucket}); err != nil {
t.Fatal(err)
}
q := &Session{Membership: m, Timeout: time.Second}
q.bus = l
mine, theirs := BusState{Session: q, Node: m.Node}, BusState{Session: q, Node: "another"}
rev, err := mine.Create("act.a-1", map[string]string{"state": "asked"})
if err != nil {
t.Fatal(err)
}
if _, err := mine.Create("act.a-1", map[string]string{}); !errors.Is(err, stdio.ErrChanged) {
t.Errorf("a second create: %v", err)
}
if _, err := mine.Update("act.a-1", map[string]string{"state": "done"}, rev+7); !errors.Is(err, stdio.ErrChanged) {
t.Errorf("an update at a stale revision: %v", err)
}
if _, err := mine.Update("act.a-1", map[string]string{"state": "done"}, rev); err != nil {
t.Errorf("an update at its revision: %v", err)
}
_, _ = theirs.Create("act.b-1", map[string]string{})
if keys, err := mine.Keys("act."); err != nil || len(keys) != 1 || keys[0] != "act.a-1" {
t.Errorf("this node's acts are %v, %v", keys, err)
}
if e, _ := js.KeyValue(ActsBucket); e != nil {
if got, err := e.Get(m.Node + ".act.a-1"); err != nil || !strings.Contains(string(got.Value()), "done") {
t.Errorf("the bucket holds %v, %v", got, err)
}
}
if err := mine.Delete("act.a-1"); err != nil {
t.Fatal(err)
}
if e, err := mine.Get("act.a-1"); err != nil || e != nil {
t.Errorf("a deleted act reads %v, %v", e, err)
}
})
// 774 (ADR 0563 §1): the node seat is served on this node's subjects alone, naming its caller, and said to discovery.
t.Run("the node seat is served on this node and discovered", func(t *testing.T) {
l.ServeEngineSeat(func(c EngineCall) (any, error) { return c.Verb + " for " + c.Caller, nil })
reply, err := conn.Request("mesh.seat.node-engine.call.acts."+m.Node+".shanks~node-tools", []byte(`{}`), 5*time.Second)
if err != nil || !strings.Contains(string(reply.Data), `"result":"acts for shanks.node-tools"`) {
t.Fatalf("the call answered %v, %v", reply, err)
}
if _, err := conn.Request(EngineToolSubject("acts", "another"), []byte(`{}`), 300*time.Millisecond); err == nil {
t.Fatal("another node's verb was answered here")
}
// Announced in the shape the console reads a seat's verb in (sweep 2026-10-30): without kind and scope it listed
// none of it, and <node>/node-engine.root-free answered that nothing in the mesh is called node-engine.
info, err := conn.Request("$SRV.INFO."+EngineSeat, nil, 5*time.Second)
if err != nil {
t.Fatal(err)
}
var said struct {
Endpoints []struct {
Subject string `json:"subject"`
Metadata map[string]string `json:"metadata"`
} `json:"endpoints"`
}
if err := json.Unmarshal(info.Data, &said); err != nil {
t.Fatal(err)
}
announced := 0
for _, e := range said.Endpoints {
md := e.Metadata
if md["kind"] != "seat" || md["scope"] != "node" || md["seat"] != EngineSeat || md["module"] != "mesh-host" ||
md["node"] != m.Node || md["description"] == "" || !strings.HasPrefix(md["schema"], "{") {
t.Errorf("the endpoint on %s announces %v", e.Subject, md)
}
if e.Subject == EngineToolSubject("root-free", m.Node) {
announced++
}
}
if announced != 1 {
t.Errorf("root-free is announced on its tool subject %d times", announced)
}
for seat, want := range map[string]bool{EngineSeat: true, "node-login-shell": false} {
verb := "acts"
if !want {
verb = "execute"
}
if heard, err := l.HeardServed(seat, verb); err != nil || heard != want {
t.Errorf("discovery hears %s.%s here: %v, %v", seat, verb, heard, err)
}
}
})
}