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