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
160 lines
6.4 KiB
Go
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")
|
|
}
|
|
}
|