diff --git a/hscontrol/state/persist_test.go b/hscontrol/state/persist_test.go new file mode 100644 index 00000000..00b04ebe --- /dev/null +++ b/hscontrol/state/persist_test.go @@ -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") +}