package link import ( "context" "crypto/ed25519" "encoding/json" "errors" "sort" "strings" "testing" "time" ) // 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) } // 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 := aQueue(m, (&applies{}).apply, &keptInMemory{}) 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 } // **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)) } }