mirror of
https://github.com/juanfont/headscale.git
synced 2026-05-23 10:42:30 +09:00
state: add regression test for Node slice persistence
Drives the persist path for ApprovedRoutes, Tags and Endpoints — seed a non-empty value, clear to nil, read the column back from disk, then close the State and reopen one against the same sqlite file to simulate a server restart. Pins the contract the named IsZero slice types enforce so future changes to the persist path cannot silently drop a cleared slice column. Updates #3110
This commit is contained in:
211
hscontrol/state/persist_test.go
Normal file
211
hscontrol/state/persist_test.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package state
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/db"
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// persistTestSetup pre-creates a sqlite database on disk with a single
|
||||
// registered node, then constructs a State that loads it. The on-disk
|
||||
// path is returned so the test can close the State and re-open one
|
||||
// against the same file to simulate a server restart. The caller owns
|
||||
// the returned State and must Close it; persistTestReopen handles the
|
||||
// second State's lifecycle for the restart simulation.
|
||||
func persistTestSetup(t *testing.T) (string, *State, types.NodeID) {
|
||||
t.Helper()
|
||||
|
||||
dbPath := t.TempDir() + "/headscale.db"
|
||||
cfg := persistTestConfig(dbPath)
|
||||
|
||||
database, err := db.NewHeadscaleDatabase(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
user := database.CreateUserForTest("persist-user")
|
||||
node := database.CreateRegisteredNodeForTest(user, "persist-node")
|
||||
|
||||
require.NoError(t, database.Close())
|
||||
|
||||
s, err := NewState(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
return dbPath, s, node.ID
|
||||
}
|
||||
|
||||
// persistTestReopen constructs a fresh State pointed at the same
|
||||
// sqlite file and registers a cleanup to close it at the end of the
|
||||
// test. Use it to simulate a server restart after the first State has
|
||||
// been explicitly closed by the caller.
|
||||
func persistTestReopen(t *testing.T, dbPath string) *State {
|
||||
t.Helper()
|
||||
|
||||
s, err := NewState(persistTestConfig(dbPath))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = s.Close() })
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func persistTestConfig(dbPath string) *types.Config {
|
||||
prefixV4 := netip.MustParsePrefix("100.64.0.0/10")
|
||||
prefixV6 := netip.MustParsePrefix("fd7a:115c:a1e0::/48")
|
||||
|
||||
return &types.Config{
|
||||
Database: types.DatabaseConfig{
|
||||
Type: types.DatabaseSqlite,
|
||||
Sqlite: types.SqliteConfig{
|
||||
Path: dbPath,
|
||||
},
|
||||
},
|
||||
PrefixV4: &prefixV4,
|
||||
PrefixV6: &prefixV6,
|
||||
IPAllocation: types.IPAllocationStrategySequential,
|
||||
BaseDomain: "headscale.test",
|
||||
Policy: types.PolicyConfig{
|
||||
Mode: types.PolicyModeDB,
|
||||
},
|
||||
Tuning: types.Tuning{
|
||||
NodeStoreBatchSize: TestBatchSize,
|
||||
NodeStoreBatchTimeout: TestBatchTimeout,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestPersistEmptyApprovedRoutes covers the State.SetApprovedRoutes
|
||||
// path. The gRPC handler builds the slice via append from a nil
|
||||
// declaration, so when the operator passes `-r ""` the persist layer
|
||||
// receives a nil []netip.Prefix. GORM's struct Updates skips nil
|
||||
// slices, so the column would stay populated with the previously
|
||||
// approved routes and a restart would re-apply them.
|
||||
func TestPersistEmptyApprovedRoutes(t *testing.T) {
|
||||
dbPath, s, nodeID := persistTestSetup(t)
|
||||
|
||||
route := netip.MustParsePrefix("10.0.0.0/8")
|
||||
|
||||
_, _, err := s.SetApprovedRoutes(nodeID, []netip.Prefix{route})
|
||||
require.NoError(t, err)
|
||||
|
||||
gotAfterApprove, err := s.DB().GetNodeByID(nodeID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []netip.Prefix{route}, gotAfterApprove.ApprovedRoutes.List(),
|
||||
"approved_routes should hold the seeded route")
|
||||
|
||||
var noRoutes []netip.Prefix
|
||||
|
||||
_, _, err = s.SetApprovedRoutes(nodeID, noRoutes)
|
||||
require.NoError(t, err)
|
||||
|
||||
gotAfterClear, err := s.DB().GetNodeByID(nodeID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, gotAfterClear.ApprovedRoutes,
|
||||
"approved_routes should be empty after rejecting all routes, got %v",
|
||||
gotAfterClear.ApprovedRoutes)
|
||||
|
||||
require.NoError(t, s.Close())
|
||||
|
||||
s2 := persistTestReopen(t, dbPath)
|
||||
|
||||
nv, ok := s2.GetNodeByID(nodeID)
|
||||
require.True(t, ok, "node should be loaded from DB after restart")
|
||||
assert.Empty(t, nv.AsStruct().ApprovedRoutes,
|
||||
"after restart, NodeStore should reflect the cleared routes")
|
||||
}
|
||||
|
||||
// TestPersistEmptyTags exercises the same persist path for the tags
|
||||
// column. State.SetNodeTags rejects an empty slice at the API level
|
||||
// (tags are one-way), so the test drives the bug surface directly via
|
||||
// NodeStore + persistNodeToDB, which is the same code path the public
|
||||
// SetApprovedRoutes call exercises.
|
||||
func TestPersistEmptyTags(t *testing.T) {
|
||||
dbPath, s, nodeID := persistTestSetup(t)
|
||||
|
||||
_, ok := s.nodeStore.UpdateNode(nodeID, func(n *types.Node) {
|
||||
n.Tags = []string{"tag:test"}
|
||||
})
|
||||
require.True(t, ok)
|
||||
|
||||
seeded, ok := s.nodeStore.GetNode(nodeID)
|
||||
require.True(t, ok)
|
||||
|
||||
_, _, err := s.persistNodeToDB(seeded)
|
||||
require.NoError(t, err)
|
||||
|
||||
gotAfterSeed, err := s.DB().GetNodeByID(nodeID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"tag:test"}, gotAfterSeed.Tags.List(),
|
||||
"tags should hold the seeded value")
|
||||
|
||||
cleared, ok := s.nodeStore.UpdateNode(nodeID, func(n *types.Node) {
|
||||
n.Tags = nil
|
||||
})
|
||||
require.True(t, ok)
|
||||
|
||||
_, _, err = s.persistNodeToDB(cleared)
|
||||
require.NoError(t, err)
|
||||
|
||||
gotAfterClear, err := s.DB().GetNodeByID(nodeID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, gotAfterClear.Tags,
|
||||
"tags should be empty after clear, got %v", gotAfterClear.Tags)
|
||||
|
||||
require.NoError(t, s.Close())
|
||||
|
||||
s2 := persistTestReopen(t, dbPath)
|
||||
|
||||
nv, ok := s2.GetNodeByID(nodeID)
|
||||
require.True(t, ok)
|
||||
assert.Empty(t, nv.AsStruct().Tags,
|
||||
"after restart, NodeStore should reflect the cleared tags")
|
||||
}
|
||||
|
||||
// TestPersistEmptyEndpoints covers the endpoints column. Endpoints
|
||||
// arrive via MapRequest in production; the test reaches the persist
|
||||
// layer directly because the bug is in serialization, not in
|
||||
// upstream parsing.
|
||||
func TestPersistEmptyEndpoints(t *testing.T) {
|
||||
dbPath, s, nodeID := persistTestSetup(t)
|
||||
|
||||
endpoint := netip.MustParseAddrPort("198.51.100.1:41641")
|
||||
|
||||
_, ok := s.nodeStore.UpdateNode(nodeID, func(n *types.Node) {
|
||||
n.Endpoints = []netip.AddrPort{endpoint}
|
||||
})
|
||||
require.True(t, ok)
|
||||
|
||||
seeded, ok := s.nodeStore.GetNode(nodeID)
|
||||
require.True(t, ok)
|
||||
|
||||
_, _, err := s.persistNodeToDB(seeded)
|
||||
require.NoError(t, err)
|
||||
|
||||
gotAfterSeed, err := s.DB().GetNodeByID(nodeID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []netip.AddrPort{endpoint}, gotAfterSeed.Endpoints.List(),
|
||||
"endpoints should hold the seeded value")
|
||||
|
||||
cleared, ok := s.nodeStore.UpdateNode(nodeID, func(n *types.Node) {
|
||||
n.Endpoints = nil
|
||||
})
|
||||
require.True(t, ok)
|
||||
|
||||
_, _, err = s.persistNodeToDB(cleared)
|
||||
require.NoError(t, err)
|
||||
|
||||
gotAfterClear, err := s.DB().GetNodeByID(nodeID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, gotAfterClear.Endpoints,
|
||||
"endpoints should be empty after clear, got %v", gotAfterClear.Endpoints)
|
||||
|
||||
require.NoError(t, s.Close())
|
||||
|
||||
s2 := persistTestReopen(t, dbPath)
|
||||
|
||||
nv, ok := s2.GetNodeByID(nodeID)
|
||||
require.True(t, ok)
|
||||
assert.Empty(t, nv.AsStruct().Endpoints,
|
||||
"after restart, NodeStore should reflect the cleared endpoints")
|
||||
}
|
||||
Reference in New Issue
Block a user