Files
mesh-host/internal/link/rotate_test.go
T
jschoubben 12168ba4da The engine carries its login fall back in its health report, and says the enrolment fallback is the pre-lease path
A fall back to the old login during a rotation is now delivery.NodeHealth's Fallback (the node, the login fallen back from,
why, when), written with the node's health report, from which the watchman raises node-account-fallback; it is said no more
once the vault's key of the node moves on (blocker 7, ADR 0583 §6). mesh-host enrol's fall back to the controller's
enrolment subject says it is the path before the lease write, which the bus refuses once the lease is written. Tests: rows
in the existing rotation test; none added.

(issue 774)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:49:01 +01:00

160 lines
6.4 KiB
Go

package link
import (
"context"
"errors"
"slices"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// rotRig is a Rotator over stand-ins: what the vault hands, what a test-dial answers, and what it was asked, in order.
type rotRig struct {
r *Rotator
logins *Logins
states []LoginsState
steps []string
dialErr error
said []string
}
func newRotRig() *rotRig {
g := &rotRig{}
g.logins = NewLogins("ace", "first", LoginsState{}, func(s LoginsState) error {
g.states = append(g.states, LoginsState{Current: s.Current, Values: map[string]string{}})
for k, v := range s.Values {
g.states[len(g.states)-1].Values[k] = v
}
return nil
})
g.r = &Rotator{Logins: g.logins,
Fetch: func(_ context.Context, login string) (string, error) {
g.steps = append(g.steps, "fetch "+login)
return "secret-of-" + login, nil
},
TestDial: func(_ context.Context, login, _ string) error {
g.steps = append(g.steps, "test-dial "+login)
return g.dialErr
},
Confirm: func(context.Context) error {
cur, _ := g.logins.Current()
g.steps = append(g.steps, "confirm as "+cur)
return nil
},
Say: func(s string) { g.said = append(g.said, s) },
Now: func() time.Time { return time.Date(2026, 11, 2, 12, 5, 0, 0, time.UTC) },
FailBound: 2}
g.r.Watch()
return g
}
func key(login, rotating, stage string) delivery.NodeLogin {
return delivery.NodeLogin{Node: "ace", Login: login, Rotating: rotating, Stage: stage, Since: time.Date(2026, 11, 2, 12, 0, 0, 0, time.UTC)}
}
// 774/S62/AC1 and AC2 (ADR 0583 §3): at ready the engine fetches the new value and test-dials it; one that does not
// authenticate leaves it on the old login, saying why; one that does is switched to, confirmed over the new connection, and
// the old value is kept.
func TestTheEngineSwitchesOnlyAfterATestDialAndConfirmsAsTheNewLogin(t *testing.T) {
g := newRotRig()
ctx := context.Background()
g.r.Heard(ctx, key("node.ace", "node.ace--b", delivery.RotationBegun))
if len(g.steps) != 0 {
t.Fatalf("before ready: %v", g.steps)
}
g.dialErr = errors.New("authorization violation")
g.r.Heard(ctx, key("node.ace", "node.ace--b", delivery.RotationReady))
if cur, _ := g.logins.Current(); cur != "node.ace" || !strings.Contains(strings.Join(g.said, "|"), "stays on node.ace") ||
slices.Contains(g.steps, "confirm as node.ace--b") {
t.Fatalf("a test-dial that failed: %s, said %v, steps %v", cur, g.said, g.steps)
}
g.dialErr = nil
g.r.Heard(ctx, key("node.ace", "node.ace--b", delivery.RotationReady))
cur, pw := g.logins.Current()
if cur != "node.ace--b" || pw != "secret-of-node.ace--b" {
t.Fatalf("after a good test-dial: %s %s", cur, pw)
}
if want := []string{"fetch node.ace--b", "test-dial node.ace--b", "test-dial node.ace--b", "confirm as node.ace--b"}; !slices.Equal(g.steps, want) {
t.Errorf("steps %v, want %v", g.steps, want)
}
if v, ok := g.logins.Value("node.ace"); !ok || v != "first" {
t.Error("the old value was not kept at the switch")
}
select {
case <-g.logins.Changed():
default:
t.Error("the link was not told to open again as the new login")
}
}
// 774/S63/AC1-AC3 (ADR 0583 §4-§6): the old value is dropped at rotation-done and not before; an abandon after the switch,
// before or after the confirmation, switches back before the new value is dropped; repeated failures with the new value before
// rotation-done bring the old one back, said as node-account-fallback, and that rotation is not tried again.
func TestTheOldValueGoesAtRotationDoneAbandonSwitchesBackAndFailuresFallBack(t *testing.T) {
ctx := context.Background()
for _, stage := range []string{"before confirming", "after confirming"} {
g := newRotRig()
g.r.Heard(ctx, key("node.ace", "node.ace--b", delivery.RotationReady))
if stage == "after confirming" {
g.r.Heard(ctx, key("node.ace", "node.ace--b", delivery.RotationConfirmed))
}
if _, held := g.logins.Value("node.ace"); !held {
t.Fatalf("%s: the old value was dropped before rotation-done", stage)
}
g.states = nil
g.r.Heard(ctx, key("node.ace", "", "")) // abandon: the vault keeps the old login in use, no rotation
if cur, _ := g.logins.Current(); cur != "node.ace" {
t.Fatalf("%s: after abandon the engine dials as %s", stage, cur)
}
for _, s := range g.states {
if _, held := s.Values["node.ace--b"]; !held && s.Current != "node.ace" {
t.Errorf("%s: the new value was dropped while %s was in use: %+v", stage, s.Current, s)
}
}
if _, held := g.logins.Value("node.ace--b"); held {
t.Errorf("%s: the abandoned value is kept", stage)
}
}
g := newRotRig()
g.r.Heard(ctx, key("node.ace", "node.ace--b", delivery.RotationReady))
g.r.Heard(ctx, key("node.ace--b", "", "")) // rotation-done: the vault retired the old login
if _, held := g.logins.Value("node.ace"); held {
t.Error("the old value is kept after rotation-done")
}
if cur, _ := g.logins.Current(); cur != "node.ace--b" {
t.Errorf("in use after done: %s", cur)
}
// Fallback: two failures with the new value (the bound) before rotation-done.
f := newRotRig()
f.r.Heard(ctx, key("node.ace", "node.ace--b", delivery.RotationReady))
f.logins.dialed("node.ace--b", errors.New("nats: authorization violation"))
if cur, _ := f.logins.Current(); cur != "node.ace--b" {
t.Fatalf("fell back after one failure: %s", cur)
}
f.logins.dialed("node.ace--b", errors.New("nats: authorization violation"))
fb := f.r.Fallback()
if cur, _ := f.logins.Current(); cur != "node.ace" || fb == nil || fb.Node != "ace" || fb.From != "node.ace--b" || fb.Why == "" || fb.At.IsZero() {
t.Fatalf("after the bound: %s, fall back %+v", cur, fb)
}
// The health report carries it, from which the watchman raises node-account-fallback.
if _, err := delivery.EncodeNodeHealth(delivery.NodeHealth{Node: "ace", Fallback: fb}); err != nil {
t.Errorf("the fall back is no health report's: %v", err)
}
steps := len(f.steps)
f.r.Heard(ctx, key("node.ace", "node.ace--b", delivery.RotationReady))
if len(f.steps) != steps {
t.Errorf("the rotation fallen back from was tried again: %v", f.steps[steps:])
}
f.logins.dialed("node.ace", errors.New("dial tcp: connection refused")) // not a refusal of credentials: counts for nothing
// The vault moves on (it abandons the rotation): the fall back is said no more.
abandoned := key("node.ace", "", "")
abandoned.Since = abandoned.Since.Add(time.Minute)
f.r.Heard(ctx, abandoned)
if f.r.Fallback() != nil {
t.Error("the fall back is still said after the vault moved on")
}
}