all: add tests for PingRequest implementation
Unit tests for Change (IsEmpty, Merge, Type, PingNode constructor), ping tracker (register/complete/cancel lifecycle, concurrency, latency), and end-to-end servertests exercising the full round-trip with real controlclient.Direct instances. Updates #2902 Updates #2129
This commit is contained in:
parent
b113655b71
commit
97778c9930
169
hscontrol/servertest/ping_test.go
Normal file
169
hscontrol/servertest/ping_test.go
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
package servertest_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/juanfont/headscale/hscontrol/servertest"
|
||||||
|
"github.com/juanfont/headscale/hscontrol/types"
|
||||||
|
"github.com/juanfont/headscale/hscontrol/types/change"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"tailscale.com/tailcfg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPingNode verifies the full ping round-trip: the server sends a
|
||||||
|
// PingRequest via MapResponse, the real controlclient.Direct handles it
|
||||||
|
// by making a HEAD request back over Noise, and the ping tracker records
|
||||||
|
// the latency.
|
||||||
|
func TestPingNode(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := servertest.NewHarness(t, 1)
|
||||||
|
|
||||||
|
nm := h.Client(0).Netmap()
|
||||||
|
require.NotNil(t, nm)
|
||||||
|
require.True(t, nm.SelfNode.Valid())
|
||||||
|
|
||||||
|
nodeID := types.NodeID(nm.SelfNode.ID()) //nolint:gosec
|
||||||
|
|
||||||
|
st := h.Server.State()
|
||||||
|
pingID, responseCh := st.RegisterPing(nodeID)
|
||||||
|
|
||||||
|
defer st.CancelPing(pingID)
|
||||||
|
|
||||||
|
callbackURL := h.Server.URL + "/machine/ping-response?id=" + pingID
|
||||||
|
h.Server.App.Change(change.PingNode(nodeID, &tailcfg.PingRequest{
|
||||||
|
URL: callbackURL,
|
||||||
|
Log: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case latency := <-responseCh:
|
||||||
|
assert.Positive(t, latency, "latency should be positive, got %v", latency)
|
||||||
|
assert.Less(t, latency, 10*time.Second, "latency should be reasonable, got %v", latency)
|
||||||
|
case <-time.After(15 * time.Second):
|
||||||
|
t.Fatal("ping response not received within 15s")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPingDisconnectedNode verifies that pinging a disconnected node
|
||||||
|
// results in no response (the channel never receives).
|
||||||
|
func TestPingDisconnectedNode(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := servertest.NewHarness(t, 1)
|
||||||
|
|
||||||
|
nm := h.Client(0).Netmap()
|
||||||
|
require.NotNil(t, nm)
|
||||||
|
|
||||||
|
nodeID := types.NodeID(nm.SelfNode.ID()) //nolint:gosec
|
||||||
|
|
||||||
|
// Disconnect the client.
|
||||||
|
h.Client(0).Disconnect(t)
|
||||||
|
|
||||||
|
st := h.Server.State()
|
||||||
|
pingID, responseCh := st.RegisterPing(nodeID)
|
||||||
|
|
||||||
|
defer st.CancelPing(pingID)
|
||||||
|
|
||||||
|
callbackURL := h.Server.URL + "/machine/ping-response?id=" + pingID
|
||||||
|
h.Server.App.Change(change.PingNode(nodeID, &tailcfg.PingRequest{
|
||||||
|
URL: callbackURL,
|
||||||
|
Log: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-responseCh:
|
||||||
|
t.Fatal("should not receive response from disconnected node")
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
// Expected: no response.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPingTwoSameNode verifies that two concurrent pings to the same
|
||||||
|
// node complete independently.
|
||||||
|
func TestPingTwoSameNode(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := servertest.NewHarness(t, 1)
|
||||||
|
|
||||||
|
nm := h.Client(0).Netmap()
|
||||||
|
require.NotNil(t, nm)
|
||||||
|
|
||||||
|
nodeID := types.NodeID(nm.SelfNode.ID()) //nolint:gosec
|
||||||
|
|
||||||
|
st := h.Server.State()
|
||||||
|
|
||||||
|
pingID1, ch1 := st.RegisterPing(nodeID)
|
||||||
|
defer st.CancelPing(pingID1)
|
||||||
|
|
||||||
|
pingID2, ch2 := st.RegisterPing(nodeID)
|
||||||
|
defer st.CancelPing(pingID2)
|
||||||
|
|
||||||
|
require.NotEqual(t, pingID1, pingID2)
|
||||||
|
|
||||||
|
// Send both PingRequests.
|
||||||
|
url1 := h.Server.URL + "/machine/ping-response?id=" + pingID1
|
||||||
|
url2 := h.Server.URL + "/machine/ping-response?id=" + pingID2
|
||||||
|
|
||||||
|
h.Server.App.Change(change.PingNode(nodeID, &tailcfg.PingRequest{
|
||||||
|
URL: url1,
|
||||||
|
}))
|
||||||
|
h.Server.App.Change(change.PingNode(nodeID, &tailcfg.PingRequest{
|
||||||
|
URL: url2,
|
||||||
|
}))
|
||||||
|
|
||||||
|
timeout := time.After(15 * time.Second)
|
||||||
|
|
||||||
|
var got1, got2 bool
|
||||||
|
|
||||||
|
for !got1 || !got2 {
|
||||||
|
select {
|
||||||
|
case latency := <-ch1:
|
||||||
|
assert.GreaterOrEqual(t, latency, time.Duration(0))
|
||||||
|
|
||||||
|
got1 = true
|
||||||
|
case latency := <-ch2:
|
||||||
|
assert.GreaterOrEqual(t, latency, time.Duration(0))
|
||||||
|
|
||||||
|
got2 = true
|
||||||
|
case <-timeout:
|
||||||
|
t.Fatalf("timed out: got1=%v got2=%v", got1, got2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPingResolveByHostname verifies that ResolveNode can find a node
|
||||||
|
// by hostname and that the resolved node can be pinged.
|
||||||
|
func TestPingResolveByHostname(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
h := servertest.NewHarness(t, 1, servertest.WithDefaultClientOptions(
|
||||||
|
servertest.WithHostname("my-test-host"),
|
||||||
|
))
|
||||||
|
|
||||||
|
st := h.Server.State()
|
||||||
|
|
||||||
|
// Resolve by hostname.
|
||||||
|
node, ok := st.ResolveNode("my-test-host")
|
||||||
|
require.True(t, ok, "should resolve node by hostname")
|
||||||
|
|
||||||
|
nodeID := node.ID()
|
||||||
|
|
||||||
|
pingID, responseCh := st.RegisterPing(nodeID)
|
||||||
|
defer st.CancelPing(pingID)
|
||||||
|
|
||||||
|
callbackURL := h.Server.URL + "/machine/ping-response?id=" + pingID
|
||||||
|
h.Server.App.Change(change.PingNode(nodeID, &tailcfg.PingRequest{
|
||||||
|
URL: callbackURL,
|
||||||
|
Log: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case latency := <-responseCh:
|
||||||
|
assert.Positive(t, latency)
|
||||||
|
case <-time.After(15 * time.Second):
|
||||||
|
t.Fatal("ping response not received")
|
||||||
|
}
|
||||||
|
}
|
||||||
156
hscontrol/state/ping_test.go
Normal file
156
hscontrol/state/ping_test.go
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
package state
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/juanfont/headscale/hscontrol/types"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPingTracker_RegisterComplete(t *testing.T) {
|
||||||
|
pt := newPingTracker()
|
||||||
|
|
||||||
|
pingID, ch := pt.register(types.NodeID(1))
|
||||||
|
assert.NotEmpty(t, pingID)
|
||||||
|
|
||||||
|
// Complete in a goroutine since it sends on the channel.
|
||||||
|
go func() {
|
||||||
|
assert.True(t, pt.complete(pingID))
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case latency := <-ch:
|
||||||
|
assert.GreaterOrEqual(t, latency, time.Duration(0), "latency should be non-negative")
|
||||||
|
assert.Less(t, latency, 5*time.Second, "latency should be reasonable")
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for ping response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPingTracker_CompleteUnknown(t *testing.T) {
|
||||||
|
pt := newPingTracker()
|
||||||
|
assert.False(t, pt.complete("nonexistent"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPingTracker_CancelThenComplete(t *testing.T) {
|
||||||
|
pt := newPingTracker()
|
||||||
|
|
||||||
|
pingID, ch := pt.register(types.NodeID(1))
|
||||||
|
pt.cancel(pingID)
|
||||||
|
assert.False(t, pt.complete(pingID))
|
||||||
|
|
||||||
|
// Channel should never receive.
|
||||||
|
select {
|
||||||
|
case <-ch:
|
||||||
|
t.Fatal("channel should not receive after cancel")
|
||||||
|
case <-time.After(50 * time.Millisecond):
|
||||||
|
// Expected: no response.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPingTracker_DoubleComplete(t *testing.T) {
|
||||||
|
pt := newPingTracker()
|
||||||
|
|
||||||
|
pingID, ch := pt.register(types.NodeID(1))
|
||||||
|
assert.True(t, pt.complete(pingID))
|
||||||
|
|
||||||
|
// Drain the channel.
|
||||||
|
<-ch
|
||||||
|
|
||||||
|
// Second complete should return false.
|
||||||
|
assert.False(t, pt.complete(pingID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPingTracker_ConcurrentDifferentIDs(t *testing.T) {
|
||||||
|
pt := newPingTracker()
|
||||||
|
|
||||||
|
const count = 10
|
||||||
|
|
||||||
|
ids := make([]string, count)
|
||||||
|
chs := make([]<-chan time.Duration, count)
|
||||||
|
|
||||||
|
for i := range count {
|
||||||
|
ids[i], chs[i] = pt.register(types.NodeID(i + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete in reverse order concurrently.
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
for i := count - 1; i >= 0; i-- {
|
||||||
|
wg.Add(1)
|
||||||
|
|
||||||
|
go func(idx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
assert.True(t, pt.complete(ids[idx]))
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// All channels should receive.
|
||||||
|
for i := range count {
|
||||||
|
select {
|
||||||
|
case latency := <-chs[i]:
|
||||||
|
assert.GreaterOrEqual(t, latency, time.Duration(0))
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatalf("timed out waiting for ping %d", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPingTracker_TwoToSameNode(t *testing.T) {
|
||||||
|
pt := newPingTracker()
|
||||||
|
nodeID := types.NodeID(42)
|
||||||
|
|
||||||
|
id1, ch1 := pt.register(nodeID)
|
||||||
|
id2, ch2 := pt.register(nodeID)
|
||||||
|
|
||||||
|
require.NotEqual(t, id1, id2, "ping IDs should be unique")
|
||||||
|
|
||||||
|
// Complete only the first.
|
||||||
|
assert.True(t, pt.complete(id1))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case latency := <-ch1:
|
||||||
|
assert.GreaterOrEqual(t, latency, time.Duration(0))
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for first ping")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second should still be pending.
|
||||||
|
select {
|
||||||
|
case <-ch2:
|
||||||
|
t.Fatal("second channel should not have received yet")
|
||||||
|
default:
|
||||||
|
// Expected.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now complete the second.
|
||||||
|
assert.True(t, pt.complete(id2))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case latency := <-ch2:
|
||||||
|
assert.GreaterOrEqual(t, latency, time.Duration(0))
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("timed out waiting for second ping")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPingTracker_LatencyNonNegative(t *testing.T) {
|
||||||
|
pt := newPingTracker()
|
||||||
|
|
||||||
|
pingID, ch := pt.register(types.NodeID(1))
|
||||||
|
assert.True(t, pt.complete(pingID))
|
||||||
|
|
||||||
|
select {
|
||||||
|
case latency := <-ch:
|
||||||
|
assert.GreaterOrEqual(t, latency, time.Duration(0), "latency should be non-negative")
|
||||||
|
assert.Less(t, latency, 5*time.Second, "latency should be reasonable")
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
|
t.Fatal("timed out")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -89,6 +89,11 @@ func TestChange_IsEmpty(t *testing.T) {
|
|||||||
response: Change{PeerPatches: []*tailcfg.PeerChange{{}}},
|
response: Change{PeerPatches: []*tailcfg.PeerChange{{}}},
|
||||||
want: false,
|
want: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "PingRequest not empty",
|
||||||
|
response: Change{PingRequest: &tailcfg.PingRequest{URL: "https://example.com"}},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@ -263,6 +268,24 @@ func TestChange_Merge(t *testing.T) {
|
|||||||
r2: Change{TargetNode: 42},
|
r2: Change{TargetNode: 42},
|
||||||
want: Change{TargetNode: 42, IncludeSelf: true},
|
want: Change{TargetNode: 42, IncludeSelf: true},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "PingRequest preserved from first",
|
||||||
|
r1: Change{PingRequest: &tailcfg.PingRequest{URL: "first"}},
|
||||||
|
r2: Change{IncludeSelf: true},
|
||||||
|
want: Change{PingRequest: &tailcfg.PingRequest{URL: "first"}, IncludeSelf: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PingRequest preserved from second when first is nil",
|
||||||
|
r1: Change{IncludeSelf: true},
|
||||||
|
r2: Change{PingRequest: &tailcfg.PingRequest{URL: "second"}},
|
||||||
|
want: Change{PingRequest: &tailcfg.PingRequest{URL: "second"}, IncludeSelf: true},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "PingRequest first wins when both set",
|
||||||
|
r1: Change{PingRequest: &tailcfg.PingRequest{URL: "first"}},
|
||||||
|
r2: Change{PingRequest: &tailcfg.PingRequest{URL: "second"}},
|
||||||
|
want: Change{PingRequest: &tailcfg.PingRequest{URL: "first"}},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
@ -417,6 +440,14 @@ func TestChange_Type(t *testing.T) {
|
|||||||
response: PolicyOnly(),
|
response: PolicyOnly(),
|
||||||
want: "config",
|
want: "config",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "ping request",
|
||||||
|
response: Change{
|
||||||
|
PingRequest: &tailcfg.PingRequest{URL: "https://example.com"},
|
||||||
|
TargetNode: 1,
|
||||||
|
},
|
||||||
|
want: "ping",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "empty is unknown",
|
name: "empty is unknown",
|
||||||
response: Change{},
|
response: Change{},
|
||||||
@ -432,6 +463,17 @@ func TestChange_Type(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPingNode(t *testing.T) {
|
||||||
|
pr := &tailcfg.PingRequest{URL: "https://example.com/ping", URLIsNoise: true, Log: true}
|
||||||
|
r := PingNode(42, pr)
|
||||||
|
assert.Equal(t, "ping node", r.Reason)
|
||||||
|
assert.Equal(t, types.NodeID(42), r.TargetNode)
|
||||||
|
assert.Equal(t, pr, r.PingRequest)
|
||||||
|
assert.True(t, r.IsTargetedToNode())
|
||||||
|
assert.False(t, r.IsEmpty())
|
||||||
|
assert.Equal(t, "ping", r.Type())
|
||||||
|
}
|
||||||
|
|
||||||
func TestUniqueNodeIDs(t *testing.T) {
|
func TestUniqueNodeIDs(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user