package cluster import ( "context" "testing" "webui4frpc/internal/store" ) // staleTokenWith copies the engine's current state but rewrites the given // forward's disabled flag, standing in for a token a neighbour captured before // this node's decision. func staleTokenWith(eng *Engine, local, remote string, port int, disabled bool) State { s := eng.state out := State{ LeaderID: s.LeaderID, Nodes: s.Nodes, Cycle: s.Cycle, Topology: map[string]*TopoEntry{}, } for id, e := range s.Topology { cp := *e if cp.Local.Name == local && cp.Remote.Name == remote && cp.Link.RemotePort == port { cp.Link.Disabled = disabled cp.Active = !disabled } out.Topology[id] = &cp } return out } // TestLocalDisableSurvivesAdoption is the regression test for the wipe that made // the ring's authority unachievable. // // OnToken/AdoptState replace e.state wholesale. A stop decided between two token // cycles was therefore erased by the next adoption whenever the incoming token // had been captured before the decision — so the flag never reached the other // members and the forward kept running. Written as a probe BEFORE the fix: it // reported "after adopting a stale token: known=true disabled=false". func TestLocalDisableSurvivesAdoption(t *testing.T) { eng := newTestEngine("n1", true) eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081}) if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state, SentAt: 1}); err != nil { t.Fatal(err) } // Local decision: stop it. eng.UpdateTopologyDisabled("web", "frps1", 18081, true) if d, _ := eng.TopologyDisabled("web", "frps1", 18081); !d { t.Fatal("precondition: the local stop should be visible") } // A token captured BEFORE the decision arrives. stale := staleTokenWith(eng, "web", "frps1", 18081, false) if _, err := eng.OnToken(context.Background(), &Token{Cycle: 2, State: stale, SentAt: 2}); err != nil { t.Fatal(err) } // Assert on the STATE, not the TopologyDisabled() accessor: the accessor // short-circuits to the pending local decision, so it would report the stop // even when the state about to be forwarded still says "enabled". What // matters is that the token this node sends on carries the decision. assertForwardedDisabled(t, eng, "web", "frps1", 18081, true) } // assertForwardedDisabled checks the disabled value a DOWNSTREAM node would see // in the token this engine forwards — the value that actually propagates. func assertForwardedDisabled(t *testing.T, eng *Engine, local, remote string, port int, want bool) { t.Helper() // Model one hop: a fresh engine adopts exactly what this one would send. peer := newTestEngine("peer", false) peer.AdoptState(eng.state) got, known := peer.TopologyDisabled(local, remote, port) if !known { t.Fatalf("peer learned no entry for %s→%s:%d", local, remote, port) } if got != want { t.Fatalf("the forwarded token carries disabled=%v, want %v — the decision did not "+ "propagate, so the other members would keep the old state", got, want) } } // TestLocalReEnableSurvivesAdoption: a re-enable needs the same protection. The // value false is easy to overlook — nothing "looks" stopped, yet losing it just // as silently strands the forward in the stopped state. func TestLocalReEnableSurvivesAdoption(t *testing.T) { eng := newTestEngine("n1", true) eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081}) if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state, SentAt: 1}); err != nil { t.Fatal(err) } eng.UpdateTopologyDisabled("web", "frps1", 18081, true) eng.UpdateTopologyDisabled("web", "frps1", 18081, false) // A token that still says disabled (captured mid-stop) arrives. mid := staleTokenWith(eng, "web", "frps1", 18081, true) if _, err := eng.OnToken(context.Background(), &Token{Cycle: 2, State: mid, SentAt: 2}); err != nil { t.Fatal(err) } assertForwardedDisabled(t, eng, "web", "frps1", 18081, false) } // TestConfirmedDecisionStopsBeingAsserted: once the ring reports the same value // the decision is confirmed everywhere, so this node must stop overriding — // otherwise it would fight a later decision made elsewhere. func TestConfirmedDecisionStopsBeingAsserted(t *testing.T) { eng := newTestEngine("n1", true) eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081}) if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state, SentAt: 1}); err != nil { t.Fatal(err) } eng.UpdateTopologyDisabled("web", "frps1", 18081, true) if len(eng.localDisabled) != 1 { t.Fatalf("a pending decision should be tracked, got %v", eng.localDisabled) } // The ring comes back agreeing. agreed := staleTokenWith(eng, "web", "frps1", 18081, true) if _, err := eng.OnToken(context.Background(), &Token{Cycle: 2, State: agreed, SentAt: 2}); err != nil { t.Fatal(err) } if len(eng.localDisabled) != 0 { t.Fatalf("a confirmed decision must stop being asserted, still tracking %v", eng.localDisabled) } // A peer now decides the opposite; this node must follow the ring. peerSaysEnabled := staleTokenWith(eng, "web", "frps1", 18081, false) if _, err := eng.OnToken(context.Background(), &Token{Cycle: 3, State: peerSaysEnabled, SentAt: 3}); err != nil { t.Fatal(err) } if d, _ := eng.TopologyDisabled("web", "frps1", 18081); d { t.Fatal("this node kept overriding a peer's later decision; the ring is not authoritative") } } // TestLocalDecisionsDoNotAccumulate: the tracking map is swept against the live // topology, so a long-lived cluster cannot grow it without bound. func TestLocalDecisionsDoNotAccumulate(t *testing.T) { eng := newTestEngine("n1", true) eng.state.AddPending(store.Local{Name: "web"}, store.Remote{Name: "frps1"}, store.Link{RemotePort: 18081}) if _, err := eng.OnToken(context.Background(), &Token{Cycle: 1, State: eng.state, SentAt: 1}); err != nil { t.Fatal(err) } // Decisions for forwards this node has never seen. eng.UpdateTopologyDisabled("ghost1", "frps1", 1, true) eng.UpdateTopologyDisabled("ghost2", "frps1", 2, true) if len(eng.localDisabled) != 2 { t.Fatalf("expected 2 tracked decisions, got %d", len(eng.localDisabled)) } // A cycle runs: neither ghost is in the topology, so both are swept. if _, err := eng.OnToken(context.Background(), &Token{Cycle: 2, State: eng.state, SentAt: 2}); err != nil { t.Fatal(err) } if len(eng.localDisabled) != 0 { t.Fatalf("decisions for forwards outside the topology must be swept, got %v", eng.localDisabled) } }