On node-logins' ready the engine fetches the new value through the vault's own hand-out (own/<node>/mesh-host/<login>, opened with its sealing key), dials a second connection as the new login on the pinned certificate, and switches only if that authenticates, then calls mesh-vault.node-account-confirm over the link as the new login (the vault is asked in the name of the login the open link authenticated as, and only once that link is open). It keeps the old value until the key says the rotation is done and drops it then, never before; an abandon, after the switch before or after the confirmation, switches back before the new value is dropped; repeated refusals of the new login's credentials before rotation-done bring it back to the old one, said as the finding node-account-fallback (in the journal: no route for an engine's finding to the watchman is decided), and the same rotation is not tried again. Tests: two added, paid by merging two identity sealing tests into one; more to be paid by deleting the identity token tests that move to mesh-sdk with the token (774/S64). (issue 774, 774/S62, 774/S63) Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
149 lines
5.8 KiB
Go
149 lines
5.8 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
|
|
finding 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) },
|
|
Finding: func(name, text string) { g.finding = name },
|
|
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"))
|
|
if cur, _ := f.logins.Current(); cur != "node.ace" || f.finding != delivery.NodeAccountFallbackFinding {
|
|
t.Fatalf("after the bound: %s, finding %q", cur, f.finding)
|
|
}
|
|
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
|
|
}
|