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") } }